pi-ccswitch-auto-switch 0.3.5 → 0.3.7

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
@@ -130,7 +130,7 @@ CCS v0.3.5 ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
130
130
  - `⛔0`: no manually disabled models.
131
131
  - `🔄3`: **本次 pi session 成功切换的模型次数**(衡量扩展有效程度)。`/new`、`/fork`、`/resume` 等新 session 开始时归零;但**失败记录与冷却状态不重置**(它们是物理事实,跨 session 保留)。累计切换数(`state.switches`)与最近 20 条切换日志会持久化到状态文件,可在 `/ccswitch` 面板和 `/ccswitch-test` 中查看。
132
132
  - `provider/model-id`: 当前实际生效的模型(`provider/id`,切换后立即更新,长名自动截断)。
133
- - During a switch, `CCS ↻2/5 provider/model` means the second of at most five attempts is being made.
133
+ - During a switch, `CCS ↻2 provider/model` means this is the second switch attempt of the current round. Attempts are only bounded by the round time window, not a fixed count; a round fails only once every candidate has been tried.
134
134
 
135
135
  四个状态(健康/冷却/禁用/切换)即使为 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.
136
136
 
@@ -151,6 +151,20 @@ The state machine waits for Pi's native retry cycle to settle before switching.
151
151
 
152
152
  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.
153
153
 
154
+ ### Modality precheck (automatic switch to a multimodal model for images)
155
+
156
+ When building a provider request, Pi silently handles images according to the current model's `input` capabilities: if the model does not support images (its `input` does not include `image`), Pi replaces the image with a text placeholder `(image omitted: model does not support images)` — **the request does not fail**, so the reactive failover path never fires and the model just answers "I can't see the image", which is useless for OCR tasks.
157
+
158
+ CCSwitch therefore prechecks **before the request is sent**, instead of waiting for a failure:
159
+
160
+ - 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;
161
+ - 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;
162
+ - Only models that **explicitly** support images (`input` includes `image`) are considered; models with missing metadata are never assumed to support images;
163
+ - The switch reason is recorded as `modality` and counted in both the session and lifetime switch counters;
164
+ - 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).
165
+
166
+ The modality precheck applies only in interactive TUI/RPC sessions (the same scope as failover); print/json modes only monitor and never switch.
167
+
154
168
  ## Data and privacy
155
169
 
156
170
  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`.
package/README.zh-CN.md CHANGED
@@ -118,7 +118,7 @@ CCS v0.3.5 ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
118
118
  - `⛔0`:没有被手动禁用的模型。
119
119
  - `🔄3`:**本次 pi session 成功切换的模型次数**(衡量扩展有效程度,0 时也显示图标)。`/new`、`/fork`、`/resume` 等新 session 开始时归零;但**失败记录与冷却状态不重置**(它们是物理事实,跨 session 保留)。累计切换数(`state.switches`)与最近 20 条切换日志会持久化到状态文件,可在 `/ccswitch` 面板和 `/ccswitch-test` 中查看。
120
120
  - `provider/model-id`:当前实际生效的模型(`provider/id`,切换后立即更新,长名自动截断)。
121
- - 切换中出现 `CCS ↻2/5 provider/model`,表示正在进行最多 5 次尝试中的第 2 次。
121
+ - 切换中出现 `CCS ↻2 provider/model`,表示这是本轮的第 2 次切换尝试。切换次数只受本轮时间窗口限制,不再有固定次数上限;只有当所有候选都尝试过仍无可用模型时才会判定本轮失败。
122
122
 
123
123
  四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中;全部健康时仍显示零计数,例如 `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、受影响模型数、底层熔断记录数、本 session 切换数与累计切换数。
124
124
 
@@ -139,6 +139,20 @@ CCS v0.3.5 ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
139
139
 
140
140
  冷却时间会指数增长但有上限。发生内容审查时,插件只在审查故障转移链中避开已标记系列,普通限流、网络或模型配置故障仍可选择这些模型。上下文溢出时只会选择上下文窗口更大的模型;带图片的请求不会切到明确仅支持文本的模型。无法归类的异常按单模型故障处理并正常切换。用户主动取消是唯一不会触发故障转移的异常终止;看门狗取消会记录为超时。
141
141
 
142
+ ### 模态预检(OCR / 图片请求自动切换多模态模型)
143
+
144
+ Pi 在把请求发给 Provider 时,会根据当前模型的 `input` 能力静默处理图片:如果模型不支持图片(`input` 不含 `image`,即非多模态模型),Pi 会把图片替换成一段文本提示 `(image omitted: model does not support images)` 再发送——**请求不会报错**,因此普通的“失败后切换”永远不会触发,模型只会回答“看不到图片”,OCR 结果拿不到。
145
+
146
+ 插件因此在**发送前主动预检**,不等失败:
147
+
148
+ - **用户输入带图片时**(`input` 事件含 `images`),如果当前模型不支持图片,立即切换到健康的多模态候选(`input` 明确含 `image`)再处理,图片被保留;
149
+ - **工具执行返回图片时**(如 `read` 工具读取图片文件,工具结果含 `image` content),同样在下一轮 LLM 调用前切到多模态模型,确保图片不被剥除;
150
+ - 候选只选**明确声明**支持图片的模型(`input` 含 `image`);元数据缺失的模型不冒险选择;
151
+ - 切换原因记为 `modality`,计入本 session 切换数与累计切换;
152
+ - 如果没有可用的多模态候选,则通知用户并保持原模型继续(Pi 会照常剥图并附带提示)。
153
+
154
+ 模态预检只在 TUI/RPC 交互模式下生效(与故障转移一致);print/json 模式只监控不切换。
155
+
142
156
  ## 数据与隐私
143
157
 
144
158
  健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息、模型系列审查约束和脱敏/截断的错误摘要;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
package/candidates.ts CHANGED
@@ -75,6 +75,17 @@ export function summarizeCandidateHealth(models: ModelRef[], health: HealthState
75
75
  return { total: models.length, healthy, cooling, disabled, breakerRecords }
76
76
  }
77
77
 
