@goodandready/dsh-subscriptions 0.7.5 → 0.7.6
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/bin/dsh-subscriptions.mjs +98 -2
- package/lib/alerts.js +8 -1
- package/lib/client.js +108 -0
- package/lib/config-schema.js +4 -0
- package/lib/index.js +22 -2
- package/lib/key-rotation-bridge.js +36 -5
- package/lib/notification-scheduler.js +7 -0
- package/lib/routes/packs.js +41 -0
- package/lib/routes.js +2 -0
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from 'node:process'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
4
5
|
|
|
5
6
|
export function parseArgs(argv) {
|
|
6
7
|
const args = Array.isArray(argv) ? argv.slice() : []
|
|
7
8
|
const command = args.shift() || 'status'
|
|
8
|
-
const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '' }
|
|
9
|
+
const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '', packAction: '', packFile: '' }
|
|
9
10
|
for (let i = 0; i < args.length; i += 1) {
|
|
10
11
|
const arg = args[i]
|
|
11
12
|
if (arg === '--csv') options.csv = true
|
|
@@ -15,6 +16,8 @@ export function parseArgs(argv) {
|
|
|
15
16
|
else if (arg === '--session') options.sessionId = args[++i] || ''
|
|
16
17
|
else if (command === 'set' && !options.setKey) options.setKey = arg
|
|
17
18
|
else if (command === 'set') options.setValue = arg
|
|
19
|
+
else if (command === 'packs' && arg === 'add') options.packAction = 'add'
|
|
20
|
+
else if (command === 'packs' && options.packAction === 'add' && !options.packFile) options.packFile = arg
|
|
18
21
|
else if (command === 'fast' && !options.sessionId) options.sessionId = arg
|
|
19
22
|
else if (command === 'fast') options.mode = arg === 'standard' || arg === 'off' ? 'standard' : 'fast'
|
|
20
23
|
}
|
|
@@ -67,9 +70,60 @@ export function buildRequest(options, env = process.env) {
|
|
|
67
70
|
}
|
|
68
71
|
if (options.command === 'config') return { url: base + '/config', init: { method: 'GET', headers } }
|
|
69
72
|
if (options.command === 'sources') return { url: base + '/sources', init: { method: 'GET', headers } }
|
|
73
|
+
if (options.command === 'packs') return { url: base + '/config', init: { method: 'GET', headers } }
|
|
74
|
+
if (options.command === 'diagnostics') return { url: base + '/diagnostics', init: { method: 'GET', headers } }
|
|
70
75
|
throw new Error('unknown command: ' + options.command)
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
export function doctorFailed(payload) {
|
|
79
|
+
const diag = (payload && payload.diagnostics) || {}
|
|
80
|
+
const report = diag.report || {}
|
|
81
|
+
const errors = Array.isArray(report.recentErrors) ? report.recentErrors.length : 0
|
|
82
|
+
const conflicts = Array.isArray(diag.conflicts) ? diag.conflicts.length : 0
|
|
83
|
+
return errors > 0 || conflicts > 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatDoctor(payload) {
|
|
87
|
+
const diag = (payload && payload.diagnostics) || {}
|
|
88
|
+
const report = diag.report || {}
|
|
89
|
+
const sources = (payload && payload.sources) || {}
|
|
90
|
+
const errors = Array.isArray(report.recentErrors) ? report.recentErrors.length : 0
|
|
91
|
+
const conflicts = Array.isArray(diag.conflicts) ? diag.conflicts.length : 0
|
|
92
|
+
const kr = sources.keyRotation || {}
|
|
93
|
+
const lines = []
|
|
94
|
+
lines.push('doctor: ' + (doctorFailed(payload) ? 'degraded' : 'ok'))
|
|
95
|
+
lines.push('errors: ' + errors + ' conflicts: ' + conflicts)
|
|
96
|
+
lines.push('key-rotation: ' + (kr.available ? (kr.healthyKeys + '/' + kr.totalKeys + ' healthy keys') : ('unavailable' + (kr.reason ? ' (' + kr.reason + ')' : ''))))
|
|
97
|
+
for (const row of (sources.providers || [])) lines.push(row.provider + ': ' + (row.selected || 'none'))
|
|
98
|
+
return lines.join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function sanitizePack(pack) {
|
|
102
|
+
if (!pack || typeof pack !== 'object') return pack
|
|
103
|
+
const copy = JSON.parse(JSON.stringify(pack))
|
|
104
|
+
if (copy.clientSecret) copy.clientSecret = '••••••'
|
|
105
|
+
if (copy.headers && typeof copy.headers === 'object') {
|
|
106
|
+
copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••••']))
|
|
107
|
+
}
|
|
108
|
+
return copy
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatPacks(packs) {
|
|
112
|
+
return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function mergeProviderPack(packs, pack) {
|
|
116
|
+
const list = (Array.isArray(packs) ? packs : []).filter((p) => p && p.id !== pack.id)
|
|
117
|
+
return [...list, pack]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildValidatePackRequest(pack, env = process.env) {
|
|
121
|
+
const base = String(env.DSH_SUBS_URL || 'http://127.0.0.1:3080/dsh-subscriptions').replace(/\/$/, '')
|
|
122
|
+
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
|
|
123
|
+
if (env.DSH_AUTH_TOKEN) headers.Authorization = 'Bearer ' + env.DSH_AUTH_TOKEN
|
|
124
|
+
return { url: base + '/provider-packs/validate', init: { method: 'POST', headers, body: JSON.stringify({ pack }) } }
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
export function formatSources(payload) {
|
|
74
128
|
const lines = []
|
|
75
129
|
const kr = payload && payload.keyRotation
|
|
@@ -98,6 +152,41 @@ export function buildSetRequest(options, configPayload, env = process.env) {
|
|
|
98
152
|
export async function run(argv = process.argv.slice(2), env = process.env, fetchImpl = fetch) {
|
|
99
153
|
const options = parseArgs(argv)
|
|
100
154
|
if (options.help) return { help: true }
|
|
155
|
+
if (options.command === 'doctor') {
|
|
156
|
+
const diagReq = buildRequest({ command: 'diagnostics' }, env)
|
|
157
|
+
const srcReq = buildRequest({ command: 'sources' }, env)
|
|
158
|
+
const [diagRes, srcRes] = await Promise.all([fetchImpl(diagReq.url, diagReq.init), fetchImpl(srcReq.url, srcReq.init)])
|
|
159
|
+
const diagText = await diagRes.text()
|
|
160
|
+
const srcText = await srcRes.text()
|
|
161
|
+
if (!diagRes.ok) throw new Error('HTTP ' + diagRes.status + ': ' + diagText.slice(0, 500))
|
|
162
|
+
if (!srcRes.ok) throw new Error('HTTP ' + srcRes.status + ': ' + srcText.slice(0, 500))
|
|
163
|
+
const payload = { diagnostics: JSON.parse(diagText || '{}'), sources: JSON.parse(srcText || '{}') }
|
|
164
|
+
return {
|
|
165
|
+
text: options.json ? JSON.stringify(payload, null, 2) + '\n' : formatDoctor(payload),
|
|
166
|
+
contentType: options.json ? 'application/json' : 'text/plain',
|
|
167
|
+
code: doctorFailed(payload) ? 1 : 0,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (options.command === 'packs' && options.packAction === 'add') {
|
|
171
|
+
if (!options.packFile) throw new Error('packs add requires a JSON file')
|
|
172
|
+
const pack = JSON.parse(await readFile(options.packFile, 'utf8'))
|
|
173
|
+
const validate = buildValidatePackRequest(pack.pack || pack, env)
|
|
174
|
+
const checked = await fetchImpl(validate.url, validate.init)
|
|
175
|
+
const checkedText = await checked.text()
|
|
176
|
+
if (!checked.ok) throw new Error('invalid pack: ' + checkedText.slice(0, 500))
|
|
177
|
+
const current = await fetchImpl(buildRequest({ command: 'config' }, env).url, buildRequest({ command: 'config' }, env).init)
|
|
178
|
+
const currentText = await current.text()
|
|
179
|
+
if (!current.ok) throw new Error('HTTP ' + current.status + ': ' + currentText.slice(0, 500))
|
|
180
|
+
const payload = JSON.parse(currentText || '{}')
|
|
181
|
+
const nextConfig = Object.assign({}, payload.config, {
|
|
182
|
+
providerPacks: mergeProviderPack(payload.config && payload.config.providerPacks, pack.pack || pack),
|
|
183
|
+
})
|
|
184
|
+
const put = buildSetRequest({ setKey: 'providerPacks', setValue: JSON.stringify(nextConfig.providerPacks) }, { config: nextConfig, revision: payload.revision }, env)
|
|
185
|
+
const saved = await fetchImpl(put.url, put.init)
|
|
186
|
+
const savedText = await saved.text()
|
|
187
|
+
if (!saved.ok) throw new Error('HTTP ' + saved.status + ': ' + savedText.slice(0, 500))
|
|
188
|
+
return { text: 'saved provider pack ' + ((pack.pack && pack.pack.id) || pack.id || ''), contentType: 'text/plain' }
|
|
189
|
+
}
|
|
101
190
|
let request = buildRequest(options.command === 'set' ? { ...options, command: 'config' } : options, env)
|
|
102
191
|
if (options.command === 'set') {
|
|
103
192
|
const current = await fetchImpl(request.url, request.init)
|
|
@@ -108,6 +197,12 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
|
|
|
108
197
|
const res = await fetchImpl(request.url, request.init)
|
|
109
198
|
const text = await res.text()
|
|
110
199
|
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 500))
|
|
200
|
+
if (options.command === 'packs' && options.packAction !== 'add') {
|
|
201
|
+
try {
|
|
202
|
+
const payload = JSON.parse(text || '{}')
|
|
203
|
+
return { text: formatPacks((payload.config && payload.config.providerPacks) || []), contentType: 'application/json' }
|
|
204
|
+
} catch { /* fall back to raw */ }
|
|
205
|
+
}
|
|
111
206
|
if (options.command === 'sources' && !options.json) {
|
|
112
207
|
try { return { text: formatSources(JSON.parse(text || '{}')), contentType: 'text/plain' } } catch { /* fall back to raw */ }
|
|
113
208
|
}
|
|
@@ -117,10 +212,11 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
|
|
|
117
212
|
async function main() {
|
|
118
213
|
const result = await run()
|
|
119
214
|
if (result.help) {
|
|
120
|
-
process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n config\n set <dot.path> <value>\n sources [--json]\n')
|
|
215
|
+
process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|doctor> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n config\n set <dot.path> <value>\n sources [--json]\n packs\n packs add <file.json>\n doctor [--json]\n')
|
|
121
216
|
return
|
|
122
217
|
}
|
|
123
218
|
process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
|
|
219
|
+
if (result.code) process.exitCode = result.code
|
|
124
220
|
}
|
|
125
221
|
|
|
126
222
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/lib/alerts.js
CHANGED
|
@@ -13,6 +13,11 @@ try {
|
|
|
13
13
|
// and webhook dispatch (e.g. Telegram/Discord or external monitoring).
|
|
14
14
|
|
|
15
15
|
const MAX_ALERTS = 100
|
|
16
|
+
let defaultWebhookSecret = ''
|
|
17
|
+
|
|
18
|
+
export function setWebhookSecret(secret) {
|
|
19
|
+
defaultWebhookSecret = String(secret || '')
|
|
20
|
+
}
|
|
16
21
|
const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
|
|
17
22
|
|
|
18
23
|
const alertBuffer = []
|
|
@@ -93,7 +98,9 @@ export async function dispatchNotificationDigest(alerts, webhookUrl, fetchImpl =
|
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
async function dispatchNotification(body, webhookUrl, fetchImpl, options) {
|
|
96
|
-
const
|
|
101
|
+
const opts = options || {}
|
|
102
|
+
const { retries = 1, timeoutMs = 5000, sleep } = opts
|
|
103
|
+
const secret = opts.secret == null ? defaultWebhookSecret : opts.secret
|
|
97
104
|
const payload = { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
|
|
98
105
|
const raw = JSON.stringify(payload)
|
|
99
106
|
const headers = {
|
package/lib/client.js
CHANGED
|
@@ -1842,6 +1842,7 @@ window.__ModuleLoader__.load({
|
|
|
1842
1842
|
),
|
|
1843
1843
|
h('div', { className: 'dsub-sub' }, 'Floating capsule can be dragged; docked stays bottom-right.'),
|
|
1844
1844
|
h(SourcesPanel, { t }),
|
|
1845
|
+
h(ProviderPacksPanel),
|
|
1845
1846
|
|
|
1846
1847
|
h('div', { className: 'dsub-row' },
|
|
1847
1848
|
h('span', { className: 'dsub-h' }, t('composerQuota')),
|
|
@@ -1872,6 +1873,40 @@ window.__ModuleLoader__.load({
|
|
|
1872
1873
|
),
|
|
1873
1874
|
h('div', { className: 'dsub-sub' },
|
|
1874
1875
|
t('expiryNotifyDaysHint')),
|
|
1876
|
+
h('div', { className: 'dsub-row' },
|
|
1877
|
+
h('span', { className: 'dsub-h' }, 'Webhook URL'),
|
|
1878
|
+
h('input', {
|
|
1879
|
+
type: 'url',
|
|
1880
|
+
className: 'dsub-inp',
|
|
1881
|
+
placeholder: 'https://hooks.example/…',
|
|
1882
|
+
value: draft.webhookUrl || '',
|
|
1883
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookUrl: e.target.value })),
|
|
1884
|
+
}),
|
|
1885
|
+
),
|
|
1886
|
+
h('div', { className: 'dsub-sub' }, 'Signed POST target for quota, session and billing alerts.'),
|
|
1887
|
+
h('div', { className: 'dsub-row' },
|
|
1888
|
+
h('span', { className: 'dsub-h' }, 'Webhook secret'),
|
|
1889
|
+
h('input', {
|
|
1890
|
+
type: 'password',
|
|
1891
|
+
className: 'dsub-inp',
|
|
1892
|
+
autoComplete: 'new-password',
|
|
1893
|
+
value: draft.webhookSecret || '',
|
|
1894
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookSecret: e.target.value })),
|
|
1895
|
+
}),
|
|
1896
|
+
),
|
|
1897
|
+
h('div', { className: 'dsub-row' },
|
|
1898
|
+
h('span', { className: 'dsub-h' }, 'Digest interval (hours)'),
|
|
1899
|
+
h('input', {
|
|
1900
|
+
type: 'number',
|
|
1901
|
+
className: 'dsub-inp',
|
|
1902
|
+
style: { width: '80px', minWidth: '80px' },
|
|
1903
|
+
min: 0,
|
|
1904
|
+
max: 168,
|
|
1905
|
+
value: draft.digestIntervalHours != null ? draft.digestIntervalHours : 0,
|
|
1906
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { digestIntervalHours: Number(e.target.value) || 0 })),
|
|
1907
|
+
}),
|
|
1908
|
+
),
|
|
1909
|
+
h('div', { className: 'dsub-sub' }, '0 disables digests. Requires a Webhook URL.'),
|
|
1875
1910
|
),
|
|
1876
1911
|
h('details', { className: 'dsub-group', open: true },
|
|
1877
1912
|
h('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
|
|
@@ -3655,6 +3690,79 @@ window.__ModuleLoader__.load({
|
|
|
3655
3690
|
)
|
|
3656
3691
|
}
|
|
3657
3692
|
|
|
3693
|
+
// #724: provider pack manager. Displays ids/models only; headers are masked.
|
|
3694
|
+
function ProviderPacksPanel() {
|
|
3695
|
+
const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
|
|
3696
|
+
const publicPack = (pack) => {
|
|
3697
|
+
const copy = Object.assign({}, pack)
|
|
3698
|
+
if (copy.clientSecret) copy.clientSecret = '••••'
|
|
3699
|
+
if (copy.headers && typeof copy.headers === 'object') {
|
|
3700
|
+
copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••']))
|
|
3701
|
+
}
|
|
3702
|
+
return copy
|
|
3703
|
+
}
|
|
3704
|
+
const pull = () => {
|
|
3705
|
+
fetch('/dsh-subscriptions/config', { cache: 'no-store' })
|
|
3706
|
+
.then((res) => res.json())
|
|
3707
|
+
.then((cfg) => setState((st) => Object.assign({}, st, { packs: (cfg.config && cfg.config.providerPacks) || [] })))
|
|
3708
|
+
.catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
|
|
3709
|
+
}
|
|
3710
|
+
React.useEffect(() => { pull() }, [])
|
|
3711
|
+
const parse = () => {
|
|
3712
|
+
const raw = JSON.parse(state.text || '{}')
|
|
3713
|
+
return raw && raw.pack ? raw.pack : raw
|
|
3714
|
+
}
|
|
3715
|
+
const validate = (pack) => fetch('/dsh-subscriptions/provider-packs/validate', {
|
|
3716
|
+
method: 'POST',
|
|
3717
|
+
headers: { 'content-type': 'application/json' },
|
|
3718
|
+
body: JSON.stringify({ pack }),
|
|
3719
|
+
}).then(async (res) => {
|
|
3720
|
+
const data = await res.json()
|
|
3721
|
+
if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
|
|
3722
|
+
return data.pack
|
|
3723
|
+
})
|
|
3724
|
+
const save = () => {
|
|
3725
|
+
setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
|
|
3726
|
+
Promise.resolve().then(parse)
|
|
3727
|
+
.then(async (pack) => {
|
|
3728
|
+
const checked = await validate(pack)
|
|
3729
|
+
const cfg = await fetch('/dsh-subscriptions/config', { cache: 'no-store' }).then((res) => res.json())
|
|
3730
|
+
const packs = ((cfg.config && cfg.config.providerPacks) || []).filter((p) => p && p.id !== pack.id)
|
|
3731
|
+
packs.push(pack)
|
|
3732
|
+
const res = await fetch('/dsh-subscriptions/config', {
|
|
3733
|
+
method: 'PUT',
|
|
3734
|
+
headers: { 'content-type': 'application/json' },
|
|
3735
|
+
body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
|
|
3736
|
+
})
|
|
3737
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
3738
|
+
setState((st) => Object.assign({}, st, { packs, busy: false, message: 'saved ' + checked.id, text: '' }))
|
|
3739
|
+
})
|
|
3740
|
+
.catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
|
|
3741
|
+
}
|
|
3742
|
+
const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: 'exported' }))
|
|
3743
|
+
return h('div', { className: 'dsub-section-card' },
|
|
3744
|
+
h('div', { className: 'dsub-section-title' }, 'Provider packs', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
|
|
3745
|
+
h('div', { className: 'dsub-section-desc' }, 'Add an OpenAI-Responses-compatible provider as a JSON manifest. Headers are masked in the editor.'),
|
|
3746
|
+
state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
|
|
3747
|
+
h('span', { className: 'dsub-h' }, pack.id),
|
|
3748
|
+
h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
|
|
3749
|
+
)) : h('div', { className: 'dsub-dim' }, 'No packs yet.'),
|
|
3750
|
+
h('textarea', {
|
|
3751
|
+
className: 'dsub-inp',
|
|
3752
|
+
style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
|
|
3753
|
+
value: state.text,
|
|
3754
|
+
placeholder: '{"id":"acme","displayName":"Acme AI","authUrl":"https://…","tokenUrl":"https://…","baseUrl":"https://…","clientId":"…","models":[{"id":"acme-1"}]}',
|
|
3755
|
+
onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
|
|
3756
|
+
}),
|
|
3757
|
+
h('div', { className: 'dsub-row' },
|
|
3758
|
+
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : 'Validate & save'),
|
|
3759
|
+
h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, 'Export'),
|
|
3760
|
+
),
|
|
3761
|
+
state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
|
|
3762
|
+
state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
|
|
3763
|
+
)
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3658
3766
|
// #713: read-only source chain panel. Never renders key values.
|
|
3659
3767
|
function SourcesPanel() {
|
|
3660
3768
|
const [state, setState] = React.useState({ loading: true, data: null, error: null })
|
package/lib/config-schema.js
CHANGED
|
@@ -157,6 +157,10 @@ export const Config = z.object({
|
|
|
157
157
|
.description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
|
|
158
158
|
webhookUrl: z.string().volatile().default('')
|
|
159
159
|
.description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
|
|
160
|
+
webhookSecret: z.string().role('secret').default('')
|
|
161
|
+
.description('#720 HMAC-SHA256 secret used to sign webhook payloads.'),
|
|
162
|
+
digestIntervalHours: z.number().volatile().default(0)
|
|
163
|
+
.description('#720 Send an alert digest every N hours (0 disables digests).'),
|
|
160
164
|
alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
|
|
161
165
|
.description('#351 Quota usage percentage thresholds for firing alerts.'),
|
|
162
166
|
enableToasts: z.boolean().volatile().default(true)
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import { createAccountsView } from './accounts-view.js'
|
|
|
28
28
|
import { inspectScopes } from './scope-inspector.js'
|
|
29
29
|
import { createAdapterManager } from './adapter-manager.js'
|
|
30
30
|
import { pickAccount, isAuthError } from './rotate.js'
|
|
31
|
-
import { notifySessionExpired } from './alerts.js'
|
|
31
|
+
import { notifySessionExpired, getRecentAlerts, dispatchNotificationDigest, setWebhookSecret } from './alerts.js'
|
|
32
|
+
import { isDigestDue } from './notification-scheduler.js'
|
|
32
33
|
|
|
33
34
|
export { observeForecast, estimateForecast } from './forecast.js'
|
|
34
35
|
export { formatRelativeReset } from './relative-time.js'
|
|
@@ -91,7 +92,10 @@ export function apply(ctx, rawConfig) {
|
|
|
91
92
|
return plainConfig(Config(plainConfig(rawConfig || {})))
|
|
92
93
|
}
|
|
93
94
|
let cachedConfig = resolveEntry()
|
|
94
|
-
const live = () =>
|
|
95
|
+
const live = () => {
|
|
96
|
+
setWebhookSecret(cachedConfig.webhookSecret)
|
|
97
|
+
return cachedConfig
|
|
98
|
+
}
|
|
95
99
|
|
|
96
100
|
function syncCustomVendors() {
|
|
97
101
|
const profiles = validateProfiles(live().customVendors)
|
|
@@ -604,6 +608,22 @@ export function apply(ctx, rawConfig) {
|
|
|
604
608
|
}
|
|
605
609
|
}, 'dsh-subscriptions: vault-sync-loop')
|
|
606
610
|
|
|
611
|
+
// #720: interval digest for busy webhook channels.
|
|
612
|
+
ctx.effect(() => {
|
|
613
|
+
let lastDigestAt = 0
|
|
614
|
+
const tick = () => {
|
|
615
|
+
const cfg = live()
|
|
616
|
+
const alerts = getRecentAlerts(100)
|
|
617
|
+
if (!isDigestDue({ lastDigestAt, nowMs: Date.now(), intervalHours: cfg.digestIntervalHours, alertCount: alerts.length })) return
|
|
618
|
+
dispatchNotificationDigest(alerts, cfg.webhookUrl, fetch, { secret: cfg.webhookSecret })
|
|
619
|
+
.then((ok) => { if (ok) lastDigestAt = Date.now() })
|
|
620
|
+
.catch(() => {})
|
|
621
|
+
}
|
|
622
|
+
tick()
|
|
623
|
+
const id = setInterval(tick, 60 * 60 * 1000)
|
|
624
|
+
return () => clearInterval(id)
|
|
625
|
+
}, 'dsh-subscriptions: notification digest')
|
|
626
|
+
|
|
607
627
|
registerRoutes(ctx, {
|
|
608
628
|
NS,
|
|
609
629
|
live,
|
|
@@ -41,19 +41,50 @@ export function summarizeKeyRotationStatus(raw) {
|
|
|
41
41
|
return { available: true, providers, totalKeys, healthyKeys }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// #726: a short TTL keeps the widget, settings panel and doctor from
|
|
45
|
+
// multiplying key-rotation status reads. Errors are cached for the same TTL.
|
|
46
|
+
let statusCache = { key: '', at: 0, value: null }
|
|
47
|
+
let inFlight = null
|
|
48
|
+
|
|
49
|
+
export function clearKeyRotationCache() {
|
|
50
|
+
statusCache = { key: '', at: 0, value: null }
|
|
51
|
+
inFlight = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readKeyRotationStatus({ baseUrl, path = KEY_ROTATION_STATUS_PATH, fetchImpl = fetch, timeoutMs = 2000, cacheTtlMs = 10000, bypassCache = false } = {}) {
|
|
45
55
|
if (!baseUrl) return { available: false, reason: 'missing base url', providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
56
|
+
const url = String(baseUrl).replace(/\/$/, '') + path
|
|
57
|
+
if (!bypassCache && statusCache.key === url && (Date.now() - statusCache.at) < Number(cacheTtlMs)) {
|
|
58
|
+
return statusCache.value
|
|
59
|
+
}
|
|
60
|
+
if (!bypassCache && inFlight && inFlight.key === url) return inFlight.promise
|
|
61
|
+
const promise = (async () => {
|
|
46
62
|
try {
|
|
47
63
|
const init = { method: 'GET', headers: { accept: 'application/json' } }
|
|
48
64
|
if (timeoutMs > 0 && typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
49
65
|
init.signal = AbortSignal.timeout(timeoutMs)
|
|
50
66
|
}
|
|
51
|
-
const res = await fetchImpl(
|
|
52
|
-
if (!res || !res.ok)
|
|
67
|
+
const res = await fetchImpl(url, init)
|
|
68
|
+
if (!res || !res.ok) {
|
|
69
|
+
const value = { available: false, reason: 'HTTP ' + (res && res.status), providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
70
|
+
statusCache = { key: url, at: Date.now(), value }
|
|
71
|
+
return value
|
|
72
|
+
}
|
|
53
73
|
const raw = await res.json()
|
|
54
|
-
|
|
74
|
+
const value = summarizeKeyRotationStatus(raw)
|
|
75
|
+
statusCache = { key: url, at: Date.now(), value }
|
|
76
|
+
return value
|
|
55
77
|
} catch (err) {
|
|
56
|
-
|
|
78
|
+
const value = { available: false, reason: String(err && err.message || err), providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
79
|
+
statusCache = { key: url, at: Date.now(), value }
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
})()
|
|
83
|
+
inFlight = { key: url, promise }
|
|
84
|
+
try {
|
|
85
|
+
return await promise
|
|
86
|
+
} finally {
|
|
87
|
+
if (inFlight && inFlight.promise === promise) inFlight = null
|
|
57
88
|
}
|
|
58
89
|
}
|
|
59
90
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// #720: digest scheduling is a pure decision so it can be tested without timers.
|
|
2
|
+
export function isDigestDue({ lastDigestAt = 0, nowMs = Date.now(), intervalHours = 0, alertCount = 0 } = {}) {
|
|
3
|
+
if (!(Number(intervalHours) > 0)) return false
|
|
4
|
+
if (!(Number(alertCount) > 0)) return false
|
|
5
|
+
if (!lastDigestAt) return true
|
|
6
|
+
return Number(nowMs) - Number(lastDigestAt) >= Number(intervalHours) * 60 * 60 * 1000
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isTrustedSettingsRequest, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
2
|
+
import { normalizeVendorPack } from '../vendor-pack.js'
|
|
3
|
+
|
|
4
|
+
export function registerPacksRoutes(ctx) {
|
|
5
|
+
ctx.effect(() => ctx.webServer.register({
|
|
6
|
+
kind: 'exact',
|
|
7
|
+
path: '/dsh-subscriptions/provider-packs/validate',
|
|
8
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
9
|
+
if (req.method !== 'POST') {
|
|
10
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
14
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
let body
|
|
18
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
|
|
19
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const raw = body && body.pack && typeof body.pack === 'object' ? body.pack : body
|
|
23
|
+
try {
|
|
24
|
+
const pack = normalizeVendorPack(raw)
|
|
25
|
+
writeJson(res, 200, {
|
|
26
|
+
ok: true,
|
|
27
|
+
pack: {
|
|
28
|
+
id: pack.id,
|
|
29
|
+
displayName: pack.displayName,
|
|
30
|
+
baseUrl: pack.baseUrl,
|
|
31
|
+
models: pack.models,
|
|
32
|
+
scope: pack.scope,
|
|
33
|
+
tokenStyle: pack.tokenStyle,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
} catch (err) {
|
|
37
|
+
writeJson(res, 400, { ok: false, error: { code: 'pack', message: String(err && err.message || err) } })
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
}), 'dsh-subscriptions: /provider-packs/validate')
|
|
41
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -6,6 +6,7 @@ import { registerOauthRoutes } from './routes/oauth.js'
|
|
|
6
6
|
import { registerAccountsRoutes } from './routes/accounts.js'
|
|
7
7
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
8
8
|
import { registerSourcesRoutes } from './routes/sources.js'
|
|
9
|
+
import { registerPacksRoutes } from './routes/packs.js'
|
|
9
10
|
import { registerPluginUpdater } from './updater.js'
|
|
10
11
|
|
|
11
12
|
export function registerRoutes(ctx, state) {
|
|
@@ -111,4 +112,5 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&').replace(/</g,'
|
|
|
111
112
|
registerAccountsRoutes(ctx, state)
|
|
112
113
|
registerProxyRoutes(ctx, state)
|
|
113
114
|
registerSourcesRoutes(ctx, state)
|
|
115
|
+
registerPacksRoutes(ctx)
|
|
114
116
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|