pi-ccswitch-auto-switch 0.1.0 → 0.1.2

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
@@ -66,15 +66,15 @@ Examples:
66
66
  ## Status bar
67
67
 
68
68
  ```text
69
- CCS ✓130 · ⏳1 · ⛔0
69
+ CCS ✓70/131 · ⏳61 · ⛔0
70
70
  ```
71
71
 
72
- - `✓130`: effective models currently eligible for selection.
73
- - `⏳1`: one model, provider, or endpoint health record is cooling down.
72
+ - `✓70/131`: 70 healthy models out of 131 unique `provider/model` combinations in Pi's effective scope. Duplicate scoped entries are counted once.
73
+ - `⏳61`: 61 models are currently affected by automatic model, provider, or endpoint cooldowns. One provider breaker can account for many affected models.
74
74
  - `⛔0`: no manually disabled models.
75
75
  - During a switch, `CCS ↻2/5 provider/model` means the second of at most five attempts is being made.
76
76
 
77
- Use `/ccswitch` to inspect the exact record behind a cooldown.
77
+ 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
78
 
79
79
  ## Failover behavior
80
80
 
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 ✓130 · ⏳1 · ⛔0
61
+ CCS ✓70/131 · ⏳61 · ⛔0
62
62
  ```
63
63
 
64
- - `✓130`:当前可参与选择的有效模型数量。
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
- 通过 `/ccswitch` 查看具体哪个记录正在冷却。
69
+ 全部唯一模型都健康时会压缩显示为 `CCS ✓131`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、受影响模型数和底层熔断记录数。
70
70
 
71
71
  ## 故障转移逻辑
72
72
 
package/candidates.ts CHANGED
@@ -8,6 +8,24 @@ export interface CandidateOptions {
8
8
  health: HealthState
9
9
  }
10
10
 
11
+ export interface CandidateSnapshot {
12
+ models: ModelRef[]
13
+ source: 'scoped' | 'available'
14
+ sourceEntries: number
15
+ }
16
+
17
+ export interface CandidateHealthSummary {
18
+ total: number
19
+ healthy: number
20
+ cooling: number
21
+ disabled: number
22
+ breakerRecords: number
23
+ }
24
+
25
+ function activeCooldown(record: HealthState['models'][string] | undefined, now: number): boolean {
26
+ return Boolean((record?.cooldownUntil && record.cooldownUntil > now) || (record?.leaseUntil && record.leaseUntil > now))
27
+ }
28
+
11
29
  function blocked(model: ModelRef, health: HealthState, now = Date.now()): boolean {
12
30
  const records = [health.models[modelKey(model)], health.providers[model.provider], health.endpoints[endpointKey(model)]]
13
31
  return records.some(record => Boolean(record?.disabled || (record?.cooldownUntil && record.cooldownUntil > now) || (record?.leaseUntil && record.leaseUntil > now)))
@@ -24,6 +42,34 @@ export function effectiveCandidates(scoped: readonly ScopedModel[], available: M
24
42
  })
25
43
  }
26
44
 
45
+ export function candidateSnapshot(scoped: readonly ScopedModel[], available: ModelRef[]): CandidateSnapshot {
46
+ const source = scoped.length > 0 ? 'scoped' : 'available'
47
+ return {
48
+ models: effectiveCandidates(scoped, available),
49
+ source,
50
+ sourceEntries: source === 'scoped' ? scoped.length : available.length,
51
+ }
52
+ }
53
+
54
+ export function summarizeCandidateHealth(models: ModelRef[], health: HealthState, now = Date.now()): CandidateHealthSummary {
55
+ let healthy = 0
56
+ let cooling = 0
57
+ let disabled = 0
58
+ for (const model of models) {
59
+ const modelRecord = health.models[modelKey(model)]
60
+ if (modelRecord?.disabled) {
61
+ disabled += 1
62
+ continue
63
+ }
64
+ const records = [modelRecord, health.providers[model.provider], health.endpoints[endpointKey(model)]]
65
+ if (records.some(record => activeCooldown(record, now))) cooling += 1
66
+ else healthy += 1
67
+ }
68
+ const breakerRecords = [...Object.values(health.models), ...Object.values(health.providers), ...Object.values(health.endpoints)]
69
+ .filter(record => activeCooldown(record, now)).length
70
+ return { total: models.length, healthy, cooling, disabled, breakerRecords }
71
+ }
72
+
27
73
  export function chooseCandidate(models: ModelRef[], options: CandidateOptions): ModelRef | undefined {
28
74
  const current = options.current
29
75
  const candidates = models.filter(model => {
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, FailureObservation, ModelRef } from './types.ts'
2
2
  import { classifyFailure, parseRetryAfter } from './classify.ts'
3
- import { 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
@@ -48,17 +48,12 @@ export default function (pi: ExtensionAPI) {
48
48
  }
49
49
  const status = (ctx: ExtensionContext) => {
50
50
  const state = health.snapshot
51
- const models = effectiveCandidates(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)
51
+ const { models } = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
52
+ const counts = summarizeCandidateHealth(models, state)
58
53
  const plain = round?.phase === 'switching' ? `CCS ↻${round.attempts}/${MAX_ATTEMPTS} ${round.model ? modelKey(round.model) : ''}` :
59
- cooling || disabled ? `CCS ✓${healthy} · ⏳${cooling} · ⛔${disabled}` : `CCS ✓${models.length}`
54
+ counts.cooling || counts.disabled ? `CCS ✓${counts.healthy}/${counts.total} · ⏳${counts.cooling} · ⛔${counts.disabled}` : `CCS ✓${counts.total}`
60
55
  const theme = ctx.ui.theme
61
- ctx.ui.setStatus('ccswitch-ha', theme ? theme.fg(cooling || disabled ? 'warning' : 'success', plain) : plain)
56
+ ctx.ui.setStatus('ccswitch-ha', theme ? theme.fg(counts.cooling || counts.disabled ? 'warning' : 'success', plain) : plain)
62
57
  }
63
58
  const notify = (ctx: ExtensionContext, message: string, type: 'info' | 'warning' | 'error' = 'info') => {
64
59
  if (ctx.hasUI) ctx.ui.notify(message, type)
@@ -81,14 +76,24 @@ export default function (pi: ExtensionAPI) {
81
76
  }
82
77
  const showPanel = async (ctx: ExtensionContext) => {
83
78
  const state = health.snapshot
84
- const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
79
+ const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
80
+ const candidates = snapshot.models
81
+ const counts = summarizeCandidateHealth(candidates, state)
82
+ const now = Date.now()
85
83
  const rows = candidates.slice(0, 10).map(model => {
86
- const record = state.models[modelKey(model)] ?? state.providers[model.provider]
87
- const suffix = record?.disabled ? '禁用' : record?.cooldownUntil && record.cooldownUntil > Date.now() ? `冷却 ${Math.ceil((record.cooldownUntil - Date.now()) / 60_000)}m` : '健康'
84
+ const modelRecord = state.models[modelKey(model)]
85
+ const records = [modelRecord, state.providers[model.provider], state.endpoints[endpointKey(model)]]
86
+ const blockedUntil = Math.max(0, ...records.flatMap(record => [record?.cooldownUntil ?? 0, record?.leaseUntil ?? 0]))
87
+ const suffix = modelRecord?.disabled ? '手动禁用' : blockedUntil > now ? `自动冷却 ${Math.max(1, Math.ceil((blockedUntil - now) / 60_000))}m` : '健康'
88
88
  return `${modelKey(model)} ${suffix}`
89
89
  })
90
- if (!ctx.ui.select) { notify(ctx, `CCSwitch:${candidates.length} 个候选,${rows.filter(row => row.includes('冷却')).length} 个冷却`, 'info'); return }
91
- const action = await ctx.ui.select(`CCSwitch 健康面板\n当前:${key(ctx.model) ?? ''}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
90
+ if (!ctx.ui.select) {
91
+ const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
92
+ notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled}`, 'info')
93
+ return
94
+ }
95
+ const scopeLabel = snapshot.source === 'scoped' ? `Pi scope:${snapshot.sourceEntries} 条` : `Pi 可用注册表:${snapshot.sourceEntries} 条`
96
+ 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') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
92
97
  if (action === '刷新') await refresh(ctx)
93
98
  if (action === '重新激活当前模型' && ctx.model) { health.reactivate(ctx.model); await health.flush(); status(ctx); notify(ctx, '已重新激活当前模型') }
94
99
  if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
@@ -230,7 +235,9 @@ export default function (pi: ExtensionAPI) {
230
235
  }})
231
236
  pi.registerCommand('ccswitch-test', { description: '检查 CCSwitch 候选模型和健康状态(不切换模型)', handler: async (_args, ctx) => {
232
237
  await refresh(ctx)
233
- const count = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable()).length
234
- notify(ctx, `CCSwitch 自检完成:${count} 个有效候选模型`, count ? 'info' : 'warning')
238
+ const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
239
+ const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
240
+ const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
241
+ notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords}`, counts.total ? 'info' : 'warning')
235
242
  }})
236
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [