pi-ccswitch-auto-switch 0.1.7 → 0.3.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
@@ -13,13 +13,15 @@ The extension observes real Pi requests, records sanitized health signals, and
13
13
  - Endpoint circuit breakers for DNS, connection, server, and streaming failures.
14
14
  - 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
15
  - Model-only isolation for missing models and incompatible parameters.
16
- - Prefers a healthy model from another provider before considering a sibling model.
16
+ - 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 current and future content-policy failover chains. Evidence expires after 30 days.
17
+ - 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.
17
18
  - 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.
18
19
  - 90-second first-response and 120-second streaming-idle watchdogs.
19
20
  - Exponential cooldowns, `Retry-After` support, and one persisted half-open probe lease per provider.
20
21
  - Persistent, atomic, cross-process health state with error redaction and bounded logs.
21
22
  - Compact Pi status bar and interactive health panel.
22
23
  - Non-interactive `--print` / JSON runs monitor and record failures but do not inject a competing retry.
24
+ - `pi-ccswitch-run` keeps one headless Pi RPC session alive and returns only the final successful answer after automatic model failover.
23
25
 
24
26
  ## Requirements
25
27
 
@@ -45,6 +47,18 @@ Restart Pi or run `/reload` after installing or updating. To update the Git inst
45
47
 
46
48
  CC Switch should be configured normally. This extension deliberately does not write Pi model settings or CC Switch data.
47
49
 
50
+ ### Headless / automation use
51
+
52
+ Install the package as an npm dependency, then run its binary (or invoke the packaged `runner.mjs` with Node):
53
+
54
+ ```bash
55
+ pi-ccswitch-run --no-tools --no-context-files @prompt.md "Summarize the attached text"
56
+ ```
57
+
58
+ The runner starts one `pi --mode rpc --no-session --no-extensions --extension <this package>/index.ts` process, so only CCSwitch is loaded. `PI_BIN` may point to the real Pi executable; it must not point to `pi-ccswitch-run`. The default deadline is ten minutes and can be changed with `--timeout-ms`.
59
+
60
+ Exit codes are `0` for success, `1` when candidates are exhausted, `2` for invocation/RPC configuration failures, `124` for timeout, and `130`/`143` for interruption. Text `@files` are supported in the first release; images and stdin input are intentionally not.
61
+
48
62
  ## Commands
49
63
 
50
64
  | Command | What it does |
@@ -54,7 +68,7 @@ CC Switch should be configured normally. This extension deliberately does not wr
54
68
  | `/ccswitch refresh` | Refresh Pi's model registry and the status display. |
55
69
  | `/ccswitch reactivate <provider/model\|all>` | Clear a breaker and let the next real request verify recovery, while preserving history. |
56
70
  | `/ccswitch disable <provider/model>` | Manually exclude a model from failover. |
57
- | `/ccswitch reset <provider/model\|all>` | Delete selected health history after confirmation. |
71
+ | `/ccswitch reset <provider/model\|all>` | Delete selected health history after confirmation; `all` also clears learned content-policy constraints. |
58
72
  | `/ccswitch-test` | Inspect candidate discovery without changing models. |
59
73
 
60
74
  Examples:
@@ -80,7 +94,7 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
80
94
 
81
95
  四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中。All-healthy still shows zero counters, e.g. `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`. Use `/ccswitch` or `/ccswitch-test` to see Pi's raw scope entry count, the deduplicated model count, affected model counts, and the underlying breaker-record count.
82
96
 
83
- 切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
97
+ 切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。RPC 模式还会发出协议版本为 `1` 的 `ccswitch-complete` 或 `ccswitch-exhausted` 终态 entry;runner 用它们区分中间失败轮和最终结果。
84
98
 
85
99
  ## Failover behavior
86
100
 
@@ -90,15 +104,16 @@ The state machine waits for Pi's native retry cycle to settle before switching.
90
104
  | --- | --- | --- |
91
105
  | `401`, `403`, quota, billing | Provider | 30 minutes |
92
106
  | `429` | Provider | `Retry-After` when present; otherwise 5 minutes |
93
- | DNS, network, `5xx`, interrupted stream | Endpoint | 2 minutes |
107
+ | DNS, network, `408`, `5xx`, interrupted stream | Endpoint | 2 minutes |
94
108
  | `404`, invalid model, incompatible parameters | Model | 15 minutes |
95
- | Content filtering or context overflow | Current round only | None |
109
+ | Content-policy or sensitive-content rejection | Current model + learned model-family constraint | 2-minute model cooldown; 30-day family evidence |
110
+ | Context overflow | Current round only | None |
96
111
 
97
- Cooldowns grow exponentially within bounded limits. Context-overflow retries only consider models with a larger context window. User cancellations are not recorded as failures; watchdog cancellations are recorded as timeouts.
112
+ Cooldowns grow exponentially within bounded limits. Learned policy-constrained families are excluded only after a content-policy rejection; they remain eligible during ordinary rate-limit, network, and model-configuration failovers. Context-overflow retries only consider models with a larger context window, and image requests do not move to a model that explicitly supports text only. Unknown errors are isolated to the current model and still fail over normally. A user cancellation is the only aborted turn that does not trigger failover; watchdog cancellations are recorded as timeouts.
98
113
 
99
114
  ## Data and privacy
100
115
 
101
- Health state is stored in Pi's agent directory as `ccswitch-auto-switch-state.json`. The extension stores counters, timestamps, cooldowns, and redacted/truncated error summaries. It does not access credentials, authorization headers, CC Switch's database, or Pi's `auth.json`.
116
+ Health state is stored in Pi's agent directory as `ccswitch-auto-switch-state.json`. The extension stores counters, timestamps, cooldowns, learned model-family policy constraints, and redacted/truncated error summaries. It does not access credentials, authorization headers, CC Switch's database, or Pi's `auth.json`.
102
117
 
103
118
  ## Development
104
119
 
@@ -108,7 +123,7 @@ npm run typecheck
108
123
  npm test
109
124
  ```
110
125
 
111
- Tests use Node's built-in test runner and cover failure classification, provider-first selection, cooldowns, state persistence, and Windows-compatible paths.
126
+ Tests use Node's built-in test runner and cover the failure matrix, family-level policy avoidance, provider-first selection, input compatibility, cooldowns, state persistence, and Windows-compatible paths.
112
127
 
113
128
  ## License
114
129
 
package/README.zh-CN.md CHANGED
@@ -12,12 +12,14 @@
12
12
  - 对认证、额度、账单和限流实施 Provider 优先熔断。
13
13
  - 对 DNS、连接、服务端和流中断实施端点熔断。
14
14
  - 模型不存在或参数不兼容时仅隔离该模型。
15
- - 优先选择不同 Provider 的健康模型;同 Provider 的模型排在后面。
15
+ - 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm`),本轮及后续内容审查故障转移会避开整个受限系列;证据 30 天后自动过期。
16
+ - 候选模型先经过健康、故障域、审查系列、输入模态和上下文窗口硬过滤,再按跨 Provider、模型等价性、能力兼容和历史稳定性排序。
16
17
  - 首响应 90 秒、流式停滞 120 秒看门狗。
17
18
  - 指数退避、`Retry-After` 支持和跨进程 half-open 单探针租约。
18
19
  - 原子持久化状态、错误脱敏和有上限的日志。
19
20
  - Pi 紧凑状态栏与健康管理面板。
20
21
  - `--print` / JSON 非交互运行只监控和记录,不会注入可能与进程退出竞争的新请求。
22
+ - `pi-ccswitch-run` 使用单个 Pi RPC 会话完成无头自动故障转移,只输出最终成功模型的回答。
21
23
 
22
24
  ## 要求
23
25
 
@@ -41,6 +43,16 @@ pi install npm:pi-ccswitch-auto-switch
41
43
 
42
44
  安装或更新后重启 Pi,或执行 `/reload`。后续更新 Git 版本可执行 `pi update --extensions`。
43
45
 
46
+ ### 无头自动化调用
47
+
48
+ 将本包作为 npm 依赖安装后,可调用:
49
+
50
+ ```bash
51
+ pi-ccswitch-run --no-tools --no-context-files @prompt.md "请总结附件"
52
+ ```
53
+
54
+ runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 只能指定内部真实 `pi`,不能指向 runner。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143`。首版只支持文本 `@文件`,不支持图片和 stdin。
55
+
44
56
  正常使用 CC Switch 配置 Provider 即可;本插件不会修改 Pi 模型设置或 CC Switch 数据。
45
57
 
46
58
  ## 命令
@@ -52,7 +64,7 @@ pi install npm:pi-ccswitch-auto-switch
52
64
  | `/ccswitch refresh` | 刷新 Pi 模型注册表和状态栏。 |
53
65
  | `/ccswitch reactivate <provider/model\|all>` | 解除熔断,下一次真实请求验证恢复情况;保留历史。 |
54
66
  | `/ccswitch disable <provider/model>` | 手动排除模型。 |
55
- | `/ccswitch reset <provider/model\|all>` | 确认后删除相应健康历史。 |
67
+ | `/ccswitch reset <provider/model\|all>` | 确认后删除相应健康历史;`all` 同时清除已学习的审查约束。 |
56
68
  | `/ccswitch-test` | 仅检查候选发现,不切换模型。 |
57
69
 
58
70
  ## 状态栏
@@ -70,7 +82,7 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
70
82
 
71
83
  四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中;全部健康时仍显示零计数,例如 `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、受影响模型数、底层熔断记录数、本 session 切换数与累计切换数。
72
84
 
73
- 切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
85
+ 切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。RPC 模式还会发出协议版本为 `1` 的 `ccswitch-complete` 或 `ccswitch-exhausted` 终态 entry,供 runner 判定最终结果。
74
86
 
75
87
  ## 故障转移逻辑
76
88
 
@@ -80,15 +92,16 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
80
92
  | --- | --- | --- |
81
93
  | `401`、`403`、额度或账单问题 | Provider | 30 分钟 |
82
94
  | `429` | Provider | 优先使用 `Retry-After`,否则 5 分钟 |
83
- | DNS、网络、`5xx`、流中断 | 端点 | 2 分钟 |
95
+ | DNS、网络、`408`、`5xx`、流中断 | 端点 | 2 分钟 |
84
96
  | `404`、模型不存在、参数不兼容 | 模型 | 15 分钟 |
85
- | 内容过滤、上下文溢出 | 仅本轮 | 无 |
97
+ | 内容审查/敏感拦截 | 当前模型 + 模型系列约束 | 模型冷却 2 分钟;系列避让证据 30 天 |
98
+ | 上下文溢出 | 仅本轮 | 无 |
86
99
 
87
- 冷却时间会指数增长但有上限。上下文溢出时只会选择上下文窗口更大的模型。用户主动取消不计失败;看门狗取消会记录为超时。
100
+ 冷却时间会指数增长但有上限。发生内容审查时,插件只在审查故障转移链中避开已标记系列,普通限流、网络或模型配置故障仍可选择这些模型。上下文溢出时只会选择上下文窗口更大的模型;带图片的请求不会切到明确仅支持文本的模型。无法归类的异常按单模型故障处理并正常切换。用户主动取消是唯一不会触发故障转移的异常终止;看门狗取消会记录为超时。
88
101
 
89
102
  ## 数据与隐私
90
103
 
91
- 健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息和脱敏/截断的错误摘要;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
104
+ 健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息、模型系列审查约束和脱敏/截断的错误摘要;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
92
105
 
93
106
  ## 开发
94
107
 
@@ -98,7 +111,7 @@ npm run typecheck
98
111
  npm test
99
112
  ```
100
113
 
101
- 测试使用 Node 内置测试运行器,覆盖失败分类、Provider 优先选择、冷却、状态持久化以及 Windows 路径。
114
+ 测试使用 Node 内置测试运行器,覆盖失败分类矩阵、系列级审查避让、Provider 优先选择、输入兼容、冷却、状态持久化以及 Windows 路径。
102
115
 
103
116
  ## 许可证
104
117
 
package/candidates.ts CHANGED
@@ -1,12 +1,16 @@
1
- import type { ModelRef, ScopedModel } from './types.ts'
1
+ import type { FailureClass, ModelRef, ScopedModel } from './types.ts'
2
2
  import { endpointKey, modelKey, type HealthState } from './health.ts'
3
3
 
4
4
  export interface CandidateOptions {
5
5
  current?: ModelRef
6
6
  tried: Set<string>
7
- failureKind?: string
7
+ failureKind?: FailureClass
8
8
  health: HealthState
9
9
  avoidEndpoints?: Set<string>
10
+ /** 模型系列已被证实存在内容审查约束时,本轮不再选择该系列。 */
11
+ avoidFamilies?: ReadonlySet<string>
12
+ /** 本轮输入真正需要的模态;显式声明不支持的候选会被排除。 */
13
+ requiredInputs?: readonly string[]
10
14
  }
11
15
 
12
16
  export interface CandidateSnapshot {
@@ -76,22 +80,64 @@ export function chooseCandidate(models: ModelRef[], options: CandidateOptions):
76
80
  const candidates = models.filter(model => {
77
81
  if (options.tried.has(modelKey(model)) || blocked(model, options.health)) return false
78
82
  if (options.avoidEndpoints?.has(endpointKey(model))) return false
83
+ if (options.avoidFamilies?.has(modelFamily(model))) return false
84
+ if (!supportsRequiredInputs(model, options.requiredInputs)) return false
79
85
  if (options.failureKind === 'context_overflow' && current && (model.contextWindow ?? 0) <= (current.contextWindow ?? 0)) return false
80
86
  return true
81
87
  })
82
- return candidates.sort((a, b) => score(a, current) - score(b, current))[0]
88
+ return candidates.sort((a, b) => score(a, current, options.health) - score(b, current, options.health))[0]
83
89
  }
84
90
 
85
- function score(candidate: ModelRef, current?: ModelRef): number {
91
+ /**
92
+ * 返回跨 Provider 稳定的模型系列标识。内容审查通常是模型系列的能力约束,而不是某个 API Key
93
+ * 或单一部署的瞬时故障,因此已知系列使用显式规则;未知模型则保守地按版本号前的 id 归组。
94
+ */
95
+ export function modelFamily(model: Pick<ModelRef, 'id' | 'name'>): string {
96
+ const identity = `${model.id} ${model.name ?? ''}`.toLowerCase()
97
+ const knownFamilies: Array<[string, RegExp]> = [
98
+ ['glm', /(?:^|[^a-z0-9])(?:chat)?glm(?:$|[^a-z])/],
99
+ ['qwen', /(?:^|[^a-z0-9])qwen(?:$|[^a-z])/],
100
+ ['deepseek', /(?:^|[^a-z0-9])deepseek(?:$|[^a-z])/],
101
+ ['kimi', /(?:^|[^a-z0-9])(?:kimi|moonshot)(?:$|[^a-z])/],
102
+ ['gpt', /(?:^|[^a-z0-9])gpt(?:$|[^a-z])/],
103
+ ['claude', /(?:^|[^a-z0-9])claude(?:$|[^a-z])/],
104
+ ['gemini', /(?:^|[^a-z0-9])gemini(?:$|[^a-z])/],
105
+ ['llama', /(?:^|[^a-z0-9])llama(?:$|[^a-z])/],
106
+ ['mistral', /(?:^|[^a-z0-9])mistral(?:$|[^a-z])/],
107
+ ['minimax', /(?:^|[^a-z0-9])minimax(?:$|[^a-z])/],
108
+ ['ernie', /(?:^|[^a-z0-9])ernie(?:$|[^a-z])/],
109
+ ['doubao', /(?:^|[^a-z0-9])doubao(?:$|[^a-z])/],
110
+ ['hunyuan', /(?:^|[^a-z0-9])hunyuan(?:$|[^a-z])/],
111
+ ]
112
+ for (const [family, pattern] of knownFamilies) if (pattern.test(identity)) return family
113
+
114
+ const leaf = model.id.toLowerCase().split('/').pop() ?? model.id.toLowerCase()
115
+ return leaf
116
+ .replace(/(?:[-_.]v)?\d+(?:[.-]\d+)*(?:[-_.].*)?$/, '')
117
+ .replace(/[-_.:]+$/, '') || leaf
118
+ }
119
+
120
+ function supportsRequiredInputs(candidate: ModelRef, required: readonly string[] | undefined): boolean {
121
+ if (!required?.length || !candidate.input?.length) return true
122
+ return required.every(input => candidate.input!.includes(input))
123
+ }
124
+
125
+ function score(candidate: ModelRef, current: ModelRef | undefined, health: HealthState): number {
86
126
  if (!current) return 0
127
+ // 排序优先级:故障域多样性 > 原模型等价性 > 输入/推理/上下文兼容性 > 历史稳定性。
128
+ // 硬约束(冷却、审查系列、输入模态、上下文溢出)已在上方 filter 中处理。
87
129
  const sameProvider = candidate.provider === current.provider ? 1000 : 0
88
130
  const differentId = candidate.id === current.id ? 0 : 100
89
131
  const inputMismatch = sameInputs(candidate, current) ? 0 : 20
90
132
  const reasoningMismatch = candidate.reasoning === current.reasoning ? 0 : 10
91
133
  const contextPenalty = (candidate.contextWindow ?? 0) < (current.contextWindow ?? 0) ? 5 : 0
92
- return sameProvider + differentId + inputMismatch + reasoningMismatch + contextPenalty
134
+ const records = [health.models[modelKey(candidate)], health.providers[candidate.provider], health.endpoints[endpointKey(candidate)]]
135
+ const historicalPenalty = Math.min(80, records.reduce((sum, record) => sum + Math.log2((record?.totalFailures ?? 0) + 1) * 4, 0))
136
+ return sameProvider + differentId + inputMismatch + reasoningMismatch + contextPenalty + historicalPenalty
93
137
  }
94
138
 
95
139
  function sameInputs(a: ModelRef, b: ModelRef): boolean {
96
- return (a.input ?? []).join(',') === (b.input ?? []).join(',')
140
+ const left = a.input ?? []
141
+ const right = b.input ?? []
142
+ return left.length === right.length && left.every(input => right.includes(input))
97
143
  }
package/classify.ts CHANGED
@@ -7,10 +7,11 @@ export interface Classification {
7
7
  retryAfterMs?: number
8
8
  }
9
9
 
10
- const content = /content[ _-]?filter|sensitive|guardrail|policy[_ ]?violation|content[_ ]?blocked/i
10
+ const content = /content[ _-]?(?:filter|policy|blocked|moderation)|sensitive|guardrail|policy[_ -]?violation|responsibleai|safety[_ -]?(?:policy|filter|violation)|security[_ -]?policy|moderation[_ -]?(?:blocked|failed)|blocked by.{0,20}(?:policy|safety|moderation)|data[_ -]?inspection[_ -]?failed|内容(?:审查|审核|安全|违规|被拦截)|安全审核|安全(?:策略|风控).{0,12}(?:阻断|拦截|拒绝)|审核(?:未通过|不通过|失败)|敏感(?:内容|词|信息)?|政治敏感|涉政|不合规|违反.{0,12}(?:安全|政策|规定)/i
11
11
  const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|monthly.?limit/i
12
12
  const context = /context.?window|context.?length|too many tokens|prompt is too long|input is too long|token limit/i
13
- const transport = /dns|enotfound|eai_again|socket|connection|network|fetch failed|timed? ?out|timeout|stream ended|terminated|websocket/i
13
+ 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
14
+ const overloaded = /overload|server busy|service unavailable|temporarily unavailable|capacity|upstream|bad gateway/i
14
15
 
15
16
  export function classifyFailure(observation: FailureObservation): Classification {
16
17
  const text = `${observation.rawStopReason ?? ''} ${observation.message ?? ''}`
@@ -21,12 +22,15 @@ export function classifyFailure(observation: FailureObservation): Classification
21
22
  // chooseCandidate 会排除后自动切换到其他模型,而不是同一内容原样重试到本轮上限。
22
23
  if (content.test(text)) return { kind: 'content_policy', scope: 'model', roundOnly: false }
23
24
  if (context.test(text)) return { kind: 'context_overflow', roundOnly: true }
25
+ if (status === 413) return { kind: 'context_overflow', roundOnly: true }
24
26
  if (status === 429) return { kind: 'rate_limit', scope: 'provider', roundOnly: false, retryAfterMs: observation.retryAfterMs }
27
+ if (status === 402) return { kind: 'quota', scope: 'provider', roundOnly: false }
25
28
  if (status === 401 || status === 403) return { kind: 'auth', scope: 'provider', roundOnly: false }
26
29
  if (quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
30
+ if (status === 408) return { kind: 'timeout', scope: 'endpoint', roundOnly: false }
27
31
  if (status !== undefined && status >= 500) return { kind: 'endpoint', scope: 'endpoint', roundOnly: false }
32
+ if (transport.test(text) || overloaded.test(text)) return { kind: 'endpoint', scope: 'endpoint', roundOnly: false }
28
33
  if (status === 400 || status === 404 || status === 422) return { kind: 'model_config', scope: 'model', roundOnly: false }
29
- if (transport.test(text)) return { kind: 'endpoint', scope: 'endpoint', roundOnly: false }
30
34
  return { kind: 'unknown', scope: 'model', roundOnly: false }
31
35
  }
32
36
 
package/health.ts CHANGED
@@ -24,12 +24,25 @@ export interface HealthRecord {
24
24
  leaseUntil?: number
25
25
  }
26
26
 
27
+ export interface ContentPolicyConstraint {
28
+ observations: number
29
+ lastObservedAt: number
30
+ avoidUntil: number
31
+ lastModel: string
32
+ lastError?: string
33
+ }
34
+
27
35
  export interface HealthState {
28
36
  schemaVersion: 2
29
37
  updatedAt: number
30
38
  models: Record<string, HealthRecord>
31
39
  providers: Record<string, HealthRecord>
32
40
  endpoints: Record<string, HealthRecord>
41
+ /**
42
+ * 已从真实失败中学到的模型系列内容审查约束。它不会影响普通故障选模,只在本轮已经
43
+ * 发生 content_policy 失败时用于避开同类系列。
44
+ */
45
+ contentPolicyFamilies?: Record<string, ContentPolicyConstraint>
33
46
  /** 累计成功切换次数(跨 session 持久化,用于衡量扩展有效程度) */
34
47
  switches?: number
35
48
  /** 最近成功切换日志(有限条,环形保留) */
@@ -64,7 +77,7 @@ function safeEndpoint(value: string): string {
64
77
  }
65
78
 
66
79
  function blank(): HealthState {
67
- return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, switches: 0, switchLog: [] }
80
+ return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, contentPolicyFamilies: {}, switches: 0, switchLog: [] }
68
81
  }
69
82
 
70
83
  function redact(text: string | undefined): string | undefined {
@@ -91,6 +104,7 @@ export class HealthStore {
91
104
  private state: HealthState = blank()
92
105
  private dirty = false
93
106
  private replaceOnFlush = false
107
+ private resetModels = new Set<string>()
94
108
 
95
109
  constructor(dir = agentDir()) { this.dir = dir }
96
110
  get file(): string { return join(this.dir, STATE_FILE) }
@@ -143,6 +157,24 @@ export class HealthStore {
143
157
  this.touch()
144
158
  }
145
159
 
160
+ /**
161
+ * 内容审查约束比限流/网络故障稳定得多,但仍设置 30 天证据有效期,避免模型策略升级后
162
+ * 永久污染路由。再次观察到同系列审查会增加证据计数并刷新有效期。
163
+ */
164
+ recordContentPolicyConstraint(family: string, model: ModelRef, message?: string): void {
165
+ const constraints = this.state.contentPolicyFamilies ??= {}
166
+ const previous = constraints[family]
167
+ const now = Date.now()
168
+ constraints[family] = {
169
+ observations: (previous?.observations ?? 0) + 1,
170
+ lastObservedAt: now,
171
+ avoidUntil: now + 30 * 24 * 60 * 60_000,
172
+ lastModel: modelKey(model),
173
+ lastError: redact(message),
174
+ }
175
+ this.touch()
176
+ }
177
+
146
178
  recordSuccess(model: ModelRef): void {
147
179
  this.close('model', modelKey(model))
148
180
  this.close('provider', model.provider)
@@ -192,9 +224,14 @@ export class HealthStore {
192
224
  }
193
225
 
194
226
  reset(target: string | 'all'): void {
195
- if (target === 'all') this.state = blank()
196
- else delete this.state.models[target]
197
- this.replaceOnFlush = true
227
+ if (target === 'all') {
228
+ this.state = blank()
229
+ this.replaceOnFlush = true
230
+ this.resetModels.clear()
231
+ } else {
232
+ delete this.state.models[target]
233
+ this.resetModels.add(target)
234
+ }
198
235
  this.touch()
199
236
  }
200
237
 
@@ -221,7 +258,8 @@ export class HealthStore {
221
258
  async flush(): Promise<void> {
222
259
  if (!this.dirty) return
223
260
  await this.withLock(async () => {
224
- this.state = mergeState(await this.readDisk(), this.state)
261
+ if (!this.replaceOnFlush) this.state = mergeState(await this.readDisk(), this.state)
262
+ for (const key of this.resetModels) delete this.state.models[key]
225
263
  await this.commit()
226
264
  }).catch(() => {})
227
265
  }
@@ -259,6 +297,7 @@ export class HealthStore {
259
297
  await rename(temp, this.file)
260
298
  this.dirty = false
261
299
  this.replaceOnFlush = false
300
+ this.resetModels.clear()
262
301
  }
263
302
  private bucket(scope: HealthScope): Record<string, HealthRecord> {
264
303
  return scope === 'model' ? this.state.models : scope === 'provider' ? this.state.providers : this.state.endpoints
@@ -314,5 +353,20 @@ function mergeState(a: HealthState, b: HealthState): HealthState {
314
353
  .sort((x, y) => y.at - x.at)
315
354
  .filter((entry, index, all) => index === 0 || all[index - 1].at !== entry.at || all[index - 1].from !== entry.from || all[index - 1].to !== entry.to)
316
355
  .slice(0, 20)
317
- return { schemaVersion: 2, updatedAt: Math.max(a.updatedAt ?? 0, b.updatedAt ?? 0), models: mergeBucket(a.models ?? {}, b.models ?? {}), providers: mergeBucket(a.providers ?? {}, b.providers ?? {}), endpoints: mergeBucket(a.endpoints ?? {}, b.endpoints ?? {}), switches, switchLog: log }
356
+ const contentPolicyFamilies: Record<string, ContentPolicyConstraint> = {}
357
+ for (const family of new Set([...Object.keys(a.contentPolicyFamilies ?? {}), ...Object.keys(b.contentPolicyFamilies ?? {})])) {
358
+ const left = a.contentPolicyFamilies?.[family]
359
+ const right = b.contentPolicyFamilies?.[family]
360
+ if (!left) contentPolicyFamilies[family] = right!
361
+ else if (!right) contentPolicyFamilies[family] = left
362
+ else {
363
+ const newest = right.lastObservedAt >= left.lastObservedAt ? right : left
364
+ contentPolicyFamilies[family] = {
365
+ ...newest,
366
+ observations: Math.max(left.observations, right.observations),
367
+ avoidUntil: Math.max(left.avoidUntil, right.avoidUntil),
368
+ }
369
+ }
370
+ }
371
+ return { schemaVersion: 2, updatedAt: Math.max(a.updatedAt ?? 0, b.updatedAt ?? 0), models: mergeBucket(a.models ?? {}, b.models ?? {}), providers: mergeBucket(a.providers ?? {}, b.providers ?? {}), endpoints: mergeBucket(a.endpoints ?? {}, b.endpoints ?? {}), contentPolicyFamilies, switches, switchLog: log }
318
372
  }
package/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { ExtensionAPI, ExtensionContext, FailureObservation, ModelRef } from './types.ts'
2
2
  import { classifyFailure, parseRetryAfter } from './classify.ts'
3
- import { candidateSnapshot, effectiveCandidates, chooseCandidate, summarizeCandidateHealth } from './candidates.ts'
4
- import { HealthStore, endpointKey, modelKey } from './health.ts'
3
+ import { candidateSnapshot, effectiveCandidates, chooseCandidate, modelFamily, summarizeCandidateHealth } from './candidates.ts'
4
+ import { HealthStore, endpointKey, modelKey, type HealthState } from './health.ts'
5
5
 
6
6
  const FIRST_RESPONSE_TIMEOUT = 90_000
7
7
  const STREAM_IDLE_TIMEOUT = 120_000
8
8
  const MAX_ATTEMPTS = 5
9
9
  const ROUND_LIMIT = 8 * 60_000
10
+ const RPC_PROTOCOL_VERSION = 1
10
11
  // 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
11
12
  const ENDPOINT_FAIL_THRESHOLD = 3
12
13
 
@@ -32,12 +33,26 @@ interface Round {
32
33
  observation?: FailureObservation
33
34
  model?: ModelRef
34
35
  endpointFails?: EndpointFailTracker
36
+ /** 本轮内容审查故障转移中必须避开的模型系列(包含持久化学到的约束)。 */
37
+ avoidFamilies?: Set<string>
35
38
  /** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“达到上限” */
36
39
  lastSwitchAt?: number
37
40
  }
38
41
 
39
42
  function key(model: ModelRef | undefined): string | undefined { return model && modelKey(model) }
40
43
  function canRetry(ctx: ExtensionContext): boolean { return ctx.mode === 'tui' || ctx.mode === 'rpc' }
44
+ function activePolicyFamilies(state: HealthState, now = Date.now()): Set<string> {
45
+ return new Set(Object.entries(state.contentPolicyFamilies ?? {})
46
+ .filter(([, record]) => record.avoidUntil > now)
47
+ .map(([family]) => family))
48
+ }
49
+
50
+ function resolveModel(ctx: ExtensionContext, provider: string, id: string, previous?: ModelRef): ModelRef {
51
+ // 优先使用注册表/scope 中的完整元数据,避免 turn_end 消息只有 provider/id 时丢失 BaseURL、
52
+ // 上下文窗口和输入模态;previous/ctx.model 作为注册表未命中时的后备。
53
+ const refs = [...ctx.scopedModels.map(item => item.model), ...ctx.modelRegistry.getAvailable(), previous, ctx.model]
54
+ return refs.find(model => model?.provider === provider && model.id === id) ?? { provider, id }
55
+ }
41
56
 
42
57
  export default function (pi: ExtensionAPI) {
43
58
  const health = new HealthStore()
@@ -51,10 +66,9 @@ export default function (pi: ExtensionAPI) {
51
66
  const armWatchdog = (ctx: ExtensionContext, ms: number, roundId: number) => {
52
67
  clearWatchdog()
53
68
  timer = setTimeout(() => {
54
- if (!round || round.id !== roundId || (!ctx.isIdle() && !round.inTool)) {
55
- round && (round.watchdog = true)
56
- ctx.abort()
57
- }
69
+ if (!round || round.id !== roundId || ctx.isIdle() || round.inTool) return
70
+ round.watchdog = true
71
+ ctx.abort()
58
72
  }, ms)
59
73
  timer.unref?.()
60
74
  }
@@ -90,7 +104,7 @@ export default function (pi: ExtensionAPI) {
90
104
  '/ccswitch refresh — 刷新 Pi 模型注册表和状态',
91
105
  '/ccswitch reactivate <provider/model|all> — 解除熔断,保留历史',
92
106
  '/ccswitch disable <provider/model> — 手动禁用模型',
93
- '/ccswitch reset <provider/model|all> — 清除健康历史(需确认)',
107
+ '/ccswitch reset <provider/model|all> — 清除健康历史;all 也会清除已学习的审查约束(需确认)',
94
108
  '/ccswitch-test — 自检候选模型,不实际切换',
95
109
  ].join('\n'), 'info')
96
110
  }
@@ -104,16 +118,19 @@ export default function (pi: ExtensionAPI) {
104
118
  const candidates = snapshot.models
105
119
  const counts = summarizeCandidateHealth(candidates, state)
106
120
  const now = Date.now()
121
+ const policyFamilies = activePolicyFamilies(state, now)
107
122
  const rows = candidates.slice(0, 10).map(model => {
108
123
  const modelRecord = state.models[modelKey(model)]
109
124
  const records = [modelRecord, state.providers[model.provider], state.endpoints[endpointKey(model)]]
110
125
  const blockedUntil = Math.max(0, ...records.flatMap(record => [record?.cooldownUntil ?? 0, record?.leaseUntil ?? 0]))
111
- const suffix = modelRecord?.disabled ? '手动禁用' : blockedUntil > now ? `自动冷却 ${Math.max(1, Math.ceil((blockedUntil - now) / 60_000))}m` : '健康'
126
+ const policySuffix = policyFamilies.has(modelFamily(model)) ? ' · 内容审查约束' : ''
127
+ const baseSuffix = modelRecord?.disabled ? '手动禁用' : blockedUntil > now ? `自动冷却 ${Math.max(1, Math.ceil((blockedUntil - now) / 60_000))}m` : '健康'
128
+ const suffix = `${baseSuffix}${policySuffix}`
112
129
  return `${modelKey(model)} ${suffix}`
113
130
  })
114
131
  if (!ctx.ui.select) {
115
132
  const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
116
- notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, 'info')
133
+ notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 审查约束系列 ${policyFamilies.size} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, 'info')
117
134
  return
118
135
  }
119
136
  const scopeLabel = snapshot.source === 'scoped' ? `Pi scope:${snapshot.sourceEntries} 条` : `Pi 可用注册表:${snapshot.sourceEntries} 条`
@@ -123,7 +140,8 @@ export default function (pi: ExtensionAPI) {
123
140
  return ` ${time} ${entry.from} 🔄 ${entry.to}`
124
141
  })
125
142
  const switchPanel = recentSwitches.length ? `\n最近切换:\n${recentSwitches.join('\n')}` : ''
126
- const action = await ctx.ui.select(`CCSwitch 健康面板\n当前:${key(ctx.model) ?? '无'}\n${scopeLabel} · 唯一模型:${counts.total} · 健康:${counts.healthy} · 自动冷却:${counts.cooling} · 手动禁用:${counts.disabled}\n熔断记录:${counts.breakerRecords}\n${switchSummary}${switchPanel}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
143
+ const policySummary = policyFamilies.size ? [...policyFamilies].join(', ') : '无'
144
+ const action = await ctx.ui.select(`CCSwitch 健康面板\n当前:${key(ctx.model) ?? '无'}\n${scopeLabel} · 唯一模型:${counts.total} · 健康:${counts.healthy} · 自动冷却:${counts.cooling} · 手动禁用:${counts.disabled}\n熔断记录:${counts.breakerRecords} · 审查约束系列:${policySummary}\n${switchSummary}${switchPanel}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
127
145
  if (action === '刷新') await refresh(ctx)
128
146
  if (action === '重新激活当前模型' && ctx.model) { health.reactivate(ctx.model); await health.flush(); status(ctx); notify(ctx, '已重新激活当前模型') }
129
147
  if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
@@ -139,6 +157,23 @@ export default function (pi: ExtensionAPI) {
139
157
  ctx.ui.setWorkingMessage()
140
158
  await health.report(`# CCSwitch 自动故障转移失败\n\n时间:${new Date().toISOString()}\n原因:${reason}\n\n已尝试:\n${[...round.tried].map(item => `- ${item}`).join('\n')}\n\n可使用 /ccswitch status 查看状态,/ccswitch reactivate <provider/model> 重新激活。`)
141
159
  await health.log(`round exhausted: ${reason}; tried=${[...round.tried].join(',')}`)
160
+ // RPC runner relies on an explicit terminal signal instead of guessing from
161
+ // agent_settled. Keep this RPC-only so ordinary TUI transcripts stay quiet.
162
+ if (ctx.mode === 'rpc') {
163
+ const lastFailure = round.observation ? classifyFailure(round.observation).kind : undefined
164
+ try {
165
+ pi.appendEntry?.('ccswitch-exhausted', {
166
+ protocolVersion: RPC_PROTOCOL_VERSION,
167
+ roundId: round.id,
168
+ reason,
169
+ lastFailure,
170
+ model: key(round.model),
171
+ attempts: round.attempts,
172
+ tried: [...round.tried],
173
+ sessionSwitches,
174
+ })
175
+ } catch { /* reporting must not prevent the regular exhausted path */ }
176
+ }
142
177
  notify(ctx, `CCSwitch:自动切换停止(${reason}),请用 /ccswitch 查看详情`, 'error')
143
178
  status(ctx)
144
179
  }
