@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 +5 -0
- package/dist/main.js +171 -80
- package/docs/security.md +1 -1
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +7 -5
- package/skills/modlens/references/configure.md +7 -0
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
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
|
|
28181
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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
package/skills/modlens/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
39
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.
|
|
40
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.
|
|
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
|
-
|
|
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.
|
|
@@ -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.
|
|
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.
|
|
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"
|