agentsw 0.11.0 → 0.11.1

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
@@ -35,7 +35,9 @@ without touching the settings you hand-tuned.
35
35
  - **Model metadata, not just an id list.** Reseller `/v1/models` endpoints return bare ids.
36
36
  agentsw enriches them from [models.dev](https://models.dev), automatically fills meaningful gaps from AI Gateway when needed, and writes what each app
37
37
  actually reads: `thinkingLevelMap` for pi/prime, `limit`/`attachment` for opencode,
38
- `contextWindow`/`maxTokens` for omp, `reasoningEfforts`/`input` for dsh.
38
+ `contextWindow`/`maxTokens` for omp, `reasoningEfforts`/`input` for dsh. Limits the endpoint declares for
39
+ its own models outrank the catalogs, so one model cannot end up with two different context windows
40
+ depending on which provider lists it.
39
41
  - **Adopts what you already have** — including [cc-switch](https://github.com/farion1231/cc-switch).
40
42
  `import` scans every installed agent *and* cc-switch's own store, merges duplicates
41
43
  (same protocol + normalized endpoint + credentials), and never writes back to either.
@@ -204,8 +206,9 @@ use `--all` explicitly in scripts. Model discovery may update the local metadata
204
206
 
205
207
  ## Model discovery filters
206
208
 
207
- `--discover` lists model ids from the provider's `/v1/models`, then enriches each id from
208
- models.dev first, with automatic AI Gateway supplementation when needed. Reseller lists are noisy,
209
+ `--discover` lists model ids from the provider's `/v1/models`, then enriches each id from models.dev first,
210
+ with automatic AI Gateway supplementation when needed. Limits the provider declares for its own models in
211
+ that same listing win over what a catalog claims for them; ids it lists bare are enriched as before. Reseller lists are noisy,
209
212
  so filters are persisted per provider and re-applied on every `discover`:
210
213
 
211
214
  ```bash
@@ -277,9 +280,12 @@ Without `--provider`, `refresh` applies to all providers, including any supplied
277
280
  `--gateway-models` replaces the whole alias map (`{}` clears it); aliases do not change the saved mode or override `off`.
278
281
  Import still skips already configured accounts.
279
282
 
280
- - **Field-level priority:** manual values > models.dev > Gateway gap filling. Tracked automatic values may
281
- refresh; subsequent manual edits are retained. Legacy values without provenance are conservatively treated
282
- as manual overrides. Unmodeled extension fields survive enrichment too.
283
+ - **Field-level priority:** manual values > the endpoint's own declaration > models.dev > Gateway gap filling.
284
+ An endpoint that advertises `context_window_tokens`/`maximum_output_tokens`
285
+ (or their `context_length`/`max_output_tokens` spellings) for its own list is authoritative for its own
286
+ route, so those limits are written instead of a catalog's estimate for the same model. Tracked automatic
287
+ values may refresh; subsequent manual edits are retained. Legacy values without provenance are conservatively
288
+ treated as manual overrides. Unmodeled extension fields survive enrichment too.
283
289
  - **Exact identities:** automatic lookup can map a bare, case-sensitive ID (no `/`) to a unique Gateway
284
290
  `creator/model` identity when models.dev evidence is absent or agrees. Ambiguous creator evidence in either
285
291
  catalog is rejected; custom or ambiguous names need explicit aliases. Qualified IDs never have their prefixes
package/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@
33
33
  - **写的是元数据,不只是模型 id。** 转售商 `/v1/models` 只返回裸 id;agentsw 从
34
34
  [models.dev](https://models.dev) 优先补全,并在有实际缺口时自动查询 AI Gateway,按各应用真正读取的字段下发:pi/prime 的
35
35
  `thinkingLevelMap`、opencode 的 `limit`/`attachment`、omp 的 `contextWindow`/`maxTokens`、
36
- dsh 的 `reasoningEfforts`/`input`。
36
+ dsh 的 `reasoningEfforts`/`input`。端点自己声明的上下文/输出上限优先于目录,避免同一模型在不同供应商下被写成不同窗口。
37
37
  - **接管你已有的配置——包括 [cc-switch](https://github.com/farion1231/cc-switch)。**
38
38
  `import` 会扫描所有已安装的智能体**以及 cc-switch 自己的存储**,合并重复项
39
39
  (相同协议 + 归一化端点 + 凭据),并且对两者都只读不写。
@@ -191,6 +191,7 @@ imported zhipu-glm · anthropic · https://open.bigmodel.cn/api/anthropic · 1 m
191
191
  ## 模型发现过滤
192
192
 
193
193
  `--discover` 从供应商 `/v1/models` 拉取模型 id,优先从 models.dev 补全元数据,必要时自动查询 AI Gateway。
194
+ 同一份列表里供应商为自家模型声明的上下文/输出上限优先于目录的估算,只给出裸 id 的模型仍按原逻辑补全。
194
195
  转售商列表噪音大,过滤规则持久化在供应商上,每次 `discover` 自动复用:
195
196
 
196
197
  ```bash
@@ -260,7 +261,9 @@ asw sync --provider myproxy,backup-proxy
260
261
  `--gateway-models` 替换整个映射,传 `{}` 清空;映射不会更改保存的模式,也不会覆盖 `off`。
261
262
  导入仍跳过已经配置的账号。
262
263
 
263
- - **按字段合并:** 用户手动值优先,其次 models.dev,最后 Gateway 补缺。已记录来源的自动值可以刷新,
264
+ - **按字段合并:** 用户手动值优先,其次端点自己声明的值,然后 models.dev,最后 Gateway 补缺。
265
+ 端点在 `/v1/models` 中为自家模型声明了 `context_window_tokens`/`maximum_output_tokens`(或
266
+ `context_length`/`max_output_tokens` 写法)时,以它为准而不是目录对同一模型的估算。已记录来源的自动值可以刷新,
264
267
  手动修改后会保留;旧版本中没有来源记录的已有值保守视为手动值,不强制覆盖。未知扩展字段也保留。
265
268
  - **精确匹配:** 不含 `/` 的裸 ID 可按大小写精确匹配唯一的 Gateway `creator/model` 身份,
266
269
  前提是 models.dev 没有身份线索或线索一致。任一目录中的创建者身份有歧义时拒绝自动匹配;
package/dist/commands.js CHANGED
@@ -11,9 +11,9 @@ import { loadCatalog, searchCatalog } from "./modelsdev.js";
11
11
  import { classifyUnresolved, enrichProviderModels, resolveMetadataOptions } from "./metadata.js";
12
12
  import { loadGatewayCatalog } from "./gateway.js";
13
13
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
14
- import { discoverProviderModels, probeProtocols } from "./discover.js";
14
+ import { discoverProviderCatalog, probeProtocols } from "./discover.js";
15
15
  import { appCommand, appPackages, installedVersion, isNewer, latestVersion, normalizeAppVersion, runShell } from "./apps.js";
16
- import { SafeConfigError, drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
16
+ import { SafeConfigError, drainPendingWrites, isDryRun, readTextIfExists, setDryRun } from "./fsutil.js";
17
17
  import { applyModelFilter } from "./filter.js";
18
18
  import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
19
19
  import { t } from "./i18n.js";
@@ -202,16 +202,18 @@ export async function cmdAdd(opts) {
202
202
  const metadataOptions = resolveMetadataOptions(opts, existing);
203
203
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
204
204
  let modelIds = manualIds;
205
+ // Limits the endpoint declares for its own models, kept from the discovery pass.
206
+ let endpointLimits = new Map();
205
207
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
206
208
  const shouldDiscover = opts.discover || manualIds.length === 0;
207
209
  if (shouldDiscover) {
208
210
  if (!opts.discover) {
209
211
  note(t("add.autoDiscover"));
210
212
  }
211
- const discover = () => withProgress(t("add.discovering", { url: baseUrl }), (progress) => discoverProviderModels({ baseUrl, apiKey: answers.apiKey, protocol }, {
213
+ const discover = () => withProgress(t("add.discovering", { url: baseUrl }), (progress) => discoverProviderCatalog({ baseUrl, apiKey: answers.apiKey, protocol }, {
212
214
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: baseUrl, page, count })),
213
215
  }));
214
- let discovered = [];
216
+ let discovered = { ids: [], limits: new Map() };
215
217
  for (let attempt = 0;; attempt++) {
216
218
  try {
217
219
  discovered = await discover();
@@ -228,9 +230,10 @@ export async function cmdAdd(opts) {
228
230
  break;
229
231
  }
230
232
  }
231
- if (discovered.length)
232
- out(t("add.providerLists", { count: discovered.length }));
233
- modelIds = [...new Set([...manualIds, ...discovered])];
233
+ if (discovered.ids.length)
234
+ out(t("add.providerLists", { count: discovered.ids.length }));
235
+ modelIds = [...new Set([...manualIds, ...discovered.ids])];
236
+ endpointLimits = discovered.limits;
234
237
  }
235
238
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
236
239
  modelIds = [...new Set([...modelIds, ...retained])];
@@ -242,7 +245,7 @@ export async function cmdAdd(opts) {
242
245
  fail(t("add.atLeastOne"));
243
246
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
244
247
  const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
245
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }));
248
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }, { endpointLimits }));
246
249
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
247
250
  const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
