cc-viewer 1.7.4 → 1.7.6

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.
Files changed (88) hide show
  1. package/cli.js +19 -4
  2. package/dist/assets/App-3VM471BO.js +2 -0
  3. package/dist/assets/{App-Dj37fluO.css → App-CCDFxj11.css} +1 -1
  4. package/dist/assets/{MdxEditorPanel-DeSJp_ct.js → MdxEditorPanel-BQ5MgI4D.js} +1 -1
  5. package/dist/assets/Mobile-wwcXmnrU.js +1 -0
  6. package/dist/assets/UnifiedProxyRetryPage-BpcKIn9N.js +1 -0
  7. package/dist/assets/UnifiedProxyRetryPage-CFz4vOH5.css +1 -0
  8. package/dist/assets/{_baseUniq-BK7Nhp_x.js → _baseUniq-yiNE5Ckw.js} +1 -1
  9. package/dist/assets/{arc-Cm4HMXz6.js → arc-M-eBYYbN.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-Co8QCowN.js → architectureDiagram-Q4EWVU46-CtweeLKi.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-Cudecf7x.js → blockDiagram-DXYQGD6D-DCVQ-go_.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-4JeFoCHX.js → c4Diagram-AHTNJAMY-C7JB9PqP.js} +1 -1
  13. package/dist/assets/{channel-K7eT7Hjr.js → channel-BnzX9zBS.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-BBjl6EVW.js → chunk-4BX2VUAB-dVKiUtqe.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-DBP9YnwO.js → chunk-4TB4RGXK-CQjD9-fD.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6--A_i9gwR.js → chunk-55IACEB6-BZuKI6JQ.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-UBsL-3qr.js → chunk-EDXVE4YY-sk9g7szp.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-DBO0asmI.js → chunk-FMBD7UC4-CGL9Lo51.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-DOCcEaE6.js → chunk-OYMX7WX6-DaN0AdQD.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-B-j8QbQh.js → chunk-QZHKN3VN-CNEAHnZV.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-B0WO1U-U.js → chunk-YZCP3GAM-BOlts_Dm.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-CumOMdO7.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-CumOMdO7.js +1 -0
  24. package/dist/assets/clone-C3SZDzs1.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-DQgpss_a.js → cose-bilkent-S5V4N54A-DH4_YiGi.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-CCJBKmKv.js → dagre-KV5264BT-RqusStYI.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-DgUcnfkU.js → diagram-5BDNPKRD-8haVbBnt.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-CRpVvexw.js → diagram-G4DWMVQ6-CsrJNUhT.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-C3IhpsM-.js → diagram-MMDJMWI5-1luPOM38.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-BaVbsrwt.js → diagram-TYMM5635-Dad1XR3W.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BxRoBUR8.js → erDiagram-SMLLAGMA-Br6-kbp9.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-Du2lIzoM.js → flowDiagram-DWJPFMVM-DuX1mbA7.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-DR1pDqLK.js → ganttDiagram-T4ZO3ILL-BL_XQ958.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DHAmE20O.js → gitGraphDiagram-UUTBAWPF-DzvW5FEs.js} +1 -1
  35. package/dist/assets/{graph-D8Yv0IU1.js → graph-CbUmu0Oe.js} +1 -1
  36. package/dist/assets/{index-B6ZWIxY0.js → index-B3-JUU7J.js} +1 -1
  37. package/dist/assets/{index-D0pvxpcl.js → index-BgW_nP_0.js} +1 -1
  38. package/dist/assets/{index-Dpu92rw_.js → index-BjNLFJ0z.js} +2 -2
  39. package/dist/assets/{index-cCB-lhEO.js → index-CzPHj3iF.js} +1 -1
  40. package/dist/assets/{index-D2c77__8.js → index-DDlAMOiV.js} +1 -1
  41. package/dist/assets/{index-Bq95HjYM.js → index-Do5xNTOT.js} +1 -1
  42. package/dist/assets/{index-AOmFG_I_.js → index-LjVy-Pzg.js} +1 -1
  43. package/dist/assets/{index-DgtUyP57.js → index-vImWXiMM.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-BAqRIm2G.js → infoDiagram-42DDH7IO-DGsgRji7.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-CzH4tezb.js → ishikawaDiagram-UXIWVN3A-D3odpv9_.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-Qs4eiyuI.js → journeyDiagram-VCZTEJTY-Dj3NaOWF.js} +1 -1
  47. package/dist/assets/{jszip.min-C0OccDsk.js → jszip.min-GmVScWAV.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-BAjl0gpV.js → kanban-definition-6JOO6SKY-GKjJaLwD.js} +1 -1
  49. package/dist/assets/{layout-Cf6cd9xE.js → layout-Bhk66H0F.js} +1 -1
  50. package/dist/assets/{linear-PzdCkji6.js → linear-CLk9nvqn.js} +1 -1
  51. package/dist/assets/{mermaid.core-DV9745Lq.js → mermaid.core-DH-GfeNS.js} +2 -2
  52. package/dist/assets/{min-CHgWxbzS.js → min-DQftOGfo.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-CHUjMdpi.js → mindmap-definition-QFDTVHPH-DgDD6Jbu.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-D_r2QC0f.js → pieDiagram-DEJITSTG-jy1yFaxA.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-DPzGygdN.js → quadrantDiagram-34T5L4WZ-BdvMwrSD.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-BA2ynnGb.js → requirementDiagram-MS252O5E-CtBOCtbk.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-XHVvXuqU.js → sankeyDiagram-XADWPNL6-DEFlQp-h.js} +1 -1
  58. package/dist/assets/{seqResourceLoaders-kBEzcKLh.js → seqResourceLoaders-CDRtUSxI.js} +2 -2
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-C47sA8YH.js → sequenceDiagram-FGHM5R23-0F2R8Ky0.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-BOmer839.js → stateDiagram-FHFEXIEX-D4xOwcSW.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js → stateDiagram-v2-QKLJ7IA2-DSf_BSF8.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-CTAKVZgs.js → timeline-definition-GMOUNBTQ-B5cPasy_.js} +1 -1
  63. package/dist/assets/{vendor-antd-BuJ6oz45.js → vendor-antd-DeqwrDxf.js} +2 -2
  64. package/dist/assets/{vendor-codemirror-D2KAW4ty.js → vendor-codemirror-_NbrtdQc.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-BsFVVUcj.js → vendor-mdxeditor-DG0Nyxw5.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-BpxSq04x.js → vendor-qrcode-B3HN--HO.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-an4WvAvS.js → vendor-virtuoso-BvrezPQx.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-CuXzoroh.js → vennDiagram-DHZGUBPP-a_ybZDST.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-CFCmxnck.js → wardley-RL74JXVD-CKla3KF8.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-gfhDB9SR.js → wardleyDiagram-NUSXRM2D-BKke2aih.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-X-pj66qI.js → xychartDiagram-5P7HB3ND-CozWtcdf.js} +1 -1
  72. package/dist/index.html +4 -4
  73. package/findcc.js +58 -0
  74. package/package.json +1 -1
  75. package/server/lib/interceptor-core.js +26 -15
  76. package/server/lib/proxy-stats.js +436 -0
  77. package/server/lib/stats-worker.js +119 -5
  78. package/server/proxy.js +50 -7
  79. package/server/pty-manager.js +8 -0
  80. package/server/routes/preferences.js +1 -1
  81. package/server/routes/proxy-stats.js +95 -0
  82. package/server/server.js +31 -0
  83. package/src/utils/isProxyMode.js +11 -0
  84. package/dist/assets/App-CKvCavmF.js +0 -2
  85. package/dist/assets/Mobile-CWou-jtF.js +0 -1
  86. package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +0 -1
  87. package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +0 -1
  88. package/dist/assets/clone-DjURvTeN.js +0 -1
@@ -400,34 +400,45 @@ export function replaceTopLevelModel(jsonStr, oldModel, newModel) {
400
400
  // 家族用**大小写不敏感子串**匹配(/opus/i 等),只认这几个已知家族单词——
401
401
  // 因此 claude-opus-4-8、未来的 claude-opus-5 等任何版本都命中同一家族,版本升级无需重配。
402
402
  // 字段直接沿用 Claude Code 的环境变量名以保持一致:
403
- // ANTHROPIC_MODEL —— 主模型;body.model 含 "fable" / "mythos" 时映射到它
403
+ // ANTHROPIC_MODEL —— 主模型(catch-all 兜底;body.model 含 "fable"/"mythos"
404
+ // 及所有未识别家族均回落到它)
404
405
  // ANTHROPIC_DEFAULT_OPUS_MODEL —— body.model 含 "opus"
405
406
  // ANTHROPIC_DEFAULT_SONNET_MODEL —— 含 "sonnet"
406
407
  // ANTHROPIC_DEFAULT_HAIKU_MODEL —— 含 "haiku"
407
- // 家族字段留空 = 该家族不改写(透传原始 model)。
408
- // 未识别家族(既不是 opus/sonnet/haiku 也不是 fable/mythos)不做兜底替换,原样透传。
408
+ // 家族字段优先:opus/sonnet/haiku 各自命中专属字段;字段留空则回落到 ANTHROPIC_MODEL。
409
+ // 未识别家族(既不是 opus/sonnet/haiku 也不是 fable/mythos)→ ANTHROPIC_MODEL 兜底。
409
410
  // 兼容旧数据:profile 未设任何新字段但有 activeModel(老结构)时,回退为旧的整体替换语义。
410
- // 返回目标模型字符串;无需改写(无目标 / 目标同旧值 / 入参非法 / 未识别家族)时返回 null。
411
+ // 返回目标模型字符串;无需改写(无目标 / 目标同旧值 / 入参非法)时返回 null。
412
+ // [1m] 后缀(Claude Code 1M context 标记)默认忽略:所有 profile 模型字段值在比较前先剥除。
411
413
  export function resolveProfileModel(oldModel, profile) {
412
414
  if (typeof oldModel !== 'string' || !oldModel || !profile || typeof profile !== 'object') return null;
413
- const opus = typeof profile.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string' ? profile.ANTHROPIC_DEFAULT_OPUS_MODEL.trim() : '';
414
- const sonnet = typeof profile.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string' ? profile.ANTHROPIC_DEFAULT_SONNET_MODEL.trim() : '';
415
- const haiku = typeof profile.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' ? profile.ANTHROPIC_DEFAULT_HAIKU_MODEL.trim() : '';
416
- const primary = typeof profile.ANTHROPIC_MODEL === 'string' ? profile.ANTHROPIC_MODEL.trim() : '';
415
+
416
+ // Strip [1m] suffix (case-insensitive) from model names; Claude Code appends it
417
+ // for 1M-context variants and it should not affect model matching/replacement.
418
+ const strip1m = (s) => (typeof s === 'string' ? s.replace(/\[1m\]/gi, '').trim() : '');
419
+
420
+ const opus = strip1m(profile.ANTHROPIC_DEFAULT_OPUS_MODEL);
421
+ const sonnet = strip1m(profile.ANTHROPIC_DEFAULT_SONNET_MODEL);
422
+ const haiku = strip1m(profile.ANTHROPIC_DEFAULT_HAIKU_MODEL);
423
+ const primary = strip1m(profile.ANTHROPIC_MODEL);
417
424
  const hasNew = !!(primary || opus || sonnet || haiku);
418
425
 
419
426
  let target = '';
420
427
  if (hasNew) {
421
- if (/opus/i.test(oldModel)) target = opus;
422
- else if (/sonnet/i.test(oldModel)) target = sonnet;
423
- else if (/haiku/i.test(oldModel)) target = haiku;
424
- else if (/fable/i.test(oldModel) || /mythos/i.test(oldModel)) target = primary; // 显式家族 → 主模型
425
- // 未识别家族:target 保持 '',下方返回 null(不替换、原样透传)
428
+ // Family-specific fields take precedence; fall back to ANTHROPIC_MODEL when
429
+ // the family field is empty/unset. ANTHROPIC_MODEL itself acts as the
430
+ // catch-all default for any unrecognized family (including third-party models
431
+ // like gpt-4o, deepseek-v4, K3/kimi, etc.).
432
+ if (/opus/i.test(oldModel)) target = opus || primary;
433
+ else if (/sonnet/i.test(oldModel)) target = sonnet || primary;
434
+ else if (/haiku/i.test(oldModel)) target = haiku || primary;
435
+ else if (/fable/i.test(oldModel) || /mythos/i.test(oldModel)) target = primary;
436
+ else target = primary; // unrecognized family → ANTHROPIC_MODEL catch-all
426
437
  } else if (typeof profile.activeModel === 'string') {
427
- target = profile.activeModel.trim(); // 旧数据整体替换语义
438
+ target = strip1m(profile.activeModel); // 旧数据整体替换语义
428
439
  }
429
440
 
430
- if (!target || target === oldModel) return null;
441
+ if (!target || target === strip1m(oldModel)) return null;
431
442
  return target;
432
443
  }
433
444
 
@@ -0,0 +1,436 @@
1
+ // Proxy Stats — pure functions for proxy retry statistics.
2
+ // Detail record schema, per-day sharding, aggregate computation (availability / percentiles / streak / by-model by-path).
3
+ //
4
+ // Design notes:
5
+ // - All pure functions, independently unit-testable; no I/O side effects (except appendRecord, which only writes files and reads no state).
6
+ // - Details are sharded per day as proxy_YYYY-MM-DD.jsonl, synonymous with llm-retry-proxy's retry_YYYY-MM-DD.jsonl.
7
+ // - Aggregation is done by stats-worker (Worker thread) calling aggregateRecords; the main thread only handles appendRecord.
8
+ // - Fully separated from cc-viewer's existing session logs (*.jsonl written by interceptor), no cross-contamination.
9
+ import { mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { AsyncWriteQueue } from './async-write-queue.js';
12
+
13
+ // ── Detail record schema ──────────────────────────────────────────────
14
+ // Appends one JSON line after each proxied LLM API request completes. Fields align with llm-retry-proxy's retry records.
15
+ //
16
+ // ts ISO timestamp (millisecond precision, at request completion)
17
+ // method HTTP method
18
+ // path Request path (without host, includes query)
19
+ // model Model name (parsed from request body.model; empty string if absent)
20
+ // upstream_status Last upstream response status code (0 on request error / network failure)
21
+ // final_status Status code returned to the client (the real last upstream failure code when retries are exhausted, e.g. 429/503; no longer hardcodes 503)
22
+ // attempts Total attempt count (including the first)
23
+ // retries Retry count = attempts - 1
24
+ // duration_ms Total duration (milliseconds)
25
+ // succeeded Whether a <400 response was ultimately received
26
+ // retry_codes List of upstream error codes returned during retries, e.g. [503, 503, 429]; [] when no retries
27
+ //
28
+ // Note: duration uses milliseconds (cc-viewer standardizes on ms throughout), unlike llm-retry-proxy which uses seconds.
29
+
30
+ /**
31
+ * Build a standardized proxy detail record. Fills in defaults and derives retries/succeeded.
32
+ * @param {object} r Raw fields
33
+ * @returns {object} Standardized record
34
+ */
35
+ export function buildRecord(r) {
36
+ const attempts = Math.max(1, Number(r.attempts) || 1);
37
+ const retries = Math.max(0, attempts - 1);
38
+ const finalStatus = Number(r.finalStatus) || 0;
39
+ const upstreamStatus = Number(r.upstreamStatus) || 0;
40
+ return {
41
+ ts: r.ts || new Date().toISOString(),
42
+ method: r.method || 'POST',
43
+ path: r.path || '/',
44
+ model: typeof r.model === 'string' ? r.model : '',
45
+ // Profile identifier (for byProfile aggregation): defaults to 'default', compatible with the built-in max profile
46
+ profile_id: typeof r.profileId === 'string' && r.profileId ? r.profileId : 'default',
47
+ profile_name: typeof r.profileName === 'string' && r.profileName ? r.profileName : 'Default',
48
+ upstream_status: upstreamStatus,
49
+ final_status: finalStatus,
50
+ attempts,
51
+ retries,
52
+ duration_ms: Math.max(0, Number(r.durationMs) || 0),
53
+ succeeded: typeof r.succeeded === 'boolean' ? r.succeeded : finalStatus > 0 && finalStatus < 400,
54
+ retry_codes: Array.isArray(r.retryCodes) ? r.retryCodes.filter(c => Number.isFinite(c)) : [],
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Determine whether a record is "successful": final_status < 400 (2xx/3xx success, 4xx/5xx failure).
60
+ * Synonymous with llm-retry-proxy's _req_succeeded.
61
+ */
62
+ export function isRecordSucceeded(r) {
63
+ return Number(r?.final_status) > 0 && Number(r?.final_status) < 400;
64
+ }
65
+
66
+ /**
67
+ * Per-day sharded detail file name: proxy_YYYY-MM-DD.jsonl
68
+ * @param {string} dateStr e.g. "2026-07-08"
69
+ */
70
+ export function dailyFileName(dateStr) {
71
+ return `proxy_${dateStr}.jsonl`;
72
+ }
73
+
74
+ /**
75
+ * Absolute path of the per-day sharded detail file.
76
+ * @param {string} projectDir Project log directory (LOG_DIR/<project>)
77
+ * @param {string} dateStr e.g. "2026-07-08"
78
+ */
79
+ export function dailyFilePath(projectDir, dateStr) {
80
+ return join(projectDir, dailyFileName(dateStr));
81
+ }
82
+
83
+ /**
84
+ * Today's date string (local timezone, consistent with stats-worker's file naming convention).
85
+ */
86
+ export function todayStr(d = new Date()) {
87
+ const y = d.getFullYear();
88
+ const m = String(d.getMonth() + 1).padStart(2, '0');
89
+ const day = String(d.getDate()).padStart(2, '0');
90
+ return `${y}-${m}-${day}`;
91
+ }
92
+
93
+ /**
94
+ * Parse the date string from a proxy_YYYY-MM-DD.jsonl file name. Returns null for non-proxy detail files.
95
+ */
96
+ export function parseDailyFileName(fileName) {
97
+ const m = /^proxy_(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(fileName);
98
+ return m ? m[1] : null;
99
+ }
100
+
101
+ /**
102
+ * Append a detail record to the per-day sharded file (line-ordered, non-blocking).
103
+ * Creates the directory automatically on first use (mkdirSync recursive, cached).
104
+ *
105
+ * Review P2 fix: this sat on the proxy hot path (before the first response
106
+ * byte) doing a synchronous mkdir + appendFileSync per request. Writes now go
107
+ * through the shared AsyncWriteQueue (single writer, ordered, falls back to
108
+ * sync on process exit so records are never lost) and the mkdir runs once per
109
+ * directory via a process-level cache.
110
+ * @param {string} filePath Absolute path of the detail file
111
+ * @param {object} record Standardized record produced by buildRecord
112
+ * @param {Function} [onDone] Called after the line hits the queue's writer (tests)
113
+ */
114
+ const _statsWriteQueue = new AsyncWriteQueue(''); // paths are always explicit (appendTo)
115
+ const _dirsEnsured = new Set();
116
+
117
+ export function appendRecord(filePath, record, onDone) {
118
+ const dir = join(filePath, '..');
119
+ if (!_dirsEnsured.has(dir)) {
120
+ try {
121
+ mkdirSync(dir, { recursive: true });
122
+ _dirsEnsured.add(dir);
123
+ } catch { /* No permission — the queued append will surface the failure as a silent no-op */ }
124
+ }
125
+ _statsWriteQueue.appendTo(filePath, JSON.stringify(record) + '\n', onDone);
126
+ }
127
+
128
+ /** Await all queued detail writes (tests / graceful shutdown). */
129
+ export function flushRecords() {
130
+ return _statsWriteQueue.flush();
131
+ }
132
+
133
+ // ── Stats-update notifier (dependency inversion, review P2) ────────────────
134
+ // proxy.js used to dynamically import('./server.js') to reach the statsWorker
135
+ // singleton — if a future proxy-only process did that first, server.js's
136
+ // module-load side effects would boot a second viewer. The owner (server.js)
137
+ // now registers its notify callback here at load time; proxy.js just emits.
138
+ // No listener registered (server.js not loaded) → emit is a no-op.
139
+ let _statsUpdateListener = null;
140
+
141
+ export function setProxyStatsListener(fn) {
142
+ _statsUpdateListener = typeof fn === 'function' ? fn : null;
143
+ }
144
+
145
+ export function emitProxyStatsUpdate(fileName) {
146
+ if (!_statsUpdateListener) return;
147
+ try {
148
+ _statsUpdateListener(fileName);
149
+ } catch (err) {
150
+ if (process.env.CCV_DEBUG) {
151
+ console.error('[CC-Viewer Proxy] proxy stats notify failed:', err?.message);
152
+ }
153
+ }
154
+ }
155
+
156
+ // ── Aggregate computation ──────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Compute a percentile. Input must be a numerically ascending-sorted array.
160
+ * Uses the same nearest-rank method as llm-retry-proxy (simple, no interpolation, sufficient for discrete distributions like durations).
161
+ * @param {number[]} sorted Sorted array
162
+ * @param {number} p 0~1, e.g. 0.95
163
+ * @returns {number} Percentile value; returns 0 for empty array
164
+ */
165
+ export function percentile(sorted, p) {
166
+ if (!Array.isArray(sorted) || sorted.length === 0) return 0;
167
+ if (sorted.length === 1) return sorted[0];
168
+ const idx = Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1);
169
+ return sorted[idx];
170
+ }
171
+
172
+ /**
173
+ * Compute the current consecutive success/failure streak and the longest failure streak.
174
+ * Synonymous with llm-retry-proxy's streak logic: iterate records in time order, accumulating same-type consecutive runs.
175
+ * @param {object[]} records Records sorted in ascending time order
176
+ * @returns {{ current: {type:'success'|'failure', count:number}, worstFailure: number }}
177
+ */
178
+ export function computeStreak(records) {
179
+ if (!Array.isArray(records) || records.length === 0) {
180
+ return { current: { type: 'success', count: 0 }, worstFailure: 0 };
181
+ }
182
+ let worstFailure = 0;
183
+ let curType = null;
184
+ let curCount = 0;
185
+ for (const r of records) {
186
+ const ok = isRecordSucceeded(r);
187
+ const type = ok ? 'success' : 'failure';
188
+ if (type === curType) {
189
+ curCount++;
190
+ } else {
191
+ curType = type;
192
+ curCount = 1;
193
+ }
194
+ if (type === 'failure' && curCount > worstFailure) worstFailure = curCount;
195
+ }
196
+ return { current: { type: curType || 'success', count: curCount }, worstFailure };
197
+ }
198
+
199
+ /**
200
+ * Aggregate a batch of detail records into a stats structure. Called by stats-worker, also used directly by /api/proxy-stats.
201
+ *
202
+ * Definitions (consistent with llm-retry-proxy):
203
+ * - Upstream availability = proportion of requests that succeeded on the first attempt (retries==0 && succeeded)
204
+ * - Downstream availability = proportion that ultimately succeeded after retries (succeeded)
205
+ * - Difference = number of requests rescued by retries
206
+ *
207
+ * @param {object[]} records Detail record array (order is irrelevant; sorted internally)
208
+ * @param {object} [opts]
209
+ * @param {number} [opts.recentLimit=50] Number of recent detail entries to keep
210
+ * @returns {object} Aggregate result (proxyStats structure)
211
+ */
212
+ export function aggregateRecords(records, opts = {}) {
213
+ const recentLimit = Number(opts.recentLimit) || 50;
214
+ if (!Array.isArray(records) || records.length === 0) {
215
+ return emptyStats();
216
+ }
217
+
218
+ // Sort a copy in ascending time order (streak / recent depend on order)
219
+ const sorted = [...records].sort((a, b) => {
220
+ const ta = a.ts ? Date.parse(a.ts) : 0;
221
+ const tb = b.ts ? Date.parse(b.ts) : 0;
222
+ return ta - tb;
223
+ });
224
+
225
+ const total = sorted.length;
226
+ let totalRetries = 0;
227
+ let succeeded = 0;
228
+ let failed = 0;
229
+ let firstOk = 0; // Succeeded on the first attempt (retries==0 && succeeded)
230
+ const durations = [];
231
+ const retryDist = new Map(); // retries -> count
232
+ const retryCodeCounts = new Map(); // code -> count
233
+ const byModelMap = new Map();
234
+ const byPathMap = new Map();
235
+ const byProfileMap = new Map();
236
+ let slowest = null;
237
+ let fastest = null;
238
+
239
+ for (const r of sorted) {
240
+ const retries = Number(r.retries) || 0;
241
+ const ok = isRecordSucceeded(r);
242
+ const dur = Number(r.duration_ms) || 0;
243
+ totalRetries += retries;
244
+ if (ok) {
245
+ succeeded++;
246
+ if (retries === 0) firstOk++;
247
+ } else {
248
+ failed++;
249
+ }
250
+ durations.push(dur);
251
+ retryDist.set(retries, (retryDist.get(retries) || 0) + 1);
252
+ for (const c of (r.retry_codes || [])) {
253
+ retryCodeCounts.set(c, (retryCodeCounts.get(c) || 0) + 1);
254
+ }
255
+ // by model
256
+ const model = r.model || '(unknown)';
257
+ if (!byModelMap.has(model)) byModelMap.set(model, newBucket());
258
+ accBucket(byModelMap.get(model), retries, ok, dur);
259
+ // by path
260
+ const path = r.path || '/';
261
+ if (!byPathMap.has(path)) byPathMap.set(path, newBucket());
262
+ accBucket(byPathMap.get(path), retries, ok, dur);
263
+ // by profile
264
+ const profileKey = r.profile_id || 'default';
265
+ if (!byProfileMap.has(profileKey)) byProfileMap.set(profileKey, { ...newBucket(), profile_name: r.profile_name || 'Default' });
266
+ accBucket(byProfileMap.get(profileKey), retries, ok, dur);
267
+ // slowest / fastest (fastest only counts successes with dur>0)
268
+ const candidate = { ts: r.ts, path, model, attempts: r.attempts, retries, duration_ms: dur, final_status: r.final_status, retry_codes: r.retry_codes || [] };
269
+ if (!slowest || dur > slowest.duration_ms) slowest = candidate;
270
+ if (ok && dur > 0 && (!fastest || dur < fastest.duration_ms)) fastest = candidate;
271
+ }
272
+
273
+ durations.sort((a, b) => a - b);
274
+ const p50 = percentile(durations, 0.5);
275
+ const p95 = percentile(durations, 0.95);
276
+ const p99 = percentile(durations, 0.99);
277
+ const maxMs = durations.length ? durations[durations.length - 1] : 0;
278
+ const avgMs = durations.length ? durations.reduce((s, x) => s + x, 0) / durations.length : 0;
279
+
280
+ const upstreamAvail = total ? round2(firstOk / total * 100) : 0;
281
+ const downstreamAvail = total ? round2(succeeded / total * 100) : 0;
282
+ const streak = computeStreak(sorted);
283
+
284
+ return {
285
+ summary: {
286
+ totalRequests: total,
287
+ totalRetries,
288
+ totalSucceeded: succeeded,
289
+ totalFailed: failed,
290
+ totalFirstOk: firstOk,
291
+ upstreamAvailabilityPct: upstreamAvail,
292
+ downstreamAvailabilityPct: downstreamAvail,
293
+ p50Ms: round3(p50),
294
+ p95Ms: round3(p95),
295
+ p99Ms: round3(p99),
296
+ maxMs: round3(maxMs),
297
+ avgMs: round3(avgMs),
298
+ currentStreakType: streak.current.type,
299
+ currentStreakCount: streak.current.count,
300
+ worstFailureStreak: streak.worstFailure,
301
+ },
302
+ byModel: mapToSortedArray(byModelMap, 'model'),
303
+ byPath: mapToSortedArray(byPathMap, 'path').slice(0, 10), // Top 10 path
304
+ byProfile: mapToSortedArrayWithExtra(byProfileMap, 'profile_id', 'profile_name'),
305
+ retryDistribution: [...retryDist.entries()]
306
+ .sort((a, b) => a[0] - b[0])
307
+ .map(([retries, count]) => ({ retries, count })),
308
+ retryCodes: [...retryCodeCounts.entries()]
309
+ .sort((a, b) => b[1] - a[1])
310
+ .map(([code, count]) => ({ code, count })),
311
+ slowest,
312
+ fastest,
313
+ recentRecords: sorted.slice(-recentLimit).reverse(),
314
+ };
315
+ }
316
+
317
+ function newBucket() {
318
+ return { requests: 0, retries: 0, succeeded: 0, firstOk: 0, failed: 0, durations: [] };
319
+ }
320
+
321
+ function accBucket(b, retries, ok, dur) {
322
+ b.requests++;
323
+ b.retries += retries;
324
+ if (ok) {
325
+ b.succeeded++;
326
+ if (retries === 0) b.firstOk++;
327
+ } else {
328
+ b.failed++;
329
+ }
330
+ b.durations.push(dur);
331
+ }
332
+
333
+ function mapToSortedArray(map, keyName) {
334
+ const arr = [];
335
+ for (const [k, b] of map.entries()) {
336
+ b.durations.sort((a, c) => a - c);
337
+ const entry = {
338
+ [keyName]: k,
339
+ requests: b.requests,
340
+ retries: b.retries,
341
+ succeeded: b.succeeded,
342
+ firstOk: b.firstOk,
343
+ failed: b.failed,
344
+ availabilityPct: b.requests ? round2(b.succeeded / b.requests * 100) : 0,
345
+ upstreamAvailabilityPct: b.requests ? round2(b.firstOk / b.requests * 100) : 0,
346
+ p95Ms: round3(percentile(b.durations, 0.95)),
347
+ };
348
+ arr.push(entry);
349
+ }
350
+ arr.sort((a, b) => b.requests - a.requests);
351
+ return arr;
352
+ }
353
+
354
+ // byProfile-specific: the bucket carries an extra profile_name field, included in the output
355
+ function mapToSortedArrayWithExtra(map, keyName, extraName) {
356
+ const arr = [];
357
+ for (const [k, b] of map.entries()) {
358
+ b.durations.sort((a, c) => a - c);
359
+ const entry = {
360
+ [keyName]: k,
361
+ [extraName]: b[extraName] || k,
362
+ requests: b.requests,
363
+ retries: b.retries,
364
+ succeeded: b.succeeded,
365
+ firstOk: b.firstOk,
366
+ failed: b.failed,
367
+ availabilityPct: b.requests ? round2(b.succeeded / b.requests * 100) : 0,
368
+ upstreamAvailabilityPct: b.requests ? round2(b.firstOk / b.requests * 100) : 0,
369
+ p95Ms: round3(percentile(b.durations, 0.95)),
370
+ };
371
+ arr.push(entry);
372
+ }
373
+ arr.sort((a, b) => b.requests - a.requests);
374
+ return arr;
375
+ }
376
+
377
+ function emptyStats() {
378
+ return {
379
+ summary: {
380
+ totalRequests: 0, totalRetries: 0, totalSucceeded: 0, totalFailed: 0, totalFirstOk: 0,
381
+ upstreamAvailabilityPct: 0, downstreamAvailabilityPct: 0,
382
+ p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0, avgMs: 0,
383
+ currentStreakType: 'success', currentStreakCount: 0, worstFailureStreak: 0,
384
+ },
385
+ byModel: [],
386
+ byPath: [],
387
+ byProfile: [],
388
+ retryDistribution: [],
389
+ retryCodes: [],
390
+ slowest: null,
391
+ fastest: null,
392
+ recentRecords: [],
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Incremental cache merge at the proxy_YYYY-MM-DD.jsonl file granularity (pure function, no I/O).
398
+ *
399
+ * Proxy detail files are sharded per day and append-only: if a file's size+mtime is unchanged, its content is unchanged,
400
+ * so the records parsed last time can be reused, skipping redundant readFileSync+JSON.parse. stats-worker calls this
401
+ * function on each notifyProxyStats trigger, avoiding a full re-scan of all historical shards.
402
+ *
403
+ * @param {object} params
404
+ * @param {object} [params.existingCache] Previous file cache: { [fileName]: { size, lastModified, records } }
405
+ * @param {Array<{name:string,size:number,lastModified:string,records?:object[]}>} params.files
406
+ * All current proxy detail files (worker has already statSync'd; records are only re-parsed by the worker when the file changes;
407
+ * unchanged files leave records empty for cache reuse)
408
+ * @returns {{records:object[], cache:object}} Merged full records + new cache (for writing the next stats JSON)
409
+ */
410
+ export function mergeProxyFileCache({ existingCache, files }) {
411
+ const cache = {};
412
+ const allRecords = [];
413
+ const existing = existingCache && typeof existingCache === 'object' ? existingCache : {};
414
+ for (const f of files) {
415
+ if (!f || !f.name) continue;
416
+ const cached = existing[f.name];
417
+ const unchanged = cached
418
+ && cached.size === f.size
419
+ && cached.lastModified === f.lastModified
420
+ && Array.isArray(cached.records);
421
+ if (unchanged) {
422
+ // File unchanged: reuse cached records, skip re-parsing
423
+ cache[f.name] = { size: f.size, lastModified: f.lastModified, records: cached.records };
424
+ for (const r of cached.records) allRecords.push(r);
425
+ } else if (Array.isArray(f.records)) {
426
+ // File changed (or new): use the worker's re-parsed records, update cache
427
+ cache[f.name] = { size: f.size, lastModified: f.lastModified, records: f.records };
428
+ for (const r of f.records) allRecords.push(r);
429
+ }
430
+ // Neither cached nor has records (worker provided none and no cache) → skip (worker should ensure changed files carry records)
431
+ }
432
+ return { records: allRecords, cache };
433
+ }
434
+
435
+ function round2(n) { return Math.round(n * 100) / 100; }
436
+ function round3(n) { return Math.round(n * 1000) / 1000; }
@@ -5,7 +5,7 @@
5
5
  // waste, review P1). Legacy v1 *.jsonl files are no longer counted — their
6
6
  // numbers return once the user migrates (ccv convert / the migrate prompt).
7
7
  import { parentPort } from 'node:worker_threads';
8
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
8
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
9
9
  import { join, basename } from 'node:path';
10
10
  import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
11
11
  import { dirSizeSync } from './v2/layout.js';
@@ -15,6 +15,7 @@ import {
15
15
  INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
16
16
  collectPromptsFromEvents, sortEpochFiles,
17
17
  } from './user-prompt-extract.js';
18
+ import { aggregateRecords, mergeProxyFileCache, parseDailyFileName } from './proxy-stats.js';
18
19
 
19
20
  // Prompt/preview extraction moved to the shared server/lib/user-prompt-extract.js
20
21
  // (also feeds the V2Writer prompts.jsonl cache and the log-list read side).
@@ -29,7 +30,12 @@ export { INTER_SESSION_TYPES, isSystemText, extractUserTexts };
29
30
  // invalidates pre-discard caches so their probe units can't be reused
30
31
  // back into filesStats, which lets the discard check sit AFTER the
31
32
  // cache-reuse branch (cache hits skip the journal head scan entirely)
32
- const STATS_VERSION = 11;
33
+ // v12: proxyStats field (proxy retry detail aggregation from per-day
34
+ // proxy_YYYY-MM-DD.jsonl shards; the bump invalidates v10 caches written
35
+ // by the pre-rebase feat/proxy-stats branch and v11 caches that lack the
36
+ // proxy fields). The per-file records cache lives in worker memory only
37
+ // (_proxyCacheByDir) — never persisted into the stats JSON.
38
+ const STATS_VERSION = 12;
33
39
 
34
40
  /**
35
41
  * Parse one v2 session directory into the same stats shape parseJsonlFile
@@ -168,7 +174,14 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
168
174
  // Scan units = v2 session dirs. The cache key is the journal's size+mtime:
169
175
  // every request/completion appends journal lines, so any change moves it.
170
176
  const sessionIds = listSessionIds(projectDir).sort();
171
- if (sessionIds.length === 0) return;
177
+ // Proxy retry shards (proxy_YYYY-MM-DD.jsonl) live at the project top level
178
+ // and can exist without any v2 session (proxy-only usage) — only bail out
179
+ // when BOTH are absent so aggregateProxyStats still runs for proxy-only dirs.
180
+ let proxyFiles = [];
181
+ try {
182
+ proxyFiles = readdirSync(projectDir).filter(f => f.startsWith('proxy_') && f.endsWith('.jsonl'));
183
+ } catch { /* unreadable project dir → nothing to aggregate from it either */ }
184
+ if (sessionIds.length === 0 && proxyFiles.length === 0) return;
172
185
 
173
186
  const filesStats = {};
174
187
  const topModels = {};
@@ -234,8 +247,9 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
234
247
  }
235
248
  }
236
249
 
237
- // No parsable session yet (dirs without journals) — keep whatever exists.
238
- if (Object.keys(filesStats).length === 0) return;
250
+ // No parsable session yet (dirs without journals) — keep whatever exists,
251
+ // unless proxy shards are present (they alone justify a stats write).
252
+ if (Object.keys(filesStats).length === 0 && proxyFiles.length === 0) return;
239
253
 
240
254
  // 计算全局汇总
241
255
  let totalRequests = 0;
@@ -256,6 +270,12 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
256
270
  totalCacheCreation += f.summary.cache_creation_input_tokens;
257
271
  }
258
272
 
273
+ // 代理重试明细聚合:扫描 proxy_YYYY-MM-DD.jsonl 文件,聚合成 proxyStats。
274
+ // 与 token 统计同文件同 worker,避免新建独立 worker。明细文件按天分片,聚合逻辑在
275
+ // proxy-stats.js:aggregateRecords(纯函数,可单测)。增量优化:仅对 size+mtime 变化的分片
276
+ // 重新解析,未变化分片复用 worker 内存缓存(mergeProxyFileCache + _proxyCacheByDir)。
277
+ const proxyStats = aggregateProxyStats(projectDir);
278
+
259
279
  const stats = {
260
280
  _v: STATS_VERSION,
261
281
  project: projectName,
@@ -272,6 +292,7 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
272
292
  cache_read_input_tokens: totalCacheRead,
273
293
  cache_creation_input_tokens: totalCacheCreation,
274
294
  },
295
+ proxyStats,
275
296
  };
276
297
 
277
298
  try {
@@ -281,6 +302,99 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
281
302
  }
282
303
  }
