agentsw 0.7.2 → 0.9.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/README.md CHANGED
@@ -45,7 +45,7 @@ ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
45
45
  keys it does not model — `authHeader`, `headers`, `compat`, `discovery` — per-model extras
46
46
  like `thinkingLevelMap`, and YAML comments all survive a re-sync.
47
47
  - **Model metadata, not just an id list.** Reseller `/v1/models` endpoints return bare ids.
48
- agentsw enriches them from [models.dev](https://models.dev) and writes what each app
48
+ agentsw enriches them from [models.dev](https://models.dev), automatically fills meaningful gaps from AI Gateway when needed, and writes what each app
49
49
  actually reads: `thinkingLevelMap` for pi/prime, `limit`/`attachment` for opencode,
50
50
  `contextWindow`/`maxTokens` for omp, `reasoningEfforts`/`input` for dsh.
51
51
  - **Adopts what you already have** — including [cc-switch](https://github.com/farion1231/cc-switch).
@@ -121,6 +121,12 @@ dsh yes openai+anthropic vfing · glm-5.3-flash ~/.ds
121
121
  An agent that is not installed is skipped, not guessed at. `--apps codex,omp` narrows a run;
122
122
  `--apps` with an uninstalled agent forces it.
123
123
 
124
+ The interactive menu asks the same question before writing: a multi-select where the first row
125
+ is **all detected apps**, detected agents are listed first and undetected ones are marked. The
126
+ last selection is remembered, so a menu `use`/`sync` keeps rewriting only the agents you picked.
127
+ An error in any config file (invalid JSON/YAML/TOML) is reported with the file and position, never
128
+ with the offending line, and dry-run keeps that detail instead of hiding it.
129
+
124
130
  ## Import what you already configured
125
131
 
126
132
  `import` reads every agent's config **and cc-switch's SQLite store** (read-only), resolves
@@ -146,8 +152,8 @@ use `--all` explicitly in scripts. Model discovery may update the local metadata
146
152
  ## Model discovery filters
147
153
 
148
154
  `--discover` lists model ids from the provider's `/v1/models`, then enriches each id from
149
- models.dev. Reseller lists are noisy, so filters are persisted per provider and re-applied on
150
- every `discover`:
155
+ models.dev first, with automatic AI Gateway supplementation when needed. Reseller lists are noisy,
156
+ so filters are persisted per provider and re-applied on every `discover`:
151
157
 
152
158
  ```bash
153
159
  # snapshot duplicates (gpt-5.2-latest, glm-4.7-250414, ...) are DROPPED by default
@@ -160,6 +166,77 @@ asw discover myproxy --no-filter # clear it
160
166
 
161
167
  Explicit `--models` entries and the default model are never filtered out.
162
168
 
169
+ ## Automatic model metadata
170
+
171
+ **No extra flags are needed.** New providers and providers without a saved mode use `auto`:
172
+ models.dev is checked first, then the public
173
+ [AI Gateway model catalog](https://vercel.com/docs/ai-gateway/models-and-providers#dynamic-model-discovery)
174
+ is consulted only for missing core parameters (context window, maximum output, reasoning or image input),
175
+ to refresh unchanged Gateway-supplied fields that models.dev has not replaced, or to verify an identity
176
+ conflict in previously tracked automatic values. Missing names, prices, optional input limits or
177
+ reasoning-effort lists alone do not trigger a Gateway lookup.
178
+ agentsw directly fetches `https://ai-gateway.vercel.sh/v1/models` without provider credentials or an AI SDK dependency.
179
+
180
+ ```bash
181
+ # Refresh only saved models using the saved mode (auto if unset)
182
+ # No model discovery or agent config writes
183
+ asw refresh --provider myproxy
184
+
185
+ # Inspect effective mode, sources, conflicts and reference pricing (no API key)
186
+ asw models --provider myproxy --metadata
187
+
188
+ # Custom or ambiguous names need explicit aliases; wire model IDs stay unchanged
189
+ asw refresh --provider myproxy --metadata-mode auto \
190
+ --gateway-models '{"my-model":"openai/gpt-5.4"}'
191
+
192
+ # Always consult Gateway, even when core parameters are complete
193
+ asw refresh --provider myproxy --metadata-mode on
194
+
195
+ # Stop future Gateway fetches; retain saved model parameters
196
+ asw refresh --provider myproxy --metadata-mode off
197
+
198
+ # Return an explicitly disabled provider to automatic mode
199
+ asw refresh --provider myproxy --metadata-mode auto
200
+
201
+ # Preview and apply separately; sync does not query catalogs
202
+ asw sync --provider myproxy --dry-run
203
+ asw sync --provider myproxy
204
+ ```
205
+
206
+ The menu's **Configure and refresh model metadata** action offers **Automatic (recommended)**,
207
+ **Always supplement**, and **Disabled**, with the provider's current mode selected. Onboarding has no extra prompts.
208
+ `add`, `quick`, `discover`, `import`, and `refresh` accept `--metadata-mode <auto|on|off>`.
209
+ The existing `--gateway-metadata` and `--no-gateway-metadata` flags remain explicit `on` and `off` choices.
210
+ Omitted options preserve saved settings: an existing `false` remains off until you select `auto` or `on`.
211
+ Invalid modes and conflicting mode/boolean flags are rejected before fetching or saving.
212
+ Without `--provider`, `refresh` applies to all providers, including any supplied mode.
213
+ `--gateway-models` replaces the whole alias map (`{}` clears it); aliases do not change the saved mode or override `off`.
214
+ Import still skips already configured accounts.
215
+
216
+ - **Field-level priority:** manual values > models.dev > Gateway gap filling. Tracked automatic values may
217
+ refresh; subsequent manual edits are retained. Legacy values without provenance are conservatively treated
218
+ as manual overrides. Unmodeled extension fields survive enrichment too.
219
+ - **Exact identities:** automatic lookup can map a bare, case-sensitive ID (no `/`) to a unique Gateway
220
+ `creator/model` identity when models.dev evidence is absent or agrees. Ambiguous creator evidence in either
221
+ catalog is rejected; custom or ambiguous names need explicit aliases. Qualified IDs never have their prefixes
222
+ stripped, and matching never guesses from substrings, case folding or versions. Auto/on mode also uses conservative
223
+ exact models.dev matching; off retains the legacy models.dev lookup. Unmatched models remain usable.
224
+ Alias changes clear unchanged automatic values tied to confirmed superseded identities, never manual edits.
225
+ - **No routing changes:** metadata never adds available models or changes IDs, protocols, URLs, defaults or
226
+ accounts. Gateway capabilities are reference specs, not proof of reseller support. `supported_specifications`
227
+ is not a URL version.
228
+ - **Reference-only prices:** Gateway prices are converted to USD per million tokens and stored separately in
229
+ `metadata.gateway.referenceCost`, not effective provider `cost` or runtime agent pricing. Variable/tiered
230
+ pricing is flagged separately.
231
+ Auto does not fetch solely to refresh this reference snapshot: check `metadata.gateway.fetchedAt`, or use
232
+ `refresh --provider <id> --metadata-mode on` to request fresh reference prices.
233
+ - **Auditable and optional:** `metadata.fields` records origins, value snapshots and timestamps;
234
+ `metadata.conflicts` records disagreements with retained values. This audit stays in agentsw, never runtime agent configs.
235
+ Gateway has a separate 24-hour cache; failures use stale cached data or skip supplementation.
236
+ - **Automatic lookup is not automatic sync:** enrichment updates saved metadata during add/quick/discover/import/refresh.
237
+ It does not add an automatic push to agents. Sync behavior is unchanged: ordinary `sync` only writes saved settings
238
+ and fetches neither model lists nor metadata catalogs.
239
+
163
240
  ## Commands
164
241
 
165
242
  | Command | What it does |
@@ -175,7 +252,8 @@ Explicit `--models` entries and the default model are never filtered out.
175
252
  | `sync` | re-apply the active provider (after an agent update, say) |
176
253
  | `discover <id> [--sync]` | refresh the model list + metadata from `/v1/models` |
177
254
  | `models [query]` | search the models.dev catalog |
178
- | `refresh` | re-fetch metadata for every configured provider |
255
+ | `refresh [--provider <id>]` | refresh saved metadata and optionally configure Gateway; keep the model list |
256
+ | `models --provider <id> --metadata` | inspect field sources, conflicts and reference pricing as JSON |
179
257
  | `prune <id>` / `remove <id> [--prune]` | remove from app configs / from the store |
180
258
  | `remove <id> --apps omp` | delete only the selected app's entry; supports `--dry-run` |
181
259
  | `apps` / `install <app>` / `upgrade` | agent version manager |
package/README.zh-CN.md CHANGED
@@ -44,7 +44,7 @@ ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
44
44
  (`authHeader`、`headers`、`compat`、`discovery`)、`thinkingLevelMap` 之类的模型级字段、
45
45
  以及 YAML 注释,都会在重新同步后原样保留。
46
46
  - **写的是元数据,不只是模型 id。** 转售商 `/v1/models` 只返回裸 id;agentsw 从
47
- [models.dev](https://models.dev) 补全,并按各应用真正读取的字段下发:pi/prime 的
47
+ [models.dev](https://models.dev) 优先补全,并在有实际缺口时自动查询 AI Gateway,按各应用真正读取的字段下发:pi/prime 的
48
48
  `thinkingLevelMap`、opencode 的 `limit`/`attachment`、omp 的 `contextWindow`/`maxTokens`、
49
49
  dsh 的 `reasoningEfforts`/`input`。
50
50
  - **接管你已有的配置——包括 [cc-switch](https://github.com/farion1231/cc-switch)。**
@@ -143,7 +143,7 @@ imported zhipu-glm · anthropic · https://open.bigmodel.cn/api/anthropic · 1 m
143
143
 
144
144
  ## 模型发现过滤
145
145
 
146
- `--discover` 从供应商 `/v1/models` 拉取模型 id,再从 models.dev 补全元数据。
146
+ `--discover` 从供应商 `/v1/models` 拉取模型 id,优先从 models.dev 补全元数据,必要时自动查询 AI Gateway。
147
147
  转售商列表噪音大,过滤规则持久化在供应商上,每次 `discover` 自动复用:
148
148
 
149
149
  ```bash
@@ -157,6 +157,69 @@ asw discover myproxy --no-filter # 清除规则
157
157
 
158
158
  手动 `--models` 指定的 id 和默认模型永不被过滤。
159
159
 
160
+ ## 自动补全模型元数据
161
+
162
+ **默认无需额外参数。** 新供应商和未保存模式的供应商使用 `auto`:先查询 models.dev,
163
+ 仅在核心参数(上下文窗口、最大输出、推理或图片输入能力)仍缺失、已有 Gateway 自动字段
164
+ 未被手动修改或 models.dev 替代而需刷新,或需核实旧自动值的身份冲突时,才查询公开的
165
+ [AI Gateway 模型目录](https://vercel.com/docs/ai-gateway/models-and-providers#dynamic-model-discovery)。
166
+ 仅缺名称、价格、可选输入上限或推理等级列表,不会触发 Gateway 查询。
167
+ 直接请求 `https://ai-gateway.vercel.sh/v1/models`,不携带供应商密钥,也不安装 AI SDK。
168
+
169
+ ```bash
170
+ # 按保存的模式刷新已有模型参数(未设置时为 auto)
171
+ # 不发现新模型、不写智能体配置
172
+ asw refresh --provider myproxy
173
+
174
+ # 查看生效模式、字段来源、冲突和参考价格;JSON 不含 API key
175
+ asw models --provider myproxy --metadata
176
+
177
+ # 自定义或有歧义的名称需显式映射;实际调用的模型 ID 不变
178
+ asw refresh --provider myproxy --metadata-mode auto \
179
+ --gateway-models '{"my-model":"openai/gpt-5.4"}'
180
+
181
+ # 即使核心参数完整,也始终查询 Gateway
182
+ asw refresh --provider myproxy --metadata-mode on
183
+
184
+ # 停止后续 Gateway 获取;保留已保存的参数
185
+ asw refresh --provider myproxy --metadata-mode off
186
+
187
+ # 将显式关闭的供应商恢复为自动模式
188
+ asw refresh --provider myproxy --metadata-mode auto
189
+
190
+ # 单独预览、同步;sync 不查询目录
191
+ asw sync --provider myproxy --dry-run
192
+ asw sync --provider myproxy
193
+ ```
194
+
195
+ 交互菜单的“设置并刷新模型参数补充源”提供“自动按需(推荐)”“始终补充”“关闭”,
196
+ 默认选中该供应商当前模式;添加供应商时不增加提问。
197
+ `add`、`quick`、`discover`、`import`、`refresh` 均支持 `--metadata-mode <auto|on|off>`。
198
+ 原有 `--gateway-metadata` / `--no-gateway-metadata` 仍分别表示显式 `on` / `off`。
199
+ 省略参数会保留已保存的设置:已有 `false` 仍保持关闭,直到主动选择 `auto` 或 `on`。
200
+ 无效模式或相互冲突的模式/布尔开关,会在查询或保存之前报错。
201
+ `refresh` 不传 `--provider` 会作用于全部供应商,包括更新传入的模式。
202
+ `--gateway-models` 替换整个映射,传 `{}` 清空;映射不会更改保存的模式,也不会覆盖 `off`。
203
+ 导入仍跳过已经配置的账号。
204
+
205
+ - **按字段合并:** 用户手动值优先,其次 models.dev,最后 Gateway 补缺。已记录来源的自动值可以刷新,
206
+ 手动修改后会保留;旧版本中没有来源记录的已有值保守视为手动值,不强制覆盖。未知扩展字段也保留。
207
+ - **精确匹配:** 不含 `/` 的裸 ID 可按大小写精确匹配唯一的 Gateway `creator/model` 身份,
208
+ 前提是 models.dev 没有身份线索或线索一致。任一目录中的创建者身份有歧义时拒绝自动匹配;
209
+ 自定义或有歧义的名称仍需显式映射。带命名空间的 ID 不会剥离前缀,不按子串、大小写折叠或版本相似度猜测。
210
+ `auto` / `on` 模式下 models.dev 也采用保守的精确匹配;`off` 保留原有 models.dev 查询行为。
211
+ 匹配失败不妨碍模型继续使用。修改/删除映射时,只清理确认已失效身份的自动值,保留手动修改。
212
+ - **不改变调用语义:** 目录不会给供应商增加模型,不改模型 ID、协议、URL、默认模型或账户;
213
+ Gateway 能力是参考规格,不保证转售商实际实现。`supported_specifications` 不是 URL 版本。
214
+ - **价格仅供参考:** Gateway 价格转换为美元/百万 token,单独保存在 `metadata.gateway.referenceCost`,
215
+ 不写入供应商的有效 `cost`,也不下发为智能体计费价格;分档/供应商差异另有标记。
216
+ 自动模式不会仅为刷新参考价格而查询:可查看 `metadata.gateway.fetchedAt` 判断快照时间,
217
+ 需要重新查询参考价格时使用 `refresh --provider <id> --metadata-mode on`。
218
+ - **可追溯、可降级:** `metadata.fields` 记录来源、原值快照和时间,`metadata.conflicts` 记录保留值与冲突。
219
+ 审计信息仅保存在 agentsw,不写入智能体运行配置。Gateway 目录独立缓存 24 小时;请求失败可使用旧缓存,无缓存时只跳过补充。
220
+ - **自动查询不等于自动同步:** 添加、快速添加、发现、导入、刷新时补全保存的元数据,并不新增自动推送。
221
+ 同步行为不变:普通 `sync` 仅写入已保存的设置,既不获取模型列表,也不请求元数据目录。
222
+
160
223
  ## 命令
161
224
 
162
225
  | 命令 | 作用 |
@@ -172,7 +235,8 @@ asw discover myproxy --no-filter # 清除规则
172
235
  | `sync` | 重新应用当前供应商(比如某个智能体升级之后) |
173
236
  | `discover <id> [--sync]` | 从 `/v1/models` 刷新模型列表与元数据 |
174
237
  | `models [query]` | 搜索 models.dev 目录 |
175
- | `refresh` | 重新拉取所有供应商的元数据 |
238
+ | `refresh [--provider <id>]` | 刷新已有模型参数,可设置 Gateway 补充源,不改变模型列表 |
239
+ | `models --provider <id> --metadata` | 以 JSON 查看字段来源、冲突和参考价格 |
176
240
  | `prune <id>` / `remove <id> [--prune]` | 从各应用配置中清除 / 从存储中删除 |
177
241
  | `remove <id> --apps omp` | 仅删除指定智能体内的条目,支持 `--dry-run` |
178
242
  | `apps` / `install <app>` / `upgrade` | 智能体版本管理 |
package/dist/commands.js CHANGED
@@ -7,14 +7,21 @@ import pc from "picocolors";
7
7
  import prompts from "prompts";
8
8
  import { loadStore, saveStore, getProvider, configFile } from "./store.js";
9
9
  import { scanCandidates, normalizeUrl, findMatchingProvider } from "./import.js";
10
- import { enrichModels, loadCatalog, searchCatalog } from "./modelsdev.js";
10
+ import { loadCatalog, searchCatalog } from "./modelsdev.js";
11
+ import { enrichProviderModels, getMetadataMode, resolveMetadataOptions } from "./metadata.js";
12
+ import { loadGatewayCatalog } from "./gateway.js";
11
13
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
12
14
  import { discoverProviderModels, probeProtocols } from "./discover.js";
13
15
  import { appCommand, appPackages, installedVersion, isNewer, latestVersion, normalizeAppVersion, runShell } from "./apps.js";
14
- import { drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
16
+ import { SafeConfigError, drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
15
17
  import { applyModelFilter } from "./filter.js";
16
18
  import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
17
19
  import { t } from "./i18n.js";
20
+ /** Share successes and failures without fetching until a model actually needs the supplement. */
21
+ function sharedGatewayLoader(refresh = false) {
22
+ let pending;
23
+ return () => pending ??= loadGatewayCatalog({ refresh }).then((catalog) => catalog ?? null);
24
+ }
18
25
  function fail(message) {
19
26
  process.stderr.write(pc.red(`error: ${message}\n`));
20
27
  process.exit(1);
@@ -58,16 +65,16 @@ function modelRows(models) {
58
65
  fmtTokens(m.contextWindow),
59
66
  fmtTokens(m.maxInput),
60
67
  fmtTokens(m.maxOutput),
61
- m.reasoning ? (m.reasoningEfforts?.join("/") ?? "yes") : "no",
68
+ m.reasoning === undefined ? "-" : m.reasoning ? (m.reasoningEfforts?.length ? m.reasoningEfforts.join("/") : "yes") : "no",
62
69
  m.cost ? `$${m.cost.input ?? "?"}/$${m.cost.output ?? "?"}` : "-",
63
70
  ]);
64
71
  }
65
72
  const MODEL_HEADER = ["MODEL", "CTX", "IN", "OUT", "REASONING", "$IN/$OUT per M"];
66
- /** Print a dim hint when some models have no models.dev metadata. */
73
+ /** Print a dim hint when some models have no catalog metadata. */
67
74
  function printUncatalogedHint(models) {
68
75
  const bare = models.filter((m) => m.contextWindow === undefined && !m.cost);
69
76
  if (bare.length > 0) {
70
- console.log(pc.dim(`${bare.length} model(s) have no models.dev metadata (shown as "-")`));
77
+ console.log(pc.dim(`${bare.length} model(s) have no catalog metadata (shown as "-")`));
71
78
  }
72
79
  }
73
80
  /** Guess the models.dev provider whose API host matches the configured baseUrl. */
@@ -113,6 +120,7 @@ function reportDropped(dropped) {
113
120
  }
114
121
  export async function cmdAdd(opts) {
115
122
  const store = loadStore();
123
+ resolveMetadataOptions(opts);
116
124
  const interactive = process.stdin.isTTY && !opts.yes;
117
125
  let answers = {};
118
126
  if (interactive) {
@@ -192,6 +200,7 @@ export async function cmdAdd(opts) {
192
200
  const existing = explicitId ? store.providers[explicitId] : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
193
201
  const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
194
202
  const openaiApi = protocol === "openai" ? wire ?? existing?.openaiApi : undefined;
203
+ const metadataOptions = resolveMetadataOptions(opts, existing);
195
204
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
196
205
  let modelIds = manualIds;
197
206
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
@@ -215,7 +224,7 @@ export async function cmdAdd(opts) {
215
224
  fail(t("add.atLeastOne"));
216
225
  const catalog = await loadCatalog();
217
226
  const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
218
- const models = enrichModels(catalog, modelIds, hint);
227
+ const models = await enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models });
219
228
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
220
229
  const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
221
230
  if (!modelIds.includes(defaultModel))
@@ -235,6 +244,7 @@ export async function cmdAdd(opts) {
235
244
  smallModel: opts.smallModel ?? existing?.smallModel,
236
245
  reasoningEffort: opts.reasoningEffort ?? existing?.reasoningEffort,
237
246
  modelsDevId: hint,
247
+ ...metadataOptions,
238
248
  modelFilter,
239
249
  };
240
250
  const existed = store.providers[provider.id] !== undefined;
@@ -252,9 +262,10 @@ export async function cmdAdd(opts) {
252
262
  /** Shared logic to create and save a single provider from discovered models. */
253
263
  async function createProvider(opts) {
254
264
  const { store, id, name, protocol, openaiApi, baseUrl, apiKey, modelIds, modelFilter } = opts;
265
+ const metadataOptions = resolveMetadataOptions(opts, opts.existing);
255
266
  const catalog = await loadCatalog();
256
267
  const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
257
- const models = enrichModels(catalog, modelIds, hint);
268
+ const models = await enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, { gatewayLoader: opts.gatewayLoader });
258
269
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
259
270
  const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
260
271
  if (!modelIds.includes(defaultModel))
@@ -275,6 +286,7 @@ async function createProvider(opts) {
275
286
  smallModel,
276
287
  reasoningEffort: opts.reasoningEffort ?? opts.existing?.reasoningEffort,
277
288
  modelsDevId: hint,
289
+ ...metadataOptions,
278
290
  modelFilter,
279
291
  };
280
292
  const existed = store.providers[provider.id] !== undefined;
@@ -295,6 +307,7 @@ async function createProvider(opts) {
295
307
  */
296
308
  export async function cmdQuickAdd(opts) {
297
309
  const store = loadStore();
310
+ resolveMetadataOptions(opts);
298
311
  const interactive = process.stdin.isTTY && !opts.yes;
299
312
  let baseUrl;
300
313
  let apiKey;
@@ -323,6 +336,7 @@ export async function cmdQuickAdd(opts) {
323
336
  fail(t("add.openaiApiInvalid"));
324
337
  const multi = protocols.length > 1;
325
338
  const createdIds = [];
339
+ const gatewayLoader = sharedGatewayLoader();
326
340
  for (const protocol of protocols) {
327
341
  const explicitId = opts.id === undefined ? undefined : `${opts.id}${multi ? `-${protocol}` : ""}`;
328
342
  const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : store.providers[explicitId];
@@ -357,6 +371,10 @@ export async function cmdQuickAdd(opts) {
357
371
  existing,
358
372
  openaiApi: opts.openaiApi,
359
373
  modelsDev: opts.modelsDev,
374
+ gatewayMetadata: opts.gatewayMetadata,
375
+ gatewayModels: opts.gatewayModels,
376
+ metadataMode: opts.metadataMode,
377
+ gatewayLoader,
360
378
  });
361
379
  createdIds.push(provider.id);
362
380
  console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
@@ -425,7 +443,8 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
425
443
  results.push(await target[op](provider));
426
444
  }
427
445
  catch (err) {
428
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(redactErrors ? "failed: configuration could not be previewed safely" : `failed: ${err.message}`) });
446
+ const safe = redactErrors && !(err instanceof SafeConfigError);
447
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? "configuration could not be previewed safely" : err.message}`) });
429
448
  process.exitCode = 1;
430
449
  }
431
450
  }
@@ -605,9 +624,21 @@ export function cmdStatus() {
605
624
  console.log(table(rows, ["APP", t("table.found"), t("table.protocols"), t("table.current"), t("table.config")]));
606
625
  }
607
626
  export async function cmdModels(query, opts) {
627
+ if (opts.metadata && !opts.provider)
628
+ fail("--metadata requires --provider");
608
629
  if (opts.provider) {
609
630
  const store = loadStore();
610
631
  const provider = getProvider(store, opts.provider);
632
+ if (opts.metadata) {
633
+ console.log(JSON.stringify({
634
+ provider: provider.id,
635
+ gatewayMetadata: provider.gatewayMetadata ?? "auto",
636
+ metadataMode: getMetadataMode(provider),
637
+ gatewayModelAliases: provider.gatewayModelAliases ?? {},
638
+ models: provider.models.map(({ id, metadata }) => ({ id, metadata })),
639
+ }, null, 2));
640
+ return;
641
+ }
611
642
  console.log(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
612
643
  console.log(table(modelRows(provider.models), MODEL_HEADER));
613
644
  printUncatalogedHint(provider.models);
@@ -630,20 +661,26 @@ export async function cmdModels(query, opts) {
630
661
  });
631
662
  console.log(table(rows, ["PROVIDER", ...MODEL_HEADER]));
632
663
  }
633
- export async function cmdRefreshMeta() {
664
+ export async function cmdRefreshMeta(opts = {}) {
634
665
  const store = loadStore();
666
+ const providers = opts.provider ? [getProvider(store, opts.provider)] : Object.values(store.providers);
667
+ resolveMetadataOptions(opts);
668
+ for (const provider of providers)
669
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
635
670
  const catalog = await loadCatalog({ refresh: true });
636
- if (!catalog)
637
- fail("models.dev catalog unavailable");
671
+ const gatewayLoader = sharedGatewayLoader(true);
638
672
  let updated = 0;
639
- for (const provider of Object.values(store.providers)) {
673
+ for (const provider of providers) {
640
674
  const before = JSON.stringify(provider.models);
641
- provider.models = enrichModels(catalog, provider.models.map((m) => m.id), provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl));
675
+ provider.models = await enrichProviderModels(catalog, provider.models.map((m) => m.id), {
676
+ ...provider,
677
+ modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl),
678
+ }, { gatewayLoader });
642
679
  if (JSON.stringify(provider.models) !== before)
643
680
  updated++;
644
681
  }
645
682
  saveStore(store);
646
- console.log(pc.green(`refreshed models.dev metadata (${updated} provider(s) changed)`));
683
+ console.log(pc.green(`checked model metadata (${updated} provider(s) changed)`));
647
684
  if (updated > 0)
648
685
  console.log(pc.dim("run `agentsw sync` to push updated metadata into app configs"));
649
686
  }
@@ -651,6 +688,7 @@ export async function cmdDiscover(id, opts) {
651
688
  resolveTargets(opts.apps);
652
689
  const store = loadStore();
653
690
  const provider = getProvider(store, id);
691
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
654
692
  // flags override and re-persist the filter; --no-filter clears it
655
693
  const flagFilter = parseFilterOpts(opts, provider.modelFilter);
656
694
  if (opts.filter === false)
@@ -659,17 +697,18 @@ export async function cmdDiscover(id, opts) {
659
697
  provider.modelFilter = flagFilter;
660
698
  process.stderr.write(pc.dim(`discovering models from ${provider.baseUrl} ...\n`));
661
699
  const listed = await discoverProviderModels(provider);
662
- const outcome = applyModelFilter(listed, provider.modelFilter, [provider.defaultModel]);
700
+ const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
701
+ const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
663
702
  reportDropped(outcome.dropped);
664
703
  const ids = outcome.kept;
665
704
  const known = provider.models.map((m) => m.id);
666
705
  const added = ids.filter((m) => !known.includes(m));
667
706
  const gone = known.filter((m) => !ids.includes(m));
668
707
  const catalog = await loadCatalog();
669
- provider.models = enrichModels(catalog, ids, provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl));
708
+ const retainedIds = [...new Set([...ids, ...pinned])];
709
+ provider.models = await enrichProviderModels(catalog, retainedIds, { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) });
670
710
  if (!ids.includes(provider.defaultModel)) {
671
711
  console.log(pc.yellow(`default model ${provider.defaultModel} no longer listed; keeping it anyway`));
672
- provider.models.push({ id: provider.defaultModel });
673
712
  }
674
713
  saveStore(store);
675
714
  console.log(`${pc.bold(id)}: ${ids.length} models (${pc.green(`+${added.length}`)} / ${pc.red(`-${gone.length}`)})` +
@@ -820,6 +859,7 @@ export async function cmdUpgrade(ids) {
820
859
  }
821
860
  /** Collect custom providers, dedupe by endpoint and credentials, and import what is new. */
822
861
  export async function cmdImport(opts) {
862
+ const metadataOptions = resolveMetadataOptions(opts);
823
863
  const rows = scanCandidates();
824
864
  const fresh = rows.filter((r) => !r.configured);
825
865
  for (const r of rows) {
@@ -864,6 +904,7 @@ export async function cmdImport(opts) {
864
904
  }
865
905
  const store = loadStore();
866
906
  const catalog = await loadCatalog();
907
+ const gatewayLoader = sharedGatewayLoader();
867
908
  const imported = [];
868
909
  for (const c of chosen) {
869
910
  let apiKey = c.apiKey;
@@ -901,7 +942,7 @@ export async function cmdImport(opts) {
901
942
  fail(t("import.noModelsImport", { id: c.id }));
902
943
  const id = availableProviderId(c.id, store.providers);
903
944
  const hint = guessProviderHint(catalog, c.baseUrl);
904
- const models = enrichModels(catalog, ids, hint);
945
+ const models = await enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader });
905
946
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
906
947
  const existed = store.providers[id] !== undefined;
907
948
  store.providers[id] = {
@@ -914,6 +955,7 @@ export async function cmdImport(opts) {
914
955
  models,
915
956
  defaultModel,
916
957
  modelsDevId: hint,
958
+ ...metadataOptions,
917
959
  };
918
960
  if (!store.active)
919
961
  store.active = id;
package/dist/fsutil.js CHANGED
@@ -3,6 +3,13 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { randomUUID } from "node:crypto";
6
+ /**
7
+ * An error whose message is guaranteed to carry no file content. Dry-run redacts
8
+ * unknown errors wholesale (parser messages can embed credentials), so config
9
+ * parsers must throw this to stay actionable there.
10
+ */
11
+ export class SafeConfigError extends Error {
12
+ }
6
13
  // AGENTSW_HOME is an explicit portable/test override. Native Windows otherwise
7
14
  // uses USERPROFILE/os.homedir(), while Unix follows HOME as expected.
8
15
  export const home = process.env.AGENTSW_HOME?.trim() ||
@@ -88,7 +95,13 @@ export function readJsonIfExists(file) {
88
95
  const text = readTextIfExists(file);
89
96
  if (text === undefined)
90
97
  return undefined;
91
- return JSON.parse(text);
98
+ try {
99
+ return JSON.parse(text);
100
+ }
101
+ catch {
102
+ // Node quotes surrounding input in the message, which may hold credentials.
103
+ throw new SafeConfigError(`${file}: invalid JSON configuration`);
104
+ }
92
105
  }
93
106
  /** Compatibility preview switch. Each target captures its value when its scoped work starts. */
94
107
  let dryRun = false;