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 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`,整条链同环境。必须让 codex 子进程发请求(它读 `HTTP_PROXY`/`HTTPS_PROXY`),不能用 ccus 自己的 Node `fetch`(裸 fetch 不读代理、代理环境下直连失败)。
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。
@@ -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 collected = new Map();
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 list = collected.get(personKey);
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
- collected.set(personKey, [event]);
444
+ target.set(personKey, [event]);
441
445
  }
442
446
  }
443
447
  }
444
448
  const curves = new Map();
445
- for (const [personKey, events] of collected.entries()) {
446
- curves.set(personKey, buildSevenDayCurveFromEvents(events));
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
- // 累计指标走全样本合并曲线,不走单机的 recomputeUsage,避免漏掉另一台机器的样本。
530
- const sevenDayCumulativeUsagePct = computeCumulativeSevenDay(sliceCurveByDate(curves.get(personKey) ?? [], date));
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
- // 整周累计:在整周合并曲线上一次性求正增量,跨天边界增量被计入,故 weekly ≥ Σ daily。
625
- const sevenDayCumulativeUsagePct = computeCumulativeSevenDay(sliceCurveByWeek(curves.get(acc.personKey) ?? [], acc.week));
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,
@@ -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
- /** url 协议发起 HTTP(S) 请求,返回 {status, body};超时或错误 reject(仿 fetchLatestVersion)。 */
312
- function httpRequest(url, opts) {
313
- return new Promise((resolve, reject) => {
314
- let parsed;
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
- parsed = new URL(url);
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
- reject(new Error(`invalid url: ${url}`));
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
- const transport = parsed.protocol === "http:" ? node_http_1.default : node_https_1.default;
323
- const request = transport.request(url, { method: opts.method, headers: opts.headers }, (response) => {
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
- const quota = {
325
- fiveHour: outcome.fiveHour,
326
- sevenDay: outcome.sevenDay,
327
- resetsAt: outcome.resetsAt,
328
- };
329
- await writeCodexQuotaCache(dataDir, { ...quota, fetchedAt: now.toISOString() });
330
- return quota;
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));
@@ -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 分区叠加累计:与 aggregate 同一套「去毛刺 + 分段峰谷和」,口径与团队看板一致。
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.5",
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
  }