pi-ccswitch-auto-switch 0.3.14 → 0.3.16
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 +7 -2
- package/README.zh-CN.md +7 -2
- package/classify.ts +10 -1
- package/index.ts +22 -9
- package/package.json +1 -1
- package/types.ts +1 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Pi CCSwitch Auto Switch
|
|
2
2
|
|
|
3
|
+
> **Recommended project:** [Magpie](https://github.com/yetone/magpie) for managing models, accounts, and routing across multiple AI agents.
|
|
4
|
+
|
|
3
5
|
> 中文说明:[README.zh-CN.md](README.zh-CN.md)
|
|
4
6
|
|
|
5
7
|
Resilient model failover for [Pi](https://github.com/badlogic/pi-mono) when its providers are managed by [CC Switch](https://ccswitch.io/).
|
|
@@ -13,6 +15,7 @@ The extension observes real Pi requests, records sanitized health signals, and
|
|
|
13
15
|
- Endpoint circuit breakers for DNS, connection, server, and streaming failures.
|
|
14
16
|
- Endpoint platform isolation: when several models on the same endpoint (same BaseURL/provider) fail within one round, the whole endpoint is isolated so sibling models from the same platform are not tried one by one.
|
|
15
17
|
- Model-only isolation for missing models and incompatible parameters.
|
|
18
|
+
- Stops on missing Pi bundle chunks with a full-restart hint, without penalizing model/provider/endpoint health or isolating a platform.
|
|
16
19
|
- Learns model-family content-policy constraints from real failures (`glm-4.5` and `GLM-4.6`, for example, are both `glm`) and avoids the entire family during the current content-policy failover chain. Evidence lives only in the owning extension instance, is never written to shared health state, and is cleared at that instance’s `session_start`, because a new session may handle tasks that are not content-sensitive, so the family becomes eligible again until this session observes its own content-policy rejection.
|
|
17
20
|
- Applies hard health, failure-domain, policy-family, input-modality, and context filters before ranking candidates by provider diversity, model equivalence, capability compatibility, and historical reliability.
|
|
18
21
|
- Independent-provider semantics: a copied vendor (e.g. `my-provider-copy`) shares the BaseURL but is a distinct provider with its own endpoint key, so isolating one never blocks the other.
|
|
@@ -57,7 +60,9 @@ This works on macOS, Linux, and Windows when Git is available. After the npm rel
|
|
|
57
60
|
pi install npm:pi-ccswitch-auto-switch
|
|
58
61
|
```
|
|
59
62
|
|
|
60
|
-
Restart Pi or run `/reload` after installing or updating. To update the Git installation later, run `pi update --extensions`.
|
|
63
|
+
Restart Pi or run `/reload` after installing or updating this extension. To update the Git installation later, run `pi update --extensions`.
|
|
64
|
+
|
|
65
|
+
After updating **Pi itself**, fully exit and restart Pi, then resume your session. `/reload` reloads extensions and resources within the existing process. If that process still references replaced Pi bundle chunks and reports `Cannot find module .../pi-coding-agent/dist/bundle/chunks/...`, CCSwitch stops the current round and asks you to restart Pi. This local loading failure does not count toward model/provider/endpoint cooldowns or the three-failure platform isolation threshold.
|
|
61
66
|
|
|
62
67
|
CC Switch should be configured normally. This extension deliberately does not write Pi model settings or CC Switch data.
|
|
63
68
|
|
|
@@ -144,7 +149,7 @@ The state machine waits for Pi's native retry cycle to settle before switching.
|
|
|
144
149
|
| Failure | Scope | Initial cooldown |
|
|
145
150
|
| --- | --- | --- |
|
|
146
151
|
| `401`, `403`, quota, billing | Provider | 30 minutes |
|
|
147
|
-
| `429` | Provider | `Retry-After` when present
|
|
152
|
+
| `429` | Provider | quota/billing body (e.g. `AccountQuotaExceeded`) → 30 minutes; otherwise `Retry-After` when present, else 5 minutes |
|
|
148
153
|
| DNS, network, `408`, `5xx`, interrupted stream | Endpoint | 2 minutes |
|
|
149
154
|
| `404`, invalid model, incompatible parameters | Model | 15 minutes |
|
|
150
155
|
| Content-policy or sensitive-content rejection | Current model + learned model-family constraint | 2-minute model cooldown; family evidence is session-scoped (cleared at `session_start`) |
|
package/README.zh-CN.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Pi CCSwitch 自动故障转移
|
|
2
2
|
|
|
3
|
+
> **推荐项目:** [Magpie](https://github.com/yetone/magpie),适合统一管理多个 AI Agent 的模型、账号和路由。
|
|
4
|
+
|
|
3
5
|
> English: [README.md](README.md)
|
|
4
6
|
|
|
5
7
|
当 [CC Switch](https://ccswitch.io/) 管理 [Pi](https://github.com/badlogic/pi-mono) 的 Provider 时,为 Pi 提供更稳健的模型自动故障转移。
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
- 对认证、额度、账单和限流实施 Provider 优先熔断。
|
|
13
15
|
- 对 DNS、连接、服务端和流中断实施端点熔断。
|
|
14
16
|
- 模型不存在或参数不兼容时仅隔离该模型。
|
|
17
|
+
- Pi 本地程序分块文件缺失时停止本轮切换并提示完整重启,不计入模型、Provider 或端点健康,也不触发平台隔离。
|
|
15
18
|
- 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm`),在本轮内容审查故障转移中避开整个受限系列。该约束仅保存在当前扩展实例的内存中,不写入共享健康状态;当前实例的 `session_start` 只清除自身约束,因为新 session 处理的任务不一定涉及审查内容,模型系列恢复可用,直到本 session 再次观察到内容审查拒绝。
|
|
16
19
|
- 候选模型先经过健康、故障域、审查系列、输入模态和上下文窗口硬过滤,再按跨 Provider、模型等价性、能力兼容和历史稳定性排序。
|
|
17
20
|
- 首响应 90 秒、流式停滞 120 秒看门狗。
|
|
@@ -55,7 +58,9 @@ pi install git:github.com/JunyWuuuu91/pi-ccswitch-auto-switch
|
|
|
55
58
|
pi install npm:pi-ccswitch-auto-switch
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
安装或更新本扩展后重启 Pi,或执行 `/reload`。后续更新 Git 版本可执行 `pi update --extensions`。
|
|
62
|
+
|
|
63
|
+
更新 **Pi 本体**后,应完整退出并重新启动 Pi,再恢复原会话。`/reload` 只在现有进程中重载扩展和资源。如果旧进程仍引用已被替换的 Pi 程序分块文件,报出 `Cannot find module .../pi-coding-agent/dist/bundle/chunks/...`,CCSwitch 会停止本轮切换并提示重启 Pi。这类本地加载错误不计入模型、Provider 或端点冷却,也不累计到“失败 3 次隔离平台”的阈值。
|
|
59
64
|
|
|
60
65
|
### 无头自动化调用
|
|
61
66
|
|
|
@@ -132,7 +137,7 @@ CCS v0.3.5 ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
132
137
|
| 失败类型 | 熔断范围 | 初始冷却 |
|
|
133
138
|
| --- | --- | --- |
|
|
134
139
|
| `401`、`403`、额度或账单问题 | Provider | 30 分钟 |
|
|
135
|
-
| `429` | Provider |
|
|
140
|
+
| `429` | Provider | 配额/余额正文(如 `AccountQuotaExceeded`)→ 30 分钟;否则优先使用 `Retry-After`,无则 5 分钟 |
|
|
136
141
|
| DNS、网络、`408`、`5xx`、流中断 | 端点 | 2 分钟 |
|
|
137
142
|
| `404`、模型不存在、参数不兼容 | 模型 | 15 分钟 |
|
|
138
143
|
| 内容审查/敏感拦截 | 当前模型 + 模型系列约束 | 模型冷却 2 分钟;系列约束按 session 隔离(`session_start` 时清除) |
|
package/classify.ts
CHANGED
|
@@ -14,18 +14,27 @@ const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|cr
|
|
|
14
14
|
const context = /context.?window|context.?length|too many tokens|prompt is too long|input is too long|token limit/i
|
|
15
15
|
const transport = /dns|enotfound|eai_again|econn(?:reset|refused|aborted)|epipe|socket|connection|network|fetch failed|tls|certificate|timed? ?out|timeout|stream ended|terminated|websocket|und_err/i
|
|
16
16
|
const overloaded = /overload|server busy|service unavailable|temporarily unavailable|capacity|upstream|bad gateway/i
|
|
17
|
+
// 只匹配缺失的 Pi 分块文件本身,不能把 imported from 中的 Pi 路径当成缺失目标。
|
|
18
|
+
const localRuntime = /cannot find (?:module|package) ['"][^'"\r\n]*[\\/]pi-coding-agent[\\/]dist[\\/]bundle[\\/]chunks[\\/][^'"\r\n]+['"]/i
|
|
17
19
|
|
|
18
20
|
export function classifyFailure(observation: FailureObservation): Classification {
|
|
19
21
|
const text = `${observation.rawStopReason ?? ''} ${observation.message ?? ''}`
|
|
20
22
|
const status = observation.status
|
|
21
23
|
if (observation.aborted && !observation.watchdog) return { kind: 'unknown', roundOnly: true }
|
|
24
|
+
if (localRuntime.test(text)) return { kind: 'local_runtime', roundOnly: true }
|
|
22
25
|
if (observation.watchdog) return { kind: 'timeout', scope: 'endpoint', roundOnly: false }
|
|
23
26
|
// 内容审查/敏感拦截属于该模型的失败:按 model 级记录并与冷却(2min×2ⁿ 上限 30min),
|
|
24
27
|
// chooseCandidate 会排除后自动切换到其他模型,而不是同一内容原样重试到本轮上限。
|
|
25
28
|
if (content.test(text)) return { kind: 'content_policy', scope: 'model', roundOnly: false }
|
|
26
29
|
if (context.test(text)) return { kind: 'context_overflow', roundOnly: true }
|
|
27
30
|
if (status === 413) return { kind: 'context_overflow', roundOnly: true }
|
|
28
|
-
|
|
31
|
+
// 账户级配额耗尽常以 429 上报(如 AccountQuotaExceeded + "exceeded the 5-hour usage quota")。
|
|
32
|
+
// quota 文本必须优先于瞬时 rate_limit:否则死 provider 只按 5min 冷却停放,
|
|
33
|
+
// 在数小时后才重置的配额窗口内被放回健康池重试。
|
|
34
|
+
if (status === 429) {
|
|
35
|
+
if (quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
36
|
+
return { kind: 'rate_limit', scope: 'provider', roundOnly: false, retryAfterMs: observation.retryAfterMs }
|
|
37
|
+
}
|
|
29
38
|
if (status === 402) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
30
39
|
if (status === 403 && quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
31
40
|
if (status === 401 || status === 403) return { kind: 'auth', scope: 'provider', roundOnly: false }
|
package/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ const roundLimitMs = (): number => {
|
|
|
17
17
|
return Number.isFinite(override) && override > 0 ? override : ROUND_LIMIT_DEFAULT
|
|
18
18
|
}
|
|
19
19
|
const RPC_PROTOCOL_VERSION = 1
|
|
20
|
-
const EXTENSION_VERSION = '0.3.
|
|
20
|
+
const EXTENSION_VERSION = '0.3.16'
|
|
21
21
|
// 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
|
|
22
22
|
const ENDPOINT_FAIL_THRESHOLD = 3
|
|
23
23
|
|
|
@@ -197,15 +197,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
197
197
|
round.phase = 'exhausted'
|
|
198
198
|
clearWatchdog()
|
|
199
199
|
ctx.ui.setWorkingMessage()
|
|
200
|
+
const lastFailure = round.observation ? classifyFailure(round.observation).kind : undefined
|
|
201
|
+
const recovery = lastFailure === 'local_runtime' ? '' : '\n\n可使用 /ccswitch status 查看状态,/ccswitch reactivate <provider/model|all> 重新激活。'
|
|
200
202
|
// 报告列出本轮每个已试模型及其最后错误,便于直接定位是哪个 provider/模型、什么原因
|
|
201
203
|
const triedLines = [...round.tried].map(item => {
|
|
202
204
|
const error = round!.errors.get(item)
|
|
203
205
|
return error ? `- ${item} — ${error}` : `- ${item}`
|
|
204
206
|
})
|
|
205
|
-
await health.report(`# CCSwitch 自动故障转移失败\n\n时间:${new Date().toISOString()}\n原因:${reason}\n\n已尝试:\n${triedLines.join('\n')}
|
|
207
|
+
await health.report(`# CCSwitch 自动故障转移失败\n\n时间:${new Date().toISOString()}\n原因:${reason}\n\n已尝试:\n${triedLines.join('\n')}${recovery}`)
|
|
206
208
|
await health.log(`round exhausted: ${reason}; tried=${[...round.tried].join(',')}`)
|
|
207
209
|
if (ctx.mode === 'rpc') {
|
|
208
|
-
const lastFailure = round.observation ? classifyFailure(round.observation).kind : undefined
|
|
209
210
|
try {
|
|
210
211
|
pi.appendEntry?.('ccswitch-exhausted', {
|
|
211
212
|
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
@@ -222,7 +223,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
222
223
|
}
|
|
223
224
|
const currentKey = key(round.model)
|
|
224
225
|
const currentError = currentKey ? round.errors.get(currentKey) : undefined
|
|
225
|
-
notify(ctx, `CCSwitch:自动切换停止(${reason})${currentError ? `;当前 ${currentKey}:${currentError}` : ''},请用 /ccswitch
|
|
226
|
+
notify(ctx, `CCSwitch:自动切换停止(${reason})${currentError ? `;当前 ${currentKey}:${currentError}` : ''}${lastFailure === 'local_runtime' ? '' : ',请用 /ccswitch 查看详情'}`, 'error')
|
|
226
227
|
status(ctx)
|
|
227
228
|
}
|
|
228
229
|
/**
|
|
@@ -309,9 +310,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
310
|
// 下一轮还会重试同一个刚失败的模型/provider
|
|
310
311
|
if (!classification.roundOnly && classification.scope) health.recordFailure(classification.scope, classification.scope === 'model' ? failedKey : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model), classification.kind, round.observation.message, classification.retryAfterMs)
|
|
311
312
|
await health.flush()
|
|
312
|
-
//
|
|
313
|
+
// 窗口从上一次成功切换(或本轮开始)起算:供应商内部重试耗时不应消耗整轮限额。
|
|
314
|
+
// 仅在本轮已成功切换过(attempts > 0)后才生效:窗口的本意是限制连续切换 churn,
|
|
315
|
+
// 长健康轮末尾的第一次失败也必须拿到兜底切换,否则轮时限会拦掉唯一一次 fallback。
|
|
313
316
|
const windowStart = Math.max(round.startedAt, round.lastSwitchAt ?? 0)
|
|
314
|
-
if (Date.now() - windowStart >= roundLimitMs()) return exhaust(ctx, '超过本轮时间限制')
|
|
317
|
+
if (round.attempts > 0 && Date.now() - windowStart >= roundLimitMs()) return exhaust(ctx, '超过本轮时间限制')
|
|
315
318
|
await refresh(ctx)
|
|
316
319
|
const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
317
320
|
const candidateOptions = () => ({
|
|
@@ -464,10 +467,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
464
467
|
}
|
|
465
468
|
})
|
|
466
469
|
pi.on('agent_settled', async (_event, ctx) => {
|
|
467
|
-
if (!round || round.phase !== 'settled-error') return
|
|
470
|
+
if (!round || round.phase !== 'settled-error' || !round.observation) return
|
|
468
471
|
if (!ctx.isIdle()) return
|
|
469
|
-
|
|
470
|
-
|
|
472
|
+
const classification = classifyFailure(round.observation)
|
|
473
|
+
// 本机 Pi 加载失败时,换任何模型都无益;必须在计入健康或平台失败次数前终止。
|
|
474
|
+
if (classification.kind === 'local_runtime') {
|
|
475
|
+
const failedKey = key(round.model)
|
|
476
|
+
if (failedKey) {
|
|
477
|
+
round.tried.add(failedKey)
|
|
478
|
+
round.errors.set(failedKey, round.observation.message ?? classification.kind)
|
|
479
|
+
}
|
|
480
|
+
await exhaust(ctx, 'Pi 本地程序文件加载失败,请完整退出并重新启动 Pi 后恢复会话;/reload 无法重新加载 Pi 本体')
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (!canRetry(ctx) && round.model) {
|
|
471
484
|
if (classification.kind === 'content_policy') health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
|
|
472
485
|
if (!classification.roundOnly && classification.scope) {
|
|
473
486
|
const domain = classification.scope === 'model' ? modelKey(round.model) : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model)
|
package/package.json
CHANGED