pi-ccswitch-auto-switch 0.3.9 → 0.3.11

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
@@ -39,6 +39,7 @@ All knobs below are optional and have sensible defaults:
39
39
  | --- | --- | --- | --- |
40
40
  | `PI_CODING_AGENT_DIR` | Extension state | `~/.pi/agent` | Where health state, logs, and failure reports are stored. Only relevant if you relocated your Pi agent directory; the extension follows Pi's own convention. |
41
41
  | `PI_BIN` | Headless runner only | `pi` on `PATH` | Must point to the real `pi` executable. Legacy self-references to `pi-ccswitch-run` or `ccswitch-run` are ignored with a warning and fall back to `pi`; other invalid commands remain configuration errors. |
42
+ | `CCSWITCH_ROUND_LIMIT_MS` | Failover | `480000` (8 minutes) | Time limit for one failover round, in milliseconds. The window restarts on each successful switch; when exceeded, switching stops and the report lists every tried model with its last error. |
42
43
  | Model scope (`/model` etc.) | Failover candidates | Full registry | When Pi has an active model scope, failover only considers models inside that scope; otherwise the full registry is used. The status bar shows which source is active. |
43
44
  | `baseUrl` metadata | Endpoint isolation | provider key | Endpoint-level platform isolation groups models by `baseUrl` (provided by CC Switch `3.20+`). Without it, isolation degrades to provider-level grouping, which still works. |
44
45
 
package/README.zh-CN.md CHANGED
@@ -37,6 +37,7 @@
37
37
  | --- | --- | --- | --- |
38
38
  | `PI_CODING_AGENT_DIR` | 扩展状态 | `~/.pi/agent` | 健康状态、日志与失败报告的存放目录;仅在你迁移了 Pi agent 目录时需要,插件跟随 Pi 自身约定。 |
39
39
  | `PI_BIN` | 仅 headless runner | `PATH` 中的 `pi` | 必须指向真实 `pi` 可执行文件。历史自引用值 `pi-ccswitch-run` 或 `ccswitch-run` 会警告并降级到 `pi`;其他非法命令仍按配置错误处理。 |
40
+ | `CCSWITCH_ROUND_LIMIT_MS` | 故障转移 | `480000`(8 分钟) | 本轮故障转移的时间限制(毫秒)。窗口从本轮开始或最后一次成功切换起算;超过后停止切换并报告已试模型及最后错误。 |
40
41
  | 模型 scope(`/model` 等) | 故障转移候选 | 全量注册表 | Pi 启用了 model scope 时,只在 scope 内模型间切换;否则使用全量注册表。状态栏会显示当前数据源。 |
41
42
  | `baseUrl` 元数据 | 端点隔离 | provider key | 端点级平台隔离按 `baseUrl` 分组模型(由 CC Switch `3.20+` 提供);缺失时退化为 provider 级分组,仍然可用。 |
42
43
 
package/candidates.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FailureClass, ModelRef, ScopedModel } from './types.ts'
2
- import { endpointKey, modelKey, type HealthState } from './health.ts'
2
+ import { endpointKey, modelKey, platformKey, type HealthState } from './health.ts'
3
3
 
4
4
  export interface CandidateOptions {
5
5
  current?: ModelRef
@@ -90,7 +90,9 @@ export function chooseCandidate(models: ModelRef[], options: CandidateOptions):
90
90
  const current = options.current
91
91
  const candidates = models.filter(model => {
92
92
  if (options.tried.has(modelKey(model)) || blocked(model, options.health)) return false
93
- if (options.avoidEndpoints?.has(endpointKey(model))) return false
93
+ // avoidEndpoints 同时支持 endpointKey(含 provider,旧语义)与 platformKey(仅 BaseURL)两种 key:
94
+ // 本轮隔离按平台(platformKey)归组,同平台的 provider 副本一并跳过
95
+ if (options.avoidEndpoints?.has(endpointKey(model)) || options.avoidEndpoints?.has(platformKey(model))) return false
94
96
  if (options.avoidFamilies?.has(modelFamily(model))) return false
95
97
  if (!supportsRequiredInputs(model, options.requiredInputs)) return false
96
98
  if (options.failureKind === 'context_overflow' && current && (model.contextWindow ?? 0) <= (current.contextWindow ?? 0)) return false
package/classify.ts CHANGED
@@ -8,7 +8,9 @@ export interface Classification {
8
8
  }
9
9
 
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
- const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|monthly.?limit/i
11
+ // 配额/余额类失败(scope provider)。含中文供应商报错:用户积分不足、余额不足、欠费等,
12
+ // 这些通常不携带英文关键词,漏分类会落入 unknown(仅 2min 模型级冷却),导致死 provider 每轮重试。
13
+ const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|monthly.?limit|积分不足|余额不足|欠费|请充值|额度.{0,6}(?:用完|耗尽|不足|已用)|无可用额度/i
12
14
  const context = /context.?window|context.?length|too many tokens|prompt is too long|input is too long|token limit/i
13
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
14
16
  const overloaded = /overload|server busy|service unavailable|temporarily unavailable|capacity|upstream|bad gateway/i
package/health.ts CHANGED
@@ -67,6 +67,16 @@ export function endpointKey(model: ModelRef): string {
67
67
  return createHash('sha256').update(`${model.provider}\0${source}`).digest('hex').slice(0, 20)
68
68
  }
69
69
 
70
+ /**
71
+ * 同平台标识:仅按 BaseURL 归组(不含 provider 名)。b-ai / b-ai-copy / b-ai-copy-copy 这类
72
+ * 同一上游平台的多个 provider 副本会得到相同 key,用于本轮内同平台隔离。
73
+ * 注意:与 endpointKey(含 provider,用于跨轮健康台账)刻意不同——不同的 API key
74
+ * 有独立的配额/限流,跨轮冷却不应互相波及。
75
+ */
76
+ export function platformKey(model: ModelRef): string {
77
+ return model.baseUrl ? safeEndpoint(model.baseUrl) : `provider:${model.provider}`
78
+ }
79
+
70
80
  function safeEndpoint(value: string): string {
71
81
  try {
72
82
  const url = new URL(value)
package/index.ts CHANGED
@@ -6,13 +6,18 @@ 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, type HealthState } from './health.ts'
9
+ import { HealthStore, endpointKey, modelKey, platformKey, 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 ROUND_LIMIT = 8 * 60_000
13
+ /** 本轮时间限制默认值;可用 CCSWITCH_ROUND_LIMIT_MS 覆盖(毫秒)。 */
14
+ const ROUND_LIMIT_DEFAULT = 8 * 60_000
15
+ const roundLimitMs = (): number => {
16
+ const override = Number(process.env.CCSWITCH_ROUND_LIMIT_MS)
17
+ return Number.isFinite(override) && override > 0 ? override : ROUND_LIMIT_DEFAULT
18
+ }
14
19
  const RPC_PROTOCOL_VERSION = 1
15
- const EXTENSION_VERSION = '0.3.8'
20
+ const EXTENSION_VERSION = '0.3.11'
16
21
  // 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
17
22
  const ENDPOINT_FAIL_THRESHOLD = 3
18
23
 
@@ -42,6 +47,8 @@ interface Round {
42
47
  avoidFamilies?: Set<string>
43
48
  /** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“超过本轮时间限制” */
44
49
  lastSwitchAt?: number
50
+ /** 本轮内每个已试模型的最后错误消息(key 为 modelKey),用于 exhaust 报告 */
51
+ errors: Map<string, string>
45
52
  }
46
53
 
47
54
  function key(model: ModelRef | undefined): string | undefined { return model && modelKey(model) }
@@ -79,6 +86,15 @@ export default function (pi: ExtensionAPI) {
79
86
  let lastStatus: { status?: number, retryAfterMs?: number } = {}
80
87
  // 本次 pi session 内成功切换的模型数量(用于衡量插件的有效程度)
81
88
  let sessionSwitches = 0
89
+ // pi.setModel() 内部会 emit model_select 事件(pi core 源码 _emitModelSelect(model, previous, "set"))。
90
+ // 扩展自己触发的切换必须标记 selfSwitching,否则 model_select 处理器会清空本轮故障转移状态
91
+ // (tried/attempts/endpointFails),导致:a) exhaust 报告“已尝试”为空;b) 端点隔离被清零后
92
+ // 同平台候选被逐个重试,烧光 ROUND_LIMIT → “超过本轮时间限制”。
93
+ let selfSwitching = false
94
+ const setModelInternally = async (model: ModelRef): Promise<boolean> => {
95
+ selfSwitching = true
96
+ try { return await pi.setModel(model) } catch { return false } finally { selfSwitching = false }
97
+ }
82
98
 
83
99
  const clearWatchdog = () => { if (timer) clearTimeout(timer); timer = undefined }
84
100
  const armWatchdog = (ctx: ExtensionContext, ms: number, roundId: number) => {
@@ -175,10 +191,13 @@ export default function (pi: ExtensionAPI) {
175
191
  round.phase = 'exhausted'
176
192
  clearWatchdog()
177
193
  ctx.ui.setWorkingMessage()
178
- 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> 重新激活。`)
194
+ // 报告列出本轮每个已试模型及其最后错误,便于直接定位是哪个 provider/模型、什么原因
195
+ const triedLines = [...round.tried].map(item => {
196
+ const error = round!.errors.get(item)
197
+ return error ? `- ${item} — ${error}` : `- ${item}`
198
+ })
199
+ await health.report(`# CCSwitch 自动故障转移失败\n\n时间:${new Date().toISOString()}\n原因:${reason}\n\n已尝试:\n${triedLines.join('\n')}\n\n可使用 /ccswitch status 查看状态,/ccswitch reactivate <provider/model|all> 重新激活。`)
179
200
  await health.log(`round exhausted: ${reason}; tried=${[...round.tried].join(',')}`)
180
- // RPC runner relies on an explicit terminal signal instead of guessing from
181
- // agent_settled. Keep this RPC-only so ordinary TUI transcripts stay quiet.
182
201
  if (ctx.mode === 'rpc') {
183
202
  const lastFailure = round.observation ? classifyFailure(round.observation).kind : undefined
184
203
  try {
@@ -190,11 +209,14 @@ export default function (pi: ExtensionAPI) {
190
209
  model: key(round.model),
191
210
  attempts: round.attempts,
192
211
  tried: [...round.tried],
212
+ errors: Object.fromEntries(round.errors),
193
213
  sessionSwitches,
194
214
  })
195
215
  } catch { /* reporting must not prevent the regular exhausted path */ }
196
216
  }
197
- notify(ctx, `CCSwitch:自动切换停止(${reason}),请用 /ccswitch 查看详情`, 'error')
217
+ const currentKey = key(round.model)
218
+ const currentError = currentKey ? round.errors.get(currentKey) : undefined
219
+ notify(ctx, `CCSwitch:自动切换停止(${reason})${currentError ? `;当前 ${currentKey}:${currentError}` : ''},请用 /ccswitch 查看详情`, 'error')
198
220
  status(ctx)
199
221
  }
200
222
  /**
@@ -214,7 +236,7 @@ export default function (pi: ExtensionAPI) {
214
236
  return
215
237
  }
216
238
  const previousModel = current
217
- const set = await pi.setModel(next).catch(() => false)
239
+ const set = await setModelInternally(next)
218
240
  if (!set) {
219
241
  await health.log(`modality precheck: Pi refused model selection ${modelKey(next)}`)
220
242
  notify(ctx, `CCSwitch:多模态候选 ${modelKey(next)} 切换失败,已保持原模型`, 'warning')
@@ -243,30 +265,35 @@ export default function (pi: ExtensionAPI) {
243
265
 
244
266
  const failover = async (ctx: ExtensionContext) => {
245
267
  if (!round || !round.observation || !round.model || !canRetry(ctx)) return
246
- // 窗口从上一次成功切换(或本轮开始)起算:供应商内部重试耗时不应消耗整轮限额
247
- const windowStart = Math.max(round.startedAt, round.lastSwitchAt ?? 0)
248
- if (Date.now() - windowStart >= ROUND_LIMIT) return exhaust(ctx, '超过本轮时间限制')
249
268
  const classification = classifyFailure(round.observation)
250
269
  if (round.observation.aborted && !round.observation.watchdog) { round.phase = 'idle'; clearWatchdog(); status(ctx); return }
251
270
  round.phase = 'switching'
252
- round.tried.add(modelKey(round.model))
253
- // 跟踪同端点失败:BaseURL 相同的模型同属一个端点平台,连续失败到阈值后隔离整个端点,
254
- // 避免同一平台下的多个模型逐个试错(它们往往共享同一故障根源)
271
+ const failedKey = modelKey(round.model)
272
+ round.tried.add(failedKey)
273
+ round.errors.set(failedKey, round.observation.message ?? classification.kind)
274
+ // 跟踪同平台失败:按 BaseURL 归组(platformKey 不含 provider 名),同一平台下的多个
275
+ // provider/模型逐个试错会共享同一故障根源,连续失败到阈值后隔离整个平台,
276
+ // 避免它们烧光本轮切换时间(每个候选最多占用 FIRST_RESPONSE_TIMEOUT)
255
277
  const failTracker = round.endpointFails ??= { failed: new Map(), isolated: new Set() }
256
- const ep = endpointKey(round.model)
257
- const epFails = (failTracker.failed.get(ep) ?? 0) + 1
258
- failTracker.failed.set(ep, epFails)
259
- if (epFails >= ENDPOINT_FAIL_THRESHOLD) {
260
- failTracker.isolated.add(ep)
261
- await health.log(`endpoint ${ep} failed ${epFails} times this round, isolating endpoint`)
278
+ const platform = platformKey(round.model)
279
+ const platformFails = (failTracker.failed.get(platform) ?? 0) + 1
280
+ failTracker.failed.set(platform, platformFails)
281
+ if (platformFails >= ENDPOINT_FAIL_THRESHOLD) {
282
+ failTracker.isolated.add(platform)
283
+ await health.log(`platform ${platform} failed ${platformFails} times this round, isolating platform`)
262
284
  }
263
285
  if (classification.kind === 'content_policy') {
264
286
  health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
265
287
  round.avoidFamilies ??= new Set()
266
288
  for (const family of activePolicyFamilies(health.snapshot)) round.avoidFamilies.add(family)
267
289
  }
268
- 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)
290
+ // 失败必须先落台账再做时限判断:否则本轮最后一次失败不会进健康状态(无冷却),
291
+ // 下一轮还会重试同一个刚失败的模型/provider
292
+ if (!classification.roundOnly && classification.scope) health.recordFailure(classification.scope, classification.scope === 'model' ? failedKey : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model), classification.kind, round.observation.message, classification.retryAfterMs)
269
293
  await health.flush()
294
+ // 窗口从上一次成功切换(或本轮开始)起算:供应商内部重试耗时不应消耗整轮限额
295
+ const windowStart = Math.max(round.startedAt, round.lastSwitchAt ?? 0)
296
+ if (Date.now() - windowStart >= roundLimitMs()) return exhaust(ctx, '超过本轮时间限制')
270
297
  await refresh(ctx)
271
298
  const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
272
299
  const candidateOptions = () => ({
@@ -284,7 +311,7 @@ export default function (pi: ExtensionAPI) {
284
311
  round.attempts++
285
312
  ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
286
313
  const previousModel = round.model
287
- const set = await pi.setModel(next).catch(() => false)
314
+ const set = await setModelInternally(next)
288
315
  if (!set) {
289
316
  health.recordFailure('model', modelKey(next), 'model_config', 'Pi refused model selection')
290
317
  round.tried.add(modelKey(next)); await health.flush()
@@ -344,7 +371,7 @@ export default function (pi: ExtensionAPI) {
344
371
  if (event.source === 'extension') return { action: 'continue' }
345
372
  clearWatchdog()
346
373
  lastStatus = {}
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 }
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() }
348
375
  status(ctx)
349
376
  // 模态预检:输入带图片但当前模型不支持图片(非多模态)→ 主动切换到多模态模型,
350
377
  // 避免 Pi 静默剥图后模型只回答“看不到图片”(此类情况不会触发 failover)。
@@ -423,6 +450,11 @@ export default function (pi: ExtensionAPI) {
423
450
  await failover(ctx)
424
451
  })
425
452
  pi.on('model_select', (event, ctx) => {
453
+ // pi.setModel() 内部也会发出 model_select(pi core _emitModelSelect(source="set"))。
454
+ // 本扩展自己触发的切换绝不能清空本轮故障转移状态(tried/attempts/endpointFails/errors),
455
+ // 否则:a) exhaust 报告“已尝试”为空,用户看不到哪些 provider/model 失败过;
456
+ // b) 同平台隔离被清零后,同平台候选被逐个重试烧光时限 → “超过本轮时间限制”。
457
+ if (selfSwitching) return
426
458
  // 用户手动切换模型(TUI 模型选择器 / /model / 方向键循环)。让 ccs 跟随:
427
459
  // 更新“当前模型”并重置本轮故障转移状态,否则 round.model 停留在上一轮使用的模型,
428
460
  // 导致状态栏与后续 failover 都滞后于实际选择,直到下次 input 才偶尔纠正。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [