@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 +12 -0
- package/lib/client.js +120 -32
- package/lib/probe-endpoint.js +99 -0
- package/lib/sandbox-service.js +7 -30
- package/package.json +1 -1
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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) =>
|
|
767
|
-
|
|
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) =>
|
|
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 =
|
|
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 + ' (' +
|
|
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
|
|
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:
|
|
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 =
|
|
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: '
|
|
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)' } }, '
|
|
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
|
+
}
|
package/lib/sandbox-service.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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": [
|