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/dist/i18n.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const messages = {
2
2
  "root.description": {
3
- en: "Manage OpenAI/Anthropic-protocol model providers and sync them into coding agents\n(Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy, DeepSeek Harness).\nModel metadata (context window, input/output limits, reasoning levels) is enriched from\nmodels.dev; existing providers can be imported from the agents and from cc-switch.\n\nRun without arguments (or via `npx agentsw`) for the interactive menu.",
4
- "zh-CN": "管理 OpenAI / Anthropic 协议的模型供应商并同步到编码智能体\n(Claude Code、Codex、omp、pi、prime-agent、opencode、Hermes、WorkBuddy、DeepSeek Harness)。\n模型元数据(上下文、输入/输出上限、推理等级)由 models.dev 补全;\n也可从各智能体与 cc-switch 导入已有供应商。\n\n不带参数运行(或 `npx agentsw`)即可打开交互菜单。",
3
+ en: "Manage OpenAI/Anthropic-protocol model providers and sync them into coding agents\n(Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy, DeepSeek Harness).\nModel metadata (context window, input/output limits, reasoning levels) is enriched from\nmodels.dev first, with automatic AI Gateway supplementation when needed.\nExisting providers can be imported from the agents and from cc-switch.\n\nRun without arguments (or via `npx agentsw`) for the interactive menu.",
4
+ "zh-CN": "管理 OpenAI / Anthropic 协议的模型供应商并同步到编码智能体\n(Claude Code、Codex、omp、pi、prime-agent、opencode、Hermes、WorkBuddy、DeepSeek Harness)。\n模型元数据(上下文、输入/输出上限、推理等级)优先由 models.dev 补全,必要时自动查询 AI Gateway;\n也可从各智能体与 cc-switch 导入已有供应商。\n\n不带参数运行(或 `npx agentsw`)即可打开交互菜单。",
5
5
  },
6
6
  "help.language": { en: "UI language: en | zh-CN (also AGENTSW_LANG)", "zh-CN": "界面语言: en | zh-CN (也可用 AGENTSW_LANG)" },
7
7
  "error.language": { en: "unsupported language \"{value}\"; use en or zh-CN", "zh-CN": "不支持语言 \"{value}\";请使用 en 或 zh-CN" },
@@ -16,12 +16,12 @@ const messages = {
16
16
  "cmd.sync": { en: "re-apply the active provider (or --provider) to app configs", "zh-CN": "将当前供应商(或 --provider)重新应用到应用配置" },
17
17
  "cmd.status": { en: "show detected apps and what they currently point at", "zh-CN": "显示检测到的应用及当前供应商" },
18
18
  "cmd.models": { en: "search models.dev, or show a configured provider's models", "zh-CN": "搜索 models.dev 或显示已配置供应商的模型" },
19
- "cmd.discover": { en: "refresh a provider's models via /v1/models and models.dev", "zh-CN": "通过 /v1/models 和 models.dev 刷新供应商模型" },
19
+ "cmd.discover": { en: "refresh a provider's model list and configured metadata sources", "zh-CN": "获取供应商模型列表,并通过已启用的目录补全参数" },
20
20
  "cmd.import": { en: "scan app configs, preview and dedupe existing custom providers, then import", "zh-CN": "扫描应用配置,预览并去重已有自定义供应商后导入" },
21
21
  "cmd.apps": { en: "check installed agents: current version vs latest", "zh-CN": "检查智能体已安装版本与最新版本" },
22
22
  "cmd.install": { en: "install an agent CLI", "zh-CN": "安装智能体 CLI" },
23
23
  "cmd.upgrade": { en: "upgrade agent CLIs (no args = everything outdated)", "zh-CN": "升级智能体 CLI(无参数时升级全部过期项)" },
24
- "cmd.refresh": { en: "re-fetch models.dev metadata for all providers", "zh-CN": "重新拉取所有供应商的 models.dev 元数据" },
24
+ "cmd.refresh": { en: "refresh metadata for saved models; optionally configure Gateway supplementation", "zh-CN": "刷新已有模型参数,可设置 Gateway 补充源(不改变模型列表)" },
25
25
  "opt.id": { en: "custom provider id (default: full hostname + protocol)", "zh-CN": "自定义供应商 ID(默认:完整域名 + 协议)" },
26
26
  "opt.name": { en: "display name", "zh-CN": "显示名称" },
27
27
  "opt.protocol": { en: "wire protocol: openai | anthropic", "zh-CN": "接口协议: openai | anthropic" },
@@ -41,6 +41,12 @@ const messages = {
41
41
  "opt.dedup": { en: "deduplicate model snapshot aliases", "zh-CN": "合并重复的模型快照别名" },
42
42
  "opt.noDedup": { en: "keep snapshot duplicates (-latest/date suffixes)", "zh-CN": "保留快照重复项(-latest/日期后缀)" },
43
43
  "opt.modelsDev": { en: "models.dev provider id for metadata matching", "zh-CN": "用于元数据匹配的 models.dev 供应商 id" },
44
+ "opt.metadataMode": { en: "save Gateway mode: auto (default, only when needed), on, or off; omission keeps saved mode", "zh-CN": "保存 Gateway 模式:auto(默认,按需补缺)、on 或 off;省略则沿用已保存模式" },
45
+ "opt.gatewayMetadata": { en: "always supplement from AI Gateway (same as --metadata-mode on)", "zh-CN": "始终使用 AI Gateway 补缺(等同 --metadata-mode on)" },
46
+ "opt.noGatewayMetadata": { en: "disable Gateway fetching; keep saved parameters (same as --metadata-mode off)", "zh-CN": "关闭 Gateway 获取,保留已保存参数(等同 --metadata-mode off)" },
47
+ "opt.gatewayModels": { en: 'replace exact aliases as JSON: {"local-id":"creator/model"}; {} clears them', "zh-CN": '用 JSON 替换精确映射:{"本地模型ID":"creator/model"};{} 清空' },
48
+ "opt.metadataDetails": { en: "show field sources, conflicts and Gateway reference pricing as JSON (requires --provider)", "zh-CN": "以 JSON 查看字段来源、冲突和 Gateway 参考价格(需 --provider)" },
49
+ "opt.refreshProvider": { en: "refresh/configure only this saved provider (default: all)", "zh-CN": "仅刷新和设置该供应商(默认全部)" },
44
50
  "opt.yes": { en: "non-interactive; require all flags", "zh-CN": "非交互模式;要求提供全部参数" },
45
51
  "opt.prune": { en: "also remove entries from app configs", "zh-CN": "同时从应用配置中移除条目" },
46
52
  "opt.apps": { en: "comma-separated apps or 'all'", "zh-CN": "逗号分隔的应用或 'all'" },
@@ -75,6 +81,16 @@ const messages = {
75
81
  "menu.list": { en: "View providers saved in agentsw", "zh-CN": "查看 agentsw 供应商列表" },
76
82
  "menu.sync": { en: "Re-sync the current provider to agents", "zh-CN": "重新同步当前供应商" },
77
83
  "menu.discover": { en: "Update a provider's model list", "zh-CN": "更新供应商模型列表" },
84
+ "menu.metadata": { en: "Configure and refresh model metadata", "zh-CN": "设置并刷新模型参数补充源" },
85
+ "menu.metadataHelp": { en: "Keep the model list; choose automatic, always-on or disabled AI Gateway lookup, then refresh saved parameters only.", "zh-CN": "保留模型列表,选择 AI Gateway 自动按需、始终补充或关闭,仅刷新保存的参数。" },
86
+ "menu.metadataProvider": { en: "which provider's metadata settings should be updated?", "zh-CN": "选择要设置模型参数补充源的供应商" },
87
+ "menu.metadataMode": { en: "AI Gateway lookup mode (no API key is sent)", "zh-CN": "AI Gateway 查询模式(请求不携带 API key)" },
88
+ "menu.metadataAuto": { en: "Automatic (recommended)", "zh-CN": "自动按需(推荐)" },
89
+ "menu.metadataAutoHelp": { en: "models.dev first; query Gateway for missing core specs, to refresh its saved fields or verify tracked identity conflicts.", "zh-CN": "优先 models.dev;仅在核心参数缺失、需刷新已有 Gateway 字段或核实旧自动值身份冲突时查询。" },
90
+ "menu.metadataOn": { en: "Always supplement", "zh-CN": "始终补充" },
91
+ "menu.metadataOnHelp": { en: "Check Gateway on every metadata lookup; models.dev still takes priority.", "zh-CN": "每次补全参数都查询 Gateway,仍以 models.dev 为优先。" },
92
+ "menu.metadataOff": { en: "Disabled", "zh-CN": "关闭" },
93
+ "menu.metadataOffHelp": { en: "Never query Gateway; keep previously saved model parameters.", "zh-CN": "不查询 Gateway,保留已保存的模型参数。" },
78
94
  "menu.remove": { en: "Delete provider configuration", "zh-CN": "删除供应商配置" },
79
95
  "menu.rename": { en: "Change a provider ID", "zh-CN": "修改供应商 ID" },
80
96
  "menu.renameProvider": { en: "which provider ID should be changed?", "zh-CN": "选择要修改 ID 的供应商" },
@@ -172,7 +188,7 @@ const messages = {
172
188
  "add.added": { en: "added", "zh-CN": "已添加" },
173
189
  "add.updated": { en: "updated", "zh-CN": "已更新" },
174
190
  "add.saved": { en: "{status} provider {id} ({protocol})", "zh-CN": "{status}供应商 {id} ({protocol})" },
175
- "add.metadata": { en: "models.dev metadata: {matched}/{total} models matched", "zh-CN": "models.dev 元数据:匹配 {matched}/{total} 个模型" },
191
+ "add.metadata": { en: "model metadata: {matched}/{total} models have a context limit", "zh-CN": "模型参数:{matched}/{total} 个模型已有上下文上限" },
176
192
  "add.providerHint": { en: " (provider hint: {hint})", "zh-CN": " (供应商提示:{hint})" },
177
193
  "add.next": { en: "next: agentsw use {id}", "zh-CN": "下一步: agentsw use {id}" },
178
194
  "quick.probing": { en: "probing protocols at {url} ...", "zh-CN": "正在探测 {url} 支持的协议..." },
@@ -186,7 +202,7 @@ const messages = {
186
202
  "menu.useHelp": { en: "Select a provider and default model, then write them to compatible agent configs.", "zh-CN": "选择供应商和默认模型,然后写入支持该协议的智能体配置。" },
187
203
  "menu.statusHelp": { en: "Show detected agents, their current providers and config file locations.", "zh-CN": "查看检测到的智能体、所用供应商及配置文件位置。" },
188
204
  "menu.listHelp": { en: "Show only the providers, protocols and default models saved in agentsw.", "zh-CN": "只查看 agentsw 已保存的供应商、协议和默认模型。" },
189
- "menu.syncHelp": { en: "Write the current agentsw provider to agent configs without fetching a new model list.", "zh-CN": "将 agentsw 当前供应商写入智能体配置,不重新获取模型列表。" },
205
+ "menu.syncHelp": { en: "Write the current agentsw provider to agent configs without fetching a model list or metadata catalogs.", "zh-CN": "将 agentsw 当前供应商写入智能体配置,不重新获取模型列表或元数据目录。" },
190
206
  "menu.discoverHelp": { en: "Fetch models from the provider, fill in model metadata, and optionally sync to agents.", "zh-CN": "从供应商接口重新获取模型并补全模型参数,可选择同步到智能体。" },
191
207
  "menu.renameHelp": { en: "Change the config ID and update references; keep custom display names.", "zh-CN": "修改配置中的标识并更新关联引用,保留自定义显示名称。" },
192
208
  "menu.removeHelp": { en: "Choose where to delete: agentsw only, one agent only, or both.", "zh-CN": "先选择删除范围:仅 agentsw、仅某个智能体,或两处一起。" },
package/dist/index.js CHANGED
@@ -63,6 +63,10 @@ program
63
63
  .option("--dedup", t("opt.dedup"))
64
64
  .option("--no-dedup", t("opt.noDedup"))
65
65
  .option("--models-dev <id>", t("opt.modelsDev"))
66
+ .option("--metadata-mode <auto|on|off>", t("opt.metadataMode"))
67
+ .option("--gateway-metadata", t("opt.gatewayMetadata"))
68
+ .option("--no-gateway-metadata", t("opt.noGatewayMetadata"))
69
+ .option("--gateway-models <json>", t("opt.gatewayModels"))
66
70
  .option("-y, --yes", t("opt.yes"))
67
71
  .action(cmdAdd);
68
72
  program
@@ -72,6 +76,10 @@ program
72
76
  .option("--name <name>", t("opt.name"))
73
77
  .option("--openai-api <flavor>", t("opt.openaiApi"))
74
78
  .option("--models-dev <id>", t("opt.modelsDev"))
79
+ .option("--metadata-mode <auto|on|off>", t("opt.metadataMode"))
80
+ .option("--gateway-metadata", t("opt.gatewayMetadata"))
81
+ .option("--no-gateway-metadata", t("opt.noGatewayMetadata"))
82
+ .option("--gateway-models <json>", t("opt.gatewayModels"))
75
83
  .option("--base-url <url>", t("opt.baseUrl"))
76
84
  .option("--api-key <key>", t("opt.apiKey"))
77
85
  .option("--default-model <id>", t("opt.defaultModel"))
@@ -126,6 +134,7 @@ program
126
134
  .command("models [query]")
127
135
  .description(t("cmd.models"))
128
136
  .option("-p, --provider <id>", t("opt.showProvider"))
137
+ .option("--metadata", t("opt.metadataDetails"))
129
138
  .option("-r, --refresh", t("opt.refresh"))
130
139
  .option("-l, --limit <n>", t("opt.limit"), "30")
131
140
  .action(cmdModels);
@@ -139,16 +148,32 @@ program
139
148
  .option("--dedup", t("opt.dedup"))
140
149
  .option("--no-dedup", t("opt.noDedup"))
141
150
  .option("--no-filter", t("opt.noFilter"))
151
+ .option("--metadata-mode <auto|on|off>", t("opt.metadataMode"))
152
+ .option("--gateway-metadata", t("opt.gatewayMetadata"))
153
+ .option("--no-gateway-metadata", t("opt.noGatewayMetadata"))
154
+ .option("--gateway-models <json>", t("opt.gatewayModels"))
142
155
  .action(cmdDiscover);
143
156
  program
144
157
  .command("import")
145
158
  .description(t("cmd.import"))
146
159
  .option("--all", t("opt.all"))
160
+ .option("--metadata-mode <auto|on|off>", t("opt.metadataMode"))
161
+ .option("--gateway-metadata", t("opt.gatewayMetadata"))
162
+ .option("--no-gateway-metadata", t("opt.noGatewayMetadata"))
163
+ .option("--gateway-models <json>", t("opt.gatewayModels"))
147
164
  .action(cmdImport);
148
165
  program.command("apps").description(t("cmd.apps")).action(cmdApps);
149
166
  program.command("install <app>").description(t("cmd.install")).action(cmdInstall);
150
167
  program.command("upgrade [apps...]").description(t("cmd.upgrade")).action(cmdUpgrade);
151
- program.command("refresh").description(t("cmd.refresh")).action(cmdRefreshMeta);
168
+ program
169
+ .command("refresh")
170
+ .description(t("cmd.refresh"))
171
+ .option("-p, --provider <id>", t("opt.refreshProvider"))
172
+ .option("--metadata-mode <auto|on|off>", t("opt.metadataMode"))
173
+ .option("--gateway-metadata", t("opt.gatewayMetadata"))
174
+ .option("--no-gateway-metadata", t("opt.noGatewayMetadata"))
175
+ .option("--gateway-models <json>", t("opt.gatewayModels"))
176
+ .action(cmdRefreshMeta);
152
177
  program.parseAsync().catch((err) => {
153
178
  process.stderr.write(`error: ${err.message}\n`);
154
179
  process.exit(1);
package/dist/menu.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import pc from "picocolors";
2
2
  import prompts from "prompts";
3
- import { cmdAdd, cmdQuickAdd, cmdApps, cmdInstall, cmdDiscover, cmdImport, cmdList, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
3
+ import { cmdAdd, cmdQuickAdd, cmdApps, cmdInstall, cmdDiscover, cmdImport, cmdRefreshMeta, cmdList, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
4
4
  import { getLocale, setLocale, t } from "./i18n.js";
5
+ import { getMetadataMode } from "./metadata.js";
5
6
  import { loadStore, saveStore } from "./store.js";
6
7
  import { appPackages, appCommand, installedVersion } from "./apps.js";
7
8
  import { cmdRemoveProvider, cmdRename } from "./provider-actions.js";
@@ -166,6 +167,7 @@ export async function cmdMenu() {
166
167
  { title: t("menu.list"), description: t("menu.listHelp"), value: "list" },
167
168
  { title: t("menu.sync"), description: t("menu.syncHelp"), value: "sync" },
168
169
  { title: t("menu.discover"), description: t("menu.discoverHelp"), value: "discover" },
170
+ { title: t("menu.metadata"), description: t("menu.metadataHelp"), value: "metadata" },
169
171
  { title: t("menu.rename"), description: t("menu.renameHelp"), value: "rename" },
170
172
  { title: t("menu.remove"), description: t("menu.removeHelp"), value: "remove" },
171
173
  { title: t("menu.apps"), description: t("menu.appsHelp"), value: "apps" },
@@ -227,6 +229,25 @@ export async function cmdMenu() {
227
229
  const sync = await askToggle(t("menu.pushRefresh"));
228
230
  await cmdDiscover(picked.id, { sync });
229
231
  }
232
+ else if (action === "metadata") {
233
+ const picked = await pickProvider(t("menu.metadataProvider"));
234
+ if (!picked)
235
+ continue;
236
+ const current = loadStore().providers[picked.id];
237
+ const { metadataMode } = await prompts({
238
+ type: "select",
239
+ name: "metadataMode",
240
+ message: t("menu.metadataMode"),
241
+ hint: t("menu.selectInstructions"),
242
+ initial: ["auto", "on", "off"].indexOf(getMetadataMode(current)),
243
+ choices: [
244
+ { title: t("menu.metadataAuto"), description: t("menu.metadataAutoHelp"), value: "auto" },
245
+ { title: t("menu.metadataOn"), description: t("menu.metadataOnHelp"), value: "on" },
246
+ { title: t("menu.metadataOff"), description: t("menu.metadataOffHelp"), value: "off" },
247
+ ],
248
+ }, cancel);
249
+ await cmdRefreshMeta({ provider: picked.id, metadataMode });
250
+ }
230
251
  else if (action === "rename" || action === "remove") {
231
252
  try {
232
253
  if (action === "rename")
@@ -0,0 +1,400 @@
1
+ import { findModelMeta, getCatalogFetchedAt, toModelSpec } from "./modelsdev.js";
2
+ import { loadGatewayCatalog } from "./gateway.js";
3
+ const forbiddenKeys = new Set(["__proto__", "prototype", "constructor"]);
4
+ const canonicalId = /^[^/\s]+\/[^/\s]+(?:\/[^/\s]+)*$/;
5
+ export function getMetadataMode(settings) {
6
+ if (settings.gatewayMetadata === undefined || settings.gatewayMetadata === "auto")
7
+ return "auto";
8
+ if (settings.gatewayMetadata === true)
9
+ return "on";
10
+ if (settings.gatewayMetadata === false)
11
+ return "off";
12
+ throw new Error("Gateway metadata must be a boolean or auto");
13
+ }
14
+ function validateSettings(settings) {
15
+ const result = {};
16
+ if (settings.gatewayMetadata !== undefined) {
17
+ getMetadataMode(settings);
18
+ result.gatewayMetadata = settings.gatewayMetadata;
19
+ }
20
+ const aliases = settings.gatewayModelAliases;
21
+ if (aliases !== undefined) {
22
+ if (!aliases || typeof aliases !== "object" || Array.isArray(aliases)
23
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(aliases))) {
24
+ throw new Error("Gateway model mappings must be a plain object of exact IDs to creator/model IDs");
25
+ }
26
+ const entries = Object.entries(aliases);
27
+ for (const [id, target] of entries) {
28
+ if (!id || /\s/.test(id) || forbiddenKeys.has(id) || typeof target !== "string" || !canonicalId.test(target)) {
29
+ throw new Error("Gateway model mappings must contain valid exact IDs and creator/model IDs");
30
+ }
31
+ }
32
+ result.gatewayModelAliases = Object.fromEntries(entries);
33
+ }
34
+ return result;
35
+ }
36
+ /** Validate before any mutation or fetch; omitted settings preserve the previous values. */
37
+ export function resolveMetadataOptions(opts, previous) {
38
+ const settings = validateSettings(previous ?? {});
39
+ if (opts.gatewayMetadata !== undefined) {
40
+ if (typeof opts.gatewayMetadata !== "boolean")
41
+ throw new Error("Gateway metadata must be a boolean");
42
+ settings.gatewayMetadata = opts.gatewayMetadata;
43
+ }
44
+ if (opts.metadataMode !== undefined) {
45
+ if (opts.metadataMode !== "auto" && opts.metadataMode !== "on" && opts.metadataMode !== "off") {
46
+ throw new Error("Metadata mode must be auto, on, or off");
47
+ }
48
+ const mode = opts.metadataMode === "auto" ? "auto" : opts.metadataMode === "on";
49
+ if (opts.gatewayMetadata !== undefined && opts.gatewayMetadata !== mode) {
50
+ throw new Error("Metadata mode conflicts with Gateway metadata flag");
51
+ }
52
+ settings.gatewayMetadata = mode;
53
+ }
54
+ if (opts.gatewayModels !== undefined) {
55
+ if (typeof opts.gatewayModels !== "string")
56
+ throw new Error("Gateway model mappings must be a JSON object");
57
+ let aliases;
58
+ try {
59
+ aliases = JSON.parse(opts.gatewayModels);
60
+ }
61
+ catch {
62
+ throw new Error("Gateway model mappings must be a JSON object");
63
+ }
64
+ settings.gatewayModelAliases = validateSettings({ gatewayModelAliases: aliases }).gatewayModelAliases;
65
+ }
66
+ return settings;
67
+ }
68
+ const fields = [
69
+ "name", "contextWindow", "maxInput", "maxOutput", "reasoning", "reasoningEfforts", "imageInput",
70
+ "cost.input", "cost.output", "cost.cacheRead", "cost.cacheWrite",
71
+ ];
72
+ const gatewayFields = fields.filter((field) => !field.startsWith("cost."));
73
+ const limits = ["maxInput", "maxOutput"];
74
+ const coreFields = ["contextWindow", "maxOutput", "reasoning", "imageInput"];
75
+ function fieldValue(spec, field) {
76
+ if (field.startsWith("cost."))
77
+ return spec.cost?.[field.slice(5)];
78
+ return spec[field];
79
+ }
80
+ function setField(spec, field, value) {
81
+ if (field.startsWith("cost.")) {
82
+ const leaf = field.slice(5);
83
+ if (value === undefined) {
84
+ if (spec.cost)
85
+ delete spec.cost[leaf];
86
+ }
87
+ else {
88
+ spec.cost ??= {};
89
+ spec.cost[leaf] = value;
90
+ }
91
+ }
92
+ else {
93
+ const record = spec;
94
+ if (value === undefined)
95
+ delete record[field];
96
+ else
97
+ record[field] = Array.isArray(value) ? [...value] : value;
98
+ }
99
+ }
100
+ function equalValue(a, b) {
101
+ if (Array.isArray(a) && Array.isArray(b))
102
+ return a.length === b.length && a.every((value, index) => value === b[index]);
103
+ return a === b;
104
+ }
105
+ function indexCatalog(catalog) {
106
+ const exact = new Map();
107
+ const canonical = new Map();
108
+ for (const provider of Object.values(catalog)) {
109
+ for (const [key, model] of Object.entries(provider.models)) {
110
+ const modelId = model.id.includes("/") ? model.id : `${provider.id}/${model.id}`;
111
+ const entry = { provider: provider.id, modelId, model };
112
+ for (const id of new Set([key, model.id])) {
113
+ const matches = exact.get(id);
114
+ if (matches)
115
+ matches.push(entry);
116
+ else
117
+ exact.set(id, [entry]);
118
+ }
119
+ const matches = canonical.get(modelId);
120
+ if (matches)
121
+ matches.push(entry);
122
+ else
123
+ canonical.set(modelId, [entry]);
124
+ }
125
+ }
126
+ return { exact, canonical };
127
+ }
128
+ function indexGatewayBareIds(gateway) {
129
+ const result = new Map();
130
+ for (const id of Object.keys(gateway.models)) {
131
+ const slash = id.indexOf("/");
132
+ if (slash < 1 || slash !== id.lastIndexOf("/") || !canonicalId.test(id))
133
+ continue;
134
+ const bare = id.slice(slash + 1);
135
+ result.set(bare, result.has(bare) ? null : id);
136
+ }
137
+ return result;
138
+ }
139
+ function selectExact(index, id, hint, aliases, gateway, gatewayBareIds) {
140
+ const alias = aliases && Object.hasOwn(aliases, id) ? aliases[id] : undefined;
141
+ const bare = !id.includes("/");
142
+ let gatewayId = alias;
143
+ if (gatewayId === undefined && gateway) {
144
+ if (canonicalId.test(id) && Object.hasOwn(gateway.models, id))
145
+ gatewayId = id;
146
+ else if (bare) {
147
+ const candidate = gatewayBareIds?.get(id);
148
+ const evidence = index?.exact.get(id);
149
+ const hintedEvidence = hint ? evidence?.filter((entry) => entry.provider === hint) : undefined;
150
+ const identities = new Set((hintedEvidence?.length ? hintedEvidence : evidence)?.map((entry) => entry.modelId));
151
+ // A unique exact basename is safe only with absent or agreeing primary evidence.
152
+ // Explicit provider hints can resolve primary ambiguity, but never Gateway ambiguity.
153
+ if (candidate && (identities.size === 0 || (identities.size === 1 && identities.has(candidate))))
154
+ gatewayId = candidate;
155
+ }
156
+ }
157
+ const candidates = gatewayId !== undefined
158
+ ? index?.canonical.get(gatewayId)
159
+ : (bare && hint ? index?.canonical.get(`${hint}/${id}`) : undefined)
160
+ ?? index?.canonical.get(id) ?? index?.exact.get(id);
161
+ const hinted = candidates?.find((entry) => entry.provider === hint);
162
+ const identities = new Set(candidates?.map((entry) => entry.modelId));
163
+ const primary = hinted ?? (identities.size === 1
164
+ ? candidates?.find((entry) => entry.modelId.startsWith(`${entry.provider}/`)) ?? candidates?.[0]
165
+ : undefined);
166
+ return { primary, gatewayId, alias };
167
+ }
168
+ function legacyPrimary(catalog, id, hint) {
169
+ const hit = findModelMeta(catalog, id, hint);
170
+ if (!hit)
171
+ return undefined;
172
+ const provider = Object.values(catalog).find((candidate) => candidate.id === hit.provider);
173
+ const base = id.slice(id.lastIndexOf("/") + 1).toLowerCase();
174
+ const model = provider && (Object.hasOwn(provider.models, id) ? provider.models[id]
175
+ : Object.entries(provider.models).find(([key]) => key.slice(key.lastIndexOf("/") + 1).toLowerCase() === base)?.[1]);
176
+ if (!model)
177
+ return undefined;
178
+ hit.spec.id = model.id;
179
+ return { spec: hit.spec, modelId: model.id.includes("/") ? model.id : `${hit.provider}/${model.id}` };
180
+ }
181
+ function clearGatewayIdentity(spec) {
182
+ const metadata = spec.metadata;
183
+ if (!metadata)
184
+ return;
185
+ for (const field of fields) {
186
+ const origin = metadata.fields?.[field];
187
+ if (origin?.source !== "ai-gateway")
188
+ continue;
189
+ if (equalValue(fieldValue(spec, field), origin.value))
190
+ setField(spec, field, undefined);
191
+ delete metadata.fields[field];
192
+ }
193
+ delete metadata.gateway;
194
+ if (metadata.conflicts)
195
+ metadata.conflicts = metadata.conflicts.filter((conflict) => conflict.source !== "ai-gateway");
196
+ }
197
+ function clearStaleIdentity(spec, modelId) {
198
+ const metadata = spec.metadata;
199
+ if (!metadata)
200
+ return;
201
+ for (const field of fields) {
202
+ const origin = metadata.fields?.[field];
203
+ if (!origin || origin.modelId === modelId)
204
+ continue;
205
+ if (equalValue(fieldValue(spec, field), origin.value))
206
+ setField(spec, field, undefined);
207
+ delete metadata.fields[field];
208
+ }
209
+ if (metadata.gateway && metadata.gateway.modelId !== modelId)
210
+ delete metadata.gateway;
211
+ if (metadata.conflicts)
212
+ metadata.conflicts = metadata.conflicts.filter((conflict) => conflict.modelId === modelId);
213
+ }
214
+ /** Apply a source as a set of field proposals, checking bounds against retained values first. */
215
+ function mergeSource(spec, incoming, source, modelId, now, fetchedAt, supplementalGateway) {
216
+ const sourceFields = source === "ai-gateway" ? gatewayFields : fields;
217
+ const proposals = new Map();
218
+ const conflicts = [];
219
+ const conflict = (field, value, keptValue, blockedBy) => {
220
+ conflicts.push({ field, source, modelId, value: Array.isArray(value) ? [...value] : value,
221
+ keptValue: Array.isArray(keptValue) ? [...keptValue] : keptValue, ...(blockedBy ? { blockedBy } : {}) });
222
+ };
223
+ for (const field of sourceFields) {
224
+ const value = fieldValue(incoming, field);
225
+ if (value === undefined)
226
+ continue;
227
+ if (field === "name" && (value === incoming.id || value === modelId))
228
+ continue;
229
+ const current = fieldValue(spec, field);
230
+ const origin = spec.metadata?.fields?.[field];
231
+ if (current === undefined || (origin && (source === "models.dev" || origin.source === source))) {
232
+ proposals.set(field, value);
233
+ }
234
+ else if (!equalValue(current, value)) {
235
+ conflict(field, value, current);
236
+ }
237
+ }
238
+ // A proposed context may shrink only when every retained limit still fits.
239
+ let context = proposals.get("contextWindow") ?? spec.contextWindow;
240
+ if (typeof context === "number" && proposals.has("contextWindow")) {
241
+ for (const field of limits) {
242
+ const proposed = proposals.get(field);
243
+ const primaryFits = typeof proposed === "number" && proposed <= context;
244
+ let retained = primaryFits ? proposed : fieldValue(spec, field);
245
+ // The supplement runs next: account for a still-owned limit that it can
246
+ // lower in this same refresh, rather than requiring a second refresh.
247
+ const next = supplementalGateway && spec.metadata?.fields?.[field]?.source === "ai-gateway"
248
+ ? supplementalGateway[field] : undefined;
249
+ if (!primaryFits && typeof next === "number" && next <= context)
250
+ retained = next;
251
+ if (typeof retained === "number" && retained > context) {
252
+ conflict("contextWindow", context, retained, field);
253
+ proposals.delete("contextWindow");
254
+ context = spec.contextWindow;
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ for (const field of limits) {
260
+ const value = proposals.get(field);
261
+ if (typeof value === "number" && typeof context === "number" && value > context) {
262
+ conflict(field, value, context, "contextWindow");
263
+ proposals.delete(field);
264
+ }
265
+ }
266
+ const reasoning = proposals.get("reasoning") ?? spec.reasoning;
267
+ if (reasoning === false) {
268
+ const efforts = proposals.get("reasoningEfforts");
269
+ const emptyEfforts = Array.isArray(efforts) && efforts.length === 0;
270
+ if (efforts !== undefined && !emptyEfforts) {
271
+ conflict("reasoningEfforts", efforts, false, "reasoning");
272
+ proposals.delete("reasoningEfforts");
273
+ }
274
+ // Do not introduce a contradictory false flag over retained effort settings.
275
+ if (proposals.has("reasoning") && spec.reasoning !== false && !emptyEfforts && spec.reasoningEfforts?.length) {
276
+ conflict("reasoning", false, spec.reasoningEfforts, "reasoningEfforts");
277
+ proposals.delete("reasoning");
278
+ }
279
+ }
280
+ const metadata = spec.metadata ??= {};
281
+ if (metadata.conflicts)
282
+ metadata.conflicts = metadata.conflicts.filter((entry) => entry.source !== source);
283
+ if (conflicts.length)
284
+ metadata.conflicts = [...(metadata.conflicts ?? []), ...conflicts];
285
+ for (const [field, value] of proposals) {
286
+ const previous = metadata.fields?.[field];
287
+ const unchanged = previous?.source === source && previous.modelId === modelId && equalValue(previous.value, value);
288
+ const origin = {
289
+ source, modelId, value: Array.isArray(value) ? [...value] : value,
290
+ updatedAt: unchanged ? previous.updatedAt : now,
291
+ };
292
+ const timestamp = fetchedAt ?? (unchanged ? previous.fetchedAt : undefined);
293
+ if (timestamp !== undefined)
294
+ origin.fetchedAt = timestamp;
295
+ (metadata.fields ??= {})[field] = origin;
296
+ setField(spec, field, value);
297
+ }
298
+ }
299
+ /** Supplement metadata only; IDs, routes, protocol and provider-effective pricing remain untouched. */
300
+ export async function enrichProviderModels(catalog, ids, settings, options = {}) {
301
+ const validated = validateSettings(settings);
302
+ const mode = getMetadataMode(validated);
303
+ if (ids.length === 0)
304
+ return [];
305
+ const enabled = mode !== "off";
306
+ const index = enabled && catalog ? indexCatalog(catalog) : undefined;
307
+ const existing = new Map(settings.models?.map((model) => [model.id, model]));
308
+ const fetchedAt = catalog ? getCatalogFetchedAt(catalog) : undefined;
309
+ const now = new Date().toISOString();
310
+ const enrich = (gateway) => {
311
+ const gatewayBareIds = gateway ? indexGatewayBareIds(gateway) : undefined;
312
+ return ids.map((id) => {
313
+ const previous = existing.get(id);
314
+ const spec = previous ? structuredClone(previous) : { id };
315
+ for (const field of fields) {
316
+ const origin = spec.metadata?.fields?.[field];
317
+ if (origin && !equalValue(fieldValue(spec, field), origin.value))
318
+ delete spec.metadata.fields[field];
319
+ }
320
+ let primary;
321
+ let supplement;
322
+ let match;
323
+ if (enabled) {
324
+ match = selectExact(index, id, settings.modelsDevId, validated.gatewayModelAliases, gateway, gatewayBareIds);
325
+ const oldGateway = spec.metadata?.gateway;
326
+ if (oldGateway && (oldGateway.alias !== match.alias
327
+ || (match.gatewayId !== undefined && oldGateway.modelId !== match.gatewayId))) {
328
+ clearGatewayIdentity(spec);
329
+ }
330
+ if (match.primary)
331
+ primary = { spec: toModelSpec(match.primary.model.id, match.primary.model), modelId: match.primary.modelId };
332
+ if (gateway && match.gatewayId !== undefined && Object.hasOwn(gateway.models, match.gatewayId)) {
333
+ supplement = gateway.models[match.gatewayId];
334
+ }
335
+ // An explicit mapping establishes identity even offline. An outage without
336
+ // a mapping or exact catalog evidence does not justify deleting metadata.
337
+ const confirmedId = match.alias ?? match.primary?.modelId ?? supplement?.id;
338
+ if (confirmedId !== undefined)
339
+ clearStaleIdentity(spec, confirmedId);
340
+ }
341
+ else if (catalog) {
342
+ primary = legacyPrimary(catalog, id, settings.modelsDevId);
343
+ }
344
+ if (primary)
345
+ mergeSource(spec, primary.spec, "models.dev", primary.modelId, now, fetchedAt, supplement?.spec);
346
+ if (supplement && gateway && match) {
347
+ mergeSource(spec, supplement.spec, "ai-gateway", supplement.id, now, gateway.fetchedAt);
348
+ const audit = { modelId: supplement.id, fetchedAt: gateway.fetchedAt };
349
+ if (match.alias !== undefined)
350
+ audit.alias = match.alias;
351
+ if (supplement.referenceCost !== undefined)
352
+ audit.referenceCost = structuredClone(supplement.referenceCost);
353
+ if (supplement.pricingIsVariable !== undefined)
354
+ audit.pricingIsVariable = supplement.pricingIsVariable;
355
+ (spec.metadata ??= {}).gateway = audit;
356
+ }
357
+ if (spec.metadata?.fields && Object.keys(spec.metadata.fields).length === 0)
358
+ delete spec.metadata.fields;
359
+ if (spec.metadata?.conflicts?.length === 0)
360
+ delete spec.metadata.conflicts;
361
+ if (spec.metadata && Object.keys(spec.metadata).length === 0)
362
+ delete spec.metadata;
363
+ return spec;
364
+ });
365
+ };
366
+ if (!enabled)
367
+ return enrich();
368
+ if (options.gateway !== undefined)
369
+ return enrich(options.gateway ?? undefined);
370
+ const primaryOnly = mode === "auto" ? enrich() : undefined;
371
+ if (primaryOnly && !primaryOnly.some((spec) => {
372
+ if (coreFields.some((field) => spec[field] === undefined)
373
+ || gatewayFields.some((field) => spec.metadata?.fields?.[field]?.source === "ai-gateway"))
374
+ return true;
375
+ // Complete legacy basename metadata still needs an exact identity check,
376
+ // unless an explicit mapping or current primary match already confirms it.
377
+ if (!canonicalId.test(spec.id) || !fields.some((field) => {
378
+ const origin = spec.metadata?.fields?.[field];
379
+ return origin && origin.modelId !== spec.id;
380
+ }))
381
+ return false;
382
+ const match = selectExact(index, spec.id, settings.modelsDevId, validated.gatewayModelAliases, undefined);
383
+ const confirmedId = match.alias ?? match.primary?.modelId ?? spec.id;
384
+ return fields.some((field) => {
385
+ const origin = spec.metadata?.fields?.[field];
386
+ return origin && origin.modelId !== confirmedId;
387
+ });
388
+ }))
389
+ return primaryOnly;
390
+ let gateway;
391
+ try {
392
+ gateway = await (options.gatewayLoader ?? loadGatewayCatalog)() ?? null;
393
+ }
394
+ catch {
395
+ gateway = null;
396
+ }
397
+ // Never feed the primary pre-pass back into merging: identity and coordinated
398
+ // bounds must be resolved with the supplement against the original values.
399
+ return gateway ? enrich(gateway) : primaryOnly ?? enrich();
400
+ }