@liustack/modlens 3.20.0 → 3.21.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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.21.0 - 2026-08-18
4
+
5
+ - **Switching OpenAI-compatible gateways no longer costs a key ([#67](https://github.com/liustack/modlens/issues/67)).** The openai slot is the one slot users point at many different gateways, and switching meant overwriting `providers.openai` field by field, with the previous endpoint's key lost unless backed up by hand. `modlens config save openai <label>` snapshots the slot whole under a name; `modlens config use openai <label>` swaps a saved copy back in whole, never merged. A slot that no label holds refuses to be overwritten without `--discard`, so the silent key loss this exists to remove now takes saying so in as many words. The saved section is inert data: resolution, guards, the failover chain, and the environment bindings never read it, the effective view lists labels with keys masked, and older CLI versions carry it through their writes untouched. The issue's own proposal, named provider slots with a type field, was declined with reasons on the thread: it opens the closed provider registry every downstream consumer assumes, and reopens the mistyped-name trap 3.19.0 closed. Per-call routing and a dsh passthrough are likewise declined; endpoint choice stays user-owned configuration.
6
+
3
7
  ## 3.20.0 - 2026-08-18
4
8
 
5
9
  - **Windows: default-layout pnpm shims work ([#43](https://github.com/liustack/modlens/issues/43) closes for pnpm too).** pnpm with the isolated linker and `extendNodePath`, which is its default configuration, prepends a four-line NODE_PATH block to every shim, and the recogniser did not know the shape, so those shims were declined and still landed on the spawn `EINVAL` that 3.18.0 removed for npm. The block is now a recognised prefix of both pnpm shapes: the plan sets NODE_PATH to the hoisted directory when the child has none and prepends when it does, with cmd's own DEFINED semantics, and the directory value goes only into the child's environment, exactly where the shim itself puts it. Fixtures are verbatim generator output for the branch, multi-directory, and native forms. Found by the same review that shipped 3.19.0, which had recorded this as the known gap.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
34
34
 
35
35
  ## Highlights
36
36
 
37
- **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.20.0`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
37
+ **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.0`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
38
38
 
39
39
  Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer — the same interaction OpenCode and Pi ship — and the `modlens_read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying text-only DeepSeek or GLM models and adds a wrapped entry per route (a stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**; extra routes like opencode-go or zai get their own); the two families' own vision models are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
40
40
 
package/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
34
34
 
35
35
  ## 亮点
36
36
 
37
- **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.20.0`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
37
+ **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.0`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
38
38
 
39
39
  DeepSeek Harness 粘贴识图有两种玩法。
40
40
 
@@ -69,7 +69,7 @@ agy # 浏览器完成
69
69
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
70
70
 
71
71
  ```sh
72
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.20.0
72
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.0
73
73
  ```
74
74
 
75
75
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
package/dist/main.js CHANGED
@@ -1770,6 +1770,9 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
1770
1770
  config2.providers[providerName][field] = value;
1771
1771
  }
1772
1772
  }
1773
+ persistConfig(config2, configPath);
1774
+ }
1775
+ function persistConfig(config2, configPath) {
1773
1776
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
1774
1777
  fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
1775
1778
  `, { mode: 384 });
@@ -1778,6 +1781,79 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
1778
1781
  } catch {
1779
1782
  }
1780
1783
  }
1784
+ const SAVED_LABEL = /^[a-z][a-z0-9-]*$/;
1785
+ function deepEqualJson(a, b) {
1786
+ if (a === b) return true;
1787
+ if (Array.isArray(a) && Array.isArray(b)) {
1788
+ return a.length === b.length && a.every((item, i) => deepEqualJson(item, b[i]));
1789
+ }
1790
+ if (a && b && typeof a === "object" && typeof b === "object") {
1791
+ const ka = Object.keys(a).sort();
1792
+ const kb = Object.keys(b).sort();
1793
+ return ka.length === kb.length && ka.every(
1794
+ (key, i) => key === kb[i] && deepEqualJson(
1795
+ a[key],
1796
+ b[key]
1797
+ )
1798
+ );
1799
+ }
1800
+ return false;
1801
+ }
1802
+ function savedSlotFor(slot) {
1803
+ const folded = slot.trim().toLowerCase();
1804
+ const canonical = providerAliases()[folded] ?? folded;
1805
+ if (canonical !== "openai") {
1806
+ throw new Error(
1807
+ `Only the openai slot has saved copies; "${slot}" does not. It is the one slot users point at many different gateways.`
1808
+ );
1809
+ }
1810
+ return canonical;
1811
+ }
1812
+ function saveProviderBundle(slot, label, configPath = CONFIG_PATH) {
1813
+ const canonical = savedSlotFor(slot);
1814
+ if (!SAVED_LABEL.test(label)) {
1815
+ throw new Error(
1816
+ `Labels are lowercase letters, digits and hyphens, starting with a letter: "${label}" is not.`
1817
+ );
1818
+ }
1819
+ const config2 = loadConfigFile(configPath);
1820
+ const snapshot = fileSettingsFor(canonical, config2);
1821
+ if (Object.keys(snapshot).length === 0) {
1822
+ throw new Error(
1823
+ `Nothing to save: the ${canonical} slot is empty in ${configPath}. Configure it first (modlens config set openai.baseUrl <url>).`
1824
+ );
1825
+ }
1826
+ config2.saved ??= {};
1827
+ config2.saved[canonical] ??= {};
1828
+ config2.saved[canonical][label] = snapshot;
1829
+ persistConfig(config2, configPath);
1830
+ }
1831
+ function useProviderBundle(slot, label, discard = false, configPath = CONFIG_PATH) {
1832
+ const canonical = savedSlotFor(slot);
1833
+ const config2 = loadConfigFile(configPath);
1834
+ const bundle = config2.saved?.[canonical]?.[label];
1835
+ if (bundle === void 0) {
1836
+ const known = Object.keys(config2.saved?.[canonical] ?? {}).sort();
1837
+ throw new Error(
1838
+ known.length === 0 ? `No saved copies exist for ${canonical} yet. Save the current one first: modlens config save openai <label>.` : `No saved copy named "${label}". Saved: ${known.join(", ")}.`
1839
+ );
1840
+ }
1841
+ const current = fileSettingsFor(canonical, config2);
1842
+ const currentSaved = Object.keys(current).length === 0 || Object.values(config2.saved?.[canonical] ?? {}).some(
1843
+ (entry) => deepEqualJson(entry, current)
1844
+ );
1845
+ if (!currentSaved && !discard) {
1846
+ throw new Error(
1847
+ `The current ${canonical} settings are not saved under any label and would be lost. Save them first (modlens config save openai <label>) or pass --discard.`
1848
+ );
1849
+ }
1850
+ for (const key of fileKeysFor(canonical, config2)) {
1851
+ delete config2.providers?.[key];
1852
+ }
1853
+ config2.providers ??= {};
1854
+ config2.providers[canonical] = { ...bundle };
1855
+ persistConfig(config2, configPath);
1856
+ }
1781
1857
  function setGuardsValue(config2, field, value) {
1782
1858
  if (field === "denyModels" || field === "allowModels") {
1783
1859
  if (value.trim() === "") {
@@ -1866,6 +1942,22 @@ function renderEffectiveConfig(config2, env = process.env) {
1866
1942
  const effective = {
1867
1943
  providers
1868
1944
  };
1945
+ const savedView = {};
1946
+ for (const [slot, bundles] of Object.entries(config2.saved ?? {})) {
1947
+ for (const label of Object.keys(bundles).sort()) {
1948
+ const bundle = bundles[label];
1949
+ const parts = [
1950
+ bundle.model,
1951
+ bundle.baseUrl,
1952
+ bundle.apiKey !== void 0 ? `key ${maskKey(bundle.apiKey)}` : "no key"
1953
+ ].filter(Boolean);
1954
+ savedView[slot] ??= {};
1955
+ savedView[slot][label] = parts.join(" @ ");
1956
+ }
1957
+ }
1958
+ if (Object.keys(savedView).length > 0) {
1959
+ effective.saved = savedView;
1960
+ }
1869
1961
  if (config2.provider?.trim()) {
1870
1962
  effective.provider = config2.provider.trim();
1871
1963
  }
@@ -4654,7 +4746,7 @@ function parsePositiveInt(raw, flag) {
4654
4746
  }
4655
4747
  return Number.parseInt(raw, 10);
4656
4748
  }
4657
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.20.0");
4749
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.21.0");
4658
4750
  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(
4659
4751
  "--extra-body <json>",
4660
4752
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -4764,7 +4856,7 @@ program.command("doctor").description(
4764
4856
  configPath: CONFIG_PATH,
4765
4857
  // Lets doctor name an installed skill copy that is older than
4766
4858
  // the CLI reporting on it (issue #33).
4767
- version: "3.20.0"
4859
+ version: "3.21.0"
4768
4860
  });
4769
4861
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
4770
4862
  process.stdout.write(`${output}
@@ -4799,6 +4891,36 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
4799
4891
  process.exitCode = 1;
4800
4892
  }
4801
4893
  });
4894
+ config.command("save <slot> <label>").description(
4895
+ "Snapshot a provider slot under a label (openai only), so switching gateways never loses a key"
4896
+ ).action((slot, label) => {
4897
+ try {
4898
+ saveProviderBundle(slot, label);
4899
+ process.stdout.write(`Saved the ${slot} slot as "${label}" in ${CONFIG_PATH}
4900
+ `);
4901
+ } catch (error) {
4902
+ process.stderr.write(
4903
+ `Error: ${error instanceof Error ? error.message : String(error)}
4904
+ `
4905
+ );
4906
+ process.exitCode = 1;
4907
+ }
4908
+ });
4909
+ config.command("use <slot> <label>").description(
4910
+ "Replace a provider slot with a saved copy, whole. Refuses to drop unsaved settings without --discard"
4911
+ ).option("--discard", "Overwrite the current slot even though it is not saved under any label").action((slot, label, options) => {
4912
+ try {
4913
+ useProviderBundle(slot, label, Boolean(options.discard));
4914
+ process.stdout.write(`The ${slot} slot now holds "${label}" (${CONFIG_PATH})
4915
+ `);
4916
+ } catch (error) {
4917
+ process.stderr.write(
4918
+ `Error: ${error instanceof Error ? error.message : String(error)}
4919
+ `
4920
+ );
4921
+ process.exitCode = 1;
4922
+ }
4923
+ });
4802
4924
  config.command("set <key> [value]").description(
4803
4925
  "Set a value. Omit the value for an apiKey to enter it at a hidden prompt (out of argv and shell history), or to read one piped line (out of argv; the command feeding the pipe is yours to keep out of history)"
4804
4926
  ).action(async (key, value) => {
@@ -55,7 +55,7 @@ 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.20.0
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.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.
@@ -81,7 +81,7 @@ modlens ships often, and both install shapes freeze at whatever version they
81
81
  got. On dsh, re-run the install with the version named:
82
82
 
83
83
  ```sh
84
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.20.0
84
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.0
85
85
  ```
86
86
 
87
87
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -55,7 +55,7 @@ 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.20.0
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.0
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
@@ -71,7 +71,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
71
71
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
72
72
 
73
73
  ```sh
74
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.20.0
74
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.0
75
75
  ```
76
76
 
77
77
  `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.20.0
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.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.20.0'
181
+ - '@liustack/modlens@3.21.0'
182
182
  ```
183
183
 
184
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -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.20.0
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.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.20.0
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.20.0'
156
+ - '@liustack/modlens@3.21.0'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.20.0",
3
+ "version": "3.21.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.20.0):
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.21.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.20.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.20.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.20.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.21.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.21.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.21.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.
@@ -62,6 +62,7 @@ Field semantics:
62
62
  - `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.
63
63
  - `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`.
64
64
  - `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.
65
+ - `saved.openai.<label>`: named saved copies of the openai slot, written only by `modlens config save openai <label>` and swapped in whole by `modlens config use openai <label>`. Switching gateways used to mean overwriting `providers.openai` and losing the previous key; a saved copy is where it survives. `use` refuses to overwrite an active slot that no label holds (pass `--discard` to drop it deliberately), and nothing in resolution, guards, or the env bindings reads this section: the active slot stays the only openai route in any run.
65
66
  - `guards`: the invocation guard, for people who run both text-only and vision-capable models through the same client. Both lists hold glob patterns (`*` and `?`, case-insensitive, matched against the model name and `provider/model`), set with `modlens config set guards.denyModels '["gemini-3*"]'` or `guards.allowModels` (a JSON array or a comma-separated list, empty clears). Two ways to express the same intent, pick the shorter list:
66
67
  - `denyModels` alone: everything runs the engine except the listed vision models. Right when text-only models are the majority of what you plug in.
67
68
  - `allowModels` non-empty (allowlist mode): only the listed models run the engine, every other identified model is denied. Right for the actual 2026 landscape, where text-only models are the short list. A deny pattern still wins over an allow match, so a broad allow can have its vision variants carved out, as in the example above: `glm-5.*` allows the text line while `glm-*v*` catches `glm-5v-turbo`. Anchor allow patterns tightly (`deepseek-v4-*`, not `deepseek*`) so a vendor's next multimodal generation falls off the list and steps aside until you have checked it.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.20.0
11
+ - Pinned CLI version: 3.21.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.20.0'
27
+ $Pinned = '3.21.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.20.0"
25
+ PINNED="3.21.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"