78
+ /**
79
+ * 模态预检候选:明确声明支持图片输入的健康模型,按优先级排序。
80
+ * 用于当前模型不支持图片但输入/工具结果带图时的主动切换。
81
+ * 只选择 input 元数据明确包含 'image' 的模型,元数据缺失的模型不冒险选择。
82
+ */
83
+ export function multimodalCandidates(candidates: ModelRef[], current: ModelRef | undefined, health: HealthState, now = Date.now()): ModelRef[] {
84
+ return candidates
85
+ .filter(model => model.input?.includes('image') && !blocked(model, health, now) && !(current && modelKey(model) === modelKey(current)))
86
+ .sort((a, b) => score(a, current, health) - score(b, current, health))
87
+ }
88
+
78
89
  export function chooseCandidate(models: ModelRef[], options: CandidateOptions): ModelRef | undefined {
79
90
  const current = options.current
80
91
  const candidates = models.filter(model => {
package/index.ts CHANGED
@@ -5,15 +5,14 @@ import { dirname, join } from 'node:path'
5
5
  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
- import { candidateSnapshot, effectiveCandidates, chooseCandidate, modelFamily, summarizeCandidateHealth } from './candidates.ts'
8
+ import { candidateSnapshot, effectiveCandidates, chooseCandidate, multimodalCandidates, modelFamily, summarizeCandidateHealth } from './candidates.ts'
9
9
  import { HealthStore, endpointKey, modelKey, type HealthState } from './health.ts'
10
10
 
11
11
  const FIRST_RESPONSE_TIMEOUT = 90_000
12
12
  const STREAM_IDLE_TIMEOUT = 120_000
13
- const MAX_ATTEMPTS = 5
14
13
  const ROUND_LIMIT = 8 * 60_000
15
14
  const RPC_PROTOCOL_VERSION = 1
16
- const EXTENSION_VERSION = '0.3.5'
15
+ const EXTENSION_VERSION = '0.3.6'
17
16
  // 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
18
17
  const ENDPOINT_FAIL_THRESHOLD = 3
19
18
 
@@ -41,7 +40,7 @@ interface Round {
41
40
  endpointFails?: EndpointFailTracker
42
41
  /** 本轮内容审查故障转移中必须避开的模型系列(包含持久化学到的约束)。 */
43
42
  avoidFamilies?: Set<string>
44
- /** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“达到上限” */
43
+ /** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“超过本轮时间限制” */
45
44
  lastSwitchAt?: number
46
45
  }
47
46
 
@@ -60,6 +59,19 @@ function resolveModel(ctx: ExtensionContext, provider: string, id: string, previ
60
59
  return refs.find(model => model?.provider === provider && model.id === id) ?? { provider, id }
61
60
  }
62
61
 
62
+ /**
63
+ * 判断工具执行结果是否包含图片内容(如 read 工具读取图片文件后返回的
64
+ * { content: [{ type: 'image', ... }] } 结构)。递归查找,兼容各种 result 形状。
65
+ */
66
+ function resultContainsImage(result: unknown): boolean {
67
+ if (Array.isArray(result)) return result.some(part => resultContainsImage(part))
68
+ if (!result || typeof result !== 'object') return false
69
+ const record = result as Record<string, unknown>
70
+ if (record.type === 'image') return true
71
+ if (Array.isArray(record.content)) return record.content.some(part => resultContainsImage(part))
72
+ return false
73
+ }
74
+
63
75
  export default function (pi: ExtensionAPI) {
64
76
  const health = new HealthStore()
65
77
  let round: Round | undefined
@@ -91,7 +103,7 @@ export default function (pi: ExtensionAPI) {
91
103
  const activeModel = round?.model ?? ctx.model
92
104
  // 恒显完整状态:健康/总数 · 冷却 · 禁用 · 本session切换 · 当前模型(均为 0 时也显示,便于确认扩展在监控中)
93
105
  const prefix = `CCS v${EXTENSION_VERSION}`
94
- const plain = round?.phase === 'switching' ? `${prefix} ↻${round.attempts}/${MAX_ATTEMPTS} ${modelTag(round.model)}` :
106
+ const plain = round?.phase === 'switching' ? `${prefix} ↻${round.attempts} ${modelTag(round.model)}` :
95
107
  round?.phase === 'exhausted' ? `${prefix} ⏸切换停止 ${modelTag(round.model ?? ctx.model)}` :
96
108
  `${prefix} ✓${counts.healthy}/${counts.total} · ⏳${counts.cooling} · ⛔${counts.disabled}${switchSuffix()} · ${modelTag(activeModel)}`
97
109
  const theme = ctx.ui.theme
@@ -185,11 +197,55 @@ export default function (pi: ExtensionAPI) {
185
197
  notify(ctx, `CCSwitch:自动切换停止(${reason}),请用 /ccswitch 查看详情`, 'error')
186
198
  status(ctx)
187
199
  }
200
+ /**
201
+ * 模态预检主动切换:当前模型不支持图片但本轮输入/工具结果带图时,
202
+ * 主动切到一个健康的多模态候选(不等失败)。无候选时通知并保持原模型。
203
+ */
204
+ const proactiveModalitySwitch = async (ctx: ExtensionContext, round: Round): Promise<void> => {
205
+ const current = round.model ?? ctx.model
206
+ if (!current || !canRetry(ctx) || round.phase !== 'monitoring') return
207
+ if (current.input?.includes('image')) return
208
+ const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
209
+ const next = multimodalCandidates(candidates, current, health.snapshot)[0]
210
+ if (!next) {
211
+ await health.log(`modality precheck: no multimodal candidate, staying on ${modelKey(current)}`)
212
+ notify(ctx, 'CCSwitch:当前模型不支持图片,且没有可用的多模态候选模型,已保持原模型', 'warning')
213
+ status(ctx)
214
+ return
215
+ }
216
+ const previousModel = current
217
+ const set = await pi.setModel(next).catch(() => false)
218
+ if (!set) {
219
+ await health.log(`modality precheck: Pi refused model selection ${modelKey(next)}`)
220
+ notify(ctx, `CCSwitch:多模态候选 ${modelKey(next)} 切换失败,已保持原模型`, 'warning')
221
+ status(ctx)
222
+ return
223
+ }
224
+ round.model = next
225
+ round.lastSwitchAt = Date.now()
226
+ sessionSwitches += 1
227
+ health.recordSwitch(modelKey(previousModel), modelKey(next), 'modality')
228
+ await health.flush()
229
+ try {
230
+ pi.appendEntry?.('ccswitch-switch', {
231
+ protocolVersion: RPC_PROTOCOL_VERSION,
232
+ roundId: round.id,
233
+ from: modelKey(previousModel),
234
+ to: modelKey(next),
235
+ reason: 'modality',
236
+ sessionSwitches,
237
+ })
238
+ } catch { /* appendEntry 失败不影响切换 */ }
239
+ await health.log(`modality precheck switch: ${modelKey(previousModel)} -> ${modelKey(next)}`)
240
+ notify(ctx, `CCSwitch:检测到图片输入,已切换至多模态模型 ${modelKey(next)}`, 'info')
241
+ status(ctx)
242
+ }
243
+
188
244
  const failover = async (ctx: ExtensionContext) => {
189
245
  if (!round || !round.observation || !round.model || !canRetry(ctx)) return
190
246
  // 窗口从上一次成功切换(或本轮开始)起算:供应商内部重试耗时不应消耗整轮限额
191
247
  const windowStart = Math.max(round.startedAt, round.lastSwitchAt ?? 0)
192
- if (round.attempts >= MAX_ATTEMPTS || Date.now() - windowStart >= ROUND_LIMIT) return exhaust(ctx, '达到本轮切换上限')
248
+ if (Date.now() - windowStart >= ROUND_LIMIT) return exhaust(ctx, '超过本轮时间限制')
193
249
  const classification = classifyFailure(round.observation)
194
250
  if (round.observation.aborted && !round.observation.watchdog) { round.phase = 'idle'; clearWatchdog(); status(ctx); return }
195
251
  round.phase = 'switching'
@@ -237,14 +293,14 @@ export default function (pi: ExtensionAPI) {
237
293
  }
238
294
  round.model = next
239
295
  round.phase = 'redispatching'
240
- // 刷新本轮切换时间窗:成功切换后重新起算 ROUND_LIMIT,避免长重试轮被误判为“达到上限”
296
+ // 刷新本轮切换时间窗:成功切换后重新起算 ROUND_LIMIT,避免长重试轮被误判为“超过时间限制”
241
297
  round.lastSwitchAt = Date.now()
242
298
  // 本次 session 成功切换计数 + 持久化累计/日志(衡量扩展有效程度)
243
299
  sessionSwitches += 1
244
300
  const fromKey = key(previousModel ?? ctx.model)
245
301
  health.recordSwitch(fromKey ?? '', modelKey(next), classification.kind)
246
302
  await health.flush()
247
- notify(ctx, `CCSwitch:已切换至 ${modelKey(next)}(${round.attempts}/${MAX_ATTEMPTS})`, 'info')
303
+ notify(ctx, `CCSwitch:已切换至 ${modelKey(next)}(第${round.attempts}次切换)`, 'info')
248
304
  // 触发 TUI 底栏/界面重绘:appendEntry 会发出 entry_appended 事件进入 session.subscribe 流,
249
305
  // interactive-mode 收到后执行 footer.invalidate() + requestRender(),footer 从 session.state.model 重新读取,
250
306
  // 从而让右下角模型名同步显示新模型(setModel 只改 state,不直接触发 footer 刷新)。
@@ -284,12 +340,15 @@ export default function (pi: ExtensionAPI) {
284
340
  await health.log('extension started')
285
341
  })
286
342
  pi.on('session_shutdown', async (_event, ctx) => { clearWatchdog(); ctx.ui.setWorkingMessage(); if (sessionSwitches > 0) await health.log(`session ended with ${sessionSwitches} successful switches`); await health.flush() })
287
- pi.on('input', (event, ctx) => {
343
+ pi.on('input', async (event, ctx) => {
288
344
  if (event.source === 'extension') return { action: 'continue' }
289
345
  clearWatchdog()
290
346
  lastStatus = {}
291
347
  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 }
292
348
  status(ctx)
349
+ // 模态预检:输入带图片但当前模型不支持图片(非多模态)→ 主动切换到多模态模型,
350
+ // 避免 Pi 静默剥图后模型只回答“看不到图片”(此类情况不会触发 failover)。
351
+ if (event.images?.length && round.phase === 'monitoring') await proactiveModalitySwitch(ctx, round)
293
352
  return { action: 'continue' }
294
353
  })
295
354
  pi.on('before_provider_request', (_event, ctx) => { lastStatus = {}; if (round) { round.inTool = false; armWatchdog(ctx, FIRST_RESPONSE_TIMEOUT, round.id) } })
@@ -310,7 +369,13 @@ export default function (pi: ExtensionAPI) {
310
369
  }
311
370
  })
312
371
  pi.on('tool_execution_start', () => { if (round) { round.hadTool = true; round.inTool = true; clearWatchdog() } })
313
- pi.on('tool_execution_end', () => { if (round) round.inTool = false })
372
+ pi.on('tool_execution_end', async (_event, ctx) => {
373
+ if (!round) return
374
+ round.inTool = false
375
+ // 工具结果含图片(如 read 图片文件)且当前模型不支持 → 主动切换多模态模型,
376
+ // 确保下一轮 LLM 调用使用新模型,工具结果中的图片不被 Pi 静默剥除。
377
+ if (round.phase === 'monitoring' && resultContainsImage(_event.result)) await proactiveModalitySwitch(ctx, round)
378
+ })
314
379
  pi.on('turn_end', async (event, ctx) => {
315
380
  const message = event.message
316
381
  if (message?.role !== 'assistant' || !round) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/runner.mjs CHANGED
@@ -189,7 +189,7 @@ export async function run(options, runtime = {}) {
189
189
  if (entry.data?.protocolVersion !== 1) return
190
190
  if (entry.type === 'ccswitch-switch') {
191
191
  retryPending = true
192
- log(`switch ${entry.data.from || '?'} → ${entry.data.to || '?'} (${entry.data.attempts ?? '?'}/5)`)
192
+ log(`switch ${entry.data.from || '?'} → ${entry.data.to || '?'} (${entry.data.attempts ?? '?'})`)
193
193
  } else if (entry.type === 'ccswitch-complete') {
194
194
  terminal = { kind: 'complete', data: entry.data }
195
195
  } else if (entry.type === 'ccswitch-exhausted') {