dsh-keys-manager 0.0.0 → 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,1782 @@
1
+ /**
2
+ * Keys Manager Host half.
3
+ *
4
+ * Responsibilities:
5
+ * - Per-provider API-key CRUD, with key literals in the Harness credential
6
+ * store and secret-free metadata in `<DSH_HOME>/keys-manager/state.json`.
7
+ * - Extend an existing provider's `apiKeyEnv` to a broker reference and
8
+ * answer that reference per request, so the original pi-ai / DeepSeek
9
+ * adapters keep working without patching their transports.
10
+ * - Sticky per-session round-robin selection, bounded retry, key failover,
11
+ * and exponential cooldown recovery.
12
+ * - Install searchable extra built-in providers as normal pi-ai routes.
13
+ * - A small Typert Remote namespace that the Web settings page calls through
14
+ * `ctx.connection.rpc`.
15
+ */
16
+ import { AsyncLocalStorage } from 'node:async_hooks';
17
+ import { createHash, randomBytes } from 'node:crypto';
18
+ import { KEY_LIMIT, applyFailureCooldown, bindingKey, isSelectable, markKeySuccess, markKeyUsed, normalizeSettings, patchSettings, pickHealthyExcluding, pickRoundRobin, shouldFailover, statusOfKey, statusOfProvider, validateKeyInput, validateKeyPatch, } from './core.js';
19
+ import { EXTRA_PROVIDER_MAP, EXTRA_PROVIDERS } from './providers.js';
20
+ import { KeysManagerStore, ensureStateFile, statePath } from './store.js';
21
+ export const name = 'keys-manager';
22
+ export const inject = ['llm', 'credentials', 'typert'];
23
+ export const provide = ['keysManager'];
24
+ const SERVICE = 'keysManager';
25
+ const NAMESPACE = 'keysManager';
26
+ const BROKER_PREFIX = 'KEYSMGR_';
27
+ const ORIGINAL = Symbol.for('cordis.original');
28
+ const MAX_SWITCHES = 6;
29
+ const ATTEMPT_TTL_MS = 60 * 60 * 1000;
30
+ /** A retry pin only lives for this long; an expired pin is treated as absent. */
31
+ const PIN_TTL_MS = 60_000;
32
+ function endpoint(method, parameters = []) {
33
+ return {
34
+ id: `dsh-keys-manager#${NAMESPACE}/${method}`,
35
+ service: SERVICE,
36
+ namespace: NAMESPACE,
37
+ method,
38
+ invocation: { kind: 'direct' },
39
+ parameters: parameters.map(field => ({
40
+ name: field,
41
+ wire: field,
42
+ source: 'json',
43
+ codec: { mode: 'src-json' },
44
+ })),
45
+ result: { mode: 'src-json' },
46
+ };
47
+ }
48
+ const INVOCATIONS = [
49
+ endpoint('getState'),
50
+ endpoint('adoptProvider', ['provider']),
51
+ endpoint('addKey', ['provider', 'alias', 'value']),
52
+ endpoint('updateKey', ['provider', 'keyId', 'patch']),
53
+ endpoint('deleteKey', ['provider', 'keyId']),
54
+ endpoint('releaseProvider', ['provider']),
55
+ endpoint('addExtraProvider', ['extraId', 'alias', 'value']),
56
+ endpoint('setSettings', ['patch']),
57
+ ];
58
+ /** Read a nested value by path. */
59
+ function getPath(value, path) {
60
+ let current = value;
61
+ for (const part of path) {
62
+ if (current === null || typeof current !== 'object')
63
+ return undefined;
64
+ current = current[part];
65
+ }
66
+ return current;
67
+ }
68
+ function asProfile(value) {
69
+ return value !== null && typeof value === 'object' ? value : undefined;
70
+ }
71
+ /** Stable broker credential reference for one provider route. */
72
+ function brokerRefFor(provider) {
73
+ const stem = provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 34);
74
+ const hash = createHash('sha256').update(provider).digest('hex').slice(0, 6).toUpperCase();
75
+ return `${BROKER_PREFIX}${stem || 'PROVIDER'}_${hash}`;
76
+ }
77
+ /** Reference the Models page would have derived for a provider route. */
78
+ function derivedKeyRef(provider) {
79
+ const stem = provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
80
+ return `${stem || 'PROVIDER'}_API_KEY`;
81
+ }
82
+ /** One fresh key credential reference beneath a provider broker. */
83
+ function newKeyRef(provider) {
84
+ return `${brokerRefFor(provider)}_K_${randomBytes(6).toString('hex').toUpperCase()}`;
85
+ }
86
+ /** One fresh non-secret key id. */
87
+ function newKeyId() {
88
+ return `k_${randomBytes(6).toString('hex')}`;
89
+ }
90
+ /** Validate a credential-reference grammar. */
91
+ function isCredentialRefName(value) {
92
+ return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
93
+ }
94
+ /** Normalize one model profile row for the pi-ai route. */
95
+ function normalizeModel(model) {
96
+ if (model === null || typeof model !== 'object')
97
+ return undefined;
98
+ const source = model;
99
+ if (typeof source.id !== 'string' || source.id.length === 0)
100
+ return undefined;
101
+ const next = { id: source.id };
102
+ if (typeof source.name === 'string' && source.name.length > 0)
103
+ next.name = source.name;
104
+ if (typeof source.contextWindow === 'number' && source.contextWindow > 0)
105
+ next.contextWindow = Math.round(source.contextWindow);
106
+ if (typeof source.maxTokens === 'number' && source.maxTokens > 0)
107
+ next.maxTokens = Math.round(source.maxTokens);
108
+ const input = Array.isArray(source.input)
109
+ ? source.input
110
+ : Array.isArray(source.inputModalities)
111
+ ? source.inputModalities
112
+ : [];
113
+ const modalities = input.filter((value) => value === 'text' || value === 'image');
114
+ if (modalities.length > 0)
115
+ next.input = [...new Set(modalities)];
116
+ if (source.reasoningEfforts !== null && typeof source.reasoningEfforts === 'object') {
117
+ next.reasoningEfforts = source.reasoningEfforts;
118
+ }
119
+ if (source.compat !== null && typeof source.compat === 'object') {
120
+ next.compat = source.compat;
121
+ }
122
+ return next;
123
+ }
124
+ /** Normalize an array of model rows, dropping unusable entries. */
125
+ function normalizeModels(raw) {
126
+ if (!Array.isArray(raw))
127
+ return [];
128
+ return raw.map(normalizeModel).filter((model) => model !== undefined);
129
+ }
130
+ /** Normalize one discovered model into the profile model shape. */
131
+ function discoveredModel(model) {
132
+ return normalizeModel({
133
+ id: model.id,
134
+ name: model.name,
135
+ contextWindow: model.contextWindow,
136
+ maxTokens: model.maxTokens,
137
+ inputModalities: model.inputModalities,
138
+ });
139
+ }
140
+ /** Public, secret-free key row. */
141
+ function publicKey(key, now) {
142
+ return {
143
+ id: key.id,
144
+ alias: key.alias,
145
+ status: statusOfKey(key, now),
146
+ enabled: key.enabled !== false,
147
+ createdAt: key.createdAt,
148
+ lastUsedAt: key.lastUsedAt,
149
+ cooldownUntil: key.cooldownUntil,
150
+ failureLevel: key.failureLevel,
151
+ consecutiveFailures: key.consecutiveFailures,
152
+ totalUses: key.totalUses,
153
+ totalFailures: key.totalFailures,
154
+ importedFrom: key.importedFrom ?? null,
155
+ valueMissing: key.valueMissing === true,
156
+ kind: key.kind ?? 'api-key',
157
+ };
158
+ }
159
+ /** Env names that are a single API-key credential, not an OAuth/federation token. */
160
+ function isKeyishEnv(name) {
161
+ if (!/(?:API_KEY|_TOKEN)$/.test(name))
162
+ return false;
163
+ if (/OAUTH/.test(name))
164
+ return false;
165
+ if (/AUTH_TOKEN|FEDERATION|ORGANIZATION|IDENTITY_TOKEN_FILE|SERVICE_ACCOUNT|WORKSPACE_ID/.test(name))
166
+ return false;
167
+ return true;
168
+ }
169
+ /**
170
+ * DSH catalog routes that are explicitly outside the single-key pool. Keeping
171
+ * this as a named table (rather than only deriving it from env names) makes a
172
+ * reviewed decision visible and stable when a provider adds another auth path.
173
+ */
174
+ const UNSUPPORTED_KEY_POOL_PROVIDERS = Object.freeze({
175
+ 'amazon-bedrock': 'Uses an AWS credential chain (profile, IAM keys, container/IRSA tokens), not a single API key',
176
+ 'cloudflare-ai-gateway': 'Needs Cloudflare account and gateway IDs in addition to the API key',
177
+ 'cloudflare-workers-ai': 'Needs a Cloudflare account ID in addition to the API key',
178
+ 'openai-codex': 'Uses ChatGPT OAuth sign-in only; no API-key credential',
179
+ });
180
+ /** Whether the provider looks like it can authenticate with one API key. */
181
+ function supportOf(entry, profile, managed) {
182
+ if (managed !== undefined)
183
+ return { supported: true };
184
+ if (entry.error !== undefined)
185
+ return { supported: false, reason: entry.error };
186
+ const unsupportedReason = UNSUPPORTED_KEY_POOL_PROVIDERS[entry.provider];
187
+ if (unsupportedReason !== undefined)
188
+ return { supported: false, reason: unsupportedReason };
189
+ if (entry.provider === 'deepseek-official')
190
+ return { supported: true };
191
+ if (profile !== undefined && typeof profile.apiKeyEnv === 'string' && profile.apiKeyEnv.length > 0)
192
+ return { supported: true };
193
+ const auth = entry.nativeAuth;
194
+ if (auth !== undefined) {
195
+ if (auth.env.some(isKeyishEnv))
196
+ return { supported: true };
197
+ if (auth.oauth)
198
+ return { supported: false, reason: 'OAuth sign-in is not managed yet' };
199
+ if (auth.env.length > 0)
200
+ return { supported: false, reason: 'This provider uses native cloud credentials, not one API key' };
201
+ return { supported: false, reason: 'This provider declares no API-key authentication' };
202
+ }
203
+ return { supported: true };
204
+ }
205
+ export class KeysManagerService {
206
+ ctx;
207
+ store;
208
+ als = new AsyncLocalStorage();
209
+ attempts = new Map();
210
+ stepAttempts = new Map();
211
+ streamAttempts = new Map();
212
+ linkedAttempts = new WeakMap();
213
+ retryBindings = new Map();
214
+ requestSteps = new Map();
215
+ attemptSequence = 0;
216
+ switches = new Map();
217
+ warnings = [];
218
+ settings;
219
+ rawCredentials;
220
+ credentialOriginals = new WeakMap();
221
+ credentialWrappers = new WeakMap();
222
+ brokerOwners = new Map();
223
+ bindingCache = new Map();
224
+ lastBindingPruneAt = 0;
225
+ selectionQueues = new Map();
226
+ queue = Promise.resolve();
227
+ closing = false;
228
+ typertRemote;
229
+ constructor(ctx, store) {
230
+ this.ctx = ctx;
231
+ this.store = store;
232
+ this.typertRemote = Object.freeze({ service: this, serviceKey: SERVICE, namespace: NAMESPACE });
233
+ this.rebuildBrokerMap();
234
+ }
235
+ /** Log without ever echoing secret material. */
236
+ warn(message) {
237
+ try {
238
+ this.ctx.logger.warn(`keys-manager: ${message}`);
239
+ }
240
+ catch {
241
+ // Logging must never break request handling.
242
+ }
243
+ }
244
+ /** Rebuild the broker-reference → provider index. */
245
+ rebuildBrokerMap() {
246
+ this.bindingCache.clear();
247
+ this.brokerOwners = new Map();
248
+ for (const provider of Object.values(this.store.providers)) {
249
+ if (provider.brokerRef.length > 0)
250
+ this.brokerOwners.set(provider.brokerRef, provider.provider);
251
+ }
252
+ for (const [key, pin] of this.retryBindings) {
253
+ const providerId = key.split('\u0000')[1];
254
+ const provider = providerId === undefined ? undefined : this.store.provider(providerId);
255
+ if (provider === undefined || !provider.keys.some(candidate => candidate.id === pin.keyId)) {
256
+ this.retryBindings.delete(key);
257
+ }
258
+ }
259
+ }
260
+ /** Serialize mutations, rejecting submissions that arrive during shutdown. */
261
+ run(operation) {
262
+ if (this.closing)
263
+ return Promise.reject(new Error('Keys Manager is shutting down'));
264
+ const guarded = async () => {
265
+ if (this.store.readOnly) {
266
+ throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
267
+ }
268
+ const result = await operation();
269
+ // A mutation is not successful until its state document is durable.
270
+ await this.store.flush();
271
+ return result;
272
+ };
273
+ const next = this.queue.then(guarded, guarded);
274
+ this.queue = next.catch(() => { });
275
+ return next;
276
+ }
277
+ /**
278
+ * Periodically drop expired transient state: retry pins after `PIN_TTL_MS`,
279
+ * the remaining attempt/step records after `ATTEMPT_TTL_MS`.
280
+ */
281
+ pruneTransient(now = Date.now()) {
282
+ this.pruneAttempts(now);
283
+ }
284
+ /** Stop accepting mutations, drain the queue, then persist the final snapshot. */
285
+ async close() {
286
+ this.closing = true;
287
+ try {
288
+ await this.queue;
289
+ }
290
+ catch {
291
+ // The mutation queue already reports failures to its caller.
292
+ }
293
+ await this.store.close();
294
+ }
295
+ /** Push a user-facing warning that rides the next state response. */
296
+ pushWarning(message) {
297
+ this.warnings.push(message);
298
+ if (this.warnings.length > 4)
299
+ this.warnings.shift();
300
+ }
301
+ takeWarnings() {
302
+ const warnings = this.warnings;
303
+ this.warnings = [];
304
+ return warnings;
305
+ }
306
+ /** Current settings service, if the composition mounted one. */
307
+ settingsService() {
308
+ if (this.settings !== undefined)
309
+ return this.settings;
310
+ return this.ctx.get('settings');
311
+ }
312
+ /** Current raw credential provider. */
313
+ credentialsRaw() {
314
+ const current = this.ctx.get('credentials');
315
+ if (current === undefined)
316
+ return undefined;
317
+ return current[ORIGINAL] ?? current;
318
+ }
319
+ /** Install the broker layer on one credential provider instance. */
320
+ attachCredentials(credentials) {
321
+ const raw = credentials[ORIGINAL] ?? credentials;
322
+ if (this.credentialWrappers.has(raw))
323
+ return;
324
+ const original = {
325
+ resolve: raw.resolve,
326
+ describe: raw.describe,
327
+ set: raw.set,
328
+ unset: raw.unset,
329
+ };
330
+ const service = this;
331
+ const wrappers = {
332
+ resolve: async (ref) => {
333
+ const owner = service.brokerOwners.get(ref);
334
+ if (owner === undefined)
335
+ return original.resolve.call(raw, ref);
336
+ const active = service.als.getStore();
337
+ if (active !== undefined && active.provider === owner) {
338
+ if (typeof active.keyValue === 'string' && active.keyValue.length > 0) {
339
+ active.keyUsed = true;
340
+ return { value: active.keyValue, source: 'keys-manager' };
341
+ }
342
+ return undefined;
343
+ }
344
+ // Listing models has no request scope; answer from any stored key
345
+ // without moving the session cursor or changing health.
346
+ const provider = service.store.provider(owner);
347
+ if (provider !== undefined) {
348
+ const now = Date.now();
349
+ for (const key of provider.keys) {
350
+ if (key.ref.length === 0 || !isSelectable(key, now))
351
+ continue;
352
+ const hit = await original.resolve.call(raw, key.ref);
353
+ if (hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) {
354
+ return { ...hit, source: 'keys-manager' };
355
+ }
356
+ }
357
+ }
358
+ return undefined;
359
+ },
360
+ describe: async (ref) => {
361
+ const owner = service.brokerOwners.get(ref);
362
+ if (owner === undefined)
363
+ return original.describe.call(raw, ref);
364
+ const provider = service.store.provider(owner);
365
+ const configured = provider !== undefined && provider.keys.length > 0;
366
+ return {
367
+ configured,
368
+ ...(configured ? { source: 'keys-manager' } : {}),
369
+ writable: false,
370
+ };
371
+ },
372
+ set: async (ref, value) => {
373
+ if (service.brokerOwners.has(ref)) {
374
+ throw new Error('This provider key is managed by Keys Manager; edit it on the plugin detail page');
375
+ }
376
+ return original.set.call(raw, ref, value);
377
+ },
378
+ unset: async (ref) => {
379
+ if (service.brokerOwners.has(ref)) {
380
+ throw new Error('This provider key is managed by Keys Manager; edit it on the plugin detail page');
381
+ }
382
+ return original.unset.call(raw, ref);
383
+ },
384
+ };
385
+ raw.resolve = wrappers.resolve;
386
+ raw.describe = wrappers.describe;
387
+ raw.set = wrappers.set;
388
+ raw.unset = wrappers.unset;
389
+ this.credentialOriginals.set(raw, original);
390
+ this.credentialWrappers.set(raw, wrappers);
391
+ if (this.rawCredentials === undefined)
392
+ this.rawCredentials = raw;
393
+ }
394
+ /** Restore the credential provider methods when our fiber unloads. */
395
+ detachCredentials(credentials) {
396
+ const raw = credentials[ORIGINAL] ?? credentials;
397
+ const wrappers = this.credentialWrappers.get(raw);
398
+ const original = this.credentialOriginals.get(raw);
399
+ if (wrappers === undefined || original === undefined)
400
+ return;
401
+ if (raw.resolve === wrappers.resolve)
402
+ raw.resolve = original.resolve;
403
+ if (raw.describe === wrappers.describe)
404
+ raw.describe = original.describe;
405
+ if (raw.set === wrappers.set)
406
+ raw.set = original.set;
407
+ if (raw.unset === wrappers.unset)
408
+ raw.unset = original.unset;
409
+ this.credentialWrappers.delete(raw);
410
+ this.credentialOriginals.delete(raw);
411
+ if (this.rawCredentials === raw)
412
+ this.rawCredentials = undefined;
413
+ }
414
+ /** Resolve a credential through the unwrapped provider. */
415
+ async resolveOriginal(ref) {
416
+ if (!isCredentialRefName(ref))
417
+ return undefined;
418
+ const raw = this.credentialsRaw();
419
+ if (raw === undefined)
420
+ return undefined;
421
+ const original = this.credentialOriginals.get(raw);
422
+ const resolve = original?.resolve ?? raw.resolve;
423
+ try {
424
+ return await resolve.call(raw, ref);
425
+ }
426
+ catch {
427
+ return undefined;
428
+ }
429
+ }
430
+ /**
431
+ * Probe one credential reference while distinguishing absence from a
432
+ * transient credential-store failure. Selection must never persist a
433
+ * permanent `valueMissing` marker for an error.
434
+ */
435
+ async probeKey(ref) {
436
+ if (!isCredentialRefName(ref))
437
+ return { missing: false, failed: true };
438
+ const raw = this.credentialsRaw();
439
+ if (raw === undefined)
440
+ return { missing: false, failed: true };
441
+ const original = this.credentialOriginals.get(raw);
442
+ const resolve = original?.resolve ?? raw.resolve;
443
+ try {
444
+ const hit = await resolve.call(raw, ref);
445
+ if (hit === undefined || typeof hit.value !== 'string' || hit.value.length === 0) {
446
+ return { missing: true, failed: false };
447
+ }
448
+ return { hit, missing: false, failed: false };
449
+ }
450
+ catch {
451
+ return { missing: false, failed: true };
452
+ }
453
+ }
454
+ /** Write one key literal through the unwrapped provider. */
455
+ async setOriginal(ref, value) {
456
+ const raw = this.credentialsRaw();
457
+ if (raw === undefined)
458
+ throw new Error('The credential store is unavailable');
459
+ const original = this.credentialOriginals.get(raw);
460
+ const set = original?.set ?? raw.set;
461
+ await set.call(raw, ref, value);
462
+ }
463
+ /**
464
+ * Remove one key literal through the unwrapped provider. A rejected unset is
465
+ * only ignored when the credential is confirmed absent; a transient or
466
+ * permission failure must keep the metadata so a later retry can finish.
467
+ */
468
+ async unsetOriginal(ref) {
469
+ if (!isCredentialRefName(ref))
470
+ return;
471
+ const raw = this.credentialsRaw();
472
+ if (raw === undefined)
473
+ throw new Error('The credential store is unavailable');
474
+ const original = this.credentialOriginals.get(raw);
475
+ const unset = original?.unset ?? raw.unset;
476
+ try {
477
+ await unset.call(raw, ref);
478
+ }
479
+ catch (error) {
480
+ const probe = await this.probeKey(ref);
481
+ if (probe.missing)
482
+ return;
483
+ throw new Error(`Could not remove credential ${ref}: ${error instanceof Error ? error.message : String(error)}`);
484
+ }
485
+ }
486
+ /** All settings descriptors, with an explicit unreadable state. */
487
+ describeSettings() {
488
+ const settings = this.settingsService();
489
+ if (settings === undefined)
490
+ return { writable: false, namespaces: [], readable: false };
491
+ try {
492
+ return { writable: settings.writable !== false, namespaces: settings.describe({ redactSecrets: true }), readable: true };
493
+ }
494
+ catch (error) {
495
+ this.warn(`settings describe failed: ${error instanceof Error ? error.message : String(error)}`);
496
+ return { writable: false, namespaces: [], readable: false };
497
+ }
498
+ }
499
+ /** Find one live settings descriptor by entry id. */
500
+ namespace(descriptor, ns) {
501
+ return descriptor.namespaces.find(entry => entry.ns === ns);
502
+ }
503
+ /** Apply one path-addressed settings write through the live revision. */
504
+ async mutateSettings(ns, ops) {
505
+ const settings = this.settingsService();
506
+ if (settings === undefined)
507
+ throw new Error('Settings are unavailable in this composition');
508
+ if (settings.writable === false)
509
+ throw new Error('The active profile is read-only');
510
+ let descriptor;
511
+ try {
512
+ descriptor = settings.describe({ redactSecrets: true }).find(entry => entry.ns === ns);
513
+ }
514
+ catch (error) {
515
+ throw new Error(`Cannot read settings: ${error instanceof Error ? error.message : String(error)}`);
516
+ }
517
+ if (descriptor === undefined)
518
+ throw new Error(`Settings namespace "${ns}" is not mounted`);
519
+ await settings.mutate(ns, ops, descriptor.revision);
520
+ }
521
+ /** Prune expired bindings at most once a minute, without a restart. */
522
+ pruneBindings(now) {
523
+ if (now - this.lastBindingPruneAt < 60_000)
524
+ return;
525
+ this.lastBindingPruneAt = now;
526
+ this.store.pruneBindings(now);
527
+ this.pruneAttempts(now);
528
+ }
529
+ /** Build the complete UI state. */
530
+ buildState() {
531
+ const now = Date.now();
532
+ this.pruneBindings(now);
533
+ const descriptor = this.describeSettings();
534
+ const directory = this.ctx.llm.listConfigurableProviders();
535
+ const active = new Set(this.ctx.llm.listProviders().map(provider => provider.id));
536
+ const views = [];
537
+ const seen = new Set();
538
+ for (const entry of directory) {
539
+ if (entry.provider === 'deepseek-account' || entry.settingsNs === 'deepseek-account' || entry.provider.startsWith('deepseek-account'))
540
+ continue;
541
+ const namespace = this.namespace(descriptor, entry.settingsNs);
542
+ const profile = namespace === undefined ? undefined : asProfile(getPath(namespace.value, entry.settingsPath));
543
+ const managed = this.store.provider(entry.provider);
544
+ const extra = EXTRA_PROVIDER_MAP.get(entry.provider);
545
+ const support = supportOf(entry, profile, managed);
546
+ const apiKeyEnv = profile !== undefined && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : null;
547
+ const binding = managed === undefined ? undefined : this.bindingState(managed, descriptor);
548
+ const bindingHealthy = binding === 'healthy' ? true : binding === 'mismatch' ? false : undefined;
549
+ const keys = managed !== undefined ? managed.keys.map(key => publicKey(key, now)) : [];
550
+ const warning = binding === 'mismatch'
551
+ ? 'The provider profile no longer points at Keys Manager. Re-adopt it after releasing it, or repair the provider in Models.'
552
+ : undefined;
553
+ const models = profile !== undefined ? normalizeModels(profile.models) : extra?.models ?? managed?.models ?? [];
554
+ views.push({
555
+ provider: entry.provider,
556
+ displayName: extra?.name ?? entry.displayName ?? entry.provider,
557
+ source: extra !== undefined ? 'extra' : 'dsh',
558
+ origin: extra !== undefined ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
559
+ managed: managed !== undefined,
560
+ createdProfile: managed?.createdProfile ?? false,
561
+ configured: active.has(entry.provider) || profile !== undefined,
562
+ active: active.has(entry.provider),
563
+ supported: support.supported,
564
+ ...(support.reason === undefined ? {} : { reason: support.reason }),
565
+ settingsNs: entry.settingsNs,
566
+ settingsPath: [...entry.settingsPath],
567
+ apiKeyEnv,
568
+ brokerRef: managed?.brokerRef ?? null,
569
+ ...(bindingHealthy === undefined ? {} : { bindingHealthy }),
570
+ extra: extra !== undefined,
571
+ extraId: extra?.id ?? managed?.extraId ?? null,
572
+ api: profile?.api ?? extra?.api ?? managed?.api ?? null,
573
+ baseURL: profile?.baseURL ?? extra?.baseURL ?? entry.defaultBaseURL ?? managed?.baseURL ?? null,
574
+ env: [...(extra?.env ?? entry.nativeAuth?.env ?? [])],
575
+ defaultBaseURL: entry.defaultBaseURL ?? extra?.baseURL ?? null,
576
+ nativeAuth: entry.nativeAuth === undefined ? null : { env: [...entry.nativeAuth.env], oauth: entry.nativeAuth.oauth },
577
+ models,
578
+ keyCount: keys.length,
579
+ healthyKeyCount: keys.filter(key => key.status === 'active').length,
580
+ status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
581
+ keys,
582
+ ...(warning === undefined ? {} : { warning }),
583
+ });
584
+ seen.add(entry.provider);
585
+ }
586
+ for (const extra of EXTRA_PROVIDERS) {
587
+ if (seen.has(extra.id))
588
+ continue;
589
+ const managed = this.store.provider(extra.id);
590
+ const binding = managed === undefined ? undefined : this.bindingState(managed, descriptor);
591
+ const keys = managed !== undefined ? managed.keys.map(key => publicKey(key, now)) : [];
592
+ views.push({
593
+ provider: extra.id,
594
+ displayName: extra.name,
595
+ source: 'extra',
596
+ origin: 'plugin',
597
+ managed: managed !== undefined,
598
+ createdProfile: managed?.createdProfile ?? false,
599
+ configured: managed !== undefined,
600
+ active: false,
601
+ supported: true,
602
+ settingsNs: 'llm-pi-ai',
603
+ settingsPath: ['providers', extra.id],
604
+ apiKeyEnv: managed?.brokerRef ?? null,
605
+ brokerRef: managed?.brokerRef ?? null,
606
+ ...(binding === 'healthy' ? { bindingHealthy: true } : binding === 'mismatch' ? { bindingHealthy: false } : {}),
607
+ extra: true,
608
+ extraId: extra.id,
609
+ api: extra.api,
610
+ baseURL: extra.baseURL,
611
+ env: [...extra.env],
612
+ defaultBaseURL: extra.baseURL,
613
+ nativeAuth: null,
614
+ models: extra.models,
615
+ keyCount: keys.length,
616
+ healthyKeyCount: keys.filter(key => key.status === 'active').length,
617
+ status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
618
+ keys,
619
+ ...(managed === undefined ? {} : { warning: 'This extra provider is managed but its pi-ai route is not registered. Check the provider profile in Models.' }),
620
+ });
621
+ seen.add(extra.id);
622
+ }
623
+ for (const managed of Object.values(this.store.providers)) {
624
+ if (seen.has(managed.provider))
625
+ continue;
626
+ const keys = managed.keys.map(key => publicKey(key, now));
627
+ const binding = this.bindingState(managed, descriptor);
628
+ views.push({
629
+ provider: managed.provider,
630
+ displayName: managed.displayName,
631
+ source: managed.source,
632
+ origin: managed.source === 'extra' ? 'plugin' : 'custom',
633
+ managed: true,
634
+ createdProfile: managed.createdProfile,
635
+ configured: false,
636
+ active: false,
637
+ supported: true,
638
+ settingsNs: managed.settingsNs,
639
+ settingsPath: [...managed.settingsPath],
640
+ apiKeyEnv: managed.brokerRef,
641
+ brokerRef: managed.brokerRef,
642
+ ...(binding === 'healthy' ? { bindingHealthy: true } : binding === 'mismatch' ? { bindingHealthy: false } : {}),
643
+ extra: managed.source === 'extra',
644
+ extraId: managed.extraId,
645
+ api: managed.api,
646
+ baseURL: managed.baseURL,
647
+ env: [],
648
+ defaultBaseURL: managed.baseURL,
649
+ nativeAuth: null,
650
+ models: managed.models,
651
+ keyCount: keys.length,
652
+ healthyKeyCount: keys.filter(key => key.status === 'active').length,
653
+ status: statusOfProvider(managed.keys, now),
654
+ keys,
655
+ warning: 'This provider route is not currently registered. Check its profile in Models.',
656
+ });
657
+ seen.add(managed.provider);
658
+ }
659
+ views.sort((a, b) => {
660
+ if (a.managed !== b.managed)
661
+ return a.managed ? -1 : 1;
662
+ if (a.configured !== b.configured)
663
+ return a.configured ? -1 : 1;
664
+ if (a.source !== b.source)
665
+ return a.source === 'extra' ? 1 : -1;
666
+ return a.displayName.localeCompare(b.displayName) || a.provider.localeCompare(b.provider);
667
+ });
668
+ const allKeys = views.flatMap(view => view.keys);
669
+ const warnings = this.takeWarnings();
670
+ if (this.store.loadError !== undefined)
671
+ warnings.unshift(`State file could not be read; started fresh (${this.store.loadError})`);
672
+ if (descriptor.writable === false)
673
+ warnings.push('The active profile is read-only; key changes cannot be saved.');
674
+ return {
675
+ now,
676
+ writable: descriptor.writable && !this.store.readOnly,
677
+ settings: { ...normalizeSettings(this.store.settings) },
678
+ providers: views,
679
+ managedCount: views.filter(view => view.managed).length,
680
+ keyCount: allKeys.length,
681
+ healthyKeyCount: allKeys.filter(key => key.status === 'active').length,
682
+ coolingKeyCount: allKeys.filter(key => key.status === 'cooling').length,
683
+ pausedKeyCount: allKeys.filter(key => key.status === 'paused').length,
684
+ bindingsCount: Object.keys(this.store.state.sessionBindings).length,
685
+ warnings,
686
+ };
687
+ }
688
+ /** Remote: complete manager state. */
689
+ getState() {
690
+ return this.buildState();
691
+ }
692
+ /** Find one directory entry by route id. */
693
+ directoryEntry(provider) {
694
+ return this.ctx.llm.listConfigurableProviders().find(entry => entry.provider === provider);
695
+ }
696
+ /**
697
+ * Classify the live binding. An unreadable settings snapshot is `unknown`,
698
+ * not a confirmed mismatch: transient settings failures must not disable
699
+ * key-pool recovery for a provider that is still managed.
700
+ */
701
+ bindingState(record, descriptor = this.describeSettings()) {
702
+ if (!descriptor.readable) {
703
+ // Never re-enable a previously confirmed mismatch from an unreadable
704
+ // snapshot; without evidence, leave the binding unknown.
705
+ return this.bindingCache.get(record.provider) ?? 'unknown';
706
+ }
707
+ const namespace = this.namespace(descriptor, record.settingsNs);
708
+ const profile = namespace === undefined ? undefined : asProfile(getPath(namespace.value, record.settingsPath));
709
+ const state = profile !== undefined && profile.apiKeyEnv === record.brokerRef ? 'healthy' : 'mismatch';
710
+ this.bindingCache.set(record.provider, state);
711
+ return state;
712
+ }
713
+ /** Candidate credential refs from which an existing key can be imported. */
714
+ importRefs(entry, profile) {
715
+ const refs = [];
716
+ if (profile !== undefined && typeof profile.apiKeyEnv === 'string' && profile.apiKeyEnv.length > 0)
717
+ refs.push(profile.apiKeyEnv);
718
+ for (const name of entry.nativeAuth?.env ?? []) {
719
+ if (isKeyishEnv(name))
720
+ refs.push(name);
721
+ }
722
+ if (entry.provider === 'deepseek-official')
723
+ refs.push('DEEPSEEK_API_KEY');
724
+ refs.push(derivedKeyRef(entry.provider));
725
+ return [...new Set(refs.filter(ref => isCredentialRefName(ref) && !ref.startsWith(BROKER_PREFIX)))];
726
+ }
727
+ /** Create and store one key record without adding it to a provider. */
728
+ async createKeyRecord(provider, alias, value, importedFrom = null) {
729
+ const keyRef = newKeyRef(provider);
730
+ await this.setOriginal(keyRef, value);
731
+ return {
732
+ id: newKeyId(),
733
+ alias,
734
+ ref: keyRef,
735
+ kind: 'api-key',
736
+ enabled: true,
737
+ createdAt: Date.now(),
738
+ lastUsedAt: 0,
739
+ cooldownUntil: 0,
740
+ lastFailureAt: 0,
741
+ lastFailureCode: '',
742
+ failureLevel: 0,
743
+ consecutiveFailures: 0,
744
+ totalUses: 0,
745
+ totalFailures: 0,
746
+ valueMissing: false,
747
+ importedFrom,
748
+ oauth: null,
749
+ };
750
+ }
751
+ /** Find the first existing key value worth importing. */
752
+ async findImport(entry, profile) {
753
+ for (const ref of this.importRefs(entry, profile)) {
754
+ const probe = await this.probeKey(ref);
755
+ if (probe.hit !== undefined)
756
+ return { ref, value: probe.hit.value };
757
+ if (probe.failed) {
758
+ throw new Error(`Could not read credential ${ref}; the credential store is unavailable. Try adoption again after it recovers.`);
759
+ }
760
+ }
761
+ return undefined;
762
+ }
763
+ /** Remote: take over one existing provider route. */
764
+ adoptProvider(provider) {
765
+ return this.run(async () => {
766
+ if (typeof provider !== 'string' || provider.length === 0)
767
+ throw new Error('provider is required');
768
+ if (this.store.provider(provider) !== undefined)
769
+ return this.buildState();
770
+ const entry = this.directoryEntry(provider);
771
+ const extra = EXTRA_PROVIDER_MAP.get(provider);
772
+ if (entry === undefined) {
773
+ if (extra !== undefined)
774
+ throw new Error('This extra provider needs a key; add it from the extra-provider section');
775
+ throw new Error(`Provider "${provider}" is not registered by any adapter`);
776
+ }
777
+ const descriptor = this.describeSettings();
778
+ const namespace = this.namespace(descriptor, entry.settingsNs);
779
+ if (namespace === undefined)
780
+ throw new Error(`Settings namespace "${entry.settingsNs}" is not mounted`);
781
+ const profile = asProfile(getPath(namespace.value, entry.settingsPath));
782
+ const support = supportOf(entry, profile, undefined);
783
+ if (!support.supported)
784
+ throw new Error(support.reason ?? 'This provider cannot be managed');
785
+ if (entry.declared === true && profile === undefined) {
786
+ throw new Error('Add this hand-declared provider in Models first, then adopt it here');
787
+ }
788
+ const brokerRef = brokerRefFor(provider);
789
+ const collision = [...this.brokerOwners.keys()].find(ref => ref === brokerRef);
790
+ if (collision !== undefined)
791
+ throw new Error(`Broker reference collision for "${provider}"`);
792
+ const originalApiKeyEnv = profile !== undefined && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : null;
793
+ const path = [...entry.settingsPath, 'apiKeyEnv'];
794
+ const hit = await this.findImport(entry, profile);
795
+ let imported;
796
+ if (hit !== undefined)
797
+ imported = await this.createKeyRecord(provider, 'Imported', hit.value, hit.ref);
798
+ try {
799
+ await this.mutateSettings(entry.settingsNs, [{ op: 'set', path, value: brokerRef }]);
800
+ }
801
+ catch (error) {
802
+ if (imported !== undefined) {
803
+ try {
804
+ await this.unsetOriginal(imported.ref);
805
+ }
806
+ catch (cleanupError) {
807
+ this.warn(`adoption cleanup left credential ${imported.ref}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
808
+ }
809
+ }
810
+ throw error;
811
+ }
812
+ const record = {
813
+ provider,
814
+ displayName: extra?.name ?? entry.displayName ?? provider,
815
+ source: extra !== undefined ? 'extra' : 'dsh',
816
+ settingsNs: entry.settingsNs,
817
+ settingsPath: [...entry.settingsPath],
818
+ adapter: entry.settingsNs.includes('deepseek') || provider === 'deepseek-official' ? 'deepseek' : 'pi-ai',
819
+ brokerRef,
820
+ originalApiKeyEnv,
821
+ createdProfile: false,
822
+ hadProfile: profile !== undefined,
823
+ extraId: extra?.id ?? null,
824
+ api: profile?.api ?? extra?.api ?? null,
825
+ baseURL: profile?.baseURL ?? extra?.baseURL ?? entry.defaultBaseURL ?? null,
826
+ models: profile !== undefined ? normalizeModels(profile.models) : extra?.models ?? [],
827
+ adoptedAt: Date.now(),
828
+ cursor: 0,
829
+ keys: imported !== undefined ? [imported] : [],
830
+ };
831
+ this.store.providers[provider] = record;
832
+ this.rebuildBrokerMap();
833
+ if (hit !== undefined)
834
+ this.pushWarning(`Imported the existing credential ${hit.ref}.`);
835
+ this.store.touch();
836
+ return this.buildState();
837
+ });
838
+ }
839
+ /** Discover models for a new extra provider, falling back to the seed catalog. */
840
+ async discoverModels(extra, value) {
841
+ let discovered = [];
842
+ let failure;
843
+ try {
844
+ discovered = await this.ctx.llm.discoverModels('llm-pi-ai', { provider: extra.id, baseURL: extra.baseURL, api: extra.api, apiKey: value }, AbortSignal.timeout(20_000));
845
+ }
846
+ catch (error) {
847
+ failure = error instanceof Error ? error.message : String(error);
848
+ }
849
+ const models = [];
850
+ const seen = new Set();
851
+ for (const model of discovered) {
852
+ const normalized = discoveredModel(model);
853
+ if (normalized === undefined || seen.has(normalized.id))
854
+ continue;
855
+ seen.add(normalized.id);
856
+ models.push(normalized);
857
+ if (models.length >= 60)
858
+ break;
859
+ }
860
+ if (models.length > 0)
861
+ return { models, failure };
862
+ return { models: normalizeModels(extra.models), failure };
863
+ }
864
+ /** Remote: install and adopt one extra built-in provider. */
865
+ addExtraProvider(extraId, alias, value) {
866
+ return this.run(async () => {
867
+ if (typeof extraId !== 'string' || extraId.length === 0)
868
+ throw new Error('extraId is required');
869
+ const input = validateKeyInput(alias, value);
870
+ const extra = EXTRA_PROVIDER_MAP.get(extraId);
871
+ if (extra === undefined)
872
+ throw new Error(`Unknown extra provider "${extraId}"`);
873
+ if (this.store.provider(extraId) !== undefined) {
874
+ this.pushWarning('Provider is already managed; the key was added instead.');
875
+ await this.addKeyLocked(extraId, input.alias, input.value);
876
+ this.store.touch();
877
+ return this.buildState();
878
+ }
879
+ const descriptor = this.describeSettings();
880
+ const namespace = this.namespace(descriptor, 'llm-pi-ai');
881
+ if (namespace === undefined)
882
+ throw new Error('The llm-pi-ai settings namespace is not mounted');
883
+ const existing = asProfile(getPath(namespace.value, ['providers', extraId]));
884
+ if (existing !== undefined) {
885
+ this.pushWarning('A stored profile already exists for this provider; adopting it without replacing its models.');
886
+ const record = {
887
+ provider: extraId,
888
+ displayName: extra.name,
889
+ source: 'extra',
890
+ settingsNs: 'llm-pi-ai',
891
+ settingsPath: ['providers', extraId],
892
+ adapter: 'pi-ai',
893
+ brokerRef: brokerRefFor(extraId),
894
+ originalApiKeyEnv: typeof existing.apiKeyEnv === 'string' ? existing.apiKeyEnv : null,
895
+ createdProfile: false,
896
+ hadProfile: true,
897
+ extraId,
898
+ api: existing.api ?? extra.api,
899
+ baseURL: existing.baseURL ?? extra.baseURL,
900
+ models: normalizeModels(existing.models).length > 0 ? normalizeModels(existing.models) : normalizeModels(extra.models),
901
+ adoptedAt: Date.now(),
902
+ cursor: 0,
903
+ keys: [],
904
+ };
905
+ const key = await this.createKeyRecord(extraId, input.alias, input.value);
906
+ try {
907
+ await this.mutateSettings('llm-pi-ai', [{ op: 'set', path: ['providers', extraId, 'apiKeyEnv'], value: record.brokerRef }]);
908
+ }
909
+ catch (error) {
910
+ try {
911
+ await this.unsetOriginal(key.ref);
912
+ }
913
+ catch (cleanupError) {
914
+ this.warn(`extra-provider cleanup left credential ${key.ref}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
915
+ }
916
+ throw error;
917
+ }
918
+ record.keys.push(key);
919
+ this.store.providers[extraId] = record;
920
+ this.rebuildBrokerMap();
921
+ this.store.touch();
922
+ return this.buildState();
923
+ }
924
+ const { models, failure } = await this.discoverModels(extra, input.value);
925
+ if (models.length === 0)
926
+ throw new Error('No models were discovered and the seed catalog is empty');
927
+ if (failure !== undefined)
928
+ this.pushWarning(`Model discovery failed; installed ${models.length} seed models instead: ${failure}`);
929
+ const brokerRef = brokerRefFor(extraId);
930
+ const profile = {
931
+ displayName: extra.name,
932
+ api: extra.api,
933
+ baseURL: extra.baseURL,
934
+ apiKeyEnv: brokerRef,
935
+ models,
936
+ };
937
+ const key = await this.createKeyRecord(extraId, input.alias, input.value);
938
+ try {
939
+ await this.mutateSettings('llm-pi-ai', [{ op: 'set', path: ['providers', extraId], value: profile }]);
940
+ }
941
+ catch (error) {
942
+ try {
943
+ await this.unsetOriginal(key.ref);
944
+ }
945
+ catch (cleanupError) {
946
+ this.warn(`extra-provider cleanup left credential ${key.ref}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
947
+ }
948
+ throw error;
949
+ }
950
+ const record = {
951
+ provider: extraId,
952
+ displayName: extra.name,
953
+ source: 'extra',
954
+ settingsNs: 'llm-pi-ai',
955
+ settingsPath: ['providers', extraId],
956
+ adapter: 'pi-ai',
957
+ brokerRef,
958
+ originalApiKeyEnv: null,
959
+ createdProfile: true,
960
+ hadProfile: false,
961
+ extraId,
962
+ api: extra.api,
963
+ baseURL: extra.baseURL,
964
+ models,
965
+ adoptedAt: Date.now(),
966
+ cursor: 0,
967
+ keys: [key],
968
+ };
969
+ this.store.providers[extraId] = record;
970
+ this.rebuildBrokerMap();
971
+ this.store.touch();
972
+ return this.buildState();
973
+ });
974
+ }
975
+ /** Add one key; caller already owns the mutation queue. */
976
+ async addKeyLocked(provider, alias, value) {
977
+ const record = this.store.provider(provider);
978
+ if (record === undefined)
979
+ throw new Error(`Provider "${provider}" is not managed`);
980
+ const input = validateKeyInput(alias, value);
981
+ if (record.keys.length >= KEY_LIMIT)
982
+ throw new Error(`A provider may have at most ${KEY_LIMIT} keys`);
983
+ const key = await this.createKeyRecord(provider, input.alias, input.value);
984
+ record.keys.push(key);
985
+ }
986
+ /** Remote: add one key to a managed provider. */
987
+ addKey(provider, alias, value) {
988
+ return this.run(async () => {
989
+ await this.addKeyLocked(provider, alias, value);
990
+ this.store.touch();
991
+ return this.buildState();
992
+ });
993
+ }
994
+ /** Remote: rename or pause/resume one key. */
995
+ updateKey(provider, keyId, patch) {
996
+ return this.run(async () => {
997
+ const record = this.store.provider(provider);
998
+ if (record === undefined)
999
+ throw new Error(`Provider "${provider}" is not managed`);
1000
+ const key = record.keys.find(candidate => candidate.id === keyId);
1001
+ if (key === undefined)
1002
+ throw new Error('Key not found');
1003
+ const clean = validateKeyPatch(patch);
1004
+ if (clean.alias !== undefined)
1005
+ key.alias = clean.alias;
1006
+ if (clean.enabled !== undefined)
1007
+ key.enabled = clean.enabled;
1008
+ this.store.touch();
1009
+ return this.buildState();
1010
+ });
1011
+ }
1012
+ /** Remote: delete one key, releasing the provider when it was the last one. */
1013
+ deleteKey(provider, keyId) {
1014
+ return this.run(async () => {
1015
+ const record = this.store.provider(provider);
1016
+ if (record === undefined)
1017
+ throw new Error(`Provider "${provider}" is not managed`);
1018
+ const index = record.keys.findIndex(candidate => candidate.id === keyId);
1019
+ if (index < 0)
1020
+ throw new Error('Key not found');
1021
+ if (record.keys.length === 1) {
1022
+ await this.releaseLocked(provider);
1023
+ return this.buildState();
1024
+ }
1025
+ const key = record.keys[index];
1026
+ // Unset first: if the credential backend refuses, keep the metadata so a
1027
+ // later retry can remove both together.
1028
+ await this.unsetOriginal(key.ref);
1029
+ record.keys.splice(index, 1);
1030
+ this.store.clearBindings((_key, binding) => binding.keyId === key.id);
1031
+ this.store.touch();
1032
+ return this.buildState();
1033
+ });
1034
+ }
1035
+ /** Read one live profile for release-time cleanup decisions. */
1036
+ profileAt(ns, path) {
1037
+ const descriptor = this.describeSettings();
1038
+ const namespace = this.namespace(descriptor, ns);
1039
+ return namespace === undefined ? undefined : asProfile(getPath(namespace.value, path));
1040
+ }
1041
+ /** Restore the exact live profile captured before release mutated it. */
1042
+ async restoreProfileSnapshot(record, snapshot) {
1043
+ if (record.settingsPath.length === 0) {
1044
+ const apiKeyEnv = snapshot !== undefined && typeof snapshot.apiKeyEnv === 'string' ? snapshot.apiKeyEnv : record.brokerRef;
1045
+ await this.mutateSettings(record.settingsNs, [{ op: 'set', path: ['apiKeyEnv'], value: apiKeyEnv }]);
1046
+ return;
1047
+ }
1048
+ await this.mutateSettings(record.settingsNs, [
1049
+ snapshot === undefined
1050
+ ? { op: 'unset', path: record.settingsPath }
1051
+ : { op: 'set', path: record.settingsPath, value: snapshot },
1052
+ ]);
1053
+ }
1054
+ /** Remove every managed credential, recording progress before a retry. */
1055
+ async deleteManagedCredentials(record) {
1056
+ for (const key of record.keys) {
1057
+ await this.unsetOriginal(key.ref);
1058
+ key.valueMissing = true;
1059
+ this.store.touch();
1060
+ }
1061
+ }
1062
+ /** Restore the provider profile and drop management; caller owns the queue. */
1063
+ async releaseLocked(provider) {
1064
+ const record = this.store.provider(provider);
1065
+ if (record === undefined)
1066
+ return;
1067
+ const descriptor = this.describeSettings();
1068
+ if (!descriptor.readable) {
1069
+ throw new Error('Settings are currently unreadable; refusing to release a provider whose binding cannot be inspected.');
1070
+ }
1071
+ const namespace = this.namespace(descriptor, record.settingsNs);
1072
+ if (namespace === undefined)
1073
+ throw new Error(`Settings namespace "${record.settingsNs}" is not mounted`);
1074
+ const binding = this.bindingState(record, descriptor);
1075
+ if (binding === 'unknown') {
1076
+ throw new Error('Settings are currently unreadable; refusing to release a provider whose binding cannot be inspected.');
1077
+ }
1078
+ // The user may have repaired or replaced the provider in Models. In that
1079
+ // case release only drops manager metadata and credentials; it must not
1080
+ // overwrite the live profile.
1081
+ if (binding === 'mismatch') {
1082
+ await this.deleteManagedCredentials(record);
1083
+ delete this.store.providers[provider];
1084
+ this.store.clearBindings(key => key.endsWith(`\u0000${provider}`));
1085
+ this.rebuildBrokerMap();
1086
+ this.store.touch();
1087
+ return;
1088
+ }
1089
+ const liveProfile = asProfile(getPath(namespace.value, record.settingsPath));
1090
+ const snapshot = liveProfile === undefined ? undefined : structuredClone(liveProfile);
1091
+ let profileMutated = false;
1092
+ try {
1093
+ if (record.createdProfile && record.extraId !== null && record.settingsNs === 'llm-pi-ai') {
1094
+ await this.mutateSettings(record.settingsNs, [{ op: 'unset', path: ['providers', record.extraId] }]);
1095
+ profileMutated = true;
1096
+ }
1097
+ else {
1098
+ // Native and pre-existing extra routes own only apiKeyEnv. Leave every
1099
+ // other profile field (baseURL, models, hand edits) exactly as it is.
1100
+ const path = [...record.settingsPath, 'apiKeyEnv'];
1101
+ await this.mutateSettings(record.settingsNs, [
1102
+ record.originalApiKeyEnv !== null
1103
+ ? { op: 'set', path, value: record.originalApiKeyEnv }
1104
+ : { op: 'unset', path },
1105
+ ]);
1106
+ profileMutated = true;
1107
+ // Remove an auto-created profile only when it is still empty after
1108
+ // clearing apiKeyEnv; a profile with later user edits stays.
1109
+ if (!record.hadProfile && record.settingsPath.length > 0) {
1110
+ const current = this.profileAt(record.settingsNs, record.settingsPath);
1111
+ if (current !== undefined && Object.keys(current).length === 0) {
1112
+ await this.mutateSettings(record.settingsNs, [{ op: 'unset', path: record.settingsPath }]);
1113
+ }
1114
+ }
1115
+ }
1116
+ // The profile mutation came first so a read-only profile fails before
1117
+ // any managed credential is deleted.
1118
+ await this.deleteManagedCredentials(record);
1119
+ }
1120
+ catch (error) {
1121
+ if (profileMutated) {
1122
+ try {
1123
+ await this.restoreProfileSnapshot(record, snapshot);
1124
+ }
1125
+ catch (restoreError) {
1126
+ this.warn(`release compensation failed for ${provider}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
1127
+ }
1128
+ }
1129
+ throw error;
1130
+ }
1131
+ delete this.store.providers[provider];
1132
+ this.store.clearBindings(key => key.endsWith(`\u0000${provider}`));
1133
+ this.rebuildBrokerMap();
1134
+ this.store.touch();
1135
+ }
1136
+ /** Remote: stop managing a provider and restore its original key reference. */
1137
+ releaseProvider(provider) {
1138
+ return this.run(async () => {
1139
+ if (this.store.provider(provider) === undefined)
1140
+ throw new Error(`Provider "${provider}" is not managed`);
1141
+ await this.releaseLocked(provider);
1142
+ return this.buildState();
1143
+ });
1144
+ }
1145
+ /** Remote: update manager policy. */
1146
+ setSettings(patch) {
1147
+ return this.run(async () => {
1148
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch))
1149
+ throw new Error('patch is required');
1150
+ const next = patchSettings(this.store.settings, patch);
1151
+ this.store.state.settings = next;
1152
+ this.store.touch();
1153
+ return this.buildState();
1154
+ });
1155
+ }
1156
+ /**
1157
+ * Serialize the first selection for a sticky session/provider pair so two
1158
+ * concurrent requests cannot advance the cursor and bind different keys.
1159
+ */
1160
+ async selectKey(providerId, sessionId, step) {
1161
+ if (!this.store.settings.stickySession || sessionId === undefined) {
1162
+ return this.doSelectKey(providerId, sessionId, step);
1163
+ }
1164
+ const queueKey = `${sessionId}\u0000${providerId}`;
1165
+ const previous = this.selectionQueues.get(queueKey) ?? Promise.resolve();
1166
+ const next = previous.then(() => this.doSelectKey(providerId, sessionId, step), () => this.doSelectKey(providerId, sessionId, step));
1167
+ const tail = next.then(() => undefined, () => undefined);
1168
+ this.selectionQueues.set(queueKey, tail);
1169
+ try {
1170
+ return await next;
1171
+ }
1172
+ finally {
1173
+ if (this.selectionQueues.get(queueKey) === tail)
1174
+ this.selectionQueues.delete(queueKey);
1175
+ }
1176
+ }
1177
+ /** Pin the key a retry must use, scoped to the failing step. */
1178
+ pinRetry(providerId, sessionId, turn, step, keyId) {
1179
+ if (sessionId === undefined)
1180
+ return;
1181
+ this.retryBindings.set(`${bindingKey(sessionId, providerId)}\u0000${turn}\u0000${step}`, { keyId, at: Date.now() });
1182
+ }
1183
+ /** Record the turn/step the agent loop is about to request for one provider. */
1184
+ noteRequestStep(payload, providerId) {
1185
+ const raw = payload.agent?.session?.id;
1186
+ if (raw === undefined || providerId.length === 0)
1187
+ return;
1188
+ // Unmanaged providers never consult this map; do not let them accumulate.
1189
+ if (this.store.provider(providerId) === undefined)
1190
+ return;
1191
+ const sessionId = String(raw);
1192
+ this.requestSteps.set(`${sessionId}\u0000${providerId}`, { turn: payload.turn, step: payload.step, at: Date.now() });
1193
+ }
1194
+ /** Drop one step's retry pin once its attempt settles. */
1195
+ clearRetry(providerId, sessionId, turn, step) {
1196
+ if (sessionId === undefined)
1197
+ return;
1198
+ this.retryBindings.delete(`${bindingKey(sessionId, providerId)}\u0000${turn}\u0000${step}`);
1199
+ }
1200
+ /** Selection body; callers reach it through {@link selectKey}. */
1201
+ async doSelectKey(providerId, sessionId, step) {
1202
+ const provider = this.store.provider(providerId);
1203
+ if (provider === undefined)
1204
+ return { key: undefined, value: undefined };
1205
+ const now = Date.now();
1206
+ this.pruneBindings(now);
1207
+ if (sessionId !== undefined && step !== undefined) {
1208
+ const pinKey = `${bindingKey(sessionId, providerId)}\u0000${step.turn}\u0000${step.step}`;
1209
+ const pinned = this.retryBindings.get(pinKey);
1210
+ this.retryBindings.delete(pinKey);
1211
+ if (pinned !== undefined && now - pinned.at <= PIN_TTL_MS) {
1212
+ const candidate = provider.keys.find(entry => entry.id === pinned.keyId);
1213
+ if (isSelectable(candidate, now)) {
1214
+ const probe = await this.probeKey(candidate.ref);
1215
+ if (probe.hit !== undefined) {
1216
+ candidate.valueMissing = false;
1217
+ markKeyUsed(candidate, now);
1218
+ this.store.touch();
1219
+ if (this.store.settings.stickySession)
1220
+ this.store.bind(sessionId, providerId, candidate.id, now);
1221
+ return { key: candidate, value: probe.hit.value };
1222
+ }
1223
+ if (probe.missing) {
1224
+ candidate.valueMissing = true;
1225
+ this.store.touch();
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+ let key;
1231
+ if (sessionId !== undefined && this.store.settings.stickySession) {
1232
+ const binding = this.store.binding(sessionId, providerId);
1233
+ if (binding !== undefined) {
1234
+ const bound = provider.keys.find(candidate => candidate.id === binding.keyId);
1235
+ if (isSelectable(bound, now))
1236
+ key = bound;
1237
+ }
1238
+ }
1239
+ if (key === undefined)
1240
+ key = pickRoundRobin(provider, now);
1241
+ const tryCandidate = async (candidate) => {
1242
+ if (candidate === undefined)
1243
+ return undefined;
1244
+ const probe = await this.probeKey(candidate.ref);
1245
+ if (probe.hit !== undefined) {
1246
+ candidate.valueMissing = false;
1247
+ markKeyUsed(candidate, now);
1248
+ this.store.touch();
1249
+ if (sessionId !== undefined && this.store.settings.stickySession) {
1250
+ this.store.bind(sessionId, providerId, candidate.id, now);
1251
+ }
1252
+ return { key: candidate, value: probe.hit.value };
1253
+ }
1254
+ if (probe.missing) {
1255
+ candidate.valueMissing = true;
1256
+ this.store.touch();
1257
+ }
1258
+ return undefined;
1259
+ };
1260
+ const tried = new Set();
1261
+ while (key !== undefined && !tried.has(key.id)) {
1262
+ tried.add(key.id);
1263
+ const selected = await tryCandidate(key);
1264
+ if (selected !== undefined)
1265
+ return selected;
1266
+ key = pickRoundRobin(provider, now, tried);
1267
+ }
1268
+ // A previously missing key may have been restored in the credential store.
1269
+ // Re-probe those candidates when no healthy key remains; a transient read
1270
+ // failure is never written back as missing.
1271
+ for (const candidate of provider.keys) {
1272
+ if (candidate.valueMissing !== true || candidate.enabled === false || Number(candidate.cooldownUntil) > now)
1273
+ continue;
1274
+ const selected = await tryCandidate(candidate);
1275
+ if (selected !== undefined)
1276
+ return selected;
1277
+ }
1278
+ return { key: undefined, value: undefined };
1279
+ }
1280
+ /** Record a terminal failure without deciding policy yet. */
1281
+ recordFailure(providerId, sessionId, keyId, failure, usedManagedKey = false) {
1282
+ if (sessionId === undefined)
1283
+ return;
1284
+ const key = bindingKey(sessionId, providerId);
1285
+ const previous = this.attempts.get(key);
1286
+ const effectiveKeyId = keyId ?? previous?.keyId;
1287
+ this.attempts.set(key, {
1288
+ keyId: effectiveKeyId,
1289
+ retries: previous !== undefined && previous.keyId === effectiveKeyId ? previous.retries : 0,
1290
+ failure,
1291
+ at: Date.now(),
1292
+ turn: previous?.turn,
1293
+ step: previous?.step,
1294
+ usedManagedKey,
1295
+ });
1296
+ }
1297
+ /** Record a successful terminal finish. */
1298
+ recordSuccess(providerId, sessionId, keyId, step) {
1299
+ const provider = this.store.provider(providerId);
1300
+ if (provider !== undefined) {
1301
+ const key = provider.keys.find(candidate => candidate.id === keyId);
1302
+ if (key !== undefined)
1303
+ markKeySuccess(key, Date.now());
1304
+ this.store.touch();
1305
+ }
1306
+ if (sessionId !== undefined) {
1307
+ this.attempts.delete(bindingKey(sessionId, providerId));
1308
+ if (step !== undefined)
1309
+ this.clearRetry(providerId, sessionId, step.turn, step.step);
1310
+ // Do not clear step-scoped switch/attempt records here: another step of
1311
+ // the same session may still be in flight. Step records expire by TTL or
1312
+ // are cleared when their own request-error path terminates.
1313
+ }
1314
+ }
1315
+ /** Mark one key cooling, preserving escalation across recovery windows. */
1316
+ markFailure(providerId, keyId, failure) {
1317
+ const provider = this.store.provider(providerId);
1318
+ if (provider === undefined || keyId === undefined)
1319
+ return;
1320
+ const key = provider.keys.find(candidate => candidate.id === keyId);
1321
+ if (key === undefined)
1322
+ return;
1323
+ const now = Date.now();
1324
+ if (key.cooldownUntil > now && now - key.lastFailureAt < 10_000)
1325
+ return;
1326
+ const result = applyFailureCooldown(key, this.store.settings, now, failure);
1327
+ this.warn(`key ${key.id} on ${providerId} cooled for ${Math.round(result.cooldownMs / 1000)}s`);
1328
+ this.store.touch();
1329
+ }
1330
+ /** Prune stale attempt records. */
1331
+ pruneAttempts(now) {
1332
+ if (this.attempts.size >= 128) {
1333
+ for (const [key, attempt] of this.attempts) {
1334
+ if (now - attempt.at <= ATTEMPT_TTL_MS)
1335
+ continue;
1336
+ const stepKey = attempt.turn !== undefined && attempt.step !== undefined
1337
+ ? `${key}\u0000${attempt.turn}\u0000${attempt.step}`
1338
+ : undefined;
1339
+ // A step-scoped record or a recent switch means the step still owns a
1340
+ // budget; do not drop the in-flight attribution record first.
1341
+ if (stepKey !== undefined && (this.stepAttempts.has(stepKey) || (this.switches.get(stepKey)?.at ?? 0) > now - ATTEMPT_TTL_MS))
1342
+ continue;
1343
+ this.attempts.delete(key);
1344
+ }
1345
+ }
1346
+ const liveSteps = new Set();
1347
+ const maxRetention = 24 * 60 * 60 * 1000;
1348
+ for (const [key, attempt] of this.stepAttempts) {
1349
+ const age = now - attempt.at;
1350
+ const hasSwitch = this.switches.has(key);
1351
+ if (age <= ATTEMPT_TTL_MS || (hasSwitch && age <= maxRetention)) {
1352
+ liveSteps.add(key);
1353
+ continue;
1354
+ }
1355
+ this.stepAttempts.delete(key);
1356
+ }
1357
+ for (const [key, entry] of this.switches) {
1358
+ if (liveSteps.has(key))
1359
+ continue;
1360
+ if (now - entry.at <= ATTEMPT_TTL_MS)
1361
+ continue;
1362
+ this.switches.delete(key);
1363
+ }
1364
+ for (const [key, attempts] of this.streamAttempts) {
1365
+ const kept = attempts.filter(entry => now - entry.at <= ATTEMPT_TTL_MS);
1366
+ if (kept.length === 0)
1367
+ this.streamAttempts.delete(key);
1368
+ else
1369
+ this.streamAttempts.set(key, kept);
1370
+ }
1371
+ for (const [key, pin] of this.retryBindings) {
1372
+ if (now - pin.at > PIN_TTL_MS)
1373
+ this.retryBindings.delete(key);
1374
+ }
1375
+ for (const [key, step] of this.requestSteps) {
1376
+ if (now - step.at > ATTEMPT_TTL_MS)
1377
+ this.requestSteps.delete(key);
1378
+ }
1379
+ }
1380
+ /** Waterfall listener: bind a key to the request and observe its terminal chunk. */
1381
+ streamListener(options, next) {
1382
+ const providerId = options.provider;
1383
+ const sessionId = options.sessionId === undefined ? undefined : String(options.sessionId);
1384
+ // `options.provider` is the route the agent/request waterfall finally
1385
+ // resolved; noteRequestStep keys its record by the same
1386
+ // `${sessionId}\u0000${providerId}` pair, so the two must stay in sync or
1387
+ // the retry pin below will not be found.
1388
+ const current = sessionId === undefined || options.purpose !== undefined
1389
+ ? undefined
1390
+ : this.requestSteps.get(`${sessionId}\u0000${providerId}`);
1391
+ const step = current !== undefined && Date.now() - current.at <= ATTEMPT_TTL_MS
1392
+ ? { turn: current.turn, step: current.step }
1393
+ : undefined;
1394
+ const record = this.store.provider(providerId);
1395
+ if (record === undefined) {
1396
+ if (sessionId !== undefined && step !== undefined)
1397
+ this.clearRetry(providerId, sessionId, step.turn, step.step);
1398
+ return next();
1399
+ }
1400
+ const binding = this.bindingState(record);
1401
+ if (binding === 'mismatch') {
1402
+ if (sessionId !== undefined && step !== undefined)
1403
+ this.clearRetry(providerId, sessionId, step.turn, step.step);
1404
+ return next();
1405
+ }
1406
+ return this.wrapStream(options, next, providerId, sessionId, step);
1407
+ }
1408
+ /** Wrap one managed stream so credential resolution sees this key. */
1409
+ wrapStream(_options, next, providerId, sessionId, step) {
1410
+ const service = this;
1411
+ let iterator;
1412
+ let store;
1413
+ let started = false;
1414
+ let keyId;
1415
+ return {
1416
+ [Symbol.asyncIterator]() {
1417
+ return {
1418
+ async next() {
1419
+ if (!started) {
1420
+ started = true;
1421
+ let selection = { key: undefined, value: undefined };
1422
+ try {
1423
+ selection = await service.selectKey(providerId, sessionId, step);
1424
+ }
1425
+ catch (error) {
1426
+ service.warn(`key selection failed: ${error instanceof Error ? error.message : String(error)}`);
1427
+ }
1428
+ keyId = selection.key?.id;
1429
+ store = {
1430
+ provider: providerId,
1431
+ keyId: keyId ?? null,
1432
+ keyValue: selection.value ?? null,
1433
+ // Only the broker resolver proves that this request actually
1434
+ // used a managed credential.
1435
+ keyUsed: false,
1436
+ };
1437
+ if (sessionId !== undefined && keyId !== undefined) {
1438
+ const key = bindingKey(sessionId, providerId);
1439
+ const previous = service.attempts.get(key);
1440
+ service.attempts.set(key, {
1441
+ keyId,
1442
+ // Preserve the retry budget across key changes. With sticky
1443
+ // sessions off, round-robin picks a different key each request;
1444
+ // resetting here would make the retry budget unbounded.
1445
+ retries: previous?.retries ?? 0,
1446
+ failure: undefined,
1447
+ at: Date.now(),
1448
+ turn: previous?.turn,
1449
+ step: previous?.step,
1450
+ usedManagedKey: false,
1451
+ });
1452
+ }
1453
+ iterator = await service.als.run(store, () => next()[Symbol.asyncIterator]());
1454
+ }
1455
+ if (iterator === undefined)
1456
+ throw new Error('managed stream has no iterator');
1457
+ const result = await service.als.run(store, () => iterator.next());
1458
+ if (result.done === true)
1459
+ return result;
1460
+ const chunk = result.value;
1461
+ if (chunk.type === 'finish' && chunk.reason !== undefined) {
1462
+ const reason = chunk.reason;
1463
+ const used = store?.keyUsed === true;
1464
+ if (reason.kind === 'error') {
1465
+ if (reason.failure !== undefined && shouldFailover(reason.failure.code) && service.store.settings.autoSwitch) {
1466
+ service.recordStreamAttempt(providerId, sessionId, keyId, used, reason.failure);
1467
+ }
1468
+ if (used && reason.failure !== undefined) {
1469
+ service.recordFailure(providerId, sessionId, keyId, reason.failure, true);
1470
+ }
1471
+ else if (sessionId !== undefined) {
1472
+ // The request failed on an external credential; do not
1473
+ // attribute it to a managed key.
1474
+ service.attempts.delete(bindingKey(sessionId, providerId));
1475
+ if (step !== undefined)
1476
+ service.clearRetry(providerId, sessionId, step.turn, step.step);
1477
+ }
1478
+ service.pruneAttempts(Date.now());
1479
+ }
1480
+ else if (reason.kind === 'aborted') {
1481
+ if (sessionId !== undefined && step !== undefined)
1482
+ service.clearRetry(providerId, sessionId, step.turn, step.step);
1483
+ }
1484
+ else if (used) {
1485
+ service.recordSuccess(providerId, sessionId, keyId, step);
1486
+ }
1487
+ else if (sessionId !== undefined) {
1488
+ service.attempts.delete(bindingKey(sessionId, providerId));
1489
+ if (step !== undefined)
1490
+ service.clearRetry(providerId, sessionId, step.turn, step.step);
1491
+ }
1492
+ }
1493
+ return result;
1494
+ },
1495
+ async return(value) {
1496
+ if (iterator?.return !== undefined && store !== undefined) {
1497
+ return service.als.run(store, () => iterator.return(value));
1498
+ }
1499
+ return { done: true, value: undefined };
1500
+ },
1501
+ async throw(error) {
1502
+ if (iterator?.throw !== undefined && store !== undefined) {
1503
+ return service.als.run(store, () => iterator.throw(error));
1504
+ }
1505
+ throw error;
1506
+ },
1507
+ };
1508
+ },
1509
+ };
1510
+ }
1511
+ /**
1512
+ * Delegate recovery to the next agent/request-error listener. Preserve the
1513
+ * current key only when that listener actually requests a retry; a declined
1514
+ * (undefined) recovery is terminal and must still cool the exhausted key.
1515
+ */
1516
+ async delegateAndMaybeCool(payload, providerId, keyId, next) {
1517
+ let action;
1518
+ try {
1519
+ action = await next();
1520
+ }
1521
+ catch (error) {
1522
+ this.markFailure(providerId, keyId, payload.failure);
1523
+ throw error;
1524
+ }
1525
+ if (action === undefined)
1526
+ this.markFailure(providerId, keyId, payload.failure);
1527
+ return action;
1528
+ }
1529
+ /** Record one failed stream attempt for turn/step attribution. */
1530
+ recordStreamAttempt(providerId, sessionId, keyId, usedManagedKey, failure) {
1531
+ if (sessionId === undefined)
1532
+ return;
1533
+ const entry = {
1534
+ keyId,
1535
+ usedManagedKey,
1536
+ at: Date.now(),
1537
+ sequence: ++this.attemptSequence,
1538
+ claimed: false,
1539
+ };
1540
+ if (typeof failure === 'object' && failure !== null) {
1541
+ // The agent loop forwards this exact failure object to
1542
+ // agent/request-error; it is a request-scoped identity.
1543
+ this.linkedAttempts.set(failure, entry);
1544
+ return;
1545
+ }
1546
+ const key = bindingKey(sessionId, providerId);
1547
+ const attempts = this.streamAttempts.get(key) ?? [];
1548
+ attempts.push(entry);
1549
+ while (attempts.length > 8)
1550
+ attempts.shift();
1551
+ this.streamAttempts.set(key, attempts);
1552
+ }
1553
+ /** Consume the stream record linked to this exact failure object. */
1554
+ takeLinkedAttempt(failure) {
1555
+ if (typeof failure !== 'object' || failure === null)
1556
+ return undefined;
1557
+ const entry = this.linkedAttempts.get(failure);
1558
+ if (entry === undefined)
1559
+ return undefined;
1560
+ this.linkedAttempts.delete(failure);
1561
+ return entry;
1562
+ }
1563
+ /** Claim the next unclaimed stream attempt after a step's last sequence. */
1564
+ claimStreamAttempt(providerId, sessionId, afterSequence) {
1565
+ if (sessionId === undefined)
1566
+ return undefined;
1567
+ const attempts = this.streamAttempts.get(bindingKey(sessionId, providerId));
1568
+ if (attempts === undefined)
1569
+ return undefined;
1570
+ const found = attempts.find(entry => !entry.claimed && entry.sequence > afterSequence);
1571
+ if (found === undefined)
1572
+ return undefined;
1573
+ found.claimed = true;
1574
+ return found;
1575
+ }
1576
+ /**
1577
+ * Find a metadata-selectable alternate whose credential actually resolves.
1578
+ * A key whose credential was removed outside the manager must not cause the
1579
+ * current, working key to be cooled and the session rebound.
1580
+ */
1581
+ async findVerifiedAlternate(record, currentKeyId, now) {
1582
+ const tried = new Set();
1583
+ for (;;) {
1584
+ const candidate = pickHealthyExcluding(record, currentKeyId, now);
1585
+ if (candidate === undefined || tried.has(candidate.id))
1586
+ return undefined;
1587
+ tried.add(candidate.id);
1588
+ const probe = await this.probeKey(candidate.ref);
1589
+ if (probe.hit !== undefined)
1590
+ return candidate;
1591
+ if (probe.missing) {
1592
+ candidate.valueMissing = true;
1593
+ this.store.touch();
1594
+ }
1595
+ }
1596
+ }
1597
+ /** Waterfall listener: bounded retry, then key switch and cool-down. */
1598
+ async requestErrorListener(payload, next) {
1599
+ // Consume a linked record on every path, including skipped recovery, so a
1600
+ // later request cannot claim this failure's evidence.
1601
+ const linked = this.takeLinkedAttempt(payload.failure);
1602
+ const providerId = payload.provider;
1603
+ const sessionId = payload.agent?.session?.id === undefined ? undefined : String(payload.agent.session.id);
1604
+ const dropPin = () => {
1605
+ if (sessionId !== undefined)
1606
+ this.clearRetry(providerId, sessionId, payload.turn, payload.step);
1607
+ };
1608
+ if (payload.signal?.aborted) {
1609
+ dropPin();
1610
+ return next();
1611
+ }
1612
+ const record = this.store.provider(providerId);
1613
+ if (record === undefined || this.bindingState(record) === 'mismatch') {
1614
+ dropPin();
1615
+ return next();
1616
+ }
1617
+ const settings = this.store.settings;
1618
+ if (!settings.autoSwitch) {
1619
+ dropPin();
1620
+ return next();
1621
+ }
1622
+ if (!shouldFailover(payload.failure?.code)) {
1623
+ dropPin();
1624
+ return next();
1625
+ }
1626
+ if (sessionId === undefined)
1627
+ return next();
1628
+ const now = Date.now();
1629
+ const attemptKey = bindingKey(sessionId, providerId);
1630
+ const switchKey = `${attemptKey}\u0000${payload.turn}\u0000${payload.step}`;
1631
+ let attempt = this.stepAttempts.get(switchKey);
1632
+ const evidence = linked ?? this.claimStreamAttempt(providerId, sessionId, attempt?.lastSequence ?? 0);
1633
+ if (attempt === undefined) {
1634
+ // Do not fall back to the shared session record: another step may have
1635
+ // overwritten it. Without request-scoped evidence, delegating is safer.
1636
+ if (evidence === undefined || evidence.usedManagedKey !== true)
1637
+ return next();
1638
+ attempt = {
1639
+ keyId: evidence.keyId,
1640
+ retries: 0,
1641
+ at: now,
1642
+ turn: payload.turn,
1643
+ step: payload.step,
1644
+ usedManagedKey: true,
1645
+ lastSequence: evidence.sequence,
1646
+ keyRetries: {},
1647
+ };
1648
+ this.stepAttempts.set(switchKey, attempt);
1649
+ }
1650
+ else {
1651
+ // Every recovery needs evidence for the request that just failed; an
1652
+ // existing step must not reuse stale attribution or budget.
1653
+ if (evidence === undefined) {
1654
+ this.switches.delete(switchKey);
1655
+ this.stepAttempts.delete(switchKey);
1656
+ this.clearRetry(providerId, sessionId, payload.turn, payload.step);
1657
+ return next();
1658
+ }
1659
+ const keyChanged = attempt.keyId !== evidence.keyId;
1660
+ attempt.keyId = evidence.keyId;
1661
+ attempt.usedManagedKey = evidence.usedManagedKey;
1662
+ attempt.lastSequence = evidence.sequence;
1663
+ attempt.at = now;
1664
+ if (keyChanged) {
1665
+ attempt.keyRetries ??= {};
1666
+ attempt.retries = evidence.keyId === undefined ? 0 : attempt.keyRetries[evidence.keyId] ?? 0;
1667
+ }
1668
+ }
1669
+ if (attempt.usedManagedKey !== true) {
1670
+ this.switches.delete(switchKey);
1671
+ this.stepAttempts.delete(switchKey);
1672
+ this.clearRetry(providerId, sessionId, payload.turn, payload.step);
1673
+ return next();
1674
+ }
1675
+ const currentKeyId = attempt.keyId;
1676
+ if (currentKeyId === undefined) {
1677
+ this.clearRetry(providerId, sessionId, payload.turn, payload.step);
1678
+ return next();
1679
+ }
1680
+ const current = record.keys.find(key => key.id === currentKeyId);
1681
+ if (current === undefined)
1682
+ return next();
1683
+ const switchCount = this.switches.get(switchKey)?.count ?? 0;
1684
+ if (attempt.retries < settings.retryCount) {
1685
+ attempt.retries += 1;
1686
+ attempt.keyRetries ??= {};
1687
+ if (attempt.keyId !== undefined)
1688
+ attempt.keyRetries[attempt.keyId] = attempt.retries;
1689
+ this.pinRetry(providerId, sessionId, payload.turn, payload.step, currentKeyId);
1690
+ this.warn(`${providerId} key ${currentKeyId} failed (${payload.failure?.code ?? 'error'}); retry ${attempt.retries}/${settings.retryCount}`);
1691
+ return { kind: 'retry' };
1692
+ }
1693
+ if (switchCount >= Math.min(MAX_SWITCHES, Math.max(1, record.keys.length))) {
1694
+ this.warn(`${providerId} exhausted key switches for this step; delegating recovery`);
1695
+ const action = await this.delegateAndMaybeCool(payload, providerId, currentKeyId, next);
1696
+ if (action === undefined) {
1697
+ this.switches.delete(switchKey);
1698
+ this.stepAttempts.delete(switchKey);
1699
+ this.clearRetry(providerId, sessionId, payload.turn, payload.step);
1700
+ }
1701
+ else {
1702
+ this.pinRetry(providerId, sessionId, payload.turn, payload.step, currentKeyId);
1703
+ }
1704
+ return action;
1705
+ }
1706
+ const candidate = await this.findVerifiedAlternate(record, currentKeyId, now);
1707
+ if (candidate === undefined) {
1708
+ this.warn(`${providerId} has no alternate with a resolvable credential; preserving the current key via delegation`);
1709
+ const action = await this.delegateAndMaybeCool(payload, providerId, currentKeyId, next);
1710
+ if (action === undefined) {
1711
+ this.switches.delete(switchKey);
1712
+ this.stepAttempts.delete(switchKey);
1713
+ this.clearRetry(providerId, sessionId, payload.turn, payload.step);
1714
+ }
1715
+ else {
1716
+ this.pinRetry(providerId, sessionId, payload.turn, payload.step, currentKeyId);
1717
+ }
1718
+ return action;
1719
+ }
1720
+ // Keep the current key usable until an alternate is actually chosen. If
1721
+ // the delegated retry re-enters llm/stream, selectKey must still find a
1722
+ // credential; cooling it before this point would strand that retry.
1723
+ this.markFailure(providerId, currentKeyId, payload.failure);
1724
+ this.store.bind(sessionId, providerId, candidate.id, now);
1725
+ attempt.keyId = candidate.id;
1726
+ attempt.keyRetries ??= {};
1727
+ attempt.keyRetries[candidate.id] ??= 0;
1728
+ attempt.retries = attempt.keyRetries[candidate.id];
1729
+ attempt.at = now;
1730
+ this.pinRetry(providerId, sessionId, payload.turn, payload.step, candidate.id);
1731
+ this.switches.set(switchKey, { count: switchCount + 1, at: now });
1732
+ this.store.touch();
1733
+ this.warn(`${providerId} switched to key ${candidate.id} after ${settings.retryCount} retries`);
1734
+ return { kind: 'retry' };
1735
+ }
1736
+ }
1737
+ /** Host entry. */
1738
+ export function apply(ctx) {
1739
+ const file = statePath();
1740
+ ensureStateFile(file);
1741
+ const store = new KeysManagerStore(file, { warn: message => ctx.logger.warn(message) });
1742
+ const service = new KeysManagerService(ctx, store);
1743
+ ctx.effect(() => () => service.close(), 'keys-manager: persist state');
1744
+ ctx.inject(['credentials'], child => {
1745
+ const credentials = child.credentials;
1746
+ service.attachCredentials(credentials);
1747
+ return () => service.detachCredentials(credentials);
1748
+ });
1749
+ ctx.inject(['settings'], child => {
1750
+ service.settings = child.settings;
1751
+ return () => {
1752
+ if (service.settings === child.settings)
1753
+ service.settings = undefined;
1754
+ };
1755
+ });
1756
+ const on = ctx.on;
1757
+ on('llm/stream', (options, next) => service.streamListener(options, next));
1758
+ const pruneTimer = setInterval(() => service.pruneTransient(), 60_000);
1759
+ pruneTimer.unref?.();
1760
+ ctx.effect(() => () => {
1761
+ clearInterval(pruneTimer);
1762
+ }, 'keys-manager: prune transient state');
1763
+ on('agent/request', async (payload, next) => {
1764
+ const config = await next();
1765
+ const provider = config !== null && typeof config === 'object' && typeof config.provider === 'string'
1766
+ ? config.provider
1767
+ : '';
1768
+ service.noteRequestStep(payload, provider);
1769
+ return config;
1770
+ }, { prepend: true });
1771
+ on('agent/request-error', (payload, next) => service.requestErrorListener(payload, next), { prepend: true });
1772
+ ctx.provide(SERVICE, service);
1773
+ ctx.effect(() => ctx.typert.register({
1774
+ package: 'dsh-keys-manager',
1775
+ face: 'host',
1776
+ schemas: [],
1777
+ model: { services: [], events: [], objects: [] },
1778
+ invocations: INVOCATIONS,
1779
+ }), 'keys-manager: typert registration');
1780
+ ctx.logger.info(`keys-manager: ${Object.keys(store.providers).length} managed provider(s), ${file}`);
1781
+ }
1782
+ //# sourceMappingURL=index.js.map