@goodandready/dsh-key-rotation 0.8.77 → 0.8.79

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/lib/client-src/00-header.js +40 -0
  3. package/lib/client-src/10-locales.js +585 -0
  4. package/lib/client-src/20-hooks.js +55 -0
  5. package/lib/client-src/30-utils.js +185 -0
  6. package/lib/client-src/40-components.js +346 -0
  7. package/lib/client-src/50-card-main.js +647 -0
  8. package/lib/client-src/51-card-pool-mutators.js +501 -0
  9. package/lib/client-src/52-card-pool-rows.js +590 -0
  10. package/lib/client-src/53-card-dialogs-and-render.js +568 -0
  11. package/lib/client-src/60-header-chip.js +137 -0
  12. package/lib/client-src/90-apply.js +69 -0
  13. package/lib/config-compat.js +18 -1
  14. package/lib/http-bridge.js +2 -0
  15. package/lib/index.js +7 -1
  16. package/lib/key-state-machine.js +267 -0
  17. package/lib/model-catalog.js +296 -0
  18. package/lib/ops-balance.js +2 -147
  19. package/lib/ops-config-io.js +2 -456
  20. package/lib/ops-keys.js +2 -567
  21. package/lib/ops-metrics.js +2 -92
  22. package/lib/ops-paths.js +2 -24
  23. package/lib/ops-quick-action.js +2 -168
  24. package/lib/ops-status.js +2 -269
  25. package/lib/ops-telemetry.js +2 -276
  26. package/lib/ops-test.js +2 -255
  27. package/lib/ops-webhook.js +2 -218
  28. package/lib/pool-selection.js +36 -13
  29. package/lib/pool-state.js +3 -0
  30. package/lib/pool.js +10 -15
  31. package/lib/quota-runway.js +222 -0
  32. package/lib/routes/balance.js +147 -0
  33. package/lib/routes/config.js +456 -0
  34. package/lib/routes/guard.js +13 -0
  35. package/lib/routes/index.js +43 -0
  36. package/lib/routes/keys.js +567 -0
  37. package/lib/routes/metrics.js +92 -0
  38. package/lib/routes/paths.js +24 -0
  39. package/lib/routes/quick-action.js +168 -0
  40. package/lib/routes/status.js +269 -0
  41. package/lib/routes/telemetry.js +276 -0
  42. package/lib/routes/testing.js +255 -0
  43. package/lib/routes/webhook.js +218 -0
  44. package/lib/routes-ops.js +2 -29
  45. package/lib/stream-metrics.js +13 -5
  46. package/lib/transports/anthropic.js +125 -0
  47. package/lib/transports/index.js +23 -0
  48. package/lib/transports/openai.js +93 -0
  49. package/lib/transports/sse.js +160 -0
  50. package/lib/transports/tokens.js +124 -0
  51. package/package.json +15 -10
@@ -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,147 +1,2 @@
1
- // lib/ops-balance.js — operational HTTP routes for balance inspection (#495).
2
-
3
- import {
4
- json,
5
- readJson,
6
- } from './http-bridge.js';
7
- import {
8
- isTrustedBridgeRequest,
9
- isValidRef,
10
- } from './pool.js';
11
- import { BALANCE_PATH } from './ops-paths.js';
12
- import { inspectBalanceForRef } from './balance-inspector.js';
13
- import { pushEvent } from './notify-events.js';
14
-
15
- export function registerBalanceRoutes(ctx, deps) {
16
- const {
17
- poolState,
18
- buildRuntime,
19
- } = deps;
20
-
21
- ctx.effect(() => ctx.webServer.register({
22
- kind: 'exact',
23
- path: BALANCE_PATH,
24
- handler: async (req, res) => {
25
- if (!isTrustedBridgeRequest(req)) {
26
- json(res, 403, { ok: false, error: 'Forbidden' });
27
- return;
28
- }
29
-
30
- const urlObj = new URL(req.url, 'http://127.0.0.1');
31
-
32
- // GET: Query single key or provider cached balances
33
- if (req.method === 'GET') {
34
- const ref = urlObj.searchParams.get('ref');
35
- const provider = urlObj.searchParams.get('provider');
36
-
37
- if (ref) {
38
- const runtime = buildRuntime();
39
- const targetPool = provider ? runtime.pools?.find((p) => p.provider === provider) : null;
40
- const pool = targetPool ?? runtime.poolByRef?.get(ref);
41
- const cached = pool?.state?.balances?.get(ref) ?? null;
42
-
43
- if (urlObj.searchParams.get('refresh') === 'true' && provider) {
44
- const inspectRes = await inspectBalanceForRef(ctx, ref, provider);
45
- if (inspectRes.ok) {
46
- if (pool && pool.state) {
47
- if (!pool.state.balances) pool.state.balances = new Map();
48
- pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
49
-
50
- // Check soft pause
51
- applyBalanceState(pool, ref, inspectRes);
52
- }
53
- json(res, 200, { ok: true, ref, balance: inspectRes });
54
- return;
55
- }
56
- json(res, 200, { ok: false, ref, error: inspectRes.error, cached });
57
- return;
58
- }
59
-
60
- json(res, 200, { ok: true, ref, balance: cached });
61
- return;
62
- }
63
-
64
- // Return all cached balances
65
- const out = {};
66
- if (poolState && typeof poolState.forEach === 'function') {
67
- poolState.forEach((st, provName) => {
68
- if (st && st.balances instanceof Map) {
69
- for (const [k, v] of st.balances) {
70
- out[k] = v;
71
- }
72
- }
73
- });
74
- }
75
- json(res, 200, { ok: true, balances: out });
76
- return;
77
- }
78
-
79
- // POST: Trigger refresh of balances for a provider or key list
80
- if (req.method === 'POST') {
81
- const body = await readJson(req);
82
- const provider = body?.provider;
83
- const ref = body?.ref;
84
- const testValue = body?.value;
85
-
86
- if (ref && provider) {
87
- const inspectRes = await inspectBalanceForRef(ctx, ref, provider, { testValue });
88
- const runtime = buildRuntime();
89
- const pool = runtime.pools?.find((p) => p.provider === provider) ?? runtime.poolByRef?.get(ref);
90
-
91
- if (inspectRes.ok && pool && pool.state) {
92
- if (!pool.state.balances) pool.state.balances = new Map();
93
- pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
94
- applyBalanceState(pool, ref, inspectRes);
95
- }
96
-
97
- json(res, inspectRes.ok ? 200 : 400, { ok: inspectRes.ok, ref, balance: inspectRes });
98
- return;
99
- }
100
-
101
- if (provider) {
102
- const runtime = buildRuntime();
103
- const pool = runtime.pools?.find((p) => p.provider === provider);
104
- if (!pool || !Array.isArray(pool.refs) || pool.refs.length === 0) {
105
- json(res, 404, { ok: false, error: `Provider '${provider}' not found or has no keys` });
106
- return;
107
- }
108
-
109
- const results = {};
110
- for (const kRef of pool.refs) {
111
- const r = await inspectBalanceForRef(ctx, kRef, provider);
112
- results[kRef] = r;
113
- if (r.ok && pool.state) {
114
- if (!pool.state.balances) pool.state.balances = new Map();
115
- pool.state.balances.set(kRef, { ...r, at: Date.now() });
116
- applyBalanceState(pool, kRef, r);
117
- }
118
- }
119
- json(res, 200, { ok: true, provider, balances: results });
120
- return;
121
- }
122
-
123
- json(res, 400, { ok: false, error: 'ref or provider required' });
124
- return;
125
- }
126
-
127
- json(res, 405, { ok: false, error: 'Method Not Allowed' });
128
- },
129
- }), 'dsh-key-rotation: balance routes');
130
- }
131
-
132
- function applyBalanceState(pool, ref, balanceRes) {
133
- if (!pool || !pool.state) return;
134
- const isZero = balanceRes.isAvailable === false || (typeof balanceRes.total === 'number' && balanceRes.total <= 0);
135
-
136
- if (isZero) {
137
- if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
138
- if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(ref);
139
- if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
140
- pool.state.pauseReasons.set(ref, 'EMPTY_BALANCE_PAUSED');
141
- pushEvent(pool, ref, 'BALANCE_EMPTY', 0);
142
- } else if (pool.state.pauseReasons?.get?.(ref) === 'EMPTY_BALANCE_PAUSED') {
143
- if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.delete(ref);
144
- pool.state.pauseReasons.delete(ref);
145
- pushEvent(pool, ref, 'BALANCE_RESTORED', 0);
146
- }
147
- }
1
+ // lib/ops-balance.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/balance.js";