cc-viewer 1.7.10 → 1.7.12

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/dist/index.html CHANGED
@@ -21,11 +21,11 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-ClB3znal.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-QCUTFwkw.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DADYo_zg.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-tF6HNoR6.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CFAmRN3Y.js">
28
- <link rel="stylesheet" crossorigin href="./assets/index-f4nPU7KM.css">
28
+ <link rel="stylesheet" crossorigin href="./assets/index-CGSyt5Ow.css">
29
29
  </head>
30
30
  <body>
31
31
  <!-- Pre-hydration splash: no SPA CSS yet — keep font-family in sync with --font-ui in src/global.css -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.7.10",
3
+ "version": "1.7.12",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server/i18n.js CHANGED
@@ -2975,11 +2975,6 @@ const i18nData = {
2975
2975
  "tr": "IM botu '{id}' zaten çalışıyor (pid {pid}); ikinci bir örnek başlatılması reddedildi.",
2976
2976
  "uk": "IM-бот '{id}' вже запущено (pid {pid}); запуск другого екземпляра відхилено."
2977
2977
  },
2978
- "server.proxyStats.starting": { "zh": "代理重试统计已启动", "en": "Proxy retry stats started", "zh-TW": "代理重試統計已啟動", "ko": "프록시 재시도 통계가 시작됨", "ja": "プロキシリトライ統計を開始しました", "de": "Proxy-Wiederholungsstatistiken gestartet", "es": "Estadísticas de reintentos de proxy iniciadas", "fr": "Statistiques de relances du proxy démarrées", "it": "Statistiche retry del proxy avviate", "da": "Proxy-genforsøgsstatistik startet", "pl": "Statystyki ponownych prób proxy uruchomione", "ru": "Статистика повторов прокси запущена", "ar": "بدأت إحصائيات إعادة محاولات الوكيل", "no": "Proxy-retry-statistikk startet", "pt-BR": "Estatísticas de retry do proxy iniciadas", "th": "เริ่มสถิติการลองใหม่ของพร็อกซีแล้ว", "tr": "Proxy yeniden deneme istatistikleri başladı", "uk": "Статистику повторів проксі запущено" },
2979
- "server.proxyStats.modeLabel": { "zh": "重试模式", "en": "Retry mode", "zh-TW": "重試模式", "ko": "재시도 모드", "ja": "リトライモード", "de": "Wiederholungsmodus", "es": "Modo de reintento", "fr": "Mode de relance", "it": "Modalità retry", "da": "Genforsøgstilstand", "pl": "Tryb ponownych prób", "ru": "Режим повторов", "ar": "وضع إعادة المحاولة", "no": "Retry-modus", "pt-BR": "Modo de retry", "th": "โหมดการลองใหม่", "tr": "Yeniden deneme modu", "uk": "Режим повторів" },
2980
- "server.proxyStats.disabled": { "zh": "代理重试已禁用", "en": "Proxy retry disabled", "zh-TW": "代理重試已停用", "ko": "프록시 재시도 비활성화됨", "ja": "プロキシリトライは無効です", "de": "Proxy-Wiederholung deaktiviert", "es": "Reintento de proxy deshabilitado", "fr": "Relance du proxy désactivée", "it": "Retry del proxy disattivato", "da": "Proxy-genforsøg deaktiveret", "pl": "Ponowne próby proxy wyłączone", "ru": "Повторы прокси отключены", "ar": "تم تعطيل إعادة محاولات الوكيل", "no": "Proxy-retry deaktivert", "pt-BR": "Retry do proxy desativado", "th": "ปิดการลองใหม่ของพร็อกซีแล้ว", "tr": "Proxy yeniden deneme devre dışı", "uk": "Повтори проксі вимкнено" },
2981
- "server.proxyStats.configLoaded": { "zh": "代理重试配置已加载", "en": "Proxy retry config loaded", "zh-TW": "代理重試設定已載入", "ko": "프록시 재시도 설정이 로드됨", "ja": "プロキシリトライ設定を読み込みました", "de": "Proxy-Wiederholungskonfiguration geladen", "es": "Configuración de reintento de proxy cargada", "fr": "Configuration de relance du proxy chargée", "it": "Configurazione retry del proxy caricata", "da": "Proxy-genforsøgskonfiguration indlæst", "pl": "Konfiguracja ponownych prób proxy załadowana", "ru": "Конфигурация повторов прокси загружена", "ar": "تم تحميل تكوين إعادة محاولات الوكيل", "no": "Proxy-retry-konfigurasjon lastet", "pt-BR": "Configuração de retry do proxy carregada", "th": "โหลดการตั้งค่าการลองใหม่ของพร็อกซีแล้ว", "tr": "Proxy yeniden deneme yapılandırması yüklendi", "uk": "Конфігурацію повторів проксі завантажено" },
2982
- "server.proxyStats.refreshed": { "zh": "代理统计已刷新", "en": "Proxy stats refreshed", "zh-TW": "代理統計已重新整理", "ko": "프록시 통계가 새로고침됨", "ja": "プロキシ統計を更新しました", "de": "Proxy-Statistiken aktualisiert", "es": "Estadísticas de proxy actualizadas", "fr": "Statistiques du proxy actualisées", "it": "Statistiche del proxy aggiornate", "da": "Proxy-statistik opdateret", "pl": "Statystyki proxy odświeżone", "ru": "Статистика прокси обновлена", "ar": "تم تحديث إحصائيات الوكيل", "no": "Proxy-statistikk oppdatert", "pt-BR": "Estatísticas do proxy atualizadas", "th": "รีเฟรชสถิติพร็อกซีแล้ว", "tr": "Proxy istatistikleri yenilendi", "uk": "Статистику проксі оновлено" }
2983
2978
  };
