@goodandready/dsh-key-rotation 0.8.15 → 0.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.17 - 2026-09-21
7
+
8
+ ### Fixed
9
+ - **Provider catalog decoupling in settings UI (#333, GitHub #11 / PR #12)**: `useProviderCatalog` in `lib/client.js` now loads the active provider catalog (`/dsh-key-rotation/config`) independently of `settingsScope` readiness, using `AbortController` and `window.focus` refresh. Eliminates false-negative `(not registered)` badge on existing routes when the settings snapshot was already ready on initial render. Fallback configuration loading is protected against overwriting newer settings values.
10
+ - **Modern provider probe endpoint discovery (#333, GitHub #11 / PR #12)**: extracted dedicated resolver `resolveProbeBaseUrl(ctx, provider)` in `lib/probe-endpoint.js`. Inspects `llm.listConfigurableProviders()` and resolves connection URLs from `settingsNs` + `settingsPath` in effective settings (`settings.describe()`). Supports `baseURL`, `baseUrl`, `apiBase`, `endpoint`, and `url`, eliminating false `no-baseurl` errors when probing SenseNova and modern dictionary-configured pi-ai / standalone adapters. Implements safe path traversal and fail-closed validation.
11
+
12
+ ## 0.8.16 - 2026-09-20
13
+
14
+ ### Fixed
15
+ - **Header chip translation scope (#330, GitHub #7 / PR #8)**: `KeyRotationHeaderChip` in `lib/client.js` now accepts props and resolves translator `resolveT(props)` before opening popover, preventing `ReferenceError: t is not defined` and fixing disappearance on first click. Header slot registration includes `locale: NS`.
16
+ - **Live bulk key testing & bounded concurrency (#330, GitHub #9 / PR #10)**: "Test all keys" now executes live API probes (`probe: 'models'`) identically to individual testing via unified `runKeyTests()`, eliminating false-positive green status on invalid keys. Requests are serialized (concurrency 1) to avoid bursting endpoints, mutex `testRunRef` guards duplicate clicks, and returned failure error codes (`tr.code`) are displayed in the UI.
17
+
6
18
  ## 0.8.15 - 2026-09-20
7
19
 
8
20
  ### Fixed
package/lib/client.js CHANGED
@@ -72,6 +72,7 @@ window.__ModuleLoader__.load({
72
72
  cardDesc: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
73
73
  loading: 'Loading…',
74
74
  notRegistered: 'not registered',
75
+ providerCatalogUnavailable: 'Provider list unavailable',
75
76
  removeKey: 'Remove key',
76
77
  removeProvider: 'Remove provider',
77
78
  addKey: '+ Add key',
@@ -211,6 +212,7 @@ window.__ModuleLoader__.load({
211
212
  cardDesc: '按提供商 API 密钥轮换:密钥池、配额/限流错误自动故障转移、冷却与恢复。',
212
213
  loading: '加载中…',
213
214
  notRegistered: '未注册',
215
+ providerCatalogUnavailable: '提供商列表暂不可用',
214
216
  removeKey: '移除密钥',
215
217
  removeProvider: '移除提供商',
216
218
  addKey: '+ 添加密钥',
@@ -642,6 +644,47 @@ window.__ModuleLoader__.load({
642
644
  );
643
645
  }
644
646
 
647
+ // Provider registration is not part of settingsScope's value. Fetch it
648
+ // independently, and never turn an unavailable catalog into proof of absence.
649
+ function useProviderCatalog() {
650
+ const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
651
+ const requestRef = React.useRef(null);
652
+ const reload = React.useCallback(() => {
653
+ requestRef.current?.abort();
654
+ const controller = new AbortController();
655
+ requestRef.current = controller;
656
+ setCatalog((s) => ({ ...s, status: 'loading' }));
657
+ return fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal })
658
+ .then(async (response) => {
659
+ if (!response.ok) throw new Error('provider catalog unavailable');
660
+ const data = await response.json();
661
+ if (!Array.isArray(data?.providers) || !data.providers.every((p) =>
662
+ p && typeof p.id === 'string' && p.id.length > 0)) {
663
+ throw new Error('invalid provider catalog');
664
+ }
665
+ if (controller.signal.aborted || requestRef.current !== controller) return;
666
+ setCatalog({ status: 'ready', providers: data.providers.map((p) => ({
667
+ id: p.id, name: typeof p.name === 'string' ? p.name : p.id,
668
+ })) });
669
+ })
670
+ .catch(() => {
671
+ if (!controller.signal.aborted && requestRef.current === controller) {
672
+ setCatalog((s) => ({ ...s, status: 'error' }));
673
+ }
674
+ });
675
+ }, []);
676
+ React.useEffect(() => {
677
+ reload();
678
+ // A provider may have been installed/enabled while this page was open.
679
+ window.addEventListener?.('focus', reload);
680
+ return () => {
681
+ requestRef.current?.abort();
682
+ window.removeEventListener?.('focus', reload);
683
+ };
684
+ }, [reload]);
685
+ return { ...catalog, reload };
686
+ }
687
+
645
688
  function KeyRotationSection(props) {
646
689
  const t = resolveT(props);
647
690
  const settingsScope = React.useMemo(() => {
@@ -681,6 +724,9 @@ window.__ModuleLoader__.load({
681
724
  getScopeSnapshot
682
725
  );
683
726
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
727
+ const catalog = useProviderCatalog();
728
+ const latestScopeSnapshot = React.useRef(scopeSnapshot);
729
+ latestScopeSnapshot.current = scopeSnapshot;
684
730
  // #290: surface settingsScope availability as card state
685
731
  React.useEffect(() => {
686
732
  if (!settingsScope) {
@@ -737,6 +783,7 @@ window.__ModuleLoader__.load({
737
783
  }
738
784
  };
739
785
  }, [confirmModal]);
786
+ const testRunRef = React.useRef(false);
740
787
  const [testing, setTesting] = React.useState('');
741
788
  const [testResult, setTestResult] = React.useState({});
742
789
  const [testAllProvider, setTestAllProvider] = React.useState('');
@@ -750,37 +797,71 @@ window.__ModuleLoader__.load({
750
797
  else if (providers[u.index]) { const keys=[...providers[u.index].keys]; keys.splice(Math.min(u.kIndex, keys.length), 0, u.key); providers[u.index] = { ...providers[u.index], keys }; }
751
798
  return { ...cur, providers };
752
799
  }); };
753
- const doTest = (ref) => {
754
- setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
755
- // #212: real API probe (models is free on most providers), not just presence
756
- fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
757
- .then((r) => r.json())
758
- .then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
759
- .catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
760
- .finally(() => setTesting(''));
800
+ // Both buttons must run the same live probe, never a presence-only check.
801
+ // Serialize probes: a large pool must not burst /models requests. The ref
802
+ // also guards repeated clicks before React has rendered disabled buttons.
803
+ const runKeyTests = async (refs, providerId = '') => {
804
+ if (testRunRef.current || refs.length === 0) return;
805
+ testRunRef.current = true;
806
+ setTestAllProvider(providerId);
807
+ setTestResult((m) => {
808
+ const next = { ...m };
809
+ for (const ref of refs) next[ref] = null;
810
+ return next;
811
+ });
812
+ if (providerId) setTestingProgress((m) => ({ ...m, [providerId]: '0/' + refs.length }));
813
+ try {
814
+ let completed = 0;
815
+ for (const ref of refs) {
816
+ setTesting(ref);
817
+ let result;
818
+ try {
819
+ const response = await fetch('/dsh-key-rotation/test', {
820
+ method: 'POST',
821
+ headers: { 'content-type': 'application/json' },
822
+ body: JSON.stringify({ ref, probe: 'models' }),
823
+ });
824
+ const data = await response.json();
825
+ if (!response.ok) {
826
+ result = { ok: false, code: data?.error?.code || ('http-' + response.status),
827
+ message: data?.error?.message || data?.message };
828
+ } else if (typeof data?.ok !== 'boolean') {
829
+ result = { ok: false, code: 'invalid-response' };
830
+ } else {
831
+ result = data;
832
+ }
833
+ } catch (e) {
834
+ result = { ok: false, code: 'error', message: String(e?.message ?? e) };
835
+ }
836
+ setTestResult((m) => ({ ...m, [ref]: result }));
837
+ completed++;
838
+ if (providerId) setTestingProgress((m) => ({ ...m, [providerId]: completed + '/' + refs.length }));
839
+ }
840
+ } finally {
841
+ setTesting('');
842
+ setTestAllProvider('');
843
+ testRunRef.current = false;
844
+ }
761
845
  };
846
+ const doTest = (ref) => runKeyTests([ref]);
762
847
  const doTestAll = (providerId) => {
763
848
  if (!val || !Array.isArray(val.providers)) return;
764
849
  const entry = val.providers.find((p) => p.provider === providerId);
765
850
  if (!entry || !Array.isArray(entry.keys)) return;
766
- const refs = entry.keys.filter((k) => k && typeof k === 'string' && k.length > 0);
767
- setTestAllProvider(providerId);
768
- Promise.all(refs.map((ref) =>
769
- fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
770
- .then((r) => r.json())
771
- .then((data) => ({ ref, data }))
772
- .catch((e) => ({ ref, data: { ok: false, message: String(e?.message ?? e) } }))
773
- )).then((results) => {
774
- setTestResult((m) => { const nm = { ...m }; for (const { ref, data } of results) nm[ref] = data; return nm; });
775
- setTestAllProvider('');
776
- });
851
+ const refs = [...new Set(entry.keys.filter((k) => typeof k === 'string' && k.trim().length > 0))];
852
+ return runKeyTests(refs, providerId);
777
853
  };
778
854
 
779
855
  const load = React.useCallback(() => {
856
+ const scopeIsReady = () => latestScopeSnapshot.current?.status === 'ready'
857
+ && latestScopeSnapshot.current.value;
780
858
  setState((s) => ({ ...s, status: 'loading', error: '' }));
781
859
  fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
782
860
  .then((r) => r.json())
783
861
  .then((data) => {
862
+ // The scope may have become ready while this fallback was in flight.
863
+ // A bridge snapshot must not replace that newer value or its revision.
864
+ if (scopeIsReady()) return;
784
865
  setState({
785
866
  status: 'ready',
786
867
  value: data.value ?? null,
@@ -788,9 +869,10 @@ window.__ModuleLoader__.load({
788
869
  providers: Array.isArray(data.providers) ? data.providers : [],
789
870
  error: data.error ? data.error.message : '',
790
871
  });
791
- setDraft(null);
792
872
  })
793
- .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
873
+ .catch((e) => {
874
+ if (!scopeIsReady()) setState((s) => ({ ...s, status: 'error', error: String(e) }));
875
+ });
794
876
  }, []);
795
877
 
796
878
  React.useEffect(() => {
@@ -932,7 +1014,7 @@ window.__ModuleLoader__.load({
932
1014
  );
933
1015
  }
934
1016
 
935
- const providers = state.providers;
1017
+ const providers = catalog.providers;
936
1018
  const providerById = new Map(providers.map((p) => [p.id, p.name]));
937
1019
 
938
1020
  const setField = (fn) => setDraft(fn(val));
@@ -1101,7 +1183,8 @@ window.__ModuleLoader__.load({
1101
1183
  const providerRows = providerList.map((entry, pIndex) => {
1102
1184
  const options = [];
1103
1185
  if (entry.provider && !providerById.has(entry.provider)) {
1104
- options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
1186
+ options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + (catalog.status === 'ready' ? t('notRegistered')
1187
+ : catalog.status === 'loading' ? t('loading') : t('providerCatalogUnavailable')) + ')'));
1105
1188
  }
1106
1189
  options.push(...providers.map((prov) =>
1107
1190
  h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
@@ -1203,12 +1286,13 @@ window.__ModuleLoader__.load({
1203
1286
  const tr = testResult[key];
1204
1287
  if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
1205
1288
  title: (tr.message || (tr.ok ? t('testOk') : t('testFail')))
1289
+ + (!tr.ok && tr.code ? ' · ' + tr.code : '')
1206
1290
  + (tr.ok && tr.modelsCount ? ' · ' + tr.modelsCount + ' models' : '')
1207
1291
  + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
1208
1292
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
1209
- tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
1293
+ tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : ('✕' + (tr.code ? ' ' + tr.code : ''))));
1210
1294
  // #219: last probe from the sandbox cache, greyed when older than 24h
1211
- else if (probeCache && probeCache[key]) {
1295
+ else if (!Object.prototype.hasOwnProperty.call(testResult, key) && probeCache && probeCache[key]) {
1212
1296
  const pc = probeCache[key];
1213
1297
  const stale = Date.now() - (pc.at ?? 0) > 86400000;
1214
1298
  meta.push(h('span', { key: 'pc', className: 'krot-tail',
@@ -1216,7 +1300,7 @@ window.__ModuleLoader__.load({
1216
1300
  style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
1217
1301
  (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
1218
1302
  }
1219
- meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
1303
+ meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: Boolean(testing || testAllProvider), title: t('testKey') }, testing === key ? '…' : t('testKey')));
1220
1304
  // #223: broken keys get a one-click live re-test + auto-unbreak
1221
1305
  if (info && info.broken) {
1222
1306
  meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
@@ -1429,13 +1513,16 @@ window.__ModuleLoader__.load({
1429
1513
  danger: false,
1430
1514
  onConfirm: () => doReset(entry.provider),
1431
1515
  }), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
1432
- btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
1516
+ btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: Boolean(testing || testAllProvider), title: t('testAll') }),
1433
1517
  exportCsv,
1434
1518
  ),
1435
1519
  );
1436
1520
  });
1437
1521
 
1438
- const noProviders = providers.length === 0
1522
+ const noProviders = catalog.status === 'error'
1523
+ ? h('div', { className: 'krot-hint', role: 'status' }, t('providerCatalogUnavailable'),
1524
+ btn(t('retry'), catalog.reload))
1525
+ : catalog.status === 'ready' && providers.length === 0
1439
1526
  ? h('div', { className: 'krot-empty', role: 'status', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '16px 14px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)' } },
1440
1527
  h('p', { style: { margin: 0, fontWeight: 600, fontSize: 14, color: 'var(--dsw-alias-label-primary)' } }, t('emptyTitle')),
1441
1528
  h('p', { className: 'krot-hint', style: { margin: 0 } }, t('emptyDesc')),
@@ -1701,7 +1788,8 @@ window.__ModuleLoader__.load({
1701
1788
  // #201 header chip: one dot + counts for all pools. Green = all healthy,
1702
1789
  // amber = some keys cooling, red = a pool fully exhausted. Click opens the
1703
1790
  // same summary the floating dashboard shows.
1704
- function KeyRotationHeaderChip() {
1791
+ function KeyRotationHeaderChip(props) {
1792
+ const t = resolveT(props);
1705
1793
  const [snap, setSnap] = React.useState(null);
1706
1794
  const [open, setOpen] = React.useState(false);
1707
1795
  const ref = React.useRef(null);
@@ -1747,7 +1835,7 @@ window.__ModuleLoader__.load({
1747
1835
  h('button', {
1748
1836
  type: 'button',
1749
1837
  className: 'krot-header-chip',
1750
- title: 'Key Rotation',
1838
+ title: t('title'),
1751
1839
  onClick: () => setOpen((v) => !v),
1752
1840
  },
1753
1841
  h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none', boxShadow: `0 0 6px ${color}` } }),
@@ -1756,7 +1844,7 @@ window.__ModuleLoader__.load({
1756
1844
  ),
1757
1845
  open ? h('div', { className: 'krot-popover' },
1758
1846
  h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
1759
- !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'No active pools') : null,
1847
+ !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
1760
1848
  poolEntries.map(([name, p]) => {
1761
1849
  const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)');
1762
1850
  return h('div', { key: name, className: 'krot-pop-row' },
@@ -1780,7 +1868,7 @@ window.__ModuleLoader__.load({
1780
1868
  try {
1781
1869
  ctx.slots.inject('conversation.session.header.utilities', () =>
1782
1870
  ctx.slots.register(
1783
- { name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6 },
1871
+ { name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6, locale: NS },
1784
1872
  KeyRotationHeaderChip,
1785
1873
  ));
1786
1874
  } catch { /* slot not available in this build */ }
@@ -0,0 +1,99 @@
1
+ // Resolve only the selected provider's connection; never infer a vendor URL.
2
+ import { bestEffort } from './best-effort.js';
3
+
4
+ const own = (object, key) => object != null && typeof object === 'object'
5
+ && Object.prototype.hasOwnProperty.call(object, key);
6
+
7
+ function endpointOf(profile) {
8
+ for (const field of ['baseURL', 'baseUrl', 'apiBase', 'endpoint', 'url']) {
9
+ if (!own(profile, field) || profile[field] == null || profile[field] === '') continue;
10
+ const value = profile[field];
11
+ if (typeof value !== 'string' || !value.trim()) return null;
12
+ try {
13
+ const url = new URL(value.trim());
14
+ // /models is appended by the runner. Query/fragment/userinfo bases do
15
+ // not compose safely; HTTP loopback remains valid for local providers.
16
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
17
+ || url.search || url.hash) return null;
18
+ return value.trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function atPath(value, path) {
27
+ if (!Array.isArray(path)) return undefined;
28
+ for (const segment of path) {
29
+ if (typeof segment !== 'string' || !segment
30
+ || ['__proto__', 'constructor', 'prototype'].includes(segment) || !own(value, segment)) return undefined;
31
+ value = value[segment];
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function legacyProfile(providers, provider) {
37
+ if (Array.isArray(providers)) {
38
+ // A declared alias is routing metadata; a display name is not an identity.
39
+ const matches = providers.filter((p) => p && (p.id === provider || p.provider === provider
40
+ || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
41
+ return matches.length === 1 ? matches[0] : undefined;
42
+ }
43
+ return own(providers, provider) ? providers[provider] : undefined;
44
+ }
45
+
46
+ /** Resolve current effective settings on every probe (including after edits).
47
+ * LlmProviderInfo is display-only. Modern hosts declare the exact settings
48
+ * address via listConfigurableProviders(); no adapter/model call is necessary.
49
+ */
50
+ export async function resolveProbeBaseUrl(ctx, provider) {
51
+ if (typeof provider !== 'string' || !provider) return null;
52
+ const llm = await bestEffort('probe.llm', () => ctx?.get?.('llm') ?? ctx?.llm);
53
+ const readSections = async () => {
54
+ const settings = await bestEffort('probe.settings', () => ctx?.get?.('settings'));
55
+ const sections = await bestEffort('probe.settings.describe', () => settings?.describe?.({ redactSecrets: true }));
56
+ return Array.isArray(sections) ? sections : [];
57
+ };
58
+ if (typeof llm?.listConfigurableProviders === 'function') {
59
+ const directory = await bestEffort('probe.directory', () => llm.listConfigurableProviders());
60
+ // Do not fall through to a stale endpoint if the authoritative directory
61
+ // is unavailable or ambiguous. No exception/connection detail is logged.
62
+ if (!Array.isArray(directory)) return null;
63
+ const matches = directory.filter((entry) => entry?.provider === provider);
64
+ if (matches.length > 1) return null;
65
+ if (matches.length === 1) {
66
+ const entry = matches[0];
67
+ if (typeof entry.settingsNs !== 'string' || !entry.settingsNs) return null;
68
+ const sections = (await readSections()).filter((section) => section?.ns === entry.settingsNs);
69
+ if (sections.length !== 1) return null;
70
+ return endpointOf(atPath(sections[0].value, entry.settingsPath));
71
+ }
72
+ }
73
+
74
+ // Older integrations may still expose endpoints directly. Only exact route
75
+ // identities are accepted; never match a provider by its human-readable name.
76
+ const info = await bestEffort('probe.provider', () => llm?.getProvider?.(provider));
77
+ if (info && (info.id == null || info.id === provider)) {
78
+ const url = endpointOf(info);
79
+ if (url) return url;
80
+ }
81
+ const providers = await bestEffort('probe.providers', () => llm?.listProviders?.());
82
+ const match = Array.isArray(providers) ? providers.filter((p) => p?.id === provider) : [];
83
+ if (match.length > 1) return null;
84
+ if (match.length === 1) {
85
+ const url = endpointOf(match[0]);
86
+ if (url) return url;
87
+ }
88
+
89
+ // Compatibility for hosts predating the configurable-provider directory.
90
+ // llm-pi-ai is a known settings namespace, not necessarily a Cordis service.
91
+ const sections = (await readSections()).filter((section) => section?.ns === 'llm-pi-ai');
92
+ if (sections.length > 1) return null;
93
+ if (sections.length === 1) {
94
+ const profile = legacyProfile(sections[0].value?.providers, provider);
95
+ if (profile !== undefined) return endpointOf(profile);
96
+ }
97
+ const legacy = await bestEffort('probe.legacy', () => ctx?.get?.('llm-pi-ai'));
98
+ return endpointOf(legacyProfile(legacy?.providers ?? legacy?.config?.providers, provider));
99
+ }
@@ -1,5 +1,7 @@
1
1
  // lib/sandbox-service.js — sandbox runner and cache service
2
2
  import { LastTestCache, SandboxRunner } from './sandbox.js';
3
+ import { bestEffort } from './best-effort.js';
4
+ import { resolveProbeBaseUrl } from './probe-endpoint.js';
3
5
 
4
6
  export function createSandboxService({ getRuntime }) {
5
7
  let sandboxRunner = null;
@@ -9,36 +11,11 @@ export function createSandboxService({ getRuntime }) {
9
11
  function ensureSandboxRunner(ctx) {
10
12
  if (ctx) defaultCtx = ctx;
11
13
  if (sandboxRunner) return sandboxRunner;
12
- function resolveBaseUrl(providerOrRef) {
13
- try {
14
- let provider = providerOrRef;
15
- const rt = typeof getRuntime === 'function' ? getRuntime() : null;
16
- const pool = rt?.poolByRef?.get(providerOrRef);
17
- if (pool?.base) provider = pool.base;
18
- else if (pool?.provider) provider = pool.provider;
19
-
20
- const c = ctx || defaultCtx;
21
- const pInfo = c?.llm?.getProvider?.(provider);
22
- if (pInfo && (pInfo.baseUrl || pInfo.endpoint || pInfo.url)) {
23
- return String(pInfo.baseUrl || pInfo.endpoint || pInfo.url);
24
- }
25
- for (const info of (c?.llm?.listProviders?.() || [])) {
26
- if (info && (info.id === provider || info.name === provider)) {
27
- const u = info.baseUrl || info.endpoint || info.url;
28
- if (u) return String(u);
29
- }
30
- }
31
- const ns = c?.get ? c.get('llm-pi-ai') : null;
32
- const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
33
- if (Array.isArray(list)) {
34
- const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
35
- const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
36
- if (base) return String(base);
37
- }
38
- return null;
39
- } catch (_) {
40
- return null;
41
- }
14
+ async function resolveBaseUrl(providerOrRef) {
15
+ const rt = await bestEffort('probe.runtime', () => typeof getRuntime === 'function' ? getRuntime() : null);
16
+ const pool = rt?.poolByRef?.get(providerOrRef);
17
+ const provider = pool?.base || pool?.provider || providerOrRef;
18
+ return resolveProbeBaseUrl(defaultCtx, provider);
42
19
  }
43
20
  sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
44
21
  return sandboxRunner;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.15",
3
+ "version": "0.8.17",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [