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 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; otherwise 5 minutes |
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
- 安装或更新后重启 Pi,或执行 `/reload`。后续更新 Git 版本可执行 `pi update --extensions`。
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 | 优先使用 `Retry-After`,否则 5 分钟 |
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
- if (status === 429) return { kind: 'rate_limit', scope: 'provider', roundOnly: false, retryAfterMs: observation.retryAfterMs }
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.14'
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')}\n\n可使用 /ccswitch status 查看状态,/ccswitch reactivate <provider/model|all> 重新激活。`)
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 查看详情`, 'error')
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
- if (!canRetry(ctx) && round.observation && round.model) {
470
- const classification = classifyFailure(round.observation)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/types.ts CHANGED
@@ -63,6 +63,7 @@ export type FailureClass =
63
63
  | 'model_config'
64
64
  | 'content_policy'
65
65
  | 'context_overflow'
66
+ | 'local_runtime'
66
67
  | 'unknown'
67
68
 
68
69
  export type HealthScope = 'model' | 'provider' | 'endpoint'