@hu3rror/pi-failover 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +146 -0
- package/README.zh-CN.md +146 -0
- package/package.json +68 -0
- package/src/add-backup-key.ts +126 -0
- package/src/auth-catalog.ts +112 -0
- package/src/failover-engine.ts +314 -0
- package/src/failover-login.ts +108 -0
- package/src/index.ts +346 -0
- package/src/model-planner.ts +51 -0
- package/src/notification.ts +43 -0
- package/src/pi-runtime.ts +192 -0
- package/tsconfig.json +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gooyoung (original pi-failover)
|
|
4
|
+
Copyright (c) 2026 Hu3rror (independent fork maintainer)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# pi-failover
|
|
2
|
+
|
|
3
|
+
Automatic credential and provider failover for [Pi coding agent](https://github.com/nicobailon/pi-coding-agent) `>=0.84.2`.
|
|
4
|
+
|
|
5
|
+
- [中文说明](./README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@hu3rror/pi-failover
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`pi-failover` helps a Pi session keep going when the current credential or provider becomes unavailable. It works with Pi's existing `auth.json` and adds one extension field, `backupKeys`, for API-key providers. The legacy `key-backup` spelling is still recognized on read.
|
|
12
|
+
|
|
13
|
+
## About this fork
|
|
14
|
+
|
|
15
|
+
`pi-failover` is MIT-licensed, originally by [gooyoung](https://github.com/gooyoung).
|
|
16
|
+
This repository is an independently maintained and independently published fork,
|
|
17
|
+
distributed on npm as `@hu3rror/pi-failover`; runtime behavior and `auth.json`
|
|
18
|
+
conventions are unchanged.
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### 1. Install the extension
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:@hu3rror/pi-failover
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Edit `auth.json`
|
|
29
|
+
|
|
30
|
+
`pi-failover` reads only Pi's `auth.json` from `getAgentDir()`, which is usually:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
~/.pi/agent/auth.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If `PI_CODING_AGENT_DIR` is set, Pi's own agent-directory resolution still applies.
|
|
37
|
+
|
|
38
|
+
Keep Pi's primary credential as-is and add `backupKeys` to any API-key provider that should have same-provider backups. The field accepts either one literal, non-empty string or a non-empty array of literal, non-empty strings:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"anthropic": {
|
|
43
|
+
"type": "api_key",
|
|
44
|
+
"key": "primary-api-key",
|
|
45
|
+
"backupKeys": ["backup-api-key-1", "backup-api-key-2"]
|
|
46
|
+
},
|
|
47
|
+
"openai-codex": {
|
|
48
|
+
"type": "oauth",
|
|
49
|
+
"access": "...",
|
|
50
|
+
"refresh": "...",
|
|
51
|
+
"expires": 1767225600000
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The existing string form remains equivalent to a one-item array. Array entries are tried in order. If the array is empty or any item is invalid, the entire backup field is ignored and the provider remains available only through its primary credential. The legacy `key-backup` spelling is read only when `backupKeys` is absent; when both fields are present, `backupKeys` wins.
|
|
57
|
+
|
|
58
|
+
### 3. Verify that failover is active
|
|
59
|
+
|
|
60
|
+
Start Pi and run:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
/failover status
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The command shows redacted runtime status only. It never prints raw credential values.
|
|
67
|
+
|
|
68
|
+
If the active key receives a handled failure during a user request, `pi-failover` can:
|
|
69
|
+
|
|
70
|
+
- switch to the next backup key for the same provider
|
|
71
|
+
- switch to the next configured provider
|
|
72
|
+
- retry the same user request automatically after a successful switch
|
|
73
|
+
- show only the final provider error when every configured option is exhausted
|
|
74
|
+
|
|
75
|
+
Intermediate provider errors are replaced by a hidden continuation, so no second user message is required. TUI and RPC modes still show one redacted warning for each applied credential or provider switch.
|
|
76
|
+
|
|
77
|
+
Example warnings emitted after a backup-credential switch and provider switches:
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
If all failover options are exhausted while Pi still has a built-in automatic retry pending, the extension keeps the last active credential in place until that retry finishes. A successful retry keeps that credential active; after a final failure, the extension restores its runtime overrides and reports exhaustion once. This prevents Pi's retry from unexpectedly falling back to a primary credential that already failed.
|
|
83
|
+
|
|
84
|
+
## Configuration Notes
|
|
85
|
+
|
|
86
|
+
- `pi-failover` never reads or writes `keyrouter.json`.
|
|
87
|
+
- `backupKeys` contains one or more keys for the same provider, not provider fallbacks. The legacy `key-backup` spelling is still recognized on read when `backupKeys` is absent.
|
|
88
|
+
- Provider fallback order follows the top-level insertion order in `auth.json`.
|
|
89
|
+
- OAuth entries can participate in provider fallback, but they do not support backup fields.
|
|
90
|
+
- Every `backupKeys` value is treated as a literal string. Values are not expanded from environment variables or commands.
|
|
91
|
+
- Pi's `/login` flow can rewrite `auth.json` and remove unknown extension fields, so `backupKeys` may need to be re-added after logging in again.
|
|
92
|
+
|
|
93
|
+
## How Failover Works
|
|
94
|
+
|
|
95
|
+
Within one user request, failed credentials and providers are disabled or cooled before the hidden continuation runs. A successful `2xx` response marks the active credential or provider healthy.
|
|
96
|
+
|
|
97
|
+
| Failure | What pi-failover does |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `401` / `403` | Disables the current credential for the session, switches to the next backup key or the next provider, then retries the same request. |
|
|
100
|
+
| `429` | Cools down the current credential by `Retry-After`, or by 60 seconds when the header is absent, switches to the next backup key, then retries. |
|
|
101
|
+
| `529` or overloaded responses | Cools down the provider by `Retry-After`, or by 30 seconds when the header is absent, changes provider, then retries. |
|
|
102
|
+
| `500`, `502`, `503`, `504`, network, timeout | Cools down the provider for 30 seconds, changes provider, then retries. |
|
|
103
|
+
| Other failures | Leaves Pi's normal error handling unchanged. |
|
|
104
|
+
|
|
105
|
+
When switching providers, `pi-failover` prefers the current model ID. If that model is unavailable on the next provider, it uses that provider's first available model. The extension calls Pi's `setModel()`, so the new default model persists. There is no automatic failback to the original provider later.
|
|
106
|
+
|
|
107
|
+
Status and warning messages identify credential slots without exposing values: the primary credential is `primary`, the first backup is `backup`, and later backups are `backup-2`, `backup-3`, and so on.
|
|
108
|
+
|
|
109
|
+
## Commands
|
|
110
|
+
|
|
111
|
+
- `/failover login`: interactively add a backup API key to an `api_key` provider; run it bare to pick from the current providers, or pass a provider name (`/failover login <provider>`, with autocompletion); the key is entered in a prompt (never on the command line), confirmed, then written to `auth.json` and the catalog is rebuilt
|
|
112
|
+
- `/failover status`: shows redacted failover state
|
|
113
|
+
- `/failover reload`: restores extension-owned overrides, then rereads `auth.json`
|
|
114
|
+
|
|
115
|
+
## Output Modes
|
|
116
|
+
|
|
117
|
+
| Mode | Notifications |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| TUI | Yes |
|
|
120
|
+
| RPC | Yes |
|
|
121
|
+
| JSON | No UI notifications; transparent retries still run |
|
|
122
|
+
| print | No UI notifications; transparent retries still run |
|
|
123
|
+
|
|
124
|
+
## Migration Notes
|
|
125
|
+
|
|
126
|
+
If migrating from `~/.pi/keyrouter.json`, move each provider's primary credential into Pi's `auth.json`, then place either one backup string or an ordered backup array in `backupKeys`. Reorder the top-level entries in `auth.json` to control provider fallback order.
|
|
127
|
+
|
|
128
|
+
There is no dual-read migration path. `pi-failover` uses only `auth.json`.
|
|
129
|
+
|
|
130
|
+
## Security Notes
|
|
131
|
+
|
|
132
|
+
- Treat `auth.json` as a secret file.
|
|
133
|
+
- Do not commit credentials.
|
|
134
|
+
- Restrict file permissions appropriately.
|
|
135
|
+
- `pi-failover` keeps status and error messages redacted.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm test
|
|
141
|
+
npm run typecheck
|
|
142
|
+
npm run audit
|
|
143
|
+
npm pack --dry-run
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`npm run audit` checks the dev-only dependency tree against the official npm registry. The only runtime dependency is `proper-lockfile`, used to guard `auth.json` writes.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# pi-failover
|
|
2
|
+
|
|
3
|
+
面向 [Pi coding agent](https://github.com/nicobailon/pi-coding-agent) `>=0.84.2` 的自动凭证与 provider 故障切换扩展。
|
|
4
|
+
|
|
5
|
+
- [English README](./README.md)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@hu3rror/pi-failover
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`pi-failover` 用于在当前凭证或 provider 不可用时,继续让 Pi 会话向下执行。它直接复用 Pi 现有的 `auth.json`,并为 API key provider 增加一个扩展字段 `backupKeys`。旧拼写 `key-backup` 在读取时仍会被识别。
|
|
12
|
+
|
|
13
|
+
## 关于本分支(fork)
|
|
14
|
+
|
|
15
|
+
`pi-failover` 采用 MIT 许可,原作者为 [gooyoung](https://github.com/gooyoung)。
|
|
16
|
+
本仓库是其独立维护、独立发布的 fork,npm 分发名为
|
|
17
|
+
`@hu3rror/pi-failover`;运行时行为与 `auth.json` 约定保持不变。
|
|
18
|
+
|
|
19
|
+
## 快速开始
|
|
20
|
+
|
|
21
|
+
### 1. 安装扩展
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:@hu3rror/pi-failover
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. 修改 `auth.json`
|
|
28
|
+
|
|
29
|
+
`pi-failover` 只读取 Pi `getAgentDir()` 下的 `auth.json`,默认位置通常是:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
~/.pi/agent/auth.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
如果设置了 `PI_CODING_AGENT_DIR`,仍然沿用 Pi 自身的 agent 目录解析规则。
|
|
36
|
+
|
|
37
|
+
保留 Pi 原有的主凭证,并在需要同 provider 备用 key 的 API-key provider 上增加 `backupKeys` 字段。该字段既可以是一个字面量、非空字符串,也可以是由字面量、非空字符串组成的非空数组:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"anthropic": {
|
|
42
|
+
"type": "api_key",
|
|
43
|
+
"key": "primary-api-key",
|
|
44
|
+
"backupKeys": ["backup-api-key-1", "backup-api-key-2"]
|
|
45
|
+
},
|
|
46
|
+
"openai-codex": {
|
|
47
|
+
"type": "oauth",
|
|
48
|
+
"access": "...",
|
|
49
|
+
"refresh": "...",
|
|
50
|
+
"expires": 1767225600000
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
现有字符串形式等价于只含一项的数组,数组中的凭证按书写顺序尝试。如果数组为空或任一元素无效,整个备用字段都会被忽略,该 provider 仍只能使用主凭证。旧拼写 `key-backup` 仅在 `backupKeys` 缺失时才会被读取;两字段同时存在时,`backupKeys` 优先。
|
|
56
|
+
|
|
57
|
+
### 3. 验证故障切换已启用
|
|
58
|
+
|
|
59
|
+
启动 Pi 后执行:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
/failover status
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
该命令只显示脱敏后的运行时状态,不会输出原始凭证值。
|
|
66
|
+
|
|
67
|
+
当当前 key 在一次用户请求中遇到已接管的故障时,`pi-failover` 会按情况执行:
|
|
68
|
+
|
|
69
|
+
- 切到同一 provider 的下一把备用 key
|
|
70
|
+
- 切到下一个已配置 provider
|
|
71
|
+
- 成功切换后自动重试同一次用户请求
|
|
72
|
+
- 当所有可选项都耗尽时,只显示最后一次 provider 错误
|
|
73
|
+
|
|
74
|
+
中间 provider 错误会被替换为隐藏的续跑消息,因此用户无需再次发送相同内容。TUI 和 RPC 模式仍会为每次实际生效的凭据或 provider 切换显示一条脱敏警告。
|
|
75
|
+
|
|
76
|
+
以下是切换到备用凭证及切换 provider 后显示的警告示例:
|
|
77
|
+
|
|
78
|
+

|
|
79
|
+
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
如果所有 failover 选项已经耗尽,但 Pi 仍有内置自动重试尚未执行,扩展会保留最后一个实际使用的凭证,直到该重试结束。重试成功时继续保留该凭证;最终仍失败时,扩展才恢复其运行时 override,并只报告一次 exhausted。这样可以避免 Pi 的重试意外切回已经失败的主凭证。
|
|
83
|
+
|
|
84
|
+
## 配置说明
|
|
85
|
+
|
|
86
|
+
- `pi-failover` 不会读取或写入 `keyrouter.json`。
|
|
87
|
+
- `backupKeys` 表示同一 provider 的一把或多把备用 key,不表示 provider 级切换。旧拼写 `key-backup` 在 `backupKeys` 缺失时仍会被识别。
|
|
88
|
+
- provider 的切换顺序由 `auth.json` 顶层字段的插入顺序决定。
|
|
89
|
+
- OAuth 条目可以参与 provider 级切换,但不支持备用字段。
|
|
90
|
+
- `backupKeys` 中的每个值都按字面量字符串处理,不支持从环境变量或命令动态展开。
|
|
91
|
+
- Pi 的 `/login` 流程可能会重写 `auth.json` 并移除未知扩展字段,因此重新登录后可能需要再次补上 `backupKeys`。
|
|
92
|
+
|
|
93
|
+
## 故障切换规则
|
|
94
|
+
|
|
95
|
+
同一次用户请求内,失败的凭证或 provider 会先被禁用或进入冷却,再执行隐藏续跑。收到成功的 `2xx` 响应后,当前凭证或 provider 会被标记为健康。
|
|
96
|
+
|
|
97
|
+
| 故障类型 | `pi-failover` 的处理方式 |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `401` / `403` | 将当前凭证在本次会话中标记为不可用,切换到下一把备用 key 或下一个 provider,然后重试同一次请求。 |
|
|
100
|
+
| `429` | 按 `Retry-After` 冷却当前凭证;如果没有该响应头,则冷却 60 秒,切换到下一把备用 key 后重试。 |
|
|
101
|
+
| `529` 或 overloaded 响应 | 按 `Retry-After` 冷却当前 provider;如果没有该响应头,则冷却 30 秒,切换 provider 后重试。 |
|
|
102
|
+
| `500`、`502`、`503`、`504`、网络错误、超时 | 将当前 provider 冷却 30 秒,切换 provider 后重试。 |
|
|
103
|
+
| 其他故障 | 保持 Pi 原有的错误处理逻辑,不额外接管。 |
|
|
104
|
+
|
|
105
|
+
发生 provider 切换时,`pi-failover` 会优先保留当前 model ID;如果目标 provider 没有该 model,则退回到该 provider 的第一个可用 model。扩展内部会调用 Pi 的 `setModel()`,因此新的默认 model 会持续生效;后续不会自动切回原 provider。
|
|
106
|
+
|
|
107
|
+
状态和警告信息只显示脱敏后的凭证槽位:主凭证为 `primary`,第一把备用凭证为 `backup`,后续依次为 `backup-2`、`backup-3`……
|
|
108
|
+
|
|
109
|
+
## 命令
|
|
110
|
+
|
|
111
|
+
- `/failover login`:交互式地为 `api_key` provider 添加备用 key;不带参数时从现有 provider 中选择,也可直接指定 provider(`/failover login <provider>`,支持自动补全);key 在输入框中录入(绝不通过命令行参数传入),经确认后写入 `auth.json` 并立即重建 failover catalog,新 key 立即可用
|
|
112
|
+
- `/failover status`:查看脱敏后的故障切换状态
|
|
113
|
+
- `/failover reload`:恢复扩展接管的 override,然后重新读取 `auth.json`
|
|
114
|
+
|
|
115
|
+
## 输出模式
|
|
116
|
+
|
|
117
|
+
| 模式 | 通知行为 |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| TUI | 显示通知 |
|
|
120
|
+
| RPC | 显示通知 |
|
|
121
|
+
| JSON | 不显示 UI 通知,但仍会执行透明重试 |
|
|
122
|
+
| print | 不显示 UI 通知,但仍会执行透明重试 |
|
|
123
|
+
|
|
124
|
+
## 迁移说明
|
|
125
|
+
|
|
126
|
+
如果从 `~/.pi/keyrouter.json` 迁移,需要把每个 provider 的主凭证搬到 Pi 的 `auth.json` 中,再把一把备用 key 字符串或按顺序排列的备用 key 数组写入 `backupKeys`。如需控制 provider 切换顺序,可直接调整 `auth.json` 顶层条目的顺序。
|
|
127
|
+
|
|
128
|
+
当前没有双读迁移模式,`pi-failover` 只读取 `auth.json`。
|
|
129
|
+
|
|
130
|
+
## 安全说明
|
|
131
|
+
|
|
132
|
+
- 将 `auth.json` 视为敏感文件。
|
|
133
|
+
- 不要提交凭证内容。
|
|
134
|
+
- 应限制文件访问权限。
|
|
135
|
+
- `pi-failover` 的状态和错误信息默认保持脱敏。
|
|
136
|
+
|
|
137
|
+
## 开发
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm test
|
|
141
|
+
npm run typecheck
|
|
142
|
+
npm run audit
|
|
143
|
+
npm pack --dry-run
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`npm run audit` 对官方 npm registry 检查仅用于开发的依赖树。发布包唯一的运行时依赖是 `proper-lockfile`,用于守卫 `auth.json` 的写入。
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hu3rror/pi-failover",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Automatic API credential and provider failover for Pi coding agent.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/hu3rror/pi-failover.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/hu3rror/pi-failover/issues"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/hu3rror/pi-failover#readme",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"packageManager": "npm@11.12.1",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"tsconfig.json",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "tsx --test tests/*.test.ts",
|
|
25
|
+
"audit": "npm audit --registry=https://registry.npmjs.org"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"pi",
|
|
29
|
+
"pi-coding-agent",
|
|
30
|
+
"pi-extension",
|
|
31
|
+
"pi-packages",
|
|
32
|
+
"pi-failover",
|
|
33
|
+
"failover",
|
|
34
|
+
"llm-failover",
|
|
35
|
+
"key-failover",
|
|
36
|
+
"provider-failover",
|
|
37
|
+
"rate-limit",
|
|
38
|
+
"fallback",
|
|
39
|
+
"llm-fallback",
|
|
40
|
+
"key-router"
|
|
41
|
+
],
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./src/index.ts"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=0.84.2"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public",
|
|
52
|
+
"registry": "https://registry.npmjs.org"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
60
|
+
"@types/node": "^25.9.1",
|
|
61
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
62
|
+
"tsx": "^4.19.0",
|
|
63
|
+
"typescript": "^6.0.3"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"proper-lockfile": "4.1.2"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { lockSync } from "proper-lockfile";
|
|
5
|
+
import {
|
|
6
|
+
isApiKeyCredential,
|
|
7
|
+
isLiteralBackupKey,
|
|
8
|
+
isOAuthCredential,
|
|
9
|
+
normalizeBackupKeys,
|
|
10
|
+
pickBackupField,
|
|
11
|
+
} from "./auth-catalog.ts";
|
|
12
|
+
|
|
13
|
+
export type AddBackupKeyRejection =
|
|
14
|
+
| "invalid-key"
|
|
15
|
+
| "unknown-provider"
|
|
16
|
+
| "oauth-provider"
|
|
17
|
+
| "unreadable"
|
|
18
|
+
| "malformed"
|
|
19
|
+
| "locked";
|
|
20
|
+
|
|
21
|
+
export type AddBackupKeyResult =
|
|
22
|
+
| { ok: true; changed: true; provider: string; backupKeys: string[] }
|
|
23
|
+
| { ok: true; changed: false; provider: string; backupKeys: string[] }
|
|
24
|
+
| { ok: false; reason: AddBackupKeyRejection };
|
|
25
|
+
|
|
26
|
+
export interface AddBackupKeyOptions {
|
|
27
|
+
authPath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The mode applies only on file creation, mirroring Pi's FileAuthStorageBackend.
|
|
31
|
+
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Appends a validated backup key to a provider's `backupKeys` in `auth.json`.
|
|
35
|
+
*
|
|
36
|
+
* Mirrors Pi's official write method (`FileAuthStorageBackend.withLock`):
|
|
37
|
+
* lockfile-guarded read-modify-write, `0600` permissions, 2-space JSON, other
|
|
38
|
+
* credentials and provider order preserved. The default `authPath` mirrors the
|
|
39
|
+
* catalog loader's injection point.
|
|
40
|
+
*/
|
|
41
|
+
export function addBackupKey(
|
|
42
|
+
providerId: string,
|
|
43
|
+
backupKey: string,
|
|
44
|
+
options: AddBackupKeyOptions = {},
|
|
45
|
+
): AddBackupKeyResult {
|
|
46
|
+
const authPath = options.authPath ?? join(getAgentDir(), "auth.json");
|
|
47
|
+
|
|
48
|
+
if (!isLiteralBackupKey(backupKey)) return { ok: false, reason: "invalid-key" };
|
|
49
|
+
|
|
50
|
+
let release: (() => void) | undefined;
|
|
51
|
+
try {
|
|
52
|
+
release = acquireLockSync(authPath);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return lockFailureResult(error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
let content: string;
|
|
59
|
+
try {
|
|
60
|
+
content = fs.readFileSync(authPath, "utf-8");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isNodeErrorWithCode(error, "ENOENT")) return { ok: false, reason: "unknown-provider" };
|
|
63
|
+
return { ok: false, reason: "unreadable" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let parsed: unknown;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(content);
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: false, reason: "malformed" };
|
|
71
|
+
}
|
|
72
|
+
if (!isRecord(parsed)) return { ok: false, reason: "malformed" };
|
|
73
|
+
|
|
74
|
+
const credential = parsed[providerId];
|
|
75
|
+
if (credential === undefined) return { ok: false, reason: "unknown-provider" };
|
|
76
|
+
if (!isRecord(credential)) return { ok: false, reason: "malformed" };
|
|
77
|
+
if (isOAuthCredential(credential)) return { ok: false, reason: "oauth-provider" };
|
|
78
|
+
if (!isApiKeyCredential(credential)) return { ok: false, reason: "malformed" };
|
|
79
|
+
|
|
80
|
+
const existing = normalizeBackupKeys(pickBackupField(credential).value) ?? [];
|
|
81
|
+
if (existing.includes(backupKey)) {
|
|
82
|
+
return { ok: true, changed: false, provider: providerId, backupKeys: existing };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const backupKeys = [...existing, backupKey];
|
|
86
|
+
const updated = { ...parsed, [providerId]: { ...credential, backupKeys } };
|
|
87
|
+
fs.writeFileSync(authPath, JSON.stringify(updated, null, 2), AUTH_FILE_WRITE_OPTIONS);
|
|
88
|
+
return { ok: true, changed: true, provider: providerId, backupKeys };
|
|
89
|
+
} finally {
|
|
90
|
+
release?.();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Acquires the `<authPath>.lock` guard, retrying `ELOCKED` briefly, mirroring
|
|
96
|
+
* Pi's FileAuthStorageBackend retry loop.
|
|
97
|
+
*/
|
|
98
|
+
function acquireLockSync(authPath: string): () => void {
|
|
99
|
+
const maxAttempts = 10;
|
|
100
|
+
const delayMs = 20;
|
|
101
|
+
for (let attempt = 1; ; attempt++) {
|
|
102
|
+
try {
|
|
103
|
+
return lockSync(authPath, { realpath: false });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!isNodeErrorWithCode(error, "ELOCKED") || attempt >= maxAttempts) throw error;
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
while (Date.now() - start < delayMs) {
|
|
108
|
+
// Synchronous busy-wait to keep the whole operation a plain function.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function lockFailureResult(error: unknown): AddBackupKeyResult {
|
|
115
|
+
if (isNodeErrorWithCode(error, "ENOENT")) return { ok: false, reason: "unknown-provider" };
|
|
116
|
+
if (isNodeErrorWithCode(error, "ELOCKED")) return { ok: false, reason: "locked" };
|
|
117
|
+
return { ok: false, reason: "unreadable" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
|
121
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
125
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
126
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export interface AuthProviderEntry {
|
|
6
|
+
provider: string;
|
|
7
|
+
type: "api_key" | "oauth";
|
|
8
|
+
backupKeys?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AuthCatalogDiagnostic {
|
|
12
|
+
message: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
field?: "backupKeys" | "key-backup";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuthCatalog {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
providers: AuthProviderEntry[];
|
|
20
|
+
diagnostics: AuthCatalogDiagnostic[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LoadAuthCatalogOptions {
|
|
24
|
+
authPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type AuthCredential = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
export function loadAuthCatalog(options: LoadAuthCatalogOptions = {}): AuthCatalog {
|
|
30
|
+
const authPath = options.authPath ?? join(getAgentDir(), "auth.json");
|
|
31
|
+
let parsed: unknown;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return disabledCatalog(error instanceof SyntaxError ? "Could not parse auth.json" : "Could not read auth.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!isRecord(parsed)) {
|
|
40
|
+
return disabledCatalog("Expected auth.json to contain an object");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const providers: AuthProviderEntry[] = [];
|
|
44
|
+
const diagnostics: AuthCatalogDiagnostic[] = [];
|
|
45
|
+
|
|
46
|
+
for (const [provider, credential] of Object.entries(parsed)) {
|
|
47
|
+
if (!isRecord(credential)) continue;
|
|
48
|
+
|
|
49
|
+
if (isApiKeyCredential(credential)) {
|
|
50
|
+
const entry: AuthProviderEntry = { provider, type: "api_key" };
|
|
51
|
+
const backupField = pickBackupField(credential);
|
|
52
|
+
const backupKeys = normalizeBackupKeys(backupField.value);
|
|
53
|
+
if (backupKeys) {
|
|
54
|
+
entry.backupKeys = backupKeys;
|
|
55
|
+
} else if (backupField.value !== undefined) {
|
|
56
|
+
diagnostics.push({ provider, field: backupField.name, message: `Ignored invalid ${backupField.name}` });
|
|
57
|
+
}
|
|
58
|
+
providers.push(entry);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isOAuthCredential(credential)) {
|
|
63
|
+
providers.push({ provider, type: "oauth" });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { enabled: true, providers, diagnostics };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pickBackupField(credential: AuthCredential): { name: "backupKeys" | "key-backup"; value: unknown } {
|
|
71
|
+
const backupKeys = credential["backupKeys"];
|
|
72
|
+
if (backupKeys !== undefined) {
|
|
73
|
+
return { name: "backupKeys", value: backupKeys };
|
|
74
|
+
}
|
|
75
|
+
return { name: "key-backup", value: credential["key-backup"] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeBackupKeys(value: unknown): string[] | undefined {
|
|
79
|
+
if (isLiteralBackupKey(value)) return [value];
|
|
80
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
81
|
+
return value.every(isLiteralBackupKey) ? value : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isLiteralBackupKey(value: unknown): value is string {
|
|
85
|
+
return typeof value === "string" && value.trim().length > 0 && !value.startsWith("!") && !/\$(?:\{|[A-Za-z_])/.test(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function disabledCatalog(message: string): AuthCatalog {
|
|
89
|
+
return { enabled: false, providers: [], diagnostics: [{ message }] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(value: unknown): value is AuthCredential {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isApiKeyCredential(value: AuthCredential): boolean {
|
|
97
|
+
const validKey = value.key === undefined || typeof value.key === "string";
|
|
98
|
+
const validEnv =
|
|
99
|
+
value.env === undefined ||
|
|
100
|
+
(isRecord(value.env) && Object.values(value.env).every((entry) => typeof entry === "string"));
|
|
101
|
+
return value.type === "api_key" && validKey && validEnv;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isOAuthCredential(value: AuthCredential): boolean {
|
|
105
|
+
return (
|
|
106
|
+
value.type === "oauth" &&
|
|
107
|
+
typeof value.access === "string" &&
|
|
108
|
+
typeof value.refresh === "string" &&
|
|
109
|
+
typeof value.expires === "number" &&
|
|
110
|
+
Number.isFinite(value.expires)
|
|
111
|
+
);
|
|
112
|
+
}
|