pi-ccswitch-auto-switch 0.3.11 → 0.3.13

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,7 +13,7 @@ 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
- - 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 is session-scoped: it is cleared at `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.
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 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
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.
18
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.
19
19
  - 90-second first-response and 120-second streaming-idle watchdogs.
@@ -96,7 +96,7 @@ pi-ccswitch-run --no-tools --no-context-files @prompt.md "Summarize the attached
96
96
 
97
97
  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. For migration safety, an old `PI_BIN=pi-ccswitch-run` or `PI_BIN=ccswitch-run` value emits a warning and falls back to `pi` on `PATH` instead of recursing or terminating every downstream job. Arbitrary commands are still rejected. The default deadline is ten minutes and can be changed with `--timeout-ms`.
98
98
 
99
- 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.
99
+ 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 and PNG/JPEG/GIF/WebP `@files` are supported. Images are detected by signature and sent as native RPC image parts; invalid image files and other binary formats are rejected. Each attachment is limited to 20 MiB and a request to 20 images. Stdin is not supported.
100
100
 
101
101
  ## Commands
102
102
 
@@ -160,15 +160,18 @@ CCSwitch therefore prechecks **before the request is sent**, instead of waiting
160
160
 
161
161
  - When user input carries images (`input` event with `images`), CCSwitch immediately switches to a healthy multimodal candidate (`input` explicitly includes `image`) before the request is processed, so the images are preserved;
162
162
  - When a tool execution returns images (e.g. the `read` tool loading an image file, whose result content includes `image` parts), CCSwitch also switches to a multimodal model before the next LLM call, so the tool-result images are not stripped;
163
+ - Image requirements remain active for the entire failover round, including images returned by tools during retry verification and later fallback attempts;
163
164
  - Only models that **explicitly** support images (`input` includes `image`) are considered; models with missing metadata are never assumed to support images;
164
165
  - The switch reason is recorded as `modality` and counted in both the session and lifetime switch counters;
165
- - If no multimodal candidate is available, CCSwitch notifies the user and keeps the current model (Pi will still strip the image and add its notice).
166
+ - If no multimodal candidate is available, RPC image input is handled with an exhausted terminal result before any image-omitted request is sent. Interactive TUI retains its existing notification behavior.
166
167
 
167
168
  The modality precheck applies only in interactive TUI/RPC sessions (the same scope as failover); print/json modes only monitor and never switch.
168
169
 
169
170
  ## Data and privacy
170
171
 
171
- 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`.
172
+ 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. Model-family policy constraints stay in session memory. It does not access credentials, authorization headers, CC Switch's database, or Pi's `auth.json`.
173
+
174
+ State schema 3 loads schema 2 health records and preserves their existing totals as a shared baseline. Per-writer counters merge independent increments without counting repeated merges twice. Older releases cannot read schema 3 and cannot be made safe by code in a newer process. **Do not run schema-2 and schema-3 releases together against the same directory.** Before upgrading: (1) stop every old Pi process and scheduled runner using the same `PI_CODING_AGENT_DIR`; (2) back up `ccswitch-auto-switch-state.json` while they are stopped; (3) upgrade every installation that uses that directory; (4) restart only the upgraded processes. A running current-version store refuses writes when it encounters an unsupported schema or observes a downgrade from 3 to 2, preserves the file, and logs the reason in `ccswitch-auto-switch.log`. This cannot undo damage already caused by old code; the stop/upgrade/restart sequence is required. Manual enable/disable operations use monotonic local timestamps; merge picks the newer operation, with disable winning an exact timestamp tie (including conflicting legacy values without timestamps). Reset barriers prevent stale snapshots from restoring cleared records. Counter metadata grows with the number of writer instances that record failures or switches.
172
175
 
173
176
  ## Development
174
177
 
@@ -178,7 +181,21 @@ npm run typecheck
178
181
  npm test
179
182
  ```
180
183
 
181
- 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.
184
+ TypeScript is an explicit development dependency. `npm test` enables Node’s native TypeScript stripping and runs all `health`, `integration`, `logic`, and `runner` suites on Node 22.19+. 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.
185
+
186
+ ## Optional task complexity
187
+
188
+ `--task-complexity auto` estimates reasoning effort from the task instruction, input text length,
189
+ and image count. Short text uses low; structured summaries or images use medium; at least
190
+ 20,000 characters, 4 images, or cross-source synthesis uses high. This is a deterministic estimate,
191
+ not a model-quality ranking. `low|medium|high` may also be supplied explicitly.
192
+ The selected model stays on Pi defaults unless CCSwitch needs a healthy/vision fallback.
193
+ Pi clamps thinking to model capabilities. An explicit `--thinking` wins; omitting task-complexity
194
+ preserves existing thinking behavior. The runner does not write global settings.
195
+ RPC image requests with no selectable image-capable model terminate with an exhausted result,
196
+ rather than silently sending an image-omitted text request. Interactive TUI behavior is unchanged.
197
+
198
+ Large images exceeding 1.5 MiB of base64 use the installed Pi image normalizer before RPC dispatch; originals are never modified. If Pi cannot normalize the image, the runner fails explicitly.
182
199
 
183
200
  ## License
184
201
 
package/README.zh-CN.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - 对认证、额度、账单和限流实施 Provider 优先熔断。
13
13
  - 对 DNS、连接、服务端和流中断实施端点熔断。
14
14
  - 模型不存在或参数不兼容时仅隔离该模型。
15
- - 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm`),在本轮内容审查故障转移中避开整个受限系列。该约束按 session 隔离:`session_start` 时清除,因为新 session 处理的任务不一定涉及审查内容,模型系列恢复可用,直到本 session 再次观察到内容审查拒绝。
15
+ - 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm`),在本轮内容审查故障转移中避开整个受限系列。该约束仅保存在当前扩展实例的内存中,不写入共享健康状态;当前实例的 `session_start` 只清除自身约束,因为新 session 处理的任务不一定涉及审查内容,模型系列恢复可用,直到本 session 再次观察到内容审查拒绝。
16
16
  - 候选模型先经过健康、故障域、审查系列、输入模态和上下文窗口硬过滤,再按跨 Provider、模型等价性、能力兼容和历史稳定性排序。
17
17
  - 首响应 90 秒、流式停滞 120 秒看门狗。
18
18
  - 指数退避、`Retry-After` 支持和跨进程 half-open 单探针租约。
@@ -90,7 +90,7 @@ cd ~/.pi/agent/npm && npm install pi-ccswitch-auto-switch@latest
90
90
  pi-ccswitch-run --no-tools --no-context-files @prompt.md "请总结附件"
91
91
  ```
92
92
 
93
- runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 应指定内部真实 `pi`。为兼容旧配置,`PI_BIN=pi-ccswitch-run` 或 `PI_BIN=ccswitch-run` 会输出警告并降级到 `PATH` 中的 `pi`,避免递归和长驻消费方连续失败;其他任意命令仍会被拒绝。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143`。首版只支持文本 `@文件`,不支持图片和 stdin。
93
+ runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 应指定内部真实 `pi`。为兼容旧配置,`PI_BIN=pi-ccswitch-run` 或 `PI_BIN=ccswitch-run` 会输出警告并降级到 `PATH` 中的 `pi`,避免递归和长驻消费方连续失败;其他任意命令仍会被拒绝。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143`。支持文本及 PNG/JPEG/GIF/WebP `@文件`,按签名识别图片并传入 RPC images;损坏/不支持的图片及其他二进制附件明确拒绝。单附件上限 20 MiB,最多 20 张图片;不支持 stdin。
94
94
 
95
95
  正常使用 CC Switch 配置 Provider 即可;本插件不会修改 Pi 模型设置或 CC Switch 数据。
96
96
 
@@ -138,7 +138,7 @@ CCS v0.3.5 ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
138
138
  | 内容审查/敏感拦截 | 当前模型 + 模型系列约束 | 模型冷却 2 分钟;系列约束按 session 隔离(`session_start` 时清除) |
139
139
  | 上下文溢出 | 仅本轮 | 无 |
140
140
 
141
- 冷却时间会指数增长但有上限。发生内容审查时,插件只在审查故障转移链中避开已标记系列,普通限流、网络或模型配置故障仍可选择这些模型。上下文溢出时只会选择上下文窗口更大的模型;带图片的请求不会切到明确仅支持文本的模型。无法归类的异常按单模型故障处理并正常切换。用户主动取消是唯一不会触发故障转移的异常终止;看门狗取消会记录为超时。
141
+ 冷却时间会指数增长但有上限。发生内容审查时,插件只在审查故障转移链中避开已标记系列,普通限流、网络或模型配置故障仍可选择这些模型。上下文溢出时只会选择上下文窗口更大的模型;带图片的请求只选择显式声明支持 image 的模型。无法归类的异常按单模型故障处理并正常切换。用户主动取消是唯一不会触发故障转移的异常终止;看门狗取消会记录为超时。
142
142
 
143
143
  ### 模态预检(OCR / 图片请求自动切换多模态模型)
144
144
 
@@ -149,24 +149,35 @@ Pi 在把请求发给 Provider 时,会根据当前模型的 `input` 能力静
149
149
  - **用户输入带图片时**(`input` 事件含 `images`),如果当前模型不支持图片,立即切换到健康的多模态候选(`input` 明确含 `image`)再处理,图片被保留;
150
150
  - **工具执行返回图片时**(如 `read` 工具读取图片文件,工具结果含 `image` content),同样在下一轮 LLM 调用前切到多模态模型,确保图片不被剥除;
151
151
  - 候选只选**明确声明**支持图片的模型(`input` 含 `image`);元数据缺失的模型不冒险选择;
152
+ - 图片能力要求贯穿整个故障转移轮次,包括重试验证阶段工具返回图片以及此后的再次切换;
152
153
  - 切换原因记为 `modality`,计入本 session 切换数与累计切换;
153
- - 如果没有可用的多模态候选,则通知用户并保持原模型继续(Pi 会照常剥图并附带提示)。
154
+ - 如果没有可用的多模态候选,RPC 图片输入会返回 exhausted 终态并在发出剥图请求前停止;交互式 TUI 保持原有通知行为。
154
155
 
155
156
  模态预检只在 TUI/RPC 交互模式下生效(与故障转移一致);print/json 模式只监控不切换。
156
157
 
157
158
  ## 数据与隐私
158
159
 
159
- 健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息、模型系列审查约束和脱敏/截断的错误摘要;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
160
+ 健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息和脱敏/截断的错误摘要;模型系列审查约束只保存在 session 内存中;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
160
161
 
161
162
  ## 开发
162
163
 
164
+ 状态 schema 3 兼容加载 schema 2 的健康记录,将原有累计数保留为共同基线;每个写入实例独立计数,合并时不丢增量、不重复累计。旧版本无法读取 schema 3,新进程中的代码无法修复仍在运行的旧代码。**禁止 schema-2 与 schema-3 版本共用同一目录混跑。**升级时必须依次:① 退出所有使用同一 `PI_CODING_AGENT_DIR` 的旧 Pi 进程并停止相关定时 runner;② 在进程停止后备份 `ccswitch-auto-switch-state.json`;③ 升级使用该目录的所有安装;④ 仅启动升级后的进程。当前版本遇到不支持的 schema,或已观察到 schema 3 后又读到 schema 2 时,会拒绝写入、保留原文件,并在 `ccswitch-auto-switch.log` 记录原因。这不能撤销旧代码已经造成的破坏,因此必须执行上述停机升级步骤。人工启用/禁用按本地单调时间戳选择较新操作;时间戳完全相同时保守保留禁用(旧记录缺字段时也如此)。reset 标记阻止旧快照恢复已清除的记录。计数元数据随实际记录失败或切换的实例数增长。
165
+
163
166
  ```bash
164
167
  npm install
165
168
  npm run typecheck
166
169
  npm test
167
170
  ```
168
171
 
169
- 测试使用 Node 内置测试运行器,覆盖失败分类矩阵、系列级审查避让、Provider 优先选择、输入兼容、冷却、状态持久化以及 Windows 路径。
172
+ TypeScript 已显式列为开发依赖。`npm test` 开启 Node 原生 TypeScript 类型剥离,在 Node 22.19+ 上运行 `health`、`integration`、`logic`、`runner` 全部测试。测试使用 Node 内置测试运行器,覆盖失败分类矩阵、系列级审查避让、Provider 优先选择、输入兼容、冷却、状态持久化以及 Windows 路径。
173
+
174
+ ## 可选任务复杂度
175
+
176
+ `--task-complexity auto` 根据任务指令、输入文字长度和图片数量估计推理强度:短文本使用 low;结构化摘要或图片使用 medium;至少 20,000 个字符、4 张图片或跨来源综合分析使用 high。也可显式指定 `low|medium|high`。这是确定性的任务估计,不是模型质量排名。
177
+
178
+ 默认沿用 Pi 当前模型;需要健康或视觉候选时由 CCSwitch 切换。Pi 会按模型能力限制推理强度。显式 `--thinking` 优先;省略 `--task-complexity` 时保留现有推理行为。runner 不写入全局设置。
179
+
180
+ RPC 图片请求没有可选的视觉模型时返回 exhausted 终态,避免发送丢失图片的请求。交互式 TUI 行为保持不变。超过 1.5 MiB base64 的大图会通过已安装 Pi 的图片处理功能压缩后传入 RPC,保留原图;处理失败时明确报错。
170
181
 
171
182
  ## 许可证
172
183
 
package/candidates.ts CHANGED
@@ -131,7 +131,9 @@ export function modelFamily(model: Pick<ModelRef, 'id' | 'name'>): string {
131
131
  }
132
132
 
133
133
  function supportsRequiredInputs(candidate: ModelRef, required: readonly string[] | undefined): boolean {
134
- if (!required?.length || !candidate.input?.length) return true
134
+ if (!required?.length) return true
135
+ if (required.includes('image') && !candidate.input?.includes('image')) return false
136
+ if (!candidate.input?.length) return true
135
137
  return required.every(input => candidate.input!.includes(input))
136
138
  }
137
139
 
package/classify.ts CHANGED
@@ -10,7 +10,7 @@ export interface Classification {
10
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
  // 配额/余额类失败(scope provider)。含中文供应商报错:用户积分不足、余额不足、欠费等,
12
12
  // 这些通常不携带英文关键词,漏分类会落入 unknown(仅 2min 模型级冷却),导致死 provider 每轮重试。
13
- const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|monthly.?limit|积分不足|余额不足|欠费|请充值|额度.{0,6}(?:用完|耗尽|不足|已用)|无可用额度/i
13
+ const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|balance.{0,12}(?:exhausted|insufficient|depleted)|credits|monthly.?limit|积分不足|余额不足|欠费|请充值|额度.{0,6}(?:用完|耗尽|不足|已用)|无可用额度/i
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
@@ -27,6 +27,7 @@ export function classifyFailure(observation: FailureObservation): Classification
27
27
  if (status === 413) return { kind: 'context_overflow', roundOnly: true }
28
28
  if (status === 429) return { kind: 'rate_limit', scope: 'provider', roundOnly: false, retryAfterMs: observation.retryAfterMs }
29
29
  if (status === 402) return { kind: 'quota', scope: 'provider', roundOnly: false }
30
+ if (status === 403 && quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
30
31
  if (status === 401 || status === 403) return { kind: 'auth', scope: 'provider', roundOnly: false }
31
32
  if (quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
32
33
  if (status === 408) return { kind: 'timeout', scope: 'endpoint', roundOnly: false }
package/health.ts CHANGED
@@ -15,12 +15,15 @@ const MAX_LOG_BYTES = 512 * 1024
15
15
  export interface HealthRecord {
16
16
  consecutiveFailures: number
17
17
  totalFailures: number
18
+ /** Per-writer grow-only counters; legacy totals form one shared baseline. */
19
+ failureCounts?: Record<string, number>
18
20
  lastFailureAt?: number
19
21
  lastSuccessAt?: number
20
22
  cooldownUntil?: number
21
23
  lastClass?: FailureClass
22
24
  lastError?: string
23
25
  disabled?: boolean
26
+ disabledUpdatedAt?: number
24
27
  leaseUntil?: number
25
28
  }
26
29
 
@@ -33,18 +36,22 @@ export interface ContentPolicyConstraint {
33
36
  }
34
37
 
35
38
  export interface HealthState {
36
- schemaVersion: 2
39
+ schemaVersion: 2 | 3
37
40
  updatedAt: number
41
+ /** Reset barriers prevent older snapshots from resurrecting cleared history. */
42
+ resetAt?: number
43
+ modelResetAt?: Record<string, number>
38
44
  models: Record<string, HealthRecord>
39
45
  providers: Record<string, HealthRecord>
40
46
  endpoints: Record<string, HealthRecord>
41
47
  /**
42
- * 已从真实失败中学到的模型系列内容审查约束。它不会影响普通故障选模,只在本轮已经
43
- * 发生 content_policy 失败时用于避开同类系列。
48
+ * Legacy schema-2 field: ignored on load and removed on the next write.
49
+ * Active policy constraints live only in the owning HealthStore instance.
44
50
  */
45
51
  contentPolicyFamilies?: Record<string, ContentPolicyConstraint>
46
52
  /** 累计成功切换次数(跨 session 持久化,用于衡量扩展有效程度) */
47
53
  switches?: number
54
+ switchCounts?: Record<string, number>
48
55
  /** 最近成功切换日志(有限条,环形保留) */
49
56
  switchLog?: Array<{
50
57
  at: number
@@ -87,7 +94,7 @@ function safeEndpoint(value: string): string {
87
94
  }
88
95
 
89
96
  function blank(): HealthState {
90
- return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, contentPolicyFamilies: {}, switches: 0, switchLog: [] }
97
+ return { schemaVersion: 3, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, switches: 0, switchLog: [] }
91
98
  }
92
99
 
93
100
  function redact(text: string | undefined): string | undefined {
@@ -113,30 +120,29 @@ export class HealthStore {
113
120
  readonly dir: string
114
121
  private state: HealthState = blank()
115
122
  private dirty = false
116
- private replaceOnFlush = false
117
- private resetModels = new Set<string>()
118
- /** 合并磁盘状态后仍需强制清空的审查约束(session 边界) */
119
- private clearContentPolicyOnFlush = false
123
+ private readonly writer = randomUUID()
124
+ private manualClock = 0
125
+ private writeBlocked = false
126
+ private observedSchema3 = false
127
+ private constraints: Record<string, ContentPolicyConstraint> = {}
120
128
 
121
129
  constructor(dir = agentDir()) { this.dir = dir }
122
130
  get file(): string { return join(this.dir, STATE_FILE) }
123
131
  get snapshot(): HealthState { return structuredClone(this.state) }
132
+ get policyConstraints(): Record<string, ContentPolicyConstraint> { return structuredClone(this.constraints) }
124
133
 
125
134
  async load(): Promise<void> {
126
135
  await mkdir(this.dir, { recursive: true })
127
136
  try {
128
- if (existsSync(this.file)) {
129
- const parsed = JSON.parse(await readFile(this.file, 'utf8')) as HealthState
130
- if (parsed.schemaVersion !== 2 || !parsed.models || !parsed.providers || !parsed.endpoints) throw new Error('unsupported state schema')
131
- this.state = parsed
132
- } else {
133
- await this.migrateLegacy()
134
- }
135
- } catch {
136
- if (existsSync(this.file)) await rename(this.file, `${this.file}.corrupt-${Date.now()}`).catch(() => {})
137
- this.state = blank()
138
- this.dirty = true
139
- await this.flush()
137
+ this.state = await this.readDisk()
138
+ this.writeBlocked = false
139
+ delete this.state.contentPolicyFamilies
140
+ if (!existsSync(this.file)) await this.migrateLegacy()
141
+ } catch (error) {
142
+ // Unknown/newer formats are not corruption. Keep the original in place
143
+ // and fail closed until an explicit successful reload repairs the state.
144
+ this.writeBlocked = true
145
+ await this.log(`health state load refused: ${String(error)}`)
140
146
  }
141
147
  }
142
148
 
@@ -163,6 +169,7 @@ export class HealthStore {
163
169
  const failures = previous.consecutiveFailures + 1
164
170
  bucket[key] = {
165
171
  ...previous, consecutiveFailures: failures, totalFailures: previous.totalFailures + 1,
172
+ failureCounts: incrementCounts(previous.failureCounts, previous.totalFailures, this.writer),
166
173
  lastFailureAt: Date.now(), cooldownUntil: Date.now() + cooldownMs(kind, failures, retryAfterMs),
167
174
  lastClass: kind, lastError: redact(message), leaseUntil: undefined,
168
175
  }
@@ -175,7 +182,7 @@ export class HealthStore {
175
182
  * 本 session 内再次观察到同系列审查会增加证据计数并刷新时间戳。
176
183
  */
177
184
  recordContentPolicyConstraint(family: string, model: ModelRef, message?: string): void {
178
- const constraints = this.state.contentPolicyFamilies ??= {}
185
+ const constraints = this.constraints
179
186
  const previous = constraints[family]
180
187
  const now = Date.now()
181
188
  constraints[family] = {
@@ -185,7 +192,6 @@ export class HealthStore {
185
192
  lastModel: modelKey(model),
186
193
  lastError: redact(message),
187
194
  }
188
- this.touch()
189
195
  }
190
196
 
191
197
  recordSuccess(model: ModelRef): void {
@@ -200,6 +206,7 @@ export class HealthStore {
200
206
  * 用于衡量扩展有效程度,并在 /ccswitch status 面板展示。
201
207
  */
202
208
  recordSwitch(from: string, to: string, reason?: string): void {
209
+ this.state.switchCounts = incrementCounts(this.state.switchCounts, this.state.switches ?? 0, this.writer)
203
210
  this.state.switches = (this.state.switches ?? 0) + 1
204
211
  const log = this.state.switchLog ?? []
205
212
  log.push({ at: Date.now(), from, to, reason })
@@ -215,35 +222,53 @@ export class HealthStore {
215
222
 
216
223
  disable(key: string, disabled: boolean): void {
217
224
  const old = this.state.models[key] ?? { consecutiveFailures: 0, totalFailures: 0 }
218
- this.state.models[key] = { ...old, disabled }
225
+ this.state.models[key] = { ...old, disabled, disabledUpdatedAt: this.manualVersion() }
219
226
  this.touch()
220
227
  }
221
228
 
222
- reactivate(model: ModelRef): void {
223
- const key = modelKey(model)
224
- for (const [scope, id] of [['model', key], ['provider', model.provider], ['endpoint', endpointKey(model)]] as const) {
225
- const old = this.bucket(scope)[id]
226
- if (old) this.bucket(scope)[id] = { ...old, disabled: false, cooldownUntil: undefined, leaseUntil: undefined, lastSuccessAt: Date.now(), consecutiveFailures: 0 }
227
- }
228
- this.touch()
229
+ async reactivate(model: ModelRef): Promise<boolean> {
230
+ return this.reactivateAtomically(model)
229
231
  }
230
232
 
231
- reactivateAll(): void {
232
- const now = Date.now()
233
- for (const bucket of [this.state.models, this.state.providers, this.state.endpoints]) {
234
- for (const [key, old] of Object.entries(bucket)) bucket[key] = { ...old, disabled: false, cooldownUntil: undefined, leaseUntil: undefined, lastSuccessAt: now, consecutiveFailures: 0 }
233
+ async reactivateAll(): Promise<boolean> {
234
+ return this.reactivateAtomically()
235
+ }
236
+
237
+ private async reactivateAtomically(model?: ModelRef): Promise<boolean> {
238
+ try {
239
+ if (this.writeBlocked) throw new Error('health state writes are blocked; reload a supported state first')
240
+ return await this.withLock(async () => {
241
+ this.state = mergeState(await this.readDisk(), this.state)
242
+ const disabledUpdatedAt = this.manualVersion()
243
+ const targets: Array<[HealthScope, string]> = model
244
+ ? [['model', modelKey(model)], ['provider', model.provider], ['endpoint', endpointKey(model)]]
245
+ : (['model', 'provider', 'endpoint'] as const).flatMap(scope => Object.keys(this.bucket(scope)).map(key => [scope, key] as [HealthScope, string]))
246
+ for (const [scope, key] of targets) {
247
+ const old = this.bucket(scope)[key]
248
+ if (!old) continue
249
+ this.bucket(scope)[key] = { ...old, disabled: false, disabledUpdatedAt,
250
+ cooldownUntil: undefined, leaseUntil: undefined, consecutiveFailures: 0,
251
+ lastSuccessAt: Math.max(Date.now(), (old.lastFailureAt ?? 0) + 1) }
252
+ }
253
+ this.touch()
254
+ await this.commit()
255
+ return true
256
+ })
257
+ } catch (error) {
258
+ await this.log(`reactivation refused: ${String(error)}`)
259
+ return false
235
260
  }
236
- this.touch()
237
261
  }
238
262
 
239
263
  reset(target: string | 'all'): void {
264
+ const resetAt = this.manualVersion()
240
265
  if (target === 'all') {
241
- this.state = blank()
242
- this.replaceOnFlush = true
243
- this.resetModels.clear()
266
+ this.clearContentPolicyConstraints()
267
+ this.state = { ...blank(), resetAt }
244
268
  } else {
245
269
  delete this.state.models[target]
246
- this.resetModels.add(target)
270
+ const resets = this.state.modelResetAt ??= {}
271
+ resets[target] = resetAt
247
272
  }
248
273
  this.touch()
249
274
  }
@@ -253,40 +278,44 @@ export class HealthStore {
253
278
  * 对审查内容敏感,因此不在 session 间继承审查约束。
254
279
  */
255
280
  clearContentPolicyConstraints(): void {
256
- this.state.contentPolicyFamilies = {}
257
- this.clearContentPolicyOnFlush = true
258
- this.touch()
281
+ this.constraints = {}
259
282
  }
260
283
 
261
284
  isBlocked(model: ModelRef, now = Date.now()): boolean {
285
+ if (this.writeBlocked) return true
262
286
  return [this.state.models[modelKey(model)], this.state.providers[model.provider], this.state.endpoints[endpointKey(model)]]
263
287
  .some(record => Boolean(record?.disabled || (record?.cooldownUntil && record.cooldownUntil > now) || (record?.leaseUntil && record.leaseUntil > now)))
264
288
  }
265
289
 
266
- async claimProvider(model: ModelRef): Promise<boolean> {
267
- const key = model.provider
268
- let claimed = true
290
+ async claimCandidate(model: ModelRef): Promise<boolean> {
291
+ if (this.writeBlocked) return false
292
+ let claimed = false
269
293
  await this.withLock(async () => {
270
- if (!this.replaceOnFlush) this.state = mergeState(await this.readDisk(), this.state)
271
- const record = this.state.providers[key]
272
- if (!record || !(record.cooldownUntil && record.cooldownUntil <= Date.now())) return
273
- if (record.leaseUntil && record.leaseUntil > Date.now()) { claimed = false; return }
274
- record.leaseUntil = Date.now() + 2 * 60_000
275
- this.touch()
276
- await this.commit()
277
- }).catch(() => { claimed = false })
294
+ const disk = await this.readDisk()
295
+ this.state = mergeState(disk, this.state)
296
+ const records = (state: HealthState) => [state.models[modelKey(model)], state.providers[model.provider], state.endpoints[endpointKey(model)]]
297
+ const now = Date.now()
298
+ // Check disk as well: a stale local disabled flag must never override a
299
+ // disable written by another process before this atomic claim.
300
+ if ([...records(disk), ...records(this.state)].some(record => record?.disabled || (record?.cooldownUntil ?? 0) > now || (record?.leaseUntil ?? 0) > now)) return
301
+ const expired = records(this.state).filter(record => record?.cooldownUntil !== undefined && record.cooldownUntil <= now)
302
+ if (expired.length) {
303
+ for (const record of expired) record!.leaseUntil = now + 2 * 60_000
304
+ this.touch()
305
+ await this.commit()
306
+ }
307
+ claimed = true
308
+ }).catch(async error => { claimed = false; await this.log(`candidate claim refused: ${String(error)}`) })
278
309
  return claimed
279
310
  }
280
311
 
281
312
  async flush(): Promise<void> {
282
- if (!this.dirty) return
313
+ if (!this.dirty || this.writeBlocked) return
283
314
  await this.withLock(async () => {
284
- if (!this.replaceOnFlush) this.state = mergeState(await this.readDisk(), this.state)
285
- for (const key of this.resetModels) delete this.state.models[key]
286
- // 必须在 merge 之后再清空:磁盘上残留的旧 session 约束不能因合并而重新出现
287
- if (this.clearContentPolicyOnFlush) this.state.contentPolicyFamilies = {}
315
+ // Even reset-all must validate the on-disk schema before writing.
316
+ this.state = mergeState(await this.readDisk(), this.state)
288
317
  await this.commit()
289
- }).catch(() => {})
318
+ }).catch(async error => { await this.log(`health state flush refused: ${String(error)}`) })
290
319
  }
291
320
 
292
321
  async log(line: string): Promise<void> {
@@ -310,20 +339,39 @@ export class HealthStore {
310
339
  private touch(): void { this.dirty = true }
311
340
  private async readDisk(): Promise<HealthState> {
312
341
  try {
313
- if (!existsSync(this.file)) return blank()
314
- const parsed = JSON.parse(await readFile(this.file, 'utf8')) as HealthState
315
- return parsed.schemaVersion === 2 ? parsed : blank()
316
- } catch { return blank() }
342
+ let raw: string
343
+ try { raw = await readFile(this.file, 'utf8') }
344
+ catch (error: any) { if (error?.code === 'ENOENT') return blank(); throw error }
345
+ const parsed = JSON.parse(raw) as HealthState
346
+ if (!parsed || (parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3)) throw new Error(`unsupported state schema: ${parsed?.schemaVersion}`)
347
+ if (this.observedSchema3 && parsed.schemaVersion === 2) throw new Error('unsupported state schema downgrade: 3 -> 2; stop all old Pi processes')
348
+ if (!parsed.models || !parsed.providers || !parsed.endpoints) throw new Error('invalid health state buckets')
349
+ this.observedSchema3 ||= parsed.schemaVersion === 3
350
+ return parsed
351
+ } catch (error) {
352
+ this.writeBlocked = true
353
+ throw error
354
+ }
355
+ }
356
+
357
+ private manualVersion(): number {
358
+ const records = [...Object.values(this.state.models), ...Object.values(this.state.providers), ...Object.values(this.state.endpoints)]
359
+ this.manualClock = Math.max(Date.now(), this.manualClock + 1, (this.state.resetAt ?? 0) + 1,
360
+ ...Object.values(this.state.modelResetAt ?? {}).map(at => at + 1),
361
+ ...records.map(record => (record.disabledUpdatedAt ?? 0) + 1))
362
+ return this.manualClock
317
363
  }
364
+
318
365
  private async commit(): Promise<void> {
366
+ if (this.writeBlocked) throw new Error("health state writes are blocked; reload a supported state first")
367
+ this.state.schemaVersion = 3
368
+ delete this.state.contentPolicyFamilies
319
369
  this.state.updatedAt = Date.now()
320
370
  const temp = `${this.file}.tmp-${process.pid}-${randomUUID()}`
321
371
  await writeFile(temp, JSON.stringify(this.state, null, 2), 'utf8')
322
372
  await rename(temp, this.file)
373
+ this.observedSchema3 = true
323
374
  this.dirty = false
324
- this.replaceOnFlush = false
325
- this.resetModels.clear()
326
- this.clearContentPolicyOnFlush = false
327
375
  }
328
376
  private bucket(scope: HealthScope): Record<string, HealthRecord> {
329
377
  return scope === 'model' ? this.state.models : scope === 'provider' ? this.state.providers : this.state.endpoints
@@ -351,10 +399,15 @@ function mergeRecord(a: HealthRecord | undefined, b: HealthRecord | undefined):
351
399
  const lastFailureAt = Math.max(a.lastFailureAt ?? 0, b.lastFailureAt ?? 0)
352
400
  const lastSuccessAt = Math.max(a.lastSuccessAt ?? 0, b.lastSuccessAt ?? 0)
353
401
  const successWins = lastSuccessAt > lastFailureAt
402
+ const failureCounts = mergeCounts(a.failureCounts, a.totalFailures, b.failureCounts, b.totalFailures)
354
403
  return {
355
404
  ...newest,
356
- totalFailures: Math.max(a.totalFailures, b.totalFailures),
357
- disabled: b.disabled ?? a.disabled,
405
+ totalFailures: sumCounts(failureCounts),
406
+ failureCounts,
407
+ disabled: (a.disabledUpdatedAt ?? 0) === (b.disabledUpdatedAt ?? 0)
408
+ ? (a.disabled === true || b.disabled === true ? true : b.disabled ?? a.disabled)
409
+ : (a.disabledUpdatedAt ?? 0) > (b.disabledUpdatedAt ?? 0) ? a.disabled : b.disabled,
410
+ disabledUpdatedAt: Math.max(a.disabledUpdatedAt ?? 0, b.disabledUpdatedAt ?? 0) || undefined,
358
411
  lastFailureAt: lastFailureAt || undefined,
359
412
  lastSuccessAt: lastSuccessAt || undefined,
360
413
  consecutiveFailures: successWins ? 0 : Math.max(a.consecutiveFailures, b.consecutiveFailures),
@@ -373,26 +426,59 @@ function mergeBucket(a: Record<string, HealthRecord>, b: Record<string, HealthRe
373
426
  }
374
427
 
375
428
  function mergeState(a: HealthState, b: HealthState): HealthState {
376
- const switches = Math.max(a.switches ?? 0, b.switches ?? 0)
429
+ const resetAt = Math.max(a.resetAt ?? 0, b.resetAt ?? 0)
430
+ a = afterGlobalReset(a, resetAt)
431
+ b = afterGlobalReset(b, resetAt)
432
+ const modelResetAt = { ...a.modelResetAt }
433
+ for (const [key, at] of Object.entries(b.modelResetAt ?? {})) modelResetAt[key] = Math.max(modelResetAt[key] ?? 0, at)
434
+ const models = mergeBucket(a.models, b.models)
435
+ for (const [key, at] of Object.entries(modelResetAt)) {
436
+ const record = mergeRecord(afterReset(a.models[key], a.modelResetAt?.[key] ?? 0, at), afterReset(b.models[key], b.modelResetAt?.[key] ?? 0, at))
437
+ if (record) models[key] = record
438
+ else delete models[key]
439
+ }
440
+ const switchCounts = mergeCounts(a.switchCounts, a.switches ?? 0, b.switchCounts, b.switches ?? 0)
441
+ const switches = sumCounts(switchCounts)
377
442
  // 取最近更新的 switchLog(按 at 去倒序合并,保留最新 20 条)
378
443
  const log = [...(a.switchLog ?? []), ...(b.switchLog ?? [])]
379
444
  .sort((x, y) => y.at - x.at)
380
445
  .filter((entry, index, all) => index === 0 || all[index - 1].at !== entry.at || all[index - 1].from !== entry.from || all[index - 1].to !== entry.to)
381
446
  .slice(0, 20)
382
- const contentPolicyFamilies: Record<string, ContentPolicyConstraint> = {}
383
- for (const family of new Set([...Object.keys(a.contentPolicyFamilies ?? {}), ...Object.keys(b.contentPolicyFamilies ?? {})])) {
384
- const left = a.contentPolicyFamilies?.[family]
385
- const right = b.contentPolicyFamilies?.[family]
386
- if (!left) contentPolicyFamilies[family] = right!
387
- else if (!right) contentPolicyFamilies[family] = left
388
- else {
389
- const newest = right.lastObservedAt >= left.lastObservedAt ? right : left
390
- contentPolicyFamilies[family] = {
391
- ...newest,
392
- observations: Math.max(left.observations, right.observations),
393
- avoidUntil: Math.max(left.avoidUntil, right.avoidUntil),
394
- }
447
+ return { schemaVersion: 3, resetAt: resetAt || undefined, modelResetAt, updatedAt: Math.max(a.updatedAt ?? 0, b.updatedAt ?? 0), models, providers: mergeBucket(a.providers ?? {}, b.providers ?? {}), endpoints: mergeBucket(a.endpoints ?? {}, b.endpoints ?? {}), switchCounts, switches, switchLog: log }
448
+ }
449
+
450
+ // Each store increments only its own component. Merging the same state twice is
451
+ // idempotent; independent increments add without inflating the backoff streak.
452
+ function mergeCounts(a: Record<string, number> | undefined, aTotal: number, b: Record<string, number> | undefined, bTotal: number): Record<string, number> {
453
+ const left = a ?? { legacy: aTotal }, right = b ?? { legacy: bTotal }
454
+ const result = { ...left }
455
+ for (const [writer, count] of Object.entries(right)) result[writer] = Math.max(result[writer] ?? 0, count)
456
+ return result
457
+ }
458
+ function incrementCounts(counts: Record<string, number> | undefined, total: number, writer: string): Record<string, number> {
459
+ const next = { ...(counts ?? { legacy: total }) }
460
+ next[writer] = (next[writer] ?? 0) + 1
461
+ return next
462
+ }
463
+ function sumCounts(counts: Record<string, number>): number {
464
+ return Object.values(counts).reduce((sum, count) => sum + count, 0)
465
+ }
466
+
467
+ function afterReset(record: HealthRecord | undefined, sourceResetAt: number, resetAt: number): HealthRecord | undefined {
468
+ if (sourceResetAt >= resetAt || !record) return record
469
+ // A manual operation newer than the reset survives, but old health history
470
+ // does not. Legacy records have timestamp zero and cannot beat a reset.
471
+ if ((record.disabledUpdatedAt ?? 0) > resetAt) return { consecutiveFailures: 0, totalFailures: 0, disabled: record.disabled, disabledUpdatedAt: record.disabledUpdatedAt }
472
+ return undefined
473
+ }
474
+ function afterGlobalReset(state: HealthState, resetAt: number): HealthState {
475
+ if ((state.resetAt ?? 0) >= resetAt) return state
476
+ const clean = { ...blank(), resetAt }
477
+ for (const scope of ['models', 'providers', 'endpoints'] as const) {
478
+ for (const [key, record] of Object.entries(state[scope])) {
479
+ const kept = afterReset(record, state.resetAt ?? 0, resetAt)
480
+ if (kept) clean[scope][key] = kept
395
481
  }
396
482
  }
397
- 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 }
483
+ return clean
398
484
  }
package/index.ts CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
6
6
  import type { ExtensionAPI, ExtensionContext, FailureObservation, ModelRef } from './types.ts'
7
7
  import { classifyFailure, parseRetryAfter } from './classify.ts'
8
8
  import { candidateSnapshot, effectiveCandidates, chooseCandidate, multimodalCandidates, modelFamily, summarizeCandidateHealth } from './candidates.ts'
9
- import { HealthStore, endpointKey, modelKey, platformKey, type HealthState } from './health.ts'
9
+ import { HealthStore, endpointKey, modelKey, platformKey, type ContentPolicyConstraint } from './health.ts'
10
10
 
11
11
  const FIRST_RESPONSE_TIMEOUT = 90_000
12
12
  const STREAM_IDLE_TIMEOUT = 120_000
@@ -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.11'
20
+ const EXTENSION_VERSION = '0.3.13'
21
21
  // 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
22
22
  const ENDPOINT_FAIL_THRESHOLD = 3
23
23
 
@@ -33,6 +33,8 @@ interface Round {
33
33
  startedAt: number
34
34
  text: string
35
35
  images?: Array<Record<string, unknown>>
36
+ needsImage: boolean
37
+ modalitySwitch?: Promise<void>
36
38
  tried: Set<string>
37
39
  attempts: number
38
40
  hadTool: boolean
@@ -43,7 +45,7 @@ interface Round {
43
45
  observation?: FailureObservation
44
46
  model?: ModelRef
45
47
  endpointFails?: EndpointFailTracker
46
- /** 本轮内容审查故障转移中必须避开的模型系列(包含持久化学到的约束)。 */
48
+ /** 本轮内容审查故障转移中必须避开的模型系列(包含本 session 学到的约束)。 */
47
49
  avoidFamilies?: Set<string>
48
50
  /** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“超过本轮时间限制” */
49
51
  lastSwitchAt?: number
@@ -53,8 +55,8 @@ interface Round {
53
55
 
54
56
  function key(model: ModelRef | undefined): string | undefined { return model && modelKey(model) }
55
57
  function canRetry(ctx: ExtensionContext): boolean { return ctx.mode === 'tui' || ctx.mode === 'rpc' }
56
- function activePolicyFamilies(state: HealthState, now = Date.now()): Set<string> {
57
- return new Set(Object.entries(state.contentPolicyFamilies ?? {})
58
+ function activePolicyFamilies(constraints: Record<string, ContentPolicyConstraint>, now = Date.now()): Set<string> {
59
+ return new Set(Object.entries(constraints)
58
60
  .filter(([, record]) => record.avoidUntil > now)
59
61
  .map(([family]) => family))
60
62
  }
@@ -154,7 +156,7 @@ export default function (pi: ExtensionAPI) {
154
156
  const candidates = snapshot.models
155
157
  const counts = summarizeCandidateHealth(candidates, state)
156
158
  const now = Date.now()
157
- const policyFamilies = activePolicyFamilies(state, now)
159
+ const policyFamilies = activePolicyFamilies(health.policyConstraints, now)
158
160
  const rows = candidates.slice(0, 10).map(model => {
159
161
  const modelRecord = state.models[modelKey(model)]
160
162
  const records = [modelRecord, state.providers[model.provider], state.endpoints[endpointKey(model)]]
@@ -179,7 +181,11 @@ export default function (pi: ExtensionAPI) {
179
181
  const policySummary = policyFamilies.size ? [...policyFamilies].join(', ') : '无'
180
182
  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') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
181
183
  if (action === '刷新') await refresh(ctx)
182
- if (action === '重新激活当前模型' && ctx.model) { health.reactivate(ctx.model); await health.flush(); status(ctx); notify(ctx, '已重新激活当前模型') }
184
+ if (action === '重新激活当前模型' && ctx.model) {
185
+ const restored = await health.reactivate(ctx.model)
186
+ status(ctx)
187
+ notify(ctx, restored ? '已重新激活当前模型' : '重新激活失败,健康状态未确认保存;请查看 ccswitch-auto-switch.log', restored ? 'info' : 'error')
188
+ }
183
189
  if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
184
190
  if (action === '重置当前模型历史' && ctx.model) {
185
191
  const ok = !ctx.ui.confirm || await ctx.ui.confirm('重置健康历史', `删除 ${modelKey(ctx.model)} 的记录?`)
@@ -223,12 +229,16 @@ export default function (pi: ExtensionAPI) {
223
229
  * 模态预检主动切换:当前模型不支持图片但本轮输入/工具结果带图时,
224
230
  * 主动切到一个健康的多模态候选(不等失败)。无候选时通知并保持原模型。
225
231
  */
226
- const proactiveModalitySwitch = async (ctx: ExtensionContext, round: Round): Promise<void> => {
232
+ const performModalitySwitch = async (ctx: ExtensionContext, round: Round): Promise<void> => {
227
233
  const current = round.model ?? ctx.model
228
- if (!current || !canRetry(ctx) || round.phase !== 'monitoring') return
234
+ if (!current || !canRetry(ctx) || !['monitoring', 'verifying'].includes(round.phase)) return
229
235
  if (current.input?.includes('image')) return
230
236
  const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
231
- const next = multimodalCandidates(candidates, current, health.snapshot)[0]
237
+ let next: ModelRef | undefined
238
+ for (const candidate of multimodalCandidates(candidates, current, health.snapshot)) {
239
+ if (round.tried.has(modelKey(candidate)) || round.avoidFamilies?.has(modelFamily(candidate)) || round.endpointFails?.isolated.has(platformKey(candidate))) continue
240
+ if (await health.claimCandidate(candidate)) { next = candidate; break }
241
+ }
232
242
  if (!next) {
233
243
  await health.log(`modality precheck: no multimodal candidate, staying on ${modelKey(current)}`)
234
244
  notify(ctx, 'CCSwitch:当前模型不支持图片,且没有可用的多模态候选模型,已保持原模型', 'warning')
@@ -263,6 +273,14 @@ export default function (pi: ExtensionAPI) {
263
273
  status(ctx)
264
274
  }
265
275
 
276
+ const proactiveModalitySwitch = (ctx: ExtensionContext, target: Round): Promise<void> => {
277
+ // Concurrent tool completions await the same switch before returning to Pi.
278
+ if (!target.modalitySwitch) {
279
+ target.modalitySwitch = performModalitySwitch(ctx, target).finally(() => { target.modalitySwitch = undefined })
280
+ }
281
+ return target.modalitySwitch
282
+ }
283
+
266
284
  const failover = async (ctx: ExtensionContext) => {
267
285
  if (!round || !round.observation || !round.model || !canRetry(ctx)) return
268
286
  const classification = classifyFailure(round.observation)
@@ -285,7 +303,7 @@ export default function (pi: ExtensionAPI) {
285
303
  if (classification.kind === 'content_policy') {
286
304
  health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
287
305
  round.avoidFamilies ??= new Set()
288
- for (const family of activePolicyFamilies(health.snapshot)) round.avoidFamilies.add(family)
306
+ for (const family of activePolicyFamilies(health.policyConstraints)) round.avoidFamilies.add(family)
289
307
  }
290
308
  // 失败必须先落台账再做时限判断:否则本轮最后一次失败不会进健康状态(无冷却),
291
309
  // 下一轮还会重试同一个刚失败的模型/provider
@@ -303,11 +321,11 @@ export default function (pi: ExtensionAPI) {
303
321
  health: health.snapshot,
304
322
  avoidEndpoints: failTracker.isolated,
305
323
  avoidFamilies: round!.avoidFamilies,
306
- requiredInputs: round!.images?.length ? ['text', 'image'] : ['text'],
324
+ requiredInputs: round!.needsImage ? ['text', 'image'] : ['text'],
307
325
  })
308
326
  let next = chooseCandidate(candidates, candidateOptions())
309
327
  while (next) {
310
- if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, candidateOptions()); continue }
328
+ if (!await health.claimCandidate(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, candidateOptions()); continue }
311
329
  round.attempts++
312
330
  ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
313
331
  const previousModel = round.model
@@ -371,14 +389,24 @@ export default function (pi: ExtensionAPI) {
371
389
  if (event.source === 'extension') return { action: 'continue' }
372
390
  clearWatchdog()
373
391
  lastStatus = {}
374
- 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, errors: new Map() }
392
+ round = { id: (round?.id ?? 0) + 1, phase: 'monitoring', startedAt: Date.now(), text: event.text, images: event.images, needsImage: Boolean(event.images?.length), tried: new Set(), attempts: 0, hadTool: false, inTool: false, hadOutput: false, watchdog: false, cleanRetry: false, model: ctx.model, endpointFails: undefined, avoidFamilies: undefined, errors: new Map() }
375
393
  status(ctx)
376
394
  // 模态预检:输入带图片但当前模型不支持图片(非多模态)→ 主动切换到多模态模型,
377
395
  // 避免 Pi 静默剥图后模型只回答“看不到图片”(此类情况不会触发 failover)。
378
396
  if (event.images?.length && round.phase === 'monitoring') await proactiveModalitySwitch(ctx, round)
397
+ if (event.images?.length && ctx.mode === 'rpc' && !round.model?.input?.includes('image')) {
398
+ await exhaust(ctx, 'image input requires an available image-capable model')
399
+ return { action: 'handled' }
400
+ }
379
401
  return { action: 'continue' }
380
402
  })
381
- pi.on('before_provider_request', (_event, ctx) => { lastStatus = {}; if (round) { round.inTool = false; armWatchdog(ctx, FIRST_RESPONSE_TIMEOUT, round.id) } })
403
+ pi.on('before_provider_request', (_event, ctx) => {
404
+ lastStatus = {}
405
+ if (round) {
406
+ round.inTool = false
407
+ armWatchdog(ctx, FIRST_RESPONSE_TIMEOUT, round.id)
408
+ }
409
+ })
382
410
  pi.on('after_provider_response', (event, ctx) => {
383
411
  lastStatus = { status: event.status, retryAfterMs: parseRetryAfter(event.headers?.['retry-after']) }
384
412
  if (round && event.status >= 200 && event.status < 300) armWatchdog(ctx, STREAM_IDLE_TIMEOUT, round.id)
@@ -401,7 +429,10 @@ export default function (pi: ExtensionAPI) {
401
429
  round.inTool = false
402
430
  // 工具结果含图片(如 read 图片文件)且当前模型不支持 → 主动切换多模态模型,
403
431
  // 确保下一轮 LLM 调用使用新模型,工具结果中的图片不被 Pi 静默剥除。
404
- if (round.phase === 'monitoring' && resultContainsImage(_event.result)) await proactiveModalitySwitch(ctx, round)
432
+ if (resultContainsImage(_event.result)) {
433
+ round.needsImage = true
434
+ await proactiveModalitySwitch(ctx, round)
435
+ }
405
436
  })
406
437
  pi.on('turn_end', async (event, ctx) => {
407
438
  const message = event.message
@@ -483,9 +514,11 @@ export default function (pi: ExtensionAPI) {
483
514
  if (!target && verb !== 'reset') { notify(ctx, '请指定 provider/model 或 all', 'warning'); return }
484
515
  if (verb === 'disable' && target) { health.disable(target, true); await health.flush(); status(ctx); return }
485
516
  if (verb === 'reactivate' && target) {
486
- if (target === 'all') health.reactivateAll()
487
- else { const [provider, ...rest] = target.split('/'); health.reactivate({ provider, id: rest.join('/') }) }
488
- await health.flush(); status(ctx); return
517
+ const [provider, ...rest] = target.split('/')
518
+ const restored = await (target === 'all' ? health.reactivateAll() : health.reactivate({ provider, id: rest.join('/') }))
519
+ status(ctx)
520
+ notify(ctx, restored ? '已重新激活' : '重新激活失败,健康状态未确认保存;请查看 ccswitch-auto-switch.log', restored ? 'info' : 'error')
521
+ return
489
522
  }
490
523
  if (verb === 'reset' && target) {
491
524
  const ok = !ctx.ui.confirm || await ctx.ui.confirm('重置健康历史', `删除 ${target} 的健康记录?`)
@@ -499,7 +532,7 @@ export default function (pi: ExtensionAPI) {
499
532
  const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
500
533
  const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
501
534
  const state = health.snapshot
502
- const policyFamilies = activePolicyFamilies(state)
535
+ const policyFamilies = activePolicyFamilies(health.policyConstraints)
503
536
  const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
504
537
  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')
505
538
  }})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -39,10 +39,11 @@
39
39
  "node": ">=22.19.0"
40
40
  },
41
41
  "scripts": {
42
- "test": "node --test test/*.test.ts test/*.test.mjs",
42
+ "test": "node --experimental-strip-types --test test/*.test.ts test/*.test.mjs",
43
43
  "typecheck": "tsc --noEmit --allowImportingTsExtensions --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck *.ts"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "^22.19.19"
46
+ "@types/node": "^22.19.19",
47
+ "typescript": "~5.9.3"
47
48
  }
48
49
  }
package/runner.mjs CHANGED
@@ -4,18 +4,20 @@
4
4
  * extension retries a failed turn on another model.
5
5
  */
6
6
  import { spawn } from 'node:child_process'
7
- import { access, readFile, stat } from 'node:fs/promises'
7
+ import { access, readFile, realpath, stat } from 'node:fs/promises'
8
8
  import { existsSync } from 'node:fs'
9
- import { fileURLToPath } from 'node:url'
9
+ import { fileURLToPath, pathToFileURL } from 'node:url'
10
10
  import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'
11
11
 
12
12
  export const EXIT = Object.freeze({ SUCCESS: 0, EXHAUSTED: 1, CONFIG: 2, TIMEOUT: 124, INTERRUPTED: 130, TERMINATED: 143 })
13
13
  export const DEFAULT_TIMEOUT_MS = 600_000
14
+ export const INLINE_IMAGE_MAX_BYTES = 1.5 * 1024 * 1024
14
15
 
15
- const usage = `Usage: pi-ccswitch-run [options] [--] [@text-file ...] <prompt>
16
+ const usage = `Usage: pi-ccswitch-run [options] [--] [@text-or-image-file ...] <prompt>
16
17
 
17
18
  Options:
18
19
  --timeout-ms <ms> Total runner timeout (default: ${DEFAULT_TIMEOUT_MS})
20
+ --task-complexity <v> auto, low, medium, high (omitted: keep Pi thinking)
19
21
  --no-tools Disable Pi tools
20
22
  --no-context-files Disable AGENTS.md / CLAUDE.md discovery
21
23
  --provider <name> Initial Pi provider
@@ -35,11 +37,17 @@ export function parseArgs(argv) {
35
37
  const files = []
36
38
  const prompt = []
37
39
  let timeoutMs = DEFAULT_TIMEOUT_MS
40
+ let complexity
38
41
  let positional = false
39
42
  for (let index = 0; index < argv.length; index += 1) {
40
43
  const arg = argv[index]
41
44
  if (arg === '--') { positional = true; continue }
42
45
  if (!positional && (arg === '-h' || arg === '--help')) return { help: true }
46
+ if (!positional && arg === '--task-complexity') {
47
+ complexity = argv[++index]
48
+ if (!['auto', 'low', 'medium', 'high'].includes(complexity)) throw new Error('invalid --task-complexity')
49
+ continue
50
+ }
43
51
  if (!positional && arg === '--timeout-ms') {
44
52
  const raw = argv[++index]
45
53
  const value = Number(raw)
@@ -64,7 +72,19 @@ export function parseArgs(argv) {
64
72
  prompt.push(arg)
65
73
  }
66
74
  if (prompt.length === 0) throw new Error('a prompt is required')
67
- return { timeoutMs, piArgs, files, prompt: prompt.join(' ') }
75
+ return { timeoutMs, piArgs, files, prompt: prompt.join(' '), ...(complexity ? { complexity } : {}) }
76
+ }
77
+
78
+ export function assessComplexity(prompt, fileText = '', imageCount = 0) {
79
+ const chars = prompt.length + fileText.length
80
+ // 只根据任务指令、输入规模和图片数量估计;不把附件正文当路由指令。
81
+ if (chars >= 20000 || imageCount >= 4 || /跨文章|跨聊天|主题脉络|交叉核验|因果分析|多来源|cross.source|synthesi[sz]/i.test(prompt)) {
82
+ return { level: 'high', reason: 'long-input-or-synthesis' }
83
+ }
84
+ if (imageCount || chars >= 2000 || /JSON|结构化|摘要|summari[sz]/i.test(prompt)) {
85
+ return { level: 'medium', reason: 'structured-or-visual' }
86
+ }
87
+ return { level: 'low', reason: 'short-text' }
68
88
  }
69
89
 
70
90
  export async function readTextFiles(fileArgs, cwd = process.cwd()) {
@@ -87,6 +107,60 @@ export function buildMessage(fileText, prompt) {
87
107
  return fileText ? `${fileText}\n${prompt}` : prompt
88
108
  }
89
109
 
110
+ export async function readAttachments(fileArgs, cwd = process.cwd()) {
111
+ let fileText = ''
112
+ const images = []
113
+ for (const raw of fileArgs) {
114
+ const path = resolve(cwd, raw)
115
+ const info = await stat(path)
116
+ if (!info.isFile()) throw new Error(`not a regular file: ${path}`)
117
+ if (info.size > 20 * 1024 * 1024) throw new Error(`attachment exceeds 20 MiB: ${path}`)
118
+ const data = await readFile(path)
119
+ let mimeType
120
+ if (data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) mimeType = 'image/png'
121
+ else if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) mimeType = 'image/jpeg'
122
+ else if (/^GIF8[79]a$/.test(data.subarray(0, 6).toString('ascii'))) mimeType = 'image/gif'
123
+ else if (data.subarray(0, 4).toString() === 'RIFF' && data.subarray(8, 12).toString() === 'WEBP') mimeType = 'image/webp'
124
+ if (mimeType) {
125
+ if (images.length >= 20) throw new Error('at most 20 image attachments are supported')
126
+ images.push({ type: 'image', data: data.toString('base64'), mimeType })
127
+ fileText += `<file name="${path}">Image attachment ${images.length}</file>\n`
128
+ } else {
129
+ if (/\.(png|jpe?g|gif|webp|heic|heif|bmp|tiff?)$/i.test(path)) throw new Error(`invalid or unsupported image: ${path}`)
130
+ fileText += await readTextFiles([path])
131
+ }
132
+ }
133
+ return { fileText, images }
134
+ }
135
+
136
+ export async function normalizeLargeImages(images, resizeImage) {
137
+ const maxBytes = INLINE_IMAGE_MAX_BYTES
138
+ const result = []
139
+ for (const image of images) {
140
+ if (image.data.length <= maxBytes) { result.push(image); continue }
141
+ const resized = await resizeImage(Buffer.from(image.data, 'base64'), image.mimeType, { maxBytes })
142
+ if (!resized?.data || resized.data.length > maxBytes) throw new Error('image exceeds inline size limit after normalization')
143
+ result.push({ type: 'image', data: resized.data, mimeType: resized.mimeType })
144
+ }
145
+ return result
146
+ }
147
+
148
+ export async function prepareImages(images, resolvePi = resolvePiCommand) {
149
+ if (!images.some(image => image.data.length > INLINE_IMAGE_MAX_BYTES)) return images
150
+ const resolved = await resolvePi()
151
+ const executable = resolved.prefix[0] || resolved.command
152
+ const candidates = isAbsolute(executable) ? [executable]
153
+ : (process.env.PATH || '').split(delimiter).filter(Boolean).map(dir => join(dir, executable))
154
+ const found = candidates.find(path => existsSync(path))
155
+ if (!found) throw new Error('cannot locate Pi image normalizer for large attachments')
156
+ const entry = await realpath(found)
157
+ const normalizer = [join(dirname(entry), 'utils', 'image-resize.js'), join(dirname(entry), '..', 'utils', 'image-resize.js')]
158
+ .find(path => existsSync(path))
159
+ if (!normalizer) throw new Error('installed Pi does not expose image normalization for large attachments')
160
+ const { resizeImage } = await import(pathToFileURL(normalizer).href)
161
+ return normalizeLargeImages(images, resizeImage)
162
+ }
163
+
90
164
  const LEGACY_RUNNER_STEMS = new Set(['pi-ccswitch-run', 'ccswitch-run'])
91
165
 
92
166
  function executableStem(value) {
@@ -242,7 +316,14 @@ export async function run(options, runtime = {}) {
242
316
  if (!Array.isArray(commands?.commands) || !commands.commands.some((entry) => entry.name === 'ccswitch')) {
243
317
  throw new Error('CCSwitch extension did not load')
244
318
  }
245
- await request('prompt', { message: options.message })
319
+ if (options.complexity && !options.piArgs.includes('--thinking')) {
320
+ const assessment = options.complexity === 'auto'
321
+ ? assessComplexity(options.prompt || '', options.fileText ?? options.message, options.images?.length || 0)
322
+ : { level: options.complexity, reason: 'explicit-complexity' }
323
+ await request('set_thinking_level', { level: assessment.level })
324
+ log(`complexity=${assessment.level} reason=${assessment.reason} images=${options.images?.length || 0}`)
325
+ }
326
+ await request('prompt', { message: options.message, ...(options.images?.length ? { images: options.images } : {}) })
246
327
  while (!terminal && !protocolError && !closed) await new Promise(resolveWait => setTimeout(resolveWait, 10))
247
328
  if (protocolError) {
248
329
  closeStdin()
@@ -282,8 +363,10 @@ export async function main(argv = process.argv.slice(2)) {
282
363
  try { options = parseArgs(argv) } catch (error) { process.stderr.write(`pi-ccswitch-run: ${error.message}\n${usage}\n`); return EXIT.CONFIG }
283
364
  if (options.help) { process.stdout.write(`${usage}\n`); return EXIT.SUCCESS }
284
365
  try {
285
- const fileText = await readTextFiles(options.files)
286
- const result = await run({ ...options, message: buildMessage(fileText, options.prompt) })
366
+ const attached = await readAttachments(options.files)
367
+ const fileText = attached.fileText
368
+ const images = await prepareImages(attached.images)
369
+ const result = await run({ ...options, fileText, images, message: buildMessage(fileText, options.prompt) })
287
370
  if (result.text) process.stdout.write(`${result.text}\n`)
288
371
  if (result.error) process.stderr.write(`pi-ccswitch-run: ${result.error}\n`)
289
372
  return result.code