2984
2979
 
2985
2980
  // 将 { key: { lang: text } } 转换为 { lang: { key: text } }
@@ -11,7 +11,9 @@
11
11
  // is delegated to the caller (preferences.js POST /api/ccswitch-import).
12
12
  //
13
13
  // Design notes:
14
- // - Open SQLite read-only (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running
14
+ // - Open SQLite read-only by default (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running;
15
+ // escalate once to a read-write + query_only connection only when a malformed leftover journal forces
16
+ // SQLITE_READONLY recovery (see readCcSwitchProviders)
15
17
  // - Multi-path probing: each platform tries the standard Tauri dir first, then falls back to ~/.cc-switch/
16
18
  // - settings_config parsing is fully fault-tolerant: null / non-JSON / missing env are all skipped, never throws
17
19
  // - Profile ids get a ccs_ prefix to distinguish from user-created proxy_ prefixed ones; updates are idempotent (re-imports update, never duplicate)
@@ -155,18 +157,31 @@ export async function readCcSwitchProviders(dbPath) {
155
157
  // localized message off it (ui.proxy.ccswitchNodeUnsupported).
156
158
  return { profiles: [], error: 'node:sqlite unavailable on this runtime (requires Node >= 22.5 with --experimental-sqlite, or >= 23.4)' };
157
159
  }
158
- let db = null;
159
- try {
160
- // Read-only: cc-switch stays unaffected by our reads (no SQLITE_BUSY)
161
- db = new DatabaseSync(dbPath, { readOnly: true });
162
- } catch (err) {
163
- return { profiles: [], error: `cannot open db: ${err && err.message}` };
164
- }
165
- try {
160
+
161
+ // Open the db. Two contention modes from cc-switch to recover from:
162
+ // (1) A leftover MALFORMED cc-switch.db-journal (cc-switch killed mid-write, leaving a
163
+ // truncated/corrupt rollback journal) forces SQLite to discard it on the first page
164
+ // access — a write — which a read-only connection refuses with SQLITE_READONLY
165
+ // ("attempt to write a readonly database"). Escalate once to a read-write open guarded
166
+ // by PRAGMA query_only=ON (lets SQLite recover, blocks our own writes), then retry.
167
+ // Mirrors what cc-switch itself does on its next normal launch.
168
+ // (2) A running cc-switch holding an EXCLUSIVE write lock (its real contention mode — a
169
+ // valid hot journal under BEGIN EXCLUSIVE blocks even read-only readers, unlike
170
+ // BEGIN IMMEDIATE) makes the read-only connection's first query throw SQLITE_BUSY
171
+ // ("database is locked"). Retry once after a short backoff (cc-switch's writes are
172
+ // short — transient locks usually clear), and if still held surface a friendly message
173
+ // rather than the opaque `query failed: database is locked`.
174
+ const primaryErrCode = (e) => Number.isInteger(e && e.errcode) ? (e.errcode & 0xff) : null;
175
+ const isReadonlyErr = (e) => primaryErrCode(e) === 8
176
+ || /readonly/i.test(e && (e.errstr || e.message || ''));
177
+ const isBusyErr = (e) => [5, 6].includes(primaryErrCode(e))
178
+ || /locked|busy/i.test(e && (e.errstr || e.message || ''));
179
+
180
+ // Run the providers read against a given connection; returns the result object or throws.
181
+ const readProviders = (db) => {
166
182
  // providers table existence check. A query that throws here means the file is
167
183
  // unreadable as a SQLite db (corrupt / non-SQLite / truncated) — the real cause
168
- // must surface, not be masked as "table not found". Let it propagate to the
169
- // outer catch, which formats it as `query failed: <message>`.
184
+ // must surface, not be masked as "table not found".
170
185
  const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='providers'").get();
171
186
  if (!r) return { profiles: [], error: `providers table not found in ${dbPath}` };
172
187
 
@@ -184,10 +199,56 @@ export async function readCcSwitchProviders(dbPath) {
184
199
  }
185
200
  }
186
201
  return { profiles, currentId, error: null };
187
- } catch (err) {
188
- return { profiles: [], error: `query failed: ${err && err.message}` };
189
- } finally {
190
- try { db.close(); } catch { /* best effort */ }
202
+ };
203
+
204
+ // Open read-only and attempt the read. Two recovery paths:
205
+ // - SQLITE_BUSY ("database is locked"): cc-switch is running and holding an EXCLUSIVE write
206
+ // lock (its real contention mode — a valid hot journal under BEGIN EXCLUSIVE blocks even
207
+ // read-only readers, unlike BEGIN IMMEDIATE). The lock is transient (cc-switch mid-write),
208
+ // so retry once after a short backoff; if still held, surface a friendly, actionable
209
+ // message instead of the opaque `query failed: database is locked` wrapper.
210
+ // - SQLITE_READONLY ("attempt to write a readonly database"): a malformed leftover journal
211
+ // (cc-switch killed mid-write) forces SQLite to discard it — a write the read-only
212
+ // connection refuses. Escalate once to a read-write open guarded by PRAGMA query_only=ON
213
+ // (lets SQLite recover, blocks our own writes), then retry. Mirrors cc-switch's own
214
+ // next-launch recovery.
215
+ // Other errors propagate to the outer catch (formatted as `query failed: <message>`),
216
+ // preserving real-cause surfacing (e.g. "file is not a database").
217
+ const LOCKED_MSG = 'cc-switch db is locked (cc-switch may be running); retry shortly';
218
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
219
+
220
+ // Keep the BUSY retry and READONLY escalation as independent one-shot budgets. Every open,
221
+ // PRAGMA and query attempt passes through the same classifier, so a real state transition such
222
+ // as BUSY (cc-switch was writing) → READONLY (it crashed and left a malformed journal) can use
223
+ // both recoveries in sequence instead of escaping through a nested retry catch.
224
+ let useRecoveryConnection = false;
225
+ let busyRetried = false;
226
+ while (true) {
227
+ let db = null;
228
+ let phase = 'open';
229
+ try {
230
+ db = useRecoveryConnection
231
+ ? new DatabaseSync(dbPath)
232
+ : new DatabaseSync(dbPath, { readOnly: true });
233
+ phase = 'query';
234
+ if (useRecoveryConnection) db.exec('PRAGMA query_only = ON');
235
+ return readProviders(db);
236
+ } catch (err) {
237
+ if (isBusyErr(err)) {
238
+ if (busyRetried) return { profiles: [], error: LOCKED_MSG };
239
+ busyRetried = true;
240
+ await sleep(200);
241
+ continue;
242
+ }
243
+ if (isReadonlyErr(err) && !useRecoveryConnection) {
244
+ useRecoveryConnection = true;
245
+ continue;
246
+ }
247
+ const prefix = phase === 'open' ? 'cannot open db' : 'query failed';
248
+ return { profiles: [], error: `${prefix}: ${err && err.message}` };
249
+ } finally {
250
+ try { db && db.close(); } catch { /* best effort */ }
251
+ }
191
252
  }
192
253
  }
193
254
 
@@ -27,6 +27,29 @@ export function parseContextSizeSuffix(modelName) {
27
27
  return m[2].toLowerCase() === 'm' ? num * 1000000 : num * 1000;
28
28
  }
29
29
 
30
+ /**
31
+ * Resolve the model name to use for context-window classification (血条窗口判定专用).
32
+ *
33
+ * Precedence differs from getEffectiveModel (response-first) on one deliberate
34
+ * point: an EXPLICIT [Nk]/[Nm] suffix on the REQUEST model (`body.model`, which
35
+ * carries the user's hot-switch config / model selector intent) is authoritative
36
+ * and must NOT be overridden by the upstream response. Upstream APIs normalize
37
+ * the response `model` — e.g. hot-switching to `k3[1m]` makes Moonshot return
38
+ * `response.body.model: "k3"`, stripping the [1m] marker; a response-first read
39
+ * would then misclassify the window (bare k3 vs the configured 1M). So: request
40
+ * suffix wins; otherwise fall back to the response model, then the request name.
41
+ *
42
+ * @param {object|null|undefined} request log entry with body / response
43
+ * @returns {string|null}
44
+ */
45
+ export function getCalibrationModel(request) {
46
+ const reqModel = request?.body?.model;
47
+ if (typeof reqModel === 'string' && parseContextSizeSuffix(reqModel) != null) return reqModel;
48
+ const respModel = request?.response?.body?.model;
49
+ if (typeof respModel === 'string' && respModel) return respModel;
50
+ return (typeof reqModel === 'string' && reqModel) ? reqModel : null;
51
+ }
52
+
30
53
  // 模型家族 → 窗口档位表(有序,首条命中)。后缀解析在表外先行(见 getModelMaxTokens)。
