@liustack/modlens 3.25.3 → 3.26.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.26.0 - 2026-09-06
4
+
5
+ - **Opt-in vision twins for other text models ([#98](https://github.com/liustack/modlens/issues/98)).** Set `families: ['*']` in the dsh plugin configuration to include models outside the default DeepSeek, GLM and MiMo families. Wildcard matches require explicit text input metadata and reject native image input, unknown capabilities and the existing native-vision exclusions. Default discovery and explicit family prefixes are unchanged. The bilingual harness guide documents the exact profile override, provider filtering and the native attachment flow. Third-party DeepSeek routes already work with default discovery ([#95](https://github.com/liustack/modlens/issues/95)).
6
+ - **Per-provider proxy routing and dsh settings ([#97](https://github.com/liustack/modlens/issues/97)).** An API provider can now inherit the shared proxy, connect directly, or use its own proxy. The dsh settings card exposes all three choices and keeps stored proxy credentials on the host. A direct provider remains reachable when a shared proxy fails. Provider failover itself is unchanged.
7
+ - **Compatibility:** an explicitly empty provider proxy, such as `providers.openai.proxy: ""`, now means direct connection and ignores the global and environment proxies. Earlier releases treated it as inheritance. To keep inheriting, remove that provider's `proxy` field or choose the inherited route in the dsh settings card. A missing field and a non-empty proxy URL retain their previous behavior. Update ModLens in every harness sharing the config before using the direct setting.
8
+ - **skills.sh installation.** Both READMEs and the installation guide include `npx -y skills add liustack/modlens --skill modlens --global` for installing the skill at user level.
9
+
10
+ ## 3.25.4 - 2026-09-01
11
+
12
+ - **openai-compat requests say `stream: false` out loud ([#94](https://github.com/liustack/modlens/issues/94)).** The OpenAI spec defaults `stream` to false when the field is absent, but some self-hosted and regional gateways default the other way and answer an SSE body, which `response.json()` rejects with `Unexpected token 'd', "data:{"id""... is not valid JSON` — making modlens unusable against such a gateway. The request body now carries `stream: false` explicitly, so the response is plain JSON regardless of what the gateway assumes. The field was already on `mergeExtraBody`'s reserved list, so `extraBody` could never override it; it just was never actually sent. Thanks to @moyi497475207 for a report that arrived with curl proof of the gateway's behavior and the exact one-line patch.
13
+ - **dsh >= 0.1.2: compact works on `(modlens vision)` routes ([#93](https://github.com/liustack/modlens/issues/93)).** dsh 0.1.2 gave its `LlmAdapter` base class a new `imageRequestPricing` default, and `LlmRuntime` forwards to it synchronously while measuring token pressure — no feature check, same shape as the 0.1.1 `prepareCall` dispatch ([#73](https://github.com/liustack/modlens/issues/73)). The wrapper registers a plain object, so the missing method turned every compact on a modlens-synthesized route into `imageRequestPricing is not a function`, and compaction never ran. The adapter now carries the same default itself and returns `undefined`: the synthetic route declares no provider-side image pricing, so the token meter keeps its neutral estimate. The regression test drives the registered adapter through the 0.1.2 dispatch shape and failed with the reported TypeError before the fix. Thanks to @ZzAltMan for a root-cause report down to the exact dsh call site, and @Ztyss for cross-checking every official adapter to confirm the wrapper was the only one missing the method.
14
+
3
15
  ## 3.25.3 - 2026-08-30
4
16
 
5
17
  - **API provider requests survive Node 26.0's built-in fetch (empty headers, still-gzipped body) ([#91](https://github.com/liustack/modlens/issues/91)).** Importing npm undici — which modlens resolves from node_modules since #23 — claims the process-wide `undici.globalDispatcher.2` slot at load time. Node 26.0.0's built-in fetch is undici 8.0.2 and reads that same slot, so on the no-proxy path (which used the host fetch) it drove npm undici 8.10.0's Agent. The two disagree about HTTP/2 response headers: 8.0.2's fetch walks `rawHeaders` as a flat array while 8.10.0's h2 client hands over an object, so the walk runs zero times and every header is dropped — `content-encoding` included. The gzip body was never decompressed and `response.json()` threw `Unexpected token '\x1f'` on the raw bytes. Gemini's endpoint negotiates HTTP/2, which is why `gemini-api` surfaced it; HTTP/1.1 headers are already an array and are immune, as are Node 24 (built-in undici 7.x on the `.1` slot, with h2 forced off by the compat wrapper) and Node 26.8+ (built-in 8.10.0 agrees with the npm copy). `apiFetch` now never touches the host fetch: with or without a proxy it uses npm undici's own fetch with a same-sourced dispatcher, verified by an integration test over the real undici stack and against a local HTTP/2 gzip server on Node 26.0.0. A body that fails mid-read after the response arrived keeps its status and headers readable and rejects `text()`/`json()` with the original error, so 401 classification and API key rotation are unchanged, and connect failures still raise the connectivity hint. Thanks to @Sol1l0quy for a report that had already isolated the failing layer with a fetch probe and byte-for-byte replays.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
33
33
 
34
34
  ## Highlights
35
35
 
36
- **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** install it instantly with one command: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.25.3`. See the [setup guide](docs/harness-setup.md) for installation and update details. If the command line is not your thing but you still want to try DSH, check out <a href="https://github.com/liustack/aimanager"><b>AIManager</b></a>, the lightest desktop wrapper for DeepSeek Harness. It gets you started with zero code or configuration and installs every dependency for you with one click.
36
+ **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** install it instantly with one command: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.0`. See the [setup guide](docs/harness-setup.md) for installation and update details. If the command line is not your thing but you still want to try DSH, check out <a href="https://github.com/liustack/aimanager"><b>AIManager</b></a>, the lightest desktop wrapper for DeepSeek Harness. It gets you started with zero code or configuration and installs every dependency for you with one click.
37
37
 
38
38
  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), then 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 eligible text-only DeepSeek, GLM, or MiMo Pro models and adds a wrapped entry per route. A stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**, while extra routes like opencode-go or zai get their own. Native vision models in those families, including GLM-5.3-Flash, 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)).
39
39
 
@@ -49,13 +49,22 @@ A hotkey that captures the screen into DeepSeek Harness is a separate plugin: [d
49
49
 
50
50
  ## Install in other harnesses
51
51
 
52
- **Step 1, hand it to your AI.** Send it this line:
52
+ **Option 1, install with [skills.sh](https://skills.sh/):**
53
+
54
+ ```bash
55
+ npx -y skills add liustack/modlens --skill modlens --global
56
+ ```
57
+
58
+ This installs the `modlens` skill at user level. Restart the harness, then ask
59
+ your AI to configure modlens and run its health check.
60
+
61
+ **Option 2, hand the install to your AI.** Send it this line:
53
62
 
54
63
  > Install and configure the modlens skill following https://github.com/liustack/modlens/blob/main/INSTALL.md, then run the health check and tell me the result.
55
64
 
56
65
  The install starts by checking what your machine already has. An existing login in Claude Code, Codex, OpenCode, or Pi can be enough: modlens asks before reusing any of them, and the health check tells you where things stand.
57
66
 
58
- **Step 2, only if the health check comes back empty, set up a free engine.** The recommended choice is a free Gemini API key (about three minutes at [Google AI Studio](https://aistudio.google.com), no credit card), which also makes every read 5-10 seconds. A free OpenAI-compatible key from another platform works too. To avoid any sign-up, install Antigravity CLI instead, then sign in:
67
+ **After either option, only if the health check comes back empty, set up a free engine.** The recommended choice is a free Gemini API key (about three minutes at [Google AI Studio](https://aistudio.google.com), no credit card), which also makes every read 5-10 seconds. A free OpenAI-compatible key from another platform works too. To avoid any sign-up, install Antigravity CLI instead, then sign in:
59
68
 
60
69
  ```bash
61
70
  curl -fsSL https://antigravity.google/cli/install.sh | bash
@@ -117,7 +126,7 @@ Two more sources of vision need zero new keys, each behind one explicit consent
117
126
 
118
127
  ### Picking and routing
119
128
 
120
- Two knobs: `modlens config set provider <name>` states a preference (the chain still backs it up), `-p <name>` pins exactly one with no fallback. Machines behind a proxy set `HTTPS_PROXY` or `modlens config set proxy <url>` and the API providers route through it. Details: the [CLI manual](docs/cli.md) for defaults and flags, [Configuration](skills/modlens/references/configure.md) for every key, and [Security](docs/security.md) for who fetches what on remote URLs.
129
+ Two knobs: `modlens config set provider <name>` states a preference (the chain still backs it up), `-p <name>` pins exactly one with no fallback. Machines behind a proxy set `HTTPS_PROXY` or `modlens config set proxy <url>` and the API providers route through it. An internal endpoint can opt out with `modlens config set openai.proxy ""`. Details: the [CLI manual](docs/cli.md) for defaults and flags, [Configuration](skills/modlens/references/configure.md) for every key, and [Security](docs/security.md) for who fetches what on remote URLs.
121
130
 
122
131
  ## See it work
123
132
 
package/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@ DeepSeek 的主力对话模型和 GLM-5.3 本体仍是纯文本,无法读图
33
33
 
34
34
  ## 亮点
35
35
 
36
- **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令即刻安装 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.25.3`。更多安装与更新细节参考 [配置手册](docs/harness-setup.zh-CN.md) 。如果用不惯命令行,也想想玩玩 DSH,推荐食用全网最轻量级的 DeepSeek Harness 桌面版封装 <a href="https://github.com/liustack/aimanager"><b> AIManager</b></a>,零代码零配置起手,一键帮你安装所有依赖环境。
36
+ **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令即刻安装 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.0`。更多安装与更新细节参考 [配置手册](docs/harness-setup.zh-CN.md) 。如果用不惯命令行,也想想玩玩 DSH,推荐食用全网最轻量级的 DeepSeek Harness 桌面版封装 <a href="https://github.com/liustack/aimanager"><b> AIManager</b></a>,零代码零配置起手,一键帮你安装所有依赖环境。
37
37
 
38
38
  DeepSeek Harness 粘贴识图有两种玩法。
39
39
 
@@ -53,13 +53,22 @@ DeepSeek Harness 粘贴识图有两种玩法。
53
53
 
54
54
  ## 在其他 Harness 中安装
55
55
 
56
- **第一步,交给你的 AI。** 把这句话发给它:
56
+ **方式一,通过 [skills.sh](https://skills.sh/) 一条命令安装:**
57
+
58
+ ```bash
59
+ npx -y skills add liustack/modlens --skill modlens --global
60
+ ```
61
+
62
+ 这会把 `modlens` skill 安装到用户级目录。重启 Harness 后,让 AI 配置
63
+ modlens 并运行体检即可。
64
+
65
+ **方式二,把安装交给你的 AI。** 把这句话发给它:
57
66
 
58
67
  > 按 https://github.com/liustack/modlens 的 INSTALL.md 安装并配置 modlens skill,完成后运行体检并把结果告诉我。
59
68
 
60
69
  安装会先盘点你机器上已有的东西。Claude Code、Codex、OpenCode 或 Pi 里任何一个已有的登录态都可能就够了:modlens 复用前一定先征得你同意,体检报告会说清现状。
61
70
 
62
- **第二步,只在体检两手空空时,才需要你配一个免费引擎。** 推荐免费的 Gemini api key(到 [Google AI Studio](https://aistudio.google.com) 领取,约三分钟,无需信用卡),配上后每次识别 5 到 10 秒。其他平台的免费 openai 兼容 key 也行。想完全免注册就装 Antigravity CLI,然后完成登录:
71
+ **两种方式完成后,只在体检两手空空时,才需要你配一个免费引擎。** 推荐免费的 Gemini api key(到 [Google AI Studio](https://aistudio.google.com) 领取,约三分钟,无需信用卡),配上后每次识别 5 到 10 秒。其他平台的免费 openai 兼容 key 也行。想完全免注册就装 Antigravity CLI,然后完成登录:
63
72
 
64
73
  ```bash
65
74
  curl -fsSL https://antigravity.google/cli/install.sh | bash
@@ -71,7 +80,7 @@ agy # 浏览器完成
71
80
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
72
81
 
73
82
  ```sh
74
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.25.3
83
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.0
75
84
  ```
76
85
 
77
86
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
@@ -129,7 +138,7 @@ modlens config set openai.model qwen3-vl-plus
129
138
 
130
139
  ### 选择与路由
131
140
 
132
- 两个旋钮:`modlens config set provider <name>` 表达偏好(链继续兜底),`-p <name>` 钉死单个不回退。代理环境设 `HTTPS_PROXY` 或 `modlens config set proxy <url>`,API provider 自动走代理。细节见 [CLI 手册](docs/cli.zh-CN.md)(默认模型与参数)、[配置手册](skills/modlens/references/configure.zh-CN.md)(全部配置键)、[安全说明](docs/security.zh-CN.md)(远程 URL 由谁抓取)。
141
+ 两个旋钮:`modlens config set provider <name>` 表达偏好(链继续兜底),`-p <name>` 钉死单个不回退。代理环境设 `HTTPS_PROXY` 或 `modlens config set proxy <url>`,API provider 自动走代理。内网端点可用 `modlens config set openai.proxy ""` 单独改为直连。细节见 [CLI 手册](docs/cli.zh-CN.md)(默认模型与参数)、[配置手册](skills/modlens/references/configure.zh-CN.md)(全部配置键)、[安全说明](docs/security.zh-CN.md)(远程 URL 由谁抓取)。
133
142
 
134
143
  ## 实测
135
144
 
package/dist/main.js CHANGED
@@ -443,9 +443,9 @@ async function readCapped(response, url) {
443
443
  return Buffer.concat(chunks);
444
444
  }
445
445
  function apiProxyDispatcher(explicitProxy, env) {
446
- const proxy = explicitProxy?.trim();
447
- if (proxy) {
448
- return new ProxyAgent(proxy);
446
+ if (explicitProxy !== void 0) {
447
+ const proxy = explicitProxy.trim();
448
+ return proxy ? new ProxyAgent(proxy) : void 0;
449
449
  }
450
450
  if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
451
451
  return new EnvHttpProxyAgent();
@@ -1560,6 +1560,11 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
1560
1560
  mergeExtraBody(
1561
1561
  {
1562
1562
  model,
1563
+ // Gateways disagree on the default: some treat a
1564
+ // missing stream as SSE, and response.json() then
1565
+ // fails on the data: prefix. Named here so the
1566
+ // request is JSON regardless of the gateway.
1567
+ stream: false,
1563
1568
  // Asked for, never assumed: a gateway without
1564
1569
  // structured-output support answers 400 for a field
1565
1570
  // it does not know (issue #37). A response_format the
@@ -1774,7 +1779,7 @@ function providerConfiguredInFile(providerName, config2) {
1774
1779
  function resolveProviderSettings(providerName, config2, env = process.env) {
1775
1780
  const mentioned = providerConfiguredInFile(providerName, config2);
1776
1781
  const settings = mentioned ? { ...fileSettingsFor(providerName, config2) } : envSettingsFor(providerName, env);
1777
- if (!settings.proxy && typeof config2.proxy === "string" && config2.proxy.trim()) {
1782
+ if (!Object.hasOwn(settings, "proxy") && typeof config2.proxy === "string" && config2.proxy.trim()) {
1778
1783
  settings.proxy = config2.proxy.trim();
1779
1784
  }
1780
1785
  return settings;
@@ -2252,7 +2257,7 @@ function renderEffectiveConfig(config2, env = process.env) {
2252
2257
  fields[field] = `(malformed: not a string) (${source})`;
2253
2258
  continue;
2254
2259
  }
2255
- const shown = field === "apiKey" ? maskKeys(value) : field === "proxy" ? maskUrlCredentials(guard(value)) : guard(value);
2260
+ const shown = field === "apiKey" ? maskKeys(value) : field === "proxy" ? value.trim() === "" ? "direct" : maskUrlCredentials(guard(value)) : guard(value);
2256
2261
  fields[field] = `${shown} (${source})`;
2257
2262
  }
2258
2263
  if (fileSettings.structuredOutput !== void 0) {
@@ -5557,7 +5562,7 @@ function parsePositiveInt(raw, flag) {
5557
5562
  }
5558
5563
  return Number.parseInt(raw, 10);
5559
5564
  }
5560
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.25.3");
5565
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.26.0");
5561
5566
  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(
5562
5567
  "--extra-body <json>",
5563
5568
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -5668,7 +5673,7 @@ program.command("doctor").description(
5668
5673
  configPath: CONFIG_PATH,
5669
5674
  // Lets doctor name an installed skill copy that is older than
5670
5675
  // the CLI reporting on it (issue #33).
5671
- version: "3.25.3"
5676
+ version: "3.26.0"
5672
5677
  });
5673
5678
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
5674
5679
  process.stdout.write(`${output}
@@ -5681,7 +5686,7 @@ program.command("doctor").description(
5681
5686
  process.exitCode = 1;
5682
5687
  }
5683
5688
  });
5684
- const config = program.command("config").description(`Manage ${CONFIG_PATH} (providers, keys, models)`);
5689
+ const config = program.command("config").description(`Manage ${CONFIG_PATH} (providers, keys, models, proxies)`);
5685
5690
  config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).option("--force", "Overwrite an existing config file").action((options) => {
5686
5691
  try {
5687
5692
  initConfigFile(CONFIG_PATH, Boolean(options.force));
@@ -5691,7 +5696,8 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
5691
5696
  "Everything is optional. The usual ones:",
5692
5697
  " modlens config set provider <name> which provider analyzes images",
5693
5698
  " modlens config set cooldown on|off quota cooldown (on by default)",
5694
- " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider settings",
5699
+ " modlens config set <provider>.<apiKey|baseUrl|model|proxy> <value> provider settings",
5700
+ ' modlens config set openai.proxy "" make one API provider connect directly',
5695
5701
  ` modlens config set <provider>.extraBody '{"thinking":{"type":"disabled"}}' vendor request fields`,
5696
5702
  ""
5697
5703
  ].join("\n")
@@ -5759,7 +5765,7 @@ config.command("set <key> [value]").description(
5759
5765
  process.exitCode = 1;
5760
5766
  }
5761
5767
  });
5762
- config.command("show").description("Print the effective config (file merged with env vars), API keys masked").action(() => {
5768
+ config.command("show").description("Print the effective config (file merged with env vars), credentials masked").action(() => {
5763
5769
  try {
5764
5770
  process.stdout.write(`${renderEffectiveConfig(loadConfigFile())}
5765
5771
  `);
package/docs/cli.md CHANGED
@@ -98,7 +98,6 @@ Six providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `o
98
98
  Other subcommands:
99
99
 
100
100
  - `modlens guard [--model <id>]`: should the engine run for the active model at all? Exit 0 allow, 1 deny, verdict as JSON.
101
- - `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), `cooldown` (`on` or `off`, on by default), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`, plus `openai.structuredOutput` (that route only). `apiKey` accepts a comma-separated list and rotates after authentication, rate-limit, or quota failures.
101
+ - `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), `cooldown` (`on` or `off`, on by default), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`, plus `openai.structuredOutput` (that route only). A provider proxy URL overrides the shared route. An explicit empty provider proxy, for example `modlens config set openai.proxy ""`, forces that provider to connect directly. `apiKey` accepts a comma-separated list and rotates after authentication, rate-limit, or quota failures.
102
102
  - `modlens state clear`: forget every provider cooldown in `~/.modlens/state.json`, so all providers are tried at full priority again.
103
103
  - `modlens doctor`: Node and node:sqlite, provider readiness (including API key counts), the failover chains for this machine, the cooldown switch and any cooling keys, 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.
104
-
package/docs/cli.zh-CN.md CHANGED
@@ -95,6 +95,6 @@ modlens recover-paste # pull a pasted image into a fil
95
95
  其他子命令:
96
96
 
97
97
  - `modlens guard [--model <id>]`:判断当前激活的模型到底该不该运行引擎。退出码 0 表示放行,1 表示拒绝,判定结果以 JSON 输出。
98
- - `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`)、`cooldown`(`on` 或 `off`,默认打开)、`reuse.<claude|codex|opencode|pi|grok>`、`guards.<denyModels|allowModels|denyWhenUnknown>`,以及 `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`,另有 `openai.structuredOutput`(仅这条路线用得上)。`apiKey` 接受英文逗号分隔的列表,鉴权、限流或配额失败后会轮换。
98
+ - `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`)、`cooldown`(`on` 或 `off`,默认打开)、`reuse.<claude|codex|opencode|pi|grok>`、`guards.<denyModels|allowModels|denyWhenUnknown>`,以及 `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`,另有 `openai.structuredOutput`(仅这条路线用得上)。provider 的代理地址会覆盖共享代理。显式传入空值,例如 `modlens config set openai.proxy ""`,会让该 provider 强制直连。`apiKey` 接受英文逗号分隔的列表,鉴权、限流或配额失败后会轮换。
99
99
  - `modlens state clear`:忘掉 `~/.modlens/state.json` 里每一条 provider 冷却,让所有 provider 重新按完整优先级尝试。
100
100
  - `modlens doctor`:报告 Node 与 node:sqlite、各 provider 的就绪状态(含密钥数量)、本机的故障转移链、冷却开关和正在冷却的密钥、检测到的 harness、guard 规则和一次现场判定,以及 Reuse 一节里按 harness 的授权决定与发现的视觉能力。不花任何额度,`--json` 输出机器可读报告。
@@ -55,25 +55,75 @@ 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.25.3
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.0
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
+ ### Third-party text models
64
+
65
+ The vision bridge is not restricted to the official DeepSeek API. By default,
66
+ modlens discovers every registered provider carrying eligible `deepseek`, `glm`
67
+ or `mimo` model IDs, including third-party routes. Select the model under the
68
+ provider group marked `(modlens vision)` to use it. Pasting, dragging an image
69
+ or using dsh's attachment button then follows the host's normal attachment
70
+ flow, with images converted to evidence before the text model is called.
71
+
72
+ To include other text models, edit the active profile's
73
+ `$DSH_HOME/profiles/<name>/cordis.patch.yml` (`DSH_HOME` defaults to `~/.dsh`).
74
+ Add an override for the installed plugin's existing ID, not another `insert`:
75
+
76
+ ```yaml
77
+ - id: modlens
78
+ config:
79
+ families: ['*']
80
+ discover: ['openrouter', 'nvidia', 'ark-deepseek']
81
+ ```
82
+
83
+ Use the actual registered provider IDs in `discover`, not their display names.
84
+ Omit `discover` to inspect all registered routes. Keep any other patch entries
85
+ already in the file. This is dsh plugin configuration, not
86
+ `~/.modlens/config.json`, which configures the engine reading the image.
87
+
88
+ `families: ['*']` is opt-in. It requires the upstream model metadata to explicitly
89
+ include `text` in `inputModalities` and exclude `image`. Unknown or empty
90
+ modalities do not qualify. Existing native-vision name exclusions still apply,
91
+ and MiMo still requires a `-pro` segment. Native vision models should declare
92
+ `image` in their upstream configuration and use their original entry.
93
+
94
+ For a narrower selection, set explicit lowercase model-ID prefixes, for example
95
+ `families: ['deepseek', 'glm', 'mimo', 'ling', 'qwen']`. This replaces the default
96
+ list. Prefixes match both the full model ID and the ID after removing a leading
97
+ `~` and its vendor namespace. Explicit prefixes retain the existing behavior
98
+ when modalities are absent, so use them only for models you know are text-only.
99
+ Declared image input and the native-vision exclusions still veto the wrapper.
100
+
101
+ Check the composed configuration, restart dsh, then select the wrapper entry:
102
+
103
+ ```sh
104
+ npx -y @deepseek-ai/dsh --profile <name> --dump-config
105
+ ```
106
+
107
+ If a third-party DeepSeek entry is missing, check the plugin version in that
108
+ profile, the actual model ID rather than its display name, any `upstream` or
109
+ `discover` restriction, and the model's declared modalities. A configured
110
+ `upstream` keeps single-route mode instead of discovery.
111
+
63
112
  ### Configuring the engine from the web UI
64
113
 
65
114
  dsh web users have no terminal in front of them, so the engine settings have a
66
115
  card in **Settings → Plugins → Plugin configuration**: which engine reads
67
- images, its key, endpoint and model, and which local sign-ins a read may
116
+ images, its key, endpoint, model and proxy route, and which local sign-ins a read may
68
117
  borrow. Expanding it probes this machine and lists the harnesses it actually
69
118
  found, so the grants are a choice between real options rather than five names.
70
119
 
71
120
  The values live where they always did, in `~/.modlens/config.json`, shared
72
121
  with every other harness: the card reads and writes that file through a
73
122
  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.
123
+ The card never receives a stored key or proxy URL, only whether the key is set
124
+ and whether proxy routing inherits, connects directly, or uses a custom URL.
125
+ Leaving either secret field empty keeps the stored value. `settingsCard: false`
126
+ in the plugin row removes it, route included.
77
127
 
78
128
  The card's own text follows dsh's interface language setting, switching
79
129
  between Chinese and English as you switch dsh. On a dsh too old to expose that
@@ -87,7 +137,7 @@ modlens ships often, and both install shapes freeze at whatever version they
87
137
  got. On dsh, re-run the install with the version named:
88
138
 
89
139
  ```sh
90
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.25.3
140
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.0
91
141
  ```
92
142
 
93
143
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -55,16 +55,59 @@ 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.25.3
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.0
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
+ 视觉桥接不限于 DeepSeek 官方 API。默认会自动发现所有已注册 provider 中符合条件的
66
+ `deepseek`、`glm`、`mimo` 模型 ID,包括第三方转发渠道。选择带有
67
+ `(modlens vision)` 的 provider 分组下的模型,粘贴、拖拽图片和附件按钮就会走
68
+ DSH 原生附件流程,在调用文本模型前转成证据文本。
69
+
70
+ 需要接入其他文本模型时,编辑当前 profile 的
71
+ `$DSH_HOME/profiles/<name>/cordis.patch.yml`。`DSH_HOME` 默认是 `~/.dsh`。
72
+ 按已安装插件的 ID 添加覆盖项,不要再写一条 `insert`:
73
+
74
+ ```yaml
75
+ - id: modlens
76
+ config:
77
+ families: ['*']
78
+ discover: ['openrouter', 'nvidia', 'ark-deepseek']
79
+ ```
80
+
81
+ `discover` 填实际注册的 provider ID,不是界面展示名。省略它则检查全部已注册渠道。
82
+ 保留文件中已有的其他配置项。这是 DSH 插件配置,不是 `~/.modlens/config.json`,
83
+ 后者配置的是实际读取图片的视觉引擎。
84
+
85
+ `families: ['*']` 需要主动开启。上游模型元数据的 `inputModalities` 必须明确包含
86
+ `text` 且不包含 `image`,缺失或空数组都不会自动获得变体。原有视觉模型名称排除规则
87
+ 仍然生效,MiMo 仍须带有 `-pro` 段。原生视觉模型应在上游配置中正确声明 `image`,
88
+ 并使用原模型条目。
89
+
90
+ 也可以缩小到明确的小写模型 ID 前缀,例如
91
+ `families: ['deepseek', 'glm', 'mimo', 'ling', 'qwen']`。该列表会替换默认值。
92
+ 前缀既匹配完整模型 ID,也匹配移除开头的 `~` 和厂商命名空间后的 ID。
93
+ 显式前缀保留原有行为,元数据缺失时也能匹配,因此只添加已确认是文本模型的前缀。
94
+ 声明支持图片的模型和命中原有视觉排除规则的模型仍不会获得变体。
95
+
96
+ 检查最终配置,重启 DSH,再选择变体:
97
+
98
+ ```sh
99
+ npx -y @deepseek-ai/dsh --profile <name> --dump-config
100
+ ```
101
+
102
+ 第三方 DeepSeek 没有出现变体时,检查该 profile 的插件版本、实际模型 ID 而非展示名、
103
+ 是否有 `upstream` 或 `discover` 限制,以及上游声明的输入能力。
104
+ 配置了 `upstream` 时沿用单渠道模式,不进行自动发现。
105
+
63
106
  ### 在网页界面里配置引擎
64
107
 
65
- dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在**设置 → 插件 → 插件配置**里:用哪个引擎读图、它的密钥、地址和模型,以及一次读取可以借用本机哪些已有登录。展开时会探测本机,只列出真正找到的 harness,让授权是在真实选项之间做选择,而不是面对五个名字。
108
+ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在**设置 → 插件 → 插件配置**里:用哪个引擎读图、它的密钥、地址、模型和代理方式,以及一次读取可以借用本机哪些已有登录。展开时会探测本机,只列出真正找到的 harness,让授权是在真实选项之间做选择,而不是面对五个名字。
66
109
 
67
- 这些值仍然住在 `~/.modlens/config.json`,与其他所有 harness 共享:卡片通过一条回环路由读写那个文件,所以在这里改一笔,和 `modlens config set` 改的是同一笔。卡片从不拿到已保存的密钥,只知道有没有;密钥框留空就不动已存的那个。在插件配置行里设 `settingsCard: false` 可以连同路由一起去掉它。
110
+ 这些值仍然住在 `~/.modlens/config.json`,与其他所有 harness 共享:卡片通过一条回环路由读写那个文件,所以在这里改一笔,和 `modlens config set` 改的是同一笔。卡片从不拿到已保存的密钥或代理地址,只知道密钥有没有,以及代理是继承、直连还是使用专属地址。密钥或代理输入框留空就不动已存的值。在插件配置行里设 `settingsCard: false` 可以连同路由一起去掉它。
68
111
 
69
112
  卡片自己的文案跟随 dsh 的界面语言设置,你在 dsh 里切换语言,卡片跟着切。dsh 版本太旧、拿不到这个设置时,退回跟随浏览器语言。失败时服务端回的那句话保持原样(例如未知引擎、打不开的路径),那是诊断信息,不是界面文案。
70
113
 
@@ -73,7 +116,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
73
116
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
74
117
 
75
118
  ```sh
76
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.25.3
119
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.0
77
120
  ```
78
121
 
79
122
  `npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
@@ -163,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
163
163
  as a deliberate request rather than a resolution:
164
164
 
165
165
  ```sh
166
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.25.3
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.0
167
167
  ```
168
168
 
169
169
  `npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
@@ -178,7 +178,7 @@ file:
178
178
 
179
179
  ```yaml
180
180
  minimumReleaseAgeExclude:
181
- - '@liustack/modlens@3.25.3'
181
+ - '@liustack/modlens@3.26.0'
182
182
  ```
183
183
 
184
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -230,8 +230,15 @@ in either form:
230
230
  HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api # env (NO_PROXY honored too)
231
231
  modlens config set proxy http://127.0.0.1:7890 # persistent, all API providers
232
232
  modlens config set openai.proxy http://127.0.0.1:7890 # one provider only
233
+ modlens config set openai.proxy "" # this provider connects directly
233
234
  ```
234
235
 
236
+ The provider field has three states. Missing inherits the shared config or
237
+ environment proxy, an empty string means direct, and a URL is a provider-only
238
+ proxy. This matters when an internet provider needs the shared proxy but an
239
+ internal endpoint must stay direct. Failover still tries both providers, but a
240
+ shared dead proxy otherwise makes both attempts fail the same way.
241
+
235
242
  The proxy applies to API provider requests only. The remote-image download
236
243
  path keeps its direct, IP-pinned connection on purpose: its SSRF guards
237
244
  validate the exact address being contacted, and a proxy would blind them. On
@@ -144,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
144
144
  `@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
145
145
 
146
146
  ```sh
147
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.25.3
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.0
148
148
  ```
149
149
 
150
150
  `npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
@@ -153,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.25.3
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.25.3'
156
+ - '@liustack/modlens@3.26.0'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
@@ -188,8 +188,11 @@ API 请求根本没离开这台机器。在要靠代理才能上网的网络里
188
188
  HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api # env (NO_PROXY honored too)
189
189
  modlens config set proxy http://127.0.0.1:7890 # persistent, all API providers
190
190
  modlens config set openai.proxy http://127.0.0.1:7890 # one provider only
191
+ modlens config set openai.proxy "" # 这个 provider 强制直连
191
192
  ```
192
193
 
194
+ provider 代理字段有三种状态。字段缺失表示继承共享配置或环境代理,空字符串表示直连,URL 表示只给该 provider 使用的代理。当外部 provider 需要共享代理,而内网端点必须直连时,这个区别很重要。故障转移仍然会尝试两者,但共享的失效代理会让两个尝试以同一种方式失败。
195
+
193
196
  代理只作用于 API provider 的请求。远程图片的下载路径有意保持直连并钉死 IP:它的 SSRF 防护校验的正是实际连接的那个地址,加了代理这些防护就失明了。在必须走代理的机器上,优先用本地文件,或让故障转移链把远程 URL 交给会在上游自行抓取的 provider。
194
197
 
195
198
  ## 配置文件问题
package/dsh/client.js CHANGED
@@ -196,6 +196,14 @@ window.__ModuleLoader__.load({
196
196
  'Separate multiple keys with commas. ModLens rotates to the next key after authentication, rate-limit, or quota failures.',
197
197
  baseUrl: 'Base URL',
198
198
  model: 'Model',
199
+ proxyRoute: 'Proxy route',
200
+ proxyInherit: 'Inherit global or environment proxy',
201
+ proxyDirect: 'Direct connection',
202
+ proxyCustom: 'Custom proxy',
203
+ proxyUrl: 'Proxy URL',
204
+ proxyStored: 'stored, leave empty to keep it',
205
+ proxyExample: 'http://127.0.0.1:7890',
206
+ proxyHint: 'Direct ignores the global proxy and HTTP_PROXY / HTTPS_PROXY.',
199
207
  stored: 'stored, leave empty to keep it',
200
208
  unset: 'not set',
201
209
  fallback: 'provider default',
@@ -224,6 +232,14 @@ window.__ModuleLoader__.load({
224
232
  apiKeyHint: '多个密钥用英文逗号分隔。鉴权、限流或配额失败时会自动轮换到下一个密钥。',
225
233
  baseUrl: '接口地址',
226
234
  model: '模型',
235
+ proxyRoute: '代理方式',
236
+ proxyInherit: '继承全局代理或环境变量',
237
+ proxyDirect: '直连',
238
+ proxyCustom: '使用专属代理',
239
+ proxyUrl: '代理地址',
240
+ proxyStored: '已保存,留空即不改动',
241
+ proxyExample: 'http://127.0.0.1:7890',
242
+ proxyHint: '直连会忽略全局代理与 HTTP_PROXY / HTTPS_PROXY。',
227
243
  stored: '已保存,留空即不改动',
228
244
  unset: '未设置',
229
245
  fallback: '使用该引擎默认值',
@@ -266,8 +282,8 @@ window.__ModuleLoader__.load({
266
282
  return detail || fallback
267
283
  }
268
284
 
269
- // The next draft when the engine changes or a summary arrives. The three
270
- // engine fields belong to the newly selected engine; the reuse grants are
285
+ // The next draft when the engine changes or a summary arrives. The engine
286
+ // fields belong to the newly selected engine; the reuse grants are
271
287
  // the user's pending answers and survive an engine switch, since granting
272
288
  // codex has nothing to do with which engine reads the images.
273
289
  function nextDraft(summary, provider, keepReuse) {
@@ -280,6 +296,10 @@ window.__ModuleLoader__.load({
280
296
  apiKey: '',
281
297
  baseUrl: engine.baseUrl,
282
298
  model: engine.model,
299
+ proxyMode: engine.proxyMode || 'inherit',
300
+ // A proxy URL can carry credentials, so the host reports only its
301
+ // mode. Blank means keep the stored custom URL unless one is typed.
302
+ proxy: '',
283
303
  reuse: Object.assign({}, keepReuse || summary.reuse),
284
304
  }
285
305
  }
@@ -299,42 +319,57 @@ window.__ModuleLoader__.load({
299
319
  payload.provider = draft.provider
300
320
  }
301
321
  var pristine = nextDraft(summary, draft.provider, draft.reuse)
302
- var engineEdited = draft.apiKey !== '' || draft.baseUrl !== pristine.baseUrl || draft.model !== pristine.model
322
+ var apiKey = draft.apiKey || ''
323
+ var baseUrl = draft.baseUrl || ''
324
+ var model = draft.model || ''
325
+ var proxyMode = draft.proxyMode || 'inherit'
326
+ var proxy = draft.proxy || ''
327
+ var apiKeyEdited = apiKey !== ''
328
+ var baseUrlEdited = baseUrl !== pristine.baseUrl
329
+ var modelEdited = model !== pristine.model
330
+ var proxyEdited = proxyMode !== pristine.proxyMode || proxy !== ''
331
+ var engineEdited = apiKeyEdited || baseUrlEdited || modelEdited || proxyEdited
303
332
  if (draft.provider !== '' && engineEdited) {
304
333
  payload.engine = draft.provider
305
- payload.apiKey = draft.apiKey
306
- payload.baseUrl = draft.baseUrl
307
- payload.model = draft.model
334
+ if (apiKeyEdited) payload.apiKey = apiKey
335
+ if (baseUrlEdited) payload.baseUrl = baseUrl
336
+ if (modelEdited) payload.model = model
337
+ if (proxyEdited) {
338
+ payload.proxyMode = proxyMode
339
+ // Once the route is inherit or direct, a custom URL is irrelevant
340
+ // and may contain credentials. Do not put stale input on the wire.
341
+ payload.proxy = proxyMode === 'custom' ? proxy : ''
342
+ }
308
343
  }
309
344
  return payload
310
345
  }
311
346
 
312
347
  /**
313
- * How to render the API key field so the characters are hidden.
348
+ * How to render key and proxy credential fields with hidden characters.
314
349
  *
315
350
  * A real password input makes Safari's iCloud Keychain offer to enable
316
351
  * autofill for the site and then pop its bubble on every focus, for a
317
- * field that is always empty: the key lives in the config file and the
352
+ * field that is always empty: the secret lives in the config file and the
318
353
  * host never sends it here, only whether one is stored. `autocomplete`
319
354
  * cannot turn that off, because WebKit ignores it on password fields on
320
355
  * purpose (issue #56). Masking with text-security gets the same hidden
321
- * characters without ever being a password field, and it also keeps a key
322
- * meant for one machine out of a synced keychain.
356
+ * characters without ever being a password field, and it also keeps a
357
+ * machine-local secret out of a synced keychain.
323
358
  *
324
359
  * Feature-detected rather than assumed. Where the property is missing the
325
360
  * field stays a password input: the nuisance is worth more than the
326
- * alternative, which is somebody's API key rendered in clear text while
327
- * they type it.
361
+ * alternative, which is somebody's credential rendered in clear text
362
+ * while they type it.
328
363
  *
329
364
  * This is a trade, not a free win, and the cost falls on people who are
330
365
  * not in the room. A password input carries a protected state into the
331
366
  * accessibility tree, and screen readers stop reading characters back
332
367
  * because of it. Masking is only paint: VoiceOver and NVDA will read this
333
- * key aloud, and ARIA has no equivalent to restore. Selection and copy
368
+ * secret aloud, and ARIA has no equivalent to restore. Selection and copy
334
369
  * also become possible, and an IME candidate window shows what is being
335
370
  * typed above the field. Accepted here because the field is empty in
336
- * normal use (the key lives in the config file and is never sent to the
337
- * browser), so what a screen reader can read back is what the user is
371
+ * normal use (the stored value is never sent to the browser), so what a
372
+ * screen reader can read back is what the user is
338
373
  * typing at that moment, not a stored secret.
339
374
  */
340
375
  /**
@@ -549,7 +584,13 @@ window.__ModuleLoader__.load({
549
584
  draft.apiKey !== '' ||
550
585
  draft.baseUrl !== pristine.baseUrl ||
551
586
  draft.model !== pristine.model ||
587
+ draft.proxyMode !== pristine.proxyMode ||
588
+ draft.proxy !== '' ||
552
589
  REUSE.some((name) => draft.reuse[name] !== summary.reuse[name])
590
+ var customProxyMissing =
591
+ draft.proxyMode === 'custom' && current.proxyMode !== 'custom' && draft.proxy.trim() === ''
592
+ var canDiscard = dirty && note !== t.saving
593
+ var canSave = canDiscard && !customProxyMissing
553
594
 
554
595
  var set = (key, value) => {
555
596
  var next = Object.assign({}, draft)
@@ -699,6 +740,55 @@ window.__ModuleLoader__.load({
699
740
  ),
700
741
  draft.provider === '' || keyless ? null : textField(t.baseUrl, 'baseUrl', 'text', t.fallback),
701
742
  draft.provider === '' ? null : textField(t.model, 'model', 'text', t.fallback),
743
+ draft.provider === '' || keyless
744
+ ? null
745
+ : fieldRow(
746
+ t.proxyRoute,
747
+ h(
748
+ 'select',
749
+ {
750
+ name: 'proxyMode',
751
+ value: draft.proxyMode,
752
+ onChange: (event) => set('proxyMode', event.target.value),
753
+ style: {
754
+ appearance: 'none',
755
+ width: '100%',
756
+ padding: '8px 12px',
757
+ borderRadius: '8px',
758
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
759
+ background: 'transparent',
760
+ color: 'inherit',
761
+ font: 'inherit',
762
+ fontSize: '13px',
763
+ },
764
+ },
765
+ [
766
+ h('option', { key: 'inherit', value: 'inherit' }, t.proxyInherit),
767
+ h('option', { key: 'direct', value: 'direct' }, t.proxyDirect),
768
+ h('option', { key: 'custom', value: 'custom' }, t.proxyCustom),
769
+ ],
770
+ ),
771
+ 'proxy-mode',
772
+ ),
773
+ draft.provider === '' || keyless
774
+ ? null
775
+ : fieldRow(
776
+ '',
777
+ h(
778
+ 'div',
779
+ {
780
+ style: {
781
+ fontSize: '13px',
782
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
783
+ },
784
+ },
785
+ t.proxyHint,
786
+ ),
787
+ 'proxy-hint',
788
+ ),
789
+ draft.provider === '' || keyless || draft.proxyMode !== 'custom'
790
+ ? null
791
+ : secretField(t.proxyUrl, 'proxy', current.proxyMode === 'custom' ? t.proxyStored : t.proxyExample),
702
792
  // Where these values are coming from, said once, because the
703
793
  // first save moves them: an engine the file names takes its
704
794
  // settings from the file alone.
@@ -794,7 +884,7 @@ window.__ModuleLoader__.load({
794
884
  'button',
795
885
  {
796
886
  type: 'button',
797
- disabled: !dirty || note === t.saving,
887
+ disabled: !canDiscard,
798
888
  onClick: () => {
799
889
  draftState[1](seed(summary, summary.provider))
800
890
  noteState[1]('')
@@ -804,13 +894,13 @@ window.__ModuleLoader__.load({
804
894
  font: 'inherit',
805
895
  fontSize: '13px',
806
896
  lineHeight: 1.5,
807
- cursor: dirty ? 'pointer' : 'default',
897
+ cursor: canDiscard ? 'pointer' : 'default',
808
898
  border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
809
899
  borderRadius: '8px',
810
900
  padding: '5px 14px',
811
901
  background: 'none',
812
902
  color: 'var(--dsw-alias-label-secondary, inherit)',
813
- opacity: dirty ? 1 : 0.4,
903
+ opacity: canDiscard ? 1 : 0.4,
814
904
  },
815
905
  },
816
906
  t.discard,
@@ -819,7 +909,7 @@ window.__ModuleLoader__.load({
819
909
  'button',
820
910
  {
821
911
  type: 'button',
822
- disabled: !dirty || note === t.saving,
912
+ disabled: !canSave,
823
913
  onClick: () => {
824
914
  noteState[1](t.saving)
825
915
  var payload = savePayload(summary, draft)
@@ -856,13 +946,13 @@ window.__ModuleLoader__.load({
856
946
  font: 'inherit',
857
947
  fontSize: '13px',
858
948
  lineHeight: 1.5,
859
- cursor: dirty ? 'pointer' : 'default',
949
+ cursor: canSave ? 'pointer' : 'default',
860
950
  border: '1px solid transparent',
861
951
  borderRadius: '8px',
862
952
  padding: '5px 14px',
863
953
  background: 'var(--dsw-alias-label-primary, currentColor)',
864
954
  color: 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
865
- opacity: dirty ? 1 : 0.4,
955
+ opacity: canSave ? 1 : 0.4,
866
956
  },
867
957
  },
868
958
  t.save,
package/dsh/index.js CHANGED
@@ -676,7 +676,16 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
676
676
  // to contain the word cannot veto the text model behind it.
677
677
  const unaliased = id.replace(/^~/, '')
678
678
  const bare = unaliased.slice(unaliased.lastIndexOf('/') + 1)
679
- if (!families.some((family) => id.startsWith(family) || bare.startsWith(family))) return false
679
+ const matchesFamily = families.some(
680
+ (family) => family !== '*' && (id.startsWith(family) || bare.startsWith(family)),
681
+ )
682
+ if (!matchesFamily) {
683
+ // Opting into all families is not evidence that an unknown model is
684
+ // text-only. Outside an explicitly named family, require the catalog
685
+ // to declare text input. The image and native-name vetoes still apply.
686
+ if (!families.includes('*') || !Array.isArray(info?.inputModalities) || !info.inputModalities.includes('text'))
687
+ return false
688
+ }
680
689
  if (VISION_ID.test(bare)) return false
681
690
  if (Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')) return false
682
691
  // MiMo needs the gate reversed. Xiaomi's bare version ids name native
@@ -810,6 +819,14 @@ function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
810
819
  stream: (options) => this.stream(options),
811
820
  }
812
821
  },
822
+ imageRequestPricing() {
823
+ // dsh >= 0.1.2 calls this with no feature check, same as
824
+ // prepareCall. Real adapters inherit the base-class default
825
+ // that returns undefined. A plain object supplies it itself.
826
+ // The synthetic route has no provider-side image pricing of
827
+ // its own, so the token meter keeps its neutral estimate.
828
+ return undefined
829
+ },
813
830
  stream(options) {
814
831
  // Convert at request time, not at log time: the durable session
815
832
  // log keeps the real image blocks (so the UI shows the paste
@@ -1634,9 +1651,10 @@ function readModlensConfig() {
1634
1651
  }
1635
1652
 
1636
1653
  /**
1637
- * What the card is allowed to know. Every engine's endpoint and model, plus
1638
- * whether a key is stored, and never the key itself: a browser that cannot
1639
- * read a secret cannot leak one, and cannot write it back either.
1654
+ * What the card is allowed to know. Every engine's endpoint, model and proxy
1655
+ * mode, plus whether a key is stored. Neither keys nor proxy URLs cross into
1656
+ * the browser: both can carry credentials, and a browser that cannot read a
1657
+ * secret cannot leak one or write it back accidentally.
1640
1658
  */
1641
1659
  function engineSummary(config = readModlensConfig()) {
1642
1660
  const engines = {}
@@ -1655,6 +1673,11 @@ function engineSummary(config = readModlensConfig()) {
1655
1673
  baseUrl: typeof settings.baseUrl === 'string' ? settings.baseUrl : '',
1656
1674
  model: typeof settings.model === 'string' ? settings.model : '',
1657
1675
  hasKey: hasApiKeys(settings.apiKey),
1676
+ proxyMode: !Object.hasOwn(settings, 'proxy')
1677
+ ? 'inherit'
1678
+ : typeof settings.proxy === 'string' && settings.proxy.trim() === ''
1679
+ ? 'direct'
1680
+ : 'custom',
1658
1681
  // '' means neither source holds anything, which is not the same as the
1659
1682
  // file holding an empty entry: that one is already off its variables.
1660
1683
  source: inFile ? 'file' : Object.keys(settings).length > 0 ? 'env' : '',
@@ -1679,7 +1702,7 @@ function engineSummary(config = readModlensConfig()) {
1679
1702
 
1680
1703
  /**
1681
1704
  * Apply one card submission to the shared file. Only the named engine's own
1682
- * three fields are touched, so switching engines in the card cannot copy one
1705
+ * fields are touched, so switching engines in the card cannot copy one
1683
1706
  * engine's endpoint onto another. An absent or empty `apiKey` leaves the
1684
1707
  * stored one alone: the card never receives a key, so it must never be able
1685
1708
  * to clear one by submitting the blank field it was shown.
@@ -1726,6 +1749,7 @@ function applyEngineSettings(patch) {
1726
1749
  const seed = holders.length > 0 ? {} : engineEnvSettings(engine)
1727
1750
  const settings = { ...seed, ...config.providers[target] }
1728
1751
  for (const field of ['baseUrl', 'model']) {
1752
+ if (!Object.hasOwn(patch, field)) continue
1729
1753
  const value = typeof patch[field] === 'string' ? patch[field].trim() : ''
1730
1754
  if (value === '') {
1731
1755
  delete settings[field]
@@ -1737,6 +1761,36 @@ function applyEngineSettings(patch) {
1737
1761
  if (apiKey !== '') {
1738
1762
  settings.apiKey = apiKey
1739
1763
  }
1764
+ if (Object.hasOwn(patch, 'proxyMode')) {
1765
+ if (patch.proxyMode === 'inherit') {
1766
+ // Absence is the inheritance signal. Remove the field from every
1767
+ // alias-backed entry or an older alias value resurfaces when settings
1768
+ // are merged, even after the canonical entry says it was cleared.
1769
+ for (const holder of holders) {
1770
+ const stored = config.providers[holder]
1771
+ if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
1772
+ delete stored.proxy
1773
+ }
1774
+ }
1775
+ delete settings.proxy
1776
+ } else if (patch.proxyMode === 'direct') {
1777
+ settings.proxy = ''
1778
+ } else if (patch.proxyMode === 'custom') {
1779
+ const proxy = typeof patch.proxy === 'string' ? patch.proxy.trim() : ''
1780
+ if (proxy !== '') {
1781
+ settings.proxy = proxy
1782
+ } else {
1783
+ // The card reports the merged route, which may live under an alias
1784
+ // while the canonical entry owns only the model or endpoint.
1785
+ const storedProxy = Object.assign({}, ...holders.map((key) => config.providers[key])).proxy
1786
+ if (typeof storedProxy !== 'string' || storedProxy.trim() === '') {
1787
+ throw new Error('custom proxy mode needs a proxy URL')
1788
+ }
1789
+ }
1790
+ } else {
1791
+ throw new Error(`unknown proxy mode: ${patch.proxyMode}`)
1792
+ }
1793
+ }
1740
1794
  config.providers[target] = settings
1741
1795
  }
1742
1796
  // Auto mode, when the card sent it: only the harnesses this build knows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.25.3",
3
+ "version": "3.26.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.25.3):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.26.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.25.3: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.25.3 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.25.3 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.26.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.26.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.26.0 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -13,6 +13,8 @@ modlens config init # write a starter config (refuses to ove
13
13
  modlens config show # effective file, API keys masked
14
14
  modlens config set provider <name> # change the default provider
15
15
  modlens config set <provider>.<field> <value> # fields: apiKey, baseUrl, model, proxy, extraBody, structuredOutput
16
+ modlens config set proxy http://127.0.0.1:7890 # default for every API provider
17
+ modlens config set openai.proxy "" # make one provider connect directly
16
18
  ```
17
19
 
18
20
  `config set` writes the file with 0600 permissions.
@@ -66,6 +68,8 @@ Field semantics:
66
68
 
67
69
  - `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `kimi`/`kimi-code` for `kimi-cli`, `claude-code` for `claude-cli`). Empty or absent pins nothing: the failover chain decides, trying configured API providers before the agent CLIs.
68
70
  - `cooldown`: `'on'` (default) or `'off'`. On, a quota-spent key is remembered in `~/.modlens/state.json` and tried last until it recovers (45 minutes by default, 24 hours for monthly HTTP 432/433, or the engine-reported `Resets in` clause). Off, that file is neither read nor written. `modlens state clear` forgets every cooldown.
71
+ - `proxy`: the default route for all API providers. When it is absent, `HTTPS_PROXY` or `HTTP_PROXY` supplies the default instead. `NO_PROXY` is honored on that environment route.
72
+ - `providers.<name>.proxy`: a three-state override. An absent field inherits the default above, an empty string forces a direct connection, and a non-empty URL selects a proxy for this provider alone. This lets an internal endpoint stay reachable when the shared proxy fails. To restore inheritance, remove the field from the file or choose the inherited route in the dsh settings card. `config show` renders the empty state as `direct`, and the card exposes all three choices without sending a stored proxy URL to the browser.
69
73
  - `providers.<name>.<field>`: six fields exist, `apiKey`, `baseUrl`, `model`, `proxy`, `extraBody`, and `structuredOutput` (the openai route only). Every provider entry is optional, and every field inside it is optional. Alias keys are read too (settings saved under `gemini` are found when `gemini-api` resolves), with the canonical key winning on conflict. `apiKey` accepts a comma-separated list. Requests use the configured order and rotate only after authentication, rate-limit, or quota failures. Other failures skip remaining keys and keep provider failover.
70
74
  - `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and each route's own enforcement machinery are refused with an error naming the field. `response_format` on the `openai` route is not one of them: setting it there deliberately replaces the schema modlens would otherwise send. The three CLI providers take no request body, so a run on `antigravity-cli`, `claude-cli` or `kimi-cli` ignores it and says so in `meta.warnings`.
71
75
  - `providers.openai.structuredOutput`: `true` asks an OpenAI-compatible gateway to enforce the vision contract itself, as `response_format: json_schema` in the strict form those endpoints require. Off by default, since a gateway without structured-output support answers 400 for the field. A `response_format` you set in `extraBody` wins over it.
@@ -13,6 +13,8 @@ modlens config init # 写入一份起步配置(已存在
13
13
  modlens config show # 生效的配置文件,API key 打码显示
14
14
  modlens config set provider <name> # 更改默认 provider
15
15
  modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、model、proxy、extraBody、structuredOutput
16
+ modlens config set proxy http://127.0.0.1:7890 # 所有 API provider 的默认代理
17
+ modlens config set openai.proxy "" # 让一个 provider 强制直连
16
18
  ```
17
19
 
18
20
  `config set` 写文件时权限为 0600。
@@ -66,6 +68,8 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
66
68
 
67
69
  - `provider`:不传 `-p` 时由哪个 provider 执行。标准名和别名都行(`agy`/`antigravity` 对应 `antigravity-cli`,`gemini` 对应 `gemini-api`,`openai-compat` 对应 `openai`,`claude` 对应 `anthropic`,`kimi`/`kimi-code` 对应 `kimi-cli`,`claude-code` 对应 `claude-cli`)。留空或缺失表示不钉任何一个:由失败切换链决定,已配置的 API provider 先于 agent CLI 被尝试。
68
70
  - `cooldown`:`'on'`(默认)或 `'off'`。打开时,配额耗尽的密钥会记入 `~/.modlens/state.json`,恢复前放到队尾再试(默认 45 分钟,月度 HTTP 432/433 为 24 小时,引擎回报的 `Resets in` 子句优先)。关闭时不读也不写那个文件。`modlens state clear` 会忘掉全部冷却。
71
+ - `proxy`:所有 API provider 的默认代理。它缺失时改由 `HTTPS_PROXY` 或 `HTTP_PROXY` 提供默认值,环境变量路线会遵守 `NO_PROXY`。
72
+ - `providers.<name>.proxy`:有三种状态。字段缺失表示继承上述默认值,空字符串表示强制直连,非空 URL 表示只给这个 provider 使用的代理。这样共享代理失效时,内网端点仍然可以直连。要恢复继承,删除配置文件中的这个字段,或在 dsh 设置卡中选择继承。`config show` 会把空值显示成 `direct`,设置卡会提供三种选择,而且不会把已保存的代理地址发送给浏览器。
69
73
  - `providers.<name>.<field>`:共六个字段,`apiKey`、`baseUrl`、`model`、`proxy`、`extraBody`、`structuredOutput`(仅 openai 路线)。每个 provider 条目都可选,条目里的每个字段也都可选。别名键同样会被读取(存在 `gemini` 下的设置在解析到 `gemini-api` 时也能找到),冲突时标准键胜出。`apiKey` 接受英文逗号分隔的列表。请求按配置顺序使用,只在鉴权、限流或配额失败后轮换。其他失败会跳过剩余密钥,并继续走现有的 provider 故障转移。
70
74
  - `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和各路线自身强制机制的字段会被拒绝,报错会点名该字段。`openai` 路线上的 `response_format` 不在此列:在那里设置它就是有意替换掉 modlens 本来会发的那份 schema。三个 CLI provider 不发请求体,所以在 `antigravity-cli`、`claude-cli` 或 `kimi-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
71
75
  - `providers.openai.structuredOutput`:设为 `true` 时,让 OpenAI 兼容网关自己强制执行视觉契约,以 `response_format: json_schema` 的严格形式发出。默认关闭,因为不支持结构化输出的网关会对这个字段返回 400。你在 `extraBody` 里设的 `response_format` 优先级更高。
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.25.3
11
+ - Pinned CLI version: 3.26.0
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.25.3'
27
+ $Pinned = '3.26.0'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.25.3"
25
+ PINNED="3.26.0"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"