dsh-vision-fallback 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,37 @@ The API key is resolved through the DSH **credentials** system (`~/.dsh/.credent
106
106
  - Observations are delivered as model-only surface replacements; your original image is never altered in the UI.
107
107
  - Image reads go through the DSH attachment service (sandbox/observation-policy aware); the vision request carries the official `attributionHeaders()`.
108
108
 
109
+ ## Usage records (usage.jsonl)
110
+
111
+ With `recordUsage` enabled, every real vision call (success or failure) appends one JSON line to
112
+ `<dshHome>/vision-fallback/usage.jsonl` (override via `usageLogPath` in the settings page), consumed by usage-dashboard.
113
+ Fields:
114
+
115
+ | Field | Meaning |
116
+ | --- | --- |
117
+ | `ts` | Call start time (epoch ms) |
118
+ | `durationMs` | Response latency of this call (ms) |
119
+ | `kind` | Always `"vision"` |
120
+ | `status` | `"ok"` success / `"error"` failure |
121
+ | `model` | Vision model name |
122
+ | `inputTokens` / `outputTokens` | Input / output tokens |
123
+ | `cacheReadTokens` | Cached input tokens served |
124
+ | `error` | Error message (failure entries only) |
125
+ | `imageName` / `mediaType` / `imageBytes` | Image filename / media type / byte size |
126
+ | `imageIndex` / `imageTotal` | This image's position / total images in the request |
127
+
128
+ Reusing a remembered observation (observations.json) does **not** append a line — this file counts real external vision calls only.
129
+
130
+ ### Observation cache semantics
131
+
132
+ Observations are keyed by **the image's occurrence in a session** (session id + message id):
133
+
134
+ - The same image at the **same message position** processed again (restart recovery, replay, compaction) → reused, no re-recognition;
135
+ - The same image at a **new position in the session** (new turn, main model asking to "look again") → **re-recognized** with fresh context;
136
+ - Observations are **not** shared across sessions.
137
+
138
+ The cache is capped at 256 entries (LRU eviction); failed results are never cached.
139
+
109
140
  ## Default vision route
110
141
 
111
142
  - Model: `mimo-v2.5` · Endpoint: `https://opencode.ai/zen/go/v1/chat/completions` · Credential: `OPENCODE_GO_API_KEY`
package/README.zh.md CHANGED
@@ -106,6 +106,37 @@ API key 通过 DSH **凭证系统**(`~/.dsh/.credentials.yaml`)解析,`pro
106
106
  - 观察结果以 model-only surface replacement 交付,界面中的原始图片永不被改写。
107
107
  - 图片读取走 DSH 附件服务(遵守沙箱与观察策略);视觉请求携带官方 `attributionHeaders()`。
108
108
 
109
+ ## 用量记录(usage.jsonl)
110
+
111
+ 开启 `recordUsage` 后,每次真实视觉调用(成功或失败)追加一行 JSON 到
112
+ `<dshHome>/vision-fallback/usage.jsonl`(可在设置页改 `usageLogPath`),供 usage-dashboard 统计。
113
+ 字段:
114
+
115
+ | 字段 | 含义 |
116
+ | --- | --- |
117
+ | `ts` | 调用发起时刻(毫秒时间戳) |
118
+ | `durationMs` | 本次调用响应耗时(毫秒) |
119
+ | `kind` | 固定 `"vision"` |
120
+ | `status` | `"ok"` 成功 / `"error"` 失败 |
121
+ | `model` | 视觉模型名 |
122
+ | `inputTokens` / `outputTokens` | 输入 / 输出 token 数 |
123
+ | `cacheReadTokens` | 命中缓存读取的 token 数 |
124
+ | `error` | 失败时的错误信息(仅失败条目) |
125
+ | `imageName` / `mediaType` / `imageBytes` | 图片文件名 / 媒体类型 / 字节数 |
126
+ | `imageIndex` / `imageTotal` | 该图在本次请求中的第几张 / 总张数 |
127
+
128
+ 复用已记忆的观察结果(observations.json)不会产生新条目——该文件统计的是真实外部视觉调用次数。
129
+
130
+ ### 观察缓存语义
131
+
132
+ 观察结果按**图片在某次会话中的出现位置**(会话 id + 消息 id)记录:
133
+
134
+ - 同一图片在**同一消息位置**被重复处理(重启恢复、重放、压缩)→ 直接复用,不重新识别;
135
+ - 同一图片在**会话的新位置**再次出现(新轮次、主模型"再仔细看看")→ **重新识别**,生成贴合当前上下文的新观察;
136
+ - 不同会话之间不共享观察结果。
137
+
138
+ 缓存上限 256 条(LRU 淘汰),失败结果不入缓存。
139
+
109
140
  ## 默认视觉路由
110
141
 
111
142
  - 模型:`mimo-v2.5` · 端点:`https://opencode.ai/zen/go/v1/chat/completions` · 凭据:`OPENCODE_GO_API_KEY`
package/lib/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * @module dsh-vision-fallback
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
- import { appendFileSync, mkdirSync } from "node:fs";
11
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
13
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
14
14
  import { attributionHeaders } from "@deepseek-ai/dsh-llm";
@@ -30,6 +30,13 @@ const DEFAULT_PROMPT = [
30
30
 
31
31
  const Config = z.object({
32
32
  enabled: z.boolean().default(true),
33
+ /**
34
+ * auto:仅当主模型不支持图片输入时才接管(默认)。主模型自带视觉
35
+ * (如 kimi-k3、mimo-v2.5)时图片原样交给它,不再转文字。
36
+ * always:无论主模型是否支持图片都转成文字(旧行为)。
37
+ * never:完全不接管(等于停用视觉桥;纯文本模型收图会照常报"模型不支持")。
38
+ */
39
+ mode: z.union(["auto", "always", "never"]).default("auto"),
33
40
  model: z.string().default("mimo-v2.5"),
34
41
  baseURL: z.string().default("https://opencode.ai/zen/go/v1"),
35
42
  apiKeyRef: z.string().role("credential-ref").default("OPENCODE_GO_API_KEY"),
@@ -154,6 +161,66 @@ function latestUserText(messages) {
154
161
  return "(用户本轮只提供了图片,没有附加文字问题)";
155
162
  }
156
163
 
164
+ /**
165
+ * 观察缓存的 key:图片在某次会话中的一次出现。
166
+ * 同一图片在同一会话的不同位置(不同消息)视为不同出现,需要重新识别;
167
+ * 同一位置被重复处理(重启恢复、重放、压缩)时才能复用。
168
+ * @param {object} attachmentRef 图片附件引用
169
+ * @param {object} cfg 插件配置
170
+ * @param {{sessionId?: string, messageId?: string}} [scope] 图片出现的会话位置
171
+ */
172
+ function observationKey(attachmentRef, cfg, scope) {
173
+ const attachmentId = attachmentRef?.attachmentId ?? JSON.stringify(attachmentRef);
174
+ return `${attachmentId}\0${scope?.sessionId ?? ""}\0${scope?.messageId ?? ""}\0${cfg.baseURL}\0${cfg.model}`;
175
+ }
176
+
177
+ const MAX_OBSERVATIONS = 256;
178
+
179
+ function rememberObservation(store, key, text) {
180
+ if (text.startsWith("【图片转换失败:")) return;
181
+ store.set(key, text);
182
+ if (store.size > MAX_OBSERVATIONS) store.delete(store.keys().next().value);
183
+ }
184
+
185
+ function observationStorePath(cfg) {
186
+ if (typeof cfg.usageLogPath === "string" && cfg.usageLogPath !== "") {
187
+ return join(dirname(cfg.usageLogPath), "observations.json");
188
+ }
189
+ if (cfg.recordUsage === true) {
190
+ return join(resolveDshHome(), "vision-fallback", "observations.json");
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ function loadObservations(cfg) {
196
+ const store = new Map();
197
+ const path = observationStorePath(cfg);
198
+ if (path === undefined) return store;
199
+ try {
200
+ const raw = JSON.parse(readFileSync(path, "utf8"));
201
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return store;
202
+ for (const [key, text] of Object.entries(raw)) {
203
+ if (typeof text === "string" && text !== "" && !text.startsWith("【图片转换失败:")) {
204
+ store.set(key, text);
205
+ }
206
+ }
207
+ } catch {
208
+ // 缓存文件不存在或损坏时按空缓存继续。
209
+ }
210
+ return store;
211
+ }
212
+
213
+ function persistObservations(cfg, store) {
214
+ const path = observationStorePath(cfg);
215
+ if (path === undefined) return;
216
+ try {
217
+ mkdirSync(dirname(path), { recursive: true });
218
+ writeFileSync(path, `${JSON.stringify(Object.fromEntries(store))}\n`, "utf8");
219
+ } catch (error) {
220
+ console.warn(`vision-fallback: 保存视觉观察缓存失败:${errorText(error)}`);
221
+ }
222
+ }
223
+
157
224
  function recentConversation(messages, cfg) {
158
225
  if (!cfg.includeRecentContext || cfg.contextMessages === 0 || cfg.contextMaxChars === 0) return "";
159
226
  const rows = [];
@@ -198,9 +265,20 @@ function errorText(error) {
198
265
 
199
266
  /**
200
267
  * 记录一次视觉调用用量(追加 JSONL),供 usage-dashboard 统计。
201
- * 失败仅告警,不影响主流程。
268
+ * 成功与失败都会记录;失败仅告警,不影响主流程。
269
+ * @param {object} cfg 插件配置
270
+ * @param {object} record 记录内容
271
+ * @param {number} [record.startedAt] 调用发起时刻(毫秒时间戳),作为 ts
272
+ * @param {object} [record.usage] OpenAI 风格用量对象(prompt_tokens 等)
273
+ * @param {"ok"|"error"} [record.status] 调用结果状态
274
+ * @param {string} [record.error] 失败时的错误信息(截断 500 字符)
275
+ * @param {string} [record.imageName] 图片文件名
276
+ * @param {string} [record.mediaType] 图片媒体类型(如 image/png)
277
+ * @param {number} [record.imageBytes] 图片字节数
278
+ * @param {number} [record.imageIndex] 本次请求中第几张图(从 1 开始)
279
+ * @param {number} [record.imageTotal] 本次请求图片总数
202
280
  */
203
- function recordVisionUsage(cfg, usage, startedAt) {
281
+ function recordVisionUsage(cfg, { startedAt, usage, status = "ok", error, imageName, mediaType, imageBytes, imageIndex, imageTotal }) {
204
282
  if (!cfg.recordUsage) return;
205
283
  try {
206
284
  const logPath = cfg.usageLogPath !== ""
@@ -209,12 +287,21 @@ function recordVisionUsage(cfg, usage, startedAt) {
209
287
  const u = usage ?? {};
210
288
  const entry = {
211
289
  ts: startedAt ?? Date.now(),
290
+ durationMs: Math.max(0, Date.now() - (startedAt ?? Date.now())),
291
+ kind: "vision",
292
+ status,
212
293
  model: cfg.model,
213
294
  inputTokens: u.prompt_tokens ?? 0,
214
295
  outputTokens: u.completion_tokens ?? 0,
215
296
  cacheReadTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
216
297
  cacheWriteTokens: 0
217
298
  };
299
+ if (error !== undefined) entry.error = String(error).slice(0, 500);
300
+ if (imageName !== undefined) entry.imageName = imageName;
301
+ if (mediaType !== undefined) entry.mediaType = mediaType;
302
+ if (imageBytes !== undefined) entry.imageBytes = imageBytes;
303
+ if (imageIndex !== undefined) entry.imageIndex = imageIndex;
304
+ if (imageTotal !== undefined) entry.imageTotal = imageTotal;
218
305
  mkdirSync(dirname(logPath), { recursive: true });
219
306
  appendFileSync(logPath, `${JSON.stringify(entry)}\n`, "utf8");
220
307
  } catch (error) {
@@ -229,7 +316,7 @@ function assertVisionConfig(cfg) {
229
316
  }
230
317
 
231
318
  function withImageCapability(info, cfg) {
232
- if (!cfg.enabled) return info;
319
+ if (!cfg.enabled || cfg.mode === "never") return info;
233
320
  const inputModalities = Array.isArray(info?.inputModalities) ? info.inputModalities : ["text"];
234
321
  if (inputModalities.includes("image")) return info;
235
322
  return { ...info, inputModalities: [...new Set([...inputModalities, "text", "image"])] };
@@ -250,7 +337,35 @@ async function resolveApiKey(credentials, ref) {
250
337
  throw new Error(`未找到 API Key:请在 DSH 凭证中配置 ${ref}`);
251
338
  }
252
339
 
253
- async function describeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal) {
340
+ async function describeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal, meta) {
341
+ const startedAt = Date.now();
342
+ const metaInfo = {
343
+ imageName: attachmentRef?.name,
344
+ imageIndex: meta?.imageIndex,
345
+ imageTotal: meta?.imageTotal
346
+ };
347
+ try {
348
+ const result = await performDescribeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal);
349
+ recordVisionUsage(cfg, {
350
+ startedAt,
351
+ ...metaInfo,
352
+ mediaType: result.mediaType,
353
+ imageBytes: result.imageBytes,
354
+ usage: result.usage
355
+ });
356
+ return result.text;
357
+ } catch (error) {
358
+ recordVisionUsage(cfg, {
359
+ startedAt,
360
+ ...metaInfo,
361
+ status: "error",
362
+ error: errorText(error)
363
+ });
364
+ throw error;
365
+ }
366
+ }
367
+
368
+ async function performDescribeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal) {
254
369
  const attachments = ctx.get("attachments");
255
370
  if (attachments === undefined || typeof attachments.readImage !== "function") {
256
371
  throw new Error("attachments 服务不可用,无法读取聊天图片");
@@ -316,29 +431,33 @@ async function describeImage(ctx, apiKey, cfg, attachmentRef, prompt, signal) {
316
431
  throw new Error(`视觉 API 返回 ${response.status} ${response.statusText}:${snippet}`);
317
432
  }
318
433
  const data = await response.json();
319
- recordVisionUsage(cfg, data?.usage, Date.now());
320
434
  const content = data?.choices?.[0]?.message?.content;
321
435
  const text = Array.isArray(content)
322
436
  ? content.map((part) => typeof part?.text === "string" ? part.text : "").join("")
323
437
  : typeof content === "string" ? content : "";
324
438
  if (text.trim() === "") throw new Error("视觉 API 返回空文本");
325
- return cfg.tagResult
326
- ? `【视觉观察:${cfg.model}${attachmentRef?.name ? ` · ${attachmentRef.name}` : ""}】\n${text.trim()}`
327
- : text.trim();
439
+ return {
440
+ text: cfg.tagResult
441
+ ? `【视觉观察:${cfg.model}${attachmentRef?.name ? ` · ${attachmentRef.name}` : ""}】\n${text.trim()}`
442
+ : text.trim(),
443
+ usage: data?.usage,
444
+ mediaType: stored.ref.mediaType,
445
+ imageBytes: bytes.length
446
+ };
328
447
  }
329
448
 
330
- async function rewriteContent(content, describe, counter) {
449
+ async function rewriteContent(content, describe, counter, messageId) {
331
450
  const rewritten = [];
332
451
  for (const block of content) {
333
452
  if (block?.type === "image") {
334
453
  counter.index += 1;
335
- rewritten.push({ type: "text", text: await describe(block.attachment, counter.index) });
454
+ rewritten.push({ type: "text", text: await describe(block.attachment, counter.index, messageId) });
336
455
  continue;
337
456
  }
338
457
  if (block?.type === "tool-result" && contentHasImage(block.content ?? [])) {
339
458
  rewritten.push({
340
459
  ...block,
341
- content: await rewriteContent(block.content ?? [], describe, counter)
460
+ content: await rewriteContent(block.content ?? [], describe, counter, messageId)
342
461
  });
343
462
  continue;
344
463
  }
@@ -360,8 +479,9 @@ async function rewriteMessages(messages, describe) {
360
479
  ...message,
361
480
  content: await rewriteContent(
362
481
  message.content ?? [],
363
- (attachmentRef, imageIndex) => describe(attachmentRef, imageIndex, imageTotal),
364
- counter
482
+ (attachmentRef, imageIndex, messageId) => describe(attachmentRef, imageIndex, imageTotal, messageId),
483
+ counter,
484
+ message.id
365
485
  )
366
486
  });
367
487
  }
@@ -386,31 +506,54 @@ class VisionFallbackController {
386
506
  this.ctx = ctx;
387
507
  this.current = current;
388
508
  this.cache = new Map();
509
+ this.observations = loadObservations(current());
389
510
  }
390
511
 
391
512
  modelInfo(info) {
392
513
  return withImageCapability(info, this.current());
393
514
  }
394
515
 
395
- async preprocess(messages, contextMessages, signal) {
516
+ hydrateObservations() {
517
+ for (const [key, text] of loadObservations(this.current())) {
518
+ if (!this.observations.has(key)) this.observations.set(key, text);
519
+ }
520
+ return this.observations;
521
+ }
522
+
523
+ recallObservation(attachmentRef, scope) {
524
+ return this.hydrateObservations().get(observationKey(attachmentRef, this.current(), scope));
525
+ }
526
+
527
+ rememberObservation(attachmentRef, text, scope) {
528
+ const cfg = this.current();
529
+ rememberObservation(this.observations, observationKey(attachmentRef, cfg, scope), text);
530
+ persistObservations(cfg, this.observations);
531
+ }
532
+
533
+ async preprocess(messages, contextMessages, signal, sessionId) {
396
534
  const cfg = this.current();
397
535
  assertVisionConfig(cfg);
398
536
  if (!cfg.enabled || !messages.some((message) => contentHasImage(message.content ?? []))) return messages;
399
537
 
400
538
  try {
401
539
  const apiKey = await resolveApiKey(this.ctx.get("credentials"), cfg.apiKeyRef);
402
- return await rewriteMessages(messages, async (attachmentRef, imageIndex, imageTotal) => {
540
+ return await rewriteMessages(messages, async (attachmentRef, imageIndex, imageTotal, messageId) => {
403
541
  const prompt = buildVisionPrompt(contextMessages, attachmentRef, imageIndex, imageTotal, cfg);
404
542
  const attachmentId = attachmentRef?.attachmentId ?? JSON.stringify(attachmentRef);
405
- const cacheKey = createHash("sha256").update(`${attachmentId}\0${prompt}\0${cfg.baseURL}\0${cfg.model}`).digest("hex");
543
+ const scope = { sessionId, messageId };
544
+ const remembered = this.recallObservation(attachmentRef, scope);
545
+ if (remembered !== undefined) return remembered;
546
+ const cacheKey = createHash("sha256").update(`${attachmentId}\0${scope.sessionId ?? ""}\0${scope.messageId ?? ""}\0${prompt}\0${cfg.baseURL}\0${cfg.model}`).digest("hex");
406
547
  let pending = this.cache.get(cacheKey);
407
548
  if (pending === undefined) {
408
- pending = describeImage(this.ctx, apiKey, cfg, attachmentRef, prompt, signal);
549
+ pending = describeImage(this.ctx, apiKey, cfg, attachmentRef, prompt, signal, { imageIndex, imageTotal });
409
550
  this.cache.set(cacheKey, pending);
410
551
  if (this.cache.size > 64) this.cache.delete(this.cache.keys().next().value);
411
552
  }
412
553
  try {
413
- return await pending;
554
+ const text = await pending;
555
+ this.rememberObservation(attachmentRef, text, scope);
556
+ return text;
414
557
  } catch (error) {
415
558
  this.cache.delete(cacheKey);
416
559
  return `【图片转换失败:${errorText(error)}】`;
@@ -461,6 +604,59 @@ class ReplacementCoordinator {
461
604
  }
462
605
  }
463
606
 
607
+ replacementFor(session, messageId) {
608
+ if (session === undefined || messageId === undefined) return undefined;
609
+ return this.pending.get(session)?.get(messageId);
610
+ }
611
+
612
+ flush(session) {
613
+ const replacements = this.pending.get(session);
614
+ if (replacements === undefined || replacements.size === 0) return;
615
+ const events = session.events;
616
+ const nodes = session.surface?.nodes;
617
+ if (!Array.isArray(events) && (events === undefined || typeof events !== "object")) return;
618
+ if (!Array.isArray(nodes)) return;
619
+ for (const seq of [...nodes]) {
620
+ const event = events[seq];
621
+ if (event === undefined) continue;
622
+ const message = typeof session.deriveEventMessage === "function"
623
+ ? session.deriveEventMessage(event)
624
+ : event.type === "user/message"
625
+ ? event.data
626
+ : event.type === "tool/result"
627
+ ? event.data?.message
628
+ : undefined;
629
+ if (message === undefined || message === null) continue;
630
+ const replacement = replacements.get(message.id);
631
+ if (replacement === undefined || !contentHasImage(message.content ?? [])) continue;
632
+ try {
633
+ if (event.type === "user/message") {
634
+ session.append("user/message", replacement, {
635
+ surfaceOp: { op: "replace", start: seq, end: seq },
636
+ sourceEventSeqs: [seq]
637
+ });
638
+ } else if (event.type === "tool/result") {
639
+ session.append("tool/result", {
640
+ ...event.data,
641
+ message: {
642
+ ...event.data.message,
643
+ content: replacement.content
644
+ }
645
+ }, {
646
+ surfaceOp: { op: "replace", start: seq, end: seq },
647
+ sourceEventSeqs: [seq]
648
+ });
649
+ } else {
650
+ continue;
651
+ }
652
+ replacements.delete(message.id);
653
+ } catch (error) {
654
+ this.logger.error("vision-fallback: 固化视觉观察失败");
655
+ this.logger.error(error);
656
+ }
657
+ }
658
+ }
659
+
464
660
  onSessionEvent(session, event) {
465
661
  if (event.type !== "user/message" || event.surfaceOp !== "append") return;
466
662
  const replacements = this.pending.get(session);
@@ -488,7 +684,11 @@ class ReplacementCoordinator {
488
684
  }
489
685
  }
490
686
 
687
+ /** 原始的 resolveModelInfo 实现(绑定原持有者,未被能力覆盖污染)。 */
688
+ let rawResolveModelInfo = null;
689
+
491
690
  function installCapabilityOverride(ctx, current) {
691
+ rawResolveModelInfo = ctx.llm.resolveModelInfo.bind(ctx.llm);
492
692
  const previous = ctx.llm.resolveModelInfo;
493
693
  const overridden = async function (provider, model, signal) {
494
694
  const info = await previous.call(this, provider, model, signal);
@@ -497,9 +697,60 @@ function installCapabilityOverride(ctx, current) {
497
697
  ctx.llm.resolveModelInfo = overridden;
498
698
  return () => {
499
699
  if (ctx.llm.resolveModelInfo === overridden) ctx.llm.resolveModelInfo = previous;
700
+ rawResolveModelInfo = null;
500
701
  };
501
702
  }
502
703
 
704
+ /**
705
+ * 用未被覆盖的 resolveModelInfo 查询主模型的真实图片能力。
706
+ * 解析失败保守返回 false(沿用接管路径,至少不会让请求挂掉)。
707
+ */
708
+ async function modelActuallySupportsImage(provider, model, signal) {
709
+ if (typeof provider !== "string" || typeof model !== "string" || provider === "" || model === "") return false;
710
+ if (typeof rawResolveModelInfo !== "function") return false;
711
+ try {
712
+ const info = await rawResolveModelInfo(provider, model, signal);
713
+ return Array.isArray(info?.inputModalities) && info.inputModalities.includes("image");
714
+ } catch {
715
+ return false;
716
+ }
717
+ }
718
+
719
+ function resolveSession(ctx, sessionId) {
720
+ if (sessionId === undefined) return undefined;
721
+ return (ctx.sessions ?? ctx.get?.("sessions"))?.get(sessionId);
722
+ }
723
+
724
+ function reuseExistingVisionMessages(messages, session, coordinator) {
725
+ const liveById = new Map();
726
+ if (session !== undefined && typeof session.deriveMessages === "function") {
727
+ for (const message of session.deriveMessages()) {
728
+ if (typeof message?.id === "string") liveById.set(message.id, message);
729
+ }
730
+ }
731
+ return messages.map((message) => {
732
+ const live = typeof message?.id === "string" ? liveById.get(message.id) : undefined;
733
+ if (live !== undefined && contentHasImage(message.content ?? []) && !contentHasImage(live.content ?? [])) {
734
+ return live;
735
+ }
736
+ return coordinator.replacementFor(session, message.id) ?? message;
737
+ });
738
+ }
739
+
740
+ function compactionImagePlaceholder(attachmentRef) {
741
+ const name = attachmentRef?.name ? attachmentRef.name : "未命名图片";
742
+ return `【图片:${name}。主模型已直接查看原图,压缩时不再重新识别,请依据后续对话中的结论。】`;
743
+ }
744
+
745
+ function replaceLeftoverCompactionImages(messages, controller, sessionId) {
746
+ const cfg = controller.current();
747
+ controller.hydrateObservations();
748
+ return rewriteMessages(messages, async (attachmentRef, imageIndex, imageTotal, messageId) => (
749
+ controller.observations.get(observationKey(attachmentRef, cfg, { sessionId, messageId }))
750
+ ?? compactionImagePlaceholder(attachmentRef)
751
+ ));
752
+ }
753
+
503
754
  function apply(ctx, config) {
504
755
  let current = () => config;
505
756
  let settingsService;
@@ -515,12 +766,51 @@ function apply(ctx, config) {
515
766
  ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
516
767
  const decision = await next();
517
768
  if (decision.kind === "reject" || signal.aborted) return decision;
769
+ const cfg = current();
770
+ if (!cfg.enabled || cfg.mode === "never") return decision;
771
+ // auto 模式:主模型自带视觉能力时让它直接看原图,不接管。
772
+ if (cfg.mode === "auto") {
773
+ const header = typeof agent?.session?.requestHeader === "function" ? agent.session.requestHeader() : undefined;
774
+ const provider = header?.config?.provider ?? agent?.options?.provider;
775
+ const model = header?.config?.model ?? agent?.options?.model;
776
+ if (await modelActuallySupportsImage(provider, model, signal)) return decision;
777
+ }
518
778
  const modelMessages = [...agent.session.deriveMessages(), ...decision.messages];
519
- const rewritten = await controller.preprocess(modelMessages, modelMessages, signal);
779
+ const rewritten = await controller.preprocess(modelMessages, modelMessages, signal, agent.session.id);
520
780
  replacements.stage(agent.session, modelMessages, rewritten);
781
+ replacements.flush(agent.session);
521
782
  return decision;
522
783
  });
523
784
 
785
+ ctx.on("llm/stream", (options, next) => (async function* () {
786
+ if (options.purpose !== "compaction") {
787
+ yield* next();
788
+ return;
789
+ }
790
+ const cfg = current();
791
+ if (!cfg.enabled || cfg.mode === "never") {
792
+ yield* next();
793
+ return;
794
+ }
795
+ if (!Array.isArray(options.messages) || !options.messages.some((message) => contentHasImage(message.content ?? []))) {
796
+ yield* next();
797
+ return;
798
+ }
799
+ if (cfg.mode === "auto" && await modelActuallySupportsImage(options.provider, options.model, options.signal)) {
800
+ yield* next();
801
+ return;
802
+ }
803
+ const projected = reuseExistingVisionMessages(
804
+ options.messages,
805
+ resolveSession(ctx, options.sessionId),
806
+ replacements
807
+ );
808
+ options.messages = projected.some((message) => contentHasImage(message.content ?? []))
809
+ ? await replaceLeftoverCompactionImages(projected, controller, options.sessionId)
810
+ : projected;
811
+ yield* next();
812
+ })(), { global: true, prepend: true });
813
+
524
814
  ctx.inject(["settings"], (sctx) => {
525
815
  settingsService = sctx.settings;
526
816
  sctx.effect(() => () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-fallback",
3
- "version": "0.6.0",
3
+ "version": "0.9.0",
4
4
  "description": "DSH 静默视觉增强:主模型照常选择,图片自动交给固定视觉模型后以隐藏上下文返回主模型。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,5 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
- import { readFile } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import test from "node:test";
4
6
  import {
5
7
  CONFIG_ROUTE,
@@ -49,6 +51,12 @@ function assistantMessage(id, text) {
49
51
  };
50
52
  }
51
53
 
54
+ async function consumeStream(stream) {
55
+ for await (const _chunk of stream) {}
56
+ }
57
+
58
+ async function* emptyStream() {}
59
+
52
60
  function createServiceContext() {
53
61
  return {
54
62
  get(service) {
@@ -325,6 +333,7 @@ test("插件注册静默预处理钩子而不注册新模型 adapter", () => {
325
333
  apply(ctx, config);
326
334
  assert.equal(typeof hooks.get("agent/pre-step"), "function");
327
335
  assert.equal(typeof hooks.get("session/event"), "function");
336
+ assert.equal(typeof hooks.get("llm/stream"), "function");
328
337
  assert.equal("registerAdapter" in ctx.llm, false);
329
338
  assert.equal("registerConfigurableProviders" in ctx.llm, false);
330
339
  assert.equal(typeof cleanup, "function");
@@ -487,3 +496,465 @@ test("浏览器端只显示静默视觉配置", async () => {
487
496
  globalThis.fetch = previousFetch;
488
497
  }
489
498
  });
499
+
500
+ test("auto 模式按主模型真实视觉能力决定是否接管", async () => {
501
+ const imageBlock = { type: "image", attachment: { attachmentId: "sha256:test", mediaType: "image/png" } };
502
+ let visionRequests = 0;
503
+ const previousFetch = globalThis.fetch;
504
+ globalThis.fetch = async () => {
505
+ visionRequests += 1;
506
+ return {
507
+ ok: true,
508
+ status: 200,
509
+ json: async () => ({ choices: [{ message: { content: "红色" } }] })
510
+ };
511
+ };
512
+
513
+ const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
514
+ const makeCtx = (realModalities) => {
515
+ let preStep;
516
+ const ctx = {
517
+ llm: {
518
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "x", name: "x", inputModalities: realModalities })
519
+ },
520
+ logger: { error() {} },
521
+ effect() {},
522
+ on(name, handler) {
523
+ if (name === "agent/pre-step") preStep = handler;
524
+ },
525
+ inject(deps, cb) {
526
+ if (deps[0] === "settings") {
527
+ cb({
528
+ settings: {
529
+ register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
530
+ },
531
+ effect() {}
532
+ });
533
+ } else {
534
+ cb({ webServer: { register: () => () => {} }, effect() {} });
535
+ }
536
+ },
537
+ get(name) {
538
+ if (name === "credentials") return { resolve: async () => ({ value: "sk-test" }) };
539
+ if (name === "attachments") return { readImage: async () => ({ data: Buffer.from("test"), ref: { mediaType: "image/png" } }) };
540
+ return undefined;
541
+ }
542
+ };
543
+ return { ctx, preStep: () => preStep };
544
+ };
545
+
546
+ // 1) 主模型自带视觉 → 不接管,视觉 API 不被调用
547
+ const vision = makeCtx(["text", "image"]);
548
+ apply(vision.ctx, { ...config, mode: "auto" });
549
+ const messages1 = [{ id: "m1", role: "user", source: { kind: "user" }, content: [{ type: "text", text: "看图" }, imageBlock] }];
550
+ const decision1 = { kind: "enter", messages: messages1 };
551
+ const out1 = await vision.preStep()(
552
+ {
553
+ agent: {
554
+ options: { provider: "opencode-go", model: "kimi-k3" },
555
+ session: {
556
+ requestHeader: () => ({ config: { provider: "opencode-go", model: "kimi-k3" } }),
557
+ deriveMessages: () => []
558
+ }
559
+ },
560
+ signal: makeSignal()
561
+ },
562
+ () => decision1
563
+ );
564
+ assert.equal(out1, decision1);
565
+ assert.equal(visionRequests, 0);
566
+
567
+ // 2) 纯文本主模型 → 接管,视觉 API 被调用一次
568
+ const text = makeCtx(["text"]);
569
+ apply(text.ctx, { ...config, mode: "auto" });
570
+ const messages2 = [{ id: "m2", role: "user", source: { kind: "user" }, content: [{ type: "text", text: "看图" }, imageBlock] }];
571
+ const decision2 = { kind: "enter", messages: messages2 };
572
+ await text.preStep()(
573
+ {
574
+ agent: {
575
+ options: { provider: "opencode-go", model: "deepseek-v4-flash" },
576
+ session: {
577
+ requestHeader: () => ({ config: { provider: "opencode-go", model: "deepseek-v4-flash" } }),
578
+ deriveMessages: () => []
579
+ }
580
+ },
581
+ signal: makeSignal()
582
+ },
583
+ () => decision2
584
+ );
585
+ assert.equal(visionRequests, 1);
586
+
587
+ globalThis.fetch = previousFetch;
588
+ });
589
+
590
+ test("同一图片在同一消息位置重复处理时复用观察", async () => {
591
+ const originalFetch = globalThis.fetch;
592
+ let visionRequests = 0;
593
+ globalThis.fetch = async () => {
594
+ visionRequests += 1;
595
+ return {
596
+ ok: true,
597
+ json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
598
+ };
599
+ };
600
+ const controller = new VisionFallbackController(createServiceContext(), () => config);
601
+ const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
602
+
603
+ try {
604
+ const [firstRewritten] = await controller.preprocess([first], [first], undefined, "session-A");
605
+ const [againRewritten] = await controller.preprocess([first], [first], undefined, "session-A");
606
+ assert.equal(visionRequests, 1);
607
+ assert.match(firstRewritten.content[1].text, /按钮文案是保存/);
608
+ assert.equal(againRewritten.content[1].text, firstRewritten.content[1].text);
609
+ } finally {
610
+ globalThis.fetch = originalFetch;
611
+ }
612
+ });
613
+
614
+ test("同一图片在会话不同位置再次出现时重新识别", async () => {
615
+ const originalFetch = globalThis.fetch;
616
+ let visionRequests = 0;
617
+ globalThis.fetch = async () => {
618
+ visionRequests += 1;
619
+ return {
620
+ ok: true,
621
+ json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
622
+ };
623
+ };
624
+ const controller = new VisionFallbackController(createServiceContext(), () => config);
625
+ const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
626
+ const second = userMessage("u2", [{ type: "text", text: "请压缩更早的对话" }, { type: "image", attachment }]);
627
+
628
+ try {
629
+ const [firstRewritten] = await controller.preprocess([first], [first], undefined, "session-A");
630
+ const [secondRewritten] = await controller.preprocess([second], [second], undefined, "session-A");
631
+ assert.equal(visionRequests, 2, "同一会话的新消息位置应重新识别");
632
+ assert.match(secondRewritten.content[1].text, /按钮文案是保存/);
633
+ assert.equal(secondRewritten.content[1].text, firstRewritten.content[1].text);
634
+ } finally {
635
+ globalThis.fetch = originalFetch;
636
+ }
637
+ });
638
+
639
+ test("同一图片在不同会话的同一位置也重新识别", async () => {
640
+ const originalFetch = globalThis.fetch;
641
+ let visionRequests = 0;
642
+ globalThis.fetch = async () => {
643
+ visionRequests += 1;
644
+ return {
645
+ ok: true,
646
+ json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
647
+ };
648
+ };
649
+ const controller = new VisionFallbackController(createServiceContext(), () => config);
650
+ const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
651
+
652
+ try {
653
+ await controller.preprocess([first], [first], undefined, "session-A");
654
+ await controller.preprocess([first], [first], undefined, "session-B");
655
+ assert.equal(visionRequests, 2, "不同会话应重新识别");
656
+ } finally {
657
+ globalThis.fetch = originalFetch;
658
+ }
659
+ });
660
+
661
+ test("重启后同一会话位置的图片复用持久化观察", async () => {
662
+ const originalFetch = globalThis.fetch;
663
+ let visionRequests = 0;
664
+ globalThis.fetch = async () => {
665
+ visionRequests += 1;
666
+ return {
667
+ ok: true,
668
+ json: async () => ({ choices: [{ message: { content: "按钮文案是保存" } }] })
669
+ };
670
+ };
671
+ const dir = await mkdtemp(join(tmpdir(), "vision-obs-"));
672
+ const logPath = join(dir, "usage.jsonl");
673
+ const makeController = () => new VisionFallbackController(
674
+ createServiceContext(),
675
+ () => ({ ...config, recordUsage: true, usageLogPath: logPath })
676
+ );
677
+ const first = userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }]);
678
+
679
+ try {
680
+ const controllerA = makeController();
681
+ const [rewrittenA] = await controllerA.preprocess([first], [first], undefined, "session-A");
682
+ assert.equal(visionRequests, 1);
683
+ // 模拟重启:新 controller 实例重新从磁盘加载观察缓存
684
+ const controllerB = makeController();
685
+ const [rewrittenB] = await controllerB.preprocess([first], [first], undefined, "session-A");
686
+ assert.equal(visionRequests, 1, "重启后同一位置应复用持久化观察");
687
+ assert.equal(rewrittenB.content[1].text, rewrittenA.content[1].text);
688
+ } finally {
689
+ globalThis.fetch = originalFetch;
690
+ await rm(dir, { recursive: true, force: true });
691
+ }
692
+ });
693
+
694
+ test("compaction 请求不再重新识图,普通请求保持原样", async () => {
695
+ const imageBlock = { type: "image", attachment: { attachmentId: "sha256:compact", mediaType: "image/png", name: "compact.png" } };
696
+ let visionRequests = 0;
697
+ const previousFetch = globalThis.fetch;
698
+ globalThis.fetch = async () => {
699
+ visionRequests += 1;
700
+ return {
701
+ ok: true,
702
+ status: 200,
703
+ json: async () => ({ choices: [{ message: { content: "压缩图中的关键错误是端口写错" } }] })
704
+ };
705
+ };
706
+
707
+ const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
708
+ let streamHandler;
709
+ const ctx = {
710
+ llm: {
711
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
712
+ },
713
+ logger: { error() {} },
714
+ effect() {},
715
+ on(name, handler) {
716
+ if (name === "llm/stream") streamHandler = handler;
717
+ },
718
+ inject(deps, cb) {
719
+ if (deps[0] === "settings") {
720
+ cb({
721
+ settings: {
722
+ register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
723
+ },
724
+ effect() {}
725
+ });
726
+ } else {
727
+ cb({ webServer: { register: () => () => {} }, effect() {} });
728
+ }
729
+ },
730
+ get(name) {
731
+ if (name === "credentials") return { resolve: async () => ({ value: "sk-test" }) };
732
+ if (name === "attachments") {
733
+ return {
734
+ readImage: async () => ({ data: Buffer.from("compact"), ref: { mediaType: "image/png" } })
735
+ };
736
+ }
737
+ return undefined;
738
+ }
739
+ };
740
+
741
+ try {
742
+ apply(ctx, { ...config, mode: "auto" });
743
+ assert.equal(typeof streamHandler, "function");
744
+
745
+ const compactionOptions = {
746
+ purpose: "compaction",
747
+ provider: "opencode-go",
748
+ model: "deepseek-v4-flash",
749
+ signal: makeSignal(),
750
+ messages: [userMessage("compact-1", [{ type: "text", text: "请压缩上下文" }, imageBlock])]
751
+ };
752
+ const compactionStream = streamHandler(compactionOptions, emptyStream);
753
+ assert.equal(typeof compactionStream[Symbol.asyncIterator], "function");
754
+ await consumeStream(compactionStream);
755
+ assert.equal(visionRequests, 0);
756
+ assert.equal(JSON.stringify(compactionOptions.messages).includes('"type":"image"'), false);
757
+ assert.match(compactionOptions.messages[0].content[1].text, /主模型已直接查看原图/);
758
+
759
+ const normalOptions = {
760
+ purpose: undefined,
761
+ provider: "opencode-go",
762
+ model: "deepseek-v4-flash",
763
+ signal: makeSignal(),
764
+ messages: [userMessage("normal-1", [{ type: "text", text: "正常请求" }, imageBlock])]
765
+ };
766
+ const before = JSON.stringify(normalOptions.messages);
767
+ await consumeStream(streamHandler(normalOptions, emptyStream));
768
+ assert.equal(JSON.stringify(normalOptions.messages), before);
769
+ assert.equal(visionRequests, 0);
770
+ } finally {
771
+ globalThis.fetch = previousFetch;
772
+ }
773
+ });
774
+
775
+ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
776
+ const imageBlock = { type: "image", attachment: { attachmentId: "sha256:reuse", mediaType: "image/png", name: "reuse.png" } };
777
+ const original = userMessage("reuse-1", [{ type: "text", text: "看这张图" }, imageBlock]);
778
+ const rewritten = userMessage("reuse-1", [{ type: "text", text: "看这张图" }, { type: "text", text: "【视觉观察:vision-model · reuse.png】\n端口写的是 8080" }]);
779
+ let visionRequests = 0;
780
+ const previousFetch = globalThis.fetch;
781
+ globalThis.fetch = async () => {
782
+ visionRequests += 1;
783
+ return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: "不应重新识图" } }] }) };
784
+ };
785
+ const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
786
+ let streamHandler;
787
+ const session = {
788
+ deriveMessages: () => [rewritten]
789
+ };
790
+ const ctx = {
791
+ sessions: {
792
+ get(sessionId) {
793
+ assert.equal(sessionId, "session-reuse");
794
+ return session;
795
+ }
796
+ },
797
+ llm: {
798
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
799
+ },
800
+ logger: { error() {} },
801
+ effect() {},
802
+ on(name, handler) {
803
+ if (name === "llm/stream") streamHandler = handler;
804
+ },
805
+ inject(deps, cb) {
806
+ if (deps[0] === "settings") {
807
+ cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
808
+ } else {
809
+ cb({ webServer: { register: () => () => {} }, effect() {} });
810
+ }
811
+ },
812
+ get() {
813
+ return undefined;
814
+ }
815
+ };
816
+
817
+ try {
818
+ apply(ctx, { ...config, mode: "auto" });
819
+ const options = {
820
+ purpose: "compaction",
821
+ sessionId: "session-reuse",
822
+ provider: "opencode-go",
823
+ model: "deepseek-v4-flash",
824
+ signal: makeSignal(),
825
+ messages: [original]
826
+ };
827
+ await consumeStream(streamHandler(options, emptyStream));
828
+ assert.equal(visionRequests, 0);
829
+ assert.equal(options.messages[0], rewritten);
830
+ assert.equal(JSON.stringify(options.messages).includes('"type":"image"'), false);
831
+ } finally {
832
+ globalThis.fetch = previousFetch;
833
+ }
834
+ });
835
+
836
+ test("compaction 请求在 mode=never 时不拦截图片", async () => {
837
+ const imageBlock = { type: "image", attachment: { attachmentId: "sha256:compact", mediaType: "image/png", name: "compact.png" } };
838
+ let visionRequests = 0;
839
+ const previousFetch = globalThis.fetch;
840
+ globalThis.fetch = async () => {
841
+ visionRequests += 1;
842
+ return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: "不应调用" } }] }) };
843
+ };
844
+ const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
845
+ let streamHandler;
846
+ const ctx = {
847
+ llm: {
848
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
849
+ },
850
+ logger: { error() {} },
851
+ effect() {},
852
+ on(name, handler) {
853
+ if (name === "llm/stream") streamHandler = handler;
854
+ },
855
+ inject(deps, cb) {
856
+ if (deps[0] === "settings") {
857
+ cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
858
+ } else {
859
+ cb({ webServer: { register: () => () => {} }, effect() {} });
860
+ }
861
+ },
862
+ get() {
863
+ return undefined;
864
+ }
865
+ };
866
+
867
+ try {
868
+ apply(ctx, { ...config, mode: "never" });
869
+ const options = {
870
+ purpose: "compaction",
871
+ provider: "opencode-go",
872
+ model: "deepseek-v4-flash",
873
+ signal: makeSignal(),
874
+ messages: [userMessage("compact-never", [{ type: "text", text: "请压缩上下文" }, imageBlock])]
875
+ };
876
+ const before = JSON.stringify(options.messages);
877
+ await consumeStream(streamHandler(options, emptyStream));
878
+ assert.equal(JSON.stringify(options.messages), before);
879
+ assert.equal(visionRequests, 0);
880
+ } finally {
881
+ globalThis.fetch = previousFetch;
882
+ }
883
+ });
884
+
885
+ test("usage 记录包含响应耗时与图片信息", async () => {
886
+ const previousFetch = globalThis.fetch;
887
+ globalThis.fetch = async () => ({
888
+ ok: true,
889
+ status: 200,
890
+ json: async () => ({
891
+ choices: [{ message: { content: "按钮文案是保存" } }],
892
+ usage: { prompt_tokens: 100, completion_tokens: 50, prompt_tokens_details: { cached_tokens: 10 } }
893
+ })
894
+ });
895
+ const dir = await mkdtemp(join(tmpdir(), "vision-usage-"));
896
+ const logPath = join(dir, "usage.jsonl");
897
+ const controller = new VisionFallbackController(
898
+ createServiceContext(),
899
+ () => ({ ...config, recordUsage: true, usageLogPath: logPath })
900
+ );
901
+ const messages = [userMessage("u1", [{ type: "text", text: "这个按钮是什么?" }, { type: "image", attachment }])];
902
+ try {
903
+ await controller.preprocess(messages, messages, undefined);
904
+ const lines = (await readFile(logPath, "utf8")).trim().split("\n");
905
+ assert.equal(lines.length, 1);
906
+ const entry = JSON.parse(lines[0]);
907
+ assert.equal(entry.status, "ok");
908
+ assert.equal(entry.kind, "vision");
909
+ assert.equal(entry.model, "vision-model");
910
+ assert.equal(entry.inputTokens, 100);
911
+ assert.equal(entry.outputTokens, 50);
912
+ assert.equal(entry.cacheReadTokens, 10);
913
+ assert.equal(entry.imageName, "error.png");
914
+ assert.equal(entry.mediaType, "image/png");
915
+ assert.equal(entry.imageBytes, 3);
916
+ assert.equal(entry.imageIndex, 1);
917
+ assert.equal(entry.imageTotal, 1);
918
+ assert.equal(typeof entry.ts, "number");
919
+ assert.equal(typeof entry.durationMs, "number");
920
+ assert.ok(entry.durationMs >= 0, "durationMs 不应为负");
921
+ } finally {
922
+ globalThis.fetch = previousFetch;
923
+ await rm(dir, { recursive: true, force: true });
924
+ }
925
+ });
926
+
927
+ test("usage 记录失败调用并带错误信息", async () => {
928
+ const previousFetch = globalThis.fetch;
929
+ globalThis.fetch = async () => ({
930
+ ok: false,
931
+ status: 500,
932
+ statusText: "Internal Server Error",
933
+ text: async () => "boom"
934
+ });
935
+ const dir = await mkdtemp(join(tmpdir(), "vision-usage-err-"));
936
+ const logPath = join(dir, "usage.jsonl");
937
+ const controller = new VisionFallbackController(
938
+ createServiceContext(),
939
+ () => ({ ...config, recordUsage: true, usageLogPath: logPath })
940
+ );
941
+ const messages = [userMessage("u1", [{ type: "text", text: "看这张图" }, { type: "image", attachment }])];
942
+ try {
943
+ const [rewritten] = await controller.preprocess(messages, messages, undefined);
944
+ assert.match(rewritten.content[1].text, /图片转换失败/);
945
+ const lines = (await readFile(logPath, "utf8")).trim().split("\n");
946
+ assert.equal(lines.length, 1);
947
+ const entry = JSON.parse(lines[0]);
948
+ assert.equal(entry.status, "error");
949
+ assert.equal(entry.kind, "vision");
950
+ assert.equal(entry.inputTokens, 0);
951
+ assert.equal(entry.outputTokens, 0);
952
+ assert.match(entry.error, /500/);
953
+ assert.equal(entry.imageName, "error.png");
954
+ assert.equal(typeof entry.durationMs, "number");
955
+ assert.ok(entry.durationMs >= 0, "失败记录的 durationMs 不应为负");
956
+ } finally {
957
+ globalThis.fetch = previousFetch;
958
+ await rm(dir, { recursive: true, force: true });
959
+ }
960
+ });