pi-ccswitch-auto-switch 0.1.1 → 0.1.3

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
@@ -11,8 +11,10 @@ The extension observes real Pi requests, records sanitized health signals, and
11
11
  - Uses Pi's effective model registry; never reads CC Switch databases, Pi auth files, or API keys.
12
12
  - Provider-first circuit breakers for authentication, quota, billing, and rate-limit failures.
13
13
  - Endpoint circuit breakers for DNS, connection, server, and streaming failures.
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.
14
15
  - Model-only isolation for missing models and incompatible parameters.
15
16
  - Prefers a healthy model from another provider before considering a sibling model.
17
+ - Independent-provider semantics: a copied vendor (e.g. `b-ai-copy`) shares the BaseURL but is a distinct provider with its own endpoint key, so isolating one never blocks the other.
16
18
  - 90-second first-response and 120-second streaming-idle watchdogs.
17
19
  - Exponential cooldowns, `Retry-After` support, and one persisted half-open probe lease per provider.
18
20
  - Persistent, atomic, cross-process health state with error redaction and bounded logs.
@@ -66,15 +68,15 @@ Examples:
66
68
  ## Status bar
67
69
 
68
70
  ```text
69
- CCS ✓70/71 · ⏳1 · ⛔0
71
+ CCS ✓70/131 · ⏳61 · ⛔0
70
72
  ```
71
73
 
72
- - `✓70/71`: 70 healthy models out of 71 unique `provider/model` combinations in Pi's effective scope. Duplicate scoped entries are counted once.
73
- - `⏳1`: one model, provider, or endpoint health record is cooling down.
74
+ - `✓70/131`: 70 healthy models out of 131 unique `provider/model` combinations in Pi's effective scope. Duplicate scoped entries are counted once.
75
+ - `⏳61`: 61 models are currently affected by automatic model, provider, or endpoint cooldowns. One provider breaker can account for many affected models.
74
76
  - `⛔0`: no manually disabled models.
75
77
  - During a switch, `CCS ↻2/5 provider/model` means the second of at most five attempts is being made.
76
78
 
77
- When all unique models are healthy, the compact form is `CCS ✓71`. Use `/ccswitch` or `/ccswitch-test` to see Pi's raw scope entry count, the deduplicated model count, the healthy count, and the exact record behind a cooldown.
79
+ When all unique models are healthy, the compact form is `CCS ✓131`. 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.
78
80
 
79
81
  ## Failover behavior
80
82
 
package/README.zh-CN.md CHANGED
@@ -58,15 +58,15 @@ pi install npm:pi-ccswitch-auto-switch
58
58
  ## 状态栏
59
59
 
60
60
  ```text
61
- CCS ✓70/71 · ⏳1 · ⛔0
61
+ CCS ✓70/131 · ⏳61 · ⛔0
62
62
  ```
63
63
 
64
- - `✓70/71`:Pi 有效范围内共有 71 个去重后的 `provider/model` 组合,其中 70 个健康;重复的 scope 条目只计一次。
65
- - `⏳1`:有一条模型、Provider 或端点健康记录仍在冷却。
64
+ - `✓70/131`:Pi 有效范围内共有 131 个去重后的 `provider/model` 组合,其中 70 个健康;重复的 scope 条目只计一次。
65
+ - `⏳61`:有 61 个模型正受模型、Provider 或端点自动冷却影响;一条 Provider 熔断记录可能同时影响许多模型。
66
66
  - `⛔0`:没有被手动禁用的模型。
67
67
  - 切换中出现 `CCS ↻2/5 provider/model`,表示正在进行最多 5 次尝试中的第 2 次。
68
68
 
69
- 全部唯一模型都健康时会压缩显示为 `CCS ✓71`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、健康数,以及具体哪个记录正在冷却。
69
+ 全部唯一模型都健康时会压缩显示为 `CCS ✓131`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、受影响模型数和底层熔断记录数。
70
70
 
71
71
  ## 故障转移逻辑
72
72
 
package/candidates.ts CHANGED
@@ -6,6 +6,7 @@ export interface CandidateOptions {
6
6
  tried: Set<string>
7
7
  failureKind?: string
8
8
  health: HealthState
9
+ avoidEndpoints?: Set<string>
9
10
  }
10
11
 
11
12
  export interface CandidateSnapshot {
@@ -14,6 +15,18 @@ export interface CandidateSnapshot {
14
15
  sourceEntries: number
15
16
  }
16
17
 
18
+ export interface CandidateHealthSummary {
19
+ total: number
20
+ healthy: number
21
+ cooling: number
22
+ disabled: number
23
+ breakerRecords: number
24
+ }
25
+
26
+ function activeCooldown(record: HealthState['models'][string] | undefined, now: number): boolean {
27
+ return Boolean((record?.cooldownUntil && record.cooldownUntil > now) || (record?.leaseUntil && record.leaseUntil > now))
28
+ }
29
+
17
30
  function blocked(model: ModelRef, health: HealthState, now = Date.now()): boolean {
18
31
  const records = [health.models[modelKey(model)], health.providers[model.provider], health.endpoints[endpointKey(model)]]
19
32
  return records.some(record => Boolean(record?.disabled || (record?.cooldownUntil && record.cooldownUntil > now) || (record?.leaseUntil && record.leaseUntil > now)))
@@ -39,10 +52,30 @@ export function candidateSnapshot(scoped: readonly ScopedModel[], available: Mod
39
52
  }
40
53
  }
41
54
 
55
+ export function summarizeCandidateHealth(models: ModelRef[], health: HealthState, now = Date.now()): CandidateHealthSummary {
56
+ let healthy = 0
57
+ let cooling = 0
58
+ let disabled = 0
59
+ for (const model of models) {
60
+ const modelRecord = health.models[modelKey(model)]
61
+ if (modelRecord?.disabled) {
62
+ disabled += 1
63
+ continue
64
+ }
65
+ const records = [modelRecord, health.providers[model.provider], health.endpoints[endpointKey(model)]]
66
+ if (records.some(record => activeCooldown(record, now))) cooling += 1
67
+ else healthy += 1
68
+ }
69
+ const breakerRecords = [...Object.values(health.models), ...Object.values(health.providers), ...Object.values(health.endpoints)]
70
+ .filter(record => activeCooldown(record, now)).length
71
+ return { total: models.length, healthy, cooling, disabled, breakerRecords }
72
+ }
73
+
42
74
  export function chooseCandidate(models: ModelRef[], options: CandidateOptions): ModelRef | undefined {
43
75
  const current = options.current
44
76
  const candidates = models.filter(model => {
45
77
  if (options.tried.has(modelKey(model)) || blocked(model, options.health)) return false
78
+ if (options.avoidEndpoints?.has(endpointKey(model))) return false
46
79
  if (options.failureKind === 'context_overflow' && current && (model.contextWindow ?? 0) <= (current.contextWindow ?? 0)) return false
47
80
  return true
48
81
  })
package/index.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type { ExtensionAPI, ExtensionContext, FailureObservation, ModelRef } from './types.ts'
2
2
  import { classifyFailure, parseRetryAfter } from './classify.ts'
3
- import { candidateSnapshot, effectiveCandidates, chooseCandidate } from './candidates.ts'
3
+ import { candidateSnapshot, effectiveCandidates, chooseCandidate, summarizeCandidateHealth } from './candidates.ts'
4
4
  import { HealthStore, endpointKey, modelKey } from './health.ts'
5
5
 
6
6
  const FIRST_RESPONSE_TIMEOUT = 90_000
7
7
  const STREAM_IDLE_TIMEOUT = 120_000
8
8
  const MAX_ATTEMPTS = 5
9
9
  const ROUND_LIMIT = 8 * 60_000
10
+ // 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
11
+ const ENDPOINT_FAIL_THRESHOLD = 3
12
+
13
+ interface EndpointFailTracker {
14
+ failed: Map<string, number>
15
+ isolated: Set<string>
16
+ }
10
17
 
11
18
  type Phase = 'idle' | 'monitoring' | 'settled-error' | 'switching' | 'redispatching' | 'verifying' | 'exhausted'
12
19
  interface Round {
@@ -24,6 +31,7 @@ interface Round {
24
31
  cleanRetry: boolean
25
32
  observation?: FailureObservation
26
33
  model?: ModelRef
34
+ endpointFails?: EndpointFailTracker
27
35
  }
28
36
 
29
37
  function key(model: ModelRef | undefined): string | undefined { return model && modelKey(model) }
@@ -49,16 +57,11 @@ export default function (pi: ExtensionAPI) {
49
57
  const status = (ctx: ExtensionContext) => {
50
58
  const state = health.snapshot
51
59
  const { models } = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
52
- const now = Date.now()
53
- const disabled = Object.values(state.models).filter(item => item.disabled).length
54
- const cooling = [...Object.values(state.models), ...Object.values(state.providers), ...Object.values(state.endpoints)]
55
- .filter(item => item.cooldownUntil && item.cooldownUntil > now).length
56
- const unhealthy = models.filter(model => health.isBlocked(model)).length
57
- const healthy = Math.max(0, models.length - unhealthy)
60
+ const counts = summarizeCandidateHealth(models, state)
58
61
  const plain = round?.phase === 'switching' ? `CCS ↻${round.attempts}/${MAX_ATTEMPTS} ${round.model ? modelKey(round.model) : ''}` :
59
- cooling || disabled ? `CCS ✓${healthy}/${models.length} · ⏳${cooling} · ⛔${disabled}` : `CCS ✓${models.length}`
62
+ counts.cooling || counts.disabled ? `CCS ✓${counts.healthy}/${counts.total} · ⏳${counts.cooling} · ⛔${counts.disabled}` : `CCS ✓${counts.total}`
60
63
  const theme = ctx.ui.theme
61
- ctx.ui.setStatus('ccswitch-ha', theme ? theme.fg(cooling || disabled ? 'warning' : 'success', plain) : plain)
64
+ ctx.ui.setStatus('ccswitch-ha', theme ? theme.fg(counts.cooling || counts.disabled ? 'warning' : 'success', plain) : plain)
62
65
  }
63
66
  const notify = (ctx: ExtensionContext, message: string, type: 'info' | 'warning' | 'error' = 'info') => {
64
67
  if (ctx.hasUI) ctx.ui.notify(message, type)
@@ -83,19 +86,22 @@ export default function (pi: ExtensionAPI) {
83
86
  const state = health.snapshot
84
87
  const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
85
88
  const candidates = snapshot.models
86
- const healthy = candidates.filter(model => !health.isBlocked(model)).length
89
+ const counts = summarizeCandidateHealth(candidates, state)
90
+ const now = Date.now()
87
91
  const rows = candidates.slice(0, 10).map(model => {
88
- const record = state.models[modelKey(model)] ?? state.providers[model.provider]
89
- const suffix = record?.disabled ? '禁用' : record?.cooldownUntil && record.cooldownUntil > Date.now() ? `冷却 ${Math.ceil((record.cooldownUntil - Date.now()) / 60_000)}m` : '健康'
92
+ const modelRecord = state.models[modelKey(model)]
93
+ const records = [modelRecord, state.providers[model.provider], state.endpoints[endpointKey(model)]]
94
+ const blockedUntil = Math.max(0, ...records.flatMap(record => [record?.cooldownUntil ?? 0, record?.leaseUntil ?? 0]))
95
+ const suffix = modelRecord?.disabled ? '手动禁用' : blockedUntil > now ? `自动冷却 ${Math.max(1, Math.ceil((blockedUntil - now) / 60_000))}m` : '健康'
90
96
  return `${modelKey(model)} ${suffix}`
91
97
  })
92
98
  if (!ctx.ui.select) {
93
99
  const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
94
- notify(ctx, `CCSwitch:${source} · 唯一模型 ${candidates.length} · 健康 ${healthy}`, 'info')
100
+ notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled}`, 'info')
95
101
  return
96
102
  }
97
103
  const scopeLabel = snapshot.source === 'scoped' ? `Pi scope:${snapshot.sourceEntries} 条` : `Pi 可用注册表:${snapshot.sourceEntries} 条`
98
- const action = await ctx.ui.select(`CCSwitch 健康面板\n当前:${key(ctx.model) ?? '无'}\n${scopeLabel} · 唯一模型:${candidates.length} · 健康:${healthy}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
104
+ const action = await ctx.ui.select(`CCSwitch 健康面板\n当前:${key(ctx.model) ?? '无'}\n${scopeLabel} · 唯一模型:${counts.total} · 健康:${counts.healthy} · 自动冷却:${counts.cooling} · 手动禁用:${counts.disabled}\n熔断记录:${counts.breakerRecords}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
99
105
  if (action === '刷新') await refresh(ctx)
100
106
  if (action === '重新激活当前模型' && ctx.model) { health.reactivate(ctx.model); await health.flush(); status(ctx); notify(ctx, '已重新激活当前模型') }
101
107
  if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
@@ -121,20 +127,30 @@ export default function (pi: ExtensionAPI) {
121
127
  if (round.observation.aborted && !round.observation.watchdog) { round.phase = 'idle'; clearWatchdog(); status(ctx); return }
122
128
  round.phase = 'switching'
123
129
  round.tried.add(modelKey(round.model))
130
+ // 跟踪同端点失败:BaseURL 相同的模型同属一个端点平台,连续失败到阈值后隔离整个端点,
131
+ // 避免同一平台下的多个模型逐个试错(它们往往共享同一故障根源)
132
+ const failTracker = round.endpointFails ??= { failed: new Map(), isolated: new Set() }
133
+ const ep = endpointKey(round.model)
134
+ const epFails = (failTracker.failed.get(ep) ?? 0) + 1
135
+ failTracker.failed.set(ep, epFails)
136
+ if (epFails >= ENDPOINT_FAIL_THRESHOLD) {
137
+ failTracker.isolated.add(ep)
138
+ await health.log(`endpoint ${ep} failed ${epFails} times this round, isolating endpoint`)
139
+ }
124
140
  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)
125
141
  await health.flush()
126
142
  await refresh(ctx)
127
143
  const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
128
- let next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot })
144
+ let next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot, avoidEndpoints: failTracker.isolated })
129
145
  while (next) {
130
- if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot }); continue }
146
+ if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot, avoidEndpoints: failTracker.isolated }); continue }
131
147
  round.attempts++
132
148
  ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
133
149
  const set = await pi.setModel(next).catch(() => false)
134
150
  if (!set) {
135
151
  health.recordFailure('model', modelKey(next), 'model_config', 'Pi refused model selection')
136
152
  round.tried.add(modelKey(next)); await health.flush()
137
- next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot })
153
+ next = chooseCandidate(candidates, { current: round.model, tried: round.tried, failureKind: classification.kind, health: health.snapshot, avoidEndpoints: failTracker.isolated })
138
154
  continue
139
155
  }
140
156
  round.model = next
@@ -160,7 +176,7 @@ export default function (pi: ExtensionAPI) {
160
176
  if (event.source === 'extension') return { action: 'continue' }
161
177
  clearWatchdog()
162
178
  lastStatus = {}
163
- 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 }
179
+ 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 }
164
180
  status(ctx)
165
181
  return { action: 'continue' }
166
182
  })
@@ -238,8 +254,8 @@ export default function (pi: ExtensionAPI) {
238
254
  pi.registerCommand('ccswitch-test', { description: '检查 CCSwitch 候选模型和健康状态(不切换模型)', handler: async (_args, ctx) => {
239
255
  await refresh(ctx)
240
256
  const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
241
- const healthy = snapshot.models.filter(model => !health.isBlocked(model)).length
257
+ const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
242
258
  const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
243
- notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${snapshot.models.length} · 健康 ${healthy}`, snapshot.models.length ? 'info' : 'warning')
259
+ notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords}`, counts.total ? 'info' : 'warning')
244
260
  }})
245
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [