pi-ccswitch-auto-switch 0.1.3 → 0.1.4
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 +9 -5
- package/health.ts +29 -2
- package/index.ts +36 -7
- package/package.json +1 -1
- package/types.ts +6 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ The extension observes real Pi requests, records sanitized health signals, and
|
|
|
14
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.
|
|
15
15
|
- Model-only isolation for missing models and incompatible parameters.
|
|
16
16
|
- Prefers a healthy model from another provider before considering a sibling model.
|
|
17
|
-
- Independent-provider semantics: a copied vendor (e.g. `
|
|
17
|
+
- Independent-provider semantics: a copied vendor (e.g. `my-provider-copy`) shares the BaseURL but is a distinct provider with its own endpoint key, so isolating one never blocks the other.
|
|
18
18
|
- 90-second first-response and 120-second streaming-idle watchdogs.
|
|
19
19
|
- Exponential cooldowns, `Retry-After` support, and one persisted half-open probe lease per provider.
|
|
20
20
|
- Persistent, atomic, cross-process health state with error redaction and bounded logs.
|
|
@@ -61,22 +61,26 @@ Examples:
|
|
|
61
61
|
|
|
62
62
|
```text
|
|
63
63
|
/ccswitch reactivate all
|
|
64
|
-
/ccswitch disable
|
|
65
|
-
/ccswitch reset
|
|
64
|
+
/ccswitch disable my-provider/my-model-id
|
|
65
|
+
/ccswitch reset my-provider/my-model-id
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
## Status bar
|
|
69
69
|
|
|
70
70
|
```text
|
|
71
|
-
CCS ✓70/131 · ⏳61 · ⛔0
|
|
71
|
+
CCS ✓70/131 · ⏳61 · ⛔0 · ⇄3 · provider/model-id
|
|
72
72
|
```
|
|
73
73
|
|
|
74
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
75
|
- `⏳61`: 61 models are currently affected by automatic model, provider, or endpoint cooldowns. One provider breaker can account for many affected models.
|
|
76
76
|
- `⛔0`: no manually disabled models.
|
|
77
|
+
- `⇄3`: **本次 pi session 成功切换的模型次数**(衡量扩展有效程度)。`/new`、`/fork`、`/resume` 等新 session 开始时归零;但**失败记录与冷却状态不重置**(它们是物理事实,跨 session 保留)。累计切换数(`state.switches`)与最近 20 条切换日志会持久化到状态文件,可在 `/ccswitch` 面板和 `/ccswitch-test` 中查看。
|
|
78
|
+
- `provider/model-id`: 当前实际生效的模型(`provider/id`,切换后立即更新,长名自动截断)。
|
|
77
79
|
- During a switch, `CCS ↻2/5 provider/model` means the second of at most five attempts is being made.
|
|
78
80
|
|
|
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.
|
|
81
|
+
When all unique models are healthy, the compact form is `CCS ✓131 · provider/model-id`. 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.
|
|
82
|
+
|
|
83
|
+
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
|
|
80
84
|
|
|
81
85
|
## Failover behavior
|
|
82
86
|
|
package/health.ts
CHANGED
|
@@ -30,6 +30,15 @@ export interface HealthState {
|
|
|
30
30
|
models: Record<string, HealthRecord>
|
|
31
31
|
providers: Record<string, HealthRecord>
|
|
32
32
|
endpoints: Record<string, HealthRecord>
|
|
33
|
+
/** 累计成功切换次数(跨 session 持久化,用于衡量扩展有效程度) */
|
|
34
|
+
switches?: number
|
|
35
|
+
/** 最近成功切换日志(有限条,环形保留) */
|
|
36
|
+
switchLog?: Array<{
|
|
37
|
+
at: number
|
|
38
|
+
from: string
|
|
39
|
+
to: string
|
|
40
|
+
reason?: string
|
|
41
|
+
}>
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export function agentDir(env = process.env, home = homedir()): string {
|
|
@@ -55,7 +64,7 @@ function safeEndpoint(value: string): string {
|
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
function blank(): HealthState {
|
|
58
|
-
return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {} }
|
|
67
|
+
return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, switches: 0, switchLog: [] }
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
function redact(text: string | undefined): string | undefined {
|
|
@@ -141,6 +150,18 @@ export class HealthStore {
|
|
|
141
150
|
this.touch()
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
/**
|
|
154
|
+
* 记录一次成功的模型切换:累计计数 + 环形保留最近 20 条切换日志。
|
|
155
|
+
* 用于衡量扩展有效程度,并在 /ccswitch status 面板展示。
|
|
156
|
+
*/
|
|
157
|
+
recordSwitch(from: string, to: string, reason?: string): void {
|
|
158
|
+
this.state.switches = (this.state.switches ?? 0) + 1
|
|
159
|
+
const log = this.state.switchLog ?? []
|
|
160
|
+
log.push({ at: Date.now(), from, to, reason })
|
|
161
|
+
this.state.switchLog = log.slice(-20)
|
|
162
|
+
this.touch()
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
private close(scope: HealthScope, key: string): void {
|
|
145
166
|
const current = this.bucket(scope)[key]
|
|
146
167
|
if (!current) return
|
|
@@ -287,5 +308,11 @@ function mergeBucket(a: Record<string, HealthRecord>, b: Record<string, HealthRe
|
|
|
287
308
|
}
|
|
288
309
|
|
|
289
310
|
function mergeState(a: HealthState, b: HealthState): HealthState {
|
|
290
|
-
|
|
311
|
+
const switches = Math.max(a.switches ?? 0, b.switches ?? 0)
|
|
312
|
+
// 取最近更新的 switchLog(按 at 去倒序合并,保留最新 20 条)
|
|
313
|
+
const log = [...(a.switchLog ?? []), ...(b.switchLog ?? [])]
|
|
314
|
+
.sort((x, y) => y.at - x.at)
|
|
315
|
+
.filter((entry, index, all) => index === 0 || all[index - 1].at !== entry.at || all[index - 1].from !== entry.from || all[index - 1].to !== entry.to)
|
|
316
|
+
.slice(0, 20)
|
|
317
|
+
return { schemaVersion: 2, updatedAt: Math.max(a.updatedAt ?? 0, b.updatedAt ?? 0), models: mergeBucket(a.models ?? {}, b.models ?? {}), providers: mergeBucket(a.providers ?? {}, b.providers ?? {}), endpoints: mergeBucket(a.endpoints ?? {}, b.endpoints ?? {}), switches, switchLog: log }
|
|
291
318
|
}
|
package/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
42
42
|
let round: Round | undefined
|
|
43
43
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
44
44
|
let lastStatus: { status?: number, retryAfterMs?: number } = {}
|
|
45
|
+
// 本次 pi session 内成功切换的模型数量(用于衡量插件的有效程度)
|
|
46
|
+
let sessionSwitches = 0
|
|
45
47
|
|
|
46
48
|
const clearWatchdog = () => { if (timer) clearTimeout(timer); timer = undefined }
|
|
47
49
|
const armWatchdog = (ctx: ExtensionContext, ms: number, roundId: number) => {
|
|
@@ -54,15 +56,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
56
|
}, ms)
|
|
55
57
|
timer.unref?.()
|
|
56
58
|
}
|
|
59
|
+
// 当前生效模型名(provider/id);过长时压缩,避免撑爆状态栏
|
|
60
|
+
const modelTag = (model: ModelRef | undefined) => {
|
|
61
|
+
if (!model) return ''
|
|
62
|
+
const full = modelKey(model)
|
|
63
|
+
return full.length > 30 ? `${model.provider}/${model.id?.slice(0, 24)}…` : full
|
|
64
|
+
}
|
|
57
65
|
const status = (ctx: ExtensionContext) => {
|
|
58
66
|
const state = health.snapshot
|
|
59
67
|
const { models } = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
60
68
|
const counts = summarizeCandidateHealth(models, state)
|
|
61
|
-
const
|
|
62
|
-
|
|
69
|
+
const activeModel = round?.model ?? ctx.model
|
|
70
|
+
const plain = round?.phase === 'switching' ? `CCS ↻${round.attempts}/${MAX_ATTEMPTS} ${modelTag(round.model)}` :
|
|
71
|
+
counts.cooling || counts.disabled ? `CCS ✓${counts.healthy}/${counts.total} · ⏳${counts.cooling} · ⛔${counts.disabled}${switchSuffix()} · ${modelTag(activeModel)}` : `CCS ✓${counts.total}${switchSuffix()} · ${modelTag(activeModel)}`
|
|
63
72
|
const theme = ctx.ui.theme
|
|
64
73
|
ctx.ui.setStatus('ccswitch-ha', theme ? theme.fg(counts.cooling || counts.disabled ? 'warning' : 'success', plain) : plain)
|
|
65
74
|
}
|
|
75
|
+
// 本次 session 成功切换计数:⇄N(N>0 时显示);累计切换数在面板/自检中可见
|
|
76
|
+
const switchSuffix = () => sessionSwitches > 0 ? ` · ⇄${sessionSwitches}` : ''
|
|
66
77
|
const notify = (ctx: ExtensionContext, message: string, type: 'info' | 'warning' | 'error' = 'info') => {
|
|
67
78
|
if (ctx.hasUI) ctx.ui.notify(message, type)
|
|
68
79
|
}
|
|
@@ -97,11 +108,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
108
|
})
|
|
98
109
|
if (!ctx.ui.select) {
|
|
99
110
|
const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
|
|
100
|
-
notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled}`, 'info')
|
|
111
|
+
notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, 'info')
|
|
101
112
|
return
|
|
102
113
|
}
|
|
103
114
|
const scopeLabel = snapshot.source === 'scoped' ? `Pi scope:${snapshot.sourceEntries} 条` : `Pi 可用注册表:${snapshot.sourceEntries} 条`
|
|
104
|
-
const
|
|
115
|
+
const switchSummary = `本session切换:${sessionSwitches} · 累计切换:${state.switches ?? 0}`
|
|
116
|
+
const recentSwitches = (state.switchLog ?? []).slice(0, 5).map(entry => {
|
|
117
|
+
const time = new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
118
|
+
return ` ${time} ${entry.from} ⇄ ${entry.to}`
|
|
119
|
+
})
|
|
120
|
+
const switchPanel = recentSwitches.length ? `\n最近切换:\n${recentSwitches.join('\n')}` : ''
|
|
121
|
+
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${switchSummary}${switchPanel}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
|
|
105
122
|
if (action === '刷新') await refresh(ctx)
|
|
106
123
|
if (action === '重新激活当前模型' && ctx.model) { health.reactivate(ctx.model); await health.flush(); status(ctx); notify(ctx, '已重新激活当前模型') }
|
|
107
124
|
if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
|
|
@@ -155,7 +172,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
155
172
|
}
|
|
156
173
|
round.model = next
|
|
157
174
|
round.phase = 'redispatching'
|
|
175
|
+
// 本次 session 成功切换计数 + 持久化累计/日志(衡量扩展有效程度)
|
|
176
|
+
sessionSwitches += 1
|
|
177
|
+
const fromKey = key(round.model ?? ctx.model)
|
|
178
|
+
health.recordSwitch(fromKey ?? '', modelKey(next), classification.kind)
|
|
179
|
+
await health.flush()
|
|
158
180
|
notify(ctx, `CCSwitch:已切换至 ${modelKey(next)}(${round.attempts}/${MAX_ATTEMPTS})`, 'info')
|
|
181
|
+
// 触发 TUI 底栏/界面重绘:appendEntry 会发出 entry_appended 事件进入 session.subscribe 流,
|
|
182
|
+
// interactive-mode 收到后执行 footer.invalidate() + requestRender(),footer 从 session.state.model 重新读取,
|
|
183
|
+
// 从而让右下角模型名同步显示新模型(setModel 只改 state,不直接触发 footer 刷新)。
|
|
184
|
+
try {
|
|
185
|
+
pi.appendEntry?.('ccswitch-switch', { from: fromKey ?? '', to: modelKey(next), reason: classification.kind, sessionSwitches })
|
|
186
|
+
} catch { /* appendEntry 失败不影响切换 */ }
|
|
159
187
|
const continuation = round.hadTool || round.hadOutput
|
|
160
188
|
? '请从当前会话状态继续完成上一条请求;不要重复已经完成的工具操作。'
|
|
161
189
|
: round.images?.length ? [{ type: 'text', text: round.text }, ...round.images] : round.text
|
|
@@ -170,8 +198,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
198
|
await exhaust(ctx, '没有健康的候选模型')
|
|
171
199
|
}
|
|
172
200
|
|
|
173
|
-
pi.on('session_start', async (_event, ctx) => { await health.load(); await refresh(ctx); await health.log('extension started') })
|
|
174
|
-
pi.on('session_shutdown', async (_event, ctx) => { clearWatchdog(); ctx.ui.setWorkingMessage(); await health.flush() })
|
|
201
|
+
pi.on('session_start', async (_event, ctx) => { await health.load(); sessionSwitches = 0; await refresh(ctx); await health.log('extension started') })
|
|
202
|
+
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() })
|
|
175
203
|
pi.on('input', (event, ctx) => {
|
|
176
204
|
if (event.source === 'extension') return { action: 'continue' }
|
|
177
205
|
clearWatchdog()
|
|
@@ -255,7 +283,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
255
283
|
await refresh(ctx)
|
|
256
284
|
const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
257
285
|
const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
|
|
286
|
+
const state = health.snapshot
|
|
258
287
|
const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
|
|
259
|
-
notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords}`, counts.total ? 'info' : 'warning')
|
|
288
|
+
notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, counts.total ? 'info' : 'warning')
|
|
260
289
|
}})
|
|
261
290
|
}
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface ExtensionAPI {
|
|
|
44
44
|
handler: (args: string, ctx: ExtensionContext) => Promise<void> | void
|
|
45
45
|
}): void
|
|
46
46
|
sendUserMessage(content: string | Array<Record<string, unknown>>): void
|
|
47
|
+
/**
|
|
48
|
+
* 追加一条不参与 LLM 上下文的 custom entry。
|
|
49
|
+
* appendEntry 会向 session.subscribe 流发出 entry_appended 事件,
|
|
50
|
+
* 从而触发 TUI footer/界面重绘——用于切换模型后强制底栏同步显示新模型名。
|
|
51
|
+
*/
|
|
52
|
+
appendEntry?(customType: string, data: Record<string, unknown>): void
|
|
47
53
|
setModel(model: ModelRef): Promise<boolean>
|
|
48
54
|
setThinkingLevel?(level: string): void
|
|
49
55
|
}
|