@liustack/modlens 3.2.0 → 3.4.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.0 - 2026-08-12
4
+
5
+ - Vendor-specific request fields can now be passed through to the three API providers, which is how you turn thinking off (issue #12). `modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}'` stores it per provider, `--extra-body '<json>'` overrides it for one run, and an empty value clears it. Reasoning models spend their budget re-deriving a transcription task that needs none, so on a thinking-by-default model this is the difference between a slow read and a fast one. There is deliberately no `--no-thinking` flag: every gateway spells the knob differently (`thinking.type` on the MiMo API, `reasoning.effort` on its Responses route, `chat_template_kwargs.enable_thinking` on a self-hosted vLLM, `thinkingConfig` inside `generationConfig` on Gemini), some ignore what they do not know and others reject it with a 400, so guessing on the user's behalf would fail silently about as often as it worked. `configure.md` carries the per-vendor recipes.
6
+ - The passthrough deep-merges into the request body, so adding a knob to a nested block keeps what was already there (a Gemini `thinkingConfig` no longer wipes out the `responseJsonSchema` next to it). The fields that carry the image, the prompt, and the schema enforcement are reserved and rejected with a message naming the field. The two CLI providers take no request body: they warn in `meta.warnings` that the value was ignored rather than letting a run look configured when nothing was sent.
7
+
8
+ ## 3.3.0 - 2026-08-07
9
+
10
+ - Automatic provider failover. A run now tries every provider that is set up on this machine, in order, and the first good result wins: a provider that errors, times out, or returns a schema-violating result hands over to the next. A local image tries `antigravity-cli`, then `gemini-api`, `openai`, `anthropic`, `claude-cli`; a remote URL tries the inline API providers first and the agent last (only the inline download path runs the private-address guards, the magic-byte check, and the size cap), and `claude-cli` never joins the remote chain since it reads local files only. The result's `meta.attempts` records every provider tried with timings and failure reasons, and `meta.warnings` carries failover notices. `doctor` prints both chains. Availability (binary on PATH, required keys present) is one shared source of truth between the doctor's readiness report and the chain. The 3.2.0 remote-URL reroute is absorbed by the remote chain order.
11
+ - Behavior change: `config set provider <name>` is now a preference, not a pin. It moves that provider to the front of its allowed region (for a remote URL an agent still stays behind the inline providers), and the rest of the chain backs it up on failure, matching modsearch's engine setting. To pin exactly one provider with no fallback, pass `-p <name>`, which keeps its original error when it fails.
12
+
3
13
  ## 3.2.0 - 2026-08-07
4
14
 
5
15
  - A remote image URL with no explicit `-p` now runs on `gemini-api` whenever a Gemini key is configured, even if the default provider is an agent. The inline path downloads the image itself, behind the private-address guards, the magic-byte image check, and the 25 MB cap; an agent fetching the URL on its own passes through none of those. Without a Gemini key the run stays on the configured default, a local image never reroutes, and an explicit `-p` always wins.
package/README.md CHANGED
@@ -16,6 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
+ <a href="https://x.com/liustack"><img src="https://img.shields.io/badge/follow-%40liustack-black?style=flat-square&logo=x&logoColor=white" alt="Follow @liustack on X"></a>
19
20
  <a href="https://www.npmjs.com/package/@liustack/modlens"><img src="https://img.shields.io/npm/v/@liustack/modlens?style=flat-square&label=npm&color=cb3837" alt="npm"></a>
20
21
  <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/@liustack/modlens?style=flat-square" alt="Node.js"></a>
21
22
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License"></a>
@@ -101,6 +102,16 @@ npx -y skills add liustack/liustack -g
101
102
 
102
103
  ⭐ If it helps, star [ModLens](https://github.com/liustack/modlens) and [liustack](https://github.com/liustack/liustack). Stars are how the next developer finds them.
103
104
 
105
+ ## Star History
106
+
107
+ <a href="https://www.star-history.com/?repos=liustack%2Fmodlens&type=date&legend=top-left">
108
+ <picture>
109
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liustack/modlens&type=date&theme=dark&legend=top-left&sealed_token=oQQAwrPffo9WRUsM6P4RnEu4ZdRART3ChPwIkavGtAfrMycGmLYdjuM2uJ4gjnoIyaF_MDwhOBkJlzmS8pT_W9IRDlsCqLafe7gwvw7Vcnr5MRTkczOasg" />
110
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liustack/modlens&type=date&legend=top-left&sealed_token=oQQAwrPffo9WRUsM6P4RnEu4ZdRART3ChPwIkavGtAfrMycGmLYdjuM2uJ4gjnoIyaF_MDwhOBkJlzmS8pT_W9IRDlsCqLafe7gwvw7Vcnr5MRTkczOasg" />
111
+ <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liustack/modlens&type=date&legend=top-left&sealed_token=oQQAwrPffo9WRUsM6P4RnEu4ZdRART3ChPwIkavGtAfrMycGmLYdjuM2uJ4gjnoIyaF_MDwhOBkJlzmS8pT_W9IRDlsCqLafe7gwvw7Vcnr5MRTkczOasg" />
112
+ </picture>
113
+ </a>
114
+
104
115
  ## Disclaimer
105
116
 
106
117
  Provided as-is under the MIT License below. The author makes no warranty and gives no endorsement for any particular use, commercial use included. Your use of upstream engines (Antigravity CLI, the Gemini, OpenAI, and Anthropic APIs, and any OpenAI-compatible endpoint) is governed by their own terms and quotas, which you are responsible for.
package/README.zh-CN.md CHANGED
@@ -16,6 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
+ <a href="https://x.com/liustack"><img src="https://img.shields.io/badge/follow-%40liustack-black?style=flat-square&logo=x&logoColor=white" alt="Follow @liustack on X"></a>
19
20
  <a href="https://www.npmjs.com/package/@liustack/modlens"><img src="https://img.shields.io/npm/v/@liustack/modlens?style=flat-square&label=npm&color=cb3837" alt="npm"></a>
20
21
  <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/@liustack/modlens?style=flat-square" alt="Node.js"></a>
21
22
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License"></a>
@@ -101,6 +102,16 @@ Codex 桌面 App 中识别一张推文截图。配文、互动数据(2.9K 回
101
102
 
102
103
  ⭐ 如果它对你有用,请给 [ModLens](https://github.com/liustack/modlens) 一个 star,这是其他开发者找到它的方式。
103
104
 
105
+ ## Star History
106
+
107
+ <a href="https://www.star-history.com/?repos=liustack%2Fmodlens&type=date&legend=top-left">
108
+ <picture>
109
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liustack/modlens&type=date&theme=dark&legend=top-left&sealed_token=oQQAwrPffo9WRUsM6P4RnEu4ZdRART3ChPwIkavGtAfrMycGmLYdjuM2uJ4gjnoIyaF_MDwhOBkJlzmS8pT_W9IRDlsCqLafe7gwvw7Vcnr5MRTkczOasg" />
110
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liustack/modlens&type=date&legend=top-left&sealed_token=oQQAwrPffo9WRUsM6P4RnEu4ZdRART3ChPwIkavGtAfrMycGmLYdjuM2uJ4gjnoIyaF_MDwhOBkJlzmS8pT_W9IRDlsCqLafe7gwvw7Vcnr5MRTkczOasg" />
111
+ <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liustack/modlens&type=date&legend=top-left&sealed_token=oQQAwrPffo9WRUsM6P4RnEu4ZdRART3ChPwIkavGtAfrMycGmLYdjuM2uJ4gjnoIyaF_MDwhOBkJlzmS8pT_W9IRDlsCqLafe7gwvw7Vcnr5MRTkczOasg" />
112
+ </picture>
113
+ </a>
114
+
104
115
  ## 免责声明
105
116
 
106
117
  本项目依下方 MIT 协议按现状提供。作者不对任何特定用途(含商业使用)提供保证或背书。上游引擎(Antigravity CLI,Gemini、OpenAI、Anthropic 的 API,以及任何 OpenAI 兼容端点)的使用受各自条款和额度约束,由使用者负责。
package/dist/main.js CHANGED
@@ -7,7 +7,7 @@ import { spawn } from "child_process";
7
7
  import * as os from "os";
8
8
  import require$$0$1 from "node:assert";
9
9
  import require$$1 from "node:net";
10
- import require$$2 from "node:http";
10
+ import require$$2$1 from "node:http";
11
11
  import require$$0$2 from "node:stream";
12
12
  import require$$5 from "node:querystring";
13
13
  import require$$0 from "node:events";
@@ -18,21 +18,44 @@ import require$$0$4 from "node:buffer";
18
18
  import require$$0$5 from "node:zlib";
19
19
  import require$$5$1 from "node:perf_hooks";
20
20
  import require$$8 from "node:util/types";
21
- import require$$2$1 from "node:worker_threads";
22
- import require$$2$2 from "node:crypto";
23
- import require$$1$1 from "node:sqlite";
21
+ import require$$2$2 from "node:worker_threads";
22
+ import require$$2$3 from "node:crypto";
24
23
  import require$$11 from "node:stream/web";
25
24
  import require$$0$6 from "node:url";
26
- import require$$1$2 from "node:async_hooks";
27
- import require$$1$3 from "node:console";
25
+ import require$$1$1 from "node:async_hooks";
26
+ import require$$1$2 from "node:console";
28
27
  import require$$0$7 from "node:fs/promises";
29
- import require$$1$4 from "node:path";
30
- import require$$2$3 from "node:timers";
31
- import require$$1$5 from "node:dns";
28
+ import require$$1$3 from "node:path";
29
+ import require$$2$4 from "node:timers";
30
+ import require$$1$4 from "node:dns";
32
31
  import * as dns$1 from "dns/promises";
33
32
  import { isIP } from "net";
34
33
  import { createRequire } from "module";
35
34
  import * as crypto from "crypto";
35
+ function getAugmentedNamespace(n) {
36
+ if (Object.prototype.hasOwnProperty.call(n, "__esModule")) return n;
37
+ var f = n.default;
38
+ if (typeof f == "function") {
39
+ var a = function a2() {
40
+ if (this instanceof a2) {
41
+ return Reflect.construct(f, arguments, this.constructor);
42
+ }
43
+ return f.apply(this, arguments);
44
+ };
45
+ a.prototype = f.prototype;
46
+ } else a = {};
47
+ Object.defineProperty(a, "__esModule", { value: true });
48
+ Object.keys(n).forEach(function(k) {
49
+ var d = Object.getOwnPropertyDescriptor(n, k);
50
+ Object.defineProperty(a, k, d.get ? d : {
51
+ enumerable: true,
52
+ get: function() {
53
+ return n[k];
54
+ }
55
+ });
56
+ });
57
+ return a;
58
+ }
36
59
  var undici = { exports: {} };
37
60
  var symbols;
38
61
  var hasRequiredSymbols;
@@ -1038,7 +1061,7 @@ function requireUtil$5() {
1038
1061
  hasRequiredUtil$5 = 1;
1039
1062
  const assert = require$$0$1;
1040
1063
  const { kDestroyed, kBodyUsed, kListeners, kBody } = requireSymbols();
1041
- const { IncomingMessage } = require$$2;
1064
+ const { IncomingMessage } = require$$2$1;
1042
1065
  const stream = require$$0$2;
1043
1066
  const net = require$$1;
1044
1067
  const { stringify } = require$$5;
@@ -4286,7 +4309,7 @@ function requireWebidl() {
4286
4309
  hasRequiredWebidl = 1;
4287
4310
  const assert = require$$0$1;
4288
4311
  const { types, inspect } = require$$3;
4289
- const { markAsUncloneable } = require$$2$1;
4312
+ const { markAsUncloneable } = require$$2$2;
4290
4313
  const UNDEFINED = 1;
4291
4314
  const BOOLEAN = 2;
4292
4315
  const STRING = 3;
@@ -5647,14 +5670,20 @@ function requireUtil$4() {
5647
5670
  return util$4;
5648
5671
  }
5649
5672
  var runtimeFeatures = {};
5673
+ const __viteBrowserExternal = {};
5674
+ const __viteBrowserExternal$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
5675
+ __proto__: null,
5676
+ default: __viteBrowserExternal
5677
+ }, Symbol.toStringTag, { value: "Module" }));
5678
+ const require$$2 = /* @__PURE__ */ getAugmentedNamespace(__viteBrowserExternal$1);
5650
5679
  var hasRequiredRuntimeFeatures;
5651
5680
  function requireRuntimeFeatures() {
5652
5681
  if (hasRequiredRuntimeFeatures) return runtimeFeatures;
5653
5682
  hasRequiredRuntimeFeatures = 1;
5654
5683
  const lazyLoaders = {
5655
5684
  __proto__: null,
5656
- "node:crypto": () => require$$2$2,
5657
- "node:sqlite": () => require$$1$1
5685
+ "node:crypto": () => require$$2$3,
5686
+ "node:sqlite": () => require$$2
5658
5687
  };
5659
5688
  function detectRuntimeFeatureByNodeModule(moduleName) {
5660
5689
  try {
@@ -5731,7 +5760,7 @@ function requireFormdata() {
5731
5760
  const { webidl } = requireWebidl();
5732
5761
  const nodeUtil = require$$3;
5733
5762
  const { runtimeFeatures: runtimeFeatures2 } = requireRuntimeFeatures();
5734
- const random = runtimeFeatures2.has("crypto") ? require$$2$2.randomInt : (max) => Math.floor(Math.random() * max);
5763
+ const random = runtimeFeatures2.has("crypto") ? require$$2$3.randomInt : (max) => Math.floor(Math.random() * max);
5735
5764
  class FormData {
5736
5765
  #state = [];
5737
5766
  #boundary = null;
@@ -9083,7 +9112,7 @@ function requireClient() {
9083
9112
  hasRequiredClient = 1;
9084
9113
  const assert = require$$0$1;
9085
9114
  const net = require$$1;
9086
- const http = require$$2;
9115
+ const http = require$$2$1;
9087
9116
  const util2 = requireUtil$5();
9088
9117
  const { ClientStats } = requireStats();
9089
9118
  const { channels } = requireDiagnostics();
@@ -12557,7 +12586,7 @@ function requireApiRequest() {
12557
12586
  if (hasRequiredApiRequest) return apiRequest.exports;
12558
12587
  hasRequiredApiRequest = 1;
12559
12588
  const assert = require$$0$1;
12560
- const { AsyncResource } = require$$1$2;
12589
+ const { AsyncResource } = require$$1$1;
12561
12590
  const { Readable } = requireReadable();
12562
12591
  const { InvalidArgumentError, RequestAbortedError } = requireErrors();
12563
12592
  const util2 = requireUtil$5();
@@ -12810,7 +12839,7 @@ function requireApiStream() {
12810
12839
  if (hasRequiredApiStream) return apiStream;
12811
12840
  hasRequiredApiStream = 1;
12812
12841
  const assert = require$$0$1;
12813
- const { AsyncResource } = require$$1$2;
12842
+ const { AsyncResource } = require$$1$1;
12814
12843
  const { InvalidArgumentError, InvalidReturnValueError } = requireErrors();
12815
12844
  const util2 = requireUtil$5();
12816
12845
  const { addSignal, removeSignal } = requireAbortSignal();
@@ -13025,7 +13054,7 @@ function requireApiPipeline() {
13025
13054
  PassThrough
13026
13055
  } = require$$0$2;
13027
13056
  const assert = require$$0$1;
13028
- const { AsyncResource } = require$$1$2;
13057
+ const { AsyncResource } = require$$1$1;
13029
13058
  const {
13030
13059
  InvalidArgumentError,
13031
13060
  InvalidReturnValueError,
@@ -13227,7 +13256,7 @@ function requireApiUpgrade() {
13227
13256
  if (hasRequiredApiUpgrade) return apiUpgrade;
13228
13257
  hasRequiredApiUpgrade = 1;
13229
13258
  const { InvalidArgumentError, SocketError } = requireErrors();
13230
- const { AsyncResource } = require$$1$2;
13259
+ const { AsyncResource } = require$$1$1;
13231
13260
  const assert = require$$0$1;
13232
13261
  const util2 = requireUtil$5();
13233
13262
  const { kHTTP2Stream } = requireSymbols();
@@ -13327,7 +13356,7 @@ function requireApiConnect() {
13327
13356
  if (hasRequiredApiConnect) return apiConnect;
13328
13357
  hasRequiredApiConnect = 1;
13329
13358
  const assert = require$$0$1;
13330
- const { AsyncResource } = require$$1$2;
13359
+ const { AsyncResource } = require$$1$1;
13331
13360
  const { InvalidArgumentError, SocketError } = requireErrors();
13332
13361
  const util2 = requireUtil$5();
13333
13362
  const { addSignal, removeSignal } = requireAbortSignal();
@@ -13502,7 +13531,7 @@ function requireMockUtils() {
13502
13531
  kTotalDispatchCount
13503
13532
  } = requireMockSymbols();
13504
13533
  const { serializePathWithQuery, parseHeaders } = requireUtil$5();
13505
- const { STATUS_CODES } = require$$2;
13534
+ const { STATUS_CODES } = require$$2$1;
13506
13535
  const {
13507
13536
  types: {
13508
13537
  isPromise
@@ -14543,7 +14572,7 @@ function requirePendingInterceptorsFormatter() {
14543
14572
  if (hasRequiredPendingInterceptorsFormatter) return pendingInterceptorsFormatter;
14544
14573
  hasRequiredPendingInterceptorsFormatter = 1;
14545
14574
  const { Transform } = require$$0$2;
14546
- const { Console } = require$$1$3;
14575
+ const { Console } = require$$1$2;
14547
14576
  const PERSISTENT = process.versions.icu ? "✅" : "Y ";
14548
14577
  const NOT_PERSISTENT = process.versions.icu ? "❌" : "N ";
14549
14578
  pendingInterceptorsFormatter = class PendingInterceptorsFormatter {
@@ -14777,7 +14806,7 @@ function requireSnapshotUtils() {
14777
14806
  match: new Set(matchHeaders.map((header) => caseSensitive ? header : header.toLowerCase()))
14778
14807
  };
14779
14808
  }
14780
- const crypto2 = runtimeFeatures2.has("crypto") ? require$$2$2 : null;
14809
+ const crypto2 = runtimeFeatures2.has("crypto") ? require$$2$3 : null;
14781
14810
  const hashId = crypto2?.hash ? (value) => crypto2.hash("sha256", value, "base64url") : (value) => Buffer.from(value).toString("base64url");
14782
14811
  function isUndiciHeaders(headers2) {
14783
14812
  return Array.isArray(headers2) && (headers2.length & 1) === 0;
@@ -14854,8 +14883,8 @@ function requireSnapshotRecorder() {
14854
14883
  if (hasRequiredSnapshotRecorder) return snapshotRecorder;
14855
14884
  hasRequiredSnapshotRecorder = 1;
14856
14885
  const { writeFile, readFile, mkdir } = require$$0$7;
14857
- const { dirname, resolve } = require$$1$4;
14858
- const { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = require$$2$3;
14886
+ const { dirname, resolve } = require$$1$3;
14887
+ const { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = require$$2$4;
14859
14888
  const { InvalidArgumentError, UndiciError } = requireErrors();
14860
14889
  const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = requireSnapshotUtils();
14861
14890
  function normalizeUrlForMatching(url, matchQuery, normalizeQuery) {
@@ -16040,7 +16069,7 @@ function requireDns() {
16040
16069
  if (hasRequiredDns) return dns;
16041
16070
  hasRequiredDns = 1;
16042
16071
  const { isIP: isIP2 } = require$$1;
16043
- const { lookup } = require$$1$5;
16072
+ const { lookup } = require$$1$4;
16044
16073
  const DecoratorHandler = requireDecoratorHandler();
16045
16074
  const { InvalidArgumentError, InformationalError } = requireErrors();
16046
16075
  const maxInt = Math.pow(2, 31) - 1;
@@ -19506,7 +19535,7 @@ function requireSqliteCacheStore() {
19506
19535
  }
19507
19536
  }
19508
19537
  if (!DatabaseSync) {
19509
- DatabaseSync = require$$1$1.DatabaseSync;
19538
+ DatabaseSync = require$$2.DatabaseSync;
19510
19539
  }
19511
19540
  this.#db = new DatabaseSync(opts?.location ?? ":memory:");
19512
19541
  this.#db.exec(`
@@ -21471,7 +21500,7 @@ function requireSubresourceIntegrity() {
21471
21500
  const validSRIHashAlgorithmTokenSet = /* @__PURE__ */ new Map([["sha256", 0], ["sha384", 1], ["sha512", 2]]);
21472
21501
  let crypto2;
21473
21502
  if (runtimeFeatures2.has("crypto")) {
21474
- crypto2 = require$$2$2;
21503
+ crypto2 = require$$2$3;
21475
21504
  const cryptoHashes = crypto2.getHashes();
21476
21505
  if (cryptoHashes.length === 0) {
21477
21506
  validSRIHashAlgorithmTokenSet.clear();
@@ -21665,7 +21694,7 @@ function requireFetch() {
21665
21694
  const { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = requireDataUrl();
21666
21695
  const { getGlobalDispatcher } = requireGlobal();
21667
21696
  const { webidl } = requireWebidl();
21668
- const { STATUS_CODES } = require$$2;
21697
+ const { STATUS_CODES } = require$$2$1;
21669
21698
  const { bytesMatch } = requireSubresourceIntegrity();
21670
21699
  const { isomorphicEncode } = requireInfra();
21671
21700
  const GET_OR_HEAD = ["GET", "HEAD"];
@@ -24425,7 +24454,7 @@ function requireFrame() {
24425
24454
  const BUFFER_SIZE = 8 * 1024;
24426
24455
  let buffer = null;
24427
24456
  let bufIdx = BUFFER_SIZE;
24428
- const randomFillSync = runtimeFeatures2.has("crypto") ? require$$2$2.randomFillSync : null;
24457
+ const randomFillSync = runtimeFeatures2.has("crypto") ? require$$2$3.randomFillSync : null;
24429
24458
  function generateMask() {
24430
24459
  if (bufIdx === BUFFER_SIZE) {
24431
24460
  bufIdx = 0;
@@ -24530,7 +24559,7 @@ function requireConnection() {
24530
24559
  const { WebsocketFrameSend } = requireFrame();
24531
24560
  const assert = require$$0$1;
24532
24561
  const { runtimeFeatures: runtimeFeatures2 } = requireRuntimeFeatures();
24533
- const crypto2 = runtimeFeatures2.has("crypto") ? require$$2$2 : null;
24562
+ const crypto2 = runtimeFeatures2.has("crypto") ? require$$2$3 : null;
24534
24563
  let warningEmitted = false;
24535
24564
  function establishWebSocketConnection(url, protocols, client2, handler, options) {
24536
24565
  const requestURL = url;
@@ -27508,6 +27537,54 @@ function schemaViolations(schema, value, path2) {
27508
27537
  }
27509
27538
  return [];
27510
27539
  }
27540
+ function parseExtraBody(raw, origin) {
27541
+ let parsed;
27542
+ try {
27543
+ parsed = JSON.parse(raw);
27544
+ } catch (error) {
27545
+ throw new Error(`${origin} is not valid JSON: ${error.message}`);
27546
+ }
27547
+ if (!isPlainObject(parsed)) {
27548
+ throw new Error(
27549
+ `${origin} must be a JSON object, for example {"thinking":{"type":"disabled"}}`
27550
+ );
27551
+ }
27552
+ return parsed;
27553
+ }
27554
+ function mergeExtraBody(body2, extra, reserved, providerName) {
27555
+ if (!extra || Object.keys(extra).length === 0) {
27556
+ return body2;
27557
+ }
27558
+ for (const path2 of reserved) {
27559
+ if (hasPath(extra, path2)) {
27560
+ throw new Error(
27561
+ `extraBody cannot override "${path2}" for the ${providerName} provider: it carries the image, the prompt, or the schema this tool depends on. Remove that field from ${providerName}.extraBody (or --extra-body).`
27562
+ );
27563
+ }
27564
+ }
27565
+ return deepMerge(body2, extra);
27566
+ }
27567
+ function deepMerge(base, overlay) {
27568
+ const merged = { ...base };
27569
+ for (const [key, value] of Object.entries(overlay)) {
27570
+ const current = merged[key];
27571
+ merged[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
27572
+ }
27573
+ return merged;
27574
+ }
27575
+ function hasPath(value, dottedPath) {
27576
+ let cursor = value;
27577
+ for (const segment of dottedPath.split(".")) {
27578
+ if (!isPlainObject(cursor) || !Object.hasOwn(cursor, segment)) {
27579
+ return false;
27580
+ }
27581
+ cursor = cursor[segment];
27582
+ }
27583
+ return true;
27584
+ }
27585
+ function isPlainObject(value) {
27586
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27587
+ }
27511
27588
  function tryParseJson(text) {
27512
27589
  try {
27513
27590
  return JSON.parse(text);
@@ -27584,27 +27661,34 @@ Report your findings by calling the ${TOOL_NAME} tool.`;
27584
27661
  "anthropic-version": "2023-06-01",
27585
27662
  "Content-Type": "application/json"
27586
27663
  },
27587
- body: JSON.stringify({
27588
- model,
27589
- max_tokens: 4096,
27590
- tools: [
27664
+ body: JSON.stringify(
27665
+ mergeExtraBody(
27591
27666
  {
27592
- name: TOOL_NAME,
27593
- description: "Report the structured visual evidence extracted from the image.",
27594
- input_schema: VISION_RESULT_SCHEMA
27595
- }
27596
- ],
27597
- tool_choice: { type: "tool", name: TOOL_NAME },
27598
- messages: [
27599
- {
27600
- role: "user",
27601
- content: [
27602
- { type: "image", source: imageSource },
27603
- { type: "text", text: prompt }
27667
+ model,
27668
+ max_tokens: 4096,
27669
+ tools: [
27670
+ {
27671
+ name: TOOL_NAME,
27672
+ description: "Report the structured visual evidence extracted from the image.",
27673
+ input_schema: VISION_RESULT_SCHEMA
27674
+ }
27675
+ ],
27676
+ tool_choice: { type: "tool", name: TOOL_NAME },
27677
+ messages: [
27678
+ {
27679
+ role: "user",
27680
+ content: [
27681
+ { type: "image", source: imageSource },
27682
+ { type: "text", text: prompt }
27683
+ ]
27684
+ }
27604
27685
  ]
27605
- }
27606
- ]
27607
- }),
27686
+ },
27687
+ options.settings?.extraBody,
27688
+ ["model", "messages", "tools", "tool_choice", "stream"],
27689
+ "anthropic"
27690
+ )
27691
+ ),
27608
27692
  signal: AbortSignal.timeout(options.timeoutMs)
27609
27693
  });
27610
27694
  if (!response2.ok) {
@@ -27871,20 +27955,31 @@ async function executeGeminiApi(options) {
27871
27955
  "x-goog-api-key": apiKey,
27872
27956
  "Content-Type": "application/json"
27873
27957
  },
27874
- body: JSON.stringify({
27875
- contents: [
27958
+ body: JSON.stringify(
27959
+ mergeExtraBody(
27876
27960
  {
27877
- parts: [
27878
- { inline_data: { mime_type: image.mimeType, data: image.data } },
27879
- { text: prompt }
27880
- ]
27881
- }
27882
- ],
27883
- generationConfig: {
27884
- responseMimeType: "application/json",
27885
- responseJsonSchema: VISION_RESULT_SCHEMA
27886
- }
27887
- }),
27961
+ contents: [
27962
+ {
27963
+ parts: [
27964
+ { inline_data: { mime_type: image.mimeType, data: image.data } },
27965
+ { text: prompt }
27966
+ ]
27967
+ }
27968
+ ],
27969
+ generationConfig: {
27970
+ responseMimeType: "application/json",
27971
+ responseJsonSchema: VISION_RESULT_SCHEMA
27972
+ }
27973
+ },
27974
+ options.settings?.extraBody,
27975
+ [
27976
+ "contents",
27977
+ "generationConfig.responseMimeType",
27978
+ "generationConfig.responseJsonSchema"
27979
+ ],
27980
+ "gemini-api"
27981
+ )
27982
+ ),
27888
27983
  signal: AbortSignal.timeout(options.timeoutMs)
27889
27984
  });
27890
27985
  if (!response2.ok) {
@@ -27941,18 +28036,25 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
27941
28036
  Authorization: `Bearer ${apiKey}`,
27942
28037
  "Content-Type": "application/json"
27943
28038
  },
27944
- body: JSON.stringify({
27945
- model,
27946
- messages: [
28039
+ body: JSON.stringify(
28040
+ mergeExtraBody(
27947
28041
  {
27948
- role: "user",
27949
- content: [
27950
- { type: "image_url", image_url: { url: imageUrl } },
27951
- { type: "text", text: prompt }
28042
+ model,
28043
+ messages: [
28044
+ {
28045
+ role: "user",
28046
+ content: [
28047
+ { type: "image_url", image_url: { url: imageUrl } },
28048
+ { type: "text", text: prompt }
28049
+ ]
28050
+ }
27952
28051
  ]
27953
- }
27954
- ]
27955
- }),
28052
+ },
28053
+ options.settings?.extraBody,
28054
+ ["model", "messages", "stream"],
28055
+ "openai"
28056
+ )
28057
+ ),
27956
28058
  signal: AbortSignal.timeout(options.timeoutMs)
27957
28059
  });
27958
28060
  if (!response2.ok) {
@@ -28022,6 +28124,7 @@ function providerAliases() {
28022
28124
  function listProviders() {
28023
28125
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
28024
28126
  }
28127
+ const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
28025
28128
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
28026
28129
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
28027
28130
  const ENV_BINDINGS = {
@@ -28053,9 +28156,6 @@ function loadConfigFile(configPath = CONFIG_PATH) {
28053
28156
  );
28054
28157
  }
28055
28158
  }
28056
- function defaultProviderName(config2) {
28057
- return config2.provider?.trim() || "antigravity-cli";
28058
- }
28059
28159
  function resolveProviderSettings(providerName, config2, env = process.env) {
28060
28160
  const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
28061
28161
  const fromFile = {
@@ -28080,17 +28180,31 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28080
28180
  const dot = dottedKey.indexOf(".");
28081
28181
  if (dot <= 0 || dot === dottedKey.length - 1) {
28082
28182
  throw new Error(
28083
- `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
28183
+ `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28084
28184
  );
28085
28185
  }
28086
28186
  const providerName = dottedKey.slice(0, dot);
28087
28187
  const field = dottedKey.slice(dot + 1);
28088
- if (!["apiKey", "baseUrl", "model"].includes(field)) {
28089
- throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
28188
+ if (field === "extraBody") {
28189
+ config2.providers ??= {};
28190
+ config2.providers[providerName] ??= {};
28191
+ if (value.trim() === "") {
28192
+ delete config2.providers[providerName].extraBody;
28193
+ } else {
28194
+ config2.providers[providerName].extraBody = parseExtraBody(
28195
+ value,
28196
+ `${providerName}.extraBody`
28197
+ );
28198
+ }
28199
+ } else if (!STRING_FIELDS.includes(field)) {
28200
+ throw new Error(
28201
+ `Unknown config field: ${field}. Use apiKey, baseUrl, model, or extraBody.`
28202
+ );
28203
+ } else {
28204
+ config2.providers ??= {};
28205
+ config2.providers[providerName] ??= {};
28206
+ config2.providers[providerName][field] = value;
28090
28207
  }
28091
- config2.providers ??= {};
28092
- config2.providers[providerName] ??= {};
28093
- config2.providers[providerName][field] = value;
28094
28208
  }
28095
28209
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
28096
28210
  fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
@@ -28129,7 +28243,7 @@ function renderEffectiveConfig(config2, env = process.env) {
28129
28243
  const fileSettings = config2.providers?.[name] ?? {};
28130
28244
  const bindings = ENV_BINDINGS[name] ?? {};
28131
28245
  const fields = {};
28132
- for (const field of ["apiKey", "baseUrl", "model"]) {
28246
+ for (const field of STRING_FIELDS) {
28133
28247
  const envName = bindings[field];
28134
28248
  const envValue = envName ? env[envName]?.trim() : void 0;
28135
28249
  const value = envValue ?? fileSettings[field];
@@ -28139,6 +28253,9 @@ function renderEffectiveConfig(config2, env = process.env) {
28139
28253
  fields[field] = `${shown} (${source})`;
28140
28254
  }
28141
28255
  }
28256
+ if (fileSettings.extraBody !== void 0) {
28257
+ fields.extraBody = `${JSON.stringify(fileSettings.extraBody)} (file)`;
28258
+ }
28142
28259
  if (Object.keys(fields).length > 0) {
28143
28260
  providers[name] = fields;
28144
28261
  }
@@ -28157,32 +28274,184 @@ function maskKey(key) {
28157
28274
  }
28158
28275
  return `${key.slice(0, 6)}...${key.slice(-2)}`;
28159
28276
  }
28277
+ const PROVIDER_DESCRIPTORS = [
28278
+ {
28279
+ name: "antigravity-cli",
28280
+ kind: "subprocess",
28281
+ bin: "agy",
28282
+ install: "curl -fsSL https://antigravity.google/cli/install.sh | bash && agy # sign in, then exit"
28283
+ },
28284
+ {
28285
+ name: "gemini-api",
28286
+ kind: "api",
28287
+ required: [{ field: "apiKey", env: "GEMINI_API_KEY" }],
28288
+ fix: "modlens config set gemini-api.apiKey <key> # free key: https://aistudio.google.com"
28289
+ },
28290
+ {
28291
+ name: "openai",
28292
+ kind: "api",
28293
+ required: [
28294
+ { field: "baseUrl", env: "OPENAI_BASE_URL" },
28295
+ { field: "apiKey", env: "OPENAI_API_KEY" },
28296
+ { field: "model" }
28297
+ ],
28298
+ fix: "modlens config set openai.baseUrl <url> / openai.apiKey <key> / openai.model <name>"
28299
+ },
28300
+ {
28301
+ name: "anthropic",
28302
+ kind: "api",
28303
+ required: [{ field: "apiKey", env: "ANTHROPIC_API_KEY" }],
28304
+ fix: "modlens config set anthropic.apiKey <key>"
28305
+ },
28306
+ {
28307
+ name: "claude-cli",
28308
+ kind: "subprocess",
28309
+ bin: "claude",
28310
+ install: "install the Claude Code CLI, then run `claude` once to sign in"
28311
+ }
28312
+ ];
28313
+ function findOnPath(bin, env) {
28314
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
28315
+ for (const dir of dirs) {
28316
+ const full = path.join(dir, bin);
28317
+ try {
28318
+ if (fs.statSync(full).isFile()) {
28319
+ return full;
28320
+ }
28321
+ } catch {
28322
+ }
28323
+ }
28324
+ return null;
28325
+ }
28326
+ function providerAvailable(name, config2, env = process.env) {
28327
+ const descriptor = PROVIDER_DESCRIPTORS.find((d) => d.name === name);
28328
+ if (!descriptor) {
28329
+ return false;
28330
+ }
28331
+ if (descriptor.kind === "subprocess") {
28332
+ return findOnPath(descriptor.bin, env) !== null;
28333
+ }
28334
+ const settings = resolveProviderSettings(name, config2, env);
28335
+ return (descriptor.required ?? []).every((req) => Boolean(settings[req.field]?.trim()));
28336
+ }
28337
+ const LOCAL_FAILOVER_ORDER = [
28338
+ "antigravity-cli",
28339
+ "gemini-api",
28340
+ "openai",
28341
+ "anthropic",
28342
+ "claude-cli"
28343
+ ];
28344
+ const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
28345
+ function providerChain(kind, config2, env = process.env) {
28346
+ const names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
28347
+ const preferred = config2.provider?.trim();
28348
+ if (preferred) {
28349
+ let canonical = null;
28350
+ try {
28351
+ canonical = resolveProvider(preferred).name;
28352
+ } catch {
28353
+ canonical = null;
28354
+ }
28355
+ const index = canonical ? names.indexOf(canonical) : -1;
28356
+ if (index > 0 && canonical) {
28357
+ const isAgent = Boolean(resolveProvider(canonical).isolateWorkdir);
28358
+ if (kind === "local" || !isAgent) {
28359
+ names.splice(index, 1);
28360
+ names.unshift(canonical);
28361
+ }
28362
+ }
28363
+ }
28364
+ return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
28365
+ }
28160
28366
  const DEFAULT_TIMEOUT_MS = 18e4;
28161
28367
  const KILL_GRACE_MS = 3e4;
28162
28368
  const DRAIN_GRACE_MS = 500;
28163
28369
  const SIGKILL_GRACE_MS = 2e3;
28164
- function chooseProviderName(requested, config2, kind, env = process.env) {
28165
- const name = requested || defaultProviderName(config2);
28166
- if (requested || kind !== "remote") {
28167
- return name;
28168
- }
28169
- if (!resolveProvider(name).isolateWorkdir) {
28170
- return name;
28171
- }
28172
- return resolveProviderSettings("gemini-api", config2, env).apiKey ? "gemini-api" : name;
28173
- }
28174
28370
  async function analyzeImage(options) {
28175
28371
  const resolvedInput = resolveInput(options.input);
28176
28372
  if (resolvedInput.kind === "local") {
28177
28373
  validateInputFile(resolvedInput.source);
28178
28374
  }
28179
28375
  const config2 = options.config ?? loadConfigFile();
28180
- const provider = resolveProvider(
28181
- chooseProviderName(options.provider, config2, resolvedInput.kind)
28182
- );
28183
- const settings = resolveProviderSettings(provider.name, config2);
28376
+ const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : providerChain(resolvedInput.kind, config2);
28377
+ if (chain.length === 0) {
28378
+ throw new Error(
28379
+ "No vision provider is set up on this machine. Install Antigravity CLI (curl -fsSL https://antigravity.google/cli/install.sh | bash, then run agy once to sign in), or configure a key: modlens config set gemini-api.apiKey <key>. Run modlens doctor for the full picture."
28380
+ );
28381
+ }
28184
28382
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28185
- const model = options.model || settings.model || provider.defaultModel;
28383
+ const attempts = [];
28384
+ const warnings = [];
28385
+ let lastError;
28386
+ for (const provider of chain) {
28387
+ const startedAt = Date.now();
28388
+ const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
28389
+ try {
28390
+ const parsed = await runProvider(
28391
+ provider,
28392
+ model,
28393
+ options,
28394
+ resolvedInput,
28395
+ timeoutMs,
28396
+ config2,
28397
+ warnings
28398
+ );
28399
+ attempts.push({
28400
+ provider: provider.name,
28401
+ ok: true,
28402
+ durationSeconds: (Date.now() - startedAt) / 1e3
28403
+ });
28404
+ if (attempts.length > 1) {
28405
+ const failed = attempts.slice(0, -1);
28406
+ warnings.push(
28407
+ `Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
28408
+ );
28409
+ if (options.model) {
28410
+ warnings.push(
28411
+ `The explicit model applied to ${failed[0].provider} only; ${provider.name} ran its own default.`
28412
+ );
28413
+ }
28414
+ }
28415
+ return {
28416
+ image: resolvedInput.source,
28417
+ provider: provider.name,
28418
+ result: parsed.result,
28419
+ meta: {
28420
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
28421
+ model,
28422
+ conversationId: parsed.meta.conversationId,
28423
+ durationSeconds: parsed.meta.durationSeconds,
28424
+ usage: parsed.meta.usage,
28425
+ attempts,
28426
+ warnings
28427
+ }
28428
+ };
28429
+ } catch (error) {
28430
+ lastError = error;
28431
+ const message = error instanceof Error ? error.message : String(error);
28432
+ attempts.push({
28433
+ provider: provider.name,
28434
+ ok: false,
28435
+ durationSeconds: (Date.now() - startedAt) / 1e3,
28436
+ error: message.slice(0, 300)
28437
+ });
28438
+ }
28439
+ }
28440
+ if (chain.length === 1) {
28441
+ throw lastError;
28442
+ }
28443
+ throw new Error(
28444
+ `Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}`
28445
+ );
28446
+ }
28447
+ async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
28448
+ const configured = resolveProviderSettings(provider.name, config2);
28449
+ const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
28450
+ if (settings.extraBody && !provider.execute) {
28451
+ warnings.push(
28452
+ `${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
28453
+ );
28454
+ }
28186
28455
  const providerOptions = {
28187
28456
  imageSource: resolvedInput.source,
28188
28457
  imageKind: resolvedInput.kind,
@@ -28228,18 +28497,7 @@ async function analyzeImage(options) {
28228
28497
  `${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
28229
28498
  );
28230
28499
  }
28231
- return {
28232
- image: resolvedInput.source,
28233
- provider: provider.name,
28234
- result: parsed.result,
28235
- meta: {
28236
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
28237
- model,
28238
- conversationId: parsed.meta.conversationId,
28239
- durationSeconds: parsed.meta.durationSeconds,
28240
- usage: parsed.meta.usage
28241
- }
28242
- };
28500
+ return parsed;
28243
28501
  }
28244
28502
  function resolveInput(input) {
28245
28503
  const trimmed = input.trim();
@@ -28449,42 +28707,6 @@ function detectHarness() {
28449
28707
  return detectHarnessDetailed().harness;
28450
28708
  }
28451
28709
  const MIN_NODE = "22.13";
28452
- const DESCRIPTORS = [
28453
- {
28454
- name: "antigravity-cli",
28455
- kind: "subprocess",
28456
- bin: "agy",
28457
- install: "curl -fsSL https://antigravity.google/cli/install.sh | bash && agy # sign in, then exit"
28458
- },
28459
- {
28460
- name: "gemini-api",
28461
- kind: "api",
28462
- required: [{ field: "apiKey", env: "GEMINI_API_KEY" }],
28463
- fix: "modlens config set gemini-api.apiKey <key> # free key: https://aistudio.google.com"
28464
- },
28465
- {
28466
- name: "openai",
28467
- kind: "api",
28468
- required: [
28469
- { field: "baseUrl", env: "OPENAI_BASE_URL" },
28470
- { field: "apiKey", env: "OPENAI_API_KEY" },
28471
- { field: "model" }
28472
- ],
28473
- fix: "modlens config set openai.baseUrl <url> / openai.apiKey <key> / openai.model <name>"
28474
- },
28475
- {
28476
- name: "anthropic",
28477
- kind: "api",
28478
- required: [{ field: "apiKey", env: "ANTHROPIC_API_KEY" }],
28479
- fix: "modlens config set anthropic.apiKey <key>"
28480
- },
28481
- {
28482
- name: "claude-cli",
28483
- kind: "subprocess",
28484
- bin: "claude",
28485
- install: "install the Claude Code CLI, then run `claude` once to sign in"
28486
- }
28487
- ];
28488
28710
  function versionParts(version) {
28489
28711
  const match = /(\d+)\.(\d+)/.exec(version.replace(/^v/, ""));
28490
28712
  if (!match) {
@@ -28497,19 +28719,6 @@ function meetsMinimum(version, minimum) {
28497
28719
  const [minMajor, minMinor] = versionParts(minimum);
28498
28720
  return major > minMajor || major === minMajor && minor >= minMinor;
28499
28721
  }
28500
- function findOnPath(bin, env) {
28501
- const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
28502
- for (const dir of dirs) {
28503
- const full = path.join(dir, bin);
28504
- try {
28505
- if (fs.statSync(full).isFile()) {
28506
- return full;
28507
- }
28508
- } catch {
28509
- }
28510
- }
28511
- return null;
28512
- }
28513
28722
  function checkNodeSqlite() {
28514
28723
  const realEmit = process.emitWarning;
28515
28724
  process.emitWarning = () => {
@@ -28617,8 +28826,12 @@ function buildDoctorReport(input) {
28617
28826
  meetsMinimum: meetsMinimum(process.version, MIN_NODE)
28618
28827
  },
28619
28828
  nodeSqlite: checkNodeSqlite(),
28620
- providers: DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
28829
+ providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
28621
28830
  selection: resolveSelection(input.config, input.providerFlag),
28831
+ chains: {
28832
+ local: providerChain("local", input.config, env).map((p) => p.name),
28833
+ remote: providerChain("remote", input.config, env).map((p) => p.name)
28834
+ },
28622
28835
  harness: (() => {
28623
28836
  const detection = detectHarnessDetailed();
28624
28837
  return { detected: detection.harness, source: detection.source };
@@ -28653,6 +28866,11 @@ function renderDoctorReport(report) {
28653
28866
  lines.push(` ${report.selection.provider}${canonicalNote}`);
28654
28867
  lines.push(` reason: ${report.selection.reason}`);
28655
28868
  lines.push("");
28869
+ lines.push("Failover chains (what a run tries, in order)");
28870
+ const chainLine = (chain) => chain.length > 0 ? chain.join(" -> ") : "(none available)";
28871
+ lines.push(` local: ${chainLine(report.chains.local)}`);
28872
+ lines.push(` remote: ${chainLine(report.chains.remote)}`);
28873
+ lines.push("");
28656
28874
  lines.push("Harness");
28657
28875
  lines.push(
28658
28876
  report.harness.detected ? ` ${report.harness.detected} (via ${report.harness.source})` : ` none detected (${report.harness.source})`
@@ -29116,8 +29334,11 @@ function recoverPastedImages(options = {}) {
29116
29334
  return result;
29117
29335
  }
29118
29336
  const program = new Command();
29119
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.2.0");
29120
- 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").action(async (options) => {
29337
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.4.0");
29338
+ 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(
29339
+ "--extra-body <json>",
29340
+ `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
29341
+ ).action(async (options) => {
29121
29342
  try {
29122
29343
  const timeoutMs = Number.parseInt(options.timeout, 10);
29123
29344
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
@@ -29130,7 +29351,8 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
29130
29351
  prompt: options.prompt,
29131
29352
  timeoutMs,
29132
29353
  providerBin: options.providerBin,
29133
- workdir: options.workdir
29354
+ workdir: options.workdir,
29355
+ extraBody: options.extraBody ? parseExtraBody(options.extraBody, "--extra-body") : void 0
29134
29356
  });
29135
29357
  const output = JSON.stringify(result, null, 2);
29136
29358
  if (options.output) {
@@ -29211,6 +29433,7 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
29211
29433
  "Everything is optional. Two things you can set:",
29212
29434
  " modlens config set provider <name> which provider analyzes images",
29213
29435
  " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider credentials",
29436
+ ` modlens config set <provider>.extraBody '{"thinking":{"type":"disabled"}}' vendor request fields`,
29214
29437
  ""
29215
29438
  ].join("\n")
29216
29439
  );
package/docs/security.md CHANGED
@@ -22,7 +22,7 @@ The `claude-cli` provider runs with `--allowedTools Read` only, so it can read l
22
22
 
23
23
  Both subprocess providers also run in a throwaway directory created fresh per call and removed afterward. For a local image it holds a private copy of that one image and nothing else, and it is a real copy, never a hardlink, so a provider writing to its temp path cannot touch the original. For a remote image the directory is empty and the agent downloads into it. Without this, text inside an image could steer a broadly-permissioned agent into reading files next to the original, or whatever project the caller happened to be in. Passing `--workdir` opts out and runs where you point it.
24
24
 
25
- This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer an inline API provider (`-p gemini-api`), which hands the bytes to an HTTP endpoint and runs no local agent. Remote URLs already default there when a Gemini key is configured: the inline path downloads the image itself, behind the private-address guards, the magic-byte image check, and the size cap, none of which apply when an agent fetches the URL on its own. An explicit `-p` overrides the reroute.
25
+ This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer an inline API provider (`-p gemini-api`), which hands the bytes to an HTTP endpoint and runs no local agent. Remote URLs already prefer that path: the failover chain for a remote URL tries the inline API providers first and the agent last, because the inline path downloads the image itself, behind the private-address guards, the magic-byte image check, and the size cap, none of which apply when an agent fetches the URL on its own. An explicit `-p` pins one provider and overrides the chain.
26
26
 
27
27
  ## Image content is untrusted input
28
28
 
@@ -127,6 +127,23 @@ antigravity-cli provider timed out after 210000 ms.
127
127
 
128
128
  Retry once with `--timeout 300000`. Dense images on agy legitimately take 15-40 seconds, and `-m gemini-3.1-pro-high` is slower still. Engines that ignore SIGTERM are escalated to SIGKILL, so a timeout returns promptly regardless.
129
129
 
130
+ ## Every read is slow on a reasoning model
131
+
132
+ A model that thinks by default spends its budget before it starts transcribing, which a vision read does not need. There is no `--no-thinking` flag because each vendor names the switch differently, so pass the vendor's own field:
133
+
134
+ ```bash
135
+ modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}'
136
+ modlens -i shot.png --extra-body '{"reasoning_effort":"low"}' # one run only
137
+ ```
138
+
139
+ The per-vendor spellings, which models cannot turn it off at all, and how to tell whether the field actually landed are in [Configuration](../skills/modlens/references/configure.md#turning-thinking-off).
140
+
141
+ ```
142
+ extraBody cannot override "messages" for the openai provider
143
+ ```
144
+
145
+ That field carries the image, the prompt, or the schema enforcement. Remove it and keep the vendor knobs. A 400 from the gateway naming a field you set means that endpoint uses a different spelling, and a run on `antigravity-cli` or `claude-cli` says in `meta.warnings` that it ignored the value, since a CLI provider has no request body.
146
+
130
147
  ## Windows
131
148
 
132
149
  ModLens runs on Windows. Three platform differences are worth knowing:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,11 +33,11 @@ The launcher finds a working way to run modlens and forwards your arguments to i
33
33
 
34
34
  ### If you cannot run the launcher script
35
35
 
36
- Some harnesses forbid running scripts. Reason through the same order by hand and run the first line that works (the pinned version is 3.2.0):
36
+ Some harnesses forbid running scripts. Reason through the same order by hand and run the first line that works (the pinned version is 3.4.0):
37
37
 
38
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.2.0: `modlens <args>`.
39
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.2.0 modlens <args>`.
40
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.2.0 <args>`.
38
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.4.0: `modlens <args>`.
39
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.4.0 modlens <args>`.
40
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.4.0 <args>`.
41
41
  4. Otherwise none of these runtimes is here. Tell the user no JavaScript runtime was found and that installing Node 22.13+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
42
42
 
43
43
  `references/runtime.md` documents the version pin, the compatibility rule, and the diagnostic fields.
@@ -56,7 +56,9 @@ modlens config show
56
56
 
57
57
  `modlens config init` writes a starter config to `~/.modlens/config.json` when none exists. Full setup recipes per provider: `references/configure.md`.
58
58
 
59
- One routing rule to know: a remote image URL with no explicit `-p` runs on `gemini-api` whenever a Gemini key is configured, even if the default provider is an agent. The inline path downloads the image itself, behind the private-address guards, the magic-byte image check, and the size cap; an agent fetching the URL on its own passes through none of those. Without a Gemini key the run stays on the configured default, and an explicit `-p` always wins.
59
+ Failover is automatic: a run tries every provider that is set up on this machine, in order, and the first good result wins (a provider that errors, times out, or returns a schema-violating result hands over to the next). A local image tries `antigravity-cli` first, then `gemini-api`, `openai`, `anthropic`, `claude-cli`. A remote URL tries the inline API providers first (`gemini-api`, `openai`, `anthropic`) and the agent last, because only the inline download path runs the private-address guards, the magic-byte image check, and the size cap. A provider set with `config set provider <name>` is a preference that moves to the front of its allowed region, not a pin. An explicit `-p` pins exactly one provider with no fallback.
60
+
61
+ In the result, the top-level `provider` names who actually answered, `meta.attempts` lists every provider tried with timings and failure reasons, and `meta.warnings` carries failover notices. Relay a failover warning when the answer's provider surprised the user.
60
62
 
61
63
  ## Command
62
64
 
@@ -76,6 +78,8 @@ modlens -i <image> -o <output.json> -m <model> --prompt "<extra focus>" --timeou
76
78
 
77
79
  Speed expectations: `gemini-api` typically 5-10 seconds, `antigravity-cli` 15-40 seconds and `claude-cli` 20-45 seconds (full agent loops), `openai`/`anthropic` depend on the endpoint. For dense or hard images on antigravity-cli, try `-m gemini-3.1-pro-high`.
78
80
 
81
+ If every read is slow because the configured model thinks before answering, pass the vendor's own switch through the request body, for example `--extra-body '{"thinking":{"type":"disabled"}}'`, or store it with `modlens config set <provider>.extraBody '<json>'`. The spelling differs per endpoint, so read `references/configure.md` before guessing.
82
+
79
83
  ## Finding the image path in the chat
80
84
 
81
85
  Harnesses rarely hand you a clean path. First identify which harness you are in, then use its route. Never mix routes across harnesses.
@@ -51,6 +51,12 @@ Output is a fixed JSON shape:
51
51
  | `--timeout <ms>` | Provider timeout | `180000` |
52
52
  | `--provider-bin <path>` | Provider binary path | `agy` / `claude` |
53
53
  | `--workdir <path>` | Working directory for the provider | a fresh isolated directory per run |
54
+ | `--extra-body <json>` | JSON merged into the API request body, e.g. `'{"thinking":{"type":"disabled"}}'` | the provider's `extraBody` from the config |
55
+
56
+ `--extra-body` is how vendor-specific knobs get through, turning thinking off
57
+ being the common one. It applies to the three API providers and replaces the
58
+ configured `extraBody` for that run. Per-vendor spellings and the fields it
59
+ refuses to touch are in [Configuration](configure.md).
54
60
 
55
61
  The default `-m` model depends on the provider:
56
62
 
@@ -10,7 +10,7 @@ Read this when the user asks how to set up, configure, or switch ModLens provide
10
10
  modlens config init # write a starter config (refuses to overwrite; --force to redo)
11
11
  modlens config show # effective file, API keys masked
12
12
  modlens config set provider <name> # change the default provider
13
- modlens config set <provider>.<field> <value> # fields: apiKey, baseUrl, model
13
+ modlens config set <provider>.<field> <value> # fields: apiKey, baseUrl, model, extraBody
14
14
  ```
15
15
 
16
16
  `config set` writes the file with 0600 permissions.
@@ -32,7 +32,8 @@ Everything lives under two top-level keys, both optional. A missing file means a
32
32
  "openai": {
33
33
  "apiKey": "sk-...",
34
34
  "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
35
- "model": "qwen3.6-27b"
35
+ "model": "qwen3.6-27b",
36
+ "extraBody": { "thinking": { "type": "disabled" } }
36
37
  },
37
38
  "anthropic": { "apiKey": "sk-ant-..." },
38
39
  "claude-cli": { "model": "haiku" }
@@ -43,7 +44,8 @@ Everything lives under two top-level keys, both optional. A missing file means a
43
44
  Field semantics:
44
45
 
45
46
  - `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`, `claude-code` for `claude-cli`). Empty or absent means `antigravity-cli`.
46
- - `providers.<name>.<field>`: only three fields exist, `apiKey`, `baseUrl`, `model`. 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.
47
+ - `providers.<name>.<field>`: four fields exist, `apiKey`, `baseUrl`, `model`, and `extraBody`. 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.
48
+ - `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 the schema enforcement are refused with an error naming the field. The two CLI providers take no request body, so a run on `antigravity-cli` or `claude-cli` ignores it and says so in `meta.warnings`.
47
49
  - Environment variables override the file for these bindings: `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`. Nothing else is read from the environment except `MODLENS_HARNESS` (paste-recovery scope, unrelated to this file).
48
50
  - Unknown top-level keys and unknown provider names are ignored rather than rejected, so a typo fails quiet: run `modlens doctor` after hand-editing, it shows which file and env values are actually in effect.
49
51
 
@@ -105,6 +107,37 @@ Rides an existing `claude` sign-in, so it costs the user's Claude subscription q
105
107
  modlens config set provider claude-cli # make it the default if the user wants
106
108
  ```
107
109
 
110
+ ## Turning thinking off
111
+
112
+ A reasoning model spends its thinking budget before it answers. Reading text out of an image needs none of that, so on a model that thinks by default the run is slower and more expensive for nothing. Every vendor names the switch differently, and there is no portable one, so modlens sends whatever you put in `extraBody` and leaves the naming to the vendor's own docs.
113
+
114
+ ```bash
115
+ modlens config set openai.extraBody '{"thinking":{"type":"disabled"}}' # persist it
116
+ modlens -i shot.png --extra-body '{"thinking":{"type":"disabled"}}' # one run only
117
+ modlens config set openai.extraBody '' # clear it
118
+ ```
119
+
120
+ `--extra-body` replaces the stored object for that run rather than merging into it.
121
+
122
+ Known spellings, current as of August 2026:
123
+
124
+ | Endpoint | Field to send |
125
+ | :-- | :-- |
126
+ | MiMo official API (`api.xiaomimimo.com/v1`) | `{"thinking":{"type":"disabled"}}` |
127
+ | MiMo Responses-format route | `{"reasoning":{"effort":"none"}}` |
128
+ | Qwen, GLM, MiMo and friends self-hosted on vLLM or SGLang | `{"chat_template_kwargs":{"enable_thinking":false}}` |
129
+ | OpenAI-style gateways that accept an effort level | `{"reasoning_effort":"low"}` |
130
+ | `gemini-api`, Gemini 3 family | `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"LOW"}}}` |
131
+ | `gemini-api`, Gemini 2.5 Flash and Flash Lite | `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}` |
132
+ | `anthropic` | nothing to do, thinking is off unless it is asked for |
133
+
134
+ Three things that bite:
135
+
136
+ - Not every model can turn it off. Gemini 3 Pro and Gemini 2.5 Pro have no off switch, only a lower level. Some models ignore an effort field entirely and think anyway.
137
+ - Strict clouds (Groq and Cerebras among them) reject fields they do not recognize with a 400. If a request that worked before now fails with a 400 naming your field, that gateway wants a different spelling, not this one.
138
+ - Others accept an unknown field and quietly ignore it, so check that it took effect instead of assuming. Compare `meta.durationSeconds` and the token counts in `meta.usage` against a run without `extraBody`. If neither moved, the field did not land.
139
+ - A weaker model may need its thinking to fill the schema. Measured on one flowchart: `gemini-3.6-flash` at `thinkingLevel: LOW` came back in 5.7s instead of 12s with the same regions and the same transcription, but `qwen3.6-27b` on DashScope with `enable_thinking: false` started omitting the required `type` on layout regions, which modlens rejects rather than passing off as evidence. If shape errors appear right after you turn thinking off, that is the trade, so turn it back on for that model or move to a route with server-side schema enforcement.
140
+
108
141
  ## Choosing a provider for the user
109
142
 
110
143
  - Wants zero setup and free: `antigravity-cli` (needs agy sign-in, 15-40s per image).
@@ -112,10 +145,19 @@ modlens config set provider claude-cli # make it the default if the user wants
112
145
  - Already pays for Claude: `claude-cli` (no extra key) or `anthropic` (API billing).
113
146
  - Has a favorite multimodal endpoint (qwen, GLM, ...): `openai`.
114
147
 
148
+ Every configured provider also backs up the others: a run tries them in a
149
+ fixed order (local images agent-first; remote URLs inline-API-first, agent
150
+ last) and fails over on an error, a timeout, or a schema-violating result.
151
+ `config set provider <name>` moves a provider to the front of its allowed
152
+ region; `-p <name>` pins exactly one with no fallback. `doctor` prints the
153
+ chains, and the result's `meta.attempts` shows what a run actually tried.
154
+
115
155
  ## Troubleshooting
116
156
 
117
157
  - Error names a missing env var or `config set` command: run exactly that.
118
158
  - `Provider CLI not found: agy`: install Antigravity CLI or switch provider.
119
159
  - `Claude CLI reported ...` or empty result: check `claude` login state.
120
160
  - openai route `does not match the vision schema`: retry once, then switch to gemini-api or anthropic.
161
+ - `extraBody cannot override "<field>"`: that field carries the image, the prompt, or the schema. Drop it from the object and keep the vendor knobs.
162
+ - A 400 that names a field you set in `extraBody`: that gateway does not know it. See the thinking section above for the other spellings.
121
163
  - `config init` refusing to run: the file exists; use `modlens config show` first, `--force` only if the user agrees to overwrite.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.2.0
11
+ - Pinned CLI version: 3.4.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.2.0'
27
+ $Pinned = '3.4.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.2.0"
25
+ PINNED="3.4.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"