@hu3rror/pi-failover 0.4.0 → 0.5.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/README.md +20 -19
- package/README.zh-CN.md +21 -16
- package/package.json +1 -1
- package/src/add-backup-key.ts +9 -65
- package/src/auth-catalog.ts +19 -3
- package/src/auth-file.ts +91 -0
- package/src/failover-engine.ts +21 -9
- package/src/index.ts +31 -2
- package/src/notification.ts +3 -1
- package/src/set-provider-fallback.ts +131 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Automatic credential and provider failover for [Pi coding agent](https://github.
|
|
|
8
8
|
pi install npm:@hu3rror/pi-failover
|
|
9
9
|
```
|
|
10
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
|
|
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 two extension fields: `backupKeys` (spare keys for an API-key provider) and `isFallback` (marks a provider as an eligible provider-failover target). The legacy `key-backup` spelling is still recognized on read.
|
|
12
12
|
|
|
13
13
|
## About this fork
|
|
14
14
|
|
|
@@ -35,14 +35,15 @@ pi install npm:@hu3rror/pi-failover
|
|
|
35
35
|
|
|
36
36
|
If `PI_CODING_AGENT_DIR` is set, Pi's own agent-directory resolution still applies.
|
|
37
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:
|
|
38
|
+
Keep Pi's primary credential as-is and add `backupKeys` to any API-key provider that should have same-provider backups. To let a provider be switched to when the current provider fails, mark it with `isFallback: true`. The field accepts either one literal, non-empty string or a non-empty array of literal, non-empty strings:
|
|
39
39
|
|
|
40
40
|
```json
|
|
41
41
|
{
|
|
42
42
|
"anthropic": {
|
|
43
43
|
"type": "api_key",
|
|
44
44
|
"key": "primary-api-key",
|
|
45
|
-
"backupKeys": ["backup-api-key-1", "backup-api-key-2"]
|
|
45
|
+
"backupKeys": ["backup-api-key-1", "backup-api-key-2"],
|
|
46
|
+
"isFallback": true
|
|
46
47
|
},
|
|
47
48
|
"openai-codex": {
|
|
48
49
|
"type": "oauth",
|
|
@@ -55,6 +56,7 @@ Keep Pi's primary credential as-is and add `backupKeys` to any API-key provider
|
|
|
55
56
|
|
|
56
57
|
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
|
|
|
59
|
+
`isFallback` defaults to absent (equivalent to `false`): by default `pi-failover` only retries keys within the current provider and hands the failure back to Pi's built-in retry, never switching providers. Mark one or more providers with `isFallback: true` to opt into provider failover.
|
|
58
60
|
### 3. Verify that failover is active
|
|
59
61
|
|
|
60
62
|
Start Pi and run:
|
|
@@ -68,10 +70,10 @@ The command shows redacted runtime status only. It never prints raw credential v
|
|
|
68
70
|
If the active key receives a handled failure during a user request, `pi-failover` can:
|
|
69
71
|
|
|
70
72
|
- switch to the next backup key for the same provider
|
|
71
|
-
- switch to
|
|
73
|
+
- switch to a marked fallback provider (`isFallback: true`) after every backup key of the current provider is exhausted; provider failover never happens unless at least one provider is marked
|
|
72
74
|
- retry the same user request automatically after a successful switch
|
|
73
|
-
- hand a failure back to Pi's built-in retry when
|
|
74
|
-
- show only the final provider error when every
|
|
75
|
+
- hand a failure back to Pi's built-in retry when no fallback provider is available
|
|
76
|
+
- show only the final provider error when every failover option is exhausted
|
|
75
77
|
|
|
76
78
|
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.
|
|
77
79
|
|
|
@@ -86,28 +88,26 @@ If all failover options are exhausted while Pi still has a built-in automatic re
|
|
|
86
88
|
|
|
87
89
|
- `pi-failover` never reads or writes `keyrouter.json`.
|
|
88
90
|
- `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.
|
|
89
|
-
- Provider fallback order follows the top-level insertion order in `auth.json`.
|
|
90
|
-
- OAuth entries can
|
|
91
|
+
- Provider fallback order follows the top-level insertion order in `auth.json`. Only providers marked `isFallback: true` are fallback targets; unmarked providers are never switched to.
|
|
92
|
+
- OAuth entries can be marked as fallback targets, but they do not support backup fields.
|
|
91
93
|
- Every `backupKeys` value is treated as a literal string. Values are not expanded from environment variables or commands.
|
|
92
|
-
- 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.
|
|
93
|
-
|
|
94
|
+
- Pi's `/login` flow can rewrite `auth.json` and remove unknown extension fields, so `backupKeys` and `isFallback` may need to be re-added after logging in again.
|
|
94
95
|
## How Failover Works
|
|
95
96
|
|
|
96
97
|
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.
|
|
97
98
|
|
|
98
99
|
| Failure | What pi-failover does |
|
|
99
100
|
| --- | --- |
|
|
100
|
-
| `401` / `403` | Disables the current credential for the session, switches to the next backup key or
|
|
101
|
-
| `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.
|
|
102
|
-
| `529` or overloaded responses | Cools down the provider by `Retry-After`, or by 30 seconds when the header is absent, changes provider, then retries. |
|
|
103
|
-
| `500`, `502`, `503`, `504`, network, timeout | Cools down the provider for 30 seconds, changes provider, then retries. |
|
|
101
|
+
| `401` / `403` | Disables the current credential for the session, switches to the next backup key, or to a marked fallback provider once keys are exhausted, then retries the same request. With no marked fallback provider the failure is handed back to Pi's own retry. |
|
|
102
|
+
| `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. Once every backup key is exhausted, the failure is handed back to Pi's built-in retry unless a marked fallback provider exists. |
|
|
103
|
+
| `529` or overloaded responses | Cools down the provider by `Retry-After`, or by 30 seconds when the header is absent, changes to a marked fallback provider, then retries. |
|
|
104
|
+
| `500`, `502`, `503`, `504`, network, timeout | Cools down the provider for 30 seconds, changes to a marked fallback provider, then retries. |
|
|
104
105
|
| Other failures | Leaves Pi's normal error handling unchanged. |
|
|
105
|
-
|
|
106
106
|
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.
|
|
107
107
|
|
|
108
108
|
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.
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
Provider failover is opt-in. A provider becomes a fallback target only when its credential record in `auth.json` carries `isFallback: true`; without any marked provider, `pi-failover` never switches providers and hands the failure back to Pi's own retry (`settings.retry`) once the active provider's backup keys (if any) are exhausted. OAuth providers can be marked too, but they never hold `backupKeys`.
|
|
111
111
|
|
|
112
112
|
## Working with Pi's Retry Settings
|
|
113
113
|
|
|
@@ -115,18 +115,19 @@ An `api_key` provider without `backupKeys` never triggers provider failover: whe
|
|
|
115
115
|
|
|
116
116
|
| Setting | Recommended | Why |
|
|
117
117
|
| --- | --- | --- |
|
|
118
|
-
| `retry.enabled` | `true` | Backstop for failures `pi-failover` deliberately does not touch (e.g.
|
|
118
|
+
| `retry.enabled` | `true` | Backstop for failures `pi-failover` deliberately does not touch (e.g. when no provider is marked as a fallback target). |
|
|
119
119
|
| `retry.maxRetries` | `3` (default) | Higher budgets only delay the final error after every failover option is exhausted. |
|
|
120
120
|
| `retry.provider.maxRetries` | `0` (default) | SDK-level retries swallow failures before Pi sees them, so failover never observes the error. |
|
|
121
121
|
| `retry.provider.timeoutMs` | e.g. `60000` | The SDK default is one hour; a hung provider blocks failover's network classification for that long. |
|
|
122
122
|
|
|
123
|
-
When the active provider
|
|
123
|
+
When no fallback provider is marked and the active provider's backup keys (if any) are exhausted, `pi-failover` does not replace the error message, so Pi's built-in retry runs with its own exponential backoff (`retry.baseDelayMs`). With a marked fallback provider, `pi-failover` switches the key or provider and retries immediately, then returns control to Pi.
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
## Commands
|
|
127
127
|
|
|
128
128
|
- `/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
|
|
129
129
|
- `/failover status`: shows redacted failover state
|
|
130
|
+
- `/failover fallback <provider> [off]`: mark a provider as a fallback target (`isFallback: true`), or clear the marker with `off`; provider names autocomplete, the flag is not secret so it is passed on the command line, and the catalog is rebuilt after the write
|
|
130
131
|
- `/failover reload`: restores extension-owned overrides, then rereads `auth.json`
|
|
131
132
|
|
|
132
133
|
## Output Modes
|
|
@@ -141,7 +142,7 @@ When the active provider has no `backupKeys` and fails, `pi-failover` does not r
|
|
|
141
142
|
## Migration Notes
|
|
142
143
|
|
|
143
144
|
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.
|
|
144
|
-
|
|
145
|
+
Mark one or more providers you want as failover targets with `isFallback: true`; unmarked providers are never switched to.
|
|
145
146
|
There is no dual-read migration path. `pi-failover` uses only `auth.json`.
|
|
146
147
|
|
|
147
148
|
## Security Notes
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
pi install npm:@hu3rror/pi-failover
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
`pi-failover` 用于在当前凭证或 provider 不可用时,继续让 Pi 会话向下执行。它直接复用 Pi 现有的 `auth.json
|
|
11
|
+
`pi-failover` 用于在当前凭证或 provider 不可用时,继续让 Pi 会话向下执行。它直接复用 Pi 现有的 `auth.json`,并增加两个扩展字段:`backupKeys`(API-key provider 的备用 key)与 `isFallback`(把某 provider 标记为 provider 级切换的合格目标)。旧拼写 `key-backup` 在读取时仍会被识别。
|
|
12
12
|
|
|
13
13
|
## 关于本分支(fork)
|
|
14
14
|
|
|
@@ -34,14 +34,15 @@ pi install npm:@hu3rror/pi-failover
|
|
|
34
34
|
|
|
35
35
|
如果设置了 `PI_CODING_AGENT_DIR`,仍然沿用 Pi 自身的 agent 目录解析规则。
|
|
36
36
|
|
|
37
|
-
保留 Pi 原有的主凭证,并在需要同 provider 备用 key 的 API-key provider 上增加 `backupKeys`
|
|
37
|
+
保留 Pi 原有的主凭证,并在需要同 provider 备用 key 的 API-key provider 上增加 `backupKeys` 字段。若希望当前 provider 失败时可以切换到某个 provider,请给它加 `isFallback: true`。`backupKeys` 既可以是一个字面量、非空字符串,也可以是由字面量、非空字符串组成的非空数组:
|
|
38
38
|
|
|
39
39
|
```json
|
|
40
40
|
{
|
|
41
41
|
"anthropic": {
|
|
42
42
|
"type": "api_key",
|
|
43
43
|
"key": "primary-api-key",
|
|
44
|
-
"backupKeys": ["backup-api-key-1", "backup-api-key-2"]
|
|
44
|
+
"backupKeys": ["backup-api-key-1", "backup-api-key-2"],
|
|
45
|
+
"isFallback": true
|
|
45
46
|
},
|
|
46
47
|
"openai-codex": {
|
|
47
48
|
"type": "oauth",
|
|
@@ -54,6 +55,7 @@ pi install npm:@hu3rror/pi-failover
|
|
|
54
55
|
|
|
55
56
|
现有字符串形式等价于只含一项的数组,数组中的凭证按书写顺序尝试。如果数组为空或任一元素无效,整个备用字段都会被忽略,该 provider 仍只能使用主凭证。旧拼写 `key-backup` 仅在 `backupKeys` 缺失时才会被读取;两字段同时存在时,`backupKeys` 优先。
|
|
56
57
|
|
|
58
|
+
`isFallback` 默认为缺省(等价于 `false`):默认情况下 `pi-failover` 只会在当前 provider 内重试 key,之后把失败交还给 Pi 的内置重试,绝不切换 provider。要给一个或多个 provider 加 `isFallback: true` 才会启用 provider 级切换。
|
|
57
59
|
### 3. 验证故障切换已启用
|
|
58
60
|
|
|
59
61
|
启动 Pi 后执行:
|
|
@@ -67,10 +69,10 @@ pi install npm:@hu3rror/pi-failover
|
|
|
67
69
|
当当前 key 在一次用户请求中遇到已接管的故障时,`pi-failover` 会按情况执行:
|
|
68
70
|
|
|
69
71
|
- 切到同一 provider 的下一把备用 key
|
|
70
|
-
-
|
|
72
|
+
- 在当前 provider 的所有备用 key 用尽之后,切到标记了 `isFallback: true` 的 provider;只要没有任何 provider 被标记,就永远不会发生 provider 级切换
|
|
71
73
|
- 成功切换后自动重试同一次用户请求
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
+
- 当不存在可用的 fallback provider 时,把失败交还给 Pi 的内置重试
|
|
75
|
+
- 当所有 failover 选项都耗尽时,只显示最后一次 provider 错误
|
|
74
76
|
|
|
75
77
|
中间 provider 错误会被替换为隐藏的续跑消息,因此用户无需再次发送相同内容。TUI 和 RPC 模式仍会为每次实际生效的凭据或 provider 切换显示一条脱敏警告。
|
|
76
78
|
|
|
@@ -86,10 +88,10 @@ pi install npm:@hu3rror/pi-failover
|
|
|
86
88
|
|
|
87
89
|
- `pi-failover` 不会读取或写入 `keyrouter.json`。
|
|
88
90
|
- `backupKeys` 表示同一 provider 的一把或多把备用 key,不表示 provider 级切换。旧拼写 `key-backup` 在 `backupKeys` 缺失时仍会被识别。
|
|
89
|
-
- provider 的切换顺序由 `auth.json`
|
|
90
|
-
- OAuth
|
|
91
|
+
- provider 的切换顺序由 `auth.json` 顶层字段的插入顺序决定。只有标记为 `isFallback: true` 的 provider 才是切换目标;未标记的 provider 永远不会被切换过去。
|
|
92
|
+
- OAuth 条目可以标记为切换目标,但不支持备用字段。
|
|
91
93
|
- `backupKeys` 中的每个值都按字面量字符串处理,不支持从环境变量或命令动态展开。
|
|
92
|
-
- Pi 的 `/login` 流程可能会重写 `auth.json` 并移除未知扩展字段,因此重新登录后可能需要再次补上 `backupKeys`。
|
|
94
|
+
- Pi 的 `/login` 流程可能会重写 `auth.json` 并移除未知扩展字段,因此重新登录后可能需要再次补上 `backupKeys` 与 `isFallback`。
|
|
93
95
|
|
|
94
96
|
## 故障切换规则
|
|
95
97
|
|
|
@@ -97,17 +99,17 @@ pi install npm:@hu3rror/pi-failover
|
|
|
97
99
|
|
|
98
100
|
| 故障类型 | `pi-failover` 的处理方式 |
|
|
99
101
|
| --- | --- |
|
|
100
|
-
| `401` / `403` | 将当前凭证在本次会话中标记为不可用,切换到下一把备用 key
|
|
101
|
-
| `429` | 按 `Retry-After` 冷却当前凭证;如果没有该响应头,则冷却 60 秒,切换到下一把备用 key
|
|
102
|
-
| `529` 或 overloaded 响应 | 按 `Retry-After` 冷却当前 provider;如果没有该响应头,则冷却 30
|
|
103
|
-
| `500`、`502`、`503`、`504`、网络错误、超时 | 将当前 provider 冷却 30
|
|
102
|
+
| `401` / `403` | 将当前凭证在本次会话中标记为不可用,切换到下一把备用 key;key 用尽后切到标记了 `isFallback` 的 provider,然后重试同一次请求。没有标记任何 fallback provider 时,失败交还给 Pi 自己的重试。 |
|
|
103
|
+
| `429` | 按 `Retry-After` 冷却当前凭证;如果没有该响应头,则冷却 60 秒,切换到下一把备用 key 后重试。备用 key 全部用尽后,除非存在标记了 `isFallback` 的 provider,否则失败交还给 Pi 的内置重试。 |
|
|
104
|
+
| `529` 或 overloaded 响应 | 按 `Retry-After` 冷却当前 provider;如果没有该响应头,则冷却 30 秒,切到标记了 `isFallback` 的 provider 后重试。 |
|
|
105
|
+
| `500`、`502`、`503`、`504`、网络错误、超时 | 将当前 provider 冷却 30 秒,切到标记了 `isFallback` 的 provider 后重试。 |
|
|
104
106
|
| 其他故障 | 保持 Pi 原有的错误处理逻辑,不额外接管。 |
|
|
105
107
|
|
|
106
108
|
发生 provider 切换时,`pi-failover` 会优先保留当前 model ID;如果目标 provider 没有该 model,则退回到该 provider 的第一个可用 model。扩展内部会调用 Pi 的 `setModel()`,因此新的默认 model 会持续生效;后续不会自动切回原 provider。
|
|
107
109
|
|
|
108
110
|
状态和警告信息只显示脱敏后的凭证槽位:主凭证为 `primary`,第一把备用凭证为 `backup`,后续依次为 `backup-2`、`backup-3`……
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
provider 级切换是显式开启的。只有当某 provider 的 credential 记录在 `auth.json` 中带有 `isFallback: true` 时,它才会成为切换目标;没有任何标记 provider 时,`pi-failover` 绝不切换 provider,而是在当前 provider 的备用 key(如有)用尽后把失败交还给 Pi 自己的重试(`settings.retry`)。OAuth provider 也可以被标记,但它们永远不会持有 `backupKeys`。
|
|
111
113
|
|
|
112
114
|
## 与 Pi 的 retry 设置配合
|
|
113
115
|
|
|
@@ -115,18 +117,19 @@ pi install npm:@hu3rror/pi-failover
|
|
|
115
117
|
|
|
116
118
|
| 设置项 | 推荐值 | 原因 |
|
|
117
119
|
| --- | --- | --- |
|
|
118
|
-
| `retry.enabled` | `true` | 作为 `pi-failover`
|
|
120
|
+
| `retry.enabled` | `true` | 作为 `pi-failover` 刻意不接管的失败(例如没有标记任何 fallback provider 时)的兜底。 |
|
|
119
121
|
| `retry.maxRetries` | `3`(默认值) | 更大的预算只会在所有 failover 选项耗尽后拖延最终错误出现的时间。 |
|
|
120
122
|
| `retry.provider.maxRetries` | `0`(默认值) | SDK 层的重试会在 Pi 看到错误之前把失败吞掉,failover 将永远观察不到该错误。 |
|
|
121
123
|
| `retry.provider.timeoutMs` | 例如 `60000` | SDK 默认是 1 小时;provider 挂起时,failover 的网络故障分类会被阻塞同样长的时间。 |
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
当没有标记任何 fallback provider、且当前 provider 的备用 key(如有)已用尽时,`pi-failover` 不替换错误消息,Pi 的内置重试会按自己的指数退避(`retry.baseDelayMs`)执行。存在标记了 `isFallback` 的 provider 时,`pi-failover` 会立即切换 key 或 provider 并重试,随后把控制权交还给 Pi。
|
|
124
126
|
|
|
125
127
|
|
|
126
128
|
## 命令
|
|
127
129
|
|
|
128
130
|
- `/failover login`:交互式地为 `api_key` provider 添加备用 key;不带参数时从现有 provider 中选择,也可直接指定 provider(`/failover login <provider>`,支持自动补全);key 在输入框中录入(绝不通过命令行参数传入),经确认后写入 `auth.json` 并立即重建 failover catalog,新 key 立即可用
|
|
129
131
|
- `/failover status`:查看脱敏后的故障切换状态
|
|
132
|
+
- `/failover fallback <provider> [off]`:把某 provider 标记为 fallback 目标(`isFallback: true`),或用 `off` 清除标记;provider 名支持自动补全,该标记非机密,可直接作为命令行参数传入,写入成功后立即重建 catalog
|
|
130
133
|
- `/failover reload`:恢复扩展接管的 override,然后重新读取 `auth.json`
|
|
131
134
|
|
|
132
135
|
## 输出模式
|
|
@@ -142,6 +145,8 @@ pi install npm:@hu3rror/pi-failover
|
|
|
142
145
|
|
|
143
146
|
如果从 `~/.pi/keyrouter.json` 迁移,需要把每个 provider 的主凭证搬到 Pi 的 `auth.json` 中,再把一把备用 key 字符串或按顺序排列的备用 key 数组写入 `backupKeys`。如需控制 provider 切换顺序,可直接调整 `auth.json` 顶层条目的顺序。
|
|
144
147
|
|
|
148
|
+
把希望作为切换目标的 provider 标记为 `isFallback: true`;未标记的 provider 永远不会被切换过去。
|
|
149
|
+
|
|
145
150
|
当前没有双读迁移模式,`pi-failover` 只读取 `auth.json`。
|
|
146
151
|
|
|
147
152
|
## 安全说明
|
package/package.json
CHANGED
package/src/add-backup-key.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AUTH_FILE_WRITE_OPTIONS,
|
|
6
|
+
isRecord,
|
|
7
|
+
withAuthFileLock,
|
|
8
|
+
} from "./auth-file.ts";
|
|
5
9
|
import {
|
|
6
10
|
isApiKeyCredential,
|
|
7
11
|
isLiteralBackupKey,
|
|
@@ -9,7 +13,6 @@ import {
|
|
|
9
13
|
normalizeBackupKeys,
|
|
10
14
|
pickBackupField,
|
|
11
15
|
} from "./auth-catalog.ts";
|
|
12
|
-
|
|
13
16
|
export type AddBackupKeyRejection =
|
|
14
17
|
| "invalid-key"
|
|
15
18
|
| "unknown-provider"
|
|
@@ -27,9 +30,6 @@ export interface AddBackupKeyOptions {
|
|
|
27
30
|
authPath?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
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
33
|
/**
|
|
34
34
|
* Appends a validated backup key to a provider's `backupKeys` in `auth.json`.
|
|
35
35
|
*
|
|
@@ -47,30 +47,7 @@ export function addBackupKey(
|
|
|
47
47
|
|
|
48
48
|
if (!isLiteralBackupKey(backupKey)) return { ok: false, reason: "invalid-key" };
|
|
49
49
|
|
|
50
|
-
|
|
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
|
-
|
|
50
|
+
const access = withAuthFileLock<AddBackupKeyResult>(authPath, (parsed) => {
|
|
74
51
|
const credential = parsed[providerId];
|
|
75
52
|
if (credential === undefined) return { ok: false, reason: "unknown-provider" };
|
|
76
53
|
if (!isRecord(credential)) return { ok: false, reason: "malformed" };
|
|
@@ -86,41 +63,8 @@ export function addBackupKey(
|
|
|
86
63
|
const updated = { ...parsed, [providerId]: { ...credential, backupKeys } };
|
|
87
64
|
fs.writeFileSync(authPath, JSON.stringify(updated, null, 2), AUTH_FILE_WRITE_OPTIONS);
|
|
88
65
|
return { ok: true, changed: true, provider: providerId, backupKeys };
|
|
89
|
-
}
|
|
90
|
-
release?.();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
66
|
+
});
|
|
93
67
|
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
|
68
|
+
if (!access.ok) return { ok: false, reason: access.reason };
|
|
69
|
+
return access.value;
|
|
122
70
|
}
|
|
123
|
-
|
|
124
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
125
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
126
|
-
}
|
package/src/auth-catalog.ts
CHANGED
|
@@ -8,14 +8,14 @@ export interface AuthProviderEntry {
|
|
|
8
8
|
provider: string;
|
|
9
9
|
type: ProviderType;
|
|
10
10
|
backupKeys?: string[];
|
|
11
|
+
isFallback?: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface AuthCatalogDiagnostic {
|
|
14
15
|
message: string;
|
|
15
16
|
provider?: string;
|
|
16
|
-
field?: "backupKeys" | "key-backup";
|
|
17
|
+
field?: "backupKeys" | "key-backup" | "isFallback";
|
|
17
18
|
}
|
|
18
|
-
|
|
19
19
|
export interface AuthCatalog {
|
|
20
20
|
enabled: boolean;
|
|
21
21
|
providers: AuthProviderEntry[];
|
|
@@ -48,6 +48,11 @@ export function loadAuthCatalog(options: LoadAuthCatalogOptions = {}): AuthCatal
|
|
|
48
48
|
for (const [provider, credential] of Object.entries(parsed)) {
|
|
49
49
|
if (!isRecord(credential)) continue;
|
|
50
50
|
|
|
51
|
+
const fallback = readIsFallback(credential);
|
|
52
|
+
if (fallback.invalid) {
|
|
53
|
+
diagnostics.push({ provider, field: "isFallback", message: "Ignored invalid isFallback" });
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
if (isApiKeyCredential(credential)) {
|
|
52
57
|
const entry: AuthProviderEntry = { provider, type: "api_key" };
|
|
53
58
|
const backupField = pickBackupField(credential);
|
|
@@ -57,12 +62,17 @@ export function loadAuthCatalog(options: LoadAuthCatalogOptions = {}): AuthCatal
|
|
|
57
62
|
} else if (backupField.value !== undefined) {
|
|
58
63
|
diagnostics.push({ provider, field: backupField.name, message: `Ignored invalid ${backupField.name}` });
|
|
59
64
|
}
|
|
65
|
+
if (fallback.value !== undefined) entry.isFallback = fallback.value;
|
|
60
66
|
providers.push(entry);
|
|
61
67
|
continue;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
if (isOAuthCredential(credential)) {
|
|
65
|
-
providers.push({
|
|
71
|
+
providers.push({
|
|
72
|
+
provider,
|
|
73
|
+
type: "oauth",
|
|
74
|
+
...(fallback.value !== undefined ? { isFallback: fallback.value } : {}),
|
|
75
|
+
});
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -77,6 +87,12 @@ export function pickBackupField(credential: AuthCredential): { name: "backupKeys
|
|
|
77
87
|
return { name: "key-backup", value: credential["key-backup"] };
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
function readIsFallback(credential: AuthCredential): { value: boolean | undefined; invalid: boolean } {
|
|
91
|
+
const raw = credential["isFallback"];
|
|
92
|
+
if (raw === undefined) return { value: undefined, invalid: false };
|
|
93
|
+
if (typeof raw === "boolean") return { value: raw, invalid: false };
|
|
94
|
+
return { value: undefined, invalid: true };
|
|
95
|
+
}
|
|
80
96
|
export function normalizeBackupKeys(value: unknown): string[] | undefined {
|
|
81
97
|
if (isLiteralBackupKey(value)) return [value];
|
|
82
98
|
if (!Array.isArray(value) || value.length === 0) return undefined;
|
package/src/auth-file.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { lockSync } from "proper-lockfile";
|
|
3
|
+
|
|
4
|
+
// Shared helpers for lockfile-guarded writes to Pi's auth.json, mirroring
|
|
5
|
+
// Pi's official FileAuthStorageBackend (lockfile guard, 0600 mode, 2-space JSON).
|
|
6
|
+
|
|
7
|
+
// The mode applies only on file creation, mirroring Pi's FileAuthStorageBackend.
|
|
8
|
+
export const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const;
|
|
9
|
+
|
|
10
|
+
export type LockFailureReason = "unknown-provider" | "locked" | "unreadable";
|
|
11
|
+
|
|
12
|
+
export type AuthFileAccessFailure = "unknown-provider" | "locked" | "unreadable" | "malformed";
|
|
13
|
+
|
|
14
|
+
export type AuthFileAccessResult<T> =
|
|
15
|
+
| { ok: true; value: T }
|
|
16
|
+
| { ok: false; reason: AuthFileAccessFailure };
|
|
17
|
+
|
|
18
|
+
export function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
|
19
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Acquires the `<authPath>.lock` guard, retrying `ELOCKED` briefly, mirroring
|
|
28
|
+
* Pi's FileAuthStorageBackend retry loop.
|
|
29
|
+
*/
|
|
30
|
+
export function acquireLockSync(authPath: string): () => void {
|
|
31
|
+
const maxAttempts = 10;
|
|
32
|
+
const delayMs = 20;
|
|
33
|
+
for (let attempt = 1; ; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
return lockSync(authPath, { realpath: false });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!isNodeErrorWithCode(error, "ELOCKED") || attempt >= maxAttempts) throw error;
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
while (Date.now() - start < delayMs) {
|
|
40
|
+
// Synchronous busy-wait to keep the whole operation a plain function.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function lockFailureReason(error: unknown): LockFailureReason {
|
|
47
|
+
if (isNodeErrorWithCode(error, "ENOENT")) return "unknown-provider";
|
|
48
|
+
if (isNodeErrorWithCode(error, "ELOCKED")) return "locked";
|
|
49
|
+
return "unreadable";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs `mutate` under the `<authPath>.lock` guard with `auth.json` already
|
|
54
|
+
* locked, read, and parsed as a top-level object. The mutation writes back
|
|
55
|
+
* through `AUTH_FILE_WRITE_OPTIONS` and preserves provider order; failures to
|
|
56
|
+
* lock, read, or parse are mapped to structured `AuthFileAccessFailure`
|
|
57
|
+
* reasons. Mirrors Pi's official `FileAuthStorageBackend.withLock` sequence.
|
|
58
|
+
*/
|
|
59
|
+
export function withAuthFileLock<T>(
|
|
60
|
+
authPath: string,
|
|
61
|
+
mutate: (parsed: Record<string, unknown>) => T,
|
|
62
|
+
): AuthFileAccessResult<T> {
|
|
63
|
+
let release: (() => void) | undefined;
|
|
64
|
+
try {
|
|
65
|
+
release = acquireLockSync(authPath);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { ok: false, reason: lockFailureReason(error) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
let content: string;
|
|
72
|
+
try {
|
|
73
|
+
content = fs.readFileSync(authPath, "utf-8");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (isNodeErrorWithCode(error, "ENOENT")) return { ok: false, reason: "unknown-provider" };
|
|
76
|
+
return { ok: false, reason: "unreadable" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let parsed: unknown;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(content);
|
|
82
|
+
} catch {
|
|
83
|
+
return { ok: false, reason: "malformed" };
|
|
84
|
+
}
|
|
85
|
+
if (!isRecord(parsed)) return { ok: false, reason: "malformed" };
|
|
86
|
+
|
|
87
|
+
return { ok: true, value: mutate(parsed) };
|
|
88
|
+
} finally {
|
|
89
|
+
release?.();
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/failover-engine.ts
CHANGED
|
@@ -55,6 +55,7 @@ export interface FailoverProvider {
|
|
|
55
55
|
id: string;
|
|
56
56
|
backupKeyCount?: number;
|
|
57
57
|
type: ProviderType;
|
|
58
|
+
isFallback?: boolean;
|
|
58
59
|
}
|
|
59
60
|
export interface FailoverEngineOptions {
|
|
60
61
|
providers: readonly FailoverProvider[];
|
|
@@ -76,6 +77,7 @@ interface ProviderState {
|
|
|
76
77
|
id: string;
|
|
77
78
|
type: ProviderType;
|
|
78
79
|
backupKeyCount: number;
|
|
80
|
+
isFallback: boolean;
|
|
79
81
|
keys: KeyState[];
|
|
80
82
|
cooldownUntil: number;
|
|
81
83
|
}
|
|
@@ -105,6 +107,7 @@ export class FailoverEngine {
|
|
|
105
107
|
private readonly nextProvider: FailoverEngineOptions["nextProvider"];
|
|
106
108
|
private visitedKeys = new Set<string>();
|
|
107
109
|
private visitedProviders = new Set<string>();
|
|
110
|
+
private fellBackOnce = false;
|
|
108
111
|
private active?: ActiveAttempt;
|
|
109
112
|
private decision: FailoverDecision = { kind: "none" };
|
|
110
113
|
|
|
@@ -113,6 +116,7 @@ export class FailoverEngine {
|
|
|
113
116
|
id: provider.id,
|
|
114
117
|
type: provider.type,
|
|
115
118
|
backupKeyCount: provider.backupKeyCount ?? 0,
|
|
119
|
+
isFallback: provider.isFallback ?? false,
|
|
116
120
|
keys: [
|
|
117
121
|
{ slot: "primary" as const, disabled: false, cooldownUntil: 0 },
|
|
118
122
|
...Array.from({ length: provider.backupKeyCount ?? 0 }, (_, index) => ({
|
|
@@ -130,6 +134,7 @@ export class FailoverEngine {
|
|
|
130
134
|
startTurn(initial: ProviderPlan): FailoverDecision {
|
|
131
135
|
this.visitedKeys = new Set();
|
|
132
136
|
this.visitedProviders = new Set();
|
|
137
|
+
this.fellBackOnce = false;
|
|
133
138
|
this.active = undefined;
|
|
134
139
|
|
|
135
140
|
return this.selectPlan(initial, "switch-key") ?? this.selectFallback(initial);
|
|
@@ -220,14 +225,11 @@ export class FailoverEngine {
|
|
|
220
225
|
}
|
|
221
226
|
|
|
222
227
|
private selectFallback(current: ProviderPlan): FailoverDecision {
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (activeProvider?.type === "api_key" && activeProvider.backupKeyCount === 0) {
|
|
229
|
-
return this.setDecision({ kind: "none" });
|
|
230
|
-
}
|
|
228
|
+
// Provider failover is opt-in: only providers marked `isFallback` are
|
|
229
|
+
// eligible targets. With no fallback target, hand the failure back to
|
|
230
|
+
// Pi's built-in retry (kind: "none") instead of switching providers.
|
|
231
|
+
const hasTarget = this.providers.some((provider) => provider.isFallback && provider.id !== current.providerId);
|
|
232
|
+
if (!hasTarget) return this.setDecision({ kind: "none" });
|
|
231
233
|
|
|
232
234
|
const considered = new Set<string>();
|
|
233
235
|
for (let remaining = this.providers.length; remaining > 0; remaining -= 1) {
|
|
@@ -237,10 +239,20 @@ export class FailoverEngine {
|
|
|
237
239
|
});
|
|
238
240
|
if (!plan) break;
|
|
239
241
|
considered.add(plan.providerId);
|
|
242
|
+
const target = this.provider(plan.providerId);
|
|
243
|
+
if (!target?.isFallback) continue;
|
|
240
244
|
const decision = this.selectPlan(plan, "switch-model");
|
|
241
|
-
if (decision)
|
|
245
|
+
if (decision) {
|
|
246
|
+
this.fellBackOnce = true;
|
|
247
|
+
return decision;
|
|
248
|
+
}
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
// The walk produced nothing this time. When no marked target was ever
|
|
252
|
+
// reached this turn (none was usable, e.g. unconfigured in the runtime),
|
|
253
|
+
// hand the failure back to Pi's built-in retry; only a genuine walk of
|
|
254
|
+
// marked targets is "exhausted".
|
|
255
|
+
if (!this.fellBackOnce) return this.setDecision({ kind: "none" });
|
|
244
256
|
return this.setDecision({ kind: "exhausted" });
|
|
245
257
|
}
|
|
246
258
|
|
package/src/index.ts
CHANGED
|
@@ -18,10 +18,15 @@ import {
|
|
|
18
18
|
notifyProviderSwitch,
|
|
19
19
|
} from "./notification.ts";
|
|
20
20
|
import { createPiRuntimeAdapter, type PiRuntimeAdapter } from "./pi-runtime.ts";
|
|
21
|
-
|
|
21
|
+
import {
|
|
22
|
+
runSetFallback,
|
|
23
|
+
setProviderFallback,
|
|
24
|
+
type SetProviderFallbackResult,
|
|
25
|
+
} from "./set-provider-fallback.ts";
|
|
22
26
|
export interface FailoverExtensionOptions {
|
|
23
27
|
loadCatalog?: () => AuthCatalog;
|
|
24
28
|
writeBackupKey?: (providerId: string, backupKey: string) => AddBackupKeyResult;
|
|
29
|
+
writeFallback?: (providerId: string, enabled: boolean) => SetProviderFallbackResult;
|
|
25
30
|
now?: () => number;
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -70,8 +75,8 @@ function retryAfterMilliseconds(headers: Record<string, string>, now: number): n
|
|
|
70
75
|
export function createFailoverExtension(options: FailoverExtensionOptions = {}) {
|
|
71
76
|
const readCatalog = options.loadCatalog ?? loadAuthCatalog;
|
|
72
77
|
const writeBackupKey = options.writeBackupKey ?? addBackupKey;
|
|
78
|
+
const writeFallback = options.writeFallback ?? setProviderFallback;
|
|
73
79
|
const now = options.now ?? Date.now;
|
|
74
|
-
|
|
75
80
|
return function registerFailover(pi: ExtensionAPI): void {
|
|
76
81
|
let catalog: AuthCatalog = { enabled: false, providers: [], diagnostics: [] };
|
|
77
82
|
let engine: FailoverEngine | undefined;
|
|
@@ -127,6 +132,7 @@ export function createFailoverExtension(options: FailoverExtensionOptions = {})
|
|
|
127
132
|
id: provider.provider,
|
|
128
133
|
type: provider.type,
|
|
129
134
|
backupKeyCount: provider.backupKeys?.length ?? 0,
|
|
135
|
+
isFallback: provider.isFallback,
|
|
130
136
|
})),
|
|
131
137
|
now,
|
|
132
138
|
nextProvider: ({ current, unavailableProviderIds }) => nextProvider(current, unavailableProviderIds),
|
|
@@ -341,6 +347,29 @@ export function createFailoverExtension(options: FailoverExtensionOptions = {})
|
|
|
341
347
|
});
|
|
342
348
|
},
|
|
343
349
|
});
|
|
350
|
+
|
|
351
|
+
pi.registerCommand("failover-fallback", {
|
|
352
|
+
description: "mark or unmark a provider as a fallback target in auth.json",
|
|
353
|
+
getArgumentCompletions: (prefix) => {
|
|
354
|
+
const items = [
|
|
355
|
+
...readCatalog().providers.map((provider) => ({ value: provider.provider, label: provider.provider })),
|
|
356
|
+
{ value: "off", label: "off" },
|
|
357
|
+
];
|
|
358
|
+
const matches = items.filter((item) => item.value.startsWith(prefix));
|
|
359
|
+
return matches.length > 0 ? matches : null;
|
|
360
|
+
},
|
|
361
|
+
handler: async (args, ctx) => {
|
|
362
|
+
await runSetFallback(args, {
|
|
363
|
+
readCatalog,
|
|
364
|
+
writeFallback,
|
|
365
|
+
rebuild: async () => {
|
|
366
|
+
await restoreOwnedOverrides();
|
|
367
|
+
rebuild(ctx);
|
|
368
|
+
},
|
|
369
|
+
notify: (message, level) => notify(ctx, message, level),
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
});
|
|
344
373
|
};
|
|
345
374
|
}
|
|
346
375
|
|
package/src/notification.ts
CHANGED
|
@@ -33,9 +33,11 @@ export function formatStatus(catalog: AuthCatalog, snapshot: FailoverSnapshot |
|
|
|
33
33
|
lines.push(`current: ${snapshot.active.providerId}/${snapshot.active.model}; key=${snapshot.active.keySlot}`);
|
|
34
34
|
}
|
|
35
35
|
for (const provider of snapshot.providers) {
|
|
36
|
+
const catalogEntry = catalog.providers.find((entry) => entry.provider === provider.providerId);
|
|
36
37
|
const providerCooldown = provider.cooldownUntil === undefined ? "" : ` until=${provider.cooldownUntil}`;
|
|
37
38
|
const keys = provider.keys.map((key) => `${key.slot}=${key.status}${key.cooldownUntil === undefined ? "" : ` until=${key.cooldownUntil}`}`).join(", ");
|
|
38
|
-
|
|
39
|
+
const marker = catalogEntry?.isFallback ? " (fallback target)" : "";
|
|
40
|
+
lines.push(`${provider.providerId}${marker}: ${provider.status}${providerCooldown}; ${keys}`);
|
|
39
41
|
}
|
|
40
42
|
lines.push(`visited providers: ${snapshot.visitedProviders.join(", ") || "none"}`);
|
|
41
43
|
lines.push(`visited keys: ${snapshot.visitedKeys.map((key) => `${key.providerId}:${key.keySlot}`).join(", ") || "none"}`);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AUTH_FILE_WRITE_OPTIONS,
|
|
7
|
+
isRecord,
|
|
8
|
+
withAuthFileLock,
|
|
9
|
+
} from "./auth-file.ts";
|
|
10
|
+
import type { AuthCatalog } from "./auth-catalog.ts";
|
|
11
|
+
import type { NotificationLevel } from "./notification.ts";
|
|
12
|
+
|
|
13
|
+
export type SetProviderFallbackRejection = "unknown-provider" | "malformed" | "unreadable" | "locked";
|
|
14
|
+
|
|
15
|
+
export type SetProviderFallbackResult =
|
|
16
|
+
| { ok: true; changed: boolean; provider: string; isFallback: boolean }
|
|
17
|
+
| { ok: false; reason: SetProviderFallbackRejection };
|
|
18
|
+
|
|
19
|
+
export interface SetProviderFallbackOptions {
|
|
20
|
+
authPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sets or clears the `isFallback` marker on a provider's credential record in
|
|
25
|
+
* `auth.json`, marking it as an eligible provider-failover target.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors Pi's official write method (`FileAuthStorageBackend.withLock`) exactly
|
|
28
|
+
* like `addBackupKey`: lockfile-guarded read-modify-write, `0600` permissions,
|
|
29
|
+
* 2-space JSON, other credentials and provider order preserved. The default
|
|
30
|
+
* `authPath` mirrors the catalog loader's injection point.
|
|
31
|
+
*/
|
|
32
|
+
export function setProviderFallback(
|
|
33
|
+
providerId: string,
|
|
34
|
+
enabled: boolean,
|
|
35
|
+
options: SetProviderFallbackOptions = {},
|
|
36
|
+
): SetProviderFallbackResult {
|
|
37
|
+
const authPath = options.authPath ?? join(getAgentDir(), "auth.json");
|
|
38
|
+
|
|
39
|
+
const access = withAuthFileLock<SetProviderFallbackResult>(authPath, (parsed) => {
|
|
40
|
+
const credential = parsed[providerId];
|
|
41
|
+
if (credential === undefined) return { ok: false, reason: "unknown-provider" };
|
|
42
|
+
if (!isRecord(credential)) return { ok: false, reason: "malformed" };
|
|
43
|
+
|
|
44
|
+
const changed = credential["isFallback"] !== enabled;
|
|
45
|
+
if (!changed) return { ok: true, changed: false, provider: providerId, isFallback: enabled };
|
|
46
|
+
|
|
47
|
+
const updated = { ...parsed, [providerId]: { ...credential, isFallback: enabled } };
|
|
48
|
+
fs.writeFileSync(authPath, JSON.stringify(updated, null, 2), AUTH_FILE_WRITE_OPTIONS);
|
|
49
|
+
return { ok: true, changed: true, provider: providerId, isFallback: enabled };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!access.ok) return { ok: false, reason: access.reason };
|
|
53
|
+
return access.value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SetFallbackDependencies {
|
|
57
|
+
readCatalog: () => AuthCatalog;
|
|
58
|
+
writeFallback: (providerId: string, enabled: boolean) => SetProviderFallbackResult;
|
|
59
|
+
rebuild: () => void | Promise<void>;
|
|
60
|
+
notify: (message: string, level: NotificationLevel) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Argument-based `/failover-fallback` flow: `/failover-fallback <provider>`
|
|
65
|
+
* marks the provider as a fallback target, `/failover-fallback <provider> off`
|
|
66
|
+
* clears the marker. The flag is not secret, so it is passed on the command
|
|
67
|
+
* line (unlike `/failover-login`, which keeps keys out of the command line).
|
|
68
|
+
*/
|
|
69
|
+
export async function runSetFallback(args: string, deps: SetFallbackDependencies): Promise<void> {
|
|
70
|
+
const tokens = args.trim().split(/\s+/).filter((token) => token !== "");
|
|
71
|
+
if (tokens.length === 0) {
|
|
72
|
+
deps.notify("pi-failover: usage: /failover-fallback <provider> [off]", "error");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (tokens.length > 2 || (tokens.length === 2 && tokens[1] !== "off")) {
|
|
76
|
+
deps.notify(
|
|
77
|
+
tokens.length === 2
|
|
78
|
+
? `pi-failover: unknown option ${tokens[1]}; use "off" to unmark a fallback provider`
|
|
79
|
+
: "pi-failover: usage: /failover-fallback <provider> [off]",
|
|
80
|
+
"error",
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const providerId = tokens[0];
|
|
86
|
+
const enabled = tokens.length === 1;
|
|
87
|
+
|
|
88
|
+
const catalog = deps.readCatalog();
|
|
89
|
+
if (!catalog.providers.some((provider) => provider.provider === providerId)) {
|
|
90
|
+
deps.notify(`pi-failover: unknown provider ${providerId}`, "error");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = deps.writeFallback(providerId, enabled);
|
|
95
|
+
if (!result.ok) {
|
|
96
|
+
deps.notify(`pi-failover: could not update ${providerId}: ${describeRejection(result.reason)}`, "error");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await deps.rebuild();
|
|
101
|
+
|
|
102
|
+
if (enabled) {
|
|
103
|
+
deps.notify(
|
|
104
|
+
result.changed
|
|
105
|
+
? `pi-failover: ${providerId} is now a fallback target`
|
|
106
|
+
: `pi-failover: ${providerId} is already a fallback target`,
|
|
107
|
+
"info",
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
deps.notify(
|
|
111
|
+
result.changed
|
|
112
|
+
? `pi-failover: ${providerId} is no longer a fallback target`
|
|
113
|
+
: `pi-failover: ${providerId} is not marked as a fallback target`,
|
|
114
|
+
"info",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function describeRejection(reason: SetProviderFallbackRejection): string {
|
|
120
|
+
switch (reason) {
|
|
121
|
+
case "unknown-provider":
|
|
122
|
+
return "provider not found in auth.json";
|
|
123
|
+
case "malformed":
|
|
124
|
+
return "auth.json malformed";
|
|
125
|
+
case "unreadable":
|
|
126
|
+
return "auth.json unreadable";
|
|
127
|
+
case "locked":
|
|
128
|
+
return "auth.json locked by another process";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|