cc-viewer 1.7.3 → 1.7.5

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 (94) hide show
  1. package/cli.js +88 -17
  2. package/dist/assets/App-CCDFxj11.css +1 -0
  3. package/dist/assets/App-CcLQlV-O.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-D_dewIIc.js → MdxEditorPanel-CAqHHy1X.js} +1 -1
  5. package/dist/assets/Mobile-BxRd4DkS.js +1 -0
  6. package/dist/assets/UnifiedProxyRetryPage-CFz4vOH5.css +1 -0
  7. package/dist/assets/UnifiedProxyRetryPage-DjL3NQXO.js +1 -0
  8. package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-yiNE5Ckw.js} +1 -1
  9. package/dist/assets/{arc-D4iFrlhb.js → arc-M-eBYYbN.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-CtweeLKi.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-DCVQ-go_.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-C7JB9PqP.js} +1 -1
  13. package/dist/assets/{channel-B3VthT0m.js → channel-BnzX9zBS.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-dVKiUtqe.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-CQjD9-fD.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6-BZuKI6JQ.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-sk9g7szp.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-CGL9Lo51.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DaN0AdQD.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-CNEAHnZV.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.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-BDhNj8rq.js → cose-bilkent-S5V4N54A-DH4_YiGi.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-RqusStYI.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-8haVbBnt.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CsrJNUhT.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-1luPOM38.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-Dad1XR3W.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-Br6-kbp9.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-DuX1mbA7.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-BL_XQ958.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DzvW5FEs.js} +1 -1
  35. package/dist/assets/{graph-BR2KmES9.js → graph-CbUmu0Oe.js} +1 -1
  36. package/dist/assets/{index-D5tSeMU7.js → index-B3-JUU7J.js} +1 -1
  37. package/dist/assets/{index--wgRW_OB.js → index-BgW_nP_0.js} +1 -1
  38. package/dist/assets/{index-OWzSX2T8.js → index-CzPHj3iF.js} +1 -1
  39. package/dist/assets/{index-DuZQ6FqR.js → index-DDlAMOiV.js} +1 -1
  40. package/dist/assets/{index-DidP9FCD.js → index-DJ0AQwus.js} +2 -2
  41. package/dist/assets/{index-D5YruOKD.js → index-Do5xNTOT.js} +1 -1
  42. package/dist/assets/{index-C-be29ey.js → index-LjVy-Pzg.js} +1 -1
  43. package/dist/assets/{index-C16fiVhv.js → index-vImWXiMM.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-DGsgRji7.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-D3odpv9_.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Dj3NaOWF.js} +1 -1
  47. package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-GmVScWAV.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-GKjJaLwD.js} +1 -1
  49. package/dist/assets/{layout-DQl4aw09.js → layout-Bhk66H0F.js} +1 -1
  50. package/dist/assets/{linear-CLD3aPlK.js → linear-CLk9nvqn.js} +1 -1
  51. package/dist/assets/mermaid.core-DH-GfeNS.js +7 -0
  52. package/dist/assets/{min-_D86henJ.js → min-DQftOGfo.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-DgDD6Jbu.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-jy1yFaxA.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-BdvMwrSD.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-CtBOCtbk.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-DEFlQp-h.js} +1 -1
  58. package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
  59. package/dist/assets/seqResourceLoaders-D1y8V4z5.js +2 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-0F2R8Ky0.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-D4xOwcSW.js} +1 -1
  62. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-DSf_BSF8.js} +1 -1
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-B5cPasy_.js} +1 -1
  64. package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-DeqwrDxf.js} +2 -2
  65. package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-_NbrtdQc.js} +1 -1
  66. package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-DG0Nyxw5.js} +2 -2
  67. package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-B3HN--HO.js} +1 -1
  68. package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-BvrezPQx.js} +1 -1
  69. package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-a_ybZDST.js} +1 -1
  70. package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CKla3KF8.js} +1 -1
  71. package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-BKke2aih.js} +1 -1
  72. package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-CozWtcdf.js} +1 -1
  73. package/dist/index.html +4 -4
  74. package/package.json +1 -1
  75. package/server/i18n.js +100 -55
  76. package/server/interceptor.js +27 -1
  77. package/server/lib/ccswitch-import.js +251 -0
  78. package/server/lib/proxy-retry.js +716 -0
  79. package/server/lib/proxy-stats.js +436 -0
  80. package/server/lib/stats-worker.js +119 -5
  81. package/server/proxy.js +159 -12
  82. package/server/routes/preferences.js +184 -1
  83. package/server/routes/proxy-stats.js +95 -0
  84. package/server/server.js +43 -1
  85. package/src/utils/contentFilter.js +17 -1
  86. package/src/utils/isProxyMode.js +11 -0
  87. package/dist/assets/App-DRsuJXEw.js +0 -2
  88. package/dist/assets/App-dYPa5-df.css +0 -1
  89. package/dist/assets/Mobile-BIzb7vpA.js +0 -1
  90. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
  91. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
  92. package/dist/assets/clone-BQtvy9Ae.js +0 -1
  93. package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
  94. package/dist/assets/seqResourceLoaders-BtGpfGRW.js +0 -2