@@ -161,20 +196,35 @@ export default function (pi: ExtensionAPI) {
161
196
  failTracker.isolated.add(ep)
162
197
  await health.log(`endpoint ${ep} failed ${epFails} times this round, isolating endpoint`)
163
198
  }
199
+ if (classification.kind === 'content_policy') {
200
+ health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
201
+ round.avoidFamilies ??= new Set()
202
+ for (const family of activePolicyFamilies(health.snapshot)) round.avoidFamilies.add(family)
203
+ }
164
204
  if (!classification.roundOnly && classification.scope) health.recordFailure(classification.scope, classification.scope === 'model' ? modelKey(round.model) : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model), classification.kind, round.observation.message, classification.retryAfterMs)
165
205
  await health.flush()
166
206
  await refresh(ctx)
167
207
  const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
168
- let next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot, avoidEndpoints: failTracker.isolated })
208
+ const candidateOptions = () => ({
209
+ current: round!.model,
210
+ tried: round!.tried,
211
+ failureKind: classification.kind,
212
+ health: health.snapshot,
213
+ avoidEndpoints: failTracker.isolated,
214
+ avoidFamilies: round!.avoidFamilies,
215
+ requiredInputs: round!.images?.length ? ['text', 'image'] : ['text'],
216
+ })
217
+ let next = chooseCandidate(candidates, candidateOptions())
169
218
  while (next) {
170
- if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot, avoidEndpoints: failTracker.isolated }); continue }
219
+ if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, candidateOptions()); continue }
171
220
  round.attempts++
