@liustack/modlens 3.16.5 → 3.16.7

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/dsh/index.js CHANGED
@@ -11,7 +11,9 @@
11
11
  // package.json `dsh.bundle` manifest). Providers, reuse grants, and guard
12
12
  // rules keep living in ~/.modlens/config.json, shared with every harness.
13
13
  import { spawn } from 'node:child_process'
14
- import { readFileSync } from 'node:fs'
14
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { dirname, join } from 'node:path'
15
17
  import { fileURLToPath } from 'node:url'
16
18
 
17
19
  const CLI_PATH = fileURLToPath(new URL('../dist/main.js', import.meta.url))
@@ -59,14 +61,29 @@ export function apply(ctx, config = {}) {
59
61
  // optional-inject form, so the route rides a scoped ctx.inject: the closure
60
62
  // runs when the service appears and never runs where it does not (headless
61
63
  // stays untouched, and the plugin itself never waits on it).
62
- if (config.pasteToPath !== false && typeof ctx.inject === 'function') {
64
+ if (typeof ctx.inject === 'function') {
63
65
  ctx.inject(['webServer'], (scope) => {
64
- try {
65
- // scope carries webServer; the plugin's own ctx carries llm for the
66
- // takeover verdicts.
67
- registerPasteRoute(scope, ctx, ownProviders)
68
- } catch (error) {
69
- console.error(`[modlens] paste-to-path route skipped: ${error}`)
66
+ if (config.pasteToPath !== false) {
67
+ try {
68
+ // scope carries webServer; the plugin's own ctx carries llm for the
69
+ // takeover verdicts.
70
+ registerPasteRoute(scope, ctx, ownProviders)
71
+ } catch (error) {
72
+ console.error(`[modlens] paste-to-path route skipped: ${error}`)
73
+ }
74
+ }
75
+ // Same web server, a separate switch: turning paste-to-path off is a
76
+ // statement about how images enter, not about whether the engine can
77
+ // be configured. dsh's own settings surface renders a hardcoded set of
78
+ // cards and does not enumerate namespaces, so the card the browser half
79
+ // contributes talks to this route rather than to a settings schema
80
+ // (issue #39).
81
+ if (config.settingsCard !== false) {
82
+ try {
83
+ registerConfigRoute(scope)
84
+ } catch (error) {
85
+ console.error(`[modlens] settings card route skipped: ${error}`)
86
+ }
70
87
  }
71
88
  })
72
89
  }
@@ -850,3 +867,354 @@ function renderEvidence(value) {
850
867
  }
851
868
  return lines.join('\n')
852
869
  }
870
+
871
+ // The engines a user can pick in the settings card, in the order the docs
872
+ // introduce them. Kept to the names modlens itself uses so the card and
873
+ // `modlens doctor` say the same words.
874
+ const ENGINES = ['antigravity-cli', 'gemini-api', 'openai', 'anthropic', 'claude-cli']
875
+ // The two CLI engines sign in through their own tool, so a key or an endpoint
876
+ // would be a field with nothing behind it. Both still take a model.
877
+ const KEYLESS_ENGINES = ['antigravity-cli', 'claude-cli']
878
+ // Every accepted spelling, mirroring src/providers/index.ts. Settings saved
879
+ // under an alias are the same engine's settings, and a provider pinned by an
880
+ // alias is pinned to that engine: showing either as something else would put
881
+ // the card at odds with what actually reads the images.
882
+ const ENGINE_ALIASES = {
883
+ antigravity: 'antigravity-cli',
884
+ agy: 'antigravity-cli',
885
+ gemini: 'gemini-api',
886
+ 'openai-compat': 'openai',
887
+ claude: 'anthropic',
888
+ 'claude-code': 'claude-cli',
889
+ }
890
+
891
+ /** The canonical engine a stored name means, or '' when it names none. */
892
+ function canonicalEngine(name) {
893
+ if (typeof name !== 'string') return ''
894
+ const trimmed = name.trim().toLowerCase()
895
+ if (ENGINES.includes(trimmed)) return trimmed
896
+ return ENGINE_ALIASES[trimmed] ?? ''
897
+ }
898
+
899
+ /** The config keys holding one engine's settings: its own, plus its aliases. */
900
+ function settingsKeysFor(engine) {
901
+ const aliases = Object.keys(ENGINE_ALIASES).filter((alias) => ENGINE_ALIASES[alias] === engine)
902
+ return [...aliases, engine]
903
+ }
904
+ // Auto mode: the local harnesses whose logins a read may borrow. `claude`
905
+ // absent counts as granted, since claude-cli predates the grant model.
906
+ const REUSE_HARNESSES = ['claude', 'codex', 'opencode', 'pi', 'grok']
907
+
908
+ /** ~/.modlens/config.json, the one file every harness shares. */
909
+ function modlensConfigPath() {
910
+ return join(homedir(), '.modlens', 'config.json')
911
+ }
912
+
913
+ /**
914
+ * The shared config, or a thrown error. Only a missing file reads as empty:
915
+ * a file that exists but cannot be parsed or read is somebody's configuration,
916
+ * and a settings card that treated it as empty would overwrite it on the next
917
+ * save. The card shows the error instead.
918
+ */
919
+ function readModlensConfig() {
920
+ let raw
921
+ try {
922
+ raw = readFileSync(modlensConfigPath(), 'utf8')
923
+ } catch (error) {
924
+ if (error?.code === 'ENOENT') return {}
925
+ throw new Error(`cannot read ${modlensConfigPath()}: ${error?.message ?? error}`)
926
+ }
927
+ let parsed
928
+ try {
929
+ parsed = JSON.parse(raw)
930
+ } catch (error) {
931
+ throw new Error(`${modlensConfigPath()} is not valid JSON: ${error?.message ?? error}`)
932
+ }
933
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
934
+ throw new Error(`${modlensConfigPath()} does not hold a JSON object`)
935
+ }
936
+ return parsed
937
+ }
938
+
939
+ /**
940
+ * What the card is allowed to know. Every engine's endpoint and model, plus
941
+ * whether a key is stored, and never the key itself: a browser that cannot
942
+ * read a secret cannot leak one, and cannot write it back either.
943
+ */
944
+ function engineSummary(config = readModlensConfig()) {
945
+ const engines = {}
946
+ for (const name of ENGINES) {
947
+ // Alias first, canonical last: the canonical key wins on conflict, the
948
+ // same order resolveProviderSettings uses.
949
+ const settings = Object.assign({}, ...settingsKeysFor(name).map((key) => config.providers?.[key] ?? {}))
950
+ engines[name] = {
951
+ baseUrl: typeof settings.baseUrl === 'string' ? settings.baseUrl : '',
952
+ model: typeof settings.model === 'string' ? settings.model : '',
953
+ hasKey: typeof settings.apiKey === 'string' && settings.apiKey !== '',
954
+ }
955
+ }
956
+ const reuse = {}
957
+ for (const harness of REUSE_HARNESSES) {
958
+ const granted = config.reuse?.[harness]
959
+ reuse[harness] = typeof granted === 'boolean' ? granted : harness === 'claude'
960
+ }
961
+ // Three states, kept apart: pinned to an engine, pinned by one of its
962
+ // aliases (reported canonically), or not pinned at all, which is its own
963
+ // answer and means the failover chain decides. Collapsing the third into
964
+ // the first pins an engine the user never chose.
965
+ return {
966
+ provider: canonicalEngine(config.provider),
967
+ engines,
968
+ keyless: KEYLESS_ENGINES,
969
+ reuse,
970
+ }
971
+ }
972
+
973
+ /**
974
+ * Apply one card submission to the shared file. Only the named engine's own
975
+ * three fields are touched, so switching engines in the card cannot copy one
976
+ * engine's endpoint onto another. An absent or empty `apiKey` leaves the
977
+ * stored one alone: the card never receives a key, so it must never be able
978
+ * to clear one by submitting the blank field it was shown.
979
+ */
980
+ function applyEngineSettings(patch) {
981
+ const config = readModlensConfig()
982
+ // The pin moves only when the card says it moved. A save that carried the
983
+ // currently displayed engine regardless turned "not pinned" into a pin on
984
+ // whatever happened to be shown, changing which engine reads every later
985
+ // image without the user asking for it.
986
+ if (patch?.provider !== undefined) {
987
+ if (patch.provider === '') {
988
+ delete config.provider
989
+ } else if (ENGINES.includes(patch.provider)) {
990
+ config.provider = patch.provider
991
+ } else {
992
+ throw new Error(`unknown engine: ${patch.provider}`)
993
+ }
994
+ }
995
+ // Engine fields are edited one engine at a time, named by `engine`. Absent
996
+ // means this save touched no engine settings, which is what a reuse-only
997
+ // save looks like.
998
+ const engine = patch?.engine
999
+ if (engine !== undefined) {
1000
+ if (!ENGINES.includes(engine)) {
1001
+ throw new Error(`unknown engine: ${engine}`)
1002
+ }
1003
+ config.providers = { ...config.providers }
1004
+ // Write where this engine's settings already live, so a key saved under
1005
+ // an alias is updated rather than shadowed by a second copy.
1006
+ // Write where the read takes effect. settingsKeysFor merges aliases
1007
+ // first and the canonical key last, so the canonical value wins; picking
1008
+ // the first existing key instead wrote a new value underneath an older
1009
+ // canonical one, which saved successfully and changed nothing. Both CLI
1010
+ // spellings existing at once is ordinary: `config set gemini.apiKey`
1011
+ // then `config set gemini-api.apiKey` leaves exactly that.
1012
+ const holders = settingsKeysFor(engine).filter((key) => config.providers[key] !== undefined)
1013
+ const target = holders.length > 0 ? holders[holders.length - 1] : engine
1014
+ const settings = { ...config.providers[target] }
1015
+ for (const field of ['baseUrl', 'model']) {
1016
+ const value = typeof patch[field] === 'string' ? patch[field].trim() : ''
1017
+ if (value === '') {
1018
+ delete settings[field]
1019
+ } else {
1020
+ settings[field] = value
1021
+ }
1022
+ }
1023
+ const apiKey = typeof patch.apiKey === 'string' ? patch.apiKey.trim() : ''
1024
+ if (apiKey !== '') {
1025
+ settings.apiKey = apiKey
1026
+ }
1027
+ config.providers[target] = settings
1028
+ }
1029
+ // Auto mode, when the card sent it: only the harnesses this build knows,
1030
+ // only booleans, so an unexpected key cannot land in the shared file.
1031
+ if (patch?.reuse !== null && typeof patch?.reuse === 'object') {
1032
+ config.reuse = { ...config.reuse }
1033
+ for (const harness of REUSE_HARNESSES) {
1034
+ const granted = patch.reuse[harness]
1035
+ if (typeof granted === 'boolean') {
1036
+ config.reuse[harness] = granted
1037
+ }
1038
+ }
1039
+ }
1040
+ const file = modlensConfigPath()
1041
+ // A symlink here would write through to wherever it points, so it is
1042
+ // refused rather than followed: the CLI writes a real file, and anything
1043
+ // else is a setup this card should not silently honor.
1044
+ try {
1045
+ if (lstatSync(file).isSymbolicLink()) {
1046
+ throw new Error(`${file} is a symlink; edit the file it points at instead`)
1047
+ }
1048
+ } catch (error) {
1049
+ if (error?.code !== 'ENOENT') throw error
1050
+ }
1051
+ mkdirSync(dirname(file), { recursive: true })
1052
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
1053
+ try {
1054
+ chmodSync(file, 0o600)
1055
+ } catch {
1056
+ // Windows has no POSIX bits; the mode on the write above is all there is.
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * GET /modlens/config: the engine summary above. POST: one submission.
1062
+ *
1063
+ * The dsh web server listens on loopback, but a page in the same browser can
1064
+ * still reach it, so a write requires a same-origin request: a cross-site POST
1065
+ * could otherwise repoint someone's engine at an endpoint of its choosing.
1066
+ * A read is refused the same way for symmetry, though it carries no secret.
1067
+ */
1068
+ /**
1069
+ * The self-check behind the card's auto-mode section: which local harnesses
1070
+ * exist to be borrowed at all. `doctor --json` already probes them without
1071
+ * network or quota, so the route spawns the CLI this package ships and lifts
1072
+ * its reuse section. Cached briefly, since one probe can take a second and
1073
+ * re-expanding the card should not re-pay it.
1074
+ */
1075
+ const DISCOVERY_TTL_MS = 60_000
1076
+ let discoveryCache = null
1077
+ async function discoverReuse() {
1078
+ if (discoveryCache !== null && Date.now() - discoveryCache.at < DISCOVERY_TTL_MS) {
1079
+ return discoveryCache.value
1080
+ }
1081
+ try {
1082
+ const { stdout, code } = await run(process.execPath, [CLI_PATH, 'doctor', '--json'], AbortSignal.timeout(30_000))
1083
+ if (code !== 0) return null
1084
+ const reuse = JSON.parse(stdout)?.reuse
1085
+ if (!reuse || !Array.isArray(reuse.probes)) return null
1086
+ // doctor names the harness claude-code; the grant key is claude.
1087
+ const probes = reuse.probes.map((probe) => ({
1088
+ harness: probe.harness === 'claude-code' ? 'claude' : probe.harness,
1089
+ cliFound: probe.cliFound === true,
1090
+ loggedIn: probe.loggedIn,
1091
+ cliPath: typeof probe.cliPath === 'string' ? probe.cliPath : '',
1092
+ }))
1093
+ discoveryCache = { at: Date.now(), value: probes }
1094
+ return probes
1095
+ } catch {
1096
+ return null
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Open the shared config file in whatever the OS considers its editor. The
1102
+ * card's "open config file" link lands here: the path never has to be
1103
+ * explained to the user, they just get the file. Created empty first when
1104
+ * missing, so the editor has something to open.
1105
+ */
1106
+ function openConfigFile() {
1107
+ const file = modlensConfigPath()
1108
+ try {
1109
+ lstatSync(file)
1110
+ } catch {
1111
+ mkdirSync(dirname(file), { recursive: true })
1112
+ writeFileSync(file, '{}\n', { mode: 0o600 })
1113
+ }
1114
+ const [command, args] =
1115
+ process.platform === 'darwin'
1116
+ ? ['open', [file]]
1117
+ : process.platform === 'win32'
1118
+ ? ['cmd', ['/c', 'start', '', file]]
1119
+ : ['xdg-open', [file]]
1120
+ spawn(command, args, { detached: true, stdio: 'ignore' }).unref()
1121
+ }
1122
+
1123
+ /** localhost, ::1, or anything in 127/8, matching dsh's own /api fence. */
1124
+ function isLoopbackHost(hostname) {
1125
+ if (hostname === 'localhost' || hostname === '[::1]') return true
1126
+ const parts = hostname.split('.')
1127
+ return (
1128
+ parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
1129
+ )
1130
+ }
1131
+
1132
+ /**
1133
+ * The same fence dsh puts in front of its own /api, for the same two
1134
+ * confused-deputy paths. Host is the header DNS rebinding cannot forge, so it
1135
+ * must name a loopback authority: a rebound page reaches this socket carrying
1136
+ * its own domain there. Origin and Sec-Fetch-Site then rule out a cross-site
1137
+ * page on the machine itself. A dsh serving a LAN address configures
1138
+ * trustedHosts for /api; this route stays loopback-only, since nothing about
1139
+ * editing an API key wants a wider door.
1140
+ */
1141
+ function isTrustedRequest(req) {
1142
+ const host = req.headers?.host
1143
+ if (typeof host !== 'string' || host === '') return false
1144
+ let hostUrl
1145
+ try {
1146
+ hostUrl = new URL(`http://${host}`)
1147
+ } catch {
1148
+ return false
1149
+ }
1150
+ if (!isLoopbackHost(hostUrl.hostname)) return false
1151
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
1152
+ const origin = req.headers?.origin
1153
+ if (origin === undefined) return true
1154
+ try {
1155
+ return new URL(origin).host === hostUrl.host
1156
+ } catch {
1157
+ return false
1158
+ }
1159
+ }
1160
+
1161
+ function registerConfigRoute(ctx) {
1162
+ ctx.webServer.register({
1163
+ name: 'modlens-config',
1164
+ kind: 'exact',
1165
+ path: '/modlens/config',
1166
+ handler: async (req, res) => {
1167
+ const send = (status, body) => {
1168
+ res.writeHead(status, { 'content-type': 'application/json' })
1169
+ res.end(JSON.stringify(body))
1170
+ }
1171
+ if (!isTrustedRequest(req)) {
1172
+ send(403, { error: 'request refused: this route answers same-origin loopback only' })
1173
+ return
1174
+ }
1175
+ if (req.method === 'GET') {
1176
+ try {
1177
+ const summary = engineSummary()
1178
+ const wantsDiscovery = new URL(req.url, 'http://localhost').searchParams.has('discover')
1179
+ if (wantsDiscovery) {
1180
+ // null when the probe failed: the card then falls back to the
1181
+ // plain grant list rather than showing nothing.
1182
+ summary.discovery = await discoverReuse()
1183
+ }
1184
+ send(200, summary)
1185
+ } catch (error) {
1186
+ send(409, { error: String(error?.message ?? error) })
1187
+ }
1188
+ return
1189
+ }
1190
+ if (req.method !== 'POST') {
1191
+ res.writeHead(405).end()
1192
+ return
1193
+ }
1194
+ try {
1195
+ const chunks = []
1196
+ let total = 0
1197
+ for await (const chunk of req) {
1198
+ total += chunk.length
1199
+ if (total > 64 * 1024) {
1200
+ send(413, { error: 'config payload too large' })
1201
+ req.destroy()
1202
+ return
1203
+ }
1204
+ chunks.push(chunk)
1205
+ }
1206
+ const patch = JSON.parse(Buffer.concat(chunks).toString('utf8'))
1207
+ // The card's "open config file" link: an action, not a setting.
1208
+ if (patch?.open === true) {
1209
+ openConfigFile()
1210
+ send(200, { opened: true })
1211
+ return
1212
+ }
1213
+ applyEngineSettings(patch)
1214
+ send(200, engineSummary())
1215
+ } catch (error) {
1216
+ send(400, { error: String(error?.message ?? error) })
1217
+ }
1218
+ },
1219
+ })
1220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.16.5",
3
+ "version": "3.16.7",
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.16.5):
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.16.7):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.5: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.5 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.5 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.7: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.7 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.7 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (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.
@@ -12,14 +12,14 @@ Read this when the user asks how to set up, configure, or switch ModLens provide
12
12
  modlens config init # write a starter config (refuses to overwrite; --force to redo)
13
13
  modlens config show # effective file, API keys masked
14
14
  modlens config set provider <name> # change the default provider
15
- modlens config set <provider>.<field> <value> # fields: apiKey, baseUrl, model, extraBody
15
+ modlens config set <provider>.<field> <value> # fields: apiKey, baseUrl, model, proxy, extraBody, structuredOutput
16
16
  ```
17
17
 
18
18
  `config set` writes the file with 0600 permissions.
19
19
 
20
20
  ## The file's exact shape
21
21
 
22
- Everything lives under four top-level keys, all optional. This example shows every supported key and field at once (a real file only needs what you use). A missing file means all defaults. Provider settings sit under `providers.<name>`, not at the top level, which is the mistake hand-editors make most.
22
+ Everything lives under five top-level keys, all optional. This example shows every supported key and field at once (a real file only needs what you use). A missing file means all defaults. Provider settings sit under `providers.<name>`, not at the top level, which is the mistake hand-editors make most.
23
23
 
24
24
  ```json
25
25
  {
@@ -42,7 +42,9 @@ Everything lives under four top-level keys, all optional. This example shows eve
42
42
  "apiKey": "sk-...",
43
43
  "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
44
44
  "model": "qwen3.6-27b",
45
- "extraBody": { "thinking": { "type": "disabled" } }
45
+ "proxy": "http://127.0.0.1:7890",
46
+ "extraBody": { "thinking": { "type": "disabled" } },
47
+ "structuredOutput": true
46
48
  },
47
49
  "anthropic": {
48
50
  "apiKey": "sk-ant-...",
@@ -57,8 +59,9 @@ Everything lives under four top-level keys, all optional. This example shows eve
57
59
  Field semantics:
58
60
 
59
61
  - `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `claude-code` for `claude-cli`). Empty or absent pins nothing: the failover chain decides, trying configured API providers before the agent CLIs.
60
- - `providers.<name>.<field>`: four fields exist, `apiKey`, `baseUrl`, `model`, and `extraBody`. Every provider entry is optional, and every field inside it is optional. Alias keys are read too (settings saved under `gemini` are found when `gemini-api` resolves), with the canonical key winning on conflict.
61
- - `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and the schema enforcement are refused with an error naming the field. The two CLI providers take no request body, so a run on `antigravity-cli` or `claude-cli` ignores it and says so in `meta.warnings`.
62
+ - `providers.<name>.<field>`: six fields exist, `apiKey`, `baseUrl`, `model`, `proxy`, `extraBody`, and `structuredOutput` (the openai route only). Every provider entry is optional, and every field inside it is optional. Alias keys are read too (settings saved under `gemini` are found when `gemini-api` resolves), with the canonical key winning on conflict.
63
+ - `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and each route's own enforcement machinery are refused with an error naming the field. `response_format` on the `openai` route is not one of them: setting it there deliberately replaces the schema modlens would otherwise send. The two CLI providers take no request body, so a run on `antigravity-cli` or `claude-cli` ignores it and says so in `meta.warnings`.
64
+ - `providers.openai.structuredOutput`: `true` asks an OpenAI-compatible gateway to enforce the vision contract itself, as `response_format: json_schema` in the strict form those endpoints require. Off by default, since a gateway without structured-output support answers 400 for the field. A `response_format` you set in `extraBody` wins over it.
62
65
  - `guards`: the invocation guard, for people who run both text-only and vision-capable models through the same client. Both lists hold glob patterns (`*` and `?`, case-insensitive, matched against the model name and `provider/model`), set with `modlens config set guards.denyModels '["gemini-3*"]'` or `guards.allowModels` (a JSON array or a comma-separated list, empty clears). Two ways to express the same intent, pick the shorter list:
63
66
  - `denyModels` alone: everything runs the engine except the listed vision models. Right when text-only models are the majority of what you plug in.
64
67
  - `allowModels` non-empty (allowlist mode): only the listed models run the engine, every other identified model is denied. Right for the actual 2026 landscape, where text-only models are the short list. A deny pattern still wins over an allow match, so a broad allow can have its vision variants carved out, as in the example above: `glm-5.*` allows the text line while `glm-*v*` catches `glm-5v-turbo`. Anchor allow patterns tightly (`deepseek-v4-*`, not `deepseek*`) so a vendor's next multimodal generation falls off the list and steps aside until you have checked it.
@@ -90,9 +93,13 @@ Any free Google account works; no Google AI Pro needed. Sign-in cannot be automa
90
93
 
91
94
  ```bash
92
95
  modlens config set gemini-api.apiKey <key>
96
+ # value omitted: a hidden prompt, so the key skips argv, shell history, and this chat
97
+ modlens config set gemini-api.apiKey
93
98
  # or environment: export GEMINI_API_KEY=<key>
94
99
  ```
95
100
 
101
+ Offer the hidden prompt first when the user is at their own terminal. Most users paste the key into the chat because it is convenient, and that works too: take it and store it. The prompt is for the ones who would rather not.
102
+
96
103
  Default model `gemini-3.6-flash` has vision on the free tier (about 10-15 requests/min, 1500/day). Free-tier data may be used by Google to improve products; mention this if the user handles sensitive images.
97
104
 
98
105
  ### openai (any OpenAI-compatible multimodal endpoint)
@@ -105,7 +112,15 @@ modlens config set openai.apiKey <sk-key>
105
112
  modlens config set openai.model qwen3.6-27b
106
113
  ```
107
114
 
108
- For official OpenAI: baseUrl `https://api.openai.com/v1`, a vision-capable model. Environment equivalents: `OPENAI_BASE_URL`, `OPENAI_API_KEY`. The model must be multimodal; text-only models will fail or hallucinate. This route has no server-side schema enforcement, so occasional shape failures are surfaced as explicit errors; retry or switch provider.
115
+ For official OpenAI: baseUrl `https://api.openai.com/v1`, a vision-capable model. Environment equivalents: `OPENAI_BASE_URL`, `OPENAI_API_KEY`. The model must be multimodal; text-only models will fail or hallucinate.
116
+
117
+ This route enforces nothing server-side by default, so a weaker model can answer with half the contract and the run fails with an explicit error. If that happens, ask the gateway to enforce it:
118
+
119
+ ```bash
120
+ modlens config set openai.structuredOutput true
121
+ ```
122
+
123
+ The contract goes out as `response_format: json_schema` in strict form, derived from the schema modlens checks against. Off by default because a gateway without structured-output support answers 400 for the field, so turn it back off if the endpoint refuses it. Turning thinking off (below) makes the shape failures more likely, so the two often go together.
109
124
 
110
125
  ### anthropic (Claude API key)
111
126
 
@@ -12,14 +12,14 @@
12
12
  modlens config init # 写入一份起步配置(已存在则拒绝,--force 重写)
13
13
  modlens config show # 生效的配置文件,API key 打码显示
14
14
  modlens config set provider <name> # 更改默认 provider
15
- modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、model、extraBody
15
+ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、model、proxy、extraBody、structuredOutput
16
16
  ```
17
17
 
18
18
  `config set` 写文件时权限为 0600。
19
19
 
20
20
  ## 配置文件的完整形状
21
21
 
22
- 所有内容都在四个顶层键之下,全部可选。下面的示例一次性展示了所有支持的键和字段(真实文件只需要写你用到的部分)。文件不存在就全用默认值。provider 的设置放在 `providers.<name>` 下面,不在顶层,手工编辑最常犯的就是这个错。
22
+ 所有内容都在五个顶层键之下,全部可选。下面的示例一次性展示了所有支持的键和字段(真实文件只需要写你用到的部分)。文件不存在就全用默认值。provider 的设置放在 `providers.<name>` 下面,不在顶层,手工编辑最常犯的就是这个错。
23
23
 
24
24
  ```json
25
25
  {
@@ -42,7 +42,9 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
42
42
  "apiKey": "sk-...",
43
43
  "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
44
44
  "model": "qwen3.6-27b",
45
- "extraBody": { "thinking": { "type": "disabled" } }
45
+ "proxy": "http://127.0.0.1:7890",
46
+ "extraBody": { "thinking": { "type": "disabled" } },
47
+ "structuredOutput": true
46
48
  },
47
49
  "anthropic": {
48
50
  "apiKey": "sk-ant-...",
@@ -57,8 +59,9 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
57
59
  字段含义:
58
60
 
59
61
  - `provider`:不传 `-p` 时由哪个 provider 执行。标准名和别名都行(`agy`/`antigravity` 对应 `antigravity-cli`,`gemini` 对应 `gemini-api`,`openai-compat` 对应 `openai`,`claude` 对应 `anthropic`,`claude-code` 对应 `claude-cli`)。留空或缺失表示不钉任何一个:由失败切换链决定,已配置的 API provider 先于 agent CLI 被尝试。
60
- - `providers.<name>.<field>`:共四个字段,`apiKey`、`baseUrl`、`model`、`extraBody`。每个 provider 条目都可选,条目里的每个字段也都可选。别名键同样会被读取(存在 `gemini` 下的设置在解析到 `gemini-api` 时也能找到),冲突时标准键胜出。
61
- - `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和 schema 约束的字段会被拒绝,报错会点名该字段。两个 CLI provider 不发请求体,所以在 `antigravity-cli` 或 `claude-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
62
+ - `providers.<name>.<field>`:共六个字段,`apiKey`、`baseUrl`、`model`、`proxy`、`extraBody`、`structuredOutput`(仅 openai 路线)。每个 provider 条目都可选,条目里的每个字段也都可选。别名键同样会被读取(存在 `gemini` 下的设置在解析到 `gemini-api` 时也能找到),冲突时标准键胜出。
63
+ - `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和各路线自身强制机制的字段会被拒绝,报错会点名该字段。`openai` 路线上的 `response_format` 不在此列:在那里设置它就是有意替换掉 modlens 本来会发的那份 schema。两个 CLI provider 不发请求体,所以在 `antigravity-cli` 或 `claude-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
64
+ - `providers.openai.structuredOutput`:设为 `true` 时,让 OpenAI 兼容网关自己强制执行视觉契约,以 `response_format: json_schema` 的严格形式发出。默认关闭,因为不支持结构化输出的网关会对这个字段返回 400。你在 `extraBody` 里设的 `response_format` 优先级更高。
62
65
  - `guards`:调用 guard,给在同一个客户端里既跑纯文本模型又跑视觉模型的人用。两个列表都放 glob 模式(支持 `*` 和 `?`,不区分大小写,同时匹配模型名和 `provider/model`),用 `modlens config set guards.denyModels '["gemini-3*"]'` 或 `guards.allowModels` 设置(JSON 数组或逗号分隔的列表都行,传空则清除)。两种写法表达同一个意图,选列表更短的那种:
63
66
  - 只用 `denyModels`:除了列出的视觉模型,其余全部运行引擎。适合你接入的模型大多是纯文本的情况。
64
67
  - `allowModels` 非空(白名单模式):只有列出的模型运行引擎,其他所有已识别的模型一律拒绝。适合 2026 年的实际格局,纯文本模型才是那份短名单。deny 模式仍然优先于 allow 匹配,所以宽泛的 allow 可以把视觉变体剔出去,正如上面的示例:`glm-5.*` 放行文本系列,`glm-*v*` 抓住 `glm-5v-turbo`。allow 模式要锚定得紧一些(写 `deepseek-v4-*` 而不是 `deepseek*`),这样厂商下一代多模态型号会自动掉出名单,等你检查过再上场。
@@ -90,9 +93,13 @@ agy # 用户需自己在浏览器完成登录,然后退出
90
93
 
91
94
  ```bash
92
95
  modlens config set gemini-api.apiKey <key>
96
+ # 省略值:进入隐藏输入,密钥不进 argv、不进 shell 历史,也不进这段对话
97
+ modlens config set gemini-api.apiKey
93
98
  # 或走环境变量:export GEMINI_API_KEY=<key>
94
99
  ```
95
100
 
101
+ 用户就在自己终端前时,先给隐藏输入这条。大多数人图方便还是会把 key 直接贴进对话,那也没问题:照收照存。隐藏输入是留给在乎的人的。
102
+
96
103
  默认模型 `gemini-3.6-flash` 在免费档就有视觉能力(约每分钟 10-15 次请求,每天 1500 次)。免费档的数据可能被 Google 用于改进产品,用户要处理敏感图片时请提醒这一点。
97
104
 
98
105
  ### openai(任意 OpenAI 兼容的多模态端点)
@@ -105,7 +112,15 @@ modlens config set openai.apiKey <sk-key>
105
112
  modlens config set openai.model qwen3.6-27b
106
113
  ```
107
114
 
108
- 官方 OpenAI 的写法:baseUrl 用 `https://api.openai.com/v1`,配一个具备视觉能力的模型。对应的环境变量:`OPENAI_BASE_URL`、`OPENAI_API_KEY`。模型必须是多模态的,纯文本模型会失败或产生幻觉。这条路线没有服务端 schema 约束,偶发的结构错误会以明确报错的形式暴露出来,重试或换 provider 即可。
115
+ 官方 OpenAI 的写法:baseUrl 用 `https://api.openai.com/v1`,配一个具备视觉能力的模型。对应的环境变量:`OPENAI_BASE_URL`、`OPENAI_API_KEY`。模型必须是多模态的,纯文本模型会失败或产生幻觉。
116
+
117
+ 这条路线默认在服务端不做任何约束,能力弱一些的模型可能只答出契约的一半,运行就会以明确报错失败。真遇到就让网关自己强制执行:
118
+
119
+ ```bash
120
+ modlens config set openai.structuredOutput true
121
+ ```
122
+
123
+ 契约会以 `response_format: json_schema` 的严格形式发出去,schema 由 modlens 校验用的那份推导而来。默认关闭,因为不支持结构化输出的网关会对这个字段返回 400,端点拒绝就关回去。关掉思考(见下)会让结构错误更容易出现,所以这两项常常一起用。
109
124
 
110
125
  ### anthropic(Claude API key)
111
126
 
@@ -27,7 +27,7 @@ Consent rules:
27
27
  - One question per decision, never a bundled yes. Reusing Codex and reusing pi credentials are two questions (or one question with independent options), not one.
28
28
  - Each question names the harness, whose quota it spends, and the accounting promise. Example wording: "Allow modlens to reuse your signed-in Codex CLI for image reads? Every reused read is labeled in the result so you always see whose quota was spent."
29
29
  - The do-nothing outcome must be safe and stated: "If you skip this, modlens just uses the engines you configure yourself."
30
- - If the user offers an API key, take exactly that key, and never go looking for keys they did not hand over.
30
+ - When a key is needed, offer the clean path first, in one line: "Run `modlens config set gemini-api.apiKey` in your terminal and paste the key at the hidden prompt. It stays out of this chat and out of your shell history, and I never see it." Most users will paste the key into the chat anyway, because that is the convenient path, and that is fine: take exactly the key they hand over, use it, and never go looking for keys they did not. The offer is for the users who care, not a gate.
31
31
 
32
32
  ## 4. Apply only what was consented to
33
33
 
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.16.5
11
+ - Pinned CLI version: 3.16.7
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.16.5'
27
+ $Pinned = '3.16.7'
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.16.5"
25
+ PINNED="3.16.7"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"