@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/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 { spawn, execFileSync } from "child_process";
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
- 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):
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 === "auto") {
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 !== "true" && normalized !== "false") {
28155
- throw new Error("auto must be true or false.");
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", "auto", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
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
- const names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
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 DEFAULT_TIMEOUT_MS = 18e4;
28394
- const KILL_GRACE_MS = 3e4;
28395
- const DRAIN_GRACE_MS = 500;
28396
- const SIGKILL_GRACE_MS = 2e3;
28397
- async function analyzeImage(options) {
28398
- const resolvedInput = resolveInput(options.input);
28399
- if (resolvedInput.kind === "local") {
28400
- validateInputFile(resolvedInput.source);
28401
- }
28402
- const config2 = options.config ?? loadConfigFile();
28403
- const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : providerChain(resolvedInput.kind, config2);
28404
- if (chain.length === 0) {
28405
- throw new Error(
28406
- "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."
28407
- );
28408
- }
28409
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28410
- const attempts = [];
28411
- const warnings = [];
28412
- let lastError;
28413
- for (const provider of chain) {
28414
- const startedAt = Date.now();
28415
- const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
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 parsed = await runProvider(
28418
- provider,
28419
- model,
28420
- options,
28421
- resolvedInput,
28422
- timeoutMs,
28423
- config2,
28424
- warnings
28425
- );
28426
- attempts.push({
28427
- provider: provider.name,
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
- image: resolvedInput.source,
28444
- provider: provider.name,
28445
- result: parsed.result,
28446
- meta: {
28447
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
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
- lastError = error;
28458
- const message = error instanceof Error ? error.message : String(error);
28459
- attempts.push({
28460
- provider: provider.name,
28461
- ok: false,
28462
- durationSeconds: (Date.now() - startedAt) / 1e3,
28463
- error: message.slice(0, 300)
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
- function readCache(cachePath, ttlMs) {
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 cached = readJson(cachePath);
28907
- if (!cached.cachedAt || !Array.isArray(cached.probes)) {
28908
- return null;
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
- if (Date.now() - Date.parse(cached.cachedAt) > ttlMs) {
28911
- return null;
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
- return cached;
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 null;
29200
+ return "";
28916
29201
  }
28917
29202
  }
28918
- function discoverAuto(options = {}) {
28919
- const env = options.env ?? process.env;
28920
- const home = options.home ?? os.homedir();
28921
- const cachePath = options.cachePath ?? path.join(home, ".modlens", "auto-cache.json");
28922
- const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
28923
- if (!options.fresh) {
28924
- const cached = readCache(cachePath, ttlMs);
28925
- if (cached) {
28926
- return { probes: cached.probes, cachedAt: cached.cachedAt, fromCache: true };
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 runCli = options.runCli ?? defaultRunCli;
28930
- const probes = [
28931
- probeClaude(env),
28932
- probeCodex(env, home),
28933
- probeOpencode(env, runCli),
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 { probes, cachedAt, fromCache: false };
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: providerChain("local", input.config, env).map((p) => p.name),
29713
- remote: providerChain("remote", input.config, env).map((p) => p.name)
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
- // doctor is the "what would auto find" view, so it always probes fresh
29728
- // (and rewrites the cache); regular runs will read the cache instead.
29729
- auto: {
29730
- enabled: input.config.auto === true,
29731
- probes: discoverAuto({ env, fresh: true, ...input.auto }).probes
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("Auto (borrowable local harness vision; off by default)");
30245
+ lines.push('Reuse (may modlens reuse other local logins? config "reuse.<harness>")');
29782
30246
  lines.push(
29783
- ` enabled: ${report.auto.enabled}${report.auto.enabled ? "" : " (turn on: modlens config set auto true)"}`
30247
+ ` decisions: ${Object.entries(report.reuse.decisions).map(([harness, decision]) => `${harness} ${decision}`).join(", ")}`
29784
30248
  );
29785
- for (const probe of report.auto.probes) {
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.7.0");
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"}}'`