@liustack/modlens 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.12.0 - 2026-08-14
4
+
5
+ - **The API providers work behind a proxy ([#20](https://github.com/liustack/modlens/issues/20)).** Node's fetch ignores `HTTP_PROXY`/`HTTPS_PROXY` entirely, so machines that reach the internet through a proxy could not use `gemini-api` at all, and the failure surfaced as a bare `fetch failed`. The three inline API providers now honor the standard environment variables (`NO_PROXY` included, via undici's `EnvHttpProxyAgent`), with an explicit setting as the escape hatch: `modlens config set proxy <url>` for all API providers, `<provider>.proxy` to scope it to one. A connect-level failure now names the unreachable host and points at both knobs instead of saying `fetch failed`. Scope is deliberate and documented: the proxy applies to API requests only, while the remote-image download path keeps its direct, IP-pinned connection, because its SSRF guards validate the exact address being contacted and a proxy would blind them. Thanks to @soloyu for a report that arrived with the diagnosis, the fix direction, and the security boundary already thought through.
6
+
3
7
  ## 3.11.0 - 2026-08-14
4
8
 
5
9
  - **A full-project audit, all ten findings fixed, then re-reviewed until clean.** An independent deep review of the whole repository (P0: none) surfaced ten conditional-but-real defects. Every fix went back through further independent review rounds, which caught real bugs in the first fixes themselves (case and Unicode boundaries, a cancellation regression); the final round accepted with no blocking findings. Each item below carries a regression test — the suite grew by 32 cases.
package/dist/main.js CHANGED
@@ -27414,6 +27414,51 @@ async function readCapped(response2, url) {
27414
27414
  }
27415
27415
  return Buffer.concat(chunks);
27416
27416
  }
27417
+ function apiProxyDispatcher(explicitProxy, env) {
27418
+ const proxy = explicitProxy?.trim();
27419
+ if (proxy) {
27420
+ return new undiciExports.ProxyAgent(proxy);
27421
+ }
27422
+ if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
27423
+ return new undiciExports.EnvHttpProxyAgent();
27424
+ }
27425
+ return void 0;
27426
+ }
27427
+ const CONNECT_CODES = /* @__PURE__ */ new Set([
27428
+ "UND_ERR_CONNECT_TIMEOUT",
27429
+ "ECONNREFUSED",
27430
+ "ECONNRESET",
27431
+ "ENOTFOUND",
27432
+ "EHOSTUNREACH",
27433
+ "ENETUNREACH",
27434
+ "ETIMEDOUT"
27435
+ ]);
27436
+ function connectFailureHint(error, url) {
27437
+ const cause = error instanceof Error ? error.cause : void 0;
27438
+ if (!cause?.code || !CONNECT_CODES.has(cause.code)) {
27439
+ return null;
27440
+ }
27441
+ let host;
27442
+ try {
27443
+ host = new URL(url).host;
27444
+ } catch {
27445
+ return null;
27446
+ }
27447
+ return `Could not connect to ${host} (${cause.code}). The request never reached the network. If this machine reaches the internet through a proxy, set HTTPS_PROXY/HTTP_PROXY, or run: modlens config set proxy <url>`;
27448
+ }
27449
+ async function apiFetch(url, init, proxy, env = process.env) {
27450
+ const dispatcher2 = apiProxyDispatcher(proxy, env);
27451
+ try {
27452
+ return await fetch(
27453
+ url,
27454
+ // `dispatcher` is a Node/undici extension to fetch's options.
27455
+ dispatcher2 ? { ...init, dispatcher: dispatcher2 } : init
27456
+ );
27457
+ } catch (error) {
27458
+ const hint = connectFailureHint(error, url);
27459
+ throw hint ? new Error(hint, { cause: error }) : error;
27460
+ }
27461
+ }
27417
27462
  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):
27418
27463
  {"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"]}`;
27419
27464
  function buildVisionPrompt(options) {
@@ -27735,43 +27780,47 @@ async function executeAnthropicApi(options) {
27735
27780
 
27736
27781
  Report your findings by calling the ${TOOL_NAME} tool.`;
27737
27782
  const startedAt = Date.now();
