agentsw 0.7.2 → 0.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/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).
@@ -146,8 +146,8 @@ use `--all` explicitly in scripts. Model discovery may update the local metadata
146
146
  ## Model discovery filters
147
147
 
148
148
  `--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`:
149
+ models.dev first, with automatic AI Gateway supplementation when needed. Reseller lists are noisy,
150
+ so filters are persisted per provider and re-applied on every `discover`:
151
151
 
152
152
  ```bash
153
153
  # snapshot duplicates (gpt-5.2-latest, glm-4.7-250414, ...) are DROPPED by default
@@ -160,6 +160,77 @@ asw discover myproxy --no-filter # clear it
160
160
 
161
161
  Explicit `--models` entries and the default model are never filtered out.
162
162
 
163
+ ## Automatic model metadata
164
+
165
+ **No extra flags are needed.** New providers and providers without a saved mode use `auto`:
166
+ models.dev is checked first, then the public
167
+ [AI Gateway model catalog](https://vercel.com/docs/ai-gateway/models-and-providers#dynamic-model-discovery)
168
+ is consulted only for missing core parameters (context window, maximum output, reasoning or image input),
169
+ to refresh unchanged Gateway-supplied fields that models.dev has not replaced, or to verify an identity
170
+ conflict in previously tracked automatic values. Missing names, prices, optional input limits or
171
+ reasoning-effort lists alone do not trigger a Gateway lookup.
172
+ agentsw directly fetches `https://ai-gateway.vercel.sh/v1/models` without provider credentials or an AI SDK dependency.
173
+
174
+ ```bash
175
+ # Refresh only saved models using the saved mode (auto if unset)
176
+ # No model discovery or agent config writes
177
+ asw refresh --provider myproxy
178
+
179
+ # Inspect effective mode, sources, conflicts and reference pricing (no API key)
180
+ asw models --provider myproxy --metadata
181
+
182
+ # Custom or ambiguous names need explicit aliases; wire model IDs stay unchanged
183
+ asw refresh --provider myproxy --metadata-mode auto \
184
+ --gateway-models '{"my-model":"openai/gpt-5.4"}'
185
+
186
+ # Always consult Gateway, even when core parameters are complete
187
+ asw refresh --provider myproxy --metadata-mode on
188
+
189
+ # Stop future Gateway fetches; retain saved model parameters
190
+ asw refresh --provider myproxy --metadata-mode off
191
+
192
+ # Return an explicitly disabled provider to automatic mode
193
+ asw refresh --provider myproxy --metadata-mode auto
194
+
195
+ # Preview and apply separately; sync does not query catalogs
196
+ asw sync --provider myproxy --dry-run
197
+ asw sync --provider myproxy
198
+ ```
199
+
200
+ The menu's **Configure and refresh model metadata** action offers **Automatic (recommended)**,
201
+ **Always supplement**, and **Disabled**, with the provider's current mode selected. Onboarding has no extra prompts.
202
+ `add`, `quick`, `discover`, `import`, and `refresh` accept `--metadata-mode <auto|on|off>`.
203
+ The existing `--gateway-metadata` and `--no-gateway-metadata` flags remain explicit `on` and `off` choices.
204
+ Omitted options preserve saved settings: an existing `false` remains off until you select `auto` or `on`.
205
+ Invalid modes and conflicting mode/boolean flags are rejected before fetching or saving.
206
+ Without `--provider`, `refresh` applies to all providers, including any supplied mode.
207
+ `--gateway-models` replaces the whole alias map (`{}` clears it); aliases do not change the saved mode or override `off`.
208
+ Import still skips already configured accounts.
209
+
210
+ - **Field-level priority:** manual values > models.dev > Gateway gap filling. Tracked automatic values may
211
+ refresh; subsequent manual edits are retained. Legacy values without provenance are conservatively treated
212
+ as manual overrides. Unmodeled extension fields survive enrichment too.
213
+ - **Exact identities:** automatic lookup can map a bare, case-sensitive ID (no `/`) to a unique Gateway
214
+ `creator/model` identity when models.dev evidence is absent or agrees. Ambiguous creator evidence in either
215
+ catalog is rejected; custom or ambiguous names need explicit aliases. Qualified IDs never have their prefixes
216
+ stripped, and matching never guesses from substrings, case folding or versions. Auto/on mode also uses conservative
217
+ exact models.dev matching; off retains the legacy models.dev lookup. Unmatched models remain usable.
218
+ Alias changes clear unchanged automatic values tied to confirmed superseded identities, never manual edits.
219
+ - **No routing changes:** metadata never adds available models or changes IDs, protocols, URLs, defaults or
220
+ accounts. Gateway capabilities are reference specs, not proof of reseller support. `supported_specifications`
221
+ is not a URL version.
222
+ - **Reference-only prices:** Gateway prices are converted to USD per million tokens and stored separately in
223
+ `metadata.gateway.referenceCost`, not effective provider `cost` or runtime agent pricing. Variable/tiered
224
+ pricing is flagged separately.
225
+ Auto does not fetch solely to refresh this reference snapshot: check `metadata.gateway.fetchedAt`, or use
226
+ `refresh --provider <id> --metadata-mode on` to request fresh reference prices.
227
+ - **Auditable and optional:** `metadata.fields` records origins, value snapshots and timestamps;
228
+ `metadata.conflicts` records disagreements with retained values. This audit stays in agentsw, never runtime agent configs.
229
+ Gateway has a separate 24-hour cache; failures use stale cached data or skip supplementation.
230
+ - **Automatic lookup is not automatic sync:** enrichment updates saved metadata during add/quick/discover/import/refresh.
231
+ It does not add an automatic push to agents. Sync behavior is unchanged: ordinary `sync` only writes saved settings
232
+ and fetches neither model lists nor metadata catalogs.
233
+
163
234
  ## Commands
