ccus-cli 0.2.5 → 0.2.7
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 +3 -1
- package/dist/lib/aggregate.js +31 -12
- package/dist/lib/api-mode.js +124 -10
- package/dist/lib/codex-fetcher.js +185 -8
- package/dist/lib/dashboard.js +4 -3
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -252,6 +252,7 @@ ccus api config # 不带参数:打印当前配置
|
|
|
252
252
|
- **缓存**:statusline 高频调用,额度按 TTL 缓存(默认 5 分钟,`--ttl 5m` 改),拉取失败自动回退上次缓存;缓存文件 `api-quota-cache.json`。
|
|
253
253
|
- **智谱请求头**:除 `Authorization` 外,`--project` / `--organization` 会作为 `bigmodel-project` / `bigmodel-organization` 头发出(团队版需要)。接口对请求来源敏感,若返回空响应疑似被拦,可用 `ccus api config --user-agent "<浏览器UA>"` 改 UA。
|
|
254
254
|
- **自定义 provider**:`--provider custom` 配其它厂商。简单响应用 `--url` / `--method` / `--header "K: V; K2: V2"`(header 值支持 `{{token}}` 占位)/ `--five-hour-path` / `--seven-day-path`(点分路径,如 `data.limits.0.percentage`)映射字段;响应结构复杂(数组筛选/排序)时用 `--extractor-file script.js` 写一段 JS 函数,接收响应对象、返回 `{ fiveHour, sevenDay }` 或 cc-switch 风格的 `[{used}, {used}]`,配了 extractor 就优先于点分路径。脚本在 ccus 进程内执行,只放信任的来源。
|
|
255
|
+
- **统一 env 代理**:智谱 / custom 的额度请求经 ccus 统一代理通道(读 `https_proxy` / `http_proxy` / `all_proxy` / `NO_PROXY`,小写优先、对齐 curl),代理环境也能拉到额度;想给 ccus 单独指定代理(覆盖系统代理)用专属的 `CCUS_PROXY`(同时管 https / http 目标,仍受 `NO_PROXY` 约束),无代理变量维持直连。
|
|
255
256
|
|
|
256
257
|
> API 模式填充的 5h/7d 会和官方 `rate_limits` 走同一条聚合管线(含 7d 累计去毛刺算法,该算法针对 Claude 官方锯齿波设计);不同厂商的额度曲线语义可能不完全一致,混用聚合时注意。
|
|
257
258
|
|
|
@@ -293,7 +294,8 @@ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
|
|
|
293
294
|
要点:
|
|
294
295
|
|
|
295
296
|
- **只支持 Codex CLI**:hooks 与 `codex app-server` 都是 CLI 能力。Codex 桌面版 app(已并入 ChatGPT 桌面 app)不一定触发 CLI 的 hooks、也不一定随附可 spawn 的 `codex` 二进制,不在本期范围。
|
|
296
|
-
- **代理继承**:ccus 由 Codex 直接 spawn,已继承 Codex 的代理 / `CODEX_HOME`;它再 spawn `codex app-server` 时透传 `process.env
|
|
297
|
+
- **代理继承**:ccus 由 Codex 直接 spawn,已继承 Codex 的代理 / `CODEX_HOME`;它再 spawn `codex app-server` 时透传 `process.env`,整条链同环境。主路径必须让 codex 子进程发请求(它读 `HTTP_PROXY` / `HTTPS_PROXY`);wham 回退走 ccus 自己的 Node HTTP,但经统一 env 代理通道(读 `CCUS_PROXY` / `https_proxy` / `http_proxy` / `all_proxy` / `NO_PROXY`,`CCUS_PROXY` 优先、小写优先、对齐 curl),代理环境同样可达。
|
|
298
|
+
- **无 codex 命令时回退 wham**:本机没装 `codex` CLI(spawn 返回 `unavailable`)且无新鲜缓存时,ccus 改走 ChatGPT 后端 `wham/usage` HTTP 直连——读 `~/.codex/auth.json` 的 OAuth token(仅 chatgpt 登录模式)、按各窗 `limit_window_seconds` 认 5h / 7d 桶,照常落盘 `source="codex"` 事件。让「装过 codex 留下 hook + auth.json、但 codex 未入 PATH / 已卸载」的用户也能采到额度。仅 `unavailable` 触发,超时 / RPC 错(`error`)不回退。
|
|
297
299
|
- **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
|
|
298
300
|
- **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),命中秒回不 spawn;过期才拉一次(带 ~10s 超时),失败回退旧缓存。
|
|
299
301
|
- **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
|
package/dist/lib/aggregate.js
CHANGED
|
@@ -411,8 +411,11 @@ function buildSevenDayCurveFromEvents(events) {
|
|
|
411
411
|
}
|
|
412
412
|
/**
|
|
413
413
|
* 同一 personKey 的 7d 累计曲线:把该人**所有 bundle**(非仅 winner)的 rawEvents 计算成事件、
|
|
414
|
-
* 取非 null `sevenDayUsagePct`、按 timestamp 升序合并、对完全相同 timestamp
|
|
415
|
-
*
|
|
414
|
+
* 取非 null `sevenDayUsagePct`、按 timestamp 升序合并、对完全相同 timestamp 去重,再做读数去毛刺。
|
|
415
|
+
*
|
|
416
|
+
* Claude 与 Codex 是两个不同额度池的独立曲线(读数水平常常相差悬殊),按 source 各自建一条,
|
|
417
|
+
* 算累计时分别做分段峰谷和再相加。混成一条会让低位源频繁触发假 reset、把高位源的上升段反复重算,
|
|
418
|
+
* 导致累计严重虚高(实测 jizhiqiang 混算 221% vs 分源相加 44%)。
|
|
416
419
|
*
|
|
417
420
|
* 走全样本而非 winner,是因为累计指标是同一条共享额度曲线的密集采样:只取 winner 会漏掉非 winner
|
|
418
421
|
* 机器的样本(曲线稀疏、累计偏小),分机各自累计再相加又会翻倍。结果按 bundles 数组缓存复用。
|
|
@@ -423,27 +426,31 @@ function buildPersonSevenDayCurve(bundles) {
|
|
|
423
426
|
if (cached) {
|
|
424
427
|
return cached;
|
|
425
428
|
}
|
|
426
|
-
const
|
|
429
|
+
const claudeByPerson = new Map();
|
|
430
|
+
const codexByPerson = new Map();
|
|
427
431
|
for (const { bundle } of bundles) {
|
|
428
432
|
const personKey = bundlePersonKey(bundle);
|
|
429
|
-
// 含 Claude + Codex 两源读数:codex 7d 读数不再过滤,并入同一条曲线算累计
|
|
430
433
|
for (const record of bundle.rawEvents.filter((r) => isPersistedStatuslineEvent(r))) {
|
|
431
434
|
const event = (0, payload_1.computeStatuslineEvent)(record);
|
|
432
435
|
if (event.sevenDayUsagePct === null) {
|
|
433
436
|
continue;
|
|
434
437
|
}
|
|
435
|
-
const
|
|
438
|
+
const target = (0, payload_1.isCodexSourceEvent)(event) ? codexByPerson : claudeByPerson;
|
|
439
|
+
const list = target.get(personKey);
|
|
436
440
|
if (list) {
|
|
437
441
|
list.push(event);
|
|
438
442
|
}
|
|
439
443
|
else {
|
|
440
|
-
|
|
444
|
+
target.set(personKey, [event]);
|
|
441
445
|
}
|
|
442
446
|
}
|
|
443
447
|
}
|
|
444
448
|
const curves = new Map();
|
|
445
|
-
for (const
|
|
446
|
-
curves.set(personKey,
|
|
449
|
+
for (const personKey of new Set([...claudeByPerson.keys(), ...codexByPerson.keys()])) {
|
|
450
|
+
curves.set(personKey, {
|
|
451
|
+
claude: buildSevenDayCurveFromEvents(claudeByPerson.get(personKey) ?? []),
|
|
452
|
+
codex: buildSevenDayCurveFromEvents(codexByPerson.get(personKey) ?? []),
|
|
453
|
+
});
|
|
447
454
|
}
|
|
448
455
|
personSevenDayCurveCache.set(bundles, curves);
|
|
449
456
|
return curves;
|
|
@@ -456,6 +463,18 @@ function sliceCurveByDate(curve, date) {
|
|
|
456
463
|
function sliceCurveByWeek(curve, week) {
|
|
457
464
|
return curve.filter((event) => weekKey(new Date(event.timestamp)) === week);
|
|
458
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* 分源 7d 累计:Claude 与 Codex 各自按区间切片做分段峰谷和,再用 `addNullable` 相加。
|
|
468
|
+
* 两源是不同额度池的独立曲线,混算会互相触发假 reset 而虚高;分源算后相加,与 CSV 里 token /
|
|
469
|
+
* 消息数「Claude+Codex 合计」口径一致。该 personKey 无任何曲线、或两源都无有效样本时返回 null。
|
|
470
|
+
*/
|
|
471
|
+
function computeCumulativeSevenDayBySource(curves, personKey, slicer, rangeKey) {
|
|
472
|
+
const bySource = curves.get(personKey);
|
|
473
|
+
if (!bySource) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
return addNullable(computeCumulativeSevenDay(slicer(bySource.claude, rangeKey)), computeCumulativeSevenDay(slicer(bySource.codex, rangeKey)));
|
|
477
|
+
}
|
|
459
478
|
/** 从一组事件按真实时间戳重算 5h / 7d 的 peak(max)与 latest(时间戳最新非空)。 */
|
|
460
479
|
function recomputeUsage(events) {
|
|
461
480
|
const newestFirst = [...events].sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
|
|
@@ -526,8 +545,8 @@ function buildAggregatedDailyRows(bundles) {
|
|
|
526
545
|
const codexUsage = recomputeUsage(codexEvents);
|
|
527
546
|
// 额度回退:rawEvents 缺时从 daySummary 取(Claude+Codex 合并,见 fallbackWeeklyUsage)
|
|
528
547
|
const fallback = fallbackWeeklyUsage(reps.map((r) => r.day));
|
|
529
|
-
//
|
|
530
|
-
const sevenDayCumulativeUsagePct =
|
|
548
|
+
// 累计指标走全样本分源曲线(Claude + Codex 各自切片累计后相加),不走单机的 recomputeUsage,避免漏掉另一台机器的样本。
|
|
549
|
+
const sevenDayCumulativeUsagePct = computeCumulativeSevenDayBySource(curves, personKey, sliceCurveByDate, date);
|
|
531
550
|
rows.push({
|
|
532
551
|
personKey,
|
|
533
552
|
date,
|
|
@@ -621,8 +640,8 @@ function buildAggregatedWeeklyRows(bundles) {
|
|
|
621
640
|
const usage = recomputeUsage(claudeEvents);
|
|
622
641
|
const codexUsage = recomputeUsage(codexEvents);
|
|
623
642
|
const fallback = fallbackWeeklyUsage(acc.days);
|
|
624
|
-
//
|
|
625
|
-
const sevenDayCumulativeUsagePct =
|
|
643
|
+
// 整周累计:Claude + Codex 各自在整周子曲线上做分段峰谷和后相加,跨天边界增量被计入,故 weekly ≥ Σ daily。
|
|
644
|
+
const sevenDayCumulativeUsagePct = computeCumulativeSevenDayBySource(curves, acc.personKey, sliceCurveByWeek, acc.week);
|
|
626
645
|
rows.push({
|
|
627
646
|
personKey: acc.personKey,
|
|
628
647
|
week: acc.week,
|
package/dist/lib/api-mode.js
CHANGED
|
@@ -13,6 +13,9 @@ exports.resolveApiTokenWithSettings = resolveApiTokenWithSettings;
|
|
|
13
13
|
exports.extractCustomQuota = extractCustomQuota;
|
|
14
14
|
exports.runExtractor = runExtractor;
|
|
15
15
|
exports.normalizeExtractorResult = normalizeExtractorResult;
|
|
16
|
+
exports.isNoProxyHost = isNoProxyHost;
|
|
17
|
+
exports.resolveProxyUrl = resolveProxyUrl;
|
|
18
|
+
exports.httpRequest = httpRequest;
|
|
16
19
|
exports.fetchQuota = fetchQuota;
|
|
17
20
|
exports.readApiQuotaCacheSync = readApiQuotaCacheSync;
|
|
18
21
|
exports.resolveApiQuota = resolveApiQuota;
|
|
@@ -308,19 +311,130 @@ function extractArrayNumber(item) {
|
|
|
308
311
|
}
|
|
309
312
|
return null;
|
|
310
313
|
}
|
|
311
|
-
/**
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
|
|
314
|
+
/** 按顺序取第一个非空(去空白后非空)的环境变量值。 */
|
|
315
|
+
function pickEnv(env, ...names) {
|
|
316
|
+
for (const name of names) {
|
|
317
|
+
const v = env[name];
|
|
318
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
319
|
+
return v.trim();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* NO_PROXY 匹配:逗号 / 空白分隔的域名后缀,支持精确匹配、后缀匹配、`*` 全匹配(对齐 curl / proxy-from-env)。
|
|
326
|
+
* 每项可带前导 `.`(`.example.com` 等价 `example.com`);hostname 命中任一项即视为应直连。
|
|
327
|
+
*/
|
|
328
|
+
function isNoProxyHost(hostname, noProxy) {
|
|
329
|
+
const items = noProxy
|
|
330
|
+
.split(/[\s,]+/)
|
|
331
|
+
.map((s) => s.trim())
|
|
332
|
+
.filter((s) => s.length > 0);
|
|
333
|
+
for (const raw of items) {
|
|
334
|
+
if (raw === "*") {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const item = raw.startsWith(".") ? raw.slice(1) : raw;
|
|
338
|
+
if (item === hostname || hostname.endsWith(`.${item}`)) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 按环境变量解析目标 URL 应走的代理(对齐 curl / proxy-from-env),纯函数、可单测。
|
|
346
|
+
*
|
|
347
|
+
* https 目标读 `https_proxy` → `HTTPS_PROXY` → `all_proxy` → `ALL_PROXY`;
|
|
348
|
+
* http 目标读 `http_proxy` → `HTTP_PROXY` → `all_proxy` → `ALL_PROXY`。**小写优先**。
|
|
349
|
+
* `NO_PROXY` / `no_proxy`(小写优先)命中的主机、或无任何代理变量时返回 null(直连)。
|
|
350
|
+
*/
|
|
351
|
+
function resolveProxyUrl(targetUrl, env) {
|
|
352
|
+
let parsed;
|
|
353
|
+
try {
|
|
354
|
+
parsed = new URL(targetUrl);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const noProxy = pickEnv(env, "no_proxy", "NO_PROXY");
|
|
360
|
+
if (noProxy && isNoProxyHost(parsed.hostname, noProxy)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
// CCUS_PROXY:ccus 专属代理,单一值同时管 https / http 目标,优先于标准 https_proxy / http_proxy / all_proxy。
|
|
364
|
+
const ccusProxy = pickEnv(env, "CCUS_PROXY");
|
|
365
|
+
if (ccusProxy) {
|
|
366
|
+
return ccusProxy;
|
|
367
|
+
}
|
|
368
|
+
const isHttps = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
|
369
|
+
const proxy = isHttps
|
|
370
|
+
? pickEnv(env, "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY")
|
|
371
|
+
: pickEnv(env, "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY");
|
|
372
|
+
return proxy ?? null;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* 懒加载两个代理 agent 构造器,模块级缓存。
|
|
376
|
+
*
|
|
377
|
+
* 这两个包是 ESM-only:ccus 是 CommonJS,Node 20/21 的顶层 `require(esm)` 会 `ERR_REQUIRE_ESM`。
|
|
378
|
+
* 必须用**原生**动态 `import()`(CJS 里对全 Node 版本可异步加载 ESM);但 tsc 在 `module: commonjs` 下会把
|
|
379
|
+
* `import()` 降级成 `require`(Node 20 仍崩),故用 `new Function` 构造一个运行时 `import()`,绕过 tsc 降级。
|
|
380
|
+
* 加载失败返回 null(回退直连)。
|
|
381
|
+
*/
|
|
382
|
+
const nativeImport = new Function("specifier", "return import(specifier)");
|
|
383
|
+
let proxyAgentsPromise = null;
|
|
384
|
+
function loadProxyAgents() {
|
|
385
|
+
if (proxyAgentsPromise) {
|
|
386
|
+
return proxyAgentsPromise;
|
|
387
|
+
}
|
|
388
|
+
proxyAgentsPromise = (async () => {
|
|
315
389
|
try {
|
|
316
|
-
|
|
390
|
+
const [httpsMod, httpMod] = (await Promise.all([
|
|
391
|
+
nativeImport("https-proxy-agent"),
|
|
392
|
+
nativeImport("http-proxy-agent"),
|
|
393
|
+
]));
|
|
394
|
+
return {
|
|
395
|
+
HttpsProxyAgent: httpsMod.HttpsProxyAgent,
|
|
396
|
+
HttpProxyAgent: httpMod.HttpProxyAgent,
|
|
397
|
+
};
|
|
317
398
|
}
|
|
318
|
-
catch {
|
|
319
|
-
|
|
320
|
-
return;
|
|
399
|
+
catch (error) {
|
|
400
|
+
(0, debug_1.debugLog)("api-mode", "failed to load proxy agent modules", error instanceof Error ? error.message : String(error));
|
|
401
|
+
return null;
|
|
321
402
|
}
|
|
322
|
-
|
|
323
|
-
|
|
403
|
+
})();
|
|
404
|
+
return proxyAgentsPromise;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 按 url 协议发起 HTTP(S) 请求,返回 {status, body};超时或错误 reject。
|
|
408
|
+
*
|
|
409
|
+
* 自带 env 代理支持(wham 回退 / 智谱 / custom 共用一条通道):有代理则给请求挂 `https-proxy-agent` /
|
|
410
|
+
* `http-proxy-agent`,无代理 / `NO_PROXY` 命中 / 代理模块加载失败时维持默认 globalAgent。`env` 默认
|
|
411
|
+
* `process.env`,对无代理环境行为零变化。
|
|
412
|
+
*/
|
|
413
|
+
async function httpRequest(url, opts, env = process.env) {
|
|
414
|
+
let parsed;
|
|
415
|
+
try {
|
|
416
|
+
parsed = new URL(url);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
throw new Error(`invalid url: ${url}`);
|
|
420
|
+
}
|
|
421
|
+
const transport = parsed.protocol === "http:" ? node_http_1.default : node_https_1.default;
|
|
422
|
+
const requestOptions = { method: opts.method, headers: opts.headers };
|
|
423
|
+
const proxyUrl = resolveProxyUrl(url, env);
|
|
424
|
+
if (proxyUrl) {
|
|
425
|
+
const agents = await loadProxyAgents();
|
|
426
|
+
if (agents) {
|
|
427
|
+
try {
|
|
428
|
+
requestOptions.agent =
|
|
429
|
+
parsed.protocol === "http:" ? new agents.HttpProxyAgent(proxyUrl) : new agents.HttpsProxyAgent(proxyUrl);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
(0, debug_1.debugLog)("api-mode", "failed to create proxy agent, falling back to direct", error instanceof Error ? error.message : String(error));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
const request = transport.request(url, requestOptions, (response) => {
|
|
324
438
|
const status = response.statusCode ?? 0;
|
|
325
439
|
let body = "";
|
|
326
440
|
response.setEncoding("utf8");
|
|
@@ -3,7 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readCodexAuth = readCodexAuth;
|
|
6
7
|
exports.fetchCodexQuota = fetchCodexQuota;
|
|
8
|
+
exports.parseWhamUsage = parseWhamUsage;
|
|
9
|
+
exports.fetchCodexQuotaViaWham = fetchCodexQuotaViaWham;
|
|
7
10
|
exports.readCodexQuotaCacheSync = readCodexQuotaCacheSync;
|
|
8
11
|
exports.resolveCodexQuota = resolveCodexQuota;
|
|
9
12
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -12,12 +15,19 @@ const promises_1 = __importDefault(require("node:fs/promises"));
|
|
|
12
15
|
const node_os_1 = require("node:os");
|
|
13
16
|
const node_path_1 = require("node:path");
|
|
14
17
|
const debug_1 = require("./debug");
|
|
18
|
+
const api_mode_1 = require("./api-mode");
|
|
15
19
|
const paths_1 = require("./paths");
|
|
16
20
|
// app-server 子进程参数:read-only + untrusted,Codex 官方推荐的最低权限拉额度姿态。
|
|
17
21
|
const CODEX_ARGS = ["-s", "read-only", "-a", "untrusted", "app-server"];
|
|
18
22
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
19
23
|
// notify 每 turn 触发,额度缓存 5 分钟,命中秒回避免阻塞 Codex 主流程。
|
|
20
24
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
25
|
+
// ChatGPT 后端 wham/usage 回退:仅本机无 codex CLI(spawn unavailable)时走,直连读 auth.json 的 OAuth token。
|
|
26
|
+
const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
27
|
+
const WHAM_TIMEOUT_MS = 15_000;
|
|
28
|
+
// wham 各窗口自带的时长(秒):5h 窗 18000、周窗 604800,按它认桶、不假设 primary/secondary 顺序。
|
|
29
|
+
const WHAM_SESSION_WINDOW_SECONDS = 18000;
|
|
30
|
+
const WHAM_WEEKLY_WINDOW_SECONDS = 604800;
|
|
21
31
|
function isRecord(value) {
|
|
22
32
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
33
|
}
|
|
@@ -136,6 +146,36 @@ function parseRateLimitsResult(result) {
|
|
|
136
146
|
function getCodexHome(codexHomePath) {
|
|
137
147
|
return codexHomePath ?? process.env.CODEX_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex");
|
|
138
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* 读 `$CODEX_HOME/auth.json`(默认 `~/.codex/auth.json`)的 OAuth token,仅 `auth_mode === "chatgpt"` 返回。
|
|
151
|
+
*
|
|
152
|
+
* 对齐 cc-switch 文件源结构 `{ auth_mode, tokens: { access_token, account_id }, last_refresh }`。
|
|
153
|
+
* 缺文件 / API key 模式(auth_mode !== "chatgpt")/ 结构异常均返回 null,绝不抛错。不实现 refresh、不读 Keychain。
|
|
154
|
+
* auth.json 结构随 Codex 升级变、易碎,解析宽松、缺字段放弃。
|
|
155
|
+
*/
|
|
156
|
+
function readCodexAuth(codexHomePath) {
|
|
157
|
+
try {
|
|
158
|
+
const authPath = (0, node_path_1.join)(getCodexHome(codexHomePath), "auth.json");
|
|
159
|
+
const raw = node_fs_1.default.readFileSync(authPath, "utf8");
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
if (!isRecord(parsed) || parsed.auth_mode !== "chatgpt") {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const tokens = parsed.tokens;
|
|
165
|
+
if (!isRecord(tokens)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const accessToken = typeof tokens.access_token === "string" && tokens.access_token.trim() !== "" ? tokens.access_token : null;
|
|
169
|
+
if (!accessToken) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const accountId = typeof tokens.account_id === "string" && tokens.account_id.trim() !== "" ? tokens.account_id : null;
|
|
173
|
+
return { accessToken, accountId };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
139
179
|
function buildRpcMessage(id, method, params) {
|
|
140
180
|
return `${JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} })}\n`;
|
|
141
181
|
}
|
|
@@ -266,6 +306,125 @@ async function fetchCodexQuota(options = {}) {
|
|
|
266
306
|
});
|
|
267
307
|
});
|
|
268
308
|
}
|
|
309
|
+
// --- wham/usage HTTP 直连回退(仅主路径 unavailable 时由 resolveCodexQuota 编排) ---
|
|
310
|
+
/** 读 wham 窗口使用率:主字段 `used_percent`(下划线,wham 实际返回),驼峰 / `used_percentage` 防御性 fallback;窗口为 null 返回 null。 */
|
|
311
|
+
function readWhamUsedPercent(window) {
|
|
312
|
+
if (!window) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
return clampPercent(toFiniteNumber(window.used_percent ?? window.usedPercent ?? window.used_percentage));
|
|
316
|
+
}
|
|
317
|
+
/** 读 wham 窗口重置时间:`reset_at` Unix 秒;< 10^10 视为秒转毫秒,否则按毫秒;窗口为 null 返回 null。 */
|
|
318
|
+
function readWhamResetsAtMs(window) {
|
|
319
|
+
if (!window) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const n = toFiniteNumber(window.reset_at ?? window.resetsAt);
|
|
323
|
+
if (n === null) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return n < 10_000_000_000 ? n * 1000 : n;
|
|
327
|
+
}
|
|
328
|
+
/** 按窗口时长(秒)认桶:18000→5h、604800→7d,未知返回 null。 */
|
|
329
|
+
function classifyWhamWindow(seconds) {
|
|
330
|
+
if (seconds === null) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (seconds === WHAM_SESSION_WINDOW_SECONDS) {
|
|
334
|
+
return "session";
|
|
335
|
+
}
|
|
336
|
+
if (seconds === WHAM_WEEKLY_WINDOW_SECONDS) {
|
|
337
|
+
return "weekly";
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 解析 wham/usage 响应。
|
|
343
|
+
*
|
|
344
|
+
* 结构 `rate_limit.{primary_window, secondary_window}.{used_percent, limit_window_seconds, reset_at}`,
|
|
345
|
+
* **按 `limit_window_seconds` 认桶**(18000→5h、604800→7d),不假设 primary/secondary 顺序(与主路径按
|
|
346
|
+
* `windowDurationMins` 认桶同构)。某窗缺 `used_percent` 跳过、不阻断另一窗;两窗都缺返回全 null。
|
|
347
|
+
*/
|
|
348
|
+
function parseWhamUsage(json) {
|
|
349
|
+
const rateLimit = isRecord(json) && isRecord(json.rate_limit) ? json.rate_limit : null;
|
|
350
|
+
if (!rateLimit) {
|
|
351
|
+
return { fiveHour: null, sevenDay: null, resetsAt: null };
|
|
352
|
+
}
|
|
353
|
+
let session = null;
|
|
354
|
+
let weekly = null;
|
|
355
|
+
for (const window of [rateLimit.primary_window, rateLimit.secondary_window]) {
|
|
356
|
+
if (!isRecord(window)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (readWhamUsedPercent(window) === null) {
|
|
360
|
+
continue; // 某窗缺 used_percent 跳过。
|
|
361
|
+
}
|
|
362
|
+
const kind = classifyWhamWindow(toFiniteNumber(window.limit_window_seconds));
|
|
363
|
+
if (kind === "session" && !session) {
|
|
364
|
+
session = window;
|
|
365
|
+
}
|
|
366
|
+
else if (kind === "weekly" && !weekly) {
|
|
367
|
+
weekly = window;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
fiveHour: readWhamUsedPercent(session),
|
|
372
|
+
sevenDay: readWhamUsedPercent(weekly),
|
|
373
|
+
resetsAt: readWhamResetsAtMs(session) ?? readWhamResetsAtMs(weekly),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** 构造一个全 null 的 outcome 占位(wham 内部失败统一用 error)。 */
|
|
377
|
+
function emptyWhamOutcome(status) {
|
|
378
|
+
return { fiveHour: null, sevenDay: null, resetsAt: null, status };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* wham/usage HTTP 直连回退:读 auth.json 的 chatgpt OAuth token → `GET wham/usage` → parseWhamUsage 认桶。
|
|
382
|
+
*
|
|
383
|
+
* 仅在主路径 `unavailable`(本机无 codex CLI)时由 resolveCodexQuota 编排调用。全程不抛错:
|
|
384
|
+
* 无 token / HTTP 失败 / 非 2xx / JSON 异常 / 解析空 → `{ status: "error", ... }`,成功 → `{ status: "ok", ... }`。
|
|
385
|
+
* 请求经 api-mode 的 httpRequest(自带统一 env 代理通道,代理环境也能回退采到额度)。
|
|
386
|
+
*/
|
|
387
|
+
async function fetchCodexQuotaViaWham(options = {}) {
|
|
388
|
+
const env = options.env ?? process.env;
|
|
389
|
+
const timeoutMs = options.timeoutMs ?? WHAM_TIMEOUT_MS;
|
|
390
|
+
const authReader = options.authReader ?? readCodexAuth;
|
|
391
|
+
const httpGet = options.httpGet ?? api_mode_1.httpRequest;
|
|
392
|
+
const auth = authReader(options.codexHomePath);
|
|
393
|
+
if (!auth) {
|
|
394
|
+
(0, debug_1.debugLog)("codex", "wham fallback: no chatgpt auth token");
|
|
395
|
+
return emptyWhamOutcome("error");
|
|
396
|
+
}
|
|
397
|
+
const headers = {
|
|
398
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
399
|
+
"User-Agent": "codex-cli",
|
|
400
|
+
Accept: "application/json",
|
|
401
|
+
};
|
|
402
|
+
if (auth.accountId) {
|
|
403
|
+
headers["ChatGPT-Account-Id"] = auth.accountId;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const { status, body } = await httpGet(WHAM_USAGE_URL, { method: "GET", headers, timeoutMs }, env);
|
|
407
|
+
if (status < 200 || status >= 300) {
|
|
408
|
+
(0, debug_1.debugLog)("codex", "wham fallback non-2xx", { status });
|
|
409
|
+
return emptyWhamOutcome("error");
|
|
410
|
+
}
|
|
411
|
+
let json;
|
|
412
|
+
try {
|
|
413
|
+
json = JSON.parse(body);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
(0, debug_1.debugLog)("codex", "wham fallback json parse failed", error instanceof Error ? error.message : String(error));
|
|
417
|
+
return emptyWhamOutcome("error");
|
|
418
|
+
}
|
|
419
|
+
const quota = parseWhamUsage(json);
|
|
420
|
+
(0, debug_1.debugLog)("codex", "wham fallback parsed", quota);
|
|
421
|
+
return { ...quota, status: "ok" };
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
(0, debug_1.debugLog)("codex", "wham fallback failed", error instanceof Error ? error.message : String(error));
|
|
425
|
+
return emptyWhamOutcome("error");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
269
428
|
/** 同步读取额度缓存;损坏 / 缺失返回 null。 */
|
|
270
429
|
function readCodexQuotaCacheSync(dataDir) {
|
|
271
430
|
try {
|
|
@@ -314,22 +473,40 @@ async function resolveCodexQuota(dataDir, options = {}) {
|
|
|
314
473
|
const now = options.now ?? new Date();
|
|
315
474
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
316
475
|
const fetcher = options.fetcher ?? fetchCodexQuota;
|
|
476
|
+
const whamFetcher = options.whamFetcher ?? fetchCodexQuotaViaWham;
|
|
317
477
|
const cached = readCodexQuotaCacheSync(dataDir);
|
|
318
478
|
if (cached && isCodexCacheFresh(cached, ttlMs, now)) {
|
|
319
479
|
return quotaFromCache(cached);
|
|
320
480
|
}
|
|
481
|
+
const persist = async (quota) => {
|
|
482
|
+
await writeCodexQuotaCache(dataDir, { ...quota, fetchedAt: now.toISOString() });
|
|
483
|
+
return quota;
|
|
484
|
+
};
|
|
321
485
|
try {
|
|
322
486
|
const outcome = await fetcher(options.fetchOptions);
|
|
323
487
|
if (outcome.status === "ok" && hasAnyQuota(outcome)) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
488
|
+
return await persist({ fiveHour: outcome.fiveHour, sevenDay: outcome.sevenDay, resetsAt: outcome.resetsAt });
|
|
489
|
+
}
|
|
490
|
+
// 主路径 unavailable(本机无 codex CLI)→ wham/usage HTTP 直连回退;
|
|
491
|
+
// error(超时 / RPC 错 / 进程崩)不触发,避免瞬时故障多扛一次 15s HTTP。
|
|
492
|
+
if (outcome.status === "unavailable") {
|
|
493
|
+
try {
|
|
494
|
+
const whamOutcome = await whamFetcher({
|
|
495
|
+
codexHomePath: options.fetchOptions?.codexHomePath,
|
|
496
|
+
env: options.fetchOptions?.env,
|
|
497
|
+
});
|
|
498
|
+
if (whamOutcome.status === "ok" && hasAnyQuota(whamOutcome)) {
|
|
499
|
+
return await persist({ fiveHour: whamOutcome.fiveHour, sevenDay: whamOutcome.sevenDay, resetsAt: whamOutcome.resetsAt });
|
|
500
|
+
}
|
|
501
|
+
(0, debug_1.debugLog)("codex", "wham fallback did not yield usable quota", { status: whamOutcome.status });
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
(0, debug_1.debugLog)("codex", "wham fallback threw", error instanceof Error ? error.message : String(error));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
(0, debug_1.debugLog)("codex", "fetch did not yield usable quota", { status: outcome.status });
|
|
331
509
|
}
|
|
332
|
-
(0, debug_1.debugLog)("codex", "fetch did not yield usable quota", { status: outcome.status });
|
|
333
510
|
}
|
|
334
511
|
catch (error) {
|
|
335
512
|
(0, debug_1.debugLog)("codex", "fetch threw, fallback to cache", error instanceof Error ? error.message : String(error));
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -34,8 +34,8 @@ function summarizeEvents(events) {
|
|
|
34
34
|
const codexByDesc = events.filter(payload_1.isCodexSourceEvent).sort(byDesc);
|
|
35
35
|
const latestUsagePct = (0, aggregate_1.addNullable)(claudeByDesc.find((event) => event.usagePct !== null)?.usagePct ?? null, codexByDesc.find((event) => event.usagePct !== null)?.usagePct ?? null);
|
|
36
36
|
const latestSevenDayUsagePct = (0, aggregate_1.addNullable)(claudeByDesc.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null, codexByDesc.find((event) => event.sevenDayUsagePct !== null)?.sevenDayUsagePct ?? null);
|
|
37
|
-
// 7d
|
|
38
|
-
const sevenDayCumulativeUsagePct = (0, aggregate_1.computeCumulativeSevenDay)((0, aggregate_1.buildSevenDayCurveFromEvents)(events));
|
|
37
|
+
// 7d 分源累计:Claude + Codex 各自「去毛刺 + 分段峰谷和」后相加,与 aggregate 同口径(混算会虚高)。
|
|
38
|
+
const sevenDayCumulativeUsagePct = (0, aggregate_1.addNullable)((0, aggregate_1.computeCumulativeSevenDay)((0, aggregate_1.buildSevenDayCurveFromEvents)(events.filter((event) => !(0, payload_1.isCodexSourceEvent)(event)))), (0, aggregate_1.computeCumulativeSevenDay)((0, aggregate_1.buildSevenDayCurveFromEvents)(events.filter(payload_1.isCodexSourceEvent))));
|
|
39
39
|
return {
|
|
40
40
|
fiveHourLatestUsagePct: latestUsagePct,
|
|
41
41
|
fiveHourPeakUsagePct: usages.length > 0 ? (0, time_1.roundNumber)(Math.max(...usages), 1) : null,
|
|
@@ -79,8 +79,9 @@ function bucketizeEvents(events, start, end, bucketMinutes = 5) {
|
|
|
79
79
|
}
|
|
80
80
|
// 7d 累计曲线(去毛刺 + 分段峰谷和的逐点版本)映射到时间桶:曲线已按时间升序,
|
|
81
81
|
// 同桶内后写覆盖前写,最终每个桶留下落在其区间内最后一个累计点的值(单调,等于桶内最大累计)。
|
|
82
|
+
// 只画 Claude 源:折线图是 Claude 趋势看板(与同图 5h/7d 两条线同源),Codex 累计已并入顶部 summary 数值。
|
|
82
83
|
const cumulativeByBucket = new Map();
|
|
83
|
-
for (const point of (0, aggregate_1.computeCumulativeSevenDayCurve)((0, aggregate_1.buildSevenDayCurveFromEvents)(events))) {
|
|
84
|
+
for (const point of (0, aggregate_1.computeCumulativeSevenDayCurve)((0, aggregate_1.buildSevenDayCurveFromEvents)(events.filter((event) => !(0, payload_1.isCodexSourceEvent)(event))))) {
|
|
84
85
|
const ts = new Date(point.timestamp).getTime();
|
|
85
86
|
if (ts < start.getTime() || ts > end.getTime()) {
|
|
86
87
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccus-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -37,5 +37,9 @@
|
|
|
37
37
|
"@types/node": "^24.0.0",
|
|
38
38
|
"tsx": "^4.20.0",
|
|
39
39
|
"typescript": "^5.8.3"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"http-proxy-agent": "^9.1.0",
|
|
43
|
+
"https-proxy-agent": "^9.1.0"
|
|
40
44
|
}
|
|
41
45
|
}
|