@liustack/modlens 3.2.0 → 3.3.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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.3.0 - 2026-08-07
4
+
5
+ - 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.
6
+ - 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.
7
+
3
8
  ## 3.2.0 - 2026-08-07
4
9
 
5
10
  - 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/dist/main.js CHANGED
@@ -28053,9 +28053,6 @@ function loadConfigFile(configPath = CONFIG_PATH) {
28053
28053
  );
28054
28054
  }
28055
28055
  }
28056
- function defaultProviderName(config2) {
28057
- return config2.provider?.trim() || "antigravity-cli";
28058
- }
28059
28056
  function resolveProviderSettings(providerName, config2, env = process.env) {
28060
28057
  const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
28061
28058
  const fromFile = {
@@ -28157,32 +28154,177 @@ function maskKey(key) {
28157
28154
  }
28158
28155
  return `${key.slice(0, 6)}...${key.slice(-2)}`;
28159
28156
  }
28157
+ const PROVIDER_DESCRIPTORS = [
28158
+ {
28159
+ name: "antigravity-cli",
28160
+ kind: "subprocess",
28161
+ bin: "agy",
28162
+ install: "curl -fsSL https://antigravity.google/cli/install.sh | bash && agy # sign in, then exit"
28163
+ },
28164
+ {
28165
+ name: "gemini-api",
28166
+ kind: "api",
28167
+ required: [{ field: "apiKey", env: "GEMINI_API_KEY" }],
28168
+ fix: "modlens config set gemini-api.apiKey <key> # free key: https://aistudio.google.com"
28169
+ },
28170
+ {
28171
+ name: "openai",
28172
+ kind: "api",
28173
+ required: [
28174
+ { field: "baseUrl", env: "OPENAI_BASE_URL" },
28175
+ { field: "apiKey", env: "OPENAI_API_KEY" },
28176
+ { field: "model" }
28177
+ ],
28178
+ fix: "modlens config set openai.baseUrl <url> / openai.apiKey <key> / openai.model <name>"
28179
+ },
28180
+ {
28181
+ name: "anthropic",
28182
+ kind: "api",
28183
+ required: [{ field: "apiKey", env: "ANTHROPIC_API_KEY" }],
28184
+ fix: "modlens config set anthropic.apiKey <key>"
28185
+ },
28186
+ {
28187
+ name: "claude-cli",
28188
+ kind: "subprocess",
28189
+ bin: "claude",
28190
+ install: "install the Claude Code CLI, then run `claude` once to sign in"
28191
+ }
28192
+ ];
28193
+ function findOnPath(bin, env) {
28194
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
28195
+ for (const dir of dirs) {
28196
+ const full = path.join(dir, bin);
28197
+ try {
28198
+ if (fs.statSync(full).isFile()) {
28199
+ return full;
28200
+ }
28201
+ } catch {
28202
+ }
28203
+ }
28204
+ return null;
28205
+ }
28206
+ function providerAvailable(name, config2, env = process.env) {
28207
+ const descriptor = PROVIDER_DESCRIPTORS.find((d) => d.name === name);
28208
+ if (!descriptor) {
28209
+ return false;
28210
+ }
28211
+ if (descriptor.kind === "subprocess") {
28212
+ return findOnPath(descriptor.bin, env) !== null;
28213
+ }
28214
+ const settings = resolveProviderSettings(name, config2, env);
28215
+ return (descriptor.required ?? []).every((req) => Boolean(settings[req.field]?.trim()));
28216
+ }
28217
+ const LOCAL_FAILOVER_ORDER = [
28218
+ "antigravity-cli",
28219
+ "gemini-api",
28220
+ "openai",
28221
+ "anthropic",
28222
+ "claude-cli"
28223
+ ];
28224
+ const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
28225
+ function providerChain(kind, config2, env = process.env) {
28226
+ const names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
28227
+ const preferred = config2.provider?.trim();
28228
+ if (preferred) {
28229
+ let canonical = null;
28230
+ try {
28231
+ canonical = resolveProvider(preferred).name;
28232
+ } catch {
28233
+ canonical = null;
28234
+ }
28235
+ const index = canonical ? names.indexOf(canonical) : -1;
28236
+ if (index > 0 && canonical) {
28237
+ const isAgent = Boolean(resolveProvider(canonical).isolateWorkdir);
28238
+ if (kind === "local" || !isAgent) {
28239
+ names.splice(index, 1);
28240
+ names.unshift(canonical);
28241
+ }
28242
+ }
28243
+ }
28244
+ return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
28245
+ }
28160
28246
  const DEFAULT_TIMEOUT_MS = 18e4;
28161
28247
  const KILL_GRACE_MS = 3e4;
28162
28248
  const DRAIN_GRACE_MS = 500;
28163
28249
  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
28250
  async function analyzeImage(options) {
28175
28251
  const resolvedInput = resolveInput(options.input);
28176
28252
  if (resolvedInput.kind === "local") {
28177
28253
  validateInputFile(resolvedInput.source);
28178
28254
  }
28179
28255
  const config2 = options.config ?? loadConfigFile();
28180
- const provider = resolveProvider(
28181
- chooseProviderName(options.provider, config2, resolvedInput.kind)
28256
+ const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : providerChain(resolvedInput.kind, config2);
28257
+ if (chain.length === 0) {
28258
+ throw new Error(
28259
+ "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."
28260
+ );
28261
+ }
28262
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28263
+ const attempts = [];
28264
+ const warnings = [];
28265
+ let lastError;
28266
+ for (const provider of chain) {
28267
+ const startedAt = Date.now();
28268
+ const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
28269
+ try {
28270
+ const parsed = await runProvider(
28271
+ provider,
28272
+ model,
28273
+ options,
28274
+ resolvedInput,
28275
+ timeoutMs,
28276
+ config2
28277
+ );
28278
+ attempts.push({
28279
+ provider: provider.name,
28280
+ ok: true,
28281
+ durationSeconds: (Date.now() - startedAt) / 1e3
28282
+ });
28283
+ if (attempts.length > 1) {
28284
+ const failed = attempts.slice(0, -1);
28285
+ warnings.push(
28286
+ `Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
28287
+ );
28288
+ if (options.model) {
28289
+ warnings.push(
28290
+ `The explicit model applied to ${failed[0].provider} only; ${provider.name} ran its own default.`
28291
+ );
28292
+ }
28293
+ }
28294
+ return {
28295
+ image: resolvedInput.source,
28296
+ provider: provider.name,
28297
+ result: parsed.result,
28298
+ meta: {
28299
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
28300
+ model,
28301
+ conversationId: parsed.meta.conversationId,
28302
+ durationSeconds: parsed.meta.durationSeconds,
28303
+ usage: parsed.meta.usage,
28304
+ attempts,
28305
+ warnings
28306
+ }
28307
+ };
28308
+ } catch (error) {
28309
+ lastError = error;
28310
+ const message = error instanceof Error ? error.message : String(error);
28311
+ attempts.push({
28312
+ provider: provider.name,
28313
+ ok: false,
28314
+ durationSeconds: (Date.now() - startedAt) / 1e3,
28315
+ error: message.slice(0, 300)
28316
+ });
28317
+ }
28318
+ }
28319
+ if (chain.length === 1) {
28320
+ throw lastError;
28321
+ }
28322
+ throw new Error(
28323
+ `Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}`
28182
28324
  );
28325
+ }
28326
+ async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2) {
28183
28327
  const settings = resolveProviderSettings(provider.name, config2);
28184
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28185
- const model = options.model || settings.model || provider.defaultModel;
28186
28328
  const providerOptions = {
28187
28329
  imageSource: resolvedInput.source,
28188
28330
  imageKind: resolvedInput.kind,
@@ -28228,18 +28370,7 @@ async function analyzeImage(options) {
28228
28370
  `${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
28229
28371
  );
28230
28372
  }
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
- };
28373
+ return parsed;
28243
28374
  }
28244
28375
  function resolveInput(input) {
28245
28376
  const trimmed = input.trim();
@@ -28449,42 +28580,6 @@ function detectHarness() {
28449
28580
  return detectHarnessDetailed().harness;
28450
28581
  }
28451
28582
  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
28583
  function versionParts(version) {
28489
28584
  const match = /(\d+)\.(\d+)/.exec(version.replace(/^v/, ""));
28490
28585
  if (!match) {
@@ -28497,19 +28592,6 @@ function meetsMinimum(version, minimum) {
28497
28592
  const [minMajor, minMinor] = versionParts(minimum);
28498
28593
  return major > minMajor || major === minMajor && minor >= minMinor;
28499
28594
  }
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
28595
  function checkNodeSqlite() {
28514
28596
  const realEmit = process.emitWarning;
28515
28597
  process.emitWarning = () => {
@@ -28617,8 +28699,12 @@ function buildDoctorReport(input) {
28617
28699
  meetsMinimum: meetsMinimum(process.version, MIN_NODE)
28618
28700
  },
28619
28701
  nodeSqlite: checkNodeSqlite(),
28620
- providers: DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
28702
+ providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
28621
28703
  selection: resolveSelection(input.config, input.providerFlag),
28704
+ chains: {
28705
+ local: providerChain("local", input.config, env).map((p) => p.name),
28706
+ remote: providerChain("remote", input.config, env).map((p) => p.name)
28707
+ },
28622
28708
  harness: (() => {
28623
28709
  const detection = detectHarnessDetailed();
28624
28710
  return { detected: detection.harness, source: detection.source };
@@ -28653,6 +28739,11 @@ function renderDoctorReport(report) {
28653
28739
  lines.push(` ${report.selection.provider}${canonicalNote}`);
28654
28740
  lines.push(` reason: ${report.selection.reason}`);
28655
28741
  lines.push("");
28742
+ lines.push("Failover chains (what a run tries, in order)");
28743
+ const chainLine = (chain) => chain.length > 0 ? chain.join(" -> ") : "(none available)";
28744
+ lines.push(` local: ${chainLine(report.chains.local)}`);
28745
+ lines.push(` remote: ${chainLine(report.chains.remote)}`);
28746
+ lines.push("");
28656
28747
  lines.push("Harness");
28657
28748
  lines.push(
28658
28749
  report.harness.detected ? ` ${report.harness.detected} (via ${report.harness.source})` : ` none detected (${report.harness.source})`
@@ -29116,7 +29207,7 @@ function recoverPastedImages(options = {}) {
29116
29207
  return result;
29117
29208
  }
29118
29209
  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");
29210
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.3.0");
29120
29211
  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) => {
29121
29212
  try {
29122
29213
  const timeoutMs = Number.parseInt(options.timeout, 10);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.2.0",
3
+ "version": "3.3.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.3.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.3.0: `modlens <args>`.
39
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.3.0 modlens <args>`.
40
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.3.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
 
@@ -112,6 +112,13 @@ modlens config set provider claude-cli # make it the default if the user wants
112
112
  - Already pays for Claude: `claude-cli` (no extra key) or `anthropic` (API billing).
113
113
  - Has a favorite multimodal endpoint (qwen, GLM, ...): `openai`.
114
114
 
115
+ Every configured provider also backs up the others: a run tries them in a
116
+ fixed order (local images agent-first; remote URLs inline-API-first, agent
117
+ last) and fails over on an error, a timeout, or a schema-violating result.
118
+ `config set provider <name>` moves a provider to the front of its allowed
119
+ region; `-p <name>` pins exactly one with no fallback. `doctor` prints the
120
+ chains, and the result's `meta.attempts` shows what a run actually tried.
121
+
115
122
  ## Troubleshooting
116
123
 
117
124
  - Error names a missing env var or `config set` command: run exactly that.
@@ -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.3.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.3.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.3.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"