@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.
- package/CHANGELOG.md +14 -0
- package/lib/client-src/00-header.js +40 -0
- package/lib/client-src/10-locales.js +585 -0
- package/lib/client-src/20-hooks.js +55 -0
- package/lib/client-src/30-utils.js +185 -0
- package/lib/client-src/40-components.js +346 -0
- package/lib/client-src/50-card-main.js +647 -0
- package/lib/client-src/51-card-pool-mutators.js +501 -0
- package/lib/client-src/52-card-pool-rows.js +590 -0
- package/lib/client-src/53-card-dialogs-and-render.js +568 -0
- package/lib/client-src/60-header-chip.js +137 -0
- package/lib/client-src/90-apply.js +69 -0
- package/lib/error-taxonomy.js +59 -10
- package/lib/errors.js +302 -0
- package/lib/index.js +6 -0
- package/lib/model-catalog.js +296 -0
- package/lib/pool-selection.js +126 -28
- package/lib/pool.js +19 -0
- package/lib/quota-runway.js +222 -0
- package/lib/rotate.js +61 -11
- package/lib/session-router.js +265 -0
- package/lib/stream-metrics.js +13 -5
- package/lib/transports/anthropic.js +125 -0
- package/lib/transports/index.js +23 -0
- package/lib/transports/openai.js +93 -0
- package/lib/transports/sse.js +160 -0
- package/lib/transports/tokens.js +124 -0
- package/package.json +5 -1
|
@@ -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();
|
package/lib/pool-selection.js
CHANGED
|
@@ -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
|
-
|
|
4
|
+
import {
|
|
5
|
+
SessionRouter,
|
|
6
|
+
defaultSessionRouter,
|
|
7
|
+
MAX_SESSIONS,
|
|
8
|
+
SESSION_TTL_MS,
|
|
9
|
+
} from './session-router.js';
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
27
|
-
pool
|
|
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
|
-
|
|
118
|
+
let list = refs.slice();
|
|
119
|
+
|
|
50
120
|
if (strategy === 'sticky') {
|
|
51
|
-
const
|
|
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
|
-
|
|
63
|
-
}
|
|
64
|
-
if (strategy === 'tier-priority') {
|
|
145
|
+
} else if (strategy === 'tier-priority') {
|
|
65
146
|
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|