@@ -0,0 +1,716 @@
1
+ // Proxy Retry Engine — proxy retry core engine.
2
+ //
3
+ // Reimplements the proxy retry capability of llm-retry-proxy (Python) in Node.js within cc-viewer.
4
+ // Three modes: serial (serial retry) / race (request racing) / stagger (rolling race). off = no retry.
5
+ //
6
+ // Key integration constraints (see .omo/plans/proxy-retry-stats.md C1-C5):
7
+ // - The fetch called by the retry engine carries the `x-cc-viewer-trace` header and goes through the
8
+ // interceptor's recording branch (interceptor.js); each retry attempt is recorded to the session log
9
+ // (the data source of the network view). Model replacement is done by this engine via the pure
10
+ // function resolveProfileModel before the call; the interceptor runs resolveProfileModel again on the
11
+ // trace request — since the model in the body has already been replaced with the final value, the second
12
+ // resolve returns null (idempotent no-op), so no double replacement occurs. Writing one session log entry
13
+ // per retry attempt is expected: the network view can see every attempt.
14
+ // - Network proxy: fetchOptions still passes dispatcher = getProxyDispatcher(), ensuring the user's http_proxy is used.
15
+ // - Streaming responses: only read status + headers to decide whether to retry; never retry after the body has
16
+ // started being sent (retry-before-first-byte strategy).
17
+ // - race/stagger use AbortController; cancelled requests must be released correctly.
18
+ import { resolveProfileModel } from './interceptor-core.js';
19
+ import { readFileSync, existsSync } from 'node:fs';
20
+
21
+ // ── Configuration ─────────────────────────────────────────────────
22
+
23
+ // Runtime hot-swappable config file path. Injected by interceptor.js after _logDir initialization
24
+ // (setRetryConfigPath), mirroring the hot-swap pattern of PROFILE_PATH: UI changes config → writes this
25
+ // file → watchFile 1.5s triggers _loadRetryConfigState. Initially null: before the path is ready,
26
+ // resolveRetryConfig uses only env (backward compatible with the legacy ccv-retry.sh workflow).
27
+ let _retryConfigPath = null;
28
+ /** @param {string|null} p */
29
+ export function setRetryConfigPath(p) { _retryConfigPath = p; }
30
+
31
+ /**
32
+ * Default retry config. Aligned with the .env defaults of llm-retry-proxy.
33
+ * mode=off ensures backward compatibility (no retry, consistent with existing proxy.js behavior).
34
+ * Note: the "recommended" values from ccv-retry.sh are serial/maxConcurrent=4 (safest), which differ
35
+ * semantically from the code defaults here (off/10) — the UI "restore defaults" returns to the code
36
+ * defaults here (off = no retry), not to the script's recommended values.
37
+ */
38
+ export const DEFAULT_RETRY_CONFIG = {
39
+ mode: 'off', // 'off' | 'serial' | 'race' | 'stagger'
40
+ retryStatusCodes: [502, 503, 504, 529, 429],
41
+ retryIntervalMs: 1000, // retry interval for 503/502/504/529
42
+ retryInterval429Ms: 5000, // interval specific to 429
43
+ maxRetries: 60, // 0 = retry indefinitely until success (capped by maxRetryDurationMs total duration)
44
+ maxConcurrent: 10, // max in-flight requests for race/stagger
45
+ connectTimeoutMs: 10000, // upstream connection timeout
46
+ streamIdleTimeoutMs: 300000, // max gap between two data chunks in streaming (reserved field, not consumed in current version)
47
+ maxRetryDurationMs: 4 * 60 * 60 * 1000, // total retry duration cap per request (4 hours): safety net when maxRetries=0
48
+ // (infinite), prevents unattended tasks from hanging forever under sustained upstream congestion;
49
+ // explicit finite maxRetries is unaffected
50
+ };
51
+
52
+ const VALID_MODES = ['off', 'serial', 'race', 'stagger'];
53
+
54
+ /**
55
+ * Computes the adaptive backoff interval for consecutive 429s in stagger mode (exponential growth, capped at 64x).
56
+ * When 429s occur consecutively, progressively lengthen the resend interval to avoid continuously hammering an
57
+ * already rate-limited upstream; non-429 still uses retryIntervalMs. If the upstream returns Retry-After,
58
+ * parseRetryAfter already uses it preferentially in computeWaitMs; this function is only used when there is no Retry-After.
59
+ */
60
+ function compute429BackoffMs(cfg, consecutive429) {
61
+ const base = cfg.retryInterval429Ms > 0 ? cfg.retryInterval429Ms : 5000;
62
+ const exp = Math.min(consecutive429, 6); // cap at 2^6 = 64x, avoid unbounded growth
63
+ return base * (2 ** exp);
64
+ }
65
+
66
+ /**
67
+ * Validates and normalizes a single raw config field. Shared by resolveRetryConfig (reading env) and
68
+ * retryConfigPost (reading UI body), ensuring both entry points share identical validation logic (single source
69
+ * of truth) to avoid drift. Returns the normalized value; returns undefined when invalid (callers using spread
70
+ * ignore undefined fields, equivalent to falling back to the default).
71
+ *
72
+ * @param {string} key field name
73
+ * @param {any} raw raw value
74
+ * @returns {any} normalized value or undefined
75
+ */
76
+ export function validateRetryField(key, raw) {
77
+ switch (key) {
78
+ case 'mode':
79
+ return typeof raw === 'string' && VALID_MODES.includes(raw.toLowerCase()) ? raw.toLowerCase() : undefined;
80
+ case 'retryStatusCodes': {
81
+ if (!Array.isArray(raw)) return undefined;
82
+ const codes = raw
83
+ .map(s => (typeof s === 'number' ? s : parseInt(String(s).trim(), 10)))
84
+ .filter(n => Number.isFinite(n) && n > 0);
85
+ return codes.length ? codes : undefined;
86
+ }
87
+ case 'retryIntervalMs':
88
+ case 'retryInterval429Ms':
89
+ case 'connectTimeoutMs':
90
+ case 'streamIdleTimeoutMs':
91
+ case 'maxRetryDurationMs': {
92
+ const n = typeof raw === 'number' ? raw : parseFloat(raw);
93
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
94
+ }
95
+ case 'maxRetries': {
96
+ const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
97
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
98
+ }
99
+ case 'maxConcurrent': {
100
+ const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
101
+ return Number.isFinite(n) && n >= 1 ? n : undefined;
102
+ }
103
+ default:
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validates an entire raw config object, returning a normalized config containing only valid fields (can be
110
+ * merged directly into DEFAULT_RETRY_CONFIG).
111
+ * @param {object} raw
112
+ * @returns {object} normalized config (may be an empty object)
113
+ */
114
+ export function validateRetryConfig(raw) {
115
+ const out = {};
116
+ if (!raw || typeof raw !== 'object') return out;
117
+ for (const key of Object.keys(DEFAULT_RETRY_CONFIG)) {
118
+ if (!(key in raw)) continue;
119
+ const v = validateRetryField(key, raw[key]);
120
+ if (v !== undefined) out[key] = v;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Parses retry config from environment variables. Called by server.js at startup.
127
+ * Environment variable prefix CCV_PROXY_RETRY_* (connect/streamIdle use CCV_PROXY_CONNECT_TIMEOUT_MS /
128
+ * CCV_PROXY_STREAM_IDLE_TIMEOUT_MS, which were previously gaps, now filled in).
129
+ *
130
+ * @param {object} env environment variable object (defaults to process.env)
131
+ * @param {{ fileOverride?: boolean }} [options] when fileOverride=true, also overrides env with retry-config.json
132
+ * @returns {object} merged retry config
133
+ */
134
+ export function resolveRetryConfig(env = process.env, options = {}) {
135
+ const { fileOverride = false } = options;
136
+ const cfg = { ...DEFAULT_RETRY_CONFIG };
137
+
138
+ // env layer
139
+ const envRaw = {
140
+ mode: env.CCV_PROXY_RETRY_MODE,
141
+ retryStatusCodes: env.CCV_PROXY_RETRY_STATUS_CODES,
142
+ retryIntervalMs: env.CCV_PROXY_RETRY_INTERVAL_MS,
143
+ retryInterval429Ms: env.CCV_PROXY_RETRY_INTERVAL_429_MS,
144
+ maxRetries: env.CCV_PROXY_MAX_RETRIES,
145
+ maxConcurrent: env.CCV_PROXY_MAX_CONCURRENT,
146
+ connectTimeoutMs: env.CCV_PROXY_CONNECT_TIMEOUT_MS,
147
+ streamIdleTimeoutMs: env.CCV_PROXY_STREAM_IDLE_TIMEOUT_MS,
148
+ maxRetryDurationMs: env.CCV_PROXY_RETRY_DURATION_MS,
149
+ };
150
+ // retryStatusCodes is a comma-separated string in env; convert to array before passing to validateRetryField
151
+ if (typeof envRaw.retryStatusCodes === 'string' && envRaw.retryStatusCodes.trim()) {
152
+ envRaw.retryStatusCodes = envRaw.retryStatusCodes.split(',');
153
+ } else {
154
+ delete envRaw.retryStatusCodes;
155
+ }
156
+ Object.assign(cfg, validateRetryConfig(envRaw));
157
+
158
+ // File override layer (retry-config.json written by the UI takes precedence over env; env remains the startup default/fallback)
159
+ if (fileOverride && _retryConfigPath) {
160
+ try {
161
+ if (existsSync(_retryConfigPath)) {
162
+ const fileRaw = JSON.parse(readFileSync(_retryConfigPath, 'utf-8'));
163
+ Object.assign(cfg, validateRetryConfig(fileRaw));
164
+ }
165
+ } catch { /* file missing/corrupt → use env only, don't block */ }
166
+ }
167
+
168
+ return cfg;
169
+ }
170
+
171
+ /**
172
+ * Loads the currently effective retry config (env base + file override). Called by the watchFile callback
173
+ * and live binding consumers.
174
+ * @returns {object}
175
+ */
176
+ export function loadRetryConfig() {
177
+ return resolveRetryConfig(process.env, { fileOverride: true });
178
+ }
179
+
180
+ // ── Retry-After parsing ───────────────────────────────────────────
181
+
182
+ /**
183
+ * Parses the Retry-After header. Supports seconds ("120") and HTTP date ("Wed, 21 Oct 2026 07:28:00 GMT").
184
+ * @param {string|null|undefined} headerValue
185
+ * @returns {number|null} wait duration in milliseconds; null when unparseable
186
+ */
187
+ export function parseRetryAfter(headerValue) {
188
+ if (!headerValue || typeof headerValue !== 'string') return null;
189
+ const s = headerValue.trim();
190
+ if (!s) return null;
191
+ // pure number = seconds
192
+ if (/^\d+$/.test(s)) {
193
+ const secs = parseInt(s, 10);
194
+ return secs >= 0 ? secs * 1000 : null;
195
+ }
196
+ // HTTP date
197
+ const date = Date.parse(s);
198
+ if (Number.isFinite(date)) {
199
+ const diff = date - Date.now();
200
+ return diff > 0 ? diff : 0; // expired also returns 0 (retry immediately), distinct from null (unrecognized)
201
+ }
202
+ return null;
203
+ }
204
+
205
+ // ── Utilities ────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Determines whether a status code should trigger a retry.
209
+ */
210
+ export function shouldRetryStatus(status, retryStatusCodes) {
211
+ return retryStatusCodes.includes(status);
212
+ }
213
+
214
+ /**
215
+ * Determines whether a response is streaming (text/event-stream).
216
+ * Defensive: some mock/pseudo responses have headers that are not Headers instances (no .get method).
217
+ */
218
+ export function isStreamResponse(response) {
219
+ try {
220
+ const ct = response?.headers?.get?.('content-type') || '';
221
+ return typeof ct === 'string' && ct.toLowerCase().includes('text/event-stream');
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Parses the model field from the request body (Buffer/string). Returns '' when unparseable.
229
+ */
230
+ export function extractModel(body) {
231
+ if (!body) return '';
232
+ try {
233
+ const s = typeof body === 'string' ? body : body.toString('utf-8');
234
+ const obj = JSON.parse(s);
235
+ return typeof obj.model === 'string' ? obj.model : '';
236
+ } catch {
237
+ return '';
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Performs model replacement on the request body. Returns a new body (string) or the original body (nothing to replace).
243
+ * Reuses the pure function resolveProfileModel from interceptor-core.js.
244
+ */
245
+ export function applyModelReplacement(body, profile) {
246
+ if (!body || !profile) return body;
247
+ try {
248
+ const s = typeof body === 'string' ? body : body.toString('utf-8');
249
+ const obj = JSON.parse(s);
250
+ const oldModel = typeof obj.model === 'string' ? obj.model : '';
251
+ if (!oldModel) return body;
252
+ const target = resolveProfileModel(oldModel, profile);
253
+ if (!target) return body;
254
+ obj.model = target;
255
+ return JSON.stringify(obj);
256
+ } catch {
257
+ return body;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Abortable sleep: resolves early (never rejects) when the signal fires, so a
263
+ * client disconnect interrupts retry waits instead of parking the loop for the
264
+ * full interval (a large upstream Retry-After would otherwise pin an abandoned
265
+ * request for its whole duration).
266
+ */
267
+ function sleep(ms, signal) {
268
+ return new Promise(resolve => {
269
+ if (signal?.aborted) return resolve();
270
+ let timer = null;
271
+ const done = () => {
272
+ if (timer) clearTimeout(timer);
273
+ signal?.removeEventListener('abort', done);
274
+ resolve();
275
+ };
276
+ timer = setTimeout(done, ms);
277
+ signal?.addEventListener('abort', done, { once: true });
278
+ });
279
+ }
280
+
281
+ /** Never sleep past the total-duration deadline (0 = no deadline). */
282
+ function clampWaitToDeadline(wait, deadline) {
283
+ if (deadline > 0) return Math.max(0, Math.min(wait, deadline - Date.now()));
284
+ return wait;
285
+ }
286
+
287
+ /**
288
+ * Computes the wait in milliseconds until the next retry.
289
+ * 429 prefers Retry-After, then retryInterval429Ms; other status codes use retryIntervalMs.
290
+ */
291
+ function computeWaitMs(status, retryAfterHeader, cfg) {
292
+ if (status === 429) {
293
+ const ra = parseRetryAfter(retryAfterHeader);
294
+ if (ra !== null) return ra;
295
+ return cfg.retryInterval429Ms;
296
+ }
297
+ return cfg.retryIntervalMs;
298
+ }
299
+
300
+ // ── Single fetch wrapper ─────────────────────────────────────────
301
+
302
+ /**
303
+ * Executes a single fetch request with the x-cc-viewer-trace header + network proxy dispatcher.
304
+ * Returns the raw Response. Does not throw (on network errors returns { __networkError: true, status: 0 }).
305
+ *
306
+ * @param {string} url full URL
307
+ * @param {object} fetchOptions method/headers/body
308
+ * @param {object} ctx { dispatcher, connectTimeoutMs, signal }
309
+ */
310
+ async function singleFetch(url, fetchOptions, ctx) {
311
+ const opts = {
312
+ method: fetchOptions.method,
313
+ headers: { ...fetchOptions.headers },
314
+ };
315
+ // Add trace header so the interceptor records the request to the session log (network view data source).
316
+ // The interceptor performs model replacement (resolveProfileModel is idempotent, consistent with the replacement
317
+ // already done by the engine).
318
+ // Multiple retries → multiple records — this is expected: the network view can see each retry attempt.
319
+ opts.headers['x-cc-viewer-trace'] = 'true';
320
+ if (fetchOptions.body) opts.body = fetchOptions.body;
321
+ if (ctx.dispatcher) opts.dispatcher = ctx.dispatcher;
322
+
323
+ // Compose the fetch signal from the external signal (race/stagger loser
324
+ // cancellation + client disconnect) and the header-arrival timeout. The
325
+ // composition must stay live for the WHOLE response lifetime — the previous
326
+ // listener-bridge design detached the external signal the moment fetch
327
+ // resolved (headers in), which made a loser's already-streaming body
328
+ // un-cancellable: ctl.abort() no longer reached the signal fetch was holding.
329
+ // AbortSignal.any keeps the linkage for as long as the body exists; the
330
+ // finally below only clears the timeout timer, never the external linkage.
331
+ let timeoutTimer = null;
332
+ let timeoutCtl = null;
333
+ const signals = [];
334
+ if (ctx.signal) signals.push(ctx.signal);
335
+ if (ctx.connectTimeoutMs > 0) {
336
+ timeoutCtl = new AbortController();
337
+ timeoutTimer = setTimeout(() => timeoutCtl.abort(), ctx.connectTimeoutMs);
338
+ signals.push(timeoutCtl.signal);
339
+ }
340
+ if (signals.length === 1) opts.signal = signals[0];
341
+ else if (signals.length > 1) opts.signal = AbortSignal.any(signals);
342
+
343
+ try {
344
+ const response = await fetch(url, opts);
345
+ return response;
346
+ } catch (err) {
347
+ // Network error/timeout/cancellation → return a pseudo response; status=0 indicates an error
348
+ const aborted = ctx.signal?.aborted || timeoutCtl?.signal.aborted;
349
+ return {
350
+ __networkError: true,
351
+ __aborted: !!aborted,
352
+ status: 0,
353
+ statusText: aborted ? 'Aborted' : (err?.message || 'Network Error'),
354
+ headers: new Map(),
355
+ ok: false,
356
+ body: null,
357
+ text: async () => '',
358
+ json: async () => ({}),
359
+ };
360
+ } finally {
361
+ if (timeoutTimer) clearTimeout(timeoutTimer);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Best-effort release of a response body that will never be piped to the
367
+ * client (failed attempts, losers of a settled race, replaced lastFailed).
368
+ * Leaving these undrained keeps the upstream socket out of undici's pool and
369
+ * pins memory for the life of the stream.
370
+ */
371
+ function discardBody(response) {
372
+ try { response?.body?.cancel?.()?.catch?.(() => {}); } catch { /* best effort */ }
373
+ }
374
+
375
+ // ── executeRequest ────────────────────────────────────────────────
376
+
377
+ /**
378
+ * Executes a request with retries. Returns { response, attempts, retryCodes, durationMs, finalStatus, upstreamStatus, succeeded }.
379
+ *
380
+ * @param {object} params
381
+ * @param {string} params.url full upstream URL
382
+ * @param {object} params.fetchOptions { method, headers, body }
383
+ * @param {object} params.retryConfig retry config
384
+ * @param {object} params.ctx { dispatcher, profile } network proxy dispatcher + model replacement profile
385
+ * @returns {Promise<object>}
386
+ */
387
+ export async function executeRequest({ url, fetchOptions, retryConfig, ctx }) {
388
+ const cfg = { ...DEFAULT_RETRY_CONFIG, ...(retryConfig || {}) };
389
+ const dispatcher = ctx?.dispatcher || null;
390
+ const profile = ctx?.profile || null;
391
+ // Client-disconnect signal from the proxy: when it fires, every in-flight
392
+ // attempt is aborted and no further attempts/waits are scheduled — a request
393
+ // whose client is gone must not keep billing the upstream for up to
394
+ // maxRetryDurationMs.
395
+ const clientSignal = ctx?.signal || null;
396
+
397
+ // Model replacement (one-time, done before retries; all retries use the same body)
398
+ let finalBody = fetchOptions.body;
399
+ if (finalBody && profile) {
400
+ finalBody = applyModelReplacement(finalBody, profile);
401
+ }
402
+ const finalFetchOptions = { ...fetchOptions, body: finalBody };
403
+
404
+ const startTime = Date.now();
405
+ const deadline = startTime + (cfg.maxRetryDurationMs > 0 ? cfg.maxRetryDurationMs : 0);
406
+ const retryCodes = [];
407
+ let attempts = 0;
408
+ let lastResponse = null;
409
+ // Real last upstream response status (distinct from finalStatus: in race/stagger full-failure fallback,
410
+ // the latter was once hard-coded to 503)
411
+ let upstreamStatus = 0;
412
+
413
+ // Backward-compat guarantee for mode 'off': the legacy proxy path awaited
414
+ // fetch with NO timeout at all (undici's own default only). connectTimeoutMs
415
+ // actually bounds time-to-HEADERS, and non-streaming completions can hold
416
+ // headers well past 10s — so with retry disabled we must not introduce a new
417
+ // failure mode. The timeout applies only when a retry mode is active.
418
+ const effectiveConnectTimeoutMs = cfg.mode === 'off' ? 0 : cfg.connectTimeoutMs;
419
+ const commonCtx = { dispatcher, connectTimeoutMs: effectiveConnectTimeoutMs };
420
+
421
+ if (cfg.mode === 'off' || cfg.mode === 'serial') {
422
+ // off / serial: serial retry. off = no retry (break on any status); serial = controlled by maxRetries (0=infinite, capped by deadline)
423
+ const isOff = cfg.mode === 'off';
424
+ const effectiveMax = isOff ? 0 : cfg.maxRetries;
425
+ while (true) {
426
+ // Total duration fallback: when maxRetries=0 (infinite), prevent permanent hang under sustained upstream congestion
427
+ if (deadline > 0 && Date.now() >= deadline) break;
428
+ if (clientSignal?.aborted) break;
429
+
430
+ attempts++;
431
+ lastResponse = await singleFetch(url, finalFetchOptions, { ...commonCtx, signal: clientSignal });
432
+ upstreamStatus = lastResponse.status;
433
+
434
+ // Streaming request special handling: after getting the response, first check the status
435
+ const status = lastResponse.status;
436
+ const isStream = lastResponse.headers && isStreamResponse(lastResponse);
437
+
438
+ // off mode: no retry on any status code, end immediately
439
+ if (isOff) break;
440
+
441
+ if (status === 0) {
442
+ // Network error
443
+ retryCodes.push(0);
444
+ } else if (!shouldRetryStatus(status, cfg.retryStatusCodes)) {
445
+ // No retry needed (success or non-retryable error code) → return
446
+ break;
447
+ }
448
+
449
+ // Retry needed
450
+ if (status !== 0) retryCodes.push(status);
451
+
452
+ // Streaming response has already started returning 200 → never retry (body may have been sent already)
453
+ if (isStream && status < 400) break;
454
+
455
+ // Reached the limit (effectiveMax=0 in serial mode = infinite, no break; off already broke above)
456
+ if (effectiveMax > 0 && attempts > effectiveMax) break;
457
+
458
+ // Release the failed response's body BEFORE waiting — holding an
459
+ // undrained body across the sleep pins the upstream socket for the
460
+ // whole interval.
461
+ discardBody(lastResponse);
462
+
463
+ // Wait (abortable: a client disconnect ends it early; clamped so a huge
464
+ // Retry-After can never sleep past the total-duration deadline)
465
+ const retryAfter = lastResponse.headers?.get?.('retry-after') || lastResponse.headers?.get?.('Retry-After');
466
+ const wait = clampWaitToDeadline(computeWaitMs(status, retryAfter, cfg), deadline);
467
+ if (wait > 0) await sleep(wait, clientSignal);
468
+ }
469
+ } else if (cfg.mode === 'race') {
470
+ const result = await raceMode({ url, fetchOptions: finalFetchOptions, cfg, commonCtx, deadline, clientSignal });
471
+ attempts = result.attempts;
472
+ retryCodes.push(...result.retryCodes);
473
+ lastResponse = result.response;
474
+ if (result.upstreamStatus !== undefined) upstreamStatus = result.upstreamStatus;
475
+ } else if (cfg.mode === 'stagger') {
476
+ const result = await staggerMode({ url, fetchOptions: finalFetchOptions, cfg, commonCtx, deadline, clientSignal });
477
+ attempts = result.attempts;
478
+ retryCodes.push(...result.retryCodes);
479
+ lastResponse = result.response;
480
+ if (result.upstreamStatus !== undefined) upstreamStatus = result.upstreamStatus;
481
+ } else {
482
+ // Unknown mode → fallback to single attempt
483
+ attempts = 1;
484
+ lastResponse = await singleFetch(url, finalFetchOptions, { ...commonCtx });
485
+ upstreamStatus = lastResponse.status;
486
+ }
487
+
488
+ const durationMs = Date.now() - startTime;
489
+ const finalStatus = lastResponse?.status || 0;
490
+ // serial/fallback: upstreamStatus was assigned the real value in the loop; race/stagger: result brings back the real last upstream status.
491
+ // If not set (theoretically impossible), fall back to finalStatus.
492
+ if (!upstreamStatus) upstreamStatus = finalStatus;
493
+ const succeeded = finalStatus > 0 && finalStatus < 400;
494
+ const retries = Math.max(0, attempts - 1);
495
+
496
+ return {
497
+ response: lastResponse,
498
+ attempts,
499
+ retries,
500
+ retryCodes,
501
+ durationMs,
502
+ finalStatus,
503
+ upstreamStatus,
504
+ succeeded,
505
+ };
506
+ }
507
+
508
+ // ── race mode: each round fans out N concurrent requests, first 200 wins ────
509
+
510
+ async function raceMode({ url, fetchOptions, cfg, commonCtx, deadline, clientSignal }) {
511
+ const retryCodes = [];
512
+ let attempts = 0;
513
+ let round = 0;
514
+ // Real last upstream response (returned on failure fallback, instead of hard-coded 503)
515
+ let lastFailed = null;
516
+ const maxRounds = cfg.maxRetries > 0 ? cfg.maxRetries : Infinity;
517
+
518
+ while (round < maxRounds) {
519
+ // Total duration fallback + client gone
520
+ if (deadline > 0 && Date.now() >= deadline) break;
521
+ if (clientSignal?.aborted) break;
522
+
523
+ round++;
524
+ // Hedged round: launch maxConcurrent attempts and resolve the round the
525
+ // moment ANY attempt succeeds — the winner must not wait for the slowest
526
+ // straggler (the old Promise.all design gated the round on the slowest
527
+ // header arrival and "aborted" losers only after they had already
528
+ // settled, i.e. never). Losers still pending are aborted immediately;
529
+ // late settlers after the win just have their bodies discarded.
530
+ const controllers = [];
531
+ const roundWinner = await new Promise((resolveRound) => {
532
+ let settled = 0;
533
+ let won = false;
534
+ for (let i = 0; i < cfg.maxConcurrent; i++) {
535
+ const ctl = new AbortController();
536
+ controllers.push(ctl);
537
+ const signal = clientSignal ? AbortSignal.any([clientSignal, ctl.signal]) : ctl.signal;
538
+ attempts++;
539
+ singleFetch(url, fetchOptions, { ...commonCtx, signal }).then((r) => {
540
+ settled++;
541
+ if (won) {
542
+ // A winner already streamed to the client — this attempt's body is unwanted.
543
+ discardBody(r);
544
+ return;
545
+ }
546
+ if (r.status > 0 && r.status < 400) {
547
+ won = true;
548
+ for (const c of controllers) {
549
+ if (c !== ctl) try { c.abort(); } catch { /* best effort */ }
550
+ }
551
+ resolveRound(r);
552
+ return;
553
+ }
554
+ // Failure: keep the latest REAL failed response for the final
555
+ // fallback (discarding the body of the one it replaces), count it.
556
+ if (r.status !== 0) {
557
+ retryCodes.push(r.status);
558
+ if (lastFailed) discardBody(lastFailed);
559
+ lastFailed = r;
560
+ } else {
561
+ retryCodes.push(0);
562
+ }
563
+ if (settled >= controllers.length) resolveRound(null);
564
+ });
565
+ }
566
+ });
567
+
568
+ if (roundWinner) {
569
+ if (lastFailed) discardBody(lastFailed); // fallback candidate no longer needed
570
+ return { response: roundWinner, attempts, retryCodes, upstreamStatus: roundWinner.status };
571
+ }
572
+ if (clientSignal?.aborted) break;
573
+
574
+ // All failed → wait then next round. Base the wait on the last real
575
+ // failure (a network-errored attempt has empty headers and status 0 and
576
+ // would silently drop an upstream Retry-After carried by a sibling 429).
577
+ const waitSrc = lastFailed;
578
+ const retryAfter = waitSrc?.headers?.get?.('retry-after') || waitSrc?.headers?.get?.('Retry-After');
579
+ const wait = clampWaitToDeadline(computeWaitMs(waitSrc ? waitSrc.status : 0, retryAfter, cfg), deadline);
580
+ if (wait > 0) await sleep(wait, clientSignal);
581
+ }
582
+
583
+ // Reached limit/timeout: return the last real failed response (preserving the real status code); fall back to 503 when there is no real failure
584
+ const response = lastFailed || lastFailedResponse();
585
+ return { response, attempts, retryCodes, upstreamStatus: response.status };
586
+ }
587
+
588
+ function lastFailedResponse() {
589
+ return { status: 503, statusText: 'Upstream Overloaded', headers: new Map(), ok: false, body: null, text: async () => 'Upstream Overloaded', json: async () => ({}) };
590
+ }
591
+
592
+ // ── stagger mode: send interleaved, cancel in-flight on any 200 ────────
593
+
594
+ async function staggerMode({ url, fetchOptions, cfg, commonCtx, deadline, clientSignal }) {
595
+ const retryCodes = [];
596
+ let attempts = 0;
597
+ const inflight = []; // { ctl, promise }
598
+ let resolved = null;
599
+ let lastFailed = null; // real last failed response (returned on fallback instead of hard-coded 503)
600
+ let totalAttempts = 0;
601
+ const maxTotal = cfg.maxRetries > 0 ? cfg.maxRetries : Infinity;
602
+ let consecutive429 = 0;
603
+
604
+ // Timeout/limit/resolved/client-gone → stop dispatching
605
+ const canLaunch = () => !resolved
606
+ && !clientSignal?.aborted
607
+ && inflight.length < cfg.maxConcurrent
608
+ && totalAttempts < maxTotal
609
+ && !(deadline > 0 && Date.now() >= deadline);
610
+
611
+ function launchOne() {
612
+ if (!canLaunch()) return false;
613
+ const ctl = new AbortController();
614
+ const signal = clientSignal ? AbortSignal.any([clientSignal, ctl.signal]) : ctl.signal;
615
+ const p = singleFetch(url, fetchOptions, { ...commonCtx, signal }).then(r => {
616
+ // Remove self from inflight
617
+ const idx = inflight.findIndex(x => x.ctl === ctl);
618
+ if (idx >= 0) inflight.splice(idx, 1);
619
+
620
+ // Single-winner latch: two attempts can land a 200 in the same tick.
621
+ // Without this guard the second would overwrite `resolved` with a
622
+ // response whose body the first winner's cleanup just aborted — the
623
+ // client would receive a truncated 200.
624
+ if (resolved) {
625
+ discardBody(r);
626
+ return;
627
+ }
628
+ if (r.status > 0 && r.status < 400) {
629
+ // Success → set resolved FIRST (canLaunch relies on it), then cancel all in-flight
630
+ resolved = { response: r, attempts: totalAttempts, retryCodes: [...retryCodes], upstreamStatus: r.status };
631
+ for (const x of inflight) try { x.ctl.abort(); } catch { /* best effort */ }
632
+ return;
633
+ }
634
+ // Failure: record the real failed response (keep only the newest body
635
+ // for the final fallback; drain the one it replaces)
636
+ if (r.status !== 0) {
637
+ retryCodes.push(r.status);
638
+ if (lastFailed) discardBody(lastFailed);
639
+ lastFailed = r;
640
+ } else {
641
+ retryCodes.push(0);
642
+ }
643
+
644
+ if (r.status === 429) {
645
+ // 429: after accumulating, the scheduler lengthens the next dispatch interval via adaptive backoff (no immediate refill)
646
+ consecutive429++;
647
+ } else {
648
+ consecutive429 = 0;
649
+ // Non-429 error → immediately dispatch a replacement (stagger semantics)
650
+ launchOne();
651
+ }
652
+ }).catch(() => {
653
+ const idx = inflight.findIndex(x => x.ctl === ctl);
654
+ if (idx >= 0) inflight.splice(idx, 1);
655
+ retryCodes.push(0);
656
+ });
657
+ inflight.push({ ctl, promise: p });
658
+ totalAttempts++;
659
+ attempts = totalAttempts;
660
+ return true;
661
+ }
662
+
663
+ // Current dispatch interval: adaptive backoff when 429s occur consecutively (exponential growth, avoids
664
+ // hammering a rate-limited upstream); otherwise uses retryIntervalMs
665
+ function currentStaggerInterval() {
666
+ if (consecutive429 > 0) return compute429BackoffMs(cfg, consecutive429);
667
+ return cfg.retryIntervalMs > 0 ? cfg.retryIntervalMs : 0;
668
+ }
669
+
670
+ // Unified interleaved scheduler: the first wave is also staggered by interval (no longer synchronously
671
+ // dispatching a full batch of maxConcurrent at once); subsequent refills reuse the same scheduler. When there
672
+ // is an inflight slot and the limit/timeout has not been reached, dispatch one, then schedule the next by interval.
673
+ // Dispatch the first immediately (ensures no idle wait); the rest are staggered by interval.
674
+ let staggerTimer = null;
675
+ const launch = () => {
676
+ if (resolved) return;
677
+ if (clientSignal?.aborted) return;
678
+ if (deadline > 0 && Date.now() >= deadline) return;
679
+ if (canLaunch()) launchOne();
680
+ const interval = currentStaggerInterval();
681
+ if (interval > 0) {
682
+ staggerTimer = setTimeout(launch, interval);
683
+ } else if (inflight.length < cfg.maxConcurrent && totalAttempts < maxTotal && !resolved) {
684
+ // interval=0: still need to keep filling (use a microtask to avoid synchronous infinite recursion)
685
+ staggerTimer = setTimeout(launch, 0);
686
+ }
687
+ };
688
+ // Dispatch the first immediately, starting the staggered loop
689
+ launch();
690
+
691
+ // Wait for resolved, all complete, client disconnect, or total duration expiry
692
+ await new Promise((resolve) => {
693
+ const check = () => {
694
+ if (resolved) { resolve(); return; }
695
+ if (clientSignal?.aborted && inflight.length === 0) { resolve(); return; }
696
+ if (deadline > 0 && Date.now() >= deadline) { resolve(); return; }
697
+ if (inflight.length === 0 && (totalAttempts >= maxTotal || !canLaunch())) { resolve(); return; }
698
+ setTimeout(check, 50);
699
+ };
700
+ // A client disconnect aborts every in-flight attempt right away (their
701
+ // settle handlers then drain via the aborted pseudo-responses).
702
+ clientSignal?.addEventListener('abort', () => {
703
+ for (const x of inflight) try { x.ctl.abort(); } catch { /* best effort */ }
704
+ }, { once: true });
705
+ check();
706
+ });
707
+
708
+ if (staggerTimer) clearTimeout(staggerTimer);
709
+ // Clean up any remaining in-flight
710
+ for (const x of inflight) try { x.ctl.abort(); } catch { /* best effort */ }
711
+
712
+ if (resolved) return resolved;
713
+ // Fallback: return the last real failed response (preserving the real status code); fall back to 503 when there is no real failure
714
+ const response = lastFailed || lastFailedResponse();
715
+ return { response, attempts, retryCodes, upstreamStatus: response.status };
716
+ }