164
235
 
165
236
  | Command | What it does |
@@ -175,7 +246,8 @@ Explicit `--models` entries and the default model are never filtered out.
175
246
  | `sync` | re-apply the active provider (after an agent update, say) |
176
247
  | `discover <id> [--sync]` | refresh the model list + metadata from `/v1/models` |
177
248
  | `models [query]` | search the models.dev catalog |
178
- | `refresh` | re-fetch metadata for every configured provider |
249
+ | `refresh [--provider <id>]` | refresh saved metadata and optionally configure Gateway; keep the model list |
250
+ | `models --provider <id> --metadata` | inspect field sources, conflicts and reference pricing as JSON |
179
251
  | `prune <id>` / `remove <id> [--prune]` | remove from app configs / from the store |
180
252
  | `remove <id> --apps omp` | delete only the selected app's entry; supports `--dry-run` |
181
253
  | `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,7 +7,9 @@ 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";
@@ -15,6 +17,11 @@ import { 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 })}`));
@@ -605,9 +623,21 @@ export function cmdStatus() {
605
623
  console.log(table(rows, ["APP", t("table.found"), t("table.protocols"), t("table.current"), t("table.config")]));
606
624
  }
607
625
  export async function cmdModels(query, opts) {
626
+ if (opts.metadata && !opts.provider)
627
+ fail("--metadata requires --provider");
608
628
  if (opts.provider) {
609
629
  const store = loadStore();
610
630
  const provider = getProvider(store, opts.provider);
631
+ if (opts.metadata) {
632
+ console.log(JSON.stringify({
633
+ provider: provider.id,
634
+ gatewayMetadata: provider.gatewayMetadata ?? "auto",
635
+ metadataMode: getMetadataMode(provider),
636
+ gatewayModelAliases: provider.gatewayModelAliases ?? {},
637
+ models: provider.models.map(({ id, metadata }) => ({ id, metadata })),
638
+ }, null, 2));
639
+ return;
640
+ }
611
641
  console.log(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
612
642
  console.log(table(modelRows(provider.models), MODEL_HEADER));
613
643
  printUncatalogedHint(provider.models);
@@ -630,20 +660,26 @@ export async function cmdModels(query, opts) {
630
660
  });
631
661
  console.log(table(rows, ["PROVIDER", ...MODEL_HEADER]));
632
662
  }
633
- export async function cmdRefreshMeta() {
663
+ export async function cmdRefreshMeta(opts = {}) {
634
664
  const store = loadStore();
665
+ const providers = opts.provider ? [getProvider(store, opts.provider)] : Object.values(store.providers);
666
+ resolveMetadataOptions(opts);
667
+ for (const provider of providers)
668
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
635
669
  const catalog = await loadCatalog({ refresh: true });
636
- if (!catalog)
637
- fail("models.dev catalog unavailable");
670
+ const gatewayLoader = sharedGatewayLoader(true);
638
671
  let updated = 0;
639
- for (const provider of Object.values(store.providers)) {
672
+ for (const provider of providers) {
640
673
  const before = JSON.stringify(provider.models);
641
- provider.models = enrichModels(catalog, provider.models.map((m) => m.id), provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl));
674
+ provider.models = await enrichProviderModels(catalog, provider.models.map((m) => m.id), {
675
+ ...provider,
676
+ modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl),
677
+ }, { gatewayLoader });
642
678
  if (JSON.stringify(provider.models) !== before)
643
679
  updated++;
644
680
  }
645
681
  saveStore(store);
646
- console.log(pc.green(`refreshed models.dev metadata (${updated} provider(s) changed)`));
682
+ console.log(pc.green(`checked model metadata (${updated} provider(s) changed)`));
647
683
  if (updated > 0)
648
684
  console.log(pc.dim("run `agentsw sync` to push updated metadata into app configs"));
649
685
  }
@@ -651,6 +687,7 @@ export async function cmdDiscover(id, opts) {
651
687
  resolveTargets(opts.apps);
652
688
  const store = loadStore();
653
689
  const provider = getProvider(store, id);
690
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
654
691
  // flags override and re-persist the filter; --no-filter clears it
655
692
  const flagFilter = parseFilterOpts(opts, provider.modelFilter);
656
693
  if (opts.filter === false)
@@ -659,17 +696,18 @@ export async function cmdDiscover(id, opts) {
659
696
  provider.modelFilter = flagFilter;
660
697
  process.stderr.write(pc.dim(`discovering models from ${provider.baseUrl} ...\n`));
661
698
  const listed = await discoverProviderModels(provider);
662
- const outcome = applyModelFilter(listed, provider.modelFilter, [provider.defaultModel]);
699
+ const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
700
+ const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
663
701
  reportDropped(outcome.dropped);
664
702
  const ids = outcome.kept;
665
703
  const known = provider.models.map((m) => m.id);
666
704
  const added = ids.filter((m) => !known.includes(m));
667
705
  const gone = known.filter((m) => !ids.includes(m));
668
706
  const catalog = await loadCatalog();
669
- provider.models = enrichModels(catalog, ids, provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl));
707
+ const retainedIds = [...new Set([...ids, ...pinned])];
708
+ provider.models = await enrichProviderModels(catalog, retainedIds, { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) });
670
709
  if (!ids.includes(provider.defaultModel)) {
671
710
  console.log(pc.yellow(`default model ${provider.defaultModel} no longer listed; keeping it anyway`));
672
- provider.models.push({ id: provider.defaultModel });
673
711
  }
674
712
  saveStore(store);
675
713
  console.log(`${pc.bold(id)}: ${ids.length} models (${pc.green(`+${added.length}`)} / ${pc.red(`-${gone.length}`)})` +
@@ -820,6 +858,7 @@ export async function cmdUpgrade(ids) {
820
858
  }
821
859
  /** Collect custom providers, dedupe by endpoint and credentials, and import what is new. */
822
860
  export async function cmdImport(opts) {
861
+ const metadataOptions = resolveMetadataOptions(opts);
823
862
  const rows = scanCandidates();
824
863
  const fresh = rows.filter((r) => !r.configured);
825
864
  for (const r of rows) {
@@ -864,6 +903,7 @@ export async function cmdImport(opts) {
864
903
  }
865
904
  const store = loadStore();
866
905
  const catalog = await loadCatalog();
906
+ const gatewayLoader = sharedGatewayLoader();
867
907
  const imported = [];
868
908
  for (const c of chosen) {
869
909
  let apiKey = c.apiKey;
@@ -901,7 +941,7 @@ export async function cmdImport(opts) {
901
941
  fail(t("import.noModelsImport", { id: c.id }));
902
942
  const id = availableProviderId(c.id, store.providers);
903
943
  const hint = guessProviderHint(catalog, c.baseUrl);
904
- const models = enrichModels(catalog, ids, hint);
944
+ const models = await enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader });
905
945
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
906
946
  const existed = store.providers[id] !== undefined;
907
947
  store.providers[id] = {
@@ -914,6 +954,7 @@ export async function cmdImport(opts) {
914
954
  models,
915
955
  defaultModel,
916
956
  modelsDevId: hint,
957
+ ...metadataOptions,
917
958
  };
918
959
  if (!store.active)
919
960
  store.active = id;
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readJsonIfExists, writeFileAtomic } from "./fsutil.js";
4
+ import { configDir } from "./store.js";
5
+ const API_URL = "https://ai-gateway.vercel.sh/v1/models";
6
+ const CACHE_FILE = path.join(configDir, "ai-gateway.json");
7
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
9
+ const REASONING_EFFORTS = {
10
+ none: true, minimal: true, low: true, medium: true, high: true, xhigh: true, max: true,
11
+ };
12
+ const PRICE_FIELDS = [
13
+ ["input", "input"],
14
+ ["output", "output"],
15
+ ["cachedInputTokens", "cacheRead"],
16
+ ["cacheCreationInputTokens", "cacheWrite"],
17
+ ];
18
+ function isStringList(value) {
19
+ return Array.isArray(value) && value.length > 0 &&
20
+ value.every((entry) => typeof entry === "string" && entry.length > 0 && entry.trim() === entry);
21
+ }
22
+ function reasoningEfforts(options) {
23
+ if (!Array.isArray(options))
24
+ return undefined;
25
+ let effort;
26
+ for (const option of options) {
27
+ if (!option || typeof option !== "object" || Array.isArray(option) || !("type" in option) || option.type !== "effort")
28
+ continue;
29
+ if (effort)
30
+ return undefined; // Conflicting declarations are not an authoritative effort list.
31
+ effort = option;
32
+ }
33
+ const values = effort?.values;
34
+ if (!isStringList(values) || !values.every((value) => Object.hasOwn(REASONING_EFFORTS, value)))
35
+ return undefined;
36
+ return [...new Set(values)];
37
+ }
38
+ function perMillion(value) {
39
+ if (typeof value === "string") {
40
+ const text = value.trim();
41
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(text))
42
+ return undefined;
43
+ value = Number(text);
44
+ }
45
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
46
+ return undefined;
47
+ const converted = value * 1_000_000;
48
+ return Number.isFinite(converted) ? converted : undefined;
49
+ }
50
+ /** Normalize public REST metadata without interpreting SDK versions as provider routing. */
51
+ export function parseGatewayCatalog(body, fetchedAt) {
52
+ if (!body || typeof body !== "object" || Array.isArray(body) || !("data" in body) || !Array.isArray(body.data)) {
53
+ throw new Error("Invalid AI Gateway catalog");
54
+ }
55
+ const models = Object.create(null);
56
+ const seen = new Set();
57
+ for (const entry of body.data) {
58
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
59
+ continue;
60
+ const row = entry;
61
+ if (typeof row.id !== "string" || !/^[^/\s]+(?:\/[^/\s]+)+$/.test(row.id))
62
+ continue;
63
+ if (seen.has(row.id))
64
+ throw new Error("Duplicate AI Gateway model identity");
65
+ seen.add(row.id);
66
+ if (row.type !== "language")
67
+ continue;
68
+ const spec = { id: row.id };
69
+ if (typeof row.name === "string" && row.name.trim())
70
+ spec.name = row.name;
71
+ const context = typeof row.context_window === "number" && Number.isSafeInteger(row.context_window) && row.context_window > 0
72
+ ? row.context_window : undefined;
73
+ const output = typeof row.max_tokens === "number" && Number.isSafeInteger(row.max_tokens) && row.max_tokens > 0
74
+ ? row.max_tokens : undefined;
75
+ // Neither member of an inconsistent pair is safe to treat as authoritative.
76
+ if (context === undefined || output === undefined || output <= context) {
77
+ if (context !== undefined)
78
+ spec.contextWindow = context;
79
+ if (output !== undefined)
80
+ spec.maxOutput = output;
81
+ }
82
+ const efforts = reasoningEfforts(row.reasoning_options);
83
+ if (efforts)
84
+ spec.reasoningEfforts = efforts;
85
+ if ((isStringList(row.tags) && row.tags.includes("reasoning")) || efforts?.some((effort) => effort !== "none")) {
86
+ spec.reasoning = true;
87
+ }
88
+ if (row.modalities && typeof row.modalities === "object" && !Array.isArray(row.modalities) &&
89
+ "input" in row.modalities && isStringList(row.modalities.input)) {
90
+ spec.imageInput = row.modalities.input.includes("image");
91
+ }
92
+ const model = { id: row.id, spec };
93
+ if (row.pricing && typeof row.pricing === "object" && !Array.isArray(row.pricing)) {
94
+ const pricing = row.pricing;
95
+ const referenceCost = {};
96
+ for (const [source, target] of PRICE_FIELDS) {
97
+ const price = perMillion(pricing[source]);
98
+ if (price !== undefined)
99
+ referenceCost[target] = price;
100
+ }
101
+ if (Object.keys(referenceCost).length)
102
+ model.referenceCost = referenceCost;
103
+ if ((Array.isArray(pricing.inputTiers) && pricing.inputTiers.length > 0) ||
104
+ (Array.isArray(pricing.outputTiers) && pricing.outputTiers.length > 0)) {
105
+ model.pricingIsVariable = true;
106
+ }
107
+ else if (typeof pricing.varies_by_provider === "boolean") {
108
+ model.pricingIsVariable = pricing.varies_by_provider;
109
+ }
110
+ }
111
+ models[row.id] = model;
112
+ }
113
+ return { fetchedAt, models };
114
+ }
115
+ function readCache() {
116
+ try {
117
+ // Bound local reads as well; the persisted envelope is only slightly larger than the response.
118
+ if (fs.statSync(CACHE_FILE).size > MAX_BODY_BYTES + 1024)
119
+ return undefined;
120
+ const cache = readJsonIfExists(CACHE_FILE);
121
+ if (!cache || typeof cache !== "object" || Array.isArray(cache) ||
122
+ !("version" in cache) || cache.version !== 1 ||
123
+ !("fetchedAt" in cache) || typeof cache.fetchedAt !== "string" || !("body" in cache))
124
+ return undefined;
125
+ const timestamp = Date.parse(cache.fetchedAt);
126
+ if (!Number.isFinite(timestamp) || timestamp > Date.now() || new Date(timestamp).toISOString() !== cache.fetchedAt) {
127
+ return undefined;
128
+ }
129
+ return parseGatewayCatalog(cache.body, cache.fetchedAt);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ async function readResponseBody(response) {
136
+ const contentLength = response.headers.get("content-length");
137
+ if (contentLength !== null && Number(contentLength) > MAX_BODY_BYTES) {
138
+ await response.body?.cancel().catch(() => { });
139
+ throw new Error("AI Gateway catalog exceeds size limit");
140
+ }
141
+ if (!response.body)
142
+ throw new Error("Missing AI Gateway catalog body");
143
+ const reader = response.body.getReader();
144
+ const decoder = new TextDecoder();
145
+ const chunks = [];
146
+ let size = 0;
147
+ try {
148
+ for (;;) {
149
+ const { done, value } = await reader.read();
150
+ if (done)
151
+ break;
152
+ size += value.byteLength;
153
+ if (size > MAX_BODY_BYTES)
154
+ throw new Error("AI Gateway catalog exceeds size limit");
155
+ chunks.push(decoder.decode(value, { stream: true }));
156
+ }
157
+ chunks.push(decoder.decode());
158
+ return chunks.join("");
159
+ }
160
+ catch (error) {
161
+ await reader.cancel().catch(() => { });
162
+ throw error;
163
+ }
164
+ finally {
165
+ reader.releaseLock();
166
+ }
167
+ }
168
+ /** Optional supplement: offline never fetches, and every failure leaves discovery usable. */
169
+ export async function loadGatewayCatalog(opts = {}) {
170
+ const cached = readCache();
171
+ if (opts.offline)
172
+ return cached;
173
+ if (!opts.refresh && cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS)
174
+ return cached;
175
+ try {
176
+ const response = await fetch(API_URL, {
177
+ signal: AbortSignal.timeout(15_000),
178
+ credentials: "omit",
179
+ redirect: "error",
180
+ });
181
+ if (!response.ok) {
182
+ await response.body?.cancel().catch(() => { });
183
+ throw new Error("AI Gateway catalog request failed");
184
+ }
185
+ const body = JSON.parse(await readResponseBody(response));
186
+ const catalog = parseGatewayCatalog(body, new Date().toISOString());
187
+ try {
188
+ writeFileAtomic(CACHE_FILE, JSON.stringify({ version: 1, fetchedAt: catalog.fetchedAt, body }) + "\n", 0o600);
189
+ }
190
+ catch {
191
+ process.stderr.write("warning: could not cache AI Gateway metadata\n");
192
+ }
193
+ return catalog;
194
+ }
195
+ catch {
196
+ process.stderr.write(cached
197
+ ? "warning: could not refresh AI Gateway metadata; using cached metadata\n"
198
+ : "warning: could not load AI Gateway metadata\n");
199
+ return cached;
200
+ }
201
+ }