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 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
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.1.2",
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": [