@liustack/modlens 3.16.5 → 3.16.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.16.7 - 2026-08-15
4
+
5
+ - **dsh: the vision engine has a settings card in the web UI ([#39](https://github.com/liustack/modlens/issues/39)).** Engine, API key, endpoint, model, and which local sign-ins a read may borrow, in Settings under Plugins, for the dsh users who never open a terminal. Expanding it probes the machine and lists the harnesses actually found, so auto mode is a choice between real options. The shared `~/.modlens/config.json` stays the one place those values live: the card reads and writes a loopback route that owns the file, so nothing is duplicated into a second store and every other harness sees the same edit. The host never sends a stored key to the browser, only whether one is stored, and a blank key field means leave the stored one alone. A cross-origin or non-loopback write is refused the way dsh fences its own API, since a page in the same browser could otherwise repoint someone's engine at an endpoint of its choosing. Not pinning an engine stays its own state rather than being collapsed into one, and a save carries only what it is about: toggling a grant never moves the pin or rewrites engine settings.
6
+ - **A key can be entered without it touching argv, shell history, or the chat.** `modlens config set gemini-api.apiKey` with the value omitted prompts for it with the echo hidden, and also accepts one piped line. The conversational path is not blocked, because it cannot be: most users hand their key to whatever agent is in front of them, and the onboarding still takes exactly the key they hand over. This is the clean path for the ones who would rather not, and the docs say which boundary each form actually holds.
7
+
8
+ ## 3.16.6 - 2026-08-15
9
+
10
+ - **A `null` where the contract asks for nothing no longer fails the read ([#37](https://github.com/liustack/modlens/issues/37)).** The reported failure named `visual.notes`, which the validator only reports when the field is present in a shape it does not accept: leaving it out was always fine. `null` is the shape a model reaches for when it has nothing to say, and it is what the reporter's own workaround had to legalize, so that is what this fixes. An optional field holding `null` is now dropped before the check rather than passing through it, which keeps the read alive and keeps `null` out of the fields this contract declares: each one is absent or holds its declared type, as the schema always promised. A key a gateway adds on its own is dropped the same way when it is null. On a required field `null` is still a violation. The error also stopped calling everything missing, since reading `missing: visual.notes` about a field that was right there sends you looking in the wrong place.
11
+ - **The openai provider can ask the gateway to enforce the contract ([#37](https://github.com/liustack/modlens/issues/37)).** `modlens config set openai.structuredOutput true` sends it as `response_format: json_schema` in the strict form those endpoints require: every property required, `additionalProperties: false`, and the ones this contract leaves optional made nullable. It is derived from the same schema the runtime checks against, so there is no second copy to keep in step, which is the part that made the reporter's own workaround expensive: they had to hand-write the whole thing to get thinking-disabled qwen through. Off by default, since a gateway without structured-output support answers 400 for the field, and a `response_format` set in `extraBody` still wins.
12
+
3
13
  ## 3.16.5 - 2026-08-15
4
14
 
5
15
  - **dsh: pasting into a plain text-only model works again ([#36](https://github.com/liustack/modlens/issues/36)).** The paste takeover shipped in 3.14.0 has been dead in every default install since 3.16.0 moved the verdict server-side. The verdict refuses when any model matching the selector label declares image input, which is right for a real vision model and wrong for the one case it could not see: this plugin's own `(modlens vision)` wrapper reuses the upstream model id verbatim and declares image input, because that declaration is exactly how the wrapper unlocks admission. So selecting plain `DeepSeek-V4-Pro` matched the real text-only model and the plugin's synthetic twin of it, the twin vetoed, and the paste fell through to dsh's own gate and its `MODEL_DOES_NOT_SUPPORT_IMAGES`. The verdict now skips a twin two ways, both requiring proof rather than a name: the provider ids this instance actually registered, tracked as each wrapper lands, and a model that carries the `(modlens vision)` marker on a provider id minted by the rule this plugin uses, which is how a sibling instance in the same process is recognized. A real vision provider still vetoes even if it borrows the marker, and an id someone else already holds is never trusted as ours. The verdict had no test at all, which is how a regression this total shipped and stayed for six releases; it now has an integration suite driving the real route against a registry shaped like a live install. Thanks to @Taz-dingo for a report that arrived with the conflicting rules already quoted side by side.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
34
34
 
35
35
  ## Highlights
36
36
 
37
- **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
37
+ **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
38
38
 
39
39
  Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer — the same interaction OpenCode and Pi ship — and the `modlens_read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying text-only DeepSeek or GLM models and adds a wrapped entry per route (a stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**; extra routes like opencode-go or zai get their own); the two families' own vision models are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
40
40
 
package/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
34
34
 
35
35
  ## 亮点
36
36
 
37
- **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
37
+ **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
38
38
 
39
39
  DeepSeek Harness 粘贴识图有两种玩法。
40
40
 
@@ -69,7 +69,7 @@ agy # 浏览器完成
69
69
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
70
70
 
71
71
  ```sh
72
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5
72
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
73
73
  ```
74
74
 
75
75
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ import * as dns from "dns/promises";
11
11
  import { isIP } from "net";
12
12
  import { createRequire } from "module";
13
13
  import * as crypto from "crypto";
14
+ import * as readline from "readline";
14
15
  function denyPatterns(guards) {
15
16
  return stringPatterns(guards?.denyModels);
16
17
  }
@@ -619,12 +620,71 @@ const VISION_RESULT_SCHEMA = {
619
620
  },
620
621
  required: ["summary", "ocr", "layout", "semantics", "visual", "uncertainty"]
621
622
  };
623
+ function strictSchema(node) {
624
+ if (node.type === "object") {
625
+ const properties = {};
626
+ const required = node.required ?? [];
627
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
628
+ const strict = strictSchema(child);
629
+ properties[key] = required.includes(key) ? strict : (
630
+ // Strict mode has no optional properties, only nullable ones.
631
+ { anyOf: [strict, { type: "null" }] }
632
+ );
633
+ }
634
+ return {
635
+ type: "object",
636
+ properties,
637
+ required: Object.keys(properties),
638
+ additionalProperties: false
639
+ };
640
+ }
641
+ if (node.type === "array" && node.items) {
642
+ return { ...node, items: strictSchema(node.items) };
643
+ }
644
+ return node;
645
+ }
646
+ function visionResponseFormat() {
647
+ return {
648
+ type: "json_schema",
649
+ json_schema: {
650
+ name: "vision_result",
651
+ strict: true,
652
+ schema: strictSchema(VISION_RESULT_SCHEMA)
653
+ }
654
+ };
655
+ }
622
656
  function visionResultSchemaJson() {
623
657
  return JSON.stringify(VISION_RESULT_SCHEMA);
624
658
  }
625
659
  function missingSchemaFields(result) {
626
660
  return schemaViolations(VISION_RESULT_SCHEMA, result, "");
627
661
  }
662
+ function withoutEmptyOptionals(value, schema) {
663
+ if (schema.type === "object") {
664
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
665
+ return value;
666
+ }
667
+ const record = value;
668
+ const cleaned = {};
669
+ for (const [key, entry] of Object.entries(record)) {
670
+ const childSchema = schema.properties?.[key];
671
+ const isRequired = schema.required?.includes(key) ?? false;
672
+ if (entry === null && !isRequired) {
673
+ continue;
674
+ }
675
+ cleaned[key] = childSchema ? withoutEmptyOptionals(entry, childSchema) : entry;
676
+ }
677
+ return cleaned;
678
+ }
679
+ if (schema.type === "array" && schema.items && Array.isArray(value)) {
680
+ const itemSchema = schema.items;
681
+ return value.map((item) => withoutEmptyOptionals(item, itemSchema));
682
+ }
683
+ return value;
684
+ }
685
+ function normalizeVisionResult(result) {
686
+ return withoutEmptyOptionals(result, VISION_RESULT_SCHEMA);
687
+ }
628
688
  function schemaViolations(schema, value, path2) {
629
689
  const label = path2 || "(root)";
630
690
  if (schema.type === "object") {
@@ -1256,6 +1316,13 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
1256
1316
  mergeExtraBody(
1257
1317
  {
1258
1318
  model,
1319
+ // Asked for, never assumed: a gateway without
1320
+ // structured-output support answers 400 for a field
1321
+ // it does not know (issue #37). A response_format the
1322
+ // caller supplied wins outright rather than being
1323
+ // merged into ours, since the two describe the same
1324
+ // thing and a blend of them describes neither.
1325
+ ...options.settings?.structuredOutput && options.settings?.extraBody?.response_format === void 0 ? { response_format: visionResponseFormat() } : {},
1259
1326
  messages: [
1260
1327
  {
1261
1328
  role: "user",
@@ -1286,14 +1353,15 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
1286
1353
  if (!text) {
1287
1354
  throw new Error("OpenAI-compatible API returned no message content.");
1288
1355
  }
1289
- const result = extractJson(text);
1290
- if (result === null) {
1356
+ const rawResult = extractJson(text);
1357
+ if (rawResult === null) {
1291
1358
  throw new Error(`OpenAI-compatible API returned non-JSON output: ${truncate(text)}`);
1292
1359
  }
1360
+ const result = normalizeVisionResult(rawResult);
1293
1361
  const missing = missingSchemaFields(result);
1294
1362
  if (missing.length > 0) {
1295
1363
  throw new Error(
1296
- `OpenAI-compatible API returned JSON that does not match the vision schema (missing: ${missing.join(", ")}). Retry, or switch to gemini-api / anthropic for enforced schemas. Got: ${truncate(text)}`
1364
+ `OpenAI-compatible API returned JSON that does not match the vision schema (wrong or missing: ${missing.join(", ")}). Retry, or switch to gemini-api / anthropic for enforced schemas. Got: ${truncate(text)}`
1297
1365
  );
1298
1366
  }
1299
1367
  return {
@@ -1432,12 +1500,31 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
1432
1500
  const dot = dottedKey.indexOf(".");
1433
1501
  if (dot <= 0 || dot === dottedKey.length - 1) {
1434
1502
  throw new Error(
1435
- `Invalid config key: ${dottedKey}. Use "provider", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
1503
+ `Invalid config key: ${dottedKey}. Use "provider", "proxy", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|proxy|extraBody|structuredOutput>".`
1436
1504
  );
1437
1505
  }
1438
1506
  const providerName = dottedKey.slice(0, dot);
1439
1507
  const field = dottedKey.slice(dot + 1);
1440
- if (field === "extraBody") {
1508
+ if (field === "structuredOutput") {
1509
+ if ((providerAliases()[providerName] ?? providerName) !== "openai") {
1510
+ throw new Error(
1511
+ `structuredOutput applies to the openai provider only, not ${providerName}.`
1512
+ );
1513
+ }
1514
+ const normalized = value.trim().toLowerCase();
1515
+ if (normalized !== "" && normalized !== "true" && normalized !== "false") {
1516
+ throw new Error(
1517
+ `${providerName}.structuredOutput must be true or false (empty clears).`
1518
+ );
1519
+ }
1520
+ config2.providers ??= {};
1521
+ config2.providers[providerName] ??= {};
1522
+ if (normalized === "") {
1523
+ delete config2.providers[providerName].structuredOutput;
1524
+ } else {
1525
+ config2.providers[providerName].structuredOutput = normalized === "true";
1526
+ }
1527
+ } else if (field === "extraBody") {
1441
1528
  config2.providers ??= {};
1442
1529
  config2.providers[providerName] ??= {};
1443
1530
  if (value.trim() === "") {
@@ -1450,7 +1537,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
1450
1537
  }
1451
1538
  } else if (!STRING_FIELDS.includes(field)) {
1452
1539
  throw new Error(
1453
- `Unknown config field: ${field}. Use apiKey, baseUrl, model, proxy, or extraBody.`
1540
+ `Unknown config field: ${field}. Use apiKey, baseUrl, model, proxy, extraBody, or structuredOutput.`
1454
1541
  );
1455
1542
  } else {
1456
1543
  config2.providers ??= {};
@@ -1539,6 +1626,9 @@ function renderEffectiveConfig(config2, env = process.env) {
1539
1626
  fields[field] = `${shown} (${source})`;
1540
1627
  }
1541
1628
  }
1629
+ if (fileSettings.structuredOutput !== void 0) {
1630
+ fields.structuredOutput = `${fileSettings.structuredOutput} (file)`;
1631
+ }
1542
1632
  if (fileSettings.extraBody !== void 0) {
1543
1633
  fields.extraBody = `${JSON.stringify(fileSettings.extraBody)} (file)`;
1544
1634
  }
@@ -2770,10 +2860,11 @@ async function runProvider(provider, model, options, resolvedInput, timeoutMs, c
2770
2860
  `Provider ${provider.name} implements neither execute nor buildInvocation.`
2771
2861
  );
2772
2862
  }
2863
+ parsed.result = normalizeVisionResult(parsed.result);
2773
2864
  const missing = missingSchemaFields(parsed.result);
2774
2865
  if (missing.length > 0) {
2775
2866
  throw new Error(
2776
- `${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
2867
+ `${provider.name} returned a result that does not match the vision schema (wrong or missing: ${missing.join(", ")}).`
2777
2868
  );
2778
2869
  }
2779
2870
  return parsed;
@@ -4062,6 +4153,53 @@ function recoverPastedImages(options = {}) {
4062
4153
  }
4063
4154
  return result;
4064
4155
  }
4156
+ async function readSecret(promptText, stdin = process.stdin, stderr = process.stderr) {
4157
+ if (!stdin.isTTY) {
4158
+ stdin.setEncoding("utf8");
4159
+ let data = "";
4160
+ for await (const chunk of stdin) {
4161
+ data += chunk;
4162
+ if (data.includes("\n")) {
4163
+ break;
4164
+ }
4165
+ }
4166
+ const value = data.split("\n")[0].trim();
4167
+ if (value === "") {
4168
+ throw new Error("no key arrived on stdin (pipe one line, or run on a terminal)");
4169
+ }
4170
+ return value;
4171
+ }
4172
+ const rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
4173
+ const muted = rl;
4174
+ stderr.write(promptText);
4175
+ muted._writeToOutput = () => {
4176
+ };
4177
+ try {
4178
+ const value = await new Promise((resolve, reject) => {
4179
+ let settled = false;
4180
+ const settle = (action) => {
4181
+ if (!settled) {
4182
+ settled = true;
4183
+ action();
4184
+ }
4185
+ };
4186
+ rl.question("", (answer) => settle(() => resolve(answer)));
4187
+ rl.on("SIGINT", () => settle(() => reject(new Error("cancelled, nothing was saved"))));
4188
+ rl.on(
4189
+ "close",
4190
+ () => settle(() => reject(new Error("input ended before a key was entered")))
4191
+ );
4192
+ });
4193
+ const trimmed = value.trim();
4194
+ if (trimmed === "") {
4195
+ throw new Error("no key entered");
4196
+ }
4197
+ return trimmed;
4198
+ } finally {
4199
+ stderr.write("\n");
4200
+ rl.close();
4201
+ }
4202
+ }
4065
4203
  const program = new Command();
4066
4204
  function parsePositiveInt(raw, flag) {
4067
4205
  if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) <= 0) {
@@ -4069,7 +4207,7 @@ function parsePositiveInt(raw, flag) {
4069
4207
  }
4070
4208
  return Number.parseInt(raw, 10);
4071
4209
  }
4072
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.5");
4210
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.7");
4073
4211
  program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
4074
4212
  "--extra-body <json>",
4075
4213
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -4179,7 +4317,7 @@ program.command("doctor").description(
4179
4317
  configPath: CONFIG_PATH,
4180
4318
  // Lets doctor name an installed skill copy that is older than
4181
4319
  // the CLI reporting on it (issue #33).
4182
- version: "3.16.5"
4320
+ version: "3.16.7"
4183
4321
  });
4184
4322
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
4185
4323
  process.stdout.write(`${output}
@@ -4199,9 +4337,9 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
4199
4337
  process.stdout.write(
4200
4338
  [
4201
4339
  `Created ${CONFIG_PATH}`,
4202
- "Everything is optional. Two things you can set:",
4340
+ "Everything is optional. The usual ones:",
4203
4341
  " modlens config set provider <name> which provider analyzes images",
4204
- " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider credentials",
4342
+ " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider settings",
4205
4343
  ` modlens config set <provider>.extraBody '{"thinking":{"type":"disabled"}}' vendor request fields`,
4206
4344
  ""
4207
4345
  ].join("\n")
@@ -4214,9 +4352,18 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
4214
4352
  process.exitCode = 1;
4215
4353
  }
4216
4354
  });
4217
- config.command("set <key> <value>").description("Set a value, e.g. modlens config set gemini-api.apiKey <key>").action((key, value) => {
4355
+ config.command("set <key> [value]").description(
4356
+ "Set a value. Omit the value for an apiKey to enter it at a hidden prompt (out of argv and shell history), or to read one piped line (out of argv; the command feeding the pipe is yours to keep out of history)"
4357
+ ).action(async (key, value) => {
4218
4358
  try {
4219
- setConfigValue(key, value);
4359
+ let resolved = value;
4360
+ if (resolved === void 0) {
4361
+ if (!key.endsWith(".apiKey")) {
4362
+ throw new Error(`${key} needs a value: modlens config set ${key} <value>`);
4363
+ }
4364
+ resolved = await readSecret(`${key} (input hidden): `);
4365
+ }
4366
+ setConfigValue(key, resolved);
4220
4367
  process.stdout.write(`Saved ${key} to ${CONFIG_PATH}
4221
4368
  `);
4222
4369
  } catch (error) {
package/docs/cli.md CHANGED
@@ -97,6 +97,6 @@ Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `
97
97
  Other subcommands:
98
98
 
99
99
  - `modlens guard [--model <id>]`: should the engine run for the active model at all? Exit 0 allow, 1 deny, verdict as JSON.
100
- - `modlens config <init|set|show>`: keys are `provider`, `proxy` (HTTP/HTTPS proxy for the API providers, `HTTPS_PROXY`/`HTTP_PROXY` also honored), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`.
100
+ - `modlens config <init|set|show>`: `set` with the value omitted on an `apiKey` field prompts for it with the echo hidden, so the key never enters argv, shell history, or the conversation with an agent driving the terminal. It also accepts one piped line (`pbpaste | modlens config set openai.apiKey`), which keeps the key out of argv, though whatever produced that pipe is still your own command to keep out of history. Keys are `provider`, `proxy` (HTTP/HTTPS proxy for the API providers, `HTTPS_PROXY`/`HTTP_PROXY` also honored), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`, plus `openai.structuredOutput` (that route only).
101
101
  - `modlens doctor`: Node and node:sqlite, provider readiness, the failover chains for this machine, the detected harness, the guard's rules with a live verdict, and the Reuse section with per-harness grant decisions and discovered vision. Spends no quota; `--json` for a machine-readable report.
102
102
 
package/docs/cli.zh-CN.md CHANGED
@@ -94,5 +94,5 @@ modlens recover-paste # pull a pasted image into a fil
94
94
  其他子命令:
95
95
 
96
96
  - `modlens guard [--model <id>]`:判断当前激活的模型到底该不该运行引擎。退出码 0 表示放行,1 表示拒绝,判定结果以 JSON 输出。
97
- - `modlens config <init|set|show>`:可用的键有 `provider`、`proxy`(API provider 的 HTTP/HTTPS 代理,也认 `HTTPS_PROXY`/`HTTP_PROXY`)、`reuse.<claude|codex|opencode|pi|grok>`、`guards.<denyModels|allowModels|denyWhenUnknown>`,以及 `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`。
97
+ - `modlens config <init|set|show>`:`set` 在 `apiKey` 字段上省略值会进入不回显的输入提示,密钥不进 argv、不进 shell 历史,也不进你与终端里那个 agent 的对话。它也接受从管道读一行(`pbpaste | modlens config set openai.apiKey`),这样密钥不进 argv,但产生这条管道的命令本身是否留在历史里,由你自己决定。可用的键有 `provider`、`proxy`(API provider 的 HTTP/HTTPS 代理,也认 `HTTPS_PROXY`/`HTTP_PROXY`)、`reuse.<claude|codex|opencode|pi|grok>`、`guards.<denyModels|allowModels|denyWhenUnknown>`,以及 `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`,另有 `openai.structuredOutput`(仅这条路线用得上)。
98
98
  - `modlens doctor`:报告 Node 与 node:sqlite、各 provider 的就绪状态、本机的故障转移链、检测到的 harness、guard 规则和一次现场判定,以及 Reuse 一节里按 harness 的授权决定与发现的视觉能力。不花任何额度,`--json` 输出机器可读报告。
@@ -55,18 +55,33 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
55
55
  dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
59
59
  ```
60
60
 
61
61
  This registers a `modlens_read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
62
62
 
63
+ ### Configuring the engine from the web UI
64
+
65
+ dsh web users have no terminal in front of them, so the engine settings have a
66
+ card in **Settings → Plugins → Plugin configuration**: which engine reads
67
+ images, its key, endpoint and model, and which local sign-ins a read may
68
+ borrow. Expanding it probes this machine and lists the harnesses it actually
69
+ found, so the grants are a choice between real options rather than five names.
70
+
71
+ The values live where they always did, in `~/.modlens/config.json`, shared
72
+ with every other harness: the card reads and writes that file through a
73
+ loopback route, so an edit here is the same edit `modlens config set` makes.
74
+ The card never receives a stored key, only whether one is set, and leaving the
75
+ key field empty keeps the stored one. `settingsCard: false` in the plugin row
76
+ removes it, route included.
77
+
63
78
  ### Keeping it up to date
64
79
 
65
80
  modlens ships often, and both install shapes freeze at whatever version they
66
81
  got. On dsh, re-run the install with the version named:
67
82
 
68
83
  ```sh
69
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.5
84
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
70
85
  ```
71
86
 
72
87
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -101,6 +116,21 @@ install-time version, so re-run the install to overwrite it in place.
101
116
  behind the CLI doing the reporting, which makes the drift visible before it
102
117
  costs anyone a debugging session.
103
118
 
119
+ ### A session that already holds an image
120
+
121
+ dsh refuses to switch a session that contains image attachments to a model
122
+ whose declared modalities exclude images, which includes every plain text-only
123
+ DeepSeek entry. The rule is dsh's and it is sound: a text-only model cannot
124
+ receive a history carrying image blocks, and the `(modlens vision)` variant is
125
+ switchable there not because it declares image input but because it converts
126
+ those blocks to evidence text at request time, which the plain entry does not
127
+ do ([#40](https://github.com/liustack/modlens/issues/40)).
128
+
129
+ Pasting through the first route above avoids the situation entirely: the image
130
+ becomes a file path and the session never holds an attachment, so nothing locks
131
+ the model selector. It only comes up after a paste on a variant or on a vision
132
+ model, where an attachment is the point.
133
+
104
134
  ### Paste-to-path (web profile)
105
135
 
106
136
  Pasting an image into the dsh Web UI under a **text-only model** used to die at
@@ -55,17 +55,23 @@ OpenCode 接 DeepSeek:执行 `opencode auth login`,选择 DeepSeek 并粘贴
55
55
  dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不是靠提示词触发的 skill。本包自身就是一个 dsh bundle,一条命令即可装进某个 profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
62
62
 
63
+ ### 在网页界面里配置引擎
64
+
65
+ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在**设置 → 插件 → 插件配置**里:用哪个引擎读图、它的密钥、地址和模型,以及一次读取可以借用本机哪些已有登录。展开时会探测本机,只列出真正找到的 harness,让授权是在真实选项之间做选择,而不是面对五个名字。
66
+
67
+ 这些值仍然住在 `~/.modlens/config.json`,与其他所有 harness 共享:卡片通过一条回环路由读写那个文件,所以在这里改一笔,和 `modlens config set` 改的是同一笔。卡片从不拿到已保存的密钥,只知道有没有;密钥框留空就不动已存的那个。在插件配置行里设 `settingsCard: false` 可以连同路由一起去掉它。
68
+
63
69
  ### 保持更新
64
70
 
65
71
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
66
72
 
67
73
  ```sh
68
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.5
74
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
69
75
  ```
70
76
 
71
77
  `npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
@@ -82,6 +88,12 @@ npx -y @deepseek-ai/dsh plugin --profile <name> list
82
88
 
83
89
  skill 类 harness 上,skill 是一个拷贝出来的文件夹,拷贝会保留安装时的版本,重跑安装原地覆盖即可。`modlens doctor` 会读出它能找到的每一份拷贝里钉住的版本,并标出落后于当前 CLI 的那些,让版本漂移在坑到人之前就先暴露出来。
84
90
 
91
+ ### 会话里已经有图片时
92
+
93
+ 会话里存在图片附件时,dsh 会拒绝切换到声明模态不含图片的模型,普通的 DeepSeek 纯文本条目都在此列。这条规则是 dsh 的,而且站得住:纯文本模型确实收不了带图片块的历史,而 `(modlens vision)` 变体之所以能切过去,靠的不是声明了图片输入,而是它在发请求时把那些块转成证据文本,普通条目没有这层转换([#40](https://github.com/liustack/modlens/issues/40))。
94
+
95
+ 走上面第一条粘贴路线就不会遇到:图片变成文件路径,会话里从不存在附件,模型选择器也就不会被锁住。只有在变体或视觉模型上粘贴时才会产生附件,而那种场景下附件本来就是目的。
96
+
85
97
  ### 粘贴转路径(paste-to-path,web profile)
86
98
 
87
99
  过去在 dsh Web UI 里,**纯文本模型**下粘贴图片会死在图片准入检查这一步。插件现在带了一个浏览器端半边(由 dsh 的客户端插件系统自动加载),恰好在这种情况下接管粘贴:图片字节发到插件在 dsh web 服务器上的 `/modlens/paste` 路由(仅回环地址,校验 magic byte,上限 25 MB),落成一个私有临时文件,输入框收到的则是纯文本的文件路径。这与 Pi、OpenCode、Claude Code 递给模型的形态一致,也正是 modlens skill 和 `modlens_read_image` 工具的首要触发条件。消息里不带图片附件,准入检查根本不会触发。
@@ -71,6 +71,8 @@ The CLI prints one JSON object to stdout:
71
71
 
72
72
  Required fields: `summary`, `ocr`, `layout`, `semantics`, `visual`, `uncertainty` — every top-level field, `visual` included. (Earlier docs called `visual` optional; the enforced schema has always required it, so build to the schema.)
73
73
 
74
+ Optional fields: `ocr.lines[].language`, `semantics.intent`, `semantics.entities[].evidence`, `semantics.relations`, `visual.dominant_colors`, `visual.style`, `visual.notes`. Each is either absent or holds its declared type. Never `null`: a model with nothing to say there often writes one, and modlens drops the key before the result reaches you, so reading an optional field means checking whether it is there, not whether it is null.
75
+
74
76
  `layout.regions[].type` is a free string, not a closed list. Region kinds are an open set: a fixed enum rejected `link` on any web screenshot and `search` on a portal, and a rejected result fails the whole read over a descriptive label. The field's schema `description` names the common vocabulary as guidance, which reaches every provider that enforces this schema server-side, so an unlisted kind costs nothing.
75
77
 
76
78
  Changes from v1: pixel `bbox` coordinates and numeric `confidence` scores were removed. Vision models fabricate both, so v2 stops pretending to provide them.
@@ -71,6 +71,8 @@ CLI 向 stdout 打印一个 JSON 对象:
71
71
 
72
72
  必填字段:`summary`、`ocr`、`layout`、`semantics`、`visual`、`uncertainty`,也就是每一个顶层字段,`visual` 也不例外。(早期文档把 `visual` 写成可选,但强制执行的 schema 一直要求它,请以 schema 为准。)
73
73
 
74
+ 可选字段:`ocr.lines[].language`、`semantics.intent`、`semantics.entities[].evidence`、`semantics.relations`、`visual.dominant_colors`、`visual.style`、`visual.notes`。每一个要么不存在,要么就是它声明的类型,绝不会是 `null`:模型在这些位置没话可说时经常写 `null`,modlens 会在结果交到你手上之前把这个键删掉,所以读可选字段只需判断它在不在,不用判断是不是 null。
75
+
74
76
  `layout.regions[].type` 是自由字符串,不是封闭列表。区域类型本质是开放集合:固定枚举会让任何网页截图里的 `link`、门户页里的 `search` 直接落选,而一次落选就为了一个描述性标签废掉整次识别。常用词表写在该字段的 schema `description` 里作为指引,凡是在服务端强制执行这份 schema 的 provider 都会收到,没列到的类型不会有任何代价。
75
77
 
76
78
  相对 v1 的变化:删掉了像素级 `bbox` 坐标和数值型 `confidence` 分数。视觉模型会凭空编造这两样,v2 不再假装提供。
@@ -98,10 +98,36 @@ Working as intended. Codex writes pasted images to disk and puts the path in the
98
98
 
99
99
  ```
100
100
  OpenAI-compatible API returned JSON that does not match the vision schema
101
- (missing: ocr, ocr.full_text, ...)
101
+ (wrong or missing: visual.notes, ...)
102
102
  ```
103
103
 
104
- That endpoint returned a partial result. Only agy, gemini-api, anthropic, and claude-cli enforce the schema server-side, so weaker gateways can produce half a result. Retry once, then switch:
104
+ That endpoint returned something the contract does not accept. Note the
105
+ wording: a field named here can be absent, or present with the wrong shape.
106
+ For an optional field like `visual.notes`, only the second is possible, since
107
+ leaving it out is accepted. A `null` there is dropped rather than refused, so
108
+ what remains is a genuinely wrong type.
109
+
110
+ Most OpenAI-compatible gateways enforce nothing server-side, so the contract
111
+ travels as a filled-in JSON template in the prompt and a weaker model can
112
+ answer with half of it, especially with thinking turned off. Ask the gateway
113
+ to enforce it instead:
114
+
115
+ ```bash
116
+ modlens config set openai.structuredOutput true
117
+ ```
118
+
119
+ That sends the contract as `response_format: json_schema` in strict form,
120
+ derived from the same schema modlens checks against, so there is nothing to
121
+ keep in sync by hand. It is off by default because a gateway that does not
122
+ support the field answers 400 for it. If that happens:
123
+
124
+ ```bash
125
+ modlens config set openai.structuredOutput false
126
+ ```
127
+
128
+ A `response_format` you set yourself in `extraBody` wins over the derived one.
129
+
130
+ Failing that, retry once, then switch:
105
131
 
106
132
  ```bash
107
133
  modlens -i <image> -p gemini-api
@@ -137,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
137
163
  as a deliberate request rather than a resolution:
138
164
 
139
165
  ```sh
140
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.5
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
141
167
  ```
142
168
 
143
169
  `npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
@@ -152,7 +178,7 @@ file:
152
178
 
153
179
  ```yaml
154
180
  minimumReleaseAgeExclude:
155
- - '@liustack/modlens@3.16.5'
181
+ - '@liustack/modlens@3.16.7'
156
182
  ```
157
183
 
158
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -98,10 +98,26 @@ tag in the message carries its path.
98
98
 
99
99
  ```
100
100
  OpenAI-compatible API returned JSON that does not match the vision schema
101
- (missing: ocr, ocr.full_text, ...)
101
+ (wrong or missing: visual.notes, ...)
102
102
  ```
103
103
 
104
- 那个端点返回了残缺的结果。只有 agy、gemini-api、anthropic 和 claude-cli 在服务端强制执行 schema,较弱的网关可能只产出半个结果。重试一次,然后换 provider:
104
+ 那个端点返回了不符合契约的内容。注意措辞:被点名的字段可能是缺失,也可能是存在但形状不对。像 `visual.notes` 这样的可选字段只可能是后者,因为它缺失是被接受的。写成 `null` 也会被丢弃而不是拒绝,所以剩下的就是真正的类型错误。
105
+
106
+ 大多数 OpenAI 兼容网关在服务端什么都不强制,契约是以填好的 JSON 模板形式随提示词发过去的,能力弱一些的模型可能只答出一半,关掉思考时尤其明显。可以改成让网关自己强制执行:
107
+
108
+ ```bash
109
+ modlens config set openai.structuredOutput true
110
+ ```
111
+
112
+ 这会把契约以 `response_format: json_schema` 的严格形式发过去,schema 由 modlens 校验用的那份推导而来,没有需要手工同步的副本。默认关闭,因为不支持这个字段的网关会直接 400。真遇到就关回去:
113
+
114
+ ```bash
115
+ modlens config set openai.structuredOutput false
116
+ ```
117
+
118
+ 你自己在 `extraBody` 里设的 `response_format` 优先级更高。
119
+
120
+ 还是不行就重试一次,然后换 provider:
105
121
 
106
122
  ```bash
107
123
  modlens -i <image> -p gemini-api
@@ -128,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
128
144
  `@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
129
145
 
130
146
  ```sh
131
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.5
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
132
148
  ```
133
149
 
134
150
  `npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
@@ -137,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.5
137
153
 
138
154
  ```yaml
139
155
  minimumReleaseAgeExclude:
140
- - '@liustack/modlens@3.16.5'
156
+ - '@liustack/modlens@3.16.7'
141
157
  ```
142
158
 
143
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens: