@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 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 one extension field, `backupKeys`, for API-key providers. The legacy `key-backup` spelling is still recognized on read.
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 the next configured provider (after every backup key of the current provider is exhausted; a provider without `backupKeys` never switches providers)
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 the active provider has no backup keys
74
- - show only the final provider error when every configured option is exhausted
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 participate in provider fallback, but they do not support backup fields.
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 the next provider, then retries the same request. A provider without `backupKeys` hands the failure back to Pi's own retry instead of switching providers. |
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. Without `backupKeys` the failure is handed back to Pi's built-in retry. |
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
- An `api_key` provider without `backupKeys` never triggers provider failover: when its credential fails, `pi-failover` leaves the error message untouched and hands the failure back to Pi's own retry (`settings.retry`). OAuth providers, which structurally cannot hold backup keys, keep participating in provider failover and remain reachable as fallback targets.
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. a provider without `backupKeys`). |
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 has no `backupKeys` and fails, `pi-failover` does not replace the error message, so Pi's built-in retry runs with its own exponential backoff (`retry.baseDelayMs`). With backups, `pi-failover` switches the key or provider and retries immediately, then returns control to Pi.
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`,并为 API key provider 增加一个扩展字段 `backupKeys`。旧拼写 `key-backup` 在读取时仍会被识别。
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
- - 切到下一个已配置 provider(当前 provider 的所有备用 key 都用尽之后;没有 `backupKeys` 的 provider 永远不会切换 provider
72
+ - 在当前 provider 的所有备用 key 用尽之后,切到标记了 `isFallback: true` 的 provider;只要没有任何 provider 被标记,就永远不会发生 provider 级切换
71
73
  - 成功切换后自动重试同一次用户请求
72
- - 当当前 provider 没有备用 key 时,把失败交还给 Pi 的内置重试
73
- - 当所有可选项都耗尽时,只显示最后一次 provider 错误
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 条目可以参与 provider 级切换,但不支持备用字段。
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 或下一个 provider,然后重试同一次请求。没有 `backupKeys` 的 provider 会把失败交还给 Pi 自己的重试,而不是切换 provider。 |
101
- | `429` | 按 `Retry-After` 冷却当前凭证;如果没有该响应头,则冷却 60 秒,切换到下一把备用 key 后重试。没有 `backupKeys` 时,失败交还给 Pi 的内置重试。 |
102
- | `529` 或 overloaded 响应 | 按 `Retry-After` 冷却当前 provider;如果没有该响应头,则冷却 30 秒,切换 provider 后重试。 |
103
- | `500`、`502`、`503`、`504`、网络错误、超时 | 将当前 provider 冷却 30 秒,切换 provider 后重试。 |
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
- 没有 `backupKeys` `api_key` provider 永远不会触发 provider 级切换:其凭证失败时,`pi-failover` 不修改错误消息,直接把失败交还给 Pi 自己的重试(`settings.retry`)。OAuth provider 结构上无法持有备用 key,仍然参与 provider 级切换,并且始终可以作为被切换的目标。
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` 刻意不接管的失败(例如没有 `backupKeys` provider)的兜底。 |
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
- 当当前 provider 没有 `backupKeys` 且请求失败时,`pi-failover` 不替换错误消息,Pi 的内置重试会按自己的指数退避(`retry.baseDelayMs`)执行。配置了备用 key 时,`pi-failover` 会立即切换 key 或 provider 并重试,随后把控制权交还给 Pi。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hu3rror/pi-failover",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Automatic API credential and provider failover for Pi coding agent.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 { lockSync } from "proper-lockfile";
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
- 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
-
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
- } finally {
90
- release?.();
91
- }
92
- }
66
+ });
93
67
 
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;
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
- }
@@ -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({ provider, type: "oauth" });
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;
@@ -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
+ }
@@ -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
- // A provider without backup keys never triggers provider failover: hand the
224
- // failure back to Pi's own retry instead of switching providers. Providers
225
- // without backups remain reachable as fallback *targets* (selectPlan does
226
- // not apply this gate); only the active provider's own escape is blocked.
227
- const activeProvider = this.provider(current.providerId);
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) return 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
 
@@ -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
- lines.push(`${provider.providerId}: ${provider.status}${providerCooldown}; ${keys}`);
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
+