31
54
  const MODEL_CONTEXT_SIZES = [
32
55
  // haiku 全系 200K,显式置于一切 1M 默认之前(claude-haiku-4-5 等)
@@ -48,6 +71,10 @@ const MODEL_CONTEXT_SIZES = [
48
71
  { match: /gpt-4o|o1|o3|o4/i, tokens: 128000 },
49
72
  { match: /gpt-4/i, tokens: 128000 },
50
73
  { match: /gpt-3/i, tokens: 16000 },
74
+ // Kimi 家族精确档:k2.x/k3 等带 kimi/moonshot 前缀的 → 256K;裸 'k3'(无前缀,
75
+ // 代理直连时的简写 model 名)→ 256K 精确档但 classifyContextWindow 不升 1M
76
+ // (见该函数的家族特判,裸 k3 归 200K 桶,超量由 adaptContextWindow 纠偏)。
77
+ { match: /kimi|moonshot|^k3$/i, tokens: 256000 },
51
78
  // deepseek-v4 defaults to 1M; placed before generic /deepseek/ so the
52
79
  // first-match-wins loop picks it up before falling through to 128K.
53
80
  { match: /deepseek-v4/i, tokens: 1000000 },
@@ -74,12 +101,19 @@ export function getModelMaxTokens(modelName) {
74
101
  * 不变量:只返回 1000000 或 200000(resolveCalibrationTokens 依赖此不变量)。
75
102
  * 裸 '1m' 子串(无方括号,如 deepseek-v3-1m)→ 1M 的宽松规则仅限本分类器,
76
103
  * 刻意不进 getModelMaxTokens(后者面向精确档位)。128K/16K 档归入 200K 桶。
104
+ * Kimi 家族特判:kimi/moonshot 前缀型号(k2.x/k3,真实窗口 256K)归 1M 桶 ——
105
+ * 避免会话中段从 200K 重标定到 256K/1M 的跳变;代价是相对真实 256K 上限
106
+ * 长期低估(约 4 倍刻度),可接受。裸 'k3' 同样归 1M:代理热切换到
107
+ * 'k3[1m]' 时上游会把响应 model 归一化成裸 'k3'(剥掉 [1m] 后缀),
108
+ * response-first 解析读到裸 'k3' 若归 200K 桶会与请求侧 1M 判定分裂,
109
+ * 血条分母错成 200K;且裸 'k3' 本就是 k3[1m] 的 1M 形态被剥后缀的产物。
77
110
  * @param {string} modelName
78
111
  * @returns {1000000|200000}
79
112
  */
80
113
  export function classifyContextWindow(modelName) {
81
114
  if (!modelName || typeof modelName !== 'string') return 200000;
82
115
  if (modelName.toLowerCase().includes('1m')) return 1000000;
116
+ if (/kimi|moonshot|^k3$/i.test(modelName)) return 1000000;
83
117
  return getModelMaxTokens(modelName) >= 1000000 ? 1000000 : 200000;
84
118
  }
85
119
 
@@ -88,7 +122,9 @@ export function classifyContextWindow(modelName) {
88
122
  * 一个真正的 200K 模型,其输入上下文(input + cache_creation + cache_read)物理上不可能
89
123
  * 超过 200K —— 超了 API 直接拒收。所以一旦真实输入用量越过 200K 还被判成 200K,必然是
90
124
  * model 名识别错了(误判),此时自动升到 1M,免得血条卡死在 100%、百分比与真实进度脱节。
91
- * 仅做 200K→1M 这一个方向的纠偏;其余判定(1M、各家 200K 真值等)一律原样返回。
125
+ * One-way upgrades only: 200K→1M and 256K→1M (the kimi exact tier used by the
126
+ * server-side SSE path); every other classification (1M, 128K/16K tiers, true
127
+ * 200K values) is returned unchanged — 128K is deliberately never promoted.
92
128
  * 注意:usedContextTokens 必须是"输入侧"用量(sumUsageInputTokens,不含 output_tokens),
93
129
  * 否则大输出会误触发。
94
130
  * @param {number} classifiedTokens classifyContextWindow / getModelMaxTokens 的结果
@@ -97,6 +133,7 @@ export function classifyContextWindow(modelName) {
97
133
  */
98
134
  export function adaptContextWindow(classifiedTokens, usedContextTokens) {
99
135
  if (classifiedTokens === 200000 && usedContextTokens > 200000) return 1000000;
136
+ if (classifiedTokens === 256000 && usedContextTokens > 256000) return 1000000;
100
137
  return classifiedTokens;
101
138
  }
102
139
 
@@ -2,7 +2,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { getClaudeConfigDir } from '../../findcc.js';
5
- import { getModelMaxTokens, adaptContextWindow, sumUsageInputTokens, sumUsageContextTokens } from './context-rules.js';
5
+ import { getModelMaxTokens, adaptContextWindow, sumUsageInputTokens, sumUsageContextTokens, getCalibrationModel } from './context-rules.js';
6
6
 
7
7
  export const CONTEXT_WINDOW_FILE = join(getClaudeConfigDir(), 'context-window.json');
8
8
  export const CLAUDE_SETTINGS_FILE = join(getClaudeConfigDir(), 'settings.json');
@@ -43,10 +43,25 @@ export function readModelContextSize() {
43
43
  /**
44
44
  * Get context size for a given API model name (e.g. 'claude-opus-4-6-20250514').
45
45
  * Uses startup cache to avoid re-reading the file.
46
- * @param {string} apiModelName - model name from req.body.model
46
+ * Accepts either a bare model-name string (legacy path) or a full log entry.
47
+ * Entry input resolves the model via getCalibrationModel (context-rules.js):
48
+ * an explicit [Nk]/[Nm] suffix on the REQUEST model wins (the user's hot-switch
49
+ * config intent, e.g. k3[1m]); otherwise the upstream response.body.model is
50
+ * authoritative. The startup cache is request-side static info, stale after a
51
+ * hot-switch, so entry resolution skips it and goes straight to the family
52
+ * rules table. String input keeps legacy cache-first behavior unchanged.
53
+ * @param {string|object} modelOrEntry - model name, or log entry with body/response
47
54
  * @returns {number} context window size in tokens
48
55
  */
49
- export function getContextSizeForModel(apiModelName) {
56
+ export function getContextSizeForModel(modelOrEntry) {
57
+ const isEntry = modelOrEntry !== null && typeof modelOrEntry === 'object';
58
+ // Entry input: calibration-aware resolution (request [Nk]/[Nm] suffix wins,
59
+ // else response model). Authoritative over the stale startup cache.
60
+ if (isEntry) {
61
+ const model = getCalibrationModel(modelOrEntry);
62
+ return model ? getModelMaxTokens(model) : (_startupContextSize || 200000);
63
+ }
64
+ const apiModelName = modelOrEntry;
50
65
  if (!apiModelName) return _startupContextSize || 200000;
51
66
  const lower = apiModelName.toLowerCase();
52
67
  // Extract base: 'claude-opus-4-6-20250514' → 'opus-4-6'
@@ -56,7 +71,7 @@ export function getContextSizeForModel(apiModelName) {
56
71
  return _startupContextSize;
57
72
  }
58
73
  // 完整档位表见 server/lib/context-rules.js(与前端同源;含 haiku/旧 opus/3-opus 200K、
59
- // deepseek-v4 1M、gpt/deepseek 等三方档位,默认 200K)
74
+ // deepseek-v4 1M、kimi/moonshot 256K、gpt/deepseek 等三方档位,默认 200K)
60
75
  return getModelMaxTokens(apiModelName);
61
76
  }
62
77
 
@@ -156,7 +156,7 @@ export function processWatchedEntry(parsed, ctx) {
156
156
  if (cached) sendEventToClients(clients, 'kv_cache_content', cached);
157
157
  const usage = parsed.response?.body?.usage;
158
158
  if (usage) {
159
- const contextSize = getContextSizeForModel(parsed.body?.model);
159
+ const contextSize = getContextSizeForModel(parsed);
160
160
  const cwData = buildContextWindowEvent(usage, contextSize);
161
161
  if (cwData) sendEventToClients(clients, 'context_window', cwData);
162
162
  }
@@ -17,6 +17,7 @@
17
17
  // - race/stagger use AbortController; cancelled requests must be released correctly.
18
18
  import { resolveProfileModel } from './interceptor-core.js';
19
19
  import { readFileSync, existsSync } from 'node:fs';
20
+ import { reportSwallowed } from './error-report.js';
20
21
 
21
22
  // ── Configuration ─────────────────────────────────────────────────
22
23
 
@@ -162,7 +163,11 @@ export function resolveRetryConfig(env = process.env, options = {}) {
162
163
  const fileRaw = JSON.parse(readFileSync(_retryConfigPath, 'utf-8'));
163
164
  Object.assign(cfg, validateRetryConfig(fileRaw));
164
165
  }
165
- } catch { /* file missing/corrupt → use env only, don't block */ }
166
+ } catch (err) {
167
+ // retry-config.json written by the UI is corrupt/unreadable → fall back to env.
168
+ // Not fatal, but a silent swallow would hide a config the user believes is active.
169
+ reportSwallowed('proxyRetry.load-config-file', err);
170
+ }
166
171
  }
167
172
 
168
173
  return cfg;
@@ -219,7 +224,10 @@ export function isStreamResponse(response) {
219
224
  try {
220
225
  const ct = response?.headers?.get?.('content-type') || '';
221
226
  return typeof ct === 'string' && ct.toLowerCase().includes('text/event-stream');
222
- } catch {
227
+ } catch (err) {
228
+ // A misbehaving header object would make us misclassify the response, which
229
+ // changes retry behavior (streaming 200 is never retried). Surface it.
230
+ reportSwallowed('proxyRetry.is-stream-response', err);
223
231
  return false;
224
232
  }
225
233
  }
@@ -233,7 +241,10 @@ export function extractModel(body) {
233
241
  const s = typeof body === 'string' ? body : body.toString('utf-8');
234
242
  const obj = JSON.parse(s);
235
243
  return typeof obj.model === 'string' ? obj.model : '';
236
- } catch {
244
+ } catch (err) {
245
+ // Unparseable body → stats detail record loses the model field. Surface it
246
+ // so a regression in request-body handling isn't hidden behind empty models.
247
+ reportSwallowed('proxyRetry.extract-model', err);
237
248
  return '';
238
249
  }
239
250
  }
@@ -253,7 +264,11 @@ export function applyModelReplacement(body, profile) {
253
264
  if (!target) return body;
254
265
  obj.model = target;
255
266
  return JSON.stringify(obj);
256
- } catch {
267
+ } catch (err) {
268
+ // JSON.parse failure means model replacement silently no-ops; the request
269
+ // still goes out with the original (un-replaced) model. Surface it so the
270
+ // mismatch between configured replacement and actual body isn't silent.
271
+ reportSwallowed('proxyRetry.apply-model-replacement', err);
257
272
  return body;
258
273
  }
259
274
  }
@@ -299,13 +314,67 @@ function computeWaitMs(status, retryAfterHeader, cfg) {
299
314
 
300
315
  // ── Single fetch wrapper ─────────────────────────────────────────
301
316
 
317
+ /**
318
+ * Attaches a streaming-idle watchdog to a streaming response body.
319
+ *
320
+ * Why: connectTimeoutMs only bounds time-to-HEADERS; once headers arrive the
321
+ * connect timer is cleared and the retry loop breaks (streaming 200 is never
322
+ * retried — retry-before-first-byte strategy). If the upstream then stalls
323
+ * (200 headers but no body chunk ever arrives — a hung upstream), the piped
324
+ * response would hang indefinitely, pinning the client socket and the upstream
325
+ * socket until the client gives up. streamIdleTimeoutMs bounds the max gap
326
+ * between two chunks; exceeding it errors the body so proxy.js's pipeline
327
+ * surfaces the stall instead of hanging.
328
+ *
329
+ * Implemented as a TransformStream pass-through so response.body stays a valid
330
+ * ReadableStream (Readable.fromWeb in proxy.js keeps working): each enqueued
331
+ * chunk resets the timer; a stalled stream fires the timer, which calls
332
+ * controller.error(), aborting the fetch's underlying body and breaking the
333
+ * pipeline.
334
+ *
335
+ * @param {ReadableStream} body original streaming body
336
+ * @param {number} idleMs max gap between chunks (0 = disabled)
337
+ * @param {AbortSignal} signal external signal (race/stagger loser cancel + client disconnect)
338
+ * @returns {ReadableStream} watched body (same chunks, bounded idle)
339
+ */
340
+ function applyStreamIdleWatchdog(body, idleMs, signal) {
341
+ if (!body || typeof body?.pipeThrough !== 'function') return body;
342
+ if (!idleMs || idleMs <= 0) return body;
343
+ let timer = null;
344
+ let aborted = false;
345
+ const arm = () => {
346
+ if (timer) clearTimeout(timer);
347
+ timer = setTimeout(() => {
348
+ aborted = true;
349
+ controller.error(new Error(`proxy stream idle timeout (${idleMs}ms)`));
350
+ }, idleMs);
351
+ };
352
+ let controller;
353
+ const transform = new TransformStream({
354
+ start(ctl) { controller = ctl; arm(); if (signal) signal.addEventListener('abort', disarm, { once: true }); },
355
+ transform(chunk, ctl) {
356
+ if (aborted) return; // already errored — drop late chunks
357
+ ctl.enqueue(chunk);
358
+ arm(); // reset on each chunk
359
+ },
360
+ flush() { disarm(); },
361
+ cancel() { disarm(); },
362
+ });
363
+ function disarm() { if (timer) { clearTimeout(timer); timer = null; } }
364
+ // teeThrough keeps our transform in the path; pipeThrough returns the readable end.
365
+ // Only pass signal when present — pipeThrough rejects a null/undefined signal.
366
+ return signal
367
+ ? body.pipeThrough(transform, { signal })
368
+ : body.pipeThrough(transform);
369
+ }
370
+
302
371
  /**
303
372
  * Executes a single fetch request with the x-cc-viewer-trace header + network proxy dispatcher.
304
373
  * Returns the raw Response. Does not throw (on network errors returns { __networkError: true, status: 0 }).
305
374
  *
306
375
  * @param {string} url full URL
307
376
  * @param {object} fetchOptions method/headers/body
308
- * @param {object} ctx { dispatcher, connectTimeoutMs, signal }
377
+ * @param {object} ctx { dispatcher, connectTimeoutMs, streamIdleTimeoutMs, signal }
309
378
  */
310
379
  async function singleFetch(url, fetchOptions, ctx) {
311
380
  const opts = {
@@ -342,10 +411,25 @@ async function singleFetch(url, fetchOptions, ctx) {
342
411
 
343
412
  try {
344
413
  const response = await fetch(url, opts);
414
+ // Streaming responses: attach the idle watchdog so a hung body (headers in,
415
+ // no chunks) breaks within streamIdleTimeoutMs instead of pinning sockets.
416
+ // connectTimeoutMs already cleared below can't help — it only bound headers.
417
+ if (response?.body && ctx.streamIdleTimeoutMs > 0 && isStreamResponse(response)) {
418
+ const watched = applyStreamIdleWatchdog(response.body, ctx.streamIdleTimeoutMs, ctx.signal);
419
+ return new Response(watched, {
420
+ status: response.status,
421
+ statusText: response.statusText,
422
+ headers: response.headers,
423
+ });
424
+ }
345
425
  return response;
346
426
  } catch (err) {
347
427
  // Network error/timeout/cancellation → return a pseudo response; status=0 indicates an error
348
428
  const aborted = ctx.signal?.aborted || timeoutCtl?.signal.aborted;
429
+ // Aborts are expected (race loser cancellation, client disconnect, connect
430
+ // timeout) — not diagnostic. Only surface genuine network errors so a
431
+ // failing upstream isn't hidden behind status=0 pseudo-responses.
432
+ if (err && !aborted) reportSwallowed('proxyRetry.single-fetch', err);
349
433
  return {
350
434
  __networkError: true,
351
435
  __aborted: !!aborted,
@@ -416,7 +500,17 @@ export async function executeRequest({ url, fetchOptions, retryConfig, ctx }) {
416
500
  // headers well past 10s — so with retry disabled we must not introduce a new
417
501
  // failure mode. The timeout applies only when a retry mode is active.
418
502
  const effectiveConnectTimeoutMs = cfg.mode === 'off' ? 0 : cfg.connectTimeoutMs;
419
- const commonCtx = { dispatcher, connectTimeoutMs: effectiveConnectTimeoutMs };
503
+ // streamIdleTimeoutMs is gated to retry modes only (NOT off), mirroring the
504
+ // connectTimeoutMs off-exclusion above. The watchdog wraps response.body in a
505
+ // TransformStream, but the interceptor (server/interceptor.js) already
506
+ // reconstructs response.body via getReader() + a new ReadableStream for
507
+ // logging/live-streaming; under concurrent load the watchdog's pipeThrough
508
+ // races that reconstruction and surfaces as a spurious `fetch failed` →
509
+ // status 0 → 502. off mode is the legacy pass-through path (no retry), so the
510
+ // watchdog's value (bound idle on a hung stream) is marginal here and the
511
+ // interceptor already observes the stream — serial/race/stagger keep the guard.
512
+ const effectiveStreamIdleMs = cfg.mode === 'off' ? 0 : (cfg.streamIdleTimeoutMs > 0 ? cfg.streamIdleTimeoutMs : 0);
513
+ const commonCtx = { dispatcher, connectTimeoutMs: effectiveConnectTimeoutMs, streamIdleTimeoutMs: effectiveStreamIdleMs };
420
514
 
421
515
  if (cfg.mode === 'off' || cfg.mode === 'serial') {
422
516
  // off / serial: serial retry. off = no retry (break on any status); serial = controlled by maxRetries (0=infinite, capped by deadline)
@@ -177,11 +177,16 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
177
177
  // Proxy retry shards (proxy_YYYY-MM-DD.jsonl) live at the project top level
178
178
  // and can exist without any v2 session (proxy-only usage) — only bail out
179
179
  // when BOTH are absent so aggregateProxyStats still runs for proxy-only dirs.
180
- let proxyFiles = [];
180
+ // Existence-only check: the full sorted file list is read once inside
181
+ // aggregateProxyStats (called below), so here we just need to know whether
182
+ // ANY proxy shard exists — short-circuit avoids a redundant full readdir+filter.
183
+ let hasProxyFiles = false;
181
184
  try {
182
- proxyFiles = readdirSync(projectDir).filter(f => f.startsWith('proxy_') && f.endsWith('.jsonl'));
185
+ for (const f of readdirSync(projectDir)) {
186
+ if (f.startsWith('proxy_') && f.endsWith('.jsonl')) { hasProxyFiles = true; break; }
187
+ }
183
188
  } catch { /* unreadable project dir → nothing to aggregate from it either */ }
184
- if (sessionIds.length === 0 && proxyFiles.length === 0) return;
189
+ if (sessionIds.length === 0 && !hasProxyFiles) return;
185
190
 
186
191
  const filesStats = {};
187
192
  const topModels = {};
@@ -249,7 +254,7 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
249
254
 
250
255
  // No parsable session yet (dirs without journals) — keep whatever exists,
251
256
  // unless proxy shards are present (they alone justify a stats write).
252
- if (Object.keys(filesStats).length === 0 && proxyFiles.length === 0) return;
257
+ if (Object.keys(filesStats).length === 0 && !hasProxyFiles) return;
253
258
 
254
259
  // 计算全局汇总
255
260
  let totalRequests = 0;
@@ -12,6 +12,9 @@ import { listV1Files, listConvertibleProjects, readConvertState } from './conver
12
12
  /** Pending v1 files + bytes of ONE project dir. */
13
13
  function pendingOf(projectDir) {
14
14
  const state = readConvertState(projectDir);
15
+ // Migration already completed — don't re-prompt, even if v1 files grew
16
+ // (dual-write captures new entries in v2).
17
+ if (state && state.status === 'done') return { files: 0, totalBytes: 0 };
15
18
  const doneAtSize = new Map(
16
19
  (state && Array.isArray(state.files) ? state.files : [])
17
20
  .filter((f) => f && f.done)
@@ -320,7 +320,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
320
320
  if (!latestContextWindow) {
321
321
  const usage = entry.response?.body?.usage;
322
322
  if (usage) {
323
- const contextSize = getContextSizeForModel(entry.body?.model);
323
+ const contextSize = getContextSizeForModel(entry);
324
324
  const cw = buildContextWindowEvent(usage, contextSize);
325
325
  if (cw) latestContextWindow = cw;
326
326
  }
@@ -7,6 +7,10 @@
7
7
  * server-reported model in `response.body.model` (authoritative under proxy
8
8
  * hot-switch) over the client-supplied `body.model`. Returns null when both
9
9
  * are missing — callers should fall back to a sensible default.
10
+ *
11
+ * KEEP IN SYNC: server/lib/context-watcher.js getContextSizeForModel reuses
12
+ * this precedence for its entry path — changing the priority here must be
13
+ * mirrored there (and vice versa).
10
14
  */
11
15
  export function getEffectiveModel(request) {
12
16
  return request?.response?.body?.model || request?.body?.model || null;
@@ -20,8 +20,9 @@ export {
20
20
  sumCacheCreationTokens,
21
21
  sumUsageInputTokens,
22
22
  sumUsageContextTokens,
23
+ getCalibrationModel,
23
24
  } from '../../server/lib/context-rules.js';
24
- import { classifyContextWindow, adaptContextWindow } from '../../server/lib/context-rules.js';
25
+ import { classifyContextWindow, adaptContextWindow, getCalibrationModel } from '../../server/lib/context-rules.js';
25
26
 
26
27
  // getEffectiveModel moved to ./effectiveModel.js (pure, node-testable — sessionMerge/sessionManager
27
28
  // import it without helpers' Vite-only svg imports); re-exported here to keep import paths stable.
@@ -57,7 +58,9 @@ const CALIBRATION_TOKEN_MAP = {
57
58
  export function resolveCalibrationTokens(calibrationModel, lastMainAgent, projectModelHint = null) {
58
59
  const direct = CALIBRATION_TOKEN_MAP[calibrationModel];
59
60
  if (direct) return direct;
60
- const lastModel = lastMainAgent ? getEffectiveModel(lastMainAgent) : null;
61
+ // 校准用 getCalibrationModel:请求名带显式 [Nk]/[Nm] 后缀时优先(用户热切换配置的
62
+ // 1M 意图),不被上游响应归一化(如 k3[1m]→裸 k3)覆盖;其余回退 response-first。
63
+ const lastModel = lastMainAgent ? getCalibrationModel(lastMainAgent) : null;
61
64
  // 优先用真实 mainAgent 信号;haiku 一律视为 init ping 噪声,跳过
62
65
  if (typeof lastModel === 'string' && lastModel && !/haiku/i.test(lastModel)) {
63
66
  return classifyContextWindow(lastModel);
@@ -334,7 +337,7 @@ const MODEL_PROVIDERS = [
334
337
  match: /kimi|moonshot|^k3$/i,
335
338
  name: 'Kimi',
336
339
  color: 'var(--bg-model-avatar)',
337
- svg: '<svg t="1771495664798" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6649" width="200" height="200"><path d="M731.062857 590.262857v318.317714h-148.589714V443.977143a146.285714 146.285714 0 0 1-146.285714 146.285714l-214.491429-0.036571v318.354285H73.142857V165.814857h148.553143v275.858286h180.882286l119.771428-275.858286h167.753143l-67.84 156.269714a255.524571 255.524571 0 0 1-106.678857 119.588572h66.925714a148.553143 148.553143 0 0 1 148.516572 148.589714z m120.758857-473.965714a99.035429 99.035429 0 0 1 0 198.070857h-99.035428V215.332571a99.035429 99.035429 0 0 1 99.035428-99.035428z" fill="currentColor" p-id="6650"></path></svg>',
340
+ svg: '<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M932.096 0a82.048 82.048 0 1 1 0 164.096h-72.352a9.568 9.568 0 0 1-9.664-9.632V82.016A82.048 82.048 0 0 1 932.096 0z" fill="#1783FF"></path><path d="M472.064 477.856l309.76-307.2c5.888-5.792 2.56-17.472-4.96-17.472h-166.72a7.008 7.008 0 0 0-5.056 2.112L271.456 486.24c-5.12 5.12-12.8 0.576-12.8-7.68V162.976c0-5.376-3.584-9.792-7.936-9.792H135.936c-4.352 0-7.936 4.416-7.936 9.792V843.52c0 5.44 3.584 9.824 7.936 9.824h114.784c4.352 0 7.936-4.288 7.936-9.824v-138.656c0-2.912 1.024-5.728 2.88-7.616l103.424-102.656a6.72 6.72 0 0 1 8.8-0.928l276.64 203.616a328.64 328.64 0 0 0 147.296 54.688c4.608 0.512 8.544-4 8.544-9.824V711.68c0-4.96-2.912-9.056-7.008-9.632a214.528 214.528 0 0 1-86.432-34.496l-239.456-173.472c-5.024-3.328-5.632-11.936-1.184-16.224h-0.096z" fill="currentColor"></path></svg>',
338
341
  },
339
342
  {
340
343
  match: /glm|chatglm/i,