248
251
  if (!modelIds.includes(defaultModel))
@@ -287,7 +290,10 @@ async function createProvider(opts) {
287
290
  const metadataOptions = resolveMetadataOptions(opts, opts.existing);
288
291
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
289
292
  const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
290
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, { gatewayLoader: opts.gatewayLoader }));
293
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, {
294
+ gatewayLoader: opts.gatewayLoader,
295
+ endpointLimits: opts.endpointLimits,
296
+ }));
291
297
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
292
298
  const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
293
299
  if (!modelIds.includes(defaultModel))
@@ -387,12 +393,12 @@ export async function cmdQuickAdd(opts) {
387
393
  const id = byId?.id ?? sameAccount?.id ?? explicitId ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
388
394
  const name = opts.name ?? existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
389
395
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
390
- const discovered = await withProgress(`${t("add.discovering", { url: baseUrl })} [${protocol}]`, (progress) => discoverProviderModels({ baseUrl, apiKey, protocol }, {
396
+ const discovered = await withProgress(`${t("add.discovering", { url: baseUrl })} [${protocol}]`, (progress) => discoverProviderCatalog({ baseUrl, apiKey, protocol }, {
391
397
  onPage: ({ page, count }) => progress.update(`${t("progress.discoverPage", { url: baseUrl, page, count })} [${protocol}]`),
392
398
  }));
393
- out(t("add.providerLists", { count: discovered.length }));
399
+ out(t("add.providerLists", { count: discovered.ids.length }));
394
400
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
395
- let modelIds = [...new Set([...discovered, ...retained])];
401
+ let modelIds = [...new Set([...discovered.ids, ...retained])];
396
402
  const pinned = [...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
397
403
  const outcome = applyModelFilter(modelIds, modelFilter, pinned);
398
404
  reportDropped(outcome.dropped);
@@ -420,6 +426,7 @@ export async function cmdQuickAdd(opts) {
420
426
  gatewayModels: opts.gatewayModels,
421
427
  metadataMode: opts.metadataMode,
422
428
  gatewayLoader,
429
+ endpointLimits: discovered.limits,
423
430
  });
424
431
  createdIds.push(provider.id);
425
432
  note(`\n${t("add.next", { id: provider.id })}`);
@@ -466,13 +473,14 @@ export async function cmdPrune(id, opts) {
466
473
  out(`${t("remove.pruning", { id: pc.bold(id) })}\n`);
467
474
  const results = await runTargets("prune", provider, opts.apps);
468
475
  if (isJson()) {
476
+ const failed = results.filter((r) => r.failed).map((r) => r.app);
469
477
  emitJson({
470
478
  provider: id,
471
479
  op: "prune",
472
480
  apps: results.filter((r) => !r.skipped).map((r) => r.app),
473
481
  files: results.flatMap((r) => (r.skipped ? [] : r.changed)),
474
482
  skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
475
- });
483
+ }, failed.length ? t("targets.failed", { apps: failed.join(", ") }) : undefined);
476
484
  return;
477
485
  }
478
486
  reportResults(results);
@@ -522,11 +530,12 @@ async function runTargets(op, provider, appsFilter, opts = {}) {
522
530
  results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
523
531
  continue;
524
532
  }
525
- results.push(await target[op](provider));
533
+ results.push(await (op === "apply" ? target.apply(provider, opts.applyOptions) : target.prune(provider)));
526
534
  }
527
535
  catch (err) {
528
- const safe = opts.redactErrors === true && !(err instanceof SafeConfigError);
529
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? t("preview.failedSafely") : err.message}`) });
536
+ const safe = (opts.redactErrors === true || isJson()) && !(err instanceof SafeConfigError);
537
+ const message = safe ? t(opts.redactErrors ? "preview.failedSafely" : "targets.failedSafely") : err.message;
538
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${message}`), failed: true });
530
539
  process.exitCode = 1;
