agentsw 0.11.2 → 0.12.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,7 @@ 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. Limits the endpoint declares for
38
+ `contextWindow`/`maxTokens` for omp, `reasoningEfforts`/`input` for dsh. Limits and prices the endpoint declares for
39
39
  its own models outrank the catalogs, so one model cannot end up with two different context windows
40
40
  depending on which provider lists it.
41
41
  - **Adopts what you already have** — including [cc-switch](https://github.com/farion1231/cc-switch).
@@ -207,7 +207,7 @@ use `--all` explicitly in scripts. Model discovery may update the local metadata
207
207
  ## Model discovery filters
208
208
 
209
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
210
+ with automatic AI Gateway supplementation when needed. Limits and prices the provider declares for its own models in
211
211
  that same listing win over what a catalog claims for them; ids it lists bare are enriched as before. Reseller lists are noisy,
212
212
  so filters are persisted per provider and re-applied on every `discover`:
213
213
 
@@ -283,7 +283,12 @@ Import still skips already configured accounts.
283
283
  - **Field-level priority:** manual values > the endpoint's own declaration > models.dev > Gateway gap filling.
284
284
  An endpoint that advertises `context_window_tokens`/`maximum_output_tokens`
285
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
286
+ route, so those limits are written instead of a catalog's estimate for the same model. The same applies
287
+ to prices a route publishes for its own models — `input_cost_per_million_tokens_usd`,
288
+ `output_cost_per_million_tokens_usd`, `cached_input_cost_per_million_tokens_usd`,
289
+ `cache_write_cost_per_million_tokens_usd`, or the nano-USD `pricing.*_nano_usd_per_million_tokens`
290
+ form — which are stored as USD per million tokens. Rates priced per image, second or character are
291
+ not token prices, and a tier rate beside the base one is not the base price, so neither is written. Tracked automatic
287
292
  values may refresh; subsequent manual edits are retained. Legacy values without provenance are conservatively
288
293
  treated as manual overrides. Unmodeled extension fields survive enrichment too.
289
294
  - **Exact identities:** automatic lookup can map a bare, case-sensitive ID (no `/`) to a unique Gateway
@@ -320,7 +325,9 @@ Import still skips already configured accounts.
320
325
  written once rather than once per provider: it gets the active provider when it can accept it, or the single
321
326
  provider in the run that it can (Claude Code takes anthropic endpoints only, so an openai active provider does not
322
327
  starve it). With several candidates and the active provider not among them the choice is yours, so the app keeps
323
- pointing where it already does: syncing is not switching. A provider whose endpoint cannot be reached is reported
328
+ pointing where it already does: syncing is not switching. The same holds for an app that keeps a pointer of its
329
+ own — only the store's active provider may move it, so `sync -p other` writes that provider's block and models and
330
+ leaves the app's current model alone (`use` and `add` are what switch). A provider whose endpoint cannot be reached is reported
324
331
  and skipped, the rest still sync, and the run exits non-zero.
325
332
  - **Automatic lookup is not an automatic push:** enrichment updates saved metadata during add/quick/discover/import/refresh
326
333
  and never writes agent configs by itself. `sync` is the push, and it refreshes first: it fetches the provider's model
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,7 +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
+ 同一份列表里供应商为自家模型声明的上下文/输出上限与价格优先于目录的估算,只给出裸 id 的模型仍按原逻辑补全。
195
195
  转售商列表噪音大,过滤规则持久化在供应商上,每次 `discover` 自动复用:
196
196
 
197
197
  ```bash
@@ -263,7 +263,11 @@ asw sync --provider myproxy,backup-proxy
263
263
 
264
264
  - **按字段合并:** 用户手动值优先,其次端点自己声明的值,然后 models.dev,最后 Gateway 补缺。
265
265
  端点在 `/v1/models` 中为自家模型声明了 `context_window_tokens`/`maximum_output_tokens`(或
266
- `context_length`/`max_output_tokens` 写法)时,以它为准而不是目录对同一模型的估算。已记录来源的自动值可以刷新,
266
+ `context_length`/`max_output_tokens` 写法)时,以它为准而不是目录对同一模型的估算;自家发布的
267
+ 价格(`input_cost_per_million_tokens_usd`、`output_cost_per_million_tokens_usd`、
268
+ `cached_input_cost_per_million_tokens_usd`、`cache_write_cost_per_million_tokens_usd`,或 `pricing` 下的
269
+ `*_nano_usd_per_million_tokens` 纳美元写法;一律按每百万 token 美元存储)同理。按图片/秒/字符计费的不是 token 价格,
270
+ 基础价之外的档位价也不是基础价,两者都不写。已记录来源的自动值可以刷新,
267
271
  手动修改后会保留;旧版本中没有来源记录的已有值保守视为手动值,不强制覆盖。未知扩展字段也保留。
268
272
  - **精确匹配:** 不含 `/` 的裸 ID 可按大小写精确匹配唯一的 Gateway `creator/model` 身份,
269
273
  前提是 models.dev 没有身份线索或线索一致。任一目录中的创建者身份有歧义时拒绝自动匹配;
package/dist/commands.js CHANGED
@@ -203,7 +203,7 @@ export async function cmdAdd(opts) {
203
203
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
204
204
  let modelIds = manualIds;
205
205
  // Limits the endpoint declares for its own models, kept from the discovery pass.
206
- let endpointLimits = new Map();
206
+ let endpointFacts = new Map();
207
207
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
208
208
  const shouldDiscover = opts.discover || manualIds.length === 0;
209
209
  if (shouldDiscover) {
@@ -213,7 +213,7 @@ export async function cmdAdd(opts) {
213
213
  const discover = () => withProgress(t("add.discovering", { url: baseUrl }), (progress) => discoverProviderCatalog({ baseUrl, apiKey: answers.apiKey, protocol }, {
214
214
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: baseUrl, page, count })),
215
215
  }));
216
- let discovered = { ids: [], limits: new Map() };
216
+ let discovered = { ids: [], facts: new Map() };
217
217
  for (let attempt = 0;; attempt++) {
218
218
  try {
219
219
  discovered = await discover();
@@ -233,7 +233,7 @@ export async function cmdAdd(opts) {
233
233
  if (discovered.ids.length)
234
234
  out(t("add.providerLists", { count: discovered.ids.length }));
235
235
  modelIds = [...new Set([...manualIds, ...discovered.ids])];
236
- endpointLimits = discovered.limits;
236
+ endpointFacts = discovered.facts;
237
237
  }
238
238
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
239
239
  modelIds = [...new Set([...modelIds, ...retained])];
@@ -245,7 +245,7 @@ export async function cmdAdd(opts) {
245
245
  fail(t("add.atLeastOne"));
246
246
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
247
247
  const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
248
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }, { endpointLimits }));
248
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }, { endpointFacts }));
249
249
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
250
250
  const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
251
251
  if (!modelIds.includes(defaultModel))
@@ -292,7 +292,7 @@ async function createProvider(opts) {
292
292
  const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
293
293
  const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, {
294
294
  gatewayLoader: opts.gatewayLoader,
295
- endpointLimits: opts.endpointLimits,
295
+ endpointFacts: opts.endpointFacts,
296
296
  }));
297
297
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
298
298
  const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
@@ -426,7 +426,7 @@ export async function cmdQuickAdd(opts) {
426
426
  gatewayModels: opts.gatewayModels,
427
427
  metadataMode: opts.metadataMode,
428
428
  gatewayLoader,
429
- endpointLimits: discovered.limits,
429
+ endpointFacts: discovered.facts,
430
430
  });
431
431
  createdIds.push(provider.id);
432
432
  note(`\n${t("add.next", { id: provider.id })}`);
@@ -747,7 +747,7 @@ async function refreshProviderModels(store, provider, save = true) {
747
747
  const added = ids.filter((m) => !known.includes(m));
748
748
  const removed = known.filter((m) => !retained.has(m));
749
749
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
750
- provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, [...retained], { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }, { endpointLimits: listed.limits }));
750
+ provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, [...retained], { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }, { endpointFacts: listed.facts }));
751
751
  // Reported by the caller, where it reads after the table or the per-app results instead of
752
752
  // scrolling away above them.
753
753
  const missing = [...new Set(pinned)].filter((model) => !ids.includes(model));
@@ -801,9 +801,10 @@ function resolveProviderIds(store, opts, fallback) {
801
801
  return ids;
802
802
  }
803
803
  /**
804
- * Apps that name a single provider are written once (see `RunTargetsOptions.oneSlot`); for the rest
805
- * the last writer owns whatever "current" pointer the app keeps, so a multi-provider sync finishes
806
- * on the active provider and leaves the agents where the store says they already are.
804
+ * Apps that name a single provider are written once (see `RunTargetsOptions.oneSlot`); every other
805
+ * app only lets the store's active provider move its "current" pointer (see `ApplyOptions.keepSelection`),
806
+ * so a multi-provider sync finishes on the active provider and a run that does not include it leaves
807
+ * the pointer where the app already had it.
807
808
  */
808
809
  function activeLast(store, ids) {
809
810
  const active = store.active;
@@ -895,7 +896,7 @@ async function syncProviders(opts, sourceStore) {
895
896
  }
896
897
  }
897
898
  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
+ const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun, oneSlot, { keepDefault: opts.keepDefault, keepSelection: provider.id !== store.active });
899
900
  if (outcome.failed?.length)
900
901
  failed.push(id);
901
902
  if (refresh?.missing.length) {
@@ -1381,7 +1382,7 @@ export async function cmdImport(opts) {
1381
1382
  continue;
1382
1383
  }
1383
1384
  let ids = [...c.models];
1384
- let endpointLimits = new Map();
1385
+ let endpointFacts = new Map();
1385
1386
  if (ids.length === 0) {
1386
1387
  const discover = () => withProgress(t("import.discovering", { id: c.id }), (progress) => discoverProviderCatalog({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol }, {
1387
1388
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: c.baseUrl, page, count })),
@@ -1408,14 +1409,14 @@ export async function cmdImport(opts) {
1408
1409
  continue;
1409
1410
  }
1410
1411
  ids = listed.ids;
1411
- endpointLimits = listed.limits;
1412
+ endpointFacts = listed.facts;
1412
1413
  ids = applyModelFilter(ids, undefined, []).kept;
1413
1414
  }
1414
1415
  if (ids.length === 0)
1415
1416
  fail(t("import.noModelsImport", { id: c.id }));
1416
1417
  const id = availableProviderId(c.id, store.providers);
1417
1418
  const hint = guessProviderHint(catalog, c.baseUrl);
1418
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader, endpointLimits }));
1419
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader, endpointFacts }));
1419
1420
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
1420
1421
  const existed = store.providers[id] !== undefined;
1421
1422
  store.providers[id] = {
package/dist/discover.js CHANGED
@@ -14,7 +14,7 @@ function modelListUrl(baseUrl) {
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
16
  /**
17
- * Spellings endpoints use for the same limit, in priority order: the first
17
+ * Spellings endpoints use for the same fact, in priority order: the first
18
18
  * declared key wins when a row carries several. Providers do not agree on
19
19
  * names — an OpenAI-compatible gateway publishes context_window_tokens /
20
20
  * maximum_output_tokens, OpenRouter publishes context_length /
@@ -23,6 +23,35 @@ export const MAX_MODEL_PAGES = 100;
23
23
  */
24
24
  const contextWindowKeys = ["context_window_tokens", "context_length", "max_context_length"];
25
25
  const maxOutputKeys = ["maximum_output_tokens", "max_output_tokens", "max_completion_tokens"];
26
+ const costSpellings = {
27
+ input: [["input_cost_per_million_tokens_usd", 1], ["pricing.input_nano_usd_per_million_tokens", 1e9]],
28
+ output: [["output_cost_per_million_tokens_usd", 1], ["pricing.output_nano_usd_per_million_tokens", 1e9]],
29
+ cacheRead: [["cached_input_cost_per_million_tokens_usd", 1], ["pricing.cached_input_nano_usd_per_million_tokens", 1e9]],
30
+ cacheWrite: [
31
+ ["cache_write_cost_per_million_tokens_usd", 1],
32
+ ["pricing.cache_write_nano_usd_per_million_tokens", 1e9],
33
+ ["pricing.cache_creation_input_nano_usd_per_million_tokens", 1e9],
34
+ ],
35
+ };
36
+ /** Read a dotted path, stopping at the first value that is not an object. */
37
+ function readPath(row, path) {
38
+ let node = row;
39
+ for (const key of path.split(".")) {
40
+ if (!node || typeof node !== "object")
41
+ return undefined;
42
+ node = node[key];
43
+ }
44
+ return node;
45
+ }
46
+ /** A price keeps its fraction — a route charging 0.15 per million says so — and 0 is a real price. */
47
+ function declaredCost(row, spellings) {
48
+ for (const [path, divisor] of spellings) {
49
+ const value = readPath(row, path);
50
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
51
+ return value / divisor;
52
+ }
53
+ return undefined;
54
+ }
26
55
  function declaredLimit(row, keys) {
27
56
  for (const key of keys) {
28
57
  const value = row[key];
@@ -36,13 +65,28 @@ function declaredLimit(row, keys) {
36
65
  }
37
66
  return undefined;
38
67
  }
39
- /** Limits a model row advertises, or undefined when it describes nothing but its id. */
40
- export function parseEndpointLimits(row) {
68
+ /** What a model row declares about its own route, or undefined when it describes nothing but its id. */
69
+ export function parseEndpointFacts(row) {
41
70
  const contextWindow = declaredLimit(row, contextWindowKeys);
42
71
  const maxOutput = declaredLimit(row, maxOutputKeys);
43
- if (contextWindow === undefined && maxOutput === undefined)
72
+ const input = declaredCost(row, costSpellings.input);
73
+ const output = declaredCost(row, costSpellings.output);
74
+ const cacheRead = declaredCost(row, costSpellings.cacheRead);
75
+ const cacheWrite = declaredCost(row, costSpellings.cacheWrite);
76
+ const costs = {
77
+ ...(input !== undefined ? { input } : {}),
78
+ ...(output !== undefined ? { output } : {}),
79
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
80
+ ...(cacheWrite !== undefined ? { cacheWrite } : {}),
81
+ };
82
+ const hasCost = Object.keys(costs).length > 0;
83
+ if (contextWindow === undefined && maxOutput === undefined && !hasCost)
44
84
  return undefined;
45
- return { ...(contextWindow !== undefined ? { contextWindow } : {}), ...(maxOutput !== undefined ? { maxOutput } : {}) };
85
+ return {
86
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
87
+ ...(maxOutput !== undefined ? { maxOutput } : {}),
88
+ ...(hasCost ? { cost: costs } : {}),
89
+ };
46
90
  }
47
91
  function parseModelPage(body) {
48
92
  if (!body || typeof body !== "object")
@@ -54,7 +98,7 @@ function parseModelPage(body) {
54
98
  if (page.has_more !== undefined && typeof page.has_more !== "boolean")
55
99
  throw new Error("invalid model-list pagination flag");
56
100
  const ids = [];
57
- const limits = new Map();
101
+ const facts = new Map();
58
102
  for (const row of rows) {
59
103
  if (!row || typeof row !== "object")
60
104
  continue;
@@ -62,19 +106,19 @@ function parseModelPage(body) {
62
106
  if (typeof id !== "string" || id.length === 0)
63
107
  continue;
64
108
  ids.push(id);
65
- const declared = parseEndpointLimits(row);
109
+ const declared = parseEndpointFacts(row);
66
110
  if (declared)
67
- limits.set(id, declared);
111
+ facts.set(id, declared);
68
112
  }
69
- return { ids, limits, hasMore: page.has_more === true, lastId: typeof page.last_id === "string" ? page.last_id : undefined };
113
+ return { ids, facts, hasMore: page.has_more === true, lastId: typeof page.last_id === "string" ? page.last_id : undefined };
70
114
  }
71
115
  /**
72
- * List the provider's own /v1/models catalog: model ids plus the limits a row
73
- * declares for itself. Plenty of resellers expose bare ids — those ids carry no
74
- * entry in `limits` and metadata is filled from models.dev afterwards
75
- * (enrichModels). An endpoint that does declare limits is authoritative for its
76
- * own route, so those numbers outrank the catalogs instead of being replaced by
77
- * whatever a third party lists for the same model.
116
+ * List the provider's own /v1/models catalog: model ids plus the limits and
117
+ * prices a row declares for itself. Plenty of resellers expose bare ids — those
118
+ * ids carry no entry in `facts` and metadata is filled from models.dev
119
+ * afterwards (enrichModels). An endpoint that declares its own numbers is
120
+ * authoritative for its own route, so they outrank the catalogs instead of
121
+ * being replaced by whatever a third party lists for the same model.
78
122
  */
79
123
  export async function discoverProviderCatalog(opts, hooks = {}) {
80
124
  const url = modelListUrl(opts.baseUrl);
@@ -82,7 +126,7 @@ export async function discoverProviderCatalog(opts, hooks = {}) {
82
126
  ? { "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" }
83
127
  : { authorization: `Bearer ${opts.apiKey}` };
84
128
  const ids = new Set();
85
- const limits = new Map();
129
+ const facts = new Map();
86
130
  const cursors = new Set();
87
131
  const initialCursor = url.searchParams.get("after_id");
88
132
  if (initialCursor)
@@ -99,11 +143,11 @@ export async function discoverProviderCatalog(opts, hooks = {}) {
99
143
  ids.add(id);
100
144
  // A repeated id keeps what an earlier page declared: merge per key so a
101
145
  // later partial row cannot drop the other limit.
102
- for (const [id, declared] of parsed.limits) {
146
+ for (const [id, declared] of parsed.facts) {
103
147
  if (!ids.has(id))
104
148
  continue;
105
- const earlier = limits.get(id);
106
- limits.set(id, earlier ? { ...earlier, ...declared } : declared);
149
+ const earlier = facts.get(id);
150
+ facts.set(id, earlier ? { ...earlier, ...declared } : declared);
107
151
  }
108
152
  hooks.onPage?.({ page, count: ids.size });
109
153
  if (!parsed.hasMore)
@@ -117,7 +161,7 @@ export async function discoverProviderCatalog(opts, hooks = {}) {
117
161
  }
118
162
  if (ids.size === 0)
119
163
  throw new Error("provider returned no models");
120
- return { ids: [...ids].sort(), limits };
164
+ return { ids: [...ids].sort(), facts };
121
165
  }
122
166
  /** The listed ids alone, for callers that only need the model list. */
123
167
  export async function discoverProviderModels(opts, hooks = {}) {
package/dist/metadata.js CHANGED
@@ -70,13 +70,13 @@ 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"];
73
+ /** What a wire is trusted for: the limits it enforces and the prices it charges, not capabilities. */
74
+ const endpointFields = ["contextWindow", "maxOutput", "cost.input", "cost.output", "cost.cacheRead", "cost.cacheWrite"];
75
75
  /**
76
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.
77
+ * limits and prices outranks both catalogs — models.dev describes the model,
78
+ * the public Gateway describes *that* gateway, and neither is the route serving
79
+ * the request. models.dev outranks Gateway, which only fills what it is given.
80
80
  */
81
81
  const sourceRank = { endpoint: 3, "models.dev": 2, "ai-gateway": 1 };
82
82
  const limits = ["maxInput", "maxOutput"];
@@ -387,8 +387,8 @@ export async function enrichProviderModels(catalog, ids, settings, options = {})
387
387
  }
388
388
  // The endpoint's own numbers go in first so the catalogs can only fill
389
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);
390
+ // instead of overwriting the numbers the route actually enforces.
391
+ const declared = options.endpointFacts?.get(id);
392
392
  if (declared)
393
393
  mergeSource(spec, { id, ...declared }, "endpoint", id, now);
394
394
  let primary;
@@ -29,7 +29,7 @@ export const codex = transactionalTarget({
29
29
  return [codexConfigFile(), codexAuthFile()];
30
30
  },
31
31
  detect: () => fs.existsSync(codexDir()),
32
- async apply(provider) {
32
+ async apply(provider, options = {}) {
33
33
  const configFile = codexConfigFile();
34
34
  const authFile = codexAuthFile();
35
35
  const notes = [];
@@ -42,16 +42,18 @@ export const codex = transactionalTarget({
42
42
  if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
43
43
  throw new Error(`${configFile}: expected model_providers to be a table`);
44
44
  }
45
- config.model_provider = provider.id;
46
- config.model = provider.defaultModel;
47
- const model = provider.models.find((m) => m.id === provider.defaultModel);
48
- if (provider.reasoningEffort && model?.reasoning !== false) {
49
- config.model_reasoning_effort = provider.reasoningEffort;
50
- }
51
- else {
52
- // The effort belongs to the active model: switching providers must not
53
- // inherit the previous provider's level (prune treats it as owned too).
54
- delete config.model_reasoning_effort;
45
+ if (!options.keepSelection) {
46
+ config.model_provider = provider.id;
47
+ config.model = provider.defaultModel;
48
+ const model = provider.models.find((m) => m.id === provider.defaultModel);
49
+ if (provider.reasoningEffort && model?.reasoning !== false) {
50
+ config.model_reasoning_effort = provider.reasoningEffort;
51
+ }
52
+ else {
53
+ // The effort belongs to the active model: switching providers must not
54
+ // inherit the previous provider's level (prune treats it as owned too).
55
+ delete config.model_reasoning_effort;
56
+ }
55
57
  }
56
58
  // Codex only speaks the OpenAI Responses API (wire_api = "chat" was removed Feb 2026).
57
59
  // requires_openai_auth = true makes Codex take the key from auth.json instead of an env var.
@@ -68,7 +68,7 @@ export const dsh = transactionalTarget({
68
68
  return [dshSettingsFile(), dshCredentialsFile()];
69
69
  },
70
70
  detect: () => fs.existsSync(dshDir()),
71
- async apply(provider) {
71
+ async apply(provider, options = {}) {
72
72
  const notes = [];
73
73
  const file = dshSettingsFile();
74
74
  const doc = parseSettings(file);
@@ -117,7 +117,9 @@ export const dsh = transactionalTarget({
117
117
  notes.push("dropped modelOverrides (llm-pi-ai refuses it beside an explicit models list)");
118
118
  }
119
119
  // The picker writes this section whole; a partial write would keep a stale reasoningEffort.
120
- doc.set("agent-default-model", doc.createNode({ provider: provider.id, model: provider.defaultModel }));
120
+ if (!options.keepSelection) {
121
+ doc.set("agent-default-model", doc.createNode({ provider: provider.id, model: provider.defaultModel }));
122
+ }
121
123
  const referenced = credentialReferences(doc, provider.id);
122
124
  const oldRef = typeof prev?.apiKeyEnv === "string" && isManagedCredentialRef(prev.apiKeyEnv, provider.id) && !referenced.has(prev.apiKeyEnv)
123
125
  ? prev.apiKeyEnv : undefined;
@@ -54,7 +54,7 @@ export const hermes = transactionalTarget({
54
54
  protocols: ["openai", "anthropic"],
55
55
  configPaths: [hermesConfigFile(), hermesEnvFile()],
56
56
  detect: () => fs.existsSync(hermesHome()),
57
- async apply(provider) {
57
+ async apply(provider, options = {}) {
58
58
  const configFile = hermesConfigFile();
59
59
  const envFile = hermesEnvFile();
60
60
  const notes = [];
@@ -101,8 +101,10 @@ export const hermes = transactionalTarget({
101
101
  else
102
102
  doc.deleteIn([...modelAt, "context_length"]);
103
103
  }
104
- doc.setIn(["model", "provider"], provider.id);
105
- doc.setIn(["model", "default"], provider.defaultModel);
104
+ if (!options.keepSelection) {
105
+ doc.setIn(["model", "provider"], provider.id);
106
+ doc.setIn(["model", "default"], provider.defaultModel);
107
+ }
106
108
  const envText = readTextIfExists(envFile) ?? "";
107
109
  const assignments = envAssignments(envFile, envText);
108
110
  const referenced = credentialReferences(doc, provider.id);
@@ -19,7 +19,7 @@ export const opencode = transactionalTarget({
19
19
  },
20
20
  detect: () => opencodeConfigFiles().some((file) => fs.existsSync(file)) ||
21
21
  opencodeConfigDirs().some((dir) => fs.existsSync(dir)),
22
- async apply(provider) {
22
+ async apply(provider, options = {}) {
23
23
  const configFile = opencodePrimaryConfigFile();
24
24
  const notes = [];
25
25
  const existed = readTextIfExists(configFile) !== undefined;
@@ -94,14 +94,16 @@ export const opencode = transactionalTarget({
94
94
  models,
95
95
  };
96
96
  config.provider = providerMap;
97
- config.model = `${provider.id}/${provider.defaultModel}`;
98
- if (provider.smallModel) {
99
- config.small_model = `${provider.id}/${provider.smallModel}`;
100
- }
101
- else if (typeof config.small_model === "string" && config.small_model.startsWith(`${provider.id}/`)) {
102
- // This provider's own previous small model is obsolete; another provider's
103
- // entry stays untouched, exactly as prune scopes its cleanup.
104
- delete config.small_model;
97
+ if (!options.keepSelection) {
98
+ config.model = `${provider.id}/${provider.defaultModel}`;
99
+ if (provider.smallModel) {
100
+ config.small_model = `${provider.id}/${provider.smallModel}`;
101
+ }
102
+ else if (typeof config.small_model === "string" && config.small_model.startsWith(`${provider.id}/`)) {
103
+ // This provider's own previous small model is obsolete; another provider's
104
+ // entry stays untouched, exactly as prune scopes its cleanup.
105
+ delete config.small_model;
106
+ }
105
107
  }
106
108
  const backup = backupFile(configFile);
107
109
  if (backup)
@@ -106,7 +106,7 @@ export function piStyleTarget(opts) {
106
106
  };
107
107
  modelsConfig.providers = providers;
108
108
  const modelsText = editJsoncObject(modelsDocument, modelsConfig);
109
- const settingsText = options.keepDefault ? undefined : editJsoncObject(settingsDocument, {
109
+ const settingsText = options.keepDefault || options.keepSelection ? undefined : editJsoncObject(settingsDocument, {
110
110
  ...settingsDocument.value,
111
111
  defaultProvider: provider.id,
112
112
  defaultModel: provider.defaultModel,
@@ -79,7 +79,7 @@ export const workbuddy = transactionalTarget({
79
79
  return [path.join(configDir(), "models.json"), path.join(configDir(), "settings.json")];
80
80
  },
81
81
  detect: () => fs.existsSync(configDir()),
82
- async apply(provider) {
82
+ async apply(provider, options = {}) {
83
83
  const dir = configDir();
84
84
  const modelsFile = path.join(dir, "models.json");
85
85
  const settingsFile = path.join(dir, "settings.json");
@@ -127,7 +127,8 @@ export const workbuddy = transactionalTarget({
127
127
  const availableModels = [...new Set([
128
128
  ...existingAvailable.filter((id) => !removedIds.has(id) || keptIds.has(id)), ...ourIds,
129
129
  ])];
130
- settings.model = provider.defaultModel;
130
+ if (!options.keepSelection)
131
+ settings.model = provider.defaultModel;
131
132
  const modelsBackup = backupFile(modelsFile);
132
133
  if (modelsBackup)
133
134
  notes.push(`backup: ${modelsBackup}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.11.2",
3
+ "version": "0.12.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": {