pi-ccswitch-auto-switch 0.1.2 → 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 +2 -0
- package/candidates.ts +2 -0
- package/index.ts +22 -4
- package/package.json +1 -1
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.
|
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 {
|
|
@@ -74,6 +75,7 @@ export function chooseCandidate(models: ModelRef[], options: CandidateOptions):
|
|
|
74
75
|
const current = options.current
|
|
75
76
|
const candidates = models.filter(model => {
|
|
76
77
|
if (options.tried.has(modelKey(model)) || blocked(model, options.health)) return false
|
|
78
|
+
if (options.avoidEndpoints?.has(endpointKey(model))) return false
|
|
77
79
|
if (options.failureKind === 'context_overflow' && current && (model.contextWindow ?? 0) <= (current.contextWindow ?? 0)) return false
|
|
78
80
|
return true
|
|
79
81
|
})
|
package/index.ts
CHANGED
|
@@ -7,6 +7,13 @@ 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) }
|
|
@@ -119,20 +127,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
119
127
|
if (round.observation.aborted && !round.observation.watchdog) { round.phase = 'idle'; clearWatchdog(); status(ctx); return }
|
|
120
128
|
round.phase = 'switching'
|
|
121
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
|
+
}
|
|
122
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)
|
|
123
141
|
await health.flush()
|
|
124
142
|
await refresh(ctx)
|
|
125
143
|
const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
126
|
-
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 })
|
|
127
145
|
while (next) {
|
|
128
|
-
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 }
|
|
129
147
|
round.attempts++
|
|
130
148
|
ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
|
|
131
149
|
const set = await pi.setModel(next).catch(() => false)
|
|
132
150
|
if (!set) {
|
|
133
151
|
health.recordFailure('model', modelKey(next), 'model_config', 'Pi refused model selection')
|
|
134
152
|
round.tried.add(modelKey(next)); await health.flush()
|
|
135
|
-
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 })
|
|
136
154
|
continue
|
|
137
155
|
}
|
|
138
156
|
round.model = next
|
|
@@ -158,7 +176,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
158
176
|
if (event.source === 'extension') return { action: 'continue' }
|
|
159
177
|
clearWatchdog()
|
|
160
178
|
lastStatus = {}
|
|
161
|
-
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 }
|
|
162
180
|
status(ctx)
|
|
163
181
|
return { action: 'continue' }
|
|
164
182
|
})
|