531
540
  }
532
541
  }
@@ -644,20 +653,22 @@ function printFileDiff(file, next, apiKey) {
644
653
  if (oldText === newText)
645
654
  out(pc.dim(t("preview.onlyRedacted")));
646
655
  }
647
- async function runWithOptionalDryRun(op, provider, apps, dryRun, oneSlot) {
656
+ async function runWithOptionalDryRun(op, provider, apps, dryRun, oneSlot, applyOptions) {
648
657
  const summarize = (results, files) => ({
658
+ ...(results.some((r) => r.failed) ? { failed: results.filter((r) => r.failed).map((r) => r.app) } : {}),
649
659
  apps: results.map((r) => r.app),
650
660
  files,
651
661
  skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
652
662
  });
653
663
  if (!dryRun) {
654
- const results = await runTargets(op, provider, apps, { oneSlot });
664
+ const results = await runTargets(op, provider, apps, { oneSlot, applyOptions });
655
665
  reportResults(results);
656
666
  return summarize(results, results.flatMap((r) => (r.skipped ? [] : r.changed)));
657
667
  }
668
+ const wasDryRun = isDryRun();
658
669
  setDryRun(true);
659
670
  try {
660
- const results = await runTargets(op, provider, apps, { redactErrors: true, oneSlot });
671
+ const results = await runTargets(op, provider, apps, { redactErrors: true, oneSlot, applyOptions });
661
672
  const writes = drainPendingWrites();
662
673
  for (const r of results) {
663
674
  if (r.skipped)
@@ -670,7 +681,7 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun, oneSlot) {
670
681
  return summarize(results, writes.map((w) => w.file));
671
682
  }
672
683
  finally {
673
- setDryRun(false);
684
+ setDryRun(wasDryRun);
674
685
  }
675
686
  }
676
687
  export async function cmdUse(id, opts) {
@@ -703,7 +714,18 @@ export async function cmdUse(id, opts) {
703
714
  out(`${t("use.switching", { id: pc.bold(id), protocol: provider.protocol, model: provider.defaultModel })}\n`);
704
715
  const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
705
716
  if (isJson()) {
706
- emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
717
+ emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome }, outcome.failed?.length ? t("targets.failed", { apps: outcome.failed.join(", ") }) : undefined);
718
+ }
719
+ }
720
+ function saveRefreshedProvider(store, provider) {
721
+ const previous = getProvider(store, provider.id);
722
+ store.providers[provider.id] = provider;
723
+ try {
724
+ saveStore(store);
725
+ }
726
+ catch (err) {
727
+ store.providers[provider.id] = previous;
728
+ throw err;
707
729
  }
708
730
  }
709
731
  /**
@@ -713,11 +735,11 @@ export async function cmdUse(id, opts) {
713
735
  * real sync would write without leaving the fetched list behind in the store.
714
736
  */
715
737
  async function refreshProviderModels(store, provider, save = true) {
716
- const listed = await withProgress(t("add.discovering", { url: provider.baseUrl }), (progress) => discoverProviderModels(provider, {
738
+ const listed = await withProgress(t("add.discovering", { url: provider.baseUrl }), (progress) => discoverProviderCatalog(provider, {
717
739
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: provider.baseUrl, page, count })),
718
740
  }));
719
741
  const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
720
- const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
742
+ const outcome = applyModelFilter(listed.ids, provider.modelFilter, pinned);
721
743
  reportDropped(outcome.dropped);
722
744
  const ids = outcome.kept;
723
745
  const retained = new Set([...ids, ...pinned]);
@@ -725,12 +747,12 @@ async function refreshProviderModels(store, provider, save = true) {
725
747
  const added = ids.filter((m) => !known.includes(m));
726
748
  const removed = known.filter((m) => !retained.has(m));
727
749
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
728
- provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, [...retained], { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }));
750
+ provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, [...retained], { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }, { endpointLimits: listed.limits }));
729
751
  // Reported by the caller, where it reads after the table or the per-app results instead of
730
752
  // scrolling away above them.
731
753
  const missing = [...new Set(pinned)].filter((model) => !ids.includes(model));
732
754
  if (save)
733
- saveStore(store);
755
+ saveRefreshedProvider(store, provider);
734
756
  return { ids, added, removed, missing, catalog };
735
757
  }
736
758
  function printModelRefresh(provider, refresh) {
@@ -817,32 +839,51 @@ function printMultiSummary(count, failed, key) {
817
839
  ? pc.yellow(t(key === "sync" ? "sync.summaryFailed" : "discover.summaryFailed", { ok: done, count }))
818
840
  : t(key === "sync" ? "sync.summaryOk" : "discover.summaryOk", { count })}`);
819
841
  }
820
- export async function cmdSync(opts) {
821
- const selectedTargets = resolveTargets(opts.apps);
822
- const store = loadStore();
842
+ function syncTargets(opts) {
843
+ const selected = resolveTargets(opts.apps);
844
+ if (opts.keepDefault) {
845
+ const unsupported = selected.filter((target) => !target.supportsKeepDefault);
846
+ if (unsupported.length)
847
+ fail(t("sync.keepDefaultUnsupported", { apps: unsupported.map((target) => target.id).join(", ") }));
848
+ }
849
+ return selected;
850
+ }
851
+ async function syncProviders(opts, sourceStore) {
852
+ const selectedTargets = syncTargets(opts);
853
+ const store = sourceStore ?? loadStore();
823
854
  const ids = activeLast(store, resolveProviderIds(store, opts, "active"));
824
855
  const many = ids.length > 1;
825
856
  const oneSlot = many ? { winners: oneSlotWinners(selectedTargets, store, ids) } : undefined;
826
857
  const saveRefresh = opts.dryRun !== true;
827
858
  const reports = [];
828
- let failed = 0;
859
+ const failed = [];
829
860
  for (const [index, id] of ids.entries()) {
830
- const provider = getProvider(store, id);
861
+ const provider = structuredClone(getProvider(store, id));
831
862
  if (index)
832
863
  out("");
833
864
  let refresh;
834
865
  if (opts.refresh !== false) {
835
866
  try {
836
- refresh = await refreshProviderModels(store, provider, saveRefresh);
867
+ const wasDryRun = isDryRun();
868
+ try {
869
+ if (opts.dryRun)
870
+ setDryRun(true);
871
+ refresh = await refreshProviderModels(store, provider, saveRefresh);
872
+ }
873
+ finally {
874
+ // Metadata cache writes are not target configuration previews.
875
+ if (opts.dryRun)
876
+ setDryRun(wasDryRun);
877
+ }
837
878
  }
838
879
  catch (err) {
839
880
  // One unreachable provider must not strand the others: report it, write nothing for it,
840
- // and fail the run. A single-provider sync has nothing else to do, so it still throws.
841
- if (!many)
881
+ // and fail the run. Text mode keeps the single-provider exception contract.
882
+ if (!many && !isJson())
842
883
  throw new Error(`${err.message}\n${t("sync.refreshFailed")}`);
843
884
  warn(t("sync.refreshSkipped", { id, message: err.message }));
844
885
  process.exitCode = 1;
845
- failed += 1;
886
+ failed.push(id);
846
887
  reports.push({ provider: id, refreshFailed: true });
847
888
  continue;
848
889
  }
@@ -853,8 +894,10 @@ export async function cmdSync(opts) {
853
894
  note(t("sync.refreshNotSaved"));
854
895
  }
855
896
  }
856
- out(`${t("sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
857
- const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun, oneSlot);
897
+ out(`${t(opts.keepDefault ? "sync.syncingKeepDefault" : "sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
898
+ const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun, oneSlot, { keepDefault: opts.keepDefault });
899
+ if (outcome.failed?.length)
900
+ failed.push(id);
858
901
  if (refresh?.missing.length) {
859
902
  const models = refresh.missing.join(", ");
860
903
  warn(t(opts.dryRun ? "refresh.pinnedMissing" : "sync.pinnedMissingWritten", { models, id }));
@@ -876,19 +919,21 @@ export async function cmdSync(opts) {
876
919
  ...outcome,
877
920
  });
878
921
  }
922
+ if (many)
923
+ printMultiSummary(ids.length, failed.length, "sync");
924
+ return { reports, failed };
925
+ }
926
+ export async function cmdSync(opts) {
927
+ const { reports, failed } = await syncProviders(opts);
879
928
  if (isJson()) {
880
- const first = reports[0] ?? {};
881
- emitJson(many
929
+ emitJson(reports.length > 1
882
930
  ? {
883
931
  dryRun: opts.dryRun === true,
884
932
  providers: reports,
885
933
  files: [...new Set(reports.flatMap((report) => report.files ?? []))],
886
934
  }
887
- : { ...first, dryRun: opts.dryRun === true });
888
- return;
935
+ : { ...(reports[0] ?? {}), dryRun: opts.dryRun === true }, failed.length ? t("sync.failed", { ids: failed.join(", ") }) : undefined);
889
936
  }
890
- if (many)
891
- printMultiSummary(ids.length, failed, "sync");
892
937
  }
893
938
  export function cmdStatus() {
894
939
  const store = loadStore();
@@ -995,7 +1040,9 @@ export async function cmdRefreshMeta(opts = {}) {
995
1040
  printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
996
1041
  }
997
1042
  export async function cmdDiscover(ids, opts) {
998
- resolveTargets(opts.apps);
1043
+ if (opts.keepDefault && !opts.sync)
1044
+ fail(t("discover.keepDefaultRequiresSync"));
1045
+ syncTargets(opts);
999
1046
  const store = loadStore();
1000
1047
  // Ids may arrive as positional arguments, as one comma-separated list, or through `-p` for
1001
1048
  // symmetry with sync; all three say the same thing, so accept them together.
@@ -1006,8 +1053,9 @@ export async function cmdDiscover(ids, opts) {
1006
1053
  const many = selected.length > 1;
1007
1054
  const reports = [];
1008
1055
  const refreshed = [];
1056
+ const failed = [];
1009
1057
  for (const [index, id] of selected.entries()) {
1010
- const provider = getProvider(store, id);
1058
+ const provider = structuredClone(getProvider(store, id));
1011
1059
  if (index)
1012
1060
  out("");
1013
1061
  Object.assign(provider, resolveMetadataOptions(opts, provider));
@@ -1019,14 +1067,28 @@ export async function cmdDiscover(ids, opts) {
1019
1067
  provider.modelFilter = flagFilter;
1020
1068
  let refresh;
1021
1069
  try {
1022
- refresh = await refreshProviderModels(store, provider);
1070
+ const wasDryRun = isDryRun();
1071
+ try {
1072
+ if (opts.dryRun)
1073
+ setDryRun(true);
1074
+ refresh = await refreshProviderModels(store, provider, false);
1075
+ if (opts.dryRun)
1076
+ store.providers[id] = provider;
1077
+ else
1078
+ saveRefreshedProvider(store, provider);
1079
+ }
1080
+ finally {
1081
+ if (opts.dryRun)
1082
+ setDryRun(wasDryRun);
1083
+ }
1023
1084
  }
1024
1085
  catch (err) {
1025
- // One unreachable endpoint must not strand the others; a single provider still throws.
1026
- if (!many)
1086
+ // One unreachable endpoint must not strand the others; text mode still throws for one.
1087
+ if (!many && !isJson())
1027
1088
  throw err;
1028
1089
  warn(t("sync.refreshSkipped", { id, message: err.message }));
1029
1090
  process.exitCode = 1;
1091
+ failed.push(id);
1030
1092
  reports.push({ provider: id, refreshFailed: true });
1031
1093
  continue;
1032
1094
  }
@@ -1038,7 +1100,17 @@ export async function cmdDiscover(ids, opts) {
1038
1100
  warn(t("refresh.pinnedMissing", { models: refresh.missing.join(", "), id }));
1039
1101
  }
1040
1102
  if (isJson()) {
1041
- emitJson(many ? { providers: reports } : (reports[0] ?? {}));
1103
+ if (opts.sync && refreshed.length) {
1104
+ // Collect application results without emitting or fetching the discovered lists again.
1105
+ const synced = await syncProviders({ provider: refreshed.join(","), apps: opts.apps, refresh: false, keepDefault: opts.keepDefault, dryRun: opts.dryRun }, store);
1106
+ failed.push(...synced.failed);
1107
+ for (const report of reports)
1108
+ Object.assign(report, synced.reports.find((entry) => entry.provider === report.provider));
1109
+ }
1110
+ const files = [...new Set(reports.flatMap((report) => report.files ?? []))];
1111
+ emitJson(many
1112
+ ? { providers: reports, ...(opts.sync ? { files, dryRun: opts.dryRun === true } : {}) }
1113
+ : { ...(reports[0] ?? {}), ...(opts.sync ? { dryRun: opts.dryRun === true } : {}) }, failed.length ? t("discover.failed", { ids: failed.join(", ") }) : undefined);
1042
1114
  return;
1043
1115
  }
1044
1116
  if (many)
@@ -1046,7 +1118,7 @@ export async function cmdDiscover(ids, opts) {
1046
1118
  if (opts.sync && refreshed.length) {
1047
1119
  out("");
1048
1120
  // The lists are already fresh: write them without a second fetch.
1049
- await cmdSync({ provider: refreshed.join(","), apps: opts.apps, refresh: false });
1121
+ await syncProviders({ provider: refreshed.join(","), apps: opts.apps, refresh: false, keepDefault: opts.keepDefault, dryRun: opts.dryRun }, store);
1050
1122
  }
1051
1123
  else if (!opts.sync) {
1052
1124
  note(`\n${t("discover.next")}`);
@@ -1309,8 +1381,9 @@ export async function cmdImport(opts) {
1309
1381
  continue;
1310
1382
  }
1311
1383
  let ids = [...c.models];
1384
+ let endpointLimits = new Map();
1312
1385
  if (ids.length === 0) {
1313
- const discover = () => withProgress(t("import.discovering", { id: c.id }), (progress) => discoverProviderModels({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol }, {
1386
+ const discover = () => withProgress(t("import.discovering", { id: c.id }), (progress) => discoverProviderCatalog({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol }, {
1314
1387
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: c.baseUrl, page, count })),
1315
1388
  }));
1316
1389
  let listed;
@@ -1334,14 +1407,15 @@ export async function cmdImport(opts) {
1334
1407
  out(pc.yellow(t("import.skippedAfterFailure", { id: c.id })));
1335
1408
  continue;
1336
1409
  }
1337
- ids = listed;
1410
+ ids = listed.ids;
1411
+ endpointLimits = listed.limits;
1338
1412
  ids = applyModelFilter(ids, undefined, []).kept;
1339
1413
  }
1340
1414
  if (ids.length === 0)
1341
1415
  fail(t("import.noModelsImport", { id: c.id }));
1342
1416
  const id = availableProviderId(c.id, store.providers);
1343
1417
  const hint = guessProviderHint(catalog, c.baseUrl);
1344
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader }));
1418
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader, endpointLimits }));
1345
1419
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
1346
1420
  const existed = store.providers[id] !== undefined;
1347
1421
  store.providers[id] = {
package/dist/discover.js CHANGED
@@ -13,6 +13,32 @@ function modelListUrl(baseUrl) {
13
13
  }
14
14
  /** Hard cap on model-list pages; an endpoint emitting endless fresh cursors must not loop forever. */
15
15
  export const MAX_MODEL_PAGES = 100;
16
+ /**
17
+ * Spellings endpoints use for the same limit, in priority order: the first
18
+ * declared key wins when a row carries several. Providers do not agree on
19
+ * names — an OpenAI-compatible gateway publishes context_window_tokens /
20
+ * maximum_output_tokens, OpenRouter publishes context_length /
21
+ * max_output_tokens — and a row without the canonical name would otherwise
22
+ * contribute nothing.
23
+ */
24
+ const contextWindowKeys = ["context_window_tokens", "context_length", "max_context_length"];
25
+ const maxOutputKeys = ["maximum_output_tokens", "max_output_tokens", "max_completion_tokens"];
26
+ function declaredLimit(row, keys) {
27
+ for (const key of keys) {
28
+ const value = row[key];
29
+ if (typeof value === "number" && Number.isFinite(value) && value > 0)
30
+ return Math.floor(value);
31
+ }
32
+ return undefined;
33
+ }
34
+ /** Limits a model row advertises, or undefined when it describes nothing but its id. */
35
+ export function parseEndpointLimits(row) {
36
+ const contextWindow = declaredLimit(row, contextWindowKeys);
37
+ const maxOutput = declaredLimit(row, maxOutputKeys);
38
+ if (contextWindow === undefined && maxOutput === undefined)
39
+ return undefined;
40
+ return { ...(contextWindow !== undefined ? { contextWindow } : {}), ...(maxOutput !== undefined ? { maxOutput } : {}) };
41
+ }
16
42
  function parseModelPage(body) {
17
43
  if (!body || typeof body !== "object")
18
44
  throw new Error("invalid model-list response");
@@ -22,25 +48,36 @@ function parseModelPage(body) {
22
48
  throw new Error("model-list response has no model array");
23
49
  if (page.has_more !== undefined && typeof page.has_more !== "boolean")
24
50
  throw new Error("invalid model-list pagination flag");
25
- const ids = rows.flatMap((row) => {
51
+ const ids = [];
52
+ const limits = new Map();
53
+ for (const row of rows) {
26
54
  if (!row || typeof row !== "object")
27
- return [];
55
+ continue;
28
56
  const id = row.id;
29
- return typeof id === "string" && id.length > 0 ? [id] : [];
30
- });
31
- return { ids, hasMore: page.has_more === true, lastId: typeof page.last_id === "string" ? page.last_id : undefined };
57
+ if (typeof id !== "string" || id.length === 0)
58
+ continue;
59
+ ids.push(id);
60
+ const declared = parseEndpointLimits(row);
61
+ if (declared)
62
+ limits.set(id, declared);
63
+ }
64
+ return { ids, limits, hasMore: page.has_more === true, lastId: typeof page.last_id === "string" ? page.last_id : undefined };
32
65
  }
33
66
  /**
34
- * List model ids from the provider's own /v1/models endpoint.
35
- * Resellers expose the id list here but no metadata (context, thinking, modalities) —
36
- * metadata is filled from models.dev afterwards (enrichModels).
67
+ * List the provider's own /v1/models catalog: model ids plus the limits a row
68
+ * declares for itself. Plenty of resellers expose bare ids — those ids carry no
69
+ * entry in `limits` and metadata is filled from models.dev afterwards
70
+ * (enrichModels). An endpoint that does declare limits is authoritative for its
71
+ * own route, so those numbers outrank the catalogs instead of being replaced by
72
+ * whatever a third party lists for the same model.
37
73
  */
38
- export async function discoverProviderModels(opts, hooks = {}) {
74
+ export async function discoverProviderCatalog(opts, hooks = {}) {
39
75
  const url = modelListUrl(opts.baseUrl);
40
76
  const headers = opts.protocol === "anthropic"
41
77
  ? { "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" }
42
78
  : { authorization: `Bearer ${opts.apiKey}` };
43
79
  const ids = new Set();
80
+ const limits = new Map();
44
81
  const cursors = new Set();
45
82
  const initialCursor = url.searchParams.get("after_id");
46
83
  if (initialCursor)
@@ -55,6 +92,9 @@ export async function discoverProviderModels(opts, hooks = {}) {
55
92
  const parsed = parseModelPage(await res.json());
56
93
  for (const id of parsed.ids)
57
94
  ids.add(id);
95
+ for (const [id, declared] of parsed.limits)
96
+ if (ids.has(id))
97
+ limits.set(id, declared);
58
98
  hooks.onPage?.({ page, count: ids.size });
59
99
  if (!parsed.hasMore)
60
100
  break;
@@ -67,7 +107,11 @@ export async function discoverProviderModels(opts, hooks = {}) {
67
107
  }
68
108
  if (ids.size === 0)
69
109
  throw new Error("provider returned no models");
70
- return [...ids].sort();
110
+ return { ids: [...ids].sort(), limits };
111
+ }
112
+ /** The listed ids alone, for callers that only need the model list. */
113
+ export async function discoverProviderModels(opts, hooks = {}) {
114
+ return (await discoverProviderCatalog(opts, hooks)).ids;
71
115
  }
72
116
  /**
73
117
  * Probe which wire protocols an endpoint supports by hitting /v1/models
package/dist/i18n.js CHANGED
@@ -63,6 +63,11 @@ const messages = {
63
63
  "opt.showProvider": { en: "show models of a configured provider", "zh-CN": "显示已配置供应商的模型" },
64
64
  "opt.refresh": { en: "force-refresh models.dev cache", "zh-CN": "强制刷新 models.dev 缓存" },
65
65
  "opt.noRefresh": { en: "skip the model-list refresh; write the saved list only", "zh-CN": "跳过模型列表刷新;只写入已保存的列表" },
66
+ "opt.keepDefault": { en: "sync providers/models only; leave app settings and default selection untouched (pi/prime only; discover requires --sync)", "zh-CN": "仅同步供应商/模型,保持应用设置和默认选择不变(仅 pi/prime;discover 需搭配 --sync)" },
67
+ "sync.keepDefaultUnsupported": { en: "--keep-default is not supported by: {apps}; select only --apps pi,prime", "zh-CN": "以下应用不支持 --keep-default:{apps};请仅选择 --apps pi,prime" },
68
+ "discover.keepDefaultRequiresSync": { en: "discover --keep-default requires --sync", "zh-CN": "discover --keep-default 需搭配 --sync" },
69
+ "sync.keepDefaultMissing": { en: "{file}: --keep-default stopped: current default {id}/{model} is missing from the synchronized model list; keep this model explicitly pinned or select an available default before retrying. No app files were changed.", "zh-CN": "{file}:--keep-default 已停止:当前默认 {id}/{model} 未出现在同步模型列表中;请明确保留此模型,或先选择可用默认模型再重试。应用文件未修改。" },
70
+ "sync.syncingKeepDefault": { en: "syncing {id} providers/models; app defaults stay unchanged", "zh-CN": "同步 {id} 的供应商/模型;应用默认选择保持不变" },
66
71
  "opt.limit": { en: "maximum results", "zh-CN": "最大结果数" },
67
72
  "opt.syncAfter": { en: "push refreshed provider into app configs", "zh-CN": "刷新后推送到应用配置" },
68
73
  "opt.appsSync": { en: "apps to sync when --sync is set", "zh-CN": "--sync 时要同步的应用" },
@@ -248,9 +253,12 @@ const messages = {
248
253
  "select.noneSelected": { en: "the provider selection is empty; name at least one id", "zh-CN": "未指定任何供应商,至少需要一个 id" },
249
254
  "sync.refreshSkipped": { en: "skipping {id}: {message}", "zh-CN": "已跳过 {id}:{message}" },
250
255
  "sync.summaryOk": { en: "synced {count} providers", "zh-CN": "已同步 {count} 个供应商" },
251
- "sync.summaryFailed": { en: "synced {ok} of {count} providers; the rest were skipped", "zh-CN": "已同步 {count} 个中的 {ok} 个供应商,其余已跳过" },
256
+ "sync.summaryFailed": { en: "synced {ok} of {count} providers; the rest failed", "zh-CN": "已同步 {count} 个中的 {ok} 个供应商,其余失败" },
252
257
  "sync.refreshNotSaved": { en: "dry run: the refreshed model list was not saved", "zh-CN": "试运行:刷新到的模型列表未保存" },
253
258
  "sync.refreshFailed": { en: "could not refresh the model list; pass --no-refresh to write the saved list instead", "zh-CN": "无法刷新模型列表;可以加 --no-refresh 只写入已保存的列表" },
259
+ "sync.failed": { en: "sync failed for providers: {ids}", "zh-CN": "供应商同步失败:{ids}" },
260
+ "targets.failed": { en: "failed targets: {apps}", "zh-CN": "目标应用失败:{apps}" },
261
+ "targets.failedSafely": { en: "target operation failed", "zh-CN": "目标应用操作失败" },
254
262
  "status.config": { en: "config: {file}", "zh-CN": "配置:{file}" },
255
263
  "status.active": { en: "active provider: {id}", "zh-CN": "当前供应商:{id}" },
256
264
  "status.none": { en: "(none)", "zh-CN": "(无)" },
@@ -387,6 +395,7 @@ const messages = {
387
395
  "upgrade.failed": { en: "{id} upgrade failed: {message}", "zh-CN": "{id} 升级失败:{message}" },
388
396
  "discover.summaryOk": { en: "refreshed {count} providers", "zh-CN": "已刷新 {count} 个供应商" },
389
397
  "discover.summaryFailed": { en: "refreshed {ok} of {count} providers; the rest were skipped", "zh-CN": "已刷新 {count} 个中的 {ok} 个供应商,其余已跳过" },
398
+ "discover.failed": { en: "discovery or sync failed for providers: {ids}", "zh-CN": "供应商发现或同步失败:{ids}" },
390
399
  "refresh.pinnedMissing": {
391
400
  en: "{models} no longer listed by the endpoint; kept as configured — pick a listed model with: agentsw use {id} -m <model>",
392
401
  "zh-CN": "{models} 已不在端点的模型列表中;仍按原配置保留 —— 可用 agentsw use {id} -m <model> 换成列表中的模型",
package/dist/index.js CHANGED
@@ -145,6 +145,7 @@ program
145
145
  .option("--all", t("opt.syncAll"))
146
146
  .option("-n, --dry-run", t("opt.dryRun"))
147
147
  .option("--no-refresh", t("opt.noRefresh"))
148
+ .option("--keep-default", t("opt.keepDefault"))
148
149
  .action(cmdSync);
149
150
  program.command("status").description(t("cmd.status")).action(cmdStatus);
150
151
  program
@@ -162,6 +163,8 @@ program
162
163
  .option("--all", t("opt.refreshAll"))
163
164
  .option("-s, --sync", t("opt.syncAfter"))
164
165
  .option("-a, --apps <apps>", t("opt.appsSync"))
166
+ .option("--keep-default", t("opt.keepDefault"))
167
+ .option("-n, --dry-run", t("opt.dryRun"))
165
168
  .option("--include <globs>", t("opt.setInclude"))
166
169
  .option("--exclude <globs>", t("opt.setExclude"))
167
170
  .option("--dedup", t("opt.dedup"))
package/dist/metadata.js CHANGED
@@ -70,6 +70,15 @@ const fields = [
70
70
  "cost.input", "cost.output", "cost.cacheRead", "cost.cacheWrite",
71
71
  ];
72
72
  const gatewayFields = fields.filter((field) => !field.startsWith("cost."));
73
+ /** Limits are all an endpoint is trusted for: its listing describes its own wire, not capabilities. */
74
+ const endpointFields = ["contextWindow", "maxOutput"];
75
+ /**
76
+ * Which source owns a field it proposes. An endpoint that declares its own
77
+ * limits outranks both catalogs — models.dev describes the model, the public
78
+ * Gateway describes *that* gateway, and neither is the route serving the
79
+ * request. models.dev outranks Gateway, which only fills what it is given.
80
+ */
81
+ const sourceRank = { endpoint: 3, "models.dev": 2, "ai-gateway": 1 };
73
82
  const limits = ["maxInput", "maxOutput"];
74
83
  const coreFields = ["contextWindow", "maxOutput", "reasoning", "imageInput"];
75
84
  function fieldValue(spec, field) {
@@ -244,7 +253,9 @@ function clearStaleIdentity(spec, modelId) {
244
253
  return;
245
254
  for (const field of fields) {
246
255
  const origin = metadata.fields?.[field];
247
- if (!origin || origin.modelId === modelId)
256
+ // Endpoint limits are keyed by the provider's own id, not by a catalog row:
257
+ // a catalog id that differs says nothing about the route's own numbers.
258
+ if (!origin || origin.source === "endpoint" || origin.modelId === modelId)
248
259
  continue;
249
260
  if (equalValue(fieldValue(spec, field), origin.value))
250
261
  setField(spec, field, undefined);
@@ -252,12 +263,13 @@ function clearStaleIdentity(spec, modelId) {
252
263
  }
253
264
  if (metadata.gateway && metadata.gateway.modelId !== modelId)
254
265
  delete metadata.gateway;
255
- if (metadata.conflicts)
256
- metadata.conflicts = metadata.conflicts.filter((conflict) => conflict.modelId === modelId);
266
+ if (metadata.conflicts) {
267
+ metadata.conflicts = metadata.conflicts.filter((conflict) => conflict.source === "endpoint" || conflict.modelId === modelId);
268
+ }
257
269
  }
258
270
  /** Apply a source as a set of field proposals, checking bounds against retained values first. */
259
271
  function mergeSource(spec, incoming, source, modelId, now, fetchedAt, supplementalGateway) {
260
- const sourceFields = source === "ai-gateway" ? gatewayFields : fields;
272
+ const sourceFields = source === "endpoint" ? [...endpointFields] : source === "ai-gateway" ? gatewayFields : fields;
261
273
  const proposals = new Map();
262
274
  const conflicts = [];
263
275
  const conflict = (field, value, keptValue, blockedBy) => {
@@ -272,7 +284,7 @@ function mergeSource(spec, incoming, source, modelId, now, fetchedAt, supplement
272
284
  continue;
273
285
  const current = fieldValue(spec, field);
274
286
  const origin = spec.metadata?.fields?.[field];
275
- if (current === undefined || (origin && (source === "models.dev" || origin.source === source))) {
287
+ if (current === undefined || (origin && sourceRank[source] >= sourceRank[origin.source])) {
276
288
  proposals.set(field, value);
277
289
  }
278
290
  else if (!equalValue(current, value)) {
@@ -293,6 +305,18 @@ function mergeSource(spec, incoming, source, modelId, now, fetchedAt, supplement
293
305
  if (!primaryFits && typeof next === "number" && next <= context)
294
306
  retained = next;
295
307
  if (typeof retained === "number" && retained > context) {
308
+ // The endpoint's own window is authoritative for its route: a retained
309
+ // limit it cannot hold is a catalog estimate that would otherwise pin
310
+ // the window open, so the limit goes and the window wins. A weaker
311
+ // source keeps the old outcome — the wider window stays, the proposal
312
+ // is reported, because nothing here proves the retained limit wrong.
313
+ if (source === "endpoint") {
314
+ conflict(field, retained, context, "contextWindow");
315
+ setField(spec, field, undefined);
316
+ if (spec.metadata?.fields)
317
+ delete spec.metadata.fields[field];
318
+ continue;
319
+ }
296
320
  conflict("contextWindow", context, retained, field);
297
321
  proposals.delete("contextWindow");
298
322
  context = spec.contextWindow;
@@ -361,6 +385,12 @@ export async function enrichProviderModels(catalog, ids, settings, options = {})
361
385
  if (origin && !equalValue(fieldValue(spec, field), origin.value))
362
386
  delete spec.metadata.fields[field];
363
387
  }
388
+ // The endpoint's own numbers go in first so the catalogs can only fill
389
+ // what it left unsaid; a catalog that disagrees is reported as a conflict
390
+ // instead of overwriting the limits the route actually enforces.
391
+ const declared = options.endpointLimits?.get(id);
392
+ if (declared)
393
+ mergeSource(spec, { id, ...declared }, "endpoint", id, now);
364
394
  let primary;
365
395
  let supplement;
366
396
  let match;
@@ -8,8 +8,8 @@ export function transactionalTarget(target) {
8
8
  const wrapped = Object.create(Object.getPrototypeOf(target), Object.getOwnPropertyDescriptors(target));
9
9
  for (const method of ["apply", "prune"]) {
10
10
  const operation = target[method];
11
- wrapped[method] = async function (provider) {
12
- const staged = await stageFileWrites(() => operation.call(this, provider));
11
+ wrapped[method] = async function (provider, options) {
12
+ const staged = await stageFileWrites(() => operation.call(this, provider, options));
13
13
  const committed = commitFileChanges(staged.changes, { dryRun: staged.dryRun, reads: staged.reads });
14
14
  if (staged.dryRun)
15
15
  recordPendingWrites(staged.changes);
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, SafeConfigError, writeFileAtomic } from "../fsutil.js";
4
4
  import { piDir, piModelsFile, piSettingsFile } from "../app-paths.js";
5
5
  import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
6
6
  import { looksLikeEnvName } from "../slug.js";
7
7
  import { transactionalTarget } from "../target-transaction.js";
8
+ import { t } from "../i18n.js";
8
9
  import { apiValue, applyDeepseekCompat, classifyApi, entryApi, mergeModels, OWNED_MODEL_METADATA_KEYS, ownedModelMetadata, sdkBaseUrl, stripConflictingOverrides, } from "./wire.js";
9
10
  /** pi writes the shared metadata plus its own thinking-level map. */
10
11
  const OWNED_MODEL_KEYS = [...OWNED_MODEL_METADATA_KEYS, "thinkingLevelMap"];
@@ -55,11 +56,12 @@ export function piStyleTarget(opts) {
55
56
  id: opts.id,
56
57
  name: opts.name,
57
58
  protocols: ["openai", "anthropic"],
59
+ supportsKeepDefault: true,
58
60
  get configPaths() {
59
61
  return [piModelsFile(id), piSettingsFile(id)];
60
62
  },
61
63
  detect: () => fs.existsSync(path.dirname(resolveDir())),
62
- async apply(provider) {
64
+ async apply(provider, options = {}) {
63
65
  const dir = resolveDir();
64
66
  const modelsFile = path.join(dir, "models.json");
65
67
  const settingsFile = path.join(dir, "settings.json");
@@ -67,6 +69,11 @@ export function piStyleTarget(opts) {
67
69
  // Validate both files before preparing any writes, including a malformed settings file.
68
70
  const modelsDocument = readModels(modelsFile);
69
71
  const settingsDocument = readSettings(settingsFile);
72
+ const current = settingsDocument.value;
73
+ if (options.keepDefault && current.defaultProvider === provider.id && current.defaultModel &&
74
+ !provider.models.some((model) => model.id === current.defaultModel)) {
75
+ throw new SafeConfigError(t("sync.keepDefaultMissing", { file: settingsFile, id: provider.id, model: current.defaultModel }));
76
+ }
70
77
  const modelsConfig = { ...modelsDocument.value };
71
78
  const providers = { ...modelsConfig.providers };
72
79
  // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
@@ -98,22 +105,25 @@ export function piStyleTarget(opts) {
98
105
  models,
99
106
  };
100
107
  modelsConfig.providers = providers;
101
- const settings = {
108
+ const modelsText = editJsoncObject(modelsDocument, modelsConfig);
109
+ const settingsText = options.keepDefault ? undefined : editJsoncObject(settingsDocument, {
102
110
  ...settingsDocument.value,
103
111
  defaultProvider: provider.id,
104
112
  defaultModel: provider.defaultModel,
105
- };
106
- const modelsText = editJsoncObject(modelsDocument, modelsConfig);
107
- const settingsText = editJsoncObject(settingsDocument, settings);
113
+ });
114
+ const changed = [modelsFile];
108
115
  const modelsBackup = backupFile(modelsFile);
109
116
  if (modelsBackup)
110
117
  notes.push(`backup: ${modelsBackup}`);
111
118
  writeFileAtomic(modelsFile, modelsText);
112
- const settingsBackup = backupFile(settingsFile);
113
- if (settingsBackup)
114
- notes.push(`backup: ${settingsBackup}`);
115
- writeFileAtomic(settingsFile, settingsText);
116
- return { app: opts.id, changed: [modelsFile, settingsFile], notes };
119
+ if (settingsText !== undefined) {
120
+ const settingsBackup = backupFile(settingsFile);
121
+ if (settingsBackup)
122
+ notes.push(`backup: ${settingsBackup}`);
123
+ writeFileAtomic(settingsFile, settingsText);
124
+ changed.push(settingsFile);
125
+ }
126
+ return { app: opts.id, changed, notes };
117
127
  },
118
128
  async prune(provider) {
119
129
  const dir = resolveDir();
package/dist/ui.js CHANGED
@@ -51,10 +51,12 @@ export function warn(message) {
51
51
  process.stderr.write(pc.yellow(`warning: ${message}`) + "\n");
52
52
  }
53
53
  function emitJsonError(message) {
54
- process.stdout.write(JSON.stringify({ ok: false, command, version, error: { message } }) + "\n");
54
+ emitJson(undefined, message);
55
55
  }
56
- export function emitJson(data) {
57
- process.stdout.write(JSON.stringify({ ok: true, command, version, data }) + "\n");
56
+ export function emitJson(data, errorMessage) {
57
+ process.stdout.write(JSON.stringify({ ok: errorMessage === undefined, command, version, data,
58
+ ...(errorMessage === undefined ? {} : { error: { message: errorMessage } }),
59
+ }) + "\n");
58
60
  }
59
61
  /** Fatal error. In machine mode the failure becomes the single stdout document, so pipes stay parseable. */
60
62
  export function error(message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
5
5
  "type": "module",
6
6
  "repository": {