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 +1 -0
- package/README.zh-CN.md +1 -0
- package/candidates.ts +4 -2
- package/classify.ts +3 -1
- package/health.ts +10 -0
- package/index.ts +55 -23
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
257
|
-
const
|
|
258
|
-
failTracker.failed.set(
|
|
259
|
-
if (
|
|
260
|
-
failTracker.isolated.add(
|
|
261
|
-
await health.log(`
|
|
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
|
-
|
|
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
|
|
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 才偶尔纠正。
|