agentsw 0.10.0 → 0.10.2
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 +66 -20
- package/README.zh-CN.md +59 -19
- package/dist/commands.js +4 -1
- package/dist/config-transaction.js +22 -17
- package/dist/gateway.js +72 -14
- package/dist/metadata.js +34 -9
- package/dist/targets/codex.js +5 -1
- package/dist/targets/dsh.js +1 -1
- package/dist/targets/pistyle.js +1 -1
- package/dist/targets/wire.js +22 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,20 +17,7 @@ without touching the settings you hand-tuned.
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
$ asw use myproxy -m glm-5.3-flash
|
|
22
|
-
switching to myproxy (openai) · default model glm-5.3-flash
|
|
23
|
-
|
|
24
|
-
skip claude Claude Code does not support openai-protocol providers
|
|
25
|
-
ok codex ~/.codex/config.toml, ~/.codex/auth.json
|
|
26
|
-
backup: ~/.config/agentsw/backups/transaction-<unique>
|
|
27
|
-
ok omp ~/.omp/agent/models.yml
|
|
28
|
-
select in omp with: omp --model myproxy/glm-5.3-flash
|
|
29
|
-
skip pi pi not detected (pass --apps pi to force)
|
|
30
|
-
ok opencode ~/.config/opencode/opencode.json
|
|
31
|
-
ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
|
|
32
|
-
select in dsh with the model picker, or run: dsh web
|
|
33
|
-
```
|
|
20
|
+

|
|
34
21
|
|
|
35
22
|
`agentsw` and the short `asw` are the same binary.
|
|
36
23
|
|
|
@@ -54,9 +41,37 @@ ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
|
|
|
54
41
|
- **Wire-aware.** `/v1/chat/completions` and `/v1/responses` are different endpoints;
|
|
55
42
|
agentsw tracks which one a provider speaks and never downgrades a working one.
|
|
56
43
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
44
|
+
## How it compares
|
|
45
|
+
|
|
46
|
+
Two projects do most of this better than agentsw does, and both are worth your time:
|
|
47
|
+
|
|
48
|
+
- **[cc-switch](https://github.com/farion1231/cc-switch)** — a desktop app (Tauri) for ten tools:
|
|
49
|
+
one-click switching, 90+ provider presets, MCP/Skills/Prompts management, local routing and
|
|
50
|
+
failover with API-format conversion, a usage and cost dashboard, a session manager, and
|
|
51
|
+
WebDAV/S3 sync.
|
|
52
|
+
- **[cc-switch-cli](https://github.com/SaladDay/cc-switch-cli)** — the community CLI fork of the
|
|
53
|
+
same idea: TUI plus scriptable subcommands, seven tools, MCP and skills, a daemon proxy with
|
|
54
|
+
token accounting, SQLite-backed state.
|
|
55
|
+
|
|
56
|
+
Between them they cover Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, Hermes,
|
|
57
|
+
OpenClaw, Pi, Grok Build and MiniMax Code. agentsw covers a different nine — Claude Code, Codex,
|
|
58
|
+
omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness — **four of which neither
|
|
59
|
+
other tool writes at all.**
|
|
60
|
+
|
|
61
|
+
| | agentsw | cc-switch | cc-switch-cli |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| Interface | CLI, `--json` for scripts | desktop GUI | TUI + CLI |
|
|
64
|
+
| Model metadata (models.dev + AI Gateway) written into each app's own fields | **yes** | no | no |
|
|
65
|
+
| MCP / Skills / Prompts management | — | yes | yes |
|
|
66
|
+
| Routing, API-format conversion, failover | — | yes | yes |
|
|
67
|
+
| Usage and cost dashboard | — | yes | yes |
|
|
68
|
+
| Presets, sessions, cloud sync, deep links | — | yes | partial |
|
|
69
|
+
| Reads the other tool's provider store | yes — read-only `import` | — | — |
|
|
70
|
+
|
|
71
|
+
The honest summary: **if you want a GUI, MCP management, or a failover proxy, use cc-switch.** If
|
|
72
|
+
you use omp, prime-agent, WorkBuddy or DeepSeek Harness, or you want each agent to receive real
|
|
73
|
+
model metadata instead of a bare id, that is what agentsw is for. It reads cc-switch's store
|
|
74
|
+
rather than competing with it.
|
|
60
75
|
|
|
61
76
|
## Install
|
|
62
77
|
|
|
@@ -104,6 +119,32 @@ opencode yes openai+anthropic myproxy/glm-5.3-flash ~/.co
|
|
|
104
119
|
dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.dsh/settings.yaml
|
|
105
120
|
```
|
|
106
121
|
|
|
122
|
+
And the point of the whole thing — one command writes every detected agent (this is the run in
|
|
123
|
+
the animation at the top of this page, as text):
|
|
124
|
+
|
|
125
|
+
```console
|
|
126
|
+
$ asw use myproxy
|
|
127
|
+
switching to myproxy (openai) · default model glm-5.3-flash
|
|
128
|
+
|
|
129
|
+
skip claude Claude Code does not support openai-protocol providers
|
|
130
|
+
ok codex ~/.codex/config.toml, ~/.codex/auth.json
|
|
131
|
+
ok omp ~/.omp/agent/models.yml
|
|
132
|
+
select in omp with: omp --model myproxy/glm-5.3-flash
|
|
133
|
+
ok pi ~/.pi/agent/models.json, ~/.pi/agent/settings.json
|
|
134
|
+
ok prime ~/.prime/agent/models.json, ~/.prime/agent/settings.json
|
|
135
|
+
ok opencode ~/.config/opencode/opencode.json
|
|
136
|
+
ok hermes ~/.hermes/config.yaml, ~/.hermes/.env
|
|
137
|
+
ok workbuddy ~/.workbuddy/models.json, ~/.workbuddy/settings.json
|
|
138
|
+
WorkBuddy watches models.json; models appear in the selector's custom section without restart
|
|
139
|
+
ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
|
|
140
|
+
select in dsh with the model picker, or run: dsh web
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`claude` is the only skip: Claude Code speaks the Anthropic protocol, and `myproxy` is an
|
|
144
|
+
OpenAI-protocol provider. On a machine where an agent's config already exists, committing to it
|
|
145
|
+
also prints a `backup: ~/.config/agentsw/backups/transaction-<unique>` line; this sandbox starts
|
|
146
|
+
with empty configs, so there is nothing to back up and none appear.
|
|
147
|
+
|
|
107
148
|
## Supported apps
|
|
108
149
|
|
|
109
150
|
| App | Config written | Protocols |
|
|
@@ -115,7 +156,7 @@ dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.d
|
|
|
115
156
|
| [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) | `~/.prime/agent/models.json` + `settings.json` | both |
|
|
116
157
|
| [opencode](https://opencode.ai) | `~/.config/opencode/opencode.json`, `.jsonc` or `config.json` (the one that exists; comments preserved) | both |
|
|
117
158
|
| [Hermes](https://pypi.org/project/hermes-agent/) | `~/.hermes/config.yaml` + `.env` (comments preserved) | both |
|
|
118
|
-
| WorkBuddy | `~/.workbuddy/models.json` + `settings.json` | openai |
|
|
159
|
+
| WorkBuddy | `~/.workbuddy/models.json` + `settings.json` (`%APPDATA%\workbuddy` on Windows) | openai |
|
|
119
160
|
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) | `~/.dsh/settings.yaml` + `.credentials.yaml` | both |
|
|
120
161
|
|
|
121
162
|
An agent that is not installed is skipped, not guessed at. `--apps codex,omp` narrows a run;
|
|
@@ -242,7 +283,9 @@ Import still skips already configured accounts.
|
|
|
242
283
|
`refresh --provider <id> --metadata-mode on` to request fresh reference prices.
|
|
243
284
|
- **Auditable and optional:** `metadata.fields` records origins, value snapshots and timestamps;
|
|
244
285
|
`metadata.conflicts` records disagreements with retained values. This audit stays in agentsw, never runtime agent configs.
|
|
245
|
-
Gateway has a separate 24-hour cache;
|
|
286
|
+
Gateway has a separate 24-hour cache; a failed request is retried once before falling back to stale cached data
|
|
287
|
+
(or skipping supplementation when there is none). `AGENTSW_DEBUG=1` reports why an attempt failed, and
|
|
288
|
+
`AGENTSW_GATEWAY_TIMEOUT_MS` overrides the 15-second whole-request budget.
|
|
246
289
|
- **Automatic lookup is not automatic sync:** enrichment updates saved metadata during add/quick/discover/import/refresh.
|
|
247
290
|
It does not add an automatic push to agents. Sync behavior is unchanged: ordinary `sync` only writes saved settings
|
|
248
291
|
and fetches neither model lists nor metadata catalogs.
|
|
@@ -352,7 +395,10 @@ are unaffected.
|
|
|
352
395
|
- A sync overwrites only the fields agentsw owns. Unmodeled provider-level keys and per-model
|
|
353
396
|
extras survive; an owned field that stops applying is cleared rather than left stale, and a
|
|
354
397
|
per-model `api`/`baseUrl` override that contradicts the route is dropped (it would silently
|
|
355
|
-
win over the entry) and reported.
|
|
398
|
+
win over the entry) and reported. Clearing needs the entry to say something in the first
|
|
399
|
+
place: a model the store knows nothing about — an id no catalog row matched — is written as
|
|
400
|
+
an id and its capabilities alone, and the limits already in the file are kept rather than
|
|
401
|
+
deleted.
|
|
356
402
|
- YAML configs (omp, Hermes, dsh) keep their comments. `~/.codex/config.toml` does not —
|
|
357
403
|
TOML round-trip drops them, which is why the backup happens first.
|
|
358
404
|
- OpenAI endpoints come in two wires: `/v1/chat/completions` and `/v1/responses`. Imported
|
package/README.zh-CN.md
CHANGED
|
@@ -17,20 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
$ asw use myproxy -m glm-5.3-flash
|
|
22
|
-
switching to myproxy (openai) · default model glm-5.3-flash
|
|
23
|
-
|
|
24
|
-
skip claude Claude Code does not support openai-protocol providers
|
|
25
|
-
ok codex ~/.codex/config.toml, ~/.codex/auth.json
|
|
26
|
-
backup: ~/.config/agentsw/backups/transaction-<unique>
|
|
27
|
-
ok omp ~/.omp/agent/models.yml
|
|
28
|
-
select in omp with: omp --model myproxy/glm-5.3-flash
|
|
29
|
-
skip pi pi not detected (pass --apps pi to force)
|
|
30
|
-
ok opencode ~/.config/opencode/opencode.json
|
|
31
|
-
ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
|
|
32
|
-
select in dsh with the model picker, or run: dsh web
|
|
33
|
-
```
|
|
20
|
+

|
|
34
21
|
|
|
35
22
|
`agentsw` 和短名 `asw` 是同一个可执行文件。
|
|
36
23
|
|
|
@@ -53,9 +40,33 @@ ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
|
|
|
53
40
|
- **区分接口形态。** `/v1/chat/completions` 与 `/v1/responses` 是两个不同端点;
|
|
54
41
|
agentsw 记录供应商说的是哪一种,并且绝不把可用的 responses 降级。
|
|
55
42
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
43
|
+
## 它和同类工具的关系
|
|
44
|
+
|
|
45
|
+
有两件事别人做得比 agentsw 好,而且都值得你去看:
|
|
46
|
+
|
|
47
|
+
- **[cc-switch](https://github.com/farion1231/cc-switch)**——桌面应用(Tauri),支持十个工具:一键切换、
|
|
48
|
+
90+ 供应商预设、MCP/Skills/Prompts 管理、本地路由与故障转移(含 API 格式转换)、用量与成本仪表盘、
|
|
49
|
+
会话管理、WebDAV/S3 同步。
|
|
50
|
+
- **[cc-switch-cli](https://github.com/SaladDay/cc-switch-cli)**——同一思路的社区 CLI 分支:TUI + 可脚本化子命令、
|
|
51
|
+
七个工具、MCP 与 Skills、带 token 统计的守护进程代理、SQLite 存储。
|
|
52
|
+
|
|
53
|
+
两者合起来覆盖 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、Hermes、OpenClaw、Pi、
|
|
54
|
+
Grok Build、MiniMax Code。agentsw 覆盖的是另外九个——Claude Code、Codex、omp、pi、prime-agent、
|
|
55
|
+
opencode、Hermes、WorkBuddy、DeepSeek Harness——**其中四个另外两个工具根本不写。**
|
|
56
|
+
|
|
57
|
+
| | agentsw | cc-switch | cc-switch-cli |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| 形态 | CLI,`--json` 可脚本化 | 桌面 GUI | TUI + CLI |
|
|
60
|
+
| 从 models.dev + AI Gateway 取元数据,写入各应用自己的字段 | **是** | 无 | 无 |
|
|
61
|
+
| MCP / Skills / Prompts 管理 | — | 有 | 有 |
|
|
62
|
+
| 路由、API 格式转换、故障转移 | — | 有 | 有 |
|
|
63
|
+
| 用量与成本仪表盘 | — | 有 | 有 |
|
|
64
|
+
| 预设、会话、云同步、deep link | — | 有 | 部分 |
|
|
65
|
+
| 读取对方的供应商库 | 有——只读 `import` | — | — |
|
|
66
|
+
|
|
67
|
+
说实话的结论:**想要 GUI、MCP 管理或故障转移代理,用 cc-switch。** 如果你在用 omp、prime-agent、
|
|
68
|
+
WorkBuddy 或 DeepSeek Harness,或者你希望每个智能体拿到的是真实模型参数而不是一个裸 id,
|
|
69
|
+
那才是 agentsw 的用处。它读 cc-switch 的库,而不是跟它抢。
|
|
59
70
|
|
|
60
71
|
## 安装
|
|
61
72
|
|
|
@@ -99,6 +110,31 @@ opencode yes openai+anthropic myproxy/glm-5.3-flash ~/.co
|
|
|
99
110
|
dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.dsh/settings.yaml
|
|
100
111
|
```
|
|
101
112
|
|
|
113
|
+
而这个工具真正的用处是另一条——一条命令写进所有检测到的智能体
|
|
114
|
+
(也就是本页顶部那段动画里跑的东西,文字版):
|
|
115
|
+
|
|
116
|
+
```console
|
|
117
|
+
$ asw use myproxy
|
|
118
|
+
switching to myproxy (openai) · default model glm-5.3-flash
|
|
119
|
+
|
|
120
|
+
skip claude Claude Code does not support openai-protocol providers
|
|
121
|
+
ok codex ~/.codex/config.toml, ~/.codex/auth.json
|
|
122
|
+
ok omp ~/.omp/agent/models.yml
|
|
123
|
+
select in omp with: omp --model myproxy/glm-5.3-flash
|
|
124
|
+
ok pi ~/.pi/agent/models.json, ~/.pi/agent/settings.json
|
|
125
|
+
ok prime ~/.prime/agent/models.json, ~/.prime/agent/settings.json
|
|
126
|
+
ok opencode ~/.config/opencode/opencode.json
|
|
127
|
+
ok hermes ~/.hermes/config.yaml, ~/.hermes/.env
|
|
128
|
+
ok workbuddy ~/.workbuddy/models.json, ~/.workbuddy/settings.json
|
|
129
|
+
WorkBuddy watches models.json; models appear in the selector's custom section without restart
|
|
130
|
+
ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
|
|
131
|
+
select in dsh with the model picker, or run: dsh web
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
唯一被跳过的是 `claude`:Claude Code 说的是 Anthropic 协议,而 `myproxy` 是 OpenAI 协议的供应商。
|
|
135
|
+
在智能体的配置已经存在的机器上,提交它时还会打印一行 `backup: ~/.config/agentsw/backups/transaction-<unique>`;
|
|
136
|
+
本沙箱从空配置开始,无物可备,因此没有这行。
|
|
137
|
+
|
|
102
138
|
## 支持的应用
|
|
103
139
|
|
|
104
140
|
| 应用 | 写入的配置 | 协议 |
|
|
@@ -110,7 +146,7 @@ dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.d
|
|
|
110
146
|
| [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) | `~/.prime/agent/models.json` + `settings.json` | 双协议 |
|
|
111
147
|
| [opencode](https://opencode.ai) | `~/.config/opencode/` 下已存在的 `opencode.json`、`.jsonc` 或 `config.json`(保留注释) | 双协议 |
|
|
112
148
|
| [Hermes](https://pypi.org/project/hermes-agent/) | `~/.hermes/config.yaml` + `.env`(保留注释) | 双协议 |
|
|
113
|
-
| WorkBuddy | `~/.workbuddy/models.json` + `settings.json` | openai |
|
|
149
|
+
| WorkBuddy | `~/.workbuddy/models.json` + `settings.json`(Windows 下为 `%APPDATA%\workbuddy`) | openai |
|
|
114
150
|
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) | `~/.dsh/settings.yaml` + `.credentials.yaml` | 双协议 |
|
|
115
151
|
|
|
116
152
|
未安装的应用会被跳过,而不是瞎猜。`--apps codex,omp` 只跑指定应用;
|
|
@@ -227,7 +263,9 @@ asw sync --provider myproxy
|
|
|
227
263
|
自动模式不会仅为刷新参考价格而查询:可查看 `metadata.gateway.fetchedAt` 判断快照时间,
|
|
228
264
|
需要重新查询参考价格时使用 `refresh --provider <id> --metadata-mode on`。
|
|
229
265
|
- **可追溯、可降级:** `metadata.fields` 记录来源、原值快照和时间,`metadata.conflicts` 记录保留值与冲突。
|
|
230
|
-
审计信息仅保存在 agentsw,不写入智能体运行配置。Gateway 目录独立缓存 24
|
|
266
|
+
审计信息仅保存在 agentsw,不写入智能体运行配置。Gateway 目录独立缓存 24 小时;请求失败会先重试一次,
|
|
267
|
+
仍失败才使用旧缓存,无缓存时只跳过补充。`AGENTSW_DEBUG=1` 会说明该次尝试失败的原因,
|
|
268
|
+
`AGENTSW_GATEWAY_TIMEOUT_MS` 可覆盖默认 15 秒的整体请求预算。
|
|
231
269
|
- **自动查询不等于自动同步:** 添加、快速添加、发现、导入、刷新时补全保存的元数据,并不新增自动推送。
|
|
232
270
|
同步行为不变:普通 `sync` 仅写入已保存的设置,既不获取模型列表,也不请求元数据目录。
|
|
233
271
|
|
|
@@ -330,6 +368,8 @@ JSON 载荷一律不含凭据;`--json --dry-run` 只报告将要改动的文
|
|
|
330
368
|
- 同步只覆盖 agentsw 自己管理的字段:未建模的供应商级键与模型级扩展字段保留;
|
|
331
369
|
自己管理但本次不再写出的字段会被清除而非留成陈旧值;与新路由矛盾的模型级
|
|
332
370
|
`api`/`baseUrl` 覆盖会被删除(否则它会静默盖过供应商条目)并在输出中点名。
|
|
371
|
+
但"清除"要求这一条本身说了点什么:store 完全不认识的模型(没有任何目录行匹配到的 id)
|
|
372
|
+
只带 id 和能力写入,文件里已有的上下文长度等参数会保留而不是被删掉。
|
|
333
373
|
- YAML 配置(omp、Hermes、dsh)保留注释;`~/.codex/config.toml` 不行——TOML 往返会丢注释,
|
|
334
374
|
所以才先备份。
|
|
335
375
|
- OpenAI 端点有两种接口形态:`/v1/chat/completions` 与 `/v1/responses`。导入时沿用配置里
|
package/dist/commands.js
CHANGED
|
@@ -483,7 +483,10 @@ function reportResults(results) {
|
|
|
483
483
|
out(`${pc.yellow(t("common.skip"))} ${r.app.padEnd(9)} ${pc.dim(r.skipped)}`);
|
|
484
484
|
continue;
|
|
485
485
|
}
|
|
486
|
-
|
|
486
|
+
// An empty change list means the app was already in sync: say so rather than
|
|
487
|
+
// printing a bare "ok" with nothing after it.
|
|
488
|
+
const detail = r.changed.length ? r.changed.join(", ") : pc.dim(t("preview.unchanged"));
|
|
489
|
+
out(`${pc.green("ok ")} ${r.app.padEnd(9)} ${detail}`);
|
|
487
490
|
for (const note of r.notes)
|
|
488
491
|
out(` ${" ".repeat(9)} ${pc.dim(note)}`);
|
|
489
492
|
}
|
|
@@ -144,26 +144,31 @@ export function commitFileChanges(changes, opts = {}) {
|
|
|
144
144
|
return withWriteLock(() => {
|
|
145
145
|
// Another writer may have committed between planning and acquiring the lock.
|
|
146
146
|
validateReads();
|
|
147
|
+
// Only a file that already existed has anything to save. When every planned
|
|
148
|
+
// file is new, no transaction directory is created: an empty one (nothing but
|
|
149
|
+
// its manifest) would read as a backup that does not exist.
|
|
147
150
|
let backupDir;
|
|
148
151
|
let currentFile = backupsDir;
|
|
149
152
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
153
|
+
if (plan.some((change) => change.before !== undefined)) {
|
|
154
|
+
fs.mkdirSync(backupsDir, { recursive: true, mode: 0o700 });
|
|
155
|
+
backupDir = fs.mkdtempSync(path.join(backupsDir, "transaction-"));
|
|
156
|
+
fs.chmodSync(backupDir, 0o700);
|
|
157
|
+
for (const [index, change] of plan.entries()) {
|
|
158
|
+
currentFile = change.file;
|
|
159
|
+
if (change.before === undefined)
|
|
160
|
+
continue;
|
|
161
|
+
const backup = path.join(backupDir, `${index}-${path.basename(change.file)}`);
|
|
162
|
+
fs.writeFileSync(backup, change.before, { flag: "wx", mode: 0o600 });
|
|
163
|
+
fs.chmodSync(backup, 0o600);
|
|
164
|
+
}
|
|
165
|
+
fs.writeFileSync(path.join(backupDir, "manifest.json"), JSON.stringify(plan.map((change, index) => ({
|
|
166
|
+
file: change.file,
|
|
167
|
+
backup: change.before === undefined ? null : `${index}-${path.basename(change.file)}`,
|
|
168
|
+
mode: change.original.mode,
|
|
169
|
+
})), null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
170
|
+
fs.chmodSync(path.join(backupDir, "manifest.json"), 0o600);
|
|
160
171
|
}
|
|
161
|
-
fs.writeFileSync(path.join(backupDir, "manifest.json"), JSON.stringify(plan.map((change, index) => ({
|
|
162
|
-
file: change.file,
|
|
163
|
-
backup: change.before === undefined ? null : `${index}-${path.basename(change.file)}`,
|
|
164
|
-
mode: change.original.mode,
|
|
165
|
-
})), null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
166
|
-
fs.chmodSync(path.join(backupDir, "manifest.json"), 0o600);
|
|
167
172
|
validateReads();
|
|
168
173
|
}
|
|
169
174
|
catch {
|
|
@@ -216,7 +221,7 @@ export function commitFileChanges(changes, opts = {}) {
|
|
|
216
221
|
}
|
|
217
222
|
catch { /* Only remove empty directories created by this transaction. */ }
|
|
218
223
|
}
|
|
219
|
-
throw new Error(`${currentFile}: configuration transaction failed; ${failed.length ? `rollback incomplete for ${failed.join(", ")}` : "previous writes rolled back"}
|
|
224
|
+
throw new Error(`${currentFile}: configuration transaction failed; ${failed.length ? `rollback incomplete for ${failed.join(", ")}` : "previous writes rolled back"}${backupDir ? `; backups: ${backupDir}` : "; no files needed backing up"}`);
|
|
220
225
|
}
|
|
221
226
|
return { files, backupDir };
|
|
222
227
|
});
|
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
|
|
147
|
+
throw new DefinitiveFailure("AI Gateway catalog exceeds size limit");
|
|
141
148
|
}
|
|
142
149
|
if (!response.body)
|
|
143
|
-
throw new
|
|
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
|
|
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
|
|
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 = {
|
|
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 =
|
|
131
|
-
return identities
|
|
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 =
|
|
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 =
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
}
|
package/dist/targets/codex.js
CHANGED
|
@@ -82,7 +82,11 @@ export const codex = transactionalTarget({
|
|
|
82
82
|
wire_api: "responses",
|
|
83
83
|
requires_openai_auth: true,
|
|
84
84
|
};
|
|
85
|
-
|
|
85
|
+
// Only worth saying when the endpoint has not declared itself Responses-compatible:
|
|
86
|
+
// a provider the user already marked `responses` needs no caveat.
|
|
87
|
+
if (provider.openaiApi !== "responses") {
|
|
88
|
+
notes.push("codex requires an OpenAI Responses-compatible endpoint (/v1/responses); chat-completions-only endpoints will not work");
|
|
89
|
+
}
|
|
86
90
|
config.model_providers = providers;
|
|
87
91
|
const configBackup = backupFile(configFile);
|
|
88
92
|
if (configBackup)
|
package/dist/targets/dsh.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/targets/pistyle.js
CHANGED
|
@@ -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
|
|
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",
|
package/dist/targets/wire.js
CHANGED
|
@@ -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,
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
3
|
+
"version": "0.10.2",
|
|
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": {
|