283
304
 
305
+ /**
306
+ * 扫描项目目录下所有 proxy_YYYY-MM-DD.jsonl 明细文件,聚合成 proxyStats 结构。
307
+ * 增量优化:proxy 明细按天分片且仅追加——文件 size+mtime 未变即内容未变,复用上次解析的 records,
308
+ * 仅对变化/新增文件重新 readFileSync+解析(仿会话 JSONL 的 files[f] 缓存)。
309
+ *
310
+ * Review P1 fix: the per-file records cache is WORKER-MEMORY ONLY
311
+ * (_proxyCacheByDir). It was previously persisted as `proxyStatsFiles` inside
312
+ * `<project>.json`, which duplicated every raw record on disk (unbounded
313
+ * growth) and made every stats update rewrite — and every /api/proxy-stats
314
+ * read re-parse — the full history. A worker restart just re-parses the
315
+ * shards once; the JSON now carries only the aggregated `proxyStats`.
316
+ *
317
+ * @param {string} projectDir
318
+ * @returns {object} aggregated proxyStats
319
+ */
320
+ const _proxyCacheByDir = new Map(); // projectDir → per-file cache {name:{records,size,lastModified}}
321
+
322
+ function aggregateProxyStats(projectDir) {
323
+ let proxyFiles = [];
324
+ try {
325
+ proxyFiles = readdirSync(projectDir)
326
+ .filter(f => f.startsWith('proxy_') && f.endsWith('.jsonl'))
327
+ .sort();
328
+ } catch {
329
+ return aggregateRecords([]);
330
+ }
331
+ if (proxyFiles.length === 0) return aggregateRecords([]);
332
+
333
+ // Optional retention (review P2): per-day shards otherwise grow without
334
+ // bound and every one participates in aggregation forever. Opt-in via
335
+ // CCV_PROXY_STATS_RETAIN_DAYS=N — shards dated older than N days ago are
336
+ // deleted before aggregation (and drop out of the memory cache, which is
337
+ // rebuilt from the surviving file list). Deliberately OFF by default:
338
+ // silently deleting a user's detail history is a policy the user must
339
+ // choose, so without the env var nothing is ever removed.
340
+ const retainDays = parseInt(process.env.CCV_PROXY_STATS_RETAIN_DAYS, 10);
341
+ if (Number.isFinite(retainDays) && retainDays > 0) {
342
+ const cutoff = new Date(Date.now() - retainDays * 86400000);
343
+ const cutoffStr = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}-${String(cutoff.getDate()).padStart(2, '0')}`;
344
+ proxyFiles = proxyFiles.filter((f) => {
345
+ const date = parseDailyFileName(f); // strict proxy_YYYY-MM-DD.jsonl; unparsable names are never deleted
346
+ if (!date || date >= cutoffStr) return true;
347
+ try { unlinkSync(join(projectDir, f)); } catch { /* deletion failed — keep it in this scan, retry next time */ }
348
+ return false;
349
+ });
350
+ if (proxyFiles.length === 0) return aggregateRecords([]);
351
+ }
352
+
353
+ const cachedFiles = _proxyCacheByDir.get(projectDir) || {};
354
+
355
+ const files = [];
356
+ for (const f of proxyFiles) {
357
+ const filePath = join(projectDir, f);
358
+ let stat;
359
+ try {
360
+ stat = statSync(filePath);
361
+ } catch {
362
+ continue; // stat 失败跳过此文件
363
+ }
364
+ const size = stat.size;
365
+ const lastModified = stat.mtime.toISOString();
366
+ const cached = cachedFiles[f];
367
+ const unchanged = cached && cached.size === size && cached.lastModified === lastModified;
368
+ if (unchanged) {
369
+ // 命中缓存:不传 records,由 mergeProxyFileCache 复用 cached.records
370
+ files.push({ name: f, size, lastModified });
371
+ } else {
372
+ // 变化/新增:重新解析记录(仅变化文件)
373
+ const records = [];
374
+ try {
375
+ const content = readFileSync(filePath, 'utf-8');
376
+ for (const line of content.split('\n')) {
377
+ const trimmed = line.trim();
378
+ if (!trimmed) continue;
379
+ try {
380
+ records.push(JSON.parse(trimmed));
381
+ } catch {
382
+ // 跳过无法解析的行
383
+ }
384
+ }
385
+ } catch {
386
+ // 文件读取失败:跳过(不进 cache,下次仍会重试)
387
+ continue;
388
+ }
389
+ files.push({ name: f, size, lastModified, records });
390
+ }
391
+ }
392
+
393
+ const { records: allRecords, cache } = mergeProxyFileCache({ existingCache: cachedFiles, files });
394
+ _proxyCacheByDir.set(projectDir, cache);
395
+ return aggregateRecords(allRecords);
396
+ }
397
+
284
398
  /**
285
399
  * 扫描 logDir 下所有项目目录,逐个生成统计
286
400
  */