@liustack/modlens 3.7.0 → 3.8.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 +9 -0
- package/README.md +24 -23
- package/README.zh-CN.md +33 -32
- package/dist/main.js +972 -508
- package/{skills/modlens/references → docs}/cli.md +19 -4
- package/{skills/modlens/references → docs}/output-schema.md +13 -2
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +33 -136
- package/skills/modlens/references/configure.md +13 -6
- package/skills/modlens/references/find-image.md +33 -0
- package/skills/modlens/references/onboard.md +51 -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/dist/main.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import * as childProcess from "child_process";
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync, spawn } from "child_process";
|
|
7
7
|
import * as os from "os";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import require$$0$1 from "node:assert";
|
|
@@ -33,6 +33,82 @@ import * as dns$1 from "dns/promises";
|
|
|
33
33
|
import { isIP } from "net";
|
|
34
34
|
import { createRequire } from "module";
|
|
35
35
|
import * as crypto from "crypto";
|
|
36
|
+
function denyPatterns(guards) {
|
|
37
|
+
return stringPatterns(guards?.denyModels);
|
|
38
|
+
}
|
|
39
|
+
function allowPatterns(guards) {
|
|
40
|
+
return stringPatterns(guards?.allowModels);
|
|
41
|
+
}
|
|
42
|
+
function stringPatterns(raw) {
|
|
43
|
+
if (!Array.isArray(raw)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return raw.filter((pattern) => typeof pattern === "string");
|
|
47
|
+
}
|
|
48
|
+
function globMatch(pattern, value) {
|
|
49
|
+
const regex = pattern.split(/([*?])/).map((part) => {
|
|
50
|
+
if (part === "*") {
|
|
51
|
+
return ".*";
|
|
52
|
+
}
|
|
53
|
+
if (part === "?") {
|
|
54
|
+
return ".";
|
|
55
|
+
}
|
|
56
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
57
|
+
}).join("");
|
|
58
|
+
return new RegExp(`^${regex}$`, "i").test(value);
|
|
59
|
+
}
|
|
60
|
+
function evaluateGuard(guards, detection) {
|
|
61
|
+
const deny = denyPatterns(guards);
|
|
62
|
+
const allow = allowPatterns(guards);
|
|
63
|
+
if (!detection.model) {
|
|
64
|
+
if (guards?.denyWhenUnknown === true) {
|
|
65
|
+
return {
|
|
66
|
+
...detection,
|
|
67
|
+
guard: "deny",
|
|
68
|
+
reason: "model unknown and denyWhenUnknown is set"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...detection,
|
|
73
|
+
guard: "allow",
|
|
74
|
+
reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (deny.length === 0 && allow.length === 0) {
|
|
78
|
+
return { ...detection, guard: "allow", reason: "no deny rules configured" };
|
|
79
|
+
}
|
|
80
|
+
const candidates = [detection.model];
|
|
81
|
+
if (detection.provider) {
|
|
82
|
+
candidates.push(`${detection.provider}/${detection.model}`);
|
|
83
|
+
}
|
|
84
|
+
const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
|
|
85
|
+
const denied = firstMatch(deny);
|
|
86
|
+
if (denied) {
|
|
87
|
+
return {
|
|
88
|
+
...detection,
|
|
89
|
+
guard: "deny",
|
|
90
|
+
matched: denied,
|
|
91
|
+
reason: "model has native vision per guards.denyModels"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (allow.length > 0) {
|
|
95
|
+
const allowed = firstMatch(allow);
|
|
96
|
+
if (allowed) {
|
|
97
|
+
return {
|
|
98
|
+
...detection,
|
|
99
|
+
guard: "allow",
|
|
100
|
+
matched: allowed,
|
|
101
|
+
reason: "model is on guards.allowModels"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...detection,
|
|
106
|
+
guard: "deny",
|
|
107
|
+
reason: "not on guards.allowModels: only listed models run the engine"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ...detection, guard: "allow", reason: "not on the deny list" };
|
|
111
|
+
}
|
|
36
112
|
var undici = { exports: {} };
|
|
37
113
|
var symbols;
|
|
38
114
|
var hasRequiredSymbols;
|
|
@@ -27335,6 +27411,8 @@ async function readCapped(response2, url) {
|
|
|
27335
27411
|
}
|
|
27336
27412
|
return Buffer.concat(chunks);
|
|
27337
27413
|
}
|
|
27414
|
+
const JSON_TEMPLATE_INSTRUCTION = `Respond with ONE JSON object only, no markdown fences, no commentary. Fill this exact structure with your findings from the image (do not repeat this template literally, replace every value):
|
|
27415
|
+
{"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title|subtitle|paragraph|list|table|chart|form|code|image|icon|other","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
|
|
27338
27416
|
function buildVisionPrompt(options) {
|
|
27339
27417
|
const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
|
|
27340
27418
|
const basePrompt = `${readInstruction}
|
|
@@ -28000,8 +28078,7 @@ async function executeOpenaiCompat(options) {
|
|
|
28000
28078
|
extraPrompt: options.extraPrompt
|
|
28001
28079
|
})}
|
|
28002
28080
|
|
|
28003
|
-
|
|
28004
|
-
{"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title|subtitle|paragraph|list|table|chart|form|code|image|icon|other","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
|
|
28081
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28005
28082
|
const startedAt = Date.now();
|
|
28006
28083
|
const response2 = await fetch(`${baseUrl}/chat/completions`, {
|
|
28007
28084
|
method: "POST",
|
|
@@ -28098,6 +28175,7 @@ function listProviders() {
|
|
|
28098
28175
|
return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
|
|
28099
28176
|
}
|
|
28100
28177
|
const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
|
|
28178
|
+
const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi"];
|
|
28101
28179
|
const CONFIG_DIR = path.join(os.homedir(), ".modlens");
|
|
28102
28180
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
28103
28181
|
const ENV_BINDINGS = {
|
|
@@ -28149,19 +28227,33 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
28149
28227
|
const config2 = loadConfigFile(configPath);
|
|
28150
28228
|
if (dottedKey === "provider") {
|
|
28151
28229
|
config2.provider = value;
|
|
28152
|
-
} else if (dottedKey
|
|
28230
|
+
} else if (dottedKey.startsWith("reuse.")) {
|
|
28231
|
+
const harness = dottedKey.slice("reuse.".length);
|
|
28232
|
+
if (!REUSE_HARNESSES.includes(harness)) {
|
|
28233
|
+
throw new Error(
|
|
28234
|
+
`Unknown reuse harness: ${harness}. Use ${REUSE_HARNESSES.join(", ")}.`
|
|
28235
|
+
);
|
|
28236
|
+
}
|
|
28237
|
+
const key = harness;
|
|
28153
28238
|
const normalized = value.trim().toLowerCase();
|
|
28154
|
-
if (normalized
|
|
28155
|
-
|
|
28239
|
+
if (normalized === "") {
|
|
28240
|
+
delete config2.reuse?.[key];
|
|
28241
|
+
if (config2.reuse && Object.keys(config2.reuse).length === 0) {
|
|
28242
|
+
delete config2.reuse;
|
|
28243
|
+
}
|
|
28244
|
+
} else if (normalized !== "true" && normalized !== "false") {
|
|
28245
|
+
throw new Error(`reuse.${harness} must be true or false (empty clears).`);
|
|
28246
|
+
} else {
|
|
28247
|
+
config2.reuse ??= {};
|
|
28248
|
+
config2.reuse[key] = normalized === "true";
|
|
28156
28249
|
}
|
|
28157
|
-
config2.auto = normalized === "true";
|
|
28158
28250
|
} else if (dottedKey.startsWith("guards.")) {
|
|
28159
28251
|
setGuardsValue(config2, dottedKey.slice("guards.".length), value);
|
|
28160
28252
|
} else {
|
|
28161
28253
|
const dot = dottedKey.indexOf(".");
|
|
28162
28254
|
if (dot <= 0 || dot === dottedKey.length - 1) {
|
|
28163
28255
|
throw new Error(
|
|
28164
|
-
`Invalid config key: ${dottedKey}. Use "provider", "
|
|
28256
|
+
`Invalid config key: ${dottedKey}. Use "provider", "reuse.<claude|codex|opencode|pi>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
|
|
28165
28257
|
);
|
|
28166
28258
|
}
|
|
28167
28259
|
const providerName = dottedKey.slice(0, dot);
|
|
@@ -28286,6 +28378,9 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
28286
28378
|
if (config2.guards.denyModels !== void 0) {
|
|
28287
28379
|
guards.denyModels = `${JSON.stringify(config2.guards.denyModels)} (file)`;
|
|
28288
28380
|
}
|
|
28381
|
+
if (config2.guards.allowModels !== void 0) {
|
|
28382
|
+
guards.allowModels = `${JSON.stringify(config2.guards.allowModels)} (file)`;
|
|
28383
|
+
}
|
|
28289
28384
|
if (config2.guards.denyWhenUnknown !== void 0) {
|
|
28290
28385
|
guards.denyWhenUnknown = `${config2.guards.denyWhenUnknown} (file)`;
|
|
28291
28386
|
}
|
|
@@ -28293,6 +28388,14 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
28293
28388
|
effective.guards = guards;
|
|
28294
28389
|
}
|
|
28295
28390
|
}
|
|
28391
|
+
if (config2.reuse && Object.keys(config2.reuse).length > 0) {
|
|
28392
|
+
effective.reuse = Object.fromEntries(
|
|
28393
|
+
Object.entries(config2.reuse).map(([harness, granted]) => [
|
|
28394
|
+
harness,
|
|
28395
|
+
`${granted} (file)`
|
|
28396
|
+
])
|
|
28397
|
+
);
|
|
28398
|
+
}
|
|
28296
28399
|
return JSON.stringify(effective, null, 2);
|
|
28297
28400
|
}
|
|
28298
28401
|
function maskKey(key) {
|
|
@@ -28362,15 +28465,18 @@ function providerAvailable(name, config2, env = process.env) {
|
|
|
28362
28465
|
return (descriptor.required ?? []).every((req) => Boolean(settings[req.field]?.trim()));
|
|
28363
28466
|
}
|
|
28364
28467
|
const LOCAL_FAILOVER_ORDER = [
|
|
28365
|
-
"antigravity-cli",
|
|
28366
28468
|
"gemini-api",
|
|
28367
28469
|
"openai",
|
|
28368
28470
|
"anthropic",
|
|
28471
|
+
"antigravity-cli",
|
|
28369
28472
|
"claude-cli"
|
|
28370
28473
|
];
|
|
28371
28474
|
const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
|
|
28372
28475
|
function providerChain(kind, config2, env = process.env) {
|
|
28373
|
-
|
|
28476
|
+
let names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
|
|
28477
|
+
if (config2.reuse?.claude === false) {
|
|
28478
|
+
names = names.filter((name) => name !== "claude-cli");
|
|
28479
|
+
}
|
|
28374
28480
|
const preferred = config2.provider?.trim();
|
|
28375
28481
|
if (preferred) {
|
|
28376
28482
|
let canonical = null;
|
|
@@ -28390,454 +28496,121 @@ function providerChain(kind, config2, env = process.env) {
|
|
|
28390
28496
|
}
|
|
28391
28497
|
return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
|
|
28392
28498
|
}
|
|
28393
|
-
const
|
|
28394
|
-
|
|
28395
|
-
|
|
28396
|
-
|
|
28397
|
-
|
|
28398
|
-
|
|
28399
|
-
|
|
28400
|
-
|
|
28401
|
-
|
|
28402
|
-
|
|
28403
|
-
|
|
28404
|
-
|
|
28405
|
-
|
|
28406
|
-
|
|
28407
|
-
|
|
28408
|
-
|
|
28409
|
-
|
|
28410
|
-
|
|
28411
|
-
|
|
28412
|
-
|
|
28413
|
-
|
|
28414
|
-
|
|
28415
|
-
|
|
28499
|
+
const VISION_MODEL_PATTERNS = [
|
|
28500
|
+
"claude-*",
|
|
28501
|
+
"gpt-4o*",
|
|
28502
|
+
"gpt-4.1*",
|
|
28503
|
+
"gpt-5*",
|
|
28504
|
+
"o3*",
|
|
28505
|
+
"o4*",
|
|
28506
|
+
"gemini-*",
|
|
28507
|
+
"glm-*v*",
|
|
28508
|
+
"qwen*-vl*",
|
|
28509
|
+
"qwen3.5-plus*",
|
|
28510
|
+
"qwen3.6-plus*",
|
|
28511
|
+
"kimi-k2.5*",
|
|
28512
|
+
"kimi-k2.6*",
|
|
28513
|
+
"kimi-k2.7*",
|
|
28514
|
+
"kimi-k3*",
|
|
28515
|
+
"moonshot-v1-*vision*",
|
|
28516
|
+
"minimax-vl*",
|
|
28517
|
+
"minimax-m3*",
|
|
28518
|
+
"deepseek-vl*",
|
|
28519
|
+
"deepseek-ocr*",
|
|
28520
|
+
"janus*",
|
|
28521
|
+
"pixtral*",
|
|
28522
|
+
"llama-4*",
|
|
28523
|
+
"llama-3.2-*vision*",
|
|
28524
|
+
"grok-4*",
|
|
28525
|
+
"grok-2-vision*",
|
|
28526
|
+
"internvl*"
|
|
28527
|
+
];
|
|
28528
|
+
function isVisionModel(modelId) {
|
|
28529
|
+
const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId;
|
|
28530
|
+
return VISION_MODEL_PATTERNS.some((pattern) => globMatch(pattern, bare));
|
|
28531
|
+
}
|
|
28532
|
+
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
28533
|
+
const CLI_TIMEOUT_MS = 1e4;
|
|
28534
|
+
function defaultRunCli(bin, args, timeoutMs) {
|
|
28535
|
+
return execFileSync(bin, args, { encoding: "utf-8", timeout: timeoutMs, stdio: "pipe" });
|
|
28536
|
+
}
|
|
28537
|
+
function timed(run) {
|
|
28538
|
+
const start = Date.now();
|
|
28539
|
+
const probe = run();
|
|
28540
|
+
return { ...probe, elapsedMs: Date.now() - start };
|
|
28541
|
+
}
|
|
28542
|
+
function readJson(filePath) {
|
|
28543
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
28544
|
+
}
|
|
28545
|
+
function probeClaude(env) {
|
|
28546
|
+
return timed(() => {
|
|
28547
|
+
const cliPath = findOnPath("claude", env);
|
|
28548
|
+
if (!cliPath) {
|
|
28549
|
+
return {
|
|
28550
|
+
harness: "claude-code",
|
|
28551
|
+
cliFound: false,
|
|
28552
|
+
visionModels: [],
|
|
28553
|
+
source: "none"
|
|
28554
|
+
};
|
|
28555
|
+
}
|
|
28556
|
+
return {
|
|
28557
|
+
harness: "claude-code",
|
|
28558
|
+
cliFound: true,
|
|
28559
|
+
cliPath,
|
|
28560
|
+
visionModels: ["anthropic/* (all current models)"],
|
|
28561
|
+
source: "builtin-table"
|
|
28562
|
+
};
|
|
28563
|
+
});
|
|
28564
|
+
}
|
|
28565
|
+
function probeCodex(env, home) {
|
|
28566
|
+
return timed(() => {
|
|
28567
|
+
const cliPath = findOnPath("codex", env);
|
|
28568
|
+
const base = { harness: "codex", cliFound: cliPath !== null };
|
|
28569
|
+
if (!cliPath) {
|
|
28570
|
+
return { ...base, visionModels: [], source: "none" };
|
|
28571
|
+
}
|
|
28572
|
+
const codexHome = path.join(home, ".codex");
|
|
28573
|
+
const loggedIn = fs.existsSync(path.join(codexHome, "auth.json"));
|
|
28574
|
+
if (!fs.existsSync(path.join(codexHome, "config.toml"))) {
|
|
28575
|
+
return {
|
|
28576
|
+
...base,
|
|
28577
|
+
cliPath,
|
|
28578
|
+
loggedIn,
|
|
28579
|
+
visionModels: ["default"],
|
|
28580
|
+
source: "builtin-table"
|
|
28581
|
+
};
|
|
28582
|
+
}
|
|
28416
28583
|
try {
|
|
28417
|
-
const
|
|
28418
|
-
|
|
28419
|
-
|
|
28420
|
-
|
|
28421
|
-
|
|
28422
|
-
|
|
28423
|
-
|
|
28424
|
-
|
|
28425
|
-
|
|
28426
|
-
|
|
28427
|
-
|
|
28428
|
-
ok: true,
|
|
28429
|
-
durationSeconds: (Date.now() - startedAt) / 1e3
|
|
28430
|
-
});
|
|
28431
|
-
if (attempts.length > 1) {
|
|
28432
|
-
const failed = attempts.slice(0, -1);
|
|
28433
|
-
warnings.push(
|
|
28434
|
-
`Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
|
|
28435
|
-
);
|
|
28436
|
-
if (options.model) {
|
|
28437
|
-
warnings.push(
|
|
28438
|
-
`The explicit model applied to ${failed[0].provider} only; ${provider.name} ran its own default.`
|
|
28439
|
-
);
|
|
28440
|
-
}
|
|
28584
|
+
const toml = fs.readFileSync(path.join(codexHome, "config.toml"), "utf-8");
|
|
28585
|
+
const catalogPath = toml.match(/^model_catalog_json\s*=\s*"([^"]+)"/m)?.[1];
|
|
28586
|
+
const thirdParty = /^model_provider\s*=/m.test(toml);
|
|
28587
|
+
if (!catalogPath) {
|
|
28588
|
+
return thirdParty ? { ...base, cliPath, loggedIn, visionModels: [], source: "none" } : {
|
|
28589
|
+
...base,
|
|
28590
|
+
cliPath,
|
|
28591
|
+
loggedIn,
|
|
28592
|
+
visionModels: ["default"],
|
|
28593
|
+
source: "builtin-table"
|
|
28594
|
+
};
|
|
28441
28595
|
}
|
|
28596
|
+
const catalog = readJson(catalogPath);
|
|
28597
|
+
const vision = (catalog.models ?? []).filter((m) => m.slug && (m.input_modalities ?? []).includes("image")).map((m) => m.slug);
|
|
28442
28598
|
return {
|
|
28443
|
-
|
|
28444
|
-
|
|
28445
|
-
|
|
28446
|
-
|
|
28447
|
-
|
|
28448
|
-
model,
|
|
28449
|
-
conversationId: parsed.meta.conversationId,
|
|
28450
|
-
durationSeconds: parsed.meta.durationSeconds,
|
|
28451
|
-
usage: parsed.meta.usage,
|
|
28452
|
-
attempts,
|
|
28453
|
-
warnings
|
|
28454
|
-
}
|
|
28599
|
+
...base,
|
|
28600
|
+
cliPath,
|
|
28601
|
+
loggedIn,
|
|
28602
|
+
visionModels: vision,
|
|
28603
|
+
source: "metadata"
|
|
28455
28604
|
};
|
|
28456
28605
|
} catch (error) {
|
|
28457
|
-
|
|
28458
|
-
|
|
28459
|
-
|
|
28460
|
-
|
|
28461
|
-
|
|
28462
|
-
|
|
28463
|
-
error: message
|
|
28464
|
-
}
|
|
28465
|
-
}
|
|
28466
|
-
}
|
|
28467
|
-
if (chain.length === 1) {
|
|
28468
|
-
throw lastError;
|
|
28469
|
-
}
|
|
28470
|
-
throw new Error(
|
|
28471
|
-
`Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}`
|
|
28472
|
-
);
|
|
28473
|
-
}
|
|
28474
|
-
async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
|
|
28475
|
-
const configured = resolveProviderSettings(provider.name, config2);
|
|
28476
|
-
const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
|
|
28477
|
-
if (settings.extraBody && !provider.execute) {
|
|
28478
|
-
warnings.push(
|
|
28479
|
-
`${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
|
|
28480
|
-
);
|
|
28481
|
-
}
|
|
28482
|
-
const providerOptions = {
|
|
28483
|
-
imageSource: resolvedInput.source,
|
|
28484
|
-
imageKind: resolvedInput.kind,
|
|
28485
|
-
model,
|
|
28486
|
-
extraPrompt: options.prompt,
|
|
28487
|
-
providerBin: options.providerBin,
|
|
28488
|
-
workdir: options.workdir,
|
|
28489
|
-
timeoutMs,
|
|
28490
|
-
settings
|
|
28491
|
-
};
|
|
28492
|
-
let parsed;
|
|
28493
|
-
if (provider.execute) {
|
|
28494
|
-
parsed = await provider.execute(providerOptions);
|
|
28495
|
-
} else if (provider.buildInvocation && provider.parseOutput) {
|
|
28496
|
-
const buildInvocation = provider.buildInvocation;
|
|
28497
|
-
const parseOutput = provider.parseOutput;
|
|
28498
|
-
const isolation = !options.workdir && provider.isolateWorkdir ? resolvedInput.kind === "local" ? isolateImage(resolvedInput.source) : emptyWorkdir() : null;
|
|
28499
|
-
try {
|
|
28500
|
-
const invocation = buildInvocation({
|
|
28501
|
-
...providerOptions,
|
|
28502
|
-
imageSource: isolation?.imageSource ?? providerOptions.imageSource,
|
|
28503
|
-
workdir: isolation?.workdir ?? providerOptions.workdir
|
|
28504
|
-
});
|
|
28505
|
-
const backstop = provider.hasInternalTimeout ? timeoutMs + KILL_GRACE_MS : timeoutMs;
|
|
28506
|
-
const commandResult = await runCommand(
|
|
28507
|
-
provider.name,
|
|
28508
|
-
invocation,
|
|
28509
|
-
backstop,
|
|
28510
|
-
provider.describeFailure
|
|
28511
|
-
);
|
|
28512
|
-
parsed = parseOutput(commandResult.stdout);
|
|
28513
|
-
} finally {
|
|
28514
|
-
isolation?.cleanup();
|
|
28515
|
-
}
|
|
28516
|
-
} else {
|
|
28517
|
-
throw new Error(
|
|
28518
|
-
`Provider ${provider.name} implements neither execute nor buildInvocation.`
|
|
28519
|
-
);
|
|
28520
|
-
}
|
|
28521
|
-
const missing = missingSchemaFields(parsed.result);
|
|
28522
|
-
if (missing.length > 0) {
|
|
28523
|
-
throw new Error(
|
|
28524
|
-
`${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
|
|
28525
|
-
);
|
|
28526
|
-
}
|
|
28527
|
-
return parsed;
|
|
28528
|
-
}
|
|
28529
|
-
function resolveInput(input) {
|
|
28530
|
-
const trimmed = input.trim();
|
|
28531
|
-
if (!trimmed) {
|
|
28532
|
-
throw new Error("Input path is required.");
|
|
28533
|
-
}
|
|
28534
|
-
if (isRemoteSource(trimmed)) {
|
|
28535
|
-
return { source: trimmed, kind: "remote" };
|
|
28536
|
-
}
|
|
28537
|
-
if (/^file:\/\//i.test(trimmed)) {
|
|
28538
|
-
return { source: path.resolve(fileURLToPath(trimmed)), kind: "local" };
|
|
28539
|
-
}
|
|
28540
|
-
return { source: path.resolve(trimmed), kind: "local" };
|
|
28541
|
-
}
|
|
28542
|
-
function isRemoteSource(value) {
|
|
28543
|
-
return /^https?:\/\//i.test(value.trim());
|
|
28544
|
-
}
|
|
28545
|
-
function validateInputFile(filePath) {
|
|
28546
|
-
if (!fs.existsSync(filePath)) {
|
|
28547
|
-
throw new Error(`Input image not found: ${filePath}`);
|
|
28548
|
-
}
|
|
28549
|
-
const stat = fs.statSync(filePath);
|
|
28550
|
-
if (!stat.isFile()) {
|
|
28551
|
-
throw new Error(`Input is not a file: ${filePath}`);
|
|
28552
|
-
}
|
|
28553
|
-
}
|
|
28554
|
-
function isolateImage(source) {
|
|
28555
|
-
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
28556
|
-
const imageSource = path.join(workdir, path.basename(source));
|
|
28557
|
-
fs.copyFileSync(source, imageSource);
|
|
28558
|
-
fs.chmodSync(imageSource, 384);
|
|
28559
|
-
return {
|
|
28560
|
-
imageSource,
|
|
28561
|
-
workdir,
|
|
28562
|
-
cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
|
|
28563
|
-
};
|
|
28564
|
-
}
|
|
28565
|
-
function emptyWorkdir() {
|
|
28566
|
-
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
28567
|
-
return {
|
|
28568
|
-
workdir,
|
|
28569
|
-
cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
|
|
28570
|
-
};
|
|
28571
|
-
}
|
|
28572
|
-
function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
28573
|
-
const runStartedAt = Date.now();
|
|
28574
|
-
return new Promise((resolve, reject) => {
|
|
28575
|
-
const child = spawn(invocation.command, invocation.args, {
|
|
28576
|
-
cwd: invocation.cwd,
|
|
28577
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
28578
|
-
});
|
|
28579
|
-
const outDecoder = new TextDecoder("utf-8");
|
|
28580
|
-
const errDecoder = new TextDecoder("utf-8");
|
|
28581
|
-
let stdout = "";
|
|
28582
|
-
let stderr = "";
|
|
28583
|
-
let timedOut = false;
|
|
28584
|
-
let settled = false;
|
|
28585
|
-
let drainTimer;
|
|
28586
|
-
const timer = setTimeout(() => {
|
|
28587
|
-
timedOut = true;
|
|
28588
|
-
child.kill("SIGTERM");
|
|
28589
|
-
settle(null);
|
|
28590
|
-
setTimeout(() => {
|
|
28591
|
-
if (!exited) {
|
|
28592
|
-
child.kill("SIGKILL");
|
|
28593
|
-
}
|
|
28594
|
-
}, SIGKILL_GRACE_MS).unref();
|
|
28595
|
-
}, timeoutMs);
|
|
28596
|
-
const settle = (code) => {
|
|
28597
|
-
if (settled) {
|
|
28598
|
-
return;
|
|
28599
|
-
}
|
|
28600
|
-
settled = true;
|
|
28601
|
-
clearTimeout(timer);
|
|
28602
|
-
clearTimeout(drainTimer);
|
|
28603
|
-
stdout += outDecoder.decode();
|
|
28604
|
-
stderr += errDecoder.decode();
|
|
28605
|
-
child.stdout?.destroy();
|
|
28606
|
-
child.stderr?.destroy();
|
|
28607
|
-
child.unref();
|
|
28608
|
-
if (timedOut) {
|
|
28609
|
-
reject(new Error(`${providerName} provider timed out after ${timeoutMs} ms.`));
|
|
28610
|
-
return;
|
|
28611
|
-
}
|
|
28612
|
-
if (code !== 0) {
|
|
28613
|
-
const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
|
|
28614
|
-
reject(
|
|
28615
|
-
new Error(
|
|
28616
|
-
explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
|
|
28617
|
-
)
|
|
28618
|
-
);
|
|
28619
|
-
return;
|
|
28620
|
-
}
|
|
28621
|
-
resolve({ stdout, stderr });
|
|
28622
|
-
};
|
|
28623
|
-
let exitCode = null;
|
|
28624
|
-
let exited = false;
|
|
28625
|
-
const restartDrain = () => {
|
|
28626
|
-
if (!exited || settled) {
|
|
28627
|
-
return;
|
|
28628
|
-
}
|
|
28629
|
-
clearTimeout(drainTimer);
|
|
28630
|
-
drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
|
|
28631
|
-
};
|
|
28632
|
-
child.stdout.on("data", (chunk) => {
|
|
28633
|
-
stdout += outDecoder.decode(chunk, { stream: true });
|
|
28634
|
-
restartDrain();
|
|
28635
|
-
});
|
|
28636
|
-
child.stderr.on("data", (chunk) => {
|
|
28637
|
-
stderr += errDecoder.decode(chunk, { stream: true });
|
|
28638
|
-
restartDrain();
|
|
28639
|
-
});
|
|
28640
|
-
child.on("error", (error) => {
|
|
28641
|
-
if (settled) {
|
|
28642
|
-
return;
|
|
28643
|
-
}
|
|
28644
|
-
settled = true;
|
|
28645
|
-
clearTimeout(timer);
|
|
28646
|
-
clearTimeout(drainTimer);
|
|
28647
|
-
if (error.code === "ENOENT") {
|
|
28648
|
-
const missingCwd = !fs.existsSync(invocation.cwd);
|
|
28649
|
-
reject(
|
|
28650
|
-
new Error(
|
|
28651
|
-
missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command}. Install it and sign in first.`
|
|
28652
|
-
)
|
|
28653
|
-
);
|
|
28654
|
-
return;
|
|
28655
|
-
}
|
|
28656
|
-
reject(error);
|
|
28657
|
-
});
|
|
28658
|
-
child.on("exit", (code) => {
|
|
28659
|
-
exitCode = code;
|
|
28660
|
-
exited = true;
|
|
28661
|
-
restartDrain();
|
|
28662
|
-
});
|
|
28663
|
-
child.on("close", (code) => settle(code));
|
|
28664
|
-
});
|
|
28665
|
-
}
|
|
28666
|
-
function denyPatterns(guards) {
|
|
28667
|
-
return stringPatterns(guards?.denyModels);
|
|
28668
|
-
}
|
|
28669
|
-
function allowPatterns(guards) {
|
|
28670
|
-
return stringPatterns(guards?.allowModels);
|
|
28671
|
-
}
|
|
28672
|
-
function stringPatterns(raw) {
|
|
28673
|
-
if (!Array.isArray(raw)) {
|
|
28674
|
-
return [];
|
|
28675
|
-
}
|
|
28676
|
-
return raw.filter((pattern) => typeof pattern === "string");
|
|
28677
|
-
}
|
|
28678
|
-
function globMatch(pattern, value) {
|
|
28679
|
-
const regex = pattern.split(/([*?])/).map((part) => {
|
|
28680
|
-
if (part === "*") {
|
|
28681
|
-
return ".*";
|
|
28682
|
-
}
|
|
28683
|
-
if (part === "?") {
|
|
28684
|
-
return ".";
|
|
28685
|
-
}
|
|
28686
|
-
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28687
|
-
}).join("");
|
|
28688
|
-
return new RegExp(`^${regex}$`, "i").test(value);
|
|
28689
|
-
}
|
|
28690
|
-
function evaluateGuard(guards, detection) {
|
|
28691
|
-
const deny = denyPatterns(guards);
|
|
28692
|
-
const allow = allowPatterns(guards);
|
|
28693
|
-
if (!detection.model) {
|
|
28694
|
-
if (guards?.denyWhenUnknown === true) {
|
|
28695
|
-
return {
|
|
28696
|
-
...detection,
|
|
28697
|
-
guard: "deny",
|
|
28698
|
-
reason: "model unknown and denyWhenUnknown is set"
|
|
28699
|
-
};
|
|
28700
|
-
}
|
|
28701
|
-
return {
|
|
28702
|
-
...detection,
|
|
28703
|
-
guard: "allow",
|
|
28704
|
-
reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
|
|
28705
|
-
};
|
|
28706
|
-
}
|
|
28707
|
-
if (deny.length === 0 && allow.length === 0) {
|
|
28708
|
-
return { ...detection, guard: "allow", reason: "no deny rules configured" };
|
|
28709
|
-
}
|
|
28710
|
-
const candidates = [detection.model];
|
|
28711
|
-
if (detection.provider) {
|
|
28712
|
-
candidates.push(`${detection.provider}/${detection.model}`);
|
|
28713
|
-
}
|
|
28714
|
-
const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
|
|
28715
|
-
const denied = firstMatch(deny);
|
|
28716
|
-
if (denied) {
|
|
28717
|
-
return {
|
|
28718
|
-
...detection,
|
|
28719
|
-
guard: "deny",
|
|
28720
|
-
matched: denied,
|
|
28721
|
-
reason: "model has native vision per guards.denyModels"
|
|
28722
|
-
};
|
|
28723
|
-
}
|
|
28724
|
-
if (allow.length > 0) {
|
|
28725
|
-
const allowed = firstMatch(allow);
|
|
28726
|
-
if (allowed) {
|
|
28727
|
-
return {
|
|
28728
|
-
...detection,
|
|
28729
|
-
guard: "allow",
|
|
28730
|
-
matched: allowed,
|
|
28731
|
-
reason: "model is on guards.allowModels"
|
|
28732
|
-
};
|
|
28733
|
-
}
|
|
28734
|
-
return {
|
|
28735
|
-
...detection,
|
|
28736
|
-
guard: "deny",
|
|
28737
|
-
reason: "not on guards.allowModels: only listed models run the engine"
|
|
28738
|
-
};
|
|
28739
|
-
}
|
|
28740
|
-
return { ...detection, guard: "allow", reason: "not on the deny list" };
|
|
28741
|
-
}
|
|
28742
|
-
const VISION_MODEL_PATTERNS = [
|
|
28743
|
-
"claude-*",
|
|
28744
|
-
"gpt-4o*",
|
|
28745
|
-
"gpt-4.1*",
|
|
28746
|
-
"gpt-5*",
|
|
28747
|
-
"o3*",
|
|
28748
|
-
"o4*",
|
|
28749
|
-
"gemini-*",
|
|
28750
|
-
"glm-*v*",
|
|
28751
|
-
"qwen*-vl*",
|
|
28752
|
-
"qwen3.5-plus*",
|
|
28753
|
-
"qwen3.6-plus*",
|
|
28754
|
-
"kimi-k2.5*",
|
|
28755
|
-
"kimi-k2.6*",
|
|
28756
|
-
"kimi-k2.7*",
|
|
28757
|
-
"kimi-k3*",
|
|
28758
|
-
"moonshot-v1-*vision*",
|
|
28759
|
-
"minimax-vl*",
|
|
28760
|
-
"minimax-m3*",
|
|
28761
|
-
"deepseek-vl*",
|
|
28762
|
-
"deepseek-ocr*",
|
|
28763
|
-
"janus*",
|
|
28764
|
-
"pixtral*",
|
|
28765
|
-
"llama-4*",
|
|
28766
|
-
"llama-3.2-*vision*",
|
|
28767
|
-
"grok-4*",
|
|
28768
|
-
"grok-2-vision*",
|
|
28769
|
-
"internvl*"
|
|
28770
|
-
];
|
|
28771
|
-
function isVisionModel(modelId) {
|
|
28772
|
-
const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId;
|
|
28773
|
-
return VISION_MODEL_PATTERNS.some((pattern) => globMatch(pattern, bare));
|
|
28774
|
-
}
|
|
28775
|
-
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
28776
|
-
const CLI_TIMEOUT_MS = 1e4;
|
|
28777
|
-
function defaultRunCli(bin, args, timeoutMs) {
|
|
28778
|
-
return execFileSync(bin, args, { encoding: "utf-8", timeout: timeoutMs, stdio: "pipe" });
|
|
28779
|
-
}
|
|
28780
|
-
function timed(run) {
|
|
28781
|
-
const start = Date.now();
|
|
28782
|
-
const probe = run();
|
|
28783
|
-
return { ...probe, elapsedMs: Date.now() - start };
|
|
28784
|
-
}
|
|
28785
|
-
function readJson(filePath) {
|
|
28786
|
-
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
28787
|
-
}
|
|
28788
|
-
function probeClaude(env) {
|
|
28789
|
-
return timed(() => {
|
|
28790
|
-
const cliPath = findOnPath("claude", env);
|
|
28791
|
-
if (!cliPath) {
|
|
28792
|
-
return {
|
|
28793
|
-
harness: "claude-code",
|
|
28794
|
-
cliFound: false,
|
|
28795
|
-
visionModels: [],
|
|
28796
|
-
source: "none"
|
|
28797
|
-
};
|
|
28798
|
-
}
|
|
28799
|
-
return {
|
|
28800
|
-
harness: "claude-code",
|
|
28801
|
-
cliFound: true,
|
|
28802
|
-
cliPath,
|
|
28803
|
-
visionModels: ["anthropic/* (all current models)"],
|
|
28804
|
-
source: "builtin-table"
|
|
28805
|
-
};
|
|
28806
|
-
});
|
|
28807
|
-
}
|
|
28808
|
-
function probeCodex(env, home) {
|
|
28809
|
-
return timed(() => {
|
|
28810
|
-
const cliPath = findOnPath("codex", env);
|
|
28811
|
-
const base = { harness: "codex", cliFound: cliPath !== null };
|
|
28812
|
-
if (!cliPath) {
|
|
28813
|
-
return { ...base, visionModels: [], source: "none" };
|
|
28814
|
-
}
|
|
28815
|
-
const codexHome = path.join(home, ".codex");
|
|
28816
|
-
const loggedIn = fs.existsSync(path.join(codexHome, "auth.json"));
|
|
28817
|
-
try {
|
|
28818
|
-
const toml = fs.readFileSync(path.join(codexHome, "config.toml"), "utf-8");
|
|
28819
|
-
const catalogPath = toml.match(/^model_catalog_json\s*=\s*"([^"]+)"/m)?.[1];
|
|
28820
|
-
if (!catalogPath) {
|
|
28821
|
-
return { ...base, cliPath, loggedIn, visionModels: [], source: "none" };
|
|
28822
|
-
}
|
|
28823
|
-
const catalog = readJson(catalogPath);
|
|
28824
|
-
const vision = (catalog.models ?? []).filter((m) => m.slug && (m.input_modalities ?? []).includes("image")).map((m) => m.slug);
|
|
28825
|
-
return {
|
|
28826
|
-
...base,
|
|
28827
|
-
cliPath,
|
|
28828
|
-
loggedIn,
|
|
28829
|
-
visionModels: vision,
|
|
28830
|
-
source: "metadata"
|
|
28831
|
-
};
|
|
28832
|
-
} catch (error) {
|
|
28833
|
-
return {
|
|
28834
|
-
...base,
|
|
28835
|
-
cliPath,
|
|
28836
|
-
loggedIn,
|
|
28837
|
-
visionModels: [],
|
|
28838
|
-
source: "none",
|
|
28839
|
-
error: error instanceof Error ? error.message : String(error)
|
|
28840
|
-
};
|
|
28606
|
+
return {
|
|
28607
|
+
...base,
|
|
28608
|
+
cliPath,
|
|
28609
|
+
loggedIn,
|
|
28610
|
+
visionModels: [],
|
|
28611
|
+
source: "none",
|
|
28612
|
+
error: error instanceof Error ? error.message : String(error)
|
|
28613
|
+
};
|
|
28841
28614
|
}
|
|
28842
28615
|
});
|
|
28843
28616
|
}
|
|
@@ -28887,61 +28660,737 @@ function probeOpencode(env, runCli) {
|
|
|
28887
28660
|
return { ...base, visionModels: [], source: "none" };
|
|
28888
28661
|
}
|
|
28889
28662
|
try {
|
|
28890
|
-
const listing = runCli(cliPath, ["models"], CLI_TIMEOUT_MS);
|
|
28891
|
-
const vision = listing.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && isVisionModel(line));
|
|
28892
|
-
return { ...base, cliPath, visionModels: vision, source: "builtin-table" };
|
|
28893
|
-
} catch (error) {
|
|
28894
|
-
return {
|
|
28895
|
-
...base,
|
|
28896
|
-
cliPath,
|
|
28897
|
-
visionModels: [],
|
|
28898
|
-
source: "none",
|
|
28899
|
-
error: error instanceof Error ? error.message : String(error)
|
|
28900
|
-
};
|
|
28663
|
+
const listing = runCli(cliPath, ["models"], CLI_TIMEOUT_MS);
|
|
28664
|
+
const vision = listing.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && isVisionModel(line));
|
|
28665
|
+
return { ...base, cliPath, visionModels: vision, source: "builtin-table" };
|
|
28666
|
+
} catch (error) {
|
|
28667
|
+
return {
|
|
28668
|
+
...base,
|
|
28669
|
+
cliPath,
|
|
28670
|
+
visionModels: [],
|
|
28671
|
+
source: "none",
|
|
28672
|
+
error: error instanceof Error ? error.message : String(error)
|
|
28673
|
+
};
|
|
28674
|
+
}
|
|
28675
|
+
});
|
|
28676
|
+
}
|
|
28677
|
+
function readCache(cachePath, ttlMs) {
|
|
28678
|
+
try {
|
|
28679
|
+
const cached = readJson(cachePath);
|
|
28680
|
+
if (!cached.cachedAt || !Array.isArray(cached.probes)) {
|
|
28681
|
+
return null;
|
|
28682
|
+
}
|
|
28683
|
+
const cachedAtMs = Date.parse(cached.cachedAt);
|
|
28684
|
+
if (!Number.isFinite(cachedAtMs) || Date.now() - cachedAtMs > ttlMs) {
|
|
28685
|
+
return null;
|
|
28686
|
+
}
|
|
28687
|
+
return cached;
|
|
28688
|
+
} catch {
|
|
28689
|
+
return null;
|
|
28690
|
+
}
|
|
28691
|
+
}
|
|
28692
|
+
function discoverAuto(options = {}) {
|
|
28693
|
+
const env = options.env ?? process.env;
|
|
28694
|
+
const home = options.home ?? os.homedir();
|
|
28695
|
+
const cachePath = options.cachePath ?? path.join(home, ".modlens", "auto-cache.json");
|
|
28696
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
28697
|
+
if (!options.fresh) {
|
|
28698
|
+
const cached = readCache(cachePath, ttlMs);
|
|
28699
|
+
if (cached) {
|
|
28700
|
+
return { probes: cached.probes, cachedAt: cached.cachedAt, fromCache: true };
|
|
28701
|
+
}
|
|
28702
|
+
}
|
|
28703
|
+
const runCli = options.runCli ?? defaultRunCli;
|
|
28704
|
+
const probes = [
|
|
28705
|
+
probeClaude(env),
|
|
28706
|
+
probeCodex(env, home),
|
|
28707
|
+
probeOpencode(env, runCli),
|
|
28708
|
+
probePi(env, home)
|
|
28709
|
+
];
|
|
28710
|
+
const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
28711
|
+
try {
|
|
28712
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
28713
|
+
fs.writeFileSync(cachePath, JSON.stringify({ cachedAt, probes }, null, 2), {
|
|
28714
|
+
mode: 384
|
|
28715
|
+
});
|
|
28716
|
+
} catch {
|
|
28717
|
+
}
|
|
28718
|
+
return { probes, cachedAt, fromCache: false };
|
|
28719
|
+
}
|
|
28720
|
+
const KEY_FETCH_TIMEOUT_MS = 1e4;
|
|
28721
|
+
function codexCliRoute(visionModel) {
|
|
28722
|
+
return {
|
|
28723
|
+
name: "codex-cli",
|
|
28724
|
+
defaultModel: visionModel,
|
|
28725
|
+
isolateWorkdir: true,
|
|
28726
|
+
reuseNote: "this read reused the local Codex CLI login and spent that account's quota.",
|
|
28727
|
+
buildInvocation: (options) => {
|
|
28728
|
+
if (options.imageKind === "remote") {
|
|
28729
|
+
throw new Error(
|
|
28730
|
+
"codex-cli route reads local files only. Remote URLs stay on the inline providers."
|
|
28731
|
+
);
|
|
28732
|
+
}
|
|
28733
|
+
const prompt = `${buildVisionPrompt({
|
|
28734
|
+
imageSource: options.imageSource,
|
|
28735
|
+
imageKind: "inline",
|
|
28736
|
+
extraPrompt: options.extraPrompt
|
|
28737
|
+
})}
|
|
28738
|
+
|
|
28739
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28740
|
+
const model = options.model || visionModel;
|
|
28741
|
+
const args = [
|
|
28742
|
+
"exec",
|
|
28743
|
+
"--skip-git-repo-check",
|
|
28744
|
+
"--ephemeral",
|
|
28745
|
+
"-s",
|
|
28746
|
+
"read-only",
|
|
28747
|
+
"--json",
|
|
28748
|
+
"-i",
|
|
28749
|
+
options.imageSource
|
|
28750
|
+
];
|
|
28751
|
+
if (model && model !== "default") {
|
|
28752
|
+
args.push("-m", model);
|
|
28753
|
+
}
|
|
28754
|
+
args.push("--", prompt);
|
|
28755
|
+
return {
|
|
28756
|
+
command: options.providerBin || "codex",
|
|
28757
|
+
args,
|
|
28758
|
+
cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
|
|
28759
|
+
};
|
|
28760
|
+
},
|
|
28761
|
+
parseOutput: (stdout) => {
|
|
28762
|
+
let threadId = null;
|
|
28763
|
+
let usage = null;
|
|
28764
|
+
let lastMessage = null;
|
|
28765
|
+
for (const line of stdout.split("\n")) {
|
|
28766
|
+
const event = tryParseJson(line.trim());
|
|
28767
|
+
if (!event) {
|
|
28768
|
+
continue;
|
|
28769
|
+
}
|
|
28770
|
+
if (event.type === "thread.started" && event.thread_id) {
|
|
28771
|
+
threadId = event.thread_id;
|
|
28772
|
+
}
|
|
28773
|
+
if (event.type === "turn.completed" && event.usage !== void 0) {
|
|
28774
|
+
usage = event.usage;
|
|
28775
|
+
}
|
|
28776
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") {
|
|
28777
|
+
lastMessage = event.item.text;
|
|
28778
|
+
}
|
|
28779
|
+
}
|
|
28780
|
+
if (!lastMessage) {
|
|
28781
|
+
throw new Error(
|
|
28782
|
+
"codex exec produced no agent message. Check the Codex login (run: codex)."
|
|
28783
|
+
);
|
|
28784
|
+
}
|
|
28785
|
+
const result = extractJson(lastMessage);
|
|
28786
|
+
if (result === null) {
|
|
28787
|
+
throw new Error(`codex returned a non-JSON answer: ${truncate(lastMessage)}`);
|
|
28788
|
+
}
|
|
28789
|
+
return {
|
|
28790
|
+
result,
|
|
28791
|
+
meta: { conversationId: threadId, durationSeconds: null, usage }
|
|
28792
|
+
};
|
|
28793
|
+
}
|
|
28794
|
+
};
|
|
28795
|
+
}
|
|
28796
|
+
function opencodeCliRoute(modelId) {
|
|
28797
|
+
return {
|
|
28798
|
+
name: "opencode-cli",
|
|
28799
|
+
defaultModel: modelId,
|
|
28800
|
+
isolateWorkdir: true,
|
|
28801
|
+
reuseNote: `this read reused OpenCode's ${modelId} and spent that account's quota.`,
|
|
28802
|
+
buildInvocation: (options) => {
|
|
28803
|
+
if (options.imageKind === "remote") {
|
|
28804
|
+
throw new Error(
|
|
28805
|
+
"opencode-cli route reads local files only. Remote URLs stay on the inline providers."
|
|
28806
|
+
);
|
|
28807
|
+
}
|
|
28808
|
+
const prompt = `${buildVisionPrompt({
|
|
28809
|
+
imageSource: options.imageSource,
|
|
28810
|
+
imageKind: "inline",
|
|
28811
|
+
extraPrompt: options.extraPrompt
|
|
28812
|
+
})}
|
|
28813
|
+
|
|
28814
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28815
|
+
return {
|
|
28816
|
+
command: options.providerBin || "opencode",
|
|
28817
|
+
args: [
|
|
28818
|
+
"run",
|
|
28819
|
+
prompt,
|
|
28820
|
+
"-m",
|
|
28821
|
+
options.model || modelId,
|
|
28822
|
+
"--format",
|
|
28823
|
+
"json",
|
|
28824
|
+
"-f",
|
|
28825
|
+
options.imageSource
|
|
28826
|
+
],
|
|
28827
|
+
cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
|
|
28828
|
+
};
|
|
28829
|
+
},
|
|
28830
|
+
parseOutput: (stdout) => {
|
|
28831
|
+
let sessionId = null;
|
|
28832
|
+
let usage = null;
|
|
28833
|
+
const texts = [];
|
|
28834
|
+
for (const line of stdout.split("\n")) {
|
|
28835
|
+
const event = tryParseJson(line.trim());
|
|
28836
|
+
if (!event) {
|
|
28837
|
+
continue;
|
|
28838
|
+
}
|
|
28839
|
+
sessionId ??= event.sessionID ?? null;
|
|
28840
|
+
if (event.type === "text" && typeof event.part?.text === "string") {
|
|
28841
|
+
texts.push(event.part.text);
|
|
28842
|
+
}
|
|
28843
|
+
if (event.type === "step_finish" && event.part?.tokens !== void 0) {
|
|
28844
|
+
usage = event.part.tokens;
|
|
28845
|
+
}
|
|
28846
|
+
}
|
|
28847
|
+
const answer = texts.join("").trim();
|
|
28848
|
+
if (!answer) {
|
|
28849
|
+
throw new Error("opencode run produced no text answer.");
|
|
28850
|
+
}
|
|
28851
|
+
const result = extractJson(answer);
|
|
28852
|
+
if (result === null) {
|
|
28853
|
+
throw new Error(`opencode returned a non-JSON answer: ${truncate(answer)}`);
|
|
28854
|
+
}
|
|
28855
|
+
return {
|
|
28856
|
+
result,
|
|
28857
|
+
meta: { conversationId: sessionId, durationSeconds: null, usage }
|
|
28858
|
+
};
|
|
28859
|
+
}
|
|
28860
|
+
};
|
|
28861
|
+
}
|
|
28862
|
+
const PI_API_TARGETS = {
|
|
28863
|
+
"openai-completions": "openai",
|
|
28864
|
+
"anthropic-messages": "anthropic"
|
|
28865
|
+
};
|
|
28866
|
+
const DEFAULT_TARGETS = {
|
|
28867
|
+
openai: openaiCompatProvider,
|
|
28868
|
+
anthropic: anthropicApiProvider,
|
|
28869
|
+
"gemini-api": geminiApiProvider
|
|
28870
|
+
};
|
|
28871
|
+
function piCliRoute(providerName, modelId) {
|
|
28872
|
+
return {
|
|
28873
|
+
name: "pi-cli",
|
|
28874
|
+
defaultModel: modelId,
|
|
28875
|
+
isolateWorkdir: true,
|
|
28876
|
+
reuseNote: `this read reused pi's ${providerName}/${modelId} and spent that account's quota.`,
|
|
28877
|
+
buildInvocation: (options) => {
|
|
28878
|
+
if (options.imageKind === "remote") {
|
|
28879
|
+
throw new Error(
|
|
28880
|
+
"pi-cli route reads local files only. Remote URLs stay on the inline providers."
|
|
28881
|
+
);
|
|
28882
|
+
}
|
|
28883
|
+
const prompt = `${buildVisionPrompt({
|
|
28884
|
+
imageSource: options.imageSource,
|
|
28885
|
+
imageKind: "inline",
|
|
28886
|
+
extraPrompt: options.extraPrompt
|
|
28887
|
+
})}
|
|
28888
|
+
|
|
28889
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28890
|
+
return {
|
|
28891
|
+
command: options.providerBin || "pi",
|
|
28892
|
+
args: [
|
|
28893
|
+
"-p",
|
|
28894
|
+
"--no-session",
|
|
28895
|
+
"--no-tools",
|
|
28896
|
+
"--mode",
|
|
28897
|
+
"json",
|
|
28898
|
+
"--provider",
|
|
28899
|
+
providerName,
|
|
28900
|
+
"--model",
|
|
28901
|
+
options.model || modelId,
|
|
28902
|
+
`@${options.imageSource}`,
|
|
28903
|
+
prompt
|
|
28904
|
+
],
|
|
28905
|
+
cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
|
|
28906
|
+
};
|
|
28907
|
+
},
|
|
28908
|
+
parseOutput: (stdout) => {
|
|
28909
|
+
let final = null;
|
|
28910
|
+
for (const line of stdout.split("\n")) {
|
|
28911
|
+
const event = tryParseJson(line.trim());
|
|
28912
|
+
if (event?.type === "message_end" && event.message) {
|
|
28913
|
+
final = event.message;
|
|
28914
|
+
}
|
|
28915
|
+
}
|
|
28916
|
+
const answer = (final?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("").trim();
|
|
28917
|
+
if (!answer) {
|
|
28918
|
+
throw new Error("pi produced no text answer. Check pi and its credentials.");
|
|
28919
|
+
}
|
|
28920
|
+
const result = extractJson(answer);
|
|
28921
|
+
if (result === null) {
|
|
28922
|
+
throw new Error(`pi returned a non-JSON answer: ${truncate(answer)}`);
|
|
28923
|
+
}
|
|
28924
|
+
return {
|
|
28925
|
+
result,
|
|
28926
|
+
meta: {
|
|
28927
|
+
conversationId: final?.responseId ?? null,
|
|
28928
|
+
durationSeconds: null,
|
|
28929
|
+
usage: final?.usage ?? null
|
|
28930
|
+
}
|
|
28931
|
+
};
|
|
28932
|
+
}
|
|
28933
|
+
};
|
|
28934
|
+
}
|
|
28935
|
+
function piRoutes(home, env, targets = DEFAULT_TARGETS) {
|
|
28936
|
+
const empty = { inline: [], agents: [] };
|
|
28937
|
+
const piPath = findOnPath("pi", env);
|
|
28938
|
+
if (!piPath) {
|
|
28939
|
+
return empty;
|
|
28940
|
+
}
|
|
28941
|
+
const agentDir = path.join(home, ".pi", "agent");
|
|
28942
|
+
let auth;
|
|
28943
|
+
let store;
|
|
28944
|
+
try {
|
|
28945
|
+
auth = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8"));
|
|
28946
|
+
store = JSON.parse(fs.readFileSync(path.join(agentDir, "models-store.json"), "utf-8"));
|
|
28947
|
+
} catch {
|
|
28948
|
+
return empty;
|
|
28949
|
+
}
|
|
28950
|
+
const routes = [];
|
|
28951
|
+
const agents = [];
|
|
28952
|
+
const usedTargets = /* @__PURE__ */ new Set();
|
|
28953
|
+
for (const entry of Object.values(store)) {
|
|
28954
|
+
for (const model of entry?.models ?? []) {
|
|
28955
|
+
if (!model.id || !model.provider || !model.baseUrl || !(model.input ?? []).includes("image") || !(model.provider in auth)) {
|
|
28956
|
+
continue;
|
|
28957
|
+
}
|
|
28958
|
+
const credential = auth[model.provider];
|
|
28959
|
+
const targetName = PI_API_TARGETS[model.api ?? ""];
|
|
28960
|
+
const target = targetName ? targets[targetName] : void 0;
|
|
28961
|
+
const targetExecute = target?.execute;
|
|
28962
|
+
if (!target || !targetExecute || credential?.type !== "api_key") {
|
|
28963
|
+
if (agents.length < 2) {
|
|
28964
|
+
agents.push(piCliRoute(model.provider, model.id));
|
|
28965
|
+
}
|
|
28966
|
+
continue;
|
|
28967
|
+
}
|
|
28968
|
+
if (usedTargets.has(target.name)) {
|
|
28969
|
+
continue;
|
|
28970
|
+
}
|
|
28971
|
+
usedTargets.add(target.name);
|
|
28972
|
+
const { id, provider, baseUrl } = model;
|
|
28973
|
+
routes.push({
|
|
28974
|
+
name: `pi:${target.name}`,
|
|
28975
|
+
defaultModel: id,
|
|
28976
|
+
reuseNote: `this read reused pi's ${provider} credentials for ${id} and spent that account's quota.`,
|
|
28977
|
+
execute: async (options) => {
|
|
28978
|
+
const apiKey = fetchPiKey(
|
|
28979
|
+
piPath,
|
|
28980
|
+
id,
|
|
28981
|
+
provider,
|
|
28982
|
+
Math.min(KEY_FETCH_TIMEOUT_MS, options.timeoutMs || KEY_FETCH_TIMEOUT_MS)
|
|
28983
|
+
);
|
|
28984
|
+
return targetExecute({
|
|
28985
|
+
...options,
|
|
28986
|
+
settings: {
|
|
28987
|
+
...options.settings ?? {},
|
|
28988
|
+
apiKey,
|
|
28989
|
+
baseUrl,
|
|
28990
|
+
model: options.model || id
|
|
28991
|
+
}
|
|
28992
|
+
});
|
|
28993
|
+
}
|
|
28994
|
+
});
|
|
28995
|
+
}
|
|
28996
|
+
}
|
|
28997
|
+
return { inline: routes.slice(0, 2), agents };
|
|
28998
|
+
}
|
|
28999
|
+
function fetchPiKey(piPath, modelId, provider, timeoutMs) {
|
|
29000
|
+
try {
|
|
29001
|
+
const key = execFileSync(
|
|
29002
|
+
piPath,
|
|
29003
|
+
["auth", "print-api-key", "--model", modelId, "--provider", provider],
|
|
29004
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs }
|
|
29005
|
+
).trim();
|
|
29006
|
+
if (!key) {
|
|
29007
|
+
throw new Error("empty");
|
|
29008
|
+
}
|
|
29009
|
+
return key;
|
|
29010
|
+
} catch {
|
|
29011
|
+
throw new Error(
|
|
29012
|
+
`pi could not print an API key for ${provider}/${modelId}. Run \`pi auth\` to check that credential.`
|
|
29013
|
+
);
|
|
29014
|
+
}
|
|
29015
|
+
}
|
|
29016
|
+
function reuseProviders(kind, config2, options = {}) {
|
|
29017
|
+
const env = options.env ?? process.env;
|
|
29018
|
+
const home = options.home ?? os.homedir();
|
|
29019
|
+
const grants = config2.reuse ?? {};
|
|
29020
|
+
const inline = [];
|
|
29021
|
+
const agents = [];
|
|
29022
|
+
let piAgents = [];
|
|
29023
|
+
if (grants.pi === true) {
|
|
29024
|
+
try {
|
|
29025
|
+
const pi = piRoutes(home, env, options.targets);
|
|
29026
|
+
inline.push(...pi.inline);
|
|
29027
|
+
piAgents = pi.agents;
|
|
29028
|
+
} catch {
|
|
29029
|
+
}
|
|
29030
|
+
}
|
|
29031
|
+
if (kind === "local" && (grants.codex === true || grants.opencode === true)) {
|
|
29032
|
+
try {
|
|
29033
|
+
const discovery = options.discovery ?? discoverAuto({ env, home });
|
|
29034
|
+
const codex = discovery.probes.find((probe) => probe.harness === "codex");
|
|
29035
|
+
if (grants.codex === true && codex?.cliFound && codex.loggedIn !== false && codex.visionModels[0]) {
|
|
29036
|
+
agents.push(codexCliRoute(codex.visionModels[0]));
|
|
29037
|
+
}
|
|
29038
|
+
const opencode = discovery.probes.find((probe) => probe.harness === "opencode");
|
|
29039
|
+
if (grants.opencode === true && opencode?.cliFound && opencode.visionModels[0]) {
|
|
29040
|
+
agents.push(opencodeCliRoute(opencode.visionModels[0]));
|
|
29041
|
+
}
|
|
29042
|
+
} catch {
|
|
29043
|
+
}
|
|
29044
|
+
}
|
|
29045
|
+
if (kind === "local") {
|
|
29046
|
+
agents.push(...piAgents);
|
|
29047
|
+
}
|
|
29048
|
+
return { inline, agents };
|
|
29049
|
+
}
|
|
29050
|
+
const DEFAULT_TIMEOUT_MS = 18e4;
|
|
29051
|
+
const KILL_GRACE_MS = 3e4;
|
|
29052
|
+
const DRAIN_GRACE_MS = 500;
|
|
29053
|
+
const SIGKILL_GRACE_MS = 2e3;
|
|
29054
|
+
async function analyzeImage(options) {
|
|
29055
|
+
const resolvedInput = resolveInput(options.input);
|
|
29056
|
+
if (resolvedInput.kind === "local") {
|
|
29057
|
+
validateInputFile(resolvedInput.source);
|
|
29058
|
+
}
|
|
29059
|
+
const config2 = options.config ?? loadConfigFile();
|
|
29060
|
+
const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions);
|
|
29061
|
+
if (chain.length === 0) {
|
|
29062
|
+
throw new Error(
|
|
29063
|
+
"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." + reuseHint(config2, options.autoOptions)
|
|
29064
|
+
);
|
|
29065
|
+
}
|
|
29066
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
29067
|
+
const attempts = [];
|
|
29068
|
+
const warnings = [];
|
|
29069
|
+
let lastError;
|
|
29070
|
+
for (const provider of chain) {
|
|
29071
|
+
const startedAt = Date.now();
|
|
29072
|
+
const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
|
|
29073
|
+
try {
|
|
29074
|
+
const parsed = await runProvider(
|
|
29075
|
+
provider,
|
|
29076
|
+
model,
|
|
29077
|
+
options,
|
|
29078
|
+
resolvedInput,
|
|
29079
|
+
timeoutMs,
|
|
29080
|
+
config2,
|
|
29081
|
+
warnings
|
|
29082
|
+
);
|
|
29083
|
+
attempts.push({
|
|
29084
|
+
provider: provider.name,
|
|
29085
|
+
ok: true,
|
|
29086
|
+
durationSeconds: (Date.now() - startedAt) / 1e3
|
|
29087
|
+
});
|
|
29088
|
+
if (provider.reuseNote) {
|
|
29089
|
+
warnings.push(provider.reuseNote);
|
|
29090
|
+
}
|
|
29091
|
+
if (attempts.length > 1) {
|
|
29092
|
+
const failed = attempts.slice(0, -1);
|
|
29093
|
+
warnings.push(
|
|
29094
|
+
`Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
|
|
29095
|
+
);
|
|
29096
|
+
if (options.model) {
|
|
29097
|
+
warnings.push(
|
|
29098
|
+
`The explicit model applied to ${failed[0].provider} only; ${provider.name} ran its own default.`
|
|
29099
|
+
);
|
|
29100
|
+
}
|
|
29101
|
+
}
|
|
29102
|
+
return {
|
|
29103
|
+
image: resolvedInput.source,
|
|
29104
|
+
provider: provider.name,
|
|
29105
|
+
result: parsed.result,
|
|
29106
|
+
meta: {
|
|
29107
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
29108
|
+
model,
|
|
29109
|
+
conversationId: parsed.meta.conversationId,
|
|
29110
|
+
durationSeconds: parsed.meta.durationSeconds,
|
|
29111
|
+
usage: parsed.meta.usage,
|
|
29112
|
+
attempts,
|
|
29113
|
+
warnings
|
|
29114
|
+
}
|
|
29115
|
+
};
|
|
29116
|
+
} catch (error) {
|
|
29117
|
+
lastError = error;
|
|
29118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29119
|
+
attempts.push({
|
|
29120
|
+
provider: provider.name,
|
|
29121
|
+
ok: false,
|
|
29122
|
+
durationSeconds: (Date.now() - startedAt) / 1e3,
|
|
29123
|
+
error: message.slice(0, 300)
|
|
29124
|
+
});
|
|
29125
|
+
}
|
|
29126
|
+
}
|
|
29127
|
+
if (chain.length === 1) {
|
|
29128
|
+
if (!options.provider && !options.providerBin && lastError instanceof Error) {
|
|
29129
|
+
const hint = reuseHint(config2, options.autoOptions);
|
|
29130
|
+
if (hint) {
|
|
29131
|
+
lastError.message += hint;
|
|
29132
|
+
}
|
|
29133
|
+
}
|
|
29134
|
+
throw lastError;
|
|
29135
|
+
}
|
|
29136
|
+
throw new Error(
|
|
29137
|
+
`Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}${reuseHint(config2, options.autoOptions)}`
|
|
29138
|
+
);
|
|
29139
|
+
}
|
|
29140
|
+
const INLINE_REGION = /* @__PURE__ */ new Set(["gemini-api", "openai", "anthropic"]);
|
|
29141
|
+
function composeChain(kind, config2, autoOptions) {
|
|
29142
|
+
const chain = [...providerChain(kind, config2, autoOptions?.env ?? process.env)];
|
|
29143
|
+
const borrowed = reuseProviders(kind, config2, autoOptions);
|
|
29144
|
+
let preferredName = null;
|
|
29145
|
+
if (config2.provider?.trim()) {
|
|
29146
|
+
try {
|
|
29147
|
+
preferredName = resolveProvider(config2.provider.trim()).name;
|
|
29148
|
+
} catch {
|
|
29149
|
+
preferredName = null;
|
|
28901
29150
|
}
|
|
28902
|
-
}
|
|
29151
|
+
}
|
|
29152
|
+
if (borrowed.inline.length > 0) {
|
|
29153
|
+
const lastInline = chain.map((p) => INLINE_REGION.has(p.name)).lastIndexOf(true);
|
|
29154
|
+
const insertAt = lastInline >= 0 ? lastInline + 1 : kind === "local" && preferredName === chain[0]?.name ? 1 : 0;
|
|
29155
|
+
chain.splice(insertAt, 0, ...borrowed.inline);
|
|
29156
|
+
}
|
|
29157
|
+
if (borrowed.agents.length > 0) {
|
|
29158
|
+
const last = chain[chain.length - 1];
|
|
29159
|
+
const beforeClaude = last?.name === "claude-cli" && preferredName !== "claude-cli";
|
|
29160
|
+
chain.splice(beforeClaude ? chain.length - 1 : chain.length, 0, ...borrowed.agents);
|
|
29161
|
+
}
|
|
29162
|
+
return chain;
|
|
28903
29163
|
}
|
|
28904
|
-
|
|
29164
|
+
const REUSE_KEY_BY_HARNESS = {
|
|
29165
|
+
codex: "codex",
|
|
29166
|
+
opencode: "opencode",
|
|
29167
|
+
pi: "pi"
|
|
29168
|
+
};
|
|
29169
|
+
function reuseHint(config2, autoOptions) {
|
|
28905
29170
|
try {
|
|
28906
|
-
const
|
|
28907
|
-
|
|
28908
|
-
|
|
29171
|
+
const grants = config2.reuse ?? {};
|
|
29172
|
+
const discovery = autoOptions?.discovery ?? discoverAuto({ env: autoOptions?.env, home: autoOptions?.home });
|
|
29173
|
+
const unasked = [];
|
|
29174
|
+
const dead = [];
|
|
29175
|
+
for (const probe of discovery.probes) {
|
|
29176
|
+
const key = REUSE_KEY_BY_HARNESS[probe.harness];
|
|
29177
|
+
if (key === void 0) {
|
|
29178
|
+
continue;
|
|
29179
|
+
}
|
|
29180
|
+
const usable = probe.cliFound && probe.visionModels.length > 0 && probe.loggedIn !== false;
|
|
29181
|
+
if (grants[key] === void 0 && usable) {
|
|
29182
|
+
unasked.push(probe.harness);
|
|
29183
|
+
} else if (grants[key] === true && !usable) {
|
|
29184
|
+
dead.push(probe.harness);
|
|
29185
|
+
}
|
|
28909
29186
|
}
|
|
28910
|
-
|
|
28911
|
-
|
|
29187
|
+
const parts = [];
|
|
29188
|
+
if (unasked.length > 0) {
|
|
29189
|
+
parts.push(
|
|
29190
|
+
` Hint: this machine has vision reachable through ${unasked.join(", ")}, which modlens is not yet allowed to reuse. Ask the user, then: modlens config set reuse.<harness> true.`
|
|
29191
|
+
);
|
|
28912
29192
|
}
|
|
28913
|
-
|
|
29193
|
+
if (dead.length > 0) {
|
|
29194
|
+
parts.push(
|
|
29195
|
+
` Note: reuse is granted for ${dead.join(", ")} but it is currently unusable (signed out, uninstalled, or no vision model); check that CLI's login.`
|
|
29196
|
+
);
|
|
29197
|
+
}
|
|
29198
|
+
return parts.join("");
|
|
28914
29199
|
} catch {
|
|
28915
|
-
return
|
|
29200
|
+
return "";
|
|
28916
29201
|
}
|
|
28917
29202
|
}
|
|
28918
|
-
function
|
|
28919
|
-
const
|
|
28920
|
-
const
|
|
28921
|
-
|
|
28922
|
-
|
|
28923
|
-
|
|
28924
|
-
|
|
28925
|
-
|
|
28926
|
-
|
|
29203
|
+
async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
|
|
29204
|
+
const configured = resolveProviderSettings(provider.name, config2);
|
|
29205
|
+
const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
|
|
29206
|
+
if (settings.extraBody && !provider.execute) {
|
|
29207
|
+
warnings.push(
|
|
29208
|
+
`${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
|
|
29209
|
+
);
|
|
29210
|
+
}
|
|
29211
|
+
const providerOptions = {
|
|
29212
|
+
imageSource: resolvedInput.source,
|
|
29213
|
+
imageKind: resolvedInput.kind,
|
|
29214
|
+
model,
|
|
29215
|
+
extraPrompt: options.prompt,
|
|
29216
|
+
providerBin: options.providerBin,
|
|
29217
|
+
workdir: options.workdir,
|
|
29218
|
+
timeoutMs,
|
|
29219
|
+
settings
|
|
29220
|
+
};
|
|
29221
|
+
let parsed;
|
|
29222
|
+
if (provider.execute) {
|
|
29223
|
+
parsed = await provider.execute(providerOptions);
|
|
29224
|
+
} else if (provider.buildInvocation && provider.parseOutput) {
|
|
29225
|
+
const buildInvocation = provider.buildInvocation;
|
|
29226
|
+
const parseOutput = provider.parseOutput;
|
|
29227
|
+
const isolation = !options.workdir && provider.isolateWorkdir ? resolvedInput.kind === "local" ? isolateImage(resolvedInput.source) : emptyWorkdir() : null;
|
|
29228
|
+
try {
|
|
29229
|
+
const invocation = buildInvocation({
|
|
29230
|
+
...providerOptions,
|
|
29231
|
+
imageSource: isolation?.imageSource ?? providerOptions.imageSource,
|
|
29232
|
+
workdir: isolation?.workdir ?? providerOptions.workdir
|
|
29233
|
+
});
|
|
29234
|
+
const backstop = provider.hasInternalTimeout ? timeoutMs + KILL_GRACE_MS : timeoutMs;
|
|
29235
|
+
const commandResult = await runCommand(
|
|
29236
|
+
provider.name,
|
|
29237
|
+
invocation,
|
|
29238
|
+
backstop,
|
|
29239
|
+
provider.describeFailure
|
|
29240
|
+
);
|
|
29241
|
+
parsed = parseOutput(commandResult.stdout);
|
|
29242
|
+
} finally {
|
|
29243
|
+
isolation?.cleanup();
|
|
28927
29244
|
}
|
|
29245
|
+
} else {
|
|
29246
|
+
throw new Error(
|
|
29247
|
+
`Provider ${provider.name} implements neither execute nor buildInvocation.`
|
|
29248
|
+
);
|
|
28928
29249
|
}
|
|
28929
|
-
const
|
|
28930
|
-
|
|
28931
|
-
|
|
28932
|
-
|
|
28933
|
-
|
|
28934
|
-
probePi(env, home)
|
|
28935
|
-
];
|
|
28936
|
-
const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
28937
|
-
try {
|
|
28938
|
-
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
28939
|
-
fs.writeFileSync(cachePath, JSON.stringify({ cachedAt, probes }, null, 2), {
|
|
28940
|
-
mode: 384
|
|
28941
|
-
});
|
|
28942
|
-
} catch {
|
|
29250
|
+
const missing = missingSchemaFields(parsed.result);
|
|
29251
|
+
if (missing.length > 0) {
|
|
29252
|
+
throw new Error(
|
|
29253
|
+
`${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
|
|
29254
|
+
);
|
|
28943
29255
|
}
|
|
28944
|
-
return
|
|
29256
|
+
return parsed;
|
|
29257
|
+
}
|
|
29258
|
+
function resolveInput(input) {
|
|
29259
|
+
const trimmed = input.trim();
|
|
29260
|
+
if (!trimmed) {
|
|
29261
|
+
throw new Error("Input path is required.");
|
|
29262
|
+
}
|
|
29263
|
+
if (isRemoteSource(trimmed)) {
|
|
29264
|
+
return { source: trimmed, kind: "remote" };
|
|
29265
|
+
}
|
|
29266
|
+
if (/^file:\/\//i.test(trimmed)) {
|
|
29267
|
+
return { source: path.resolve(fileURLToPath(trimmed)), kind: "local" };
|
|
29268
|
+
}
|
|
29269
|
+
return { source: path.resolve(trimmed), kind: "local" };
|
|
29270
|
+
}
|
|
29271
|
+
function isRemoteSource(value) {
|
|
29272
|
+
return /^https?:\/\//i.test(value.trim());
|
|
29273
|
+
}
|
|
29274
|
+
function validateInputFile(filePath) {
|
|
29275
|
+
if (!fs.existsSync(filePath)) {
|
|
29276
|
+
throw new Error(`Input image not found: ${filePath}`);
|
|
29277
|
+
}
|
|
29278
|
+
const stat = fs.statSync(filePath);
|
|
29279
|
+
if (!stat.isFile()) {
|
|
29280
|
+
throw new Error(`Input is not a file: ${filePath}`);
|
|
29281
|
+
}
|
|
29282
|
+
}
|
|
29283
|
+
function isolateImage(source) {
|
|
29284
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
29285
|
+
const imageSource = path.join(workdir, path.basename(source));
|
|
29286
|
+
fs.copyFileSync(source, imageSource);
|
|
29287
|
+
fs.chmodSync(imageSource, 384);
|
|
29288
|
+
return {
|
|
29289
|
+
imageSource,
|
|
29290
|
+
workdir,
|
|
29291
|
+
cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
|
|
29292
|
+
};
|
|
29293
|
+
}
|
|
29294
|
+
function emptyWorkdir() {
|
|
29295
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
29296
|
+
return {
|
|
29297
|
+
workdir,
|
|
29298
|
+
cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
|
|
29299
|
+
};
|
|
29300
|
+
}
|
|
29301
|
+
function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
29302
|
+
const runStartedAt = Date.now();
|
|
29303
|
+
return new Promise((resolve, reject) => {
|
|
29304
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
29305
|
+
cwd: invocation.cwd,
|
|
29306
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
29307
|
+
});
|
|
29308
|
+
const outDecoder = new TextDecoder("utf-8");
|
|
29309
|
+
const errDecoder = new TextDecoder("utf-8");
|
|
29310
|
+
let stdout = "";
|
|
29311
|
+
let stderr = "";
|
|
29312
|
+
let timedOut = false;
|
|
29313
|
+
let settled = false;
|
|
29314
|
+
let drainTimer;
|
|
29315
|
+
const timer = setTimeout(() => {
|
|
29316
|
+
timedOut = true;
|
|
29317
|
+
child.kill("SIGTERM");
|
|
29318
|
+
settle(null);
|
|
29319
|
+
setTimeout(() => {
|
|
29320
|
+
if (!exited) {
|
|
29321
|
+
child.kill("SIGKILL");
|
|
29322
|
+
}
|
|
29323
|
+
}, SIGKILL_GRACE_MS).unref();
|
|
29324
|
+
}, timeoutMs);
|
|
29325
|
+
const settle = (code) => {
|
|
29326
|
+
if (settled) {
|
|
29327
|
+
return;
|
|
29328
|
+
}
|
|
29329
|
+
settled = true;
|
|
29330
|
+
clearTimeout(timer);
|
|
29331
|
+
clearTimeout(drainTimer);
|
|
29332
|
+
stdout += outDecoder.decode();
|
|
29333
|
+
stderr += errDecoder.decode();
|
|
29334
|
+
child.stdout?.destroy();
|
|
29335
|
+
child.stderr?.destroy();
|
|
29336
|
+
child.unref();
|
|
29337
|
+
if (timedOut) {
|
|
29338
|
+
reject(new Error(`${providerName} provider timed out after ${timeoutMs} ms.`));
|
|
29339
|
+
return;
|
|
29340
|
+
}
|
|
29341
|
+
if (code !== 0) {
|
|
29342
|
+
const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
|
|
29343
|
+
reject(
|
|
29344
|
+
new Error(
|
|
29345
|
+
explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
|
|
29346
|
+
)
|
|
29347
|
+
);
|
|
29348
|
+
return;
|
|
29349
|
+
}
|
|
29350
|
+
resolve({ stdout, stderr });
|
|
29351
|
+
};
|
|
29352
|
+
let exitCode = null;
|
|
29353
|
+
let exited = false;
|
|
29354
|
+
const restartDrain = () => {
|
|
29355
|
+
if (!exited || settled) {
|
|
29356
|
+
return;
|
|
29357
|
+
}
|
|
29358
|
+
clearTimeout(drainTimer);
|
|
29359
|
+
drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
|
|
29360
|
+
};
|
|
29361
|
+
child.stdout.on("data", (chunk) => {
|
|
29362
|
+
stdout += outDecoder.decode(chunk, { stream: true });
|
|
29363
|
+
restartDrain();
|
|
29364
|
+
});
|
|
29365
|
+
child.stderr.on("data", (chunk) => {
|
|
29366
|
+
stderr += errDecoder.decode(chunk, { stream: true });
|
|
29367
|
+
restartDrain();
|
|
29368
|
+
});
|
|
29369
|
+
child.on("error", (error) => {
|
|
29370
|
+
if (settled) {
|
|
29371
|
+
return;
|
|
29372
|
+
}
|
|
29373
|
+
settled = true;
|
|
29374
|
+
clearTimeout(timer);
|
|
29375
|
+
clearTimeout(drainTimer);
|
|
29376
|
+
if (error.code === "ENOENT") {
|
|
29377
|
+
const missingCwd = !fs.existsSync(invocation.cwd);
|
|
29378
|
+
reject(
|
|
29379
|
+
new Error(
|
|
29380
|
+
missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command}. Install it and sign in first.`
|
|
29381
|
+
)
|
|
29382
|
+
);
|
|
29383
|
+
return;
|
|
29384
|
+
}
|
|
29385
|
+
reject(error);
|
|
29386
|
+
});
|
|
29387
|
+
child.on("exit", (code) => {
|
|
29388
|
+
exitCode = code;
|
|
29389
|
+
exited = true;
|
|
29390
|
+
restartDrain();
|
|
29391
|
+
});
|
|
29392
|
+
child.on("close", (code) => settle(code));
|
|
29393
|
+
});
|
|
28945
29394
|
}
|
|
28946
29395
|
const HARNESS_BY_BASENAME = {
|
|
28947
29396
|
claude: "claude-code",
|
|
@@ -29587,6 +30036,9 @@ function versionParts(version) {
|
|
|
29587
30036
|
}
|
|
29588
30037
|
return [Number(match[1]), Number(match[2])];
|
|
29589
30038
|
}
|
|
30039
|
+
function chainEntryName(provider) {
|
|
30040
|
+
return provider.reuseNote ? `${provider.name} (reused)` : provider.name;
|
|
30041
|
+
}
|
|
29590
30042
|
function meetsMinimum(version, minimum) {
|
|
29591
30043
|
const [major, minor] = versionParts(version);
|
|
29592
30044
|
const [minMajor, minMinor] = versionParts(minimum);
|
|
@@ -29699,6 +30151,8 @@ function buildDoctorReport(input) {
|
|
|
29699
30151
|
harness: harnessDetection.harness
|
|
29700
30152
|
});
|
|
29701
30153
|
const guardVerdict = evaluateGuard(input.config.guards, guardDetection);
|
|
30154
|
+
const reuseDiscovery = discoverAuto({ env, fresh: true, ...input.auto });
|
|
30155
|
+
const reuseOptions = { env, ...input.auto, discovery: reuseDiscovery };
|
|
29702
30156
|
return {
|
|
29703
30157
|
node: {
|
|
29704
30158
|
version: process.version,
|
|
@@ -29708,9 +30162,12 @@ function buildDoctorReport(input) {
|
|
|
29708
30162
|
nodeSqlite: checkNodeSqlite(),
|
|
29709
30163
|
providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
|
|
29710
30164
|
selection: resolveSelection(input.config, input.providerFlag),
|
|
30165
|
+
// The chains a run would actually use, reused routes included and
|
|
30166
|
+
// labeled, so a machine living entirely on granted logins does not
|
|
30167
|
+
// read as "no engine" right next to a granted Reuse section.
|
|
29711
30168
|
chains: {
|
|
29712
|
-
local:
|
|
29713
|
-
remote:
|
|
30169
|
+
local: composeChain("local", input.config, reuseOptions).map(chainEntryName),
|
|
30170
|
+
remote: composeChain("remote", input.config, reuseOptions).map(chainEntryName)
|
|
29714
30171
|
},
|
|
29715
30172
|
harness: { detected: harnessDetection.harness, source: harnessDetection.source },
|
|
29716
30173
|
guard: {
|
|
@@ -29724,11 +30181,18 @@ function buildDoctorReport(input) {
|
|
|
29724
30181
|
reason: guardVerdict.reason
|
|
29725
30182
|
},
|
|
29726
30183
|
config: inspectConfigFile(configPath),
|
|
29727
|
-
|
|
29728
|
-
|
|
29729
|
-
|
|
29730
|
-
|
|
29731
|
-
|
|
30184
|
+
reuse: {
|
|
30185
|
+
decisions: Object.fromEntries(
|
|
30186
|
+
REUSE_HARNESSES.map((harness) => {
|
|
30187
|
+
const decision = input.config.reuse?.[harness];
|
|
30188
|
+
const fallback = harness === "claude" ? "granted" : "not asked";
|
|
30189
|
+
return [
|
|
30190
|
+
harness,
|
|
30191
|
+
decision === true ? "granted" : decision === false ? "refused" : fallback
|
|
30192
|
+
];
|
|
30193
|
+
})
|
|
30194
|
+
),
|
|
30195
|
+
probes: reuseDiscovery.probes
|
|
29732
30196
|
}
|
|
29733
30197
|
};
|
|
29734
30198
|
}
|
|
@@ -29778,11 +30242,11 @@ function renderDoctorReport(report) {
|
|
|
29778
30242
|
` verdict: ${report.guard.verdict}${report.guard.matched ? ` (matched "${report.guard.matched}")` : ""}, ${report.guard.reason}`
|
|
29779
30243
|
);
|
|
29780
30244
|
lines.push("");
|
|
29781
|
-
lines.push(
|
|
30245
|
+
lines.push('Reuse (may modlens reuse other local logins? config "reuse.<harness>")');
|
|
29782
30246
|
lines.push(
|
|
29783
|
-
`
|
|
30247
|
+
` decisions: ${Object.entries(report.reuse.decisions).map(([harness, decision]) => `${harness} ${decision}`).join(", ")}`
|
|
29784
30248
|
);
|
|
29785
|
-
for (const probe of report.
|
|
30249
|
+
for (const probe of report.reuse.probes) {
|
|
29786
30250
|
if (!probe.cliFound) {
|
|
29787
30251
|
lines.push(` ${probe.harness}: cli not found`);
|
|
29788
30252
|
continue;
|
|
@@ -29994,7 +30458,7 @@ function recoverPastedImages(options = {}) {
|
|
|
29994
30458
|
return result;
|
|
29995
30459
|
}
|
|
29996
30460
|
const program = new Command();
|
|
29997
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.
|
|
30461
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.8.0");
|
|
29998
30462
|
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(
|
|
29999
30463
|
"--extra-body <json>",
|
|
30000
30464
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|