@liustack/modlens 3.25.4 → 3.26.1

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.26.1 - 2026-09-08
4
+
5
+ - **dsh paste-to-path writes into the Lexical composer ([#100](https://github.com/liustack/modlens/issues/100)).** dsh 0.1.2-rc.1 replaced the composer textarea with a Lexical contenteditable div. The paste listener still uploaded the image, then `insertText` returned immediately because it only accepted `TEXTAREA` and `INPUT`, so the path never landed and the console stayed quiet. Paste-to-path now resolves a writable target (textarea, input, or `[data-composer-input][contenteditable=true]`) before taking the event, inserts with `execCommand`, and logs the path if that insert fails. Thanks to @xp1205700819-sudo.
6
+
7
+ ## 3.26.0 - 2026-09-06
8
+
9
+ - **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)).
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+
3
14
  ## 3.25.4 - 2026-09-01
4
15
 
5
16
  - **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.
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.4`. 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.1`. 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.4`。更多安装与更新细节参考 [配置手册](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.1`。更多安装与更新细节参考 [配置手册](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.4
83
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1
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();
@@ -1779,7 +1779,7 @@ function providerConfiguredInFile(providerName, config2) {
1779
1779
  function resolveProviderSettings(providerName, config2, env = process.env) {
1780
1780
  const mentioned = providerConfiguredInFile(providerName, config2);
1781
1781
  const settings = mentioned ? { ...fileSettingsFor(providerName, config2) } : envSettingsFor(providerName, env);
1782
- if (!settings.proxy && typeof config2.proxy === "string" && config2.proxy.trim()) {
1782
+ if (!Object.hasOwn(settings, "proxy") && typeof config2.proxy === "string" && config2.proxy.trim()) {
1783
1783
  settings.proxy = config2.proxy.trim();
1784
1784
  }
1785
1785
  return settings;
@@ -2257,7 +2257,7 @@ function renderEffectiveConfig(config2, env = process.env) {
2257
2257
  fields[field] = `(malformed: not a string) (${source})`;
2258
2258
  continue;
2259
2259
  }
2260
- 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);
2261
2261
  fields[field] = `${shown} (${source})`;
2262
2262
  }
2263
2263
  if (fileSettings.structuredOutput !== void 0) {
@@ -5562,7 +5562,7 @@ function parsePositiveInt(raw, flag) {
5562
5562
  }
5563
5563
  return Number.parseInt(raw, 10);
5564
5564
  }
5565
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.25.4");
5565
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.26.1");
5566
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(
5567
5567
  "--extra-body <json>",
5568
5568
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -5673,7 +5673,7 @@ program.command("doctor").description(
5673
5673
  configPath: CONFIG_PATH,
5674
5674
  // Lets doctor name an installed skill copy that is older than
5675
5675
  // the CLI reporting on it (issue #33).
5676
- version: "3.25.4"
5676
+ version: "3.26.1"
5677
5677
  });
5678
5678
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
5679
5679
  process.stdout.write(`${output}
@@ -5686,7 +5686,7 @@ program.command("doctor").description(
5686
5686
  process.exitCode = 1;
5687
5687
  }
5688
5688
  });
5689
- 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)`);
5690
5690
  config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).option("--force", "Overwrite an existing config file").action((options) => {
5691
5691
  try {
5692
5692
  initConfigFile(CONFIG_PATH, Boolean(options.force));
@@ -5696,7 +5696,8 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
5696
5696
  "Everything is optional. The usual ones:",
5697
5697
  " modlens config set provider <name> which provider analyzes images",
5698
5698
  " modlens config set cooldown on|off quota cooldown (on by default)",
5699
- " 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',
5700
5701
  ` modlens config set <provider>.extraBody '{"thinking":{"type":"disabled"}}' vendor request fields`,
5701
5702
  ""
5702
5703
  ].join("\n")
@@ -5764,7 +5765,7 @@ config.command("set <key> [value]").description(
5764
5765
  process.exitCode = 1;
5765
5766
  }
5766
5767
  });
5767
- 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(() => {
5768
5769
  try {
5769
5770
  process.stdout.write(`${renderEffectiveConfig(loadConfigFile())}
5770
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.4
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1
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.4
140
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
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.4
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1
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.4
119
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
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.4
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
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.4'
181
+ - '@liustack/modlens@3.26.1'
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.4
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
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.4
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.25.4'
156
+ - '@liustack/modlens@3.26.1'
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
@@ -32,10 +32,36 @@ window.__ModuleLoader__.load({
32
32
  return files
33
33
  }
34
34
 
35
+ function isTextField(el) {
36
+ return el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')
37
+ }
38
+
39
+ function isComposerEditable(el) {
40
+ if (!el || typeof el.getAttribute !== 'function') return false
41
+ if (el.getAttribute('data-composer-input') == null) return false
42
+ return el.isContentEditable === true || el.contentEditable === 'true'
43
+ }
44
+
45
+ function isWritable(el) {
46
+ return isTextField(el) || isComposerEditable(el)
47
+ }
48
+
49
+ // Resolve before taking the event. No writable composer means native
50
+ // paste; missing closest (stubs, odd hosts) falls through to
51
+ // activeElement so a focused textarea still works.
52
+ function resolveWriteTarget(event) {
53
+ var fromEvent = event.target
54
+ if (fromEvent && typeof fromEvent.closest === 'function') {
55
+ var found = fromEvent.closest('textarea, input, [data-composer-input][contenteditable=true]')
56
+ if (found) return found
57
+ }
58
+ var active = document.activeElement
59
+ return isWritable(active) ? active : null
60
+ }
61
+
35
62
  function insertText(target, text) {
36
- var el = target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') ? target : document.activeElement
37
- if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return
38
- el.focus()
63
+ if (!isWritable(target)) return false
64
+ target.focus()
39
65
  // execCommand fires the input event React's controlled textarea needs;
40
66
  // the prototype-setter dance is the fallback for engines dropping it.
41
67
  var inserted = false
@@ -44,11 +70,19 @@ window.__ModuleLoader__.load({
44
70
  } catch {
45
71
  inserted = false
46
72
  }
47
- if (!inserted) {
48
- var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
73
+ if (inserted) return true
74
+ // Lexical's composer is a contenteditable div: it has no value
75
+ // setter, and assigning innerHTML/textContent bypasses the editor.
76
+ if (!isTextField(target)) return false
77
+ try {
78
+ var proto =
79
+ target.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
49
80
  var setter = Object.getOwnPropertyDescriptor(proto, 'value').set
50
- setter.call(el, el.value + text)
51
- el.dispatchEvent(new Event('input', { bubbles: true }))
81
+ setter.call(target, target.value + text)
82
+ target.dispatchEvent(new Event('input', { bubbles: true }))
83
+ return true
84
+ } catch {
85
+ return false
52
86
  }
53
87
  }
54
88
 
@@ -146,18 +180,22 @@ window.__ModuleLoader__.load({
146
180
  // No fresh confirmed host verdict: leave the paste native. Wrong only
147
181
  // for a text-only model's very first paste, and self-correcting.
148
182
  if (!cached || cached.at === 0 || cached.takeover !== true || Date.now() - cached.at > VERDICT_MAX_AGE_MS) return
183
+ var target = resolveWriteTarget(event)
184
+ if (!target) return
149
185
  // Take the paste before the composer's intake starts an attachment (and
150
186
  // with it the host-side image admission a text-only model fails).
151
187
  event.preventDefault()
152
188
  event.stopImmediatePropagation()
153
- var target = event.target
154
189
  Promise.all(files.map(uploadOne))
155
190
  .then((results) => {
156
191
  var text = results
157
192
  .map((r) => r.path)
158
193
  .filter(Boolean)
159
194
  .join(' ')
160
- if (text) insertText(target, `${text} `)
195
+ if (!text) return
196
+ if (!insertText(target, `${text} `)) {
197
+ console.error(`[modlens] paste-to-path: could not insert into the composer (${text})`)
198
+ }
161
199
  })
162
200
  .catch((error) => {
163
201
  // A 404 here means the route vanished AFTER a verdict confirmed it
@@ -196,6 +234,14 @@ window.__ModuleLoader__.load({
196
234
  'Separate multiple keys with commas. ModLens rotates to the next key after authentication, rate-limit, or quota failures.',
197
235
  baseUrl: 'Base URL',
198
236
  model: 'Model',
237
+ proxyRoute: 'Proxy route',
238
+ proxyInherit: 'Inherit global or environment proxy',
239
+ proxyDirect: 'Direct connection',
240
+ proxyCustom: 'Custom proxy',
241
+ proxyUrl: 'Proxy URL',
242
+ proxyStored: 'stored, leave empty to keep it',
243
+ proxyExample: 'http://127.0.0.1:7890',
244
+ proxyHint: 'Direct ignores the global proxy and HTTP_PROXY / HTTPS_PROXY.',
199
245
  stored: 'stored, leave empty to keep it',
200
246
  unset: 'not set',
201
247
  fallback: 'provider default',
@@ -224,6 +270,14 @@ window.__ModuleLoader__.load({
224
270
  apiKeyHint: '多个密钥用英文逗号分隔。鉴权、限流或配额失败时会自动轮换到下一个密钥。',
225
271
  baseUrl: '接口地址',
226
272
  model: '模型',
273
+ proxyRoute: '代理方式',
274
+ proxyInherit: '继承全局代理或环境变量',
275
+ proxyDirect: '直连',
276
+ proxyCustom: '使用专属代理',
277
+ proxyUrl: '代理地址',
278
+ proxyStored: '已保存,留空即不改动',
279
+ proxyExample: 'http://127.0.0.1:7890',
280
+ proxyHint: '直连会忽略全局代理与 HTTP_PROXY / HTTPS_PROXY。',
227
281
  stored: '已保存,留空即不改动',
228
282
  unset: '未设置',
229
283
  fallback: '使用该引擎默认值',
@@ -266,8 +320,8 @@ window.__ModuleLoader__.load({
266
320
  return detail || fallback
267
321
  }
268
322
 
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
323
+ // The next draft when the engine changes or a summary arrives. The engine
324
+ // fields belong to the newly selected engine; the reuse grants are
271
325
  // the user's pending answers and survive an engine switch, since granting
272
326
  // codex has nothing to do with which engine reads the images.
273
327
  function nextDraft(summary, provider, keepReuse) {
@@ -280,6 +334,10 @@ window.__ModuleLoader__.load({
280
334
  apiKey: '',
281
335
  baseUrl: engine.baseUrl,
282
336
  model: engine.model,
337
+ proxyMode: engine.proxyMode || 'inherit',
338
+ // A proxy URL can carry credentials, so the host reports only its
339
+ // mode. Blank means keep the stored custom URL unless one is typed.
340
+ proxy: '',
283
341
  reuse: Object.assign({}, keepReuse || summary.reuse),
284
342
  }
285
343
  }
@@ -299,42 +357,57 @@ window.__ModuleLoader__.load({
299
357
  payload.provider = draft.provider
300
358
  }
301
359
  var pristine = nextDraft(summary, draft.provider, draft.reuse)
302
- var engineEdited = draft.apiKey !== '' || draft.baseUrl !== pristine.baseUrl || draft.model !== pristine.model
360
+ var apiKey = draft.apiKey || ''
361
+ var baseUrl = draft.baseUrl || ''
362
+ var model = draft.model || ''
363
+ var proxyMode = draft.proxyMode || 'inherit'
364
+ var proxy = draft.proxy || ''
365
+ var apiKeyEdited = apiKey !== ''
366
+ var baseUrlEdited = baseUrl !== pristine.baseUrl
367
+ var modelEdited = model !== pristine.model
368
+ var proxyEdited = proxyMode !== pristine.proxyMode || proxy !== ''
369
+ var engineEdited = apiKeyEdited || baseUrlEdited || modelEdited || proxyEdited
303
370
  if (draft.provider !== '' && engineEdited) {
304
371
  payload.engine = draft.provider
305
- payload.apiKey = draft.apiKey
306
- payload.baseUrl = draft.baseUrl
307
- payload.model = draft.model
372
+ if (apiKeyEdited) payload.apiKey = apiKey
373
+ if (baseUrlEdited) payload.baseUrl = baseUrl
374
+ if (modelEdited) payload.model = model
375
+ if (proxyEdited) {
376
+ payload.proxyMode = proxyMode
377
+ // Once the route is inherit or direct, a custom URL is irrelevant
378
+ // and may contain credentials. Do not put stale input on the wire.
379
+ payload.proxy = proxyMode === 'custom' ? proxy : ''
380
+ }
308
381
  }
309
382
  return payload
310
383
  }
311
384
 
312
385
  /**
313
- * How to render the API key field so the characters are hidden.
386
+ * How to render key and proxy credential fields with hidden characters.
314
387
  *
315
388
  * A real password input makes Safari's iCloud Keychain offer to enable
316
389
  * 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
390
+ * field that is always empty: the secret lives in the config file and the
318
391
  * host never sends it here, only whether one is stored. `autocomplete`
319
392
  * cannot turn that off, because WebKit ignores it on password fields on
320
393
  * 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.
394
+ * characters without ever being a password field, and it also keeps a
395
+ * machine-local secret out of a synced keychain.
323
396
  *
324
397
  * Feature-detected rather than assumed. Where the property is missing the
325
398
  * 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.
399
+ * alternative, which is somebody's credential rendered in clear text
400
+ * while they type it.
328
401
  *
329
402
  * This is a trade, not a free win, and the cost falls on people who are
330
403
  * not in the room. A password input carries a protected state into the
331
404
  * accessibility tree, and screen readers stop reading characters back
332
405
  * 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
406
+ * secret aloud, and ARIA has no equivalent to restore. Selection and copy
334
407
  * also become possible, and an IME candidate window shows what is being
335
408
  * 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
409
+ * normal use (the stored value is never sent to the browser), so what a
410
+ * screen reader can read back is what the user is
338
411
  * typing at that moment, not a stored secret.
339
412
  */
340
413
  /**
@@ -549,7 +622,13 @@ window.__ModuleLoader__.load({
549
622
  draft.apiKey !== '' ||
550
623
  draft.baseUrl !== pristine.baseUrl ||
551
624
  draft.model !== pristine.model ||
625
+ draft.proxyMode !== pristine.proxyMode ||
626
+ draft.proxy !== '' ||
552
627
  REUSE.some((name) => draft.reuse[name] !== summary.reuse[name])
628
+ var customProxyMissing =
629
+ draft.proxyMode === 'custom' && current.proxyMode !== 'custom' && draft.proxy.trim() === ''
630
+ var canDiscard = dirty && note !== t.saving
631
+ var canSave = canDiscard && !customProxyMissing
553
632
 
554
633
  var set = (key, value) => {
555
634
  var next = Object.assign({}, draft)
@@ -699,6 +778,55 @@ window.__ModuleLoader__.load({
699
778
  ),
700
779
  draft.provider === '' || keyless ? null : textField(t.baseUrl, 'baseUrl', 'text', t.fallback),
701
780
  draft.provider === '' ? null : textField(t.model, 'model', 'text', t.fallback),
781
+ draft.provider === '' || keyless
782
+ ? null
783
+ : fieldRow(
784
+ t.proxyRoute,
785
+ h(
786
+ 'select',
787
+ {
788
+ name: 'proxyMode',
789
+ value: draft.proxyMode,
790
+ onChange: (event) => set('proxyMode', event.target.value),
791
+ style: {
792
+ appearance: 'none',
793
+ width: '100%',
794
+ padding: '8px 12px',
795
+ borderRadius: '8px',
796
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
797
+ background: 'transparent',
798
+ color: 'inherit',
799
+ font: 'inherit',
800
+ fontSize: '13px',
801
+ },
802
+ },
803
+ [
804
+ h('option', { key: 'inherit', value: 'inherit' }, t.proxyInherit),
805
+ h('option', { key: 'direct', value: 'direct' }, t.proxyDirect),
806
+ h('option', { key: 'custom', value: 'custom' }, t.proxyCustom),
807
+ ],
808
+ ),
809
+ 'proxy-mode',
810
+ ),
811
+ draft.provider === '' || keyless
812
+ ? null
813
+ : fieldRow(
814
+ '',
815
+ h(
816
+ 'div',
817
+ {
818
+ style: {
819
+ fontSize: '13px',
820
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
821
+ },
822
+ },
823
+ t.proxyHint,
824
+ ),
825
+ 'proxy-hint',
826
+ ),
827
+ draft.provider === '' || keyless || draft.proxyMode !== 'custom'
828
+ ? null
829
+ : secretField(t.proxyUrl, 'proxy', current.proxyMode === 'custom' ? t.proxyStored : t.proxyExample),
702
830
  // Where these values are coming from, said once, because the
703
831
  // first save moves them: an engine the file names takes its
704
832
  // settings from the file alone.
@@ -794,7 +922,7 @@ window.__ModuleLoader__.load({
794
922
  'button',
795
923
  {
796
924
  type: 'button',
797
- disabled: !dirty || note === t.saving,
925
+ disabled: !canDiscard,
798
926
  onClick: () => {
799
927
  draftState[1](seed(summary, summary.provider))
800
928
  noteState[1]('')
@@ -804,13 +932,13 @@ window.__ModuleLoader__.load({
804
932
  font: 'inherit',
805
933
  fontSize: '13px',
806
934
  lineHeight: 1.5,
807
- cursor: dirty ? 'pointer' : 'default',
935
+ cursor: canDiscard ? 'pointer' : 'default',
808
936
  border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
809
937
  borderRadius: '8px',
810
938
  padding: '5px 14px',
811
939
  background: 'none',
812
940
  color: 'var(--dsw-alias-label-secondary, inherit)',
813
- opacity: dirty ? 1 : 0.4,
941
+ opacity: canDiscard ? 1 : 0.4,
814
942
  },
815
943
  },
816
944
  t.discard,
@@ -819,7 +947,7 @@ window.__ModuleLoader__.load({
819
947
  'button',
820
948
  {
821
949
  type: 'button',
822
- disabled: !dirty || note === t.saving,
950
+ disabled: !canSave,
823
951
  onClick: () => {
824
952
  noteState[1](t.saving)
825
953
  var payload = savePayload(summary, draft)
@@ -856,13 +984,13 @@ window.__ModuleLoader__.load({
856
984
  font: 'inherit',
857
985
  fontSize: '13px',
858
986
  lineHeight: 1.5,
859
- cursor: dirty ? 'pointer' : 'default',
987
+ cursor: canSave ? 'pointer' : 'default',
860
988
  border: '1px solid transparent',
861
989
  borderRadius: '8px',
862
990
  padding: '5px 14px',
863
991
  background: 'var(--dsw-alias-label-primary, currentColor)',
864
992
  color: 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
865
- opacity: dirty ? 1 : 0.4,
993
+ opacity: canSave ? 1 : 0.4,
866
994
  },
867
995
  },
868
996
  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
@@ -1642,9 +1651,10 @@ function readModlensConfig() {
1642
1651
  }
1643
1652
 
1644
1653
  /**
1645
- * What the card is allowed to know. Every engine's endpoint and model, plus
1646
- * whether a key is stored, and never the key itself: a browser that cannot
1647
- * 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.
1648
1658
  */
1649
1659
  function engineSummary(config = readModlensConfig()) {
1650
1660
  const engines = {}
@@ -1663,6 +1673,11 @@ function engineSummary(config = readModlensConfig()) {
1663
1673
  baseUrl: typeof settings.baseUrl === 'string' ? settings.baseUrl : '',
1664
1674
  model: typeof settings.model === 'string' ? settings.model : '',
1665
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',
1666
1681
  // '' means neither source holds anything, which is not the same as the
1667
1682
  // file holding an empty entry: that one is already off its variables.
1668
1683
  source: inFile ? 'file' : Object.keys(settings).length > 0 ? 'env' : '',
@@ -1687,7 +1702,7 @@ function engineSummary(config = readModlensConfig()) {
1687
1702
 
1688
1703
  /**
1689
1704
  * Apply one card submission to the shared file. Only the named engine's own
1690
- * 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
1691
1706
  * engine's endpoint onto another. An absent or empty `apiKey` leaves the
1692
1707
  * stored one alone: the card never receives a key, so it must never be able
1693
1708
  * to clear one by submitting the blank field it was shown.
@@ -1734,6 +1749,7 @@ function applyEngineSettings(patch) {
1734
1749
  const seed = holders.length > 0 ? {} : engineEnvSettings(engine)
1735
1750
  const settings = { ...seed, ...config.providers[target] }
1736
1751
  for (const field of ['baseUrl', 'model']) {
1752
+ if (!Object.hasOwn(patch, field)) continue
1737
1753
  const value = typeof patch[field] === 'string' ? patch[field].trim() : ''
1738
1754
  if (value === '') {
1739
1755
  delete settings[field]
@@ -1745,6 +1761,36 @@ function applyEngineSettings(patch) {
1745
1761
  if (apiKey !== '') {
1746
1762
  settings.apiKey = apiKey
1747
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
+ }
1748
1794
  config.providers[target] = settings
1749
1795
  }
1750
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.4",
3
+ "version": "3.26.1",
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.4):
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.1):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.25.4: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.25.4 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.25.4 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.26.1: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.26.1 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.26.1 <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.4
11
+ - Pinned CLI version: 3.26.1
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.4'
27
+ $Pinned = '3.26.1'
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.4"
25
+ PINNED="3.26.1"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"