172
221
  ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
222
+ const previousModel = round.model
173
223
  const set = await pi.setModel(next).catch(() => false)
174
224
  if (!set) {
175
225
  health.recordFailure('model', modelKey(next), 'model_config', 'Pi refused model selection')
176
226
  round.tried.add(modelKey(next)); await health.flush()
177
- next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot, avoidEndpoints: failTracker.isolated })
227
+ next = chooseCandidate(candidates, candidateOptions())
178
228
  continue
179
229
  }
180
230
  round.model = next
@@ -183,7 +233,7 @@ export default function (pi: ExtensionAPI) {
183
233
  round.lastSwitchAt = Date.now()
184
234
  // 本次 session 成功切换计数 + 持久化累计/日志(衡量扩展有效程度)
185
235
  sessionSwitches += 1
186
- const fromKey = key(round.model ?? ctx.model)
236
+ const fromKey = key(previousModel ?? ctx.model)
187
237
  health.recordSwitch(fromKey ?? '', modelKey(next), classification.kind)
188
238
  await health.flush()
189
239
  notify(ctx, `CCSwitch:已切换至 ${modelKey(next)}(${round.attempts}/${MAX_ATTEMPTS})`, 'info')
@@ -191,7 +241,15 @@ export default function (pi: ExtensionAPI) {
191
241
  // interactive-mode 收到后执行 footer.invalidate() + requestRender(),footer 从 session.state.model 重新读取,
192
242
  // 从而让右下角模型名同步显示新模型(setModel 只改 state,不直接触发 footer 刷新)。
193
243
  try {
194
- pi.appendEntry?.('ccswitch-switch', { from: fromKey ?? '', to: modelKey(next), reason: classification.kind, sessionSwitches })
244
+ pi.appendEntry?.('ccswitch-switch', {
245
+ protocolVersion: RPC_PROTOCOL_VERSION,
246
+ roundId: round.id,
247
+ from: fromKey ?? '',
248
+ to: modelKey(next),
249
+ reason: classification.kind,
250
+ attempts: round.attempts,
251
+ sessionSwitches,
252
+ })
195
253
  } catch { /* appendEntry 失败不影响切换 */ }
196
254
  const continuation = round.hadTool || round.hadOutput
197
255
  ? '请从当前会话状态继续完成上一条请求;不要重复已经完成的工具操作。'
@@ -213,7 +271,7 @@ export default function (pi: ExtensionAPI) {
213
271
  if (event.source === 'extension') return { action: 'continue' }
214
272
  clearWatchdog()
215
273
  lastStatus = {}
216
- round = { id: (round?.id ?? 0) + 1, phase: 'monitoring', startedAt: Date.now(), text: event.text, images: event.images, tried: new Set(), attempts: 0, hadTool: false, inTool: false, hadOutput: false, watchdog: false, cleanRetry: false, model: ctx.model, endpointFails: undefined }
274
+ round = { id: (round?.id ?? 0) + 1, phase: 'monitoring', startedAt: Date.now(), text: event.text, images: event.images, tried: new Set(), attempts: 0, hadTool: false, inTool: false, hadOutput: false, watchdog: false, cleanRetry: false, model: ctx.model, endpointFails: undefined, avoidFamilies: undefined }
217
275
  status(ctx)
218
276
  return { action: 'continue' }
219
277
  })
@@ -240,7 +298,7 @@ export default function (pi: ExtensionAPI) {
240
298
  const message = event.message
241
299
  if (message?.role !== 'assistant' || !round) return
242
300
  clearWatchdog()
243
- round.model = { provider: message.provider, id: message.model }
301
+ round.model = resolveModel(ctx, message.provider, message.model, round.model)
244
302
  round.hadOutput ||= Boolean(message.content?.length)
245
303
  if (message.stopReason === 'error' || message.stopReason === 'aborted') {
246
304
  round.phase = 'settled-error'
@@ -249,6 +307,20 @@ export default function (pi: ExtensionAPI) {
249
307
  } else {
250
308
  health.recordSuccess(round.model); await health.flush()
251
309
  round.phase = 'idle'; round.observation = undefined; ctx.ui.setWorkingMessage(); status(ctx)
310
+ // agent_settled is also emitted after intermediate failed turns. An
311
+ // explicit terminal entry lets the headless RPC runner return only after
312
+ // this final successful assistant turn has settled.
313
+ if (ctx.mode === 'rpc') {
314
+ try {
315
+ pi.appendEntry?.('ccswitch-complete', {
316
+ protocolVersion: RPC_PROTOCOL_VERSION,
317
+ roundId: round.id,
318
+ model: key(round.model),
319
+ attempts: round.attempts,
320
+ sessionSwitches,
321
+ })
322
+ } catch { /* completion reporting is best-effort */ }
323
+ }
252
324
  }
253
325
  })
254
326
  pi.on('agent_settled', async (_event, ctx) => {
@@ -256,6 +328,7 @@ export default function (pi: ExtensionAPI) {
256
328
  if (!ctx.isIdle()) return
257
329
  if (!canRetry(ctx) && round.observation && round.model) {
258
330
  const classification = classifyFailure(round.observation)
331
+ if (classification.kind === 'content_policy') health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
259
332
  if (!classification.roundOnly && classification.scope) {
260
333
  const domain = classification.scope === 'model' ? modelKey(round.model) : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model)
261
334
  health.recordFailure(classification.scope, domain, classification.kind, round.observation.message, classification.retryAfterMs)
@@ -293,7 +366,8 @@ export default function (pi: ExtensionAPI) {
293
366
  const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
294
367
  const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
295
368
  const state = health.snapshot
369
+ const policyFamilies = activePolicyFamilies(state)
296
370
  const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
297
- notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, counts.total ? 'info' : 'warning')
371
+ notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords} · 审查约束系列 ${policyFamilies.size} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, counts.total ? 'info' : 'warning')
298
372
  }})
299
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.1.7",
3
+ "version": "0.3.0",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -20,11 +20,15 @@
20
20
  "homepage": "https://github.com/JunyWuuuu91/pi-ccswitch-auto-switch#readme",
21
21
  "files": [
22
22
  "index.ts",
23
+ "runner.mjs",
23
24
  "types.ts",
24
25
  "classify.ts",
25
26
  "candidates.ts",
26
27
  "health.ts"
27
28
  ],
29
+ "bin": {
30
+ "pi-ccswitch-run": "./runner.mjs"
31
+ },
28
32
  "pi": {
29
33
  "extensions": [
30
34
  "./index.ts"
@@ -35,7 +39,7 @@
35
39
  "node": ">=22.19.0"
36
40
  },
37
41
  "scripts": {
38
- "test": "node --test test/*.test.ts",
42
+ "test": "node --test test/*.test.ts test/*.test.mjs",
39
43
  "typecheck": "tsc --noEmit --allowImportingTsExtensions --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck *.ts"
40
44
  },
41
45
  "devDependencies": {
package/runner.mjs ADDED
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Headless Pi runner that keeps one RPC session alive while the CCSwitch
4
+ * extension retries a failed turn on another model.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { access, readFile, stat } from 'node:fs/promises'
8
+ import { existsSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { basename, delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'
11
+
12
+ export const EXIT = Object.freeze({ SUCCESS: 0, EXHAUSTED: 1, CONFIG: 2, TIMEOUT: 124, INTERRUPTED: 130, TERMINATED: 143 })
13
+ export const DEFAULT_TIMEOUT_MS = 600_000
14
+
15
+ const usage = `Usage: pi-ccswitch-run [options] [--] [@text-file ...] <prompt>
16
+
17
+ Options:
18
+ --timeout-ms <ms> Total runner timeout (default: ${DEFAULT_TIMEOUT_MS})
19
+ --no-tools Disable Pi tools
20
+ --no-context-files Disable AGENTS.md / CLAUDE.md discovery
21
+ --provider <name> Initial Pi provider
22
+ --model <name> Initial Pi model
23
+ --thinking <level> Initial Pi thinking level
24
+ --models <patterns> Limit the failover candidate scope
25
+ -p, --print Accepted for migration compatibility
26
+ --no-session Accepted; sessions are always ephemeral
27
+ --no-extensions Accepted; external discovery is always disabled
28
+ -h, --help Show this help`
29
+
30
+ const passthroughWithValue = new Set(['--provider', '--model', '--thinking', '--models'])
31
+ const acceptedNoops = new Set(['-p', '--print', '--no-session', '--no-extensions'])
32
+
33
+ export function parseArgs(argv) {
34
+ const piArgs = []
35
+ const files = []
36
+ const prompt = []
37
+ let timeoutMs = DEFAULT_TIMEOUT_MS
38
+ let positional = false
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const arg = argv[index]
41
+ if (arg === '--') { positional = true; continue }
42
+ if (!positional && (arg === '-h' || arg === '--help')) return { help: true }
43
+ if (!positional && arg === '--timeout-ms') {
44
+ const raw = argv[++index]
45
+ const value = Number(raw)
46
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error('--timeout-ms must be a positive integer')
47
+ timeoutMs = value
48
+ continue
49
+ }
50
+ if (!positional && (arg === '--no-tools' || arg === '--no-context-files')) { piArgs.push(arg); continue }
51
+ if (!positional && passthroughWithValue.has(arg)) {
52
+ const value = argv[++index]
53
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`)
54
+ piArgs.push(arg, value)
55
+ continue
56
+ }
57
+ if (!positional && acceptedNoops.has(arg)) continue
58
+ if (!positional && arg.startsWith('-')) throw new Error(`unsupported option: ${arg}`)
59
+ if (!positional && arg.startsWith('@')) {
60
+ if (arg.length === 1) throw new Error('@file requires a path')
61
+ files.push(arg.slice(1))
62
+ continue
63
+ }
64
+ prompt.push(arg)
65
+ }
66
+ if (prompt.length === 0) throw new Error('a prompt is required')
67
+ return { timeoutMs, piArgs, files, prompt: prompt.join(' ') }
68
+ }
69
+
70
+ export async function readTextFiles(fileArgs, cwd = process.cwd()) {
71
+ let text = ''
72
+ for (const raw of fileArgs) {
73
+ const path = resolve(cwd, raw)
74
+ try { await access(path) } catch { throw new Error(`file not found: ${path}`) }
75
+ const info = await stat(path)
76
+ if (!info.isFile()) throw new Error(`not a regular text file: ${path}`)
77
+ if (info.size === 0) continue
78
+ let content
79
+ try { content = await readFile(path, 'utf8') } catch { throw new Error(`not a UTF-8 text file: ${path}`) }
80
+ if (content.includes('\0')) throw new Error(`binary files are not supported: ${path}`)
81
+ text += `<file name="${path}">\n${content.replace(/^\uFEFF/, '')}\n</file>\n`
82
+ }
83
+ return text
84
+ }
85
+
86
+ export function buildMessage(fileText, prompt) {
87
+ return fileText ? `${fileText}\n${prompt}` : prompt
88
+ }
89
+
90
+ function validPiBinary(value) {
91
+ const normalized = basename(value).toLowerCase().replace(/\.(cmd|exe|bat)$/, '')
92
+ return normalized === 'pi'
93
+ }
94
+
95
+ async function resolveWindowsShim(command) {
96
+ const candidates = []
97
+ if (isAbsolute(command) || command.includes('\\') || command.includes('/')) candidates.push(command)
98
+ else {
99
+ for (const dir of (process.env.PATH || '').split(delimiter).filter(Boolean)) {
100
+ candidates.push(join(dir, command))
101
+ if (!/\.(cmd|bat)$/i.test(command)) candidates.push(join(dir, `${command}.cmd`))
102
+ }
103
+ }
104
+ for (const candidate of candidates) {
105
+ if (!existsSync(candidate) || !/\.(cmd|bat)$/i.test(candidate)) continue
106
+ const source = await readFile(candidate, 'utf8').catch(() => '')
107
+ const match = source.match(/["']([^"']+\.js)["']/i)
108
+ if (!match) continue
109
+ const target = match[1].replace(/%~dp0/ig, `${dirname(candidate)}\\`)
110
+ const entry = resolve(target.replace(/\\/g, '/'))
111
+ if (existsSync(entry)) return { command: process.execPath, prefix: [entry] }
112
+ }
113
+ return { command, prefix: [] }
114
+ }
115
+
116
+ async function resolvePiCommand() {
117
+ const configured = String(process.env.PI_BIN || '').trim()
118
+ if (configured && !validPiBinary(configured)) throw new Error('PI_BIN must name the real pi executable, not an arbitrary command')
119
+ const command = configured || 'pi'
120
+ if (process.platform === 'win32') return resolveWindowsShim(command)
121
+ return { command, prefix: [] }
122
+ }
123
+
124
+ function terminateTree(child, signal = 'SIGTERM') {
125
+ if (!child || child.exitCode !== null) return
126
+ if (process.platform === 'win32' && child.pid) {
127
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
128
+ killer.on('error', () => { try { child.kill('SIGKILL') } catch {} })
129
+ return
130
+ }
131
+ try { child.kill(signal) } catch {}
132
+ setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 1_000).unref()
133
+ }
134
+
135
+ function customEntry(event) {
136
+ if (event?.type !== 'entry_appended' || event.entry?.type !== 'custom') return undefined
137
+ return { type: event.entry.customType, data: event.entry.data || {} }
138
+ }
139
+
140
+ export async function run(options, runtime = {}) {
141
+ const spawnImpl = runtime.spawn || spawn
142
+ const extensionPath = runtime.extensionPath || fileURLToPath(new URL('./index.ts', import.meta.url))
143
+ const resolvePi = runtime.resolvePiCommand || resolvePiCommand
144
+ const log = runtime.log || ((message) => process.stderr.write(`[ccswitch] ${message}\n`))
145
+ const resolved = await resolvePi()
146
+ const args = [...resolved.prefix, '--mode', 'rpc', '--no-session', '--no-extensions', '--extension', extensionPath, ...options.piArgs]
147
+ const child = spawnImpl(resolved.command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: process.env })
148
+ let buffer = ''
149
+ let closed = false
150
+ let terminal
151
+ let retryPending = false
152
+ let protocolError
153
+ let nextId = 1
154
+ const pending = new Map()
155
+ const closeStdin = () => { try { child.stdin?.end() } catch {} }
156
+ const request = (type, payload = {}) => new Promise((resolveRequest, rejectRequest) => {
157
+ const id = String(nextId++)
158
+ pending.set(id, { resolve: resolveRequest, reject: rejectRequest })
159
+ try { child.stdin.write(`${JSON.stringify({ id, type, ...payload })}\n`) } catch (error) { pending.delete(id); rejectRequest(error) }
160
+ })
161
+ const failPending = (error) => {
162
+ for (const item of pending.values()) item.reject(error)
163
+ pending.clear()
164
+ }
165
+ const handleEvent = (event) => {
166
+ if (event?.type === 'extension_error') { protocolError = `extension error: ${event.error}`; return }
167
+ if (event?.type === 'turn_start') retryPending = false
168
+ const entry = customEntry(event)
169
+ if (!entry) {
170
+ if (event?.type === 'agent_settled' && !terminal && !retryPending) {
171
+ protocolError = 'Pi settled without a CCSwitch terminal event'
172
+ }
173
+ return
174
+ }
175
+ if (entry.data?.protocolVersion !== 1) return
176
+ if (entry.type === 'ccswitch-switch') {
177
+ retryPending = true
178
+ log(`switch ${entry.data.from || '?'} → ${entry.data.to || '?'} (${entry.data.attempts ?? '?'}/5)`)
179
+ } else if (entry.type === 'ccswitch-complete') {
180
+ terminal = { kind: 'complete', data: entry.data }
181
+ } else if (entry.type === 'ccswitch-exhausted') {
182
+ terminal = { kind: 'exhausted', data: entry.data }
183
+ }
184
+ }
185
+ const processLine = (line) => {
186
+ if (!line.trim()) return
187
+ let event
188
+ try { event = JSON.parse(line) } catch { protocolError = 'Pi RPC emitted malformed JSONL'; return }
189
+ if (event?.type === 'response' && event.id !== undefined) {
190
+ const item = pending.get(String(event.id))
191
+ if (item) {
192
+ pending.delete(String(event.id))
193
+ if (event.success) item.resolve(event.data)
194
+ else item.reject(new Error(event.error || `${event.command || 'RPC'} failed`))
195
+ }
196
+ return
197
+ }
198
+ handleEvent(event)
199
+ }
200
+ child.stdout?.setEncoding('utf8')
201
+ child.stdout?.on('data', (chunk) => {
202
+ buffer += chunk
203
+ let newline
204
+ while ((newline = buffer.indexOf('\n')) !== -1) {
205
+ const line = buffer.slice(0, newline)
206
+ buffer = buffer.slice(newline + 1)
207
+ processLine(line)
208
+ }
209
+ })
210
+ child.stderr?.on('data', () => {})
211
+ const exit = new Promise((resolveExit, rejectExit) => {
212
+ child.once('error', rejectExit)
213
+ child.once('close', (code, signal) => { closed = true; resolveExit({ code, signal }) })
214
+ })
215
+ const timeout = setTimeout(() => { protocolError = 'runner timed out'; terminateTree(child) }, options.timeoutMs)
216
+ timeout.unref?.()
217
+ const onSignal = (signal, code) => {
218
+ protocolError = signal
219
+ terminateTree(child, signal)
220
+ process.exitCode = code
221
+ }
222
+ const onSigint = () => onSignal('SIGINT', EXIT.INTERRUPTED)
223
+ const onSigterm = () => onSignal('SIGTERM', EXIT.TERMINATED)
224
+ process.once('SIGINT', onSigint)
225
+ process.once('SIGTERM', onSigterm)
226
+ try {
227
+ const commands = await request('get_commands')
228
+ if (!Array.isArray(commands?.commands) || !commands.commands.some((entry) => entry.name === 'ccswitch')) {
229
+ throw new Error('CCSwitch extension did not load')
230
+ }
231
+ await request('prompt', { message: options.message })
232
+ while (!terminal && !protocolError && !closed) await new Promise(resolveWait => setTimeout(resolveWait, 10))
233
+ if (protocolError) {
234
+ closeStdin()
235
+ await exit.catch(() => {})
236
+ if (protocolError === 'runner timed out') return { code: EXIT.TIMEOUT, error: protocolError }
237
+ if (protocolError === 'SIGINT') return { code: EXIT.INTERRUPTED, error: protocolError }
238
+ if (protocolError === 'SIGTERM') return { code: EXIT.TERMINATED, error: protocolError }
239
+ return { code: EXIT.CONFIG, error: protocolError }
240
+ }
241
+ if (!terminal) return { code: EXIT.CONFIG, error: 'Pi exited before CCSwitch reached a terminal state' }
242
+ if (terminal.kind === 'exhausted') {
243
+ closeStdin()
244
+ await exit.catch(() => {})
245
+ return { code: EXIT.EXHAUSTED, error: terminal.data.reason || 'all candidate models were exhausted' }
246
+ }
247
+ const last = await request('get_last_assistant_text')
248
+ const text = String(last?.text || '').trim()
249
+ closeStdin()
250
+ await exit.catch(() => {})
251
+ if (!text) return { code: EXIT.CONFIG, error: 'CCSwitch completed but Pi returned no assistant text' }
252
+ return { code: EXIT.SUCCESS, text }
253
+ } catch (error) {
254
+ terminateTree(child)
255
+ closeStdin()
256
+ await exit.catch(() => {})
257
+ return { code: EXIT.CONFIG, error: error instanceof Error ? error.message : String(error) }
258
+ } finally {
259
+ clearTimeout(timeout)
260
+ process.off('SIGINT', onSigint)
261
+ process.off('SIGTERM', onSigterm)
262
+ if (!closed) { failPending(new Error('runner stopped')); terminateTree(child) }
263
+ }
264
+ }
265
+
266
+ export async function main(argv = process.argv.slice(2)) {
267
+ let options
268
+ try { options = parseArgs(argv) } catch (error) { process.stderr.write(`pi-ccswitch-run: ${error.message}\n${usage}\n`); return EXIT.CONFIG }
269
+ if (options.help) { process.stdout.write(`${usage}\n`); return EXIT.SUCCESS }
270
+ try {
271
+ const fileText = await readTextFiles(options.files)
272
+ const result = await run({ ...options, message: buildMessage(fileText, options.prompt) })
273
+ if (result.text) process.stdout.write(`${result.text}\n`)
274
+ if (result.error) process.stderr.write(`pi-ccswitch-run: ${result.error}\n`)
275
+ return result.code
276
+ } catch (error) {
277
+ process.stderr.write(`pi-ccswitch-run: ${error instanceof Error ? error.message : String(error)}\n`)
278
+ return EXIT.CONFIG
279
+ }
280
+ }
281
+
282
+ const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
283
+ if (invoked) main().then(code => { process.exitCode = code })