agentsw 0.10.0 → 0.10.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
@@ -115,7 +115,7 @@ dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.d
115
115
  | [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) | `~/.prime/agent/models.json` + `settings.json` | both |
116
116
  | [opencode](https://opencode.ai) | `~/.config/opencode/opencode.json`, `.jsonc` or `config.json` (the one that exists; comments preserved) | both |
117
117
  | [Hermes](https://pypi.org/project/hermes-agent/) | `~/.hermes/config.yaml` + `.env` (comments preserved) | both |
118
- | WorkBuddy | `~/.workbuddy/models.json` + `settings.json` | openai |
118
+ | WorkBuddy | `~/.workbuddy/models.json` + `settings.json` (`%APPDATA%\workbuddy` on Windows) | openai |
119
119
  | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) | `~/.dsh/settings.yaml` + `.credentials.yaml` | both |
120
120
 
121
121
  An agent that is not installed is skipped, not guessed at. `--apps codex,omp` narrows a run;
@@ -242,7 +242,9 @@ Import still skips already configured accounts.
242
242
  `refresh --provider <id> --metadata-mode on` to request fresh reference prices.
243
243
  - **Auditable and optional:** `metadata.fields` records origins, value snapshots and timestamps;
244
244
  `metadata.conflicts` records disagreements with retained values. This audit stays in agentsw, never runtime agent configs.
245
- Gateway has a separate 24-hour cache; failures use stale cached data or skip supplementation.
245
+ Gateway has a separate 24-hour cache; a failed request is retried once before falling back to stale cached data
246
+ (or skipping supplementation when there is none). `AGENTSW_DEBUG=1` reports why an attempt failed, and
247
+ `AGENTSW_GATEWAY_TIMEOUT_MS` overrides the 15-second whole-request budget.
246
248
  - **Automatic lookup is not automatic sync:** enrichment updates saved metadata during add/quick/discover/import/refresh.
247
249
  It does not add an automatic push to agents. Sync behavior is unchanged: ordinary `sync` only writes saved settings
248
250
  and fetches neither model lists nor metadata catalogs.
@@ -352,7 +354,10 @@ are unaffected.
352
354
  - A sync overwrites only the fields agentsw owns. Unmodeled provider-level keys and per-model
353
355
  extras survive; an owned field that stops applying is cleared rather than left stale, and a
354
356
  per-model `api`/`baseUrl` override that contradicts the route is dropped (it would silently
355
- win over the entry) and reported.
357
+ win over the entry) and reported. Clearing needs the entry to say something in the first
358
+ place: a model the store knows nothing about — an id no catalog row matched — is written as
359
+ an id and its capabilities alone, and the limits already in the file are kept rather than
360
+ deleted.
356
361
  - YAML configs (omp, Hermes, dsh) keep their comments. `~/.codex/config.toml` does not —
357
362
  TOML round-trip drops them, which is why the backup happens first.
358
363
  - OpenAI endpoints come in two wires: `/v1/chat/completions` and `/v1/responses`. Imported
package/README.zh-CN.md CHANGED
@@ -110,7 +110,7 @@ dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.d
110
110
  | [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) | `~/.prime/agent/models.json` + `settings.json` | 双协议 |
111
111
  | [opencode](https://opencode.ai) | `~/.config/opencode/` 下已存在的 `opencode.json`、`.jsonc` 或 `config.json`(保留注释) | 双协议 |
112
112
  | [Hermes](https://pypi.org/project/hermes-agent/) | `~/.hermes/config.yaml` + `.env`(保留注释) | 双协议 |
113
- | WorkBuddy | `~/.workbuddy/models.json` + `settings.json` | openai |
113
+ | WorkBuddy | `~/.workbuddy/models.json` + `settings.json`(Windows 下为 `%APPDATA%\workbuddy`) | openai |
114
114
  | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) | `~/.dsh/settings.yaml` + `.credentials.yaml` | 双协议 |
115
115
 
116
116
  未安装的应用会被跳过,而不是瞎猜。`--apps codex,omp` 只跑指定应用;
@@ -227,7 +227,9 @@ asw sync --provider myproxy
227
227
  自动模式不会仅为刷新参考价格而查询:可查看 `metadata.gateway.fetchedAt` 判断快照时间,
228
228
  需要重新查询参考价格时使用 `refresh --provider <id> --metadata-mode on`。
229
229
  - **可追溯、可降级:** `metadata.fields` 记录来源、原值快照和时间,`metadata.conflicts` 记录保留值与冲突。
230
- 审计信息仅保存在 agentsw,不写入智能体运行配置。Gateway 目录独立缓存 24 小时;请求失败可使用旧缓存,无缓存时只跳过补充。
230
+ 审计信息仅保存在 agentsw,不写入智能体运行配置。Gateway 目录独立缓存 24 小时;请求失败会先重试一次,
231
+ 仍失败才使用旧缓存,无缓存时只跳过补充。`AGENTSW_DEBUG=1` 会说明该次尝试失败的原因,
232
+ `AGENTSW_GATEWAY_TIMEOUT_MS` 可覆盖默认 15 秒的整体请求预算。
231
233
  - **自动查询不等于自动同步:** 添加、快速添加、发现、导入、刷新时补全保存的元数据,并不新增自动推送。
232
234
  同步行为不变:普通 `sync` 仅写入已保存的设置,既不获取模型列表,也不请求元数据目录。
233
235
 
@@ -330,6 +332,8 @@ JSON 载荷一律不含凭据;`--json --dry-run` 只报告将要改动的文
330
332
  - 同步只覆盖 agentsw 自己管理的字段:未建模的供应商级键与模型级扩展字段保留;
331
333
  自己管理但本次不再写出的字段会被清除而非留成陈旧值;与新路由矛盾的模型级
332
334
  `api`/`baseUrl` 覆盖会被删除(否则它会静默盖过供应商条目)并在输出中点名。
335
+ 但"清除"要求这一条本身说了点什么:store 完全不认识的模型(没有任何目录行匹配到的 id)
336
+ 只带 id 和能力写入,文件里已有的上下文长度等参数会保留而不是被删掉。
333
337
  - YAML 配置(omp、Hermes、dsh)保留注释;`~/.codex/config.toml` 不行——TOML 往返会丢注释,
334
338
  所以才先备份。
335
339
  - OpenAI 端点有两种接口形态:`/v1/chat/completions` 与 `/v1/responses`。导入时沿用配置里
package/dist/gateway.js CHANGED
@@ -7,6 +7,13 @@ const API_URL = "https://ai-gateway.vercel.sh/v1/models";
7
7
  const CACHE_FILE = path.join(configDir, "ai-gateway.json");
8
8
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
9
9
  const MAX_BODY_BYTES = 5 * 1024 * 1024;
10
+ /** Whole-request budget. A cold handshake through a tunnel measures ~3s here, so this is generous. */
11
+ const DEFAULT_TIMEOUT_MS = 15_000;
12
+ const MAX_ATTEMPTS = 2;
13
+ const RETRY_DELAY_MS = 300;
14
+ /** The endpoint answered — a status, an oversize body, a missing one. A retry repeats the answer. */
15
+ class DefinitiveFailure extends Error {
16
+ }
10
17
  const REASONING_EFFORTS = {
11
18
  none: true, minimal: true, low: true, medium: true, high: true, xhigh: true, max: true,
12
19
  };
@@ -137,10 +144,10 @@ async function readResponseBody(response) {
137
144
  const contentLength = response.headers.get("content-length");
138
145
  if (contentLength !== null && Number(contentLength) > MAX_BODY_BYTES) {
139
146
  await response.body?.cancel().catch(() => { });
140
- throw new Error("AI Gateway catalog exceeds size limit");
147
+ throw new DefinitiveFailure("AI Gateway catalog exceeds size limit");
141
148
  }
142
149
  if (!response.body)
143
- throw new Error("Missing AI Gateway catalog body");
150
+ throw new DefinitiveFailure("Missing AI Gateway catalog body");
144
151
  const reader = response.body.getReader();
145
152
  const decoder = new TextDecoder();
146
153
  const chunks = [];
@@ -152,7 +159,7 @@ async function readResponseBody(response) {
152
159
  break;
153
160
  size += value.byteLength;
154
161
  if (size > MAX_BODY_BYTES)
155
- throw new Error("AI Gateway catalog exceeds size limit");
162
+ throw new DefinitiveFailure("AI Gateway catalog exceeds size limit");
156
163
  chunks.push(decoder.decode(value, { stream: true }));
157
164
  }
158
165
  chunks.push(decoder.decode());
@@ -166,6 +173,65 @@ async function readResponseBody(response) {
166
173
  reader.releaseLock();
167
174
  }
168
175
  }
176
+ /** Diagnostics for a failure the user cannot otherwise see; opt in with AGENTSW_DEBUG=1. */
177
+ function debug(message) {
178
+ if (process.env.AGENTSW_DEBUG !== "1")
179
+ return;
180
+ warn(message);
181
+ }
182
+ /** One-line reason for a failed attempt, for `AGENTSW_DEBUG` only — never part of the warning. */
183
+ function describeFailure(error, timeoutMs) {
184
+ if (error instanceof Error && error.name === "TimeoutError")
185
+ return `no response within ${timeoutMs}ms`;
186
+ const cause = error instanceof Error ? error.cause : undefined;
187
+ const code = cause && typeof cause === "object" && "code" in cause ? ` (${String(cause.code)})` : "";
188
+ return `${error instanceof Error ? error.message : String(error)}${code}`;
189
+ }
190
+ /**
191
+ * Whole-request budget, overridable with `AGENTSW_GATEWAY_TIMEOUT_MS`. An unusable value is
192
+ * ignored rather than fatal: this is an escape hatch for a slow route, not configuration.
193
+ */
194
+ function timeoutMs() {
195
+ const raw = process.env.AGENTSW_GATEWAY_TIMEOUT_MS;
196
+ if (raw === undefined)
197
+ return DEFAULT_TIMEOUT_MS;
198
+ const value = Number(raw);
199
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_MS;
200
+ }
201
+ async function requestOnce(budget) {
202
+ const response = await fetch(API_URL, {
203
+ signal: AbortSignal.timeout(budget),
204
+ credentials: "omit",
205
+ redirect: "error",
206
+ });
207
+ if (!response.ok) {
208
+ await response.body?.cancel().catch(() => { });
209
+ throw new DefinitiveFailure(`HTTP ${response.status}`);
210
+ }
211
+ return readResponseBody(response);
212
+ }
213
+ /**
214
+ * Read the catalog text, retrying once when the attempt failed before the endpoint could
215
+ * answer. The likeliest failure by far is a transient one: a cold TLS handshake over whatever
216
+ * route this machine has measures an order of magnitude slower than a warm one, so a stall or a
217
+ * reset is far more common than a bad answer. A `DefinitiveFailure` is not retried — the second
218
+ * request would only repeat it — and neither is a body that arrived but would not parse, which
219
+ * is why parsing stays outside this call.
220
+ */
221
+ async function requestCatalogText() {
222
+ const budget = timeoutMs();
223
+ for (let attempt = 1;; attempt++) {
224
+ try {
225
+ return await requestOnce(budget);
226
+ }
227
+ catch (error) {
228
+ if (error instanceof DefinitiveFailure || attempt >= MAX_ATTEMPTS)
229
+ throw error;
230
+ debug(`AI Gateway attempt ${attempt} failed (${describeFailure(error, budget)}); retrying`);
231
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
232
+ }
233
+ }
234
+ }
169
235
  /** Optional supplement: offline never fetches, and every failure leaves discovery usable. */
170
236
  export async function loadGatewayCatalog(opts = {}) {
171
237
  const cached = readCache();
@@ -174,16 +240,7 @@ export async function loadGatewayCatalog(opts = {}) {
174
240
  if (!opts.refresh && cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS)
175
241
  return cached;
176
242
  try {
177
- const response = await fetch(API_URL, {
178
- signal: AbortSignal.timeout(15_000),
179
- credentials: "omit",
180
- redirect: "error",
181
- });
182
- if (!response.ok) {
183
- await response.body?.cancel().catch(() => { });
184
- throw new Error("AI Gateway catalog request failed");
185
- }
186
- const body = JSON.parse(await readResponseBody(response));
243
+ const body = JSON.parse(await requestCatalogText());
187
244
  const catalog = parseGatewayCatalog(body, new Date().toISOString());
188
245
  try {
189
246
  writeFileAtomic(CACHE_FILE, JSON.stringify({ version: 1, fetchedAt: catalog.fetchedAt, body }) + "\n", 0o600);
@@ -193,7 +250,8 @@ export async function loadGatewayCatalog(opts = {}) {
193
250
  }
194
251
  return catalog;
195
252
  }
196
- catch {
253
+ catch (error) {
254
+ debug(`AI Gateway metadata unavailable: ${describeFailure(error, timeoutMs())}`);
197
255
  warn(cached
198
256
  ? "could not refresh AI Gateway metadata; using cached metadata"
199
257
  : "could not load AI Gateway metadata");
package/dist/metadata.js CHANGED
@@ -116,7 +116,10 @@ function indexCatalog(catalog) {
116
116
  for (const provider of Object.values(catalog)) {
117
117
  for (const [key, model] of Object.entries(provider.models)) {
118
118
  const modelId = model.id.includes("/") ? model.id : `${provider.id}/${model.id}`;
119
- const entry = { provider: provider.id, modelId, identity: model.canonical_model_id ?? modelId, model };
119
+ const entry = {
120
+ provider: provider.id, modelId, identity: model.canonical_model_id ?? modelId,
121
+ claimed: model.canonical_model_id !== undefined, model,
122
+ };
120
123
  for (const id of new Set([key, model.id]))
121
124
  push(exact, id, entry);
122
125
  push(canonical, modelId, entry);
@@ -125,10 +128,29 @@ function indexCatalog(catalog) {
125
128
  }
126
129
  return { exact, canonical, identities };
127
130
  }
131
+ /**
132
+ * The identities a group of catalog rows stands for.
133
+ *
134
+ * Only `canonical_model_id` is evidence. Counting the fallback guess of a row
135
+ * that declares nothing would let one sloppy reseller row invent a second
136
+ * creator and block an otherwise unique match — the id then reports as
137
+ * ambiguous even though every row that does carry the field names one model.
138
+ * A group where *nothing* declares an identity is a different situation: there
139
+ * is no primary evidence to weigh, so the listings' own ids are all the group
140
+ * can be held to, which is exactly the conservative answer. An empty group
141
+ * stays empty — that is "no evidence at all", not "one guess".
142
+ */
143
+ function claimIdentities(entries) {
144
+ const list = entries ?? [];
145
+ const claimed = new Set(list.filter((entry) => entry.claimed).map((entry) => entry.identity));
146
+ if (claimed.size)
147
+ return claimed;
148
+ return new Set(list.map((entry) => entry.identity));
149
+ }
128
150
  /** Creator identity of a catalog model id, or undefined when its rows disagree. */
129
151
  function catalogIdentity(index, modelId) {
130
- const identities = new Set(index?.canonical.get(modelId)?.map((entry) => entry.identity));
131
- return identities?.size === 1 ? [...identities][0] : undefined;
152
+ const identities = claimIdentities(index?.canonical.get(modelId));
153
+ return identities.size === 1 ? [...identities][0] : undefined;
132
154
  }
133
155
  /**
134
156
  * The creator's own listing for an identity, when models.dev carries one.
@@ -164,7 +186,7 @@ function selectExact(index, id, hint, aliases, gateway, gatewayBareIds) {
164
186
  const candidate = gatewayBareIds?.get(id);
165
187
  const evidence = index?.exact.get(id);
166
188
  const hintedEvidence = hint ? evidence?.filter((entry) => entry.provider === hint) : undefined;
167
- const identities = new Set((hintedEvidence?.length ? hintedEvidence : evidence)?.map((entry) => entry.identity));
189
+ const identities = claimIdentities(hintedEvidence?.length ? hintedEvidence : evidence);
168
190
  // Gateway names the creator while models.dev names the listing provider, so
169
191
  // the two agree only once both are reduced to a creator identity. A gateway
170
192
  // id the catalog has no row for stands for itself.
@@ -181,7 +203,7 @@ function selectExact(index, id, hint, aliases, gateway, gatewayBareIds) {
181
203
  : (bare && hint ? index?.canonical.get(`${hint}/${id}`) : undefined)
182
204
  ?? index?.canonical.get(id) ?? index?.exact.get(id);
183
205
  const hinted = candidates?.find((entry) => entry.provider === hint);
184
- const identities = new Set(candidates?.map((entry) => entry.identity));
206
+ const identities = claimIdentities(candidates);
185
207
  // Reseller rows for one model share an identity but disagree on limits and
186
208
  // prices, so only the creator's own row is authoritative.
187
209
  const primary = hinted ?? (identities.size === 1 ? creatorEntry(index, [...identities][0]) : undefined);
@@ -440,10 +462,13 @@ export function classifyUnresolved(catalog, ids, hint) {
440
462
  const candidates = hinted ?? index.canonical.get(id) ?? index.exact.get(id);
441
463
  if (!candidates?.length)
442
464
  unknown.push(id);
443
- else if (new Set(candidates.map((entry) => entry.identity)).size > 1)
444
- ambiguous.push(id);
445
- else if (!creatorEntry(index, [...new Set(candidates.map((entry) => entry.identity))][0]))
446
- noCreatorRow.push(id);
465
+ else {
466
+ const identities = claimIdentities(candidates);
467
+ if (identities.size > 1)
468
+ ambiguous.push(id);
469
+ else if (!creatorEntry(index, [...identities][0]))
470
+ noCreatorRow.push(id);
471
+ }
447
472
  }
448
473
  return { ambiguous, unknown, noCreatorRow };
449
474
  }
@@ -10,7 +10,7 @@ import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl } from "./wire
10
10
  const CREDENTIALS_VERSION = 1;
11
11
  /** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
12
12
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
13
- /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
13
+ /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
14
14
  const OWNED_MODEL_KEYS = ["id", "name", "contextWindow", "maxTokens", "input", "reasoningEfforts"];
15
15
  /** models.dev efforts -> `{ level: wire spelling }`; an unoffered level is omitted, not nulled. */
16
16
  function reasoningEfforts(efforts) {
@@ -6,7 +6,7 @@ import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
6
6
  import { looksLikeEnvName } from "../slug.js";
7
7
  import { transactionalTarget } from "../target-transaction.js";
8
8
  import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
- /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
9
+ /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
10
10
  const OWNED_MODEL_KEYS = [
11
11
  "id",
12
12
  "name",
@@ -69,12 +69,24 @@ export function entryApi(entry) {
69
69
  /**
70
70
  * Merge the model entries agentsw owns over the ones already in an app
71
71
  * config: per-model keys the adapter does not model (`compat`, per-model wire
72
- * overrides, ...) survive a re-sync, while an `owned` key the new entry no
73
- * longer carries is cleared instead of lingering as last sync's value — a
74
- * stale `thinkingLevelMap` beside `reasoning: false` is a state no fresh write
72
+ * overrides, ...) survive a re-sync, and an `owned` key the entry omits is
73
+ * cleared rather than lingering as last sync's value — a stale
74
+ * `thinkingLevelMap` beside `reasoning: false` is a state no fresh write
75
75
  * produces.
76
+ *
77
+ * That clearing is only meaningful for an entry that carries metadata at all.
78
+ * A model the store knows nothing about arrives as a bare `{ id }` — that is
79
+ * exactly what enrichment does with an id no catalog row matched — and every
80
+ * adapter still adds the derived capability in `derived`. Absence in such a
81
+ * stub says nothing about the model, so the config's existing values are kept:
82
+ * deleting them would throw away limits an earlier lookup filled in, or that a
83
+ * person typed by hand, every time a catalog lookup misses. Once an entry
84
+ * carries any owned metadata of its own, the same absence is a statement about
85
+ * the model and is applied as before.
76
86
  */
77
- export function mergeModels(previous, written, owned) {
87
+ export function mergeModels(previous, written, owned,
88
+ /** Owned keys every adapter derives instead of reading them from the model. */
89
+ derived = ["input"]) {
78
90
  const prev = new Map();
79
91
  if (Array.isArray(previous)) {
80
92
  for (const m of previous) {
@@ -82,14 +94,17 @@ export function mergeModels(previous, written, owned) {
82
94
  prev.set(m.id, m);
83
95
  }
84
96
  }
97
+ const informative = owned.filter((key) => key !== "id" && !derived.includes(key));
85
98
  return written.map((m) => {
86
99
  const old = prev.get(m.id);
87
100
  if (!old)
88
101
  return m;
89
102
  const kept = { ...old };
90
- for (const key of owned)
91
- if (!(key in m))
92
- delete kept[key];
103
+ const known = informative.some((key) => key in m);
104
+ if (known)
105
+ for (const key of owned)
106
+ if (!(key in m))
107
+ delete kept[key];
93
108
  return { ...kept, ...m };
94
109
  });
95
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.10.0",
3
+ "version": "0.10.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": {