pi-ccswitch-auto-switch 0.3.5 → 0.3.6

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
@@ -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
@@ -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,7 +5,7 @@ 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
@@ -13,7 +13,7 @@ const STREAM_IDLE_TIMEOUT = 120_000
13
13
  const MAX_ATTEMPTS = 5
14
14
  const ROUND_LIMIT = 8 * 60_000
15
15
  const RPC_PROTOCOL_VERSION = 1
16
- const EXTENSION_VERSION = '0.3.5'
16
+ const EXTENSION_VERSION = '0.3.6'
17
17
  // 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
18
18
  const ENDPOINT_FAIL_THRESHOLD = 3
19
19
 
@@ -60,6 +60,19 @@ function resolveModel(ctx: ExtensionContext, provider: string, id: string, previ
60
60
  return refs.find(model => model?.provider === provider && model.id === id) ?? { provider, id }
61
61
  }
62
62
 
63
+ /**
64
+ * 判断工具执行结果是否包含图片内容(如 read 工具读取图片文件后返回的
65
+ * { content: [{ type: 'image', ... }] } 结构)。递归查找,兼容各种 result 形状。
66
+ */
67
+ function resultContainsImage(result: unknown): boolean {
68
+ if (Array.isArray(result)) return result.some(part => resultContainsImage(part))
69
+ if (!result || typeof result !== 'object') return false
70
+ const record = result as Record<string, unknown>
71
+ if (record.type === 'image') return true
72
+ if (Array.isArray(record.content)) return record.content.some(part => resultContainsImage(part))
73
+ return false
74
+ }
75
+
63
76
  export default function (pi: ExtensionAPI) {
64
77
  const health = new HealthStore()
65
78
  let round: Round | undefined
@@ -185,6 +198,50 @@ export default function (pi: ExtensionAPI) {
185
198
  notify(ctx, `CCSwitch:自动切换停止(${reason}),请用 /ccswitch 查看详情`, 'error')
186
199
  status(ctx)
187
200
  }
201
+ /**
202
+ * 模态预检主动切换:当前模型不支持图片但本轮输入/工具结果带图时,
203
+ * 主动切到一个健康的多模态候选(不等失败)。无候选时通知并保持原模型。
204
+ */
205
+ const proactiveModalitySwitch = async (ctx: ExtensionContext, round: Round): Promise<void> => {
206
+ const current = round.model ?? ctx.model
207
+ if (!current || !canRetry(ctx) || round.phase !== 'monitoring') return
208
+ if (current.input?.includes('image')) return
209
+ const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
210
+ const next = multimodalCandidates(candidates, current, health.snapshot)[0]
211
+ if (!next) {
212
+ await health.log(`modality precheck: no multimodal candidate, staying on ${modelKey(current)}`)
213
+ notify(ctx, 'CCSwitch:当前模型不支持图片,且没有可用的多模态候选模型,已保持原模型', 'warning')
214
+ status(ctx)
215
+ return
216
+ }
217
+ const previousModel = current
218
+ const set = await pi.setModel(next).catch(() => false)
219
+ if (!set) {
220
+ await health.log(`modality precheck: Pi refused model selection ${modelKey(next)}`)
221
+ notify(ctx, `CCSwitch:多模态候选 ${modelKey(next)} 切换失败,已保持原模型`, 'warning')
222
+ status(ctx)
223
+ return
224
+ }
225
+ round.model = next
226
+ round.lastSwitchAt = Date.now()
227
+ sessionSwitches += 1
228
+ health.recordSwitch(modelKey(previousModel), modelKey(next), 'modality')
229
+ await health.flush()
230
+ try {
231
+ pi.appendEntry?.('ccswitch-switch', {
232
+ protocolVersion: RPC_PROTOCOL_VERSION,
233
+ roundId: round.id,
234
+ from: modelKey(previousModel),
235
+ to: modelKey(next),
236
+ reason: 'modality',
237
+ sessionSwitches,
238
+ })
239
+ } catch { /* appendEntry 失败不影响切换 */ }
240
+ await health.log(`modality precheck switch: ${modelKey(previousModel)} -> ${modelKey(next)}`)
241
+ notify(ctx, `CCSwitch:检测到图片输入,已切换至多模态模型 ${modelKey(next)}`, 'info')
242
+ status(ctx)
243
+ }
244
+
188
245
  const failover = async (ctx: ExtensionContext) => {
189
246
  if (!round || !round.observation || !round.model || !canRetry(ctx)) return
190
247
  // 窗口从上一次成功切换(或本轮开始)起算:供应商内部重试耗时不应消耗整轮限额
@@ -284,12 +341,15 @@ export default function (pi: ExtensionAPI) {
284
341
  await health.log('extension started')
285
342
  })
286
343
  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) => {
344
+ pi.on('input', async (event, ctx) => {
288
345
  if (event.source === 'extension') return { action: 'continue' }
289
346
  clearWatchdog()
290
347
  lastStatus = {}
291
348
  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
349
  status(ctx)
350
+ // 模态预检:输入带图片但当前模型不支持图片(非多模态)→ 主动切换到多模态模型,
351
+ // 避免 Pi 静默剥图后模型只回答“看不到图片”(此类情况不会触发 failover)。
352
+ if (event.images?.length && round.phase === 'monitoring') await proactiveModalitySwitch(ctx, round)
293
353
  return { action: 'continue' }
294
354
  })
295
355
  pi.on('before_provider_request', (_event, ctx) => { lastStatus = {}; if (round) { round.inTool = false; armWatchdog(ctx, FIRST_RESPONSE_TIMEOUT, round.id) } })
@@ -310,7 +370,13 @@ export default function (pi: ExtensionAPI) {
310
370
  }
311
371
  })
312
372
  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 })
373
+ pi.on('tool_execution_end', async (_event, ctx) => {
374
+ if (!round) return
375
+ round.inTool = false
376
+ // 工具结果含图片(如 read 图片文件)且当前模型不支持 → 主动切换多模态模型,
377
+ // 确保下一轮 LLM 调用使用新模型,工具结果中的图片不被 Pi 静默剥除。
378
+ if (round.phase === 'monitoring' && resultContainsImage(_event.result)) await proactiveModalitySwitch(ctx, round)
379
+ })
314
380
  pi.on('turn_end', async (event, ctx) => {
315
381
  const message = event.message
316
382
  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.6",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [