@goodandready/dsh-key-rotation 0.8.76 → 0.8.78

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.
@@ -0,0 +1,296 @@
1
+ // lib/model-catalog.js — Centralized model catalog with Cache Stampede protection (#550).
2
+ // Deduplicates concurrent in-flight requests and provides TTL-based caching and model capability normalization.
3
+
4
+ export const DEFAULT_CATALOG_TTL_MS = 60 * 60 * 1000; // 1 hour
5
+ export const DEFAULT_CATALOG_TIMEOUT_MS = 15_000; // 15 seconds
6
+
7
+ export const DEFAULT_CONTEXT_WINDOW = 128_000;
8
+ export const DEFAULT_MAX_TOKENS = 8_192;
9
+
10
+ export const KNOWN_MODELS_FALLBACK = {
11
+ 'deepseek-chat': {
12
+ id: 'deepseek-chat',
13
+ name: 'DeepSeek Chat',
14
+ contextWindow: 64_000,
15
+ maxTokens: 8_000,
16
+ supportsReasoning: false,
17
+ supportsTools: true,
18
+ supportsVision: false,
19
+ },
20
+ 'deepseek-reasoner': {
21
+ id: 'deepseek-reasoner',
22
+ name: 'DeepSeek Reasoner',
23
+ contextWindow: 64_000,
24
+ maxTokens: 8_000,
25
+ supportsReasoning: true,
26
+ supportsTools: false,
27
+ supportsVision: false,
28
+ },
29
+ 'gpt-4o': {
30
+ id: 'gpt-4o',
31
+ name: 'GPT-4o',
32
+ contextWindow: 128_000,
33
+ maxTokens: 16_384,
34
+ supportsReasoning: false,
35
+ supportsTools: true,
36
+ supportsVision: true,
37
+ },
38
+ 'gpt-4o-mini': {
39
+ id: 'gpt-4o-mini',
40
+ name: 'GPT-4o mini',
41
+ contextWindow: 128_000,
42
+ maxTokens: 16_384,
43
+ supportsReasoning: false,
44
+ supportsTools: true,
45
+ supportsVision: true,
46
+ },
47
+ 'claude-3-5-sonnet-20241022': {
48
+ id: 'claude-3-5-sonnet-20241022',
49
+ name: 'Claude 3.5 Sonnet',
50
+ contextWindow: 200_000,
51
+ maxTokens: 8_192,
52
+ supportsReasoning: false,
53
+ supportsTools: true,
54
+ supportsVision: true,
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Normalizes raw model object from provider /models endpoint.
60
+ * @param {object|string} raw
61
+ * @returns {object}
62
+ */
63
+ export function normalizeModelMeta(raw) {
64
+ if (typeof raw === 'string') {
65
+ const fallback = KNOWN_MODELS_FALLBACK[raw];
66
+ return fallback ? { ...fallback } : {
67
+ id: raw,
68
+ name: raw,
69
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
70
+ maxTokens: DEFAULT_MAX_TOKENS,
71
+ supportsReasoning: raw.includes('reason') || raw.includes('r1') || raw.includes('thought'),
72
+ supportsTools: true,
73
+ supportsVision: raw.includes('vision') || raw.includes('4o') || raw.includes('vl'),
74
+ };
75
+ }
76
+
77
+ const id = String(raw?.id || raw?.slug || raw?.name || '');
78
+ const fallback = KNOWN_MODELS_FALLBACK[id];
79
+
80
+ const name = String(raw?.name || raw?.display_name || id);
81
+ const contextWindow =
82
+ Number(raw?.context_window || raw?.max_context_window || raw?.contextWindow) ||
83
+ fallback?.contextWindow ||
84
+ DEFAULT_CONTEXT_WINDOW;
85
+ const maxTokens =
86
+ Number(raw?.max_tokens || raw?.max_output_tokens || raw?.maxTokens) ||
87
+ fallback?.maxTokens ||
88
+ DEFAULT_MAX_TOKENS;
89
+
90
+ const caps = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
91
+ const reasoningLevels = Array.isArray(raw?.supported_reasoning_levels) ? raw.supported_reasoning_levels : [];
92
+
93
+ const supportsReasoning =
94
+ Boolean(raw?.supportsReasoning) ||
95
+ caps.includes('thinking') ||
96
+ caps.includes('reasoning') ||
97
+ reasoningLevels.length > 0 ||
98
+ id.includes('reason') ||
99
+ id.includes('r1') ||
100
+ fallback?.supportsReasoning ||
101
+ false;
102
+
103
+ const supportsTools =
104
+ raw?.supportsTools !== undefined
105
+ ? Boolean(raw.supportsTools)
106
+ : caps.includes('tools') || fallback?.supportsTools || true;
107
+
108
+ const inputModalities = Array.isArray(raw?.input_modalities) ? raw.input_modalities : [];
109
+ const supportsVision =
110
+ Boolean(raw?.supportsVision) ||
111
+ inputModalities.includes('image') ||
112
+ id.includes('vision') ||
113
+ id.includes('4o') ||
114
+ id.includes('vl') ||
115
+ fallback?.supportsVision ||
116
+ false;
117
+
118
+ return {
119
+ id,
120
+ name,
121
+ contextWindow,
122
+ maxTokens,
123
+ supportsReasoning,
124
+ supportsTools,
125
+ supportsVision,
126
+ raw,
127
+ };
128
+ }
129
+
130
+ export class ModelCatalog {
131
+ /**
132
+ * @param {object} [options]
133
+ * @param {number} [options.ttlMs]
134
+ * @param {number} [options.timeoutMs]
135
+ * @param {typeof fetch} [options.fetchImpl]
136
+ */
137
+ constructor(options = {}) {
138
+ this.ttlMs = options.ttlMs ?? DEFAULT_CATALOG_TTL_MS;
139
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
140
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
141
+ /** @type {Map<string, { at: number, models: Array<object> }>} */
142
+ this.cache = new Map();
143
+ /** @type {Map<string, Promise<Array<object>>>} In-flight deduplication map */
144
+ this.inflight = new Map();
145
+ }
146
+
147
+ _buildKey(endpoint, apiKey) {
148
+ const keyHint = apiKey ? apiKey.slice(-6) : 'anonymous';
149
+ return `${endpoint || 'default'}|${keyHint}`;
150
+ }
151
+
152
+ /**
153
+ * Gets list of models for an endpoint.
154
+ * If a request for this endpoint is already in flight, reuses the promise (anti-stampede).
155
+ *
156
+ * @param {object} options
157
+ * @param {string} options.endpoint Base URL or /models URL
158
+ * @param {string} [options.apiKey]
159
+ * @param {boolean} [options.forceRefresh]
160
+ * @param {typeof fetch} [options.fetchImpl]
161
+ * @returns {Promise<Array<object>>}
162
+ */
163
+ async getModels(options = {}) {
164
+ const { endpoint, apiKey, forceRefresh = false } = options;
165
+ const fetchFn = options.fetchImpl || this.fetchImpl;
166
+ const cacheKey = this._buildKey(endpoint, apiKey);
167
+ const now = Date.now();
168
+
169
+ // 1. Check TTL cache if not forcing refresh
170
+ if (!forceRefresh) {
171
+ const cached = this.cache.get(cacheKey);
172
+ if (cached && (now - cached.at < this.ttlMs)) {
173
+ return cached.models;
174
+ }
175
+ }
176
+
177
+ // 2. Check in-flight deduplication map (Cache Stampede protection)
178
+ const existingPromise = this.inflight.get(cacheKey);
179
+ if (existingPromise) {
180
+ return existingPromise;
181
+ }
182
+
183
+ // 3. Initiate single fetch and record in inflight map
184
+ const fetchPromise = (async () => {
185
+ try {
186
+ const models = await this._fetchModels(endpoint, apiKey, fetchFn);
187
+ this.cache.set(cacheKey, { at: Date.now(), models });
188
+ return models;
189
+ } catch (err) {
190
+ // On error, if we have stale cache, serve stale cache
191
+ const stale = this.cache.get(cacheKey);
192
+ if (stale && Array.isArray(stale.models) && stale.models.length > 0) {
193
+ return stale.models;
194
+ }
195
+ // Fallback to known models
196
+ return Object.values(KNOWN_MODELS_FALLBACK).map(normalizeModelMeta);
197
+ } finally {
198
+ this.inflight.delete(cacheKey);
199
+ }
200
+ })();
201
+
202
+ this.inflight.set(cacheKey, fetchPromise);
203
+ return fetchPromise;
204
+ }
205
+
206
+ /**
207
+ * Gets metadata for a specific model ID.
208
+ * @param {string} modelId
209
+ * @param {object} [options]
210
+ * @returns {Promise<object|null>}
211
+ */
212
+ async getModel(modelId, options = {}) {
213
+ if (!modelId) return null;
214
+ const models = await this.getModels(options);
215
+ const found = models.find((m) => m.id === modelId);
216
+ if (found) return found;
217
+ return normalizeModelMeta(modelId);
218
+ }
219
+
220
+ async _fetchModels(endpoint, apiKey, fetchFn) {
221
+ if (!endpoint || typeof fetchFn !== 'function') {
222
+ return Object.values(KNOWN_MODELS_FALLBACK).map(normalizeModelMeta);
223
+ }
224
+
225
+ let url = endpoint;
226
+ if (!url.endsWith('/models') && !url.includes('/models?')) {
227
+ url = url.replace(/\/+$/, '') + '/models';
228
+ }
229
+
230
+ const headers = {
231
+ accept: 'application/json',
232
+ 'user-agent': 'dsh-key-rotation/model-catalog',
233
+ };
234
+ if (apiKey) {
235
+ headers.authorization = `Bearer ${apiKey}`;
236
+ }
237
+
238
+ const controller = new AbortController();
239
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
240
+
241
+ try {
242
+ const resp = await fetchFn(url, {
243
+ headers,
244
+ signal: controller.signal,
245
+ });
246
+
247
+ if (!resp.ok) {
248
+ throw new Error(`Catalog fetch failed with status ${resp.status}`);
249
+ }
250
+
251
+ const body = await resp.json();
252
+ let rawList = [];
253
+
254
+ if (Array.isArray(body?.data)) {
255
+ rawList = body.data;
256
+ } else if (Array.isArray(body?.models)) {
257
+ rawList = body.models;
258
+ } else if (body?.models && typeof body.models === 'object') {
259
+ rawList = Object.entries(body.models).map(([k, v]) => ({ id: k, ...(typeof v === 'object' ? v : {}) }));
260
+ }
261
+
262
+ if (rawList.length === 0) {
263
+ return Object.values(KNOWN_MODELS_FALLBACK).map(normalizeModelMeta);
264
+ }
265
+
266
+ return rawList.map(normalizeModelMeta);
267
+ } finally {
268
+ clearTimeout(timer);
269
+ }
270
+ }
271
+
272
+ invalidate(endpoint, apiKey) {
273
+ const key = this._buildKey(endpoint, apiKey);
274
+ this.cache.delete(key);
275
+ this.inflight.delete(key);
276
+ }
277
+
278
+ clear() {
279
+ this.cache.clear();
280
+ this.inflight.clear();
281
+ }
282
+
283
+ getStatus(endpoint, apiKey) {
284
+ const key = this._buildKey(endpoint, apiKey);
285
+ const entry = this.cache.get(key);
286
+ const isInflight = this.inflight.has(key);
287
+ return {
288
+ cached: Boolean(entry),
289
+ count: entry?.models?.length ?? 0,
290
+ fetchedAt: entry?.at ?? null,
291
+ inflight: isInflight,
292
+ };
293
+ }
294
+ }
295
+
296
+ export const defaultModelCatalog = new ModelCatalog();
@@ -1,30 +1,98 @@
1
- // lib/pool-selection.js — pool selection, sticky sessions, attempt sorting and budget metrics (#512).
1
+ // lib/pool-selection.js — pool selection, sticky sessions, attempt sorting and budget metrics (#512, #548, #549).
2
2
  // Extracted from lib/pool.js to enforce < 600 line modularity standard.
3
3
 
4
- export const MAX_SESSION_STICKY_ENTRIES = 500;
4
+ import {
5
+ SessionRouter,
6
+ defaultSessionRouter,
7
+ MAX_SESSIONS,
8
+ SESSION_TTL_MS,
9
+ } from './session-router.js';
5
10
 
6
- export function setSessionSticky(pool, sessionId, candidate) {
7
- if (!sessionId || !candidate || !pool?.state) return;
8
- if (!(pool.state.sessionSticky instanceof Map)) {
9
- pool.state.sessionSticky = new Map();
11
+ import {
12
+ QuotaRunway,
13
+ defaultQuotaRunway,
14
+ } from './quota-runway.js';
15
+
16
+ export {
17
+ SessionRouter,
18
+ defaultSessionRouter,
19
+ MAX_SESSIONS,
20
+ SESSION_TTL_MS,
21
+ QuotaRunway,
22
+ defaultQuotaRunway,
23
+ };
24
+
25
+ export const MAX_SESSION_STICKY_ENTRIES = MAX_SESSIONS;
26
+
27
+ /**
28
+ * Get or initialize the SessionRouter on pool.state or fallback to default.
29
+ * @param {object} [pool]
30
+ * @returns {SessionRouter}
31
+ */
32
+ export function getOrCreateSessionRouter(pool) {
33
+ if (!pool?.state) return defaultSessionRouter;
34
+ if (!pool.state.sessionRouter) {
35
+ pool.state.sessionRouter = new SessionRouter({
36
+ maxSessions: MAX_SESSIONS,
37
+ sessionTtlMs: SESSION_TTL_MS,
38
+ });
10
39
  }
11
- if (pool.state.sessionSticky.has(sessionId)) {
12
- pool.state.sessionSticky.delete(sessionId);
13
- } else if (pool.state.sessionSticky.size >= MAX_SESSION_STICKY_ENTRIES) {
14
- const oldest = pool.state.sessionSticky.keys().next().value;
15
- if (oldest !== undefined) pool.state.sessionSticky.delete(oldest);
40
+ return pool.state.sessionRouter;
41
+ }
42
+
43
+ export function setSessionSticky(pool, sessionId, candidate) {
44
+ if (!sessionId || !candidate) return;
45
+ const router = getOrCreateSessionRouter(pool);
46
+ router.setSession(sessionId, candidate);
47
+ if (pool?.state) {
48
+ if (!(pool.state.sessionSticky instanceof Map)) {
49
+ pool.state.sessionSticky = new Map();
50
+ }
51
+ if (pool.state.sessionSticky.has(sessionId)) {
52
+ pool.state.sessionSticky.delete(sessionId);
53
+ } else if (pool.state.sessionSticky.size >= MAX_SESSION_STICKY_ENTRIES) {
54
+ const oldest = pool.state.sessionSticky.keys().next().value;
55
+ if (oldest !== undefined) pool.state.sessionSticky.delete(oldest);
56
+ }
57
+ pool.state.sessionSticky.set(sessionId, candidate);
16
58
  }
17
- pool.state.sessionSticky.set(sessionId, candidate);
18
59
  }
19
60
 
20
61
  export function getSessionSticky(pool, sessionId) {
21
- if (!sessionId || !pool?.state?.sessionSticky) return null;
22
- return pool.state.sessionSticky.get(sessionId) ?? null;
62
+ if (!sessionId) return null;
63
+ const router = getOrCreateSessionRouter(pool);
64
+ const fromRouter = router.getSession(sessionId);
65
+ if (fromRouter) return fromRouter;
66
+ return pool?.state?.sessionSticky?.get?.(sessionId) ?? null;
23
67
  }
24
68
 
25
69
  export function clearSessionSticky(pool, sessionId) {
26
- if (!sessionId || !pool?.state?.sessionSticky) return;
27
- pool.state.sessionSticky.delete(sessionId);
70
+ if (!sessionId) return;
71
+ const router = getOrCreateSessionRouter(pool);
72
+ router.unstick(sessionId);
73
+ if (pool?.state?.sessionSticky) {
74
+ pool.state.sessionSticky.delete(sessionId);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Proactively unstick all sessions pinned to a cooling or failed key (#548).
80
+ * @param {object} pool
81
+ * @param {string} keyRef
82
+ * @returns {number}
83
+ */
84
+ export function unstickFailedKey(pool, keyRef) {
85
+ if (!keyRef) return 0;
86
+ const router = getOrCreateSessionRouter(pool);
87
+ const count = router.unstickKey(keyRef);
88
+ if (pool?.state?.sessionSticky) {
89
+ for (const [sId, ref] of pool.state.sessionSticky.entries()) {
90
+ if (ref === keyRef) {
91
+ pool.state.sessionSticky.delete(sId);
92
+ }
93
+ }
94
+ }
95
+ return count;
28
96
  }
29
97
 
30
98
  /** Pick a key pool for a (provider, model) pair. */
@@ -42,16 +110,31 @@ export function selectPool(modelPoolByProvider, providerToPool, provider, model)
42
110
 
43
111
  /**
44
112
  * Sort candidate refs according to pool routing strategy.
113
+ * Preemptively deprioritizes keys projected to exhaust their quota before reset (#549).
45
114
  * Returns a new array; does not mutate input.
46
115
  */
47
116
  export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
48
117
  if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
49
- const list = refs.slice();
118
+ let list = refs.slice();
119
+
50
120
  if (strategy === 'sticky') {
51
- const last = deps.sessionSticky ||
121
+ const router = getOrCreateSessionRouter(deps.pool);
122
+ let chosenKey = null;
123
+
124
+ if (deps.sessionId) {
125
+ const resolved = router.resolveSessionKey(deps.sessionId, list);
126
+ if (resolved && resolved.keyRef) {
127
+ chosenKey = resolved.keyRef;
128
+ if (deps.pool) setSessionSticky(deps.pool, deps.sessionId, chosenKey);
129
+ }
130
+ }
131
+
132
+ const last = chosenKey ||
133
+ deps.sessionSticky ||
52
134
  (deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
53
135
  deps.lastUsed ||
54
136
  (deps.pool && deps.pool.state && deps.pool.state.lastUsed);
137
+
55
138
  if (last && list.includes(last)) {
56
139
  const idx = list.indexOf(last);
57
140
  if (idx > 0) {
@@ -59,19 +142,16 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
59
142
  list.unshift(last);
60
143
  }
61
144
  }
62
- return list;
63
- }
64
- if (strategy === 'tier-priority') {
145
+ } else if (strategy === 'tier-priority') {
65
146
  const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
66
- return list.sort((a, b) => {
147
+ list = list.sort((a, b) => {
67
148
  const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
68
149
  const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
69
150
  return ta - tb;
70
151
  });
71
- }
72
- if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
152
+ } else if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
73
153
  const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
74
- return list.sort((a, b) => {
154
+ list = list.sort((a, b) => {
75
155
  const ca = deps.concurrencyTracker.getActive(a) ?? 0;
76
156
  const cb = deps.concurrencyTracker.getActive(b) ?? 0;
77
157
  const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
@@ -81,9 +161,8 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
81
161
  if (scoreA !== scoreB) return scoreA - scoreB;
82
162
  return ca - cb;
83
163
  });
84
- }
85
- if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
86
- return list.sort((a, b) => {
164
+ } else if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
165
+ list = list.sort((a, b) => {
87
166
  const sa = deps.latencyHistogram.snapshot(a);
88
167
  const sb = deps.latencyHistogram.snapshot(b);
89
168
  const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
@@ -91,6 +170,25 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
91
170
  return la - lb;
92
171
  });
93
172
  }
173
+
174
+ // Preemptively deprioritize keys projected to exhaust before quota window reset (#549)
175
+ const runway = deps.quotaRunway ?? deps.pool?.state?.quotaRunway;
176
+ if (runway && typeof runway.isExhaustingBeforeReset === 'function' && list.length > 1) {
177
+ const now = typeof deps.now === 'function' ? deps.now() : (Number(deps.now) || Date.now());
178
+ const healthy = [];
179
+ const exhausting = [];
180
+ for (const ref of list) {
181
+ if (runway.isExhaustingBeforeReset(ref, now)) {
182
+ exhausting.push(ref);
183
+ } else {
184
+ healthy.push(ref);
185
+ }
186
+ }
187
+ if (healthy.length > 0 && exhausting.length > 0) {
188
+ return healthy.concat(exhausting);
189
+ }
190
+ }
191
+
94
192
  return list;
95
193
  }
96
194
 
package/lib/pool.js CHANGED
@@ -1,16 +1,20 @@
1
1
  // lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
2
2
  import {
3
3
  MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
4
+ unstickFailedKey, SessionRouter, defaultSessionRouter, MAX_SESSIONS, SESSION_TTL_MS,
4
5
  selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
5
6
  costForDay, costForWeek, budgetVerdict,
6
7
  } from './pool-selection.js';
7
8
 
8
9
  export {
9
10
  MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
11
+ unstickFailedKey, SessionRouter, defaultSessionRouter, MAX_SESSIONS, SESSION_TTL_MS,
10
12
  selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
11
13
  costForDay, costForWeek, budgetVerdict,
12
14
  };
13
15
 
16
+ import { classifyResponseError, CONTEXT_WINDOW_EXCEEDED_CODE } from './errors.js';
17
+ export { classifyResponseError };
14
18
  import { resetModelQuotaIfNeeded } from './model-quota.js';
15
19
  import { decryptSecret } from './crypto-storage.js';
16
20
  import { bestEffort } from './best-effort.js';
@@ -190,6 +194,7 @@ export function pickNext(pool, now, refsCount = pool.refs.length) {
190
194
 
191
195
  /** Apply a failed-key cooldown to pool state. Pure: returns next state shape. */
192
196
  export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
197
+ unstickFailedKey(pool, ref);
193
198
  return {
194
199
  ...pool,
195
200
  state: {
@@ -218,6 +223,7 @@ export function computeBackoff(baseMs, failCount, maxMs, isSoft = false) {
218
223
 
219
224
  /** Record a failure for `ref` in `pool.state`. */
220
225
  export function recordFailure(pool, ref, now, baseMs, maxMs, isSoft = false, jitter = false) {
226
+ unstickFailedKey(pool, ref);
221
227
  if (!pool.state.failCounts) pool.state.failCounts = new Map();
222
228
  let next = pool.state.failCounts.get(ref) ?? 0;
223
229
  if (!isSoft) {
@@ -494,6 +500,18 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
494
500
  status = Number(rawCode);
495
501
  }
496
502
 
503
+ // 0. Structured response error classification (#547)
504
+ if (status || failure.body || message.startsWith('{') || (code && code !== 'ERROR')) {
505
+ const typed = classifyResponseError(status, failure.body ?? message, failure.headers ?? failure.response?.headers);
506
+ if (typed.code === CONTEXT_WINDOW_EXCEEDED_CODE) return false;
507
+ if ((typed.code === 'QUOTA' || typed.code === 'ACCOUNT_QUOTA') && (switchCodes.has('QUOTA') || switchCodes.has('RATE_LIMIT') || switchCodes.has('429'))) return true;
508
+ if (typed.code === 'RATE_LIMIT' && (switchCodes.has('RATE_LIMIT') || switchCodes.has('429'))) return true;
509
+ if ((typed.code === 'AUTH' || typed.code === 'INVALID_CREDENTIAL') && (switchCodes.has('AUTH') || switchCodes.has('401') || switchCodes.has('403'))) return true;
510
+ if (typed.code === 'SERVER' && (switchCodes.has('SERVER') || (status >= 500 && status <= 504 && switchCodes.has(String(status))))) return true;
511
+ if (typed.code === 'TIMEOUT' && (switchCodes.has('TIMEOUT') || switchCodes.has('408') || switchCodes.has('504'))) return true;
512
+ if (switchCodes.has(typed.code)) return true;
513
+ }
514
+
497
515
  // 1. Direct HTTP status codes (#267: also 408/425 transient, #545: numeric status/code mapped to semantic switch codes)
498
516
  if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
499
517
  if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401') || switchCodes.has('403') || switchCodes.has(String(status)))) return true;
@@ -576,3 +594,4 @@ export function isKeyBroken(pool, ref, now = Date.now()) {
576
594
  if (pool.basePool) return isKeyBroken(pool.basePool, ref, now);
577
595
  return false;
578
596
  }
597
+