27738
- const response2 = await fetch(`${baseUrl}/v1/messages`, {
27739
- method: "POST",
27740
- headers: {
27741
- "x-api-key": apiKey,
27742
- "anthropic-version": "2023-06-01",
27743
- "Content-Type": "application/json"
27783
+ const response2 = await apiFetch(
27784
+ `${baseUrl}/v1/messages`,
27785
+ {
27786
+ method: "POST",
27787
+ headers: {
27788
+ "x-api-key": apiKey,
27789
+ "anthropic-version": "2023-06-01",
27790
+ "Content-Type": "application/json"
27791
+ },
27792
+ body: JSON.stringify(
27793
+ mergeExtraBody(
27794
+ {
27795
+ model,
27796
+ max_tokens: 4096,
27797
+ tools: [
27798
+ {
27799
+ name: TOOL_NAME,
27800
+ description: "Report the structured visual evidence extracted from the image.",
27801
+ input_schema: VISION_RESULT_SCHEMA
27802
+ }
27803
+ ],
27804
+ tool_choice: { type: "tool", name: TOOL_NAME },
27805
+ messages: [
27806
+ {
27807
+ role: "user",
27808
+ content: [
27809
+ { type: "image", source: imageSource },
27810
+ { type: "text", text: prompt }
27811
+ ]
27812
+ }
27813
+ ]
27814
+ },
27815
+ options.settings?.extraBody,
27816
+ ["model", "messages", "tools", "tool_choice", "stream"],
27817
+ "anthropic"
27818
+ )
27819
+ ),
27820
+ signal: AbortSignal.timeout(options.timeoutMs)
27744
27821
  },
27745
- body: JSON.stringify(
27746
- mergeExtraBody(
27747
- {
27748
- model,
27749
- max_tokens: 4096,
27750
- tools: [
27751
- {
27752
- name: TOOL_NAME,
27753
- description: "Report the structured visual evidence extracted from the image.",
27754
- input_schema: VISION_RESULT_SCHEMA
27755
- }
27756
- ],
27757
- tool_choice: { type: "tool", name: TOOL_NAME },
27758
- messages: [
27759
- {
27760
- role: "user",
27761
- content: [
27762
- { type: "image", source: imageSource },
27763
- { type: "text", text: prompt }
27764
- ]
27765
- }
27766
- ]
27767
- },
27768
- options.settings?.extraBody,
27769
- ["model", "messages", "tools", "tool_choice", "stream"],
27770
- "anthropic"
27771
- )
27772
- ),
27773
- signal: AbortSignal.timeout(options.timeoutMs)
27774
- });
27822
+ options.settings?.proxy
27823
+ );
27775
27824
  if (!response2.ok) {
27776
27825
  const body2 = await response2.text();
27777
27826
  throw new Error(
@@ -28032,39 +28081,48 @@ async function executeGeminiApi(options) {
28032
28081
  extraPrompt: options.extraPrompt
28033
28082
  });
28034
28083
  const startedAt = Date.now();
28035
- const response2 = await fetch(`${baseUrl}/v1beta/models/${model}:generateContent`, {
28036
- method: "POST",
28037
- headers: {
28038
- "x-goog-api-key": apiKey,
28039
- "Content-Type": "application/json"
28040
- },
28041
- body: JSON.stringify(
28042
- mergeExtraBody(
28043
- {
28044
- contents: [
28045
- {
28046
- parts: [
28047
- { inline_data: { mime_type: image.mimeType, data: image.data } },
28048
- { text: prompt }
28049
- ]
28084
+ const response2 = await apiFetch(
28085
+ `${baseUrl}/v1beta/models/${model}:generateContent`,
28086
+ {
28087
+ method: "POST",
28088
+ headers: {
28089
+ "x-goog-api-key": apiKey,
28090
+ "Content-Type": "application/json"
28091
+ },
28092
+ body: JSON.stringify(
28093
+ mergeExtraBody(
28094
+ {
28095
+ contents: [
28096
+ {
28097
+ parts: [
28098
+ {
28099
+ inline_data: {
28100
+ mime_type: image.mimeType,
28101
+ data: image.data
28102
+ }
28103
+ },
28104
+ { text: prompt }
28105
+ ]
28106
+ }
28107
+ ],
28108
+ generationConfig: {
28109
+ responseMimeType: "application/json",
28110
+ responseJsonSchema: VISION_RESULT_SCHEMA
28050
28111
  }
28112
+ },
28113
+ options.settings?.extraBody,
28114
+ [
28115
+ "contents",
28116
+ "generationConfig.responseMimeType",
28117
+ "generationConfig.responseJsonSchema"
28051
28118
  ],
28052
- generationConfig: {
28053
- responseMimeType: "application/json",
28054
- responseJsonSchema: VISION_RESULT_SCHEMA
28055
- }
28056
- },
28057
- options.settings?.extraBody,
28058
- [
28059
- "contents",
28060
- "generationConfig.responseMimeType",
28061
- "generationConfig.responseJsonSchema"
28062
- ],
28063
- "gemini-api"
28064
- )
28065
- ),
28066
- signal: AbortSignal.timeout(options.timeoutMs)
28067
- });
28119
+ "gemini-api"
28120
+ )
28121
+ ),
28122
+ signal: AbortSignal.timeout(options.timeoutMs)
28123
+ },
28124
+ options.settings?.proxy
28125
+ );
28068
28126
  if (!response2.ok) {
28069
28127
  const body2 = await response2.text();
28070
28128
  throw new Error(
@@ -28114,33 +28172,37 @@ async function executeOpenaiCompat(options) {
28114
28172
 
28115
28173
  ${JSON_TEMPLATE_INSTRUCTION}`;
28116
28174
  const startedAt = Date.now();
28117
- const response2 = await fetch(`${baseUrl}/chat/completions`, {
28118
- method: "POST",
28119
- headers: {
28120
- Authorization: `Bearer ${apiKey}`,
28121
- "Content-Type": "application/json"
28175
+ const response2 = await apiFetch(
28176
+ `${baseUrl}/chat/completions`,
28177
+ {
28178
+ method: "POST",
28179
+ headers: {
28180
+ Authorization: `Bearer ${apiKey}`,
28181
+ "Content-Type": "application/json"
28182
+ },
28183
+ body: JSON.stringify(
28184
+ mergeExtraBody(
28185
+ {
28186
+ model,
28187
+ messages: [
28188
+ {
28189
+ role: "user",
28190
+ content: [
28191
+ { type: "image_url", image_url: { url: imageUrl } },
28192
+ { type: "text", text: prompt }
28193
+ ]
28194
+ }
28195
+ ]
28196
+ },
28197
+ options.settings?.extraBody,
28198
+ ["model", "messages", "stream"],
28199
+ "openai"
28200
+ )
28201
+ ),
28202
+ signal: AbortSignal.timeout(options.timeoutMs)
28122
28203
  },
28123
- body: JSON.stringify(
28124
- mergeExtraBody(
28125
- {
28126
- model,
28127
- messages: [
28128
- {
28129
- role: "user",
28130
- content: [
28131
- { type: "image_url", image_url: { url: imageUrl } },
28132
- { type: "text", text: prompt }
28133
- ]
28134
- }
28135
- ]
28136
- },
28137
- options.settings?.extraBody,
28138
- ["model", "messages", "stream"],
28139
- "openai"
28140
- )
28141
- ),
28142
- signal: AbortSignal.timeout(options.timeoutMs)
28143
- });
28204
+ options.settings?.proxy
28205
+ );
28144
28206
  if (!response2.ok) {
28145
28207
  const body2 = await response2.text();
28146
28208
  throw new Error(
@@ -28210,7 +28272,7 @@ function providerAliases() {
28210
28272
  function listProviders() {
28211
28273
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
28212
28274
  }
28213
- const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
28275
+ const STRING_FIELDS = ["apiKey", "baseUrl", "model", "proxy"];
28214
28276
  const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi", "grok"];
28215
28277
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
28216
28278
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
@@ -28251,6 +28313,9 @@ function resolveProviderSettings(providerName, config2, env = process.env) {
28251
28313
  };
28252
28314
  const bindings = ENV_BINDINGS[providerName] ?? {};
28253
28315
  const settings = { ...fromFile };
28316
+ if (!settings.proxy && config2.proxy?.trim()) {
28317
+ settings.proxy = config2.proxy.trim();
28318
+ }
28254
28319
  for (const [field, envName] of Object.entries(bindings)) {
28255
28320
  const value = env[envName]?.trim();
28256
28321
  if (value) {
@@ -28263,6 +28328,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28263
28328
  const config2 = loadConfigFile(configPath);
28264
28329
  if (dottedKey === "provider") {
28265
28330
  config2.provider = value;
28331
+ } else if (dottedKey === "proxy") {
28332
+ if (value.trim() === "") {
28333
+ delete config2.proxy;
28334
+ } else {
28335
+ config2.proxy = value.trim();
28336
+ }
28266
28337
  } else if (dottedKey.startsWith("reuse.")) {
28267
28338
  const harness = dottedKey.slice("reuse.".length);
28268
28339
  if (!REUSE_HARNESSES.includes(harness)) {
@@ -28307,7 +28378,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28307
28378
  }
28308
28379
  } else if (!STRING_FIELDS.includes(field)) {
28309
28380
  throw new Error(
28310
- `Unknown config field: ${field}. Use apiKey, baseUrl, model, or extraBody.`
28381
+ `Unknown config field: ${field}. Use apiKey, baseUrl, model, proxy, or extraBody.`
28311
28382
  );
28312
28383
  } else {
28313
28384
  config2.providers ??= {};
@@ -28409,6 +28480,11 @@ function renderEffectiveConfig(config2, env = process.env) {
28409
28480
  if (config2.provider?.trim()) {
28410
28481
  effective.provider = config2.provider.trim();
28411
28482
  }
28483
+ if (config2.proxy?.trim()) {
28484
+ effective.proxy = `${config2.proxy.trim()} (file)`;
28485
+ } else if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
28486
+ effective.proxy = `${env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy} (env)`;
28487
+ }
28412
28488
  if (config2.guards) {
28413
28489
  const guards = {};
28414
28490
  if (config2.guards.denyModels !== void 0) {
@@ -30626,7 +30702,7 @@ function parsePositiveInt(raw, flag) {
30626
30702
  }
30627
30703
  return Number.parseInt(raw, 10);
30628
30704
  }
30629
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.11.0");
30705
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.12.0");
30630
30706
  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(
30631
30707
  "--extra-body <json>",
30632
30708
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
package/docs/cli.md CHANGED
@@ -95,6 +95,6 @@ Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `
95
95
  Other subcommands:
96
96
 
97
97
  - `modlens guard [--model <id>]`: should the engine run for the active model at all? Exit 0 allow, 1 deny, verdict as JSON.
98
- - `modlens config <init|set|show>`: keys are `provider`, `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|extraBody>`.
98
+ - `modlens config <init|set|show>`: keys are `provider`, `proxy` (HTTP/HTTPS proxy for the API providers, `HTTPS_PROXY`/`HTTP_PROXY` also honored), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`.
99
99
  - `modlens doctor`: Node and node:sqlite, provider readiness, the failover chains for this machine, the detected harness, the guard's rules with a live verdict, and the Reuse section with per-harness grant decisions and discovered vision. Spends no quota; `--json` for a machine-readable report.
100
100
 
@@ -154,6 +154,29 @@ The trade-off is honest either way: an explicit `@latest` (or the exclusion)
154
154
  opts modlens out of pnpm's supply-chain cooling-off window, so new releases
155
155
  install immediately.
156
156
 
157
+ ## fetch failed, or could not connect
158
+
159
+ ```
160
+ Could not connect to generativelanguage.googleapis.com (UND_ERR_CONNECT_TIMEOUT). The request never reached the network. ...
161
+ ```
162
+
163
+ The API request never left the machine. On networks that reach the internet
164
+ through a proxy this is expected: Node's fetch ignores the proxy environment
165
+ variables by default. modlens honors them once you ask it to route that way,
166
+ in either form:
167
+
168
+ ```bash
169
+ HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api # env (NO_PROXY honored too)
170
+ modlens config set proxy http://127.0.0.1:7890 # persistent, all API providers
171
+ modlens config set openai.proxy http://127.0.0.1:7890 # one provider only
172
+ ```
173
+
174
+ The proxy applies to API provider requests only. The remote-image download
175
+ path keeps its direct, IP-pinned connection on purpose: its SSRF guards
176
+ validate the exact address being contacted, and a proxy would blind them. On
177
+ a proxied machine, prefer local files or let the failover chain hand remote
178
+ URLs to a provider that fetches them upstream.
179
+
157
180
  ## Config file problems
158
181
 
159
182
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.11.0):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.12.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.11.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.11.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.11.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.12.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.12.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.12.0 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.13+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -22,6 +22,7 @@ Everything lives under four top-level keys, all optional. This example shows eve
22
22
  ```json
23
23
  {
24
24
  "provider": "gemini-api",
25
+ "proxy": "http://127.0.0.1:7890",
25
26
  "reuse": { "claude": true, "codex": true, "opencode": false, "pi": true, "grok": true },
26
27
  "guards": {
27
28
  "allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.11.0
11
+ - Pinned CLI version: 3.12.0
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.11.0'
27
+ $Pinned = '3.12.0'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.11.0"
25
+ PINNED="3.12.0"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"