@goodandready/dsh-subscriptions 0.7.3 → 0.7.4
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 +69 -3
- package/lib/client.js +82 -8
- package/lib/config-schema.js +3 -0
- package/lib/key-rotation-bridge.js +102 -0
- package/lib/routes/sources.js +52 -0
- package/lib/routes/status.js +1 -0
- package/lib/routes.js +2 -0
- package/package.json +1 -1
|
@@ -5,19 +5,49 @@ import { pathToFileURL } from 'node:url'
|
|
|
5
5
|
export function parseArgs(argv) {
|
|
6
6
|
const args = Array.isArray(argv) ? argv.slice() : []
|
|
7
7
|
const command = args.shift() || 'status'
|
|
8
|
-
const options = { command, csv: false, type: 'accounts', sessionId: '', mode: 'fast', help: false }
|
|
8
|
+
const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '' }
|
|
9
9
|
for (let i = 0; i < args.length; i += 1) {
|
|
10
10
|
const arg = args[i]
|
|
11
11
|
if (arg === '--csv') options.csv = true
|
|
12
|
+
else if (arg === '--json') options.json = true
|
|
12
13
|
else if (arg === '--help' || arg === '-h') options.help = true
|
|
13
14
|
else if (arg === '--type') options.type = args[++i] || 'accounts'
|
|
14
15
|
else if (arg === '--session') options.sessionId = args[++i] || ''
|
|
16
|
+
else if (command === 'set' && !options.setKey) options.setKey = arg
|
|
17
|
+
else if (command === 'set') options.setValue = arg
|
|
15
18
|
else if (command === 'fast' && !options.sessionId) options.sessionId = arg
|
|
16
19
|
else if (command === 'fast') options.mode = arg === 'standard' || arg === 'off' ? 'standard' : 'fast'
|
|
17
20
|
}
|
|
18
21
|
return options
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
export function parseConfigValue(raw) {
|
|
25
|
+
if (raw === 'true') return true
|
|
26
|
+
if (raw === 'false') return false
|
|
27
|
+
if (raw === 'null') return null
|
|
28
|
+
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw)
|
|
29
|
+
if (raw.startsWith('{') || raw.startsWith('[')) {
|
|
30
|
+
try { return JSON.parse(raw) } catch { throw new Error('value is not valid JSON: ' + raw) }
|
|
31
|
+
}
|
|
32
|
+
return raw
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setNested(target, path, value) {
|
|
36
|
+
const parts = String(path || '').split('.').filter(Boolean)
|
|
37
|
+
if (!parts.length) throw new Error('set requires a dot path, for example rotationStrategy')
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
if (part === '__proto__' || part === 'constructor' || part === 'prototype') throw new Error('unsafe path: ' + path)
|
|
40
|
+
}
|
|
41
|
+
let cursor = target
|
|
42
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
43
|
+
const key = parts[i]
|
|
44
|
+
if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) cursor[key] = {}
|
|
45
|
+
cursor = cursor[key]
|
|
46
|
+
}
|
|
47
|
+
cursor[parts[parts.length - 1]] = value
|
|
48
|
+
return target
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
export function buildRequest(options, env = process.env) {
|
|
22
52
|
const base = String(env.DSH_SUBS_URL || 'http://127.0.0.1:3080/dsh-subscriptions').replace(/\/$/, '')
|
|
23
53
|
const headers = { Accept: 'application/json' }
|
|
@@ -35,23 +65,59 @@ export function buildRequest(options, env = process.env) {
|
|
|
35
65
|
init: { method: 'POST', headers, body: JSON.stringify({ sessionId: options.sessionId, mode: options.mode || 'fast' }) },
|
|
36
66
|
}
|
|
37
67
|
}
|
|
68
|
+
if (options.command === 'config') return { url: base + '/config', init: { method: 'GET', headers } }
|
|
69
|
+
if (options.command === 'sources') return { url: base + '/sources', init: { method: 'GET', headers } }
|
|
38
70
|
throw new Error('unknown command: ' + options.command)
|
|
39
71
|
}
|
|
40
72
|
|
|
73
|
+
export function formatSources(payload) {
|
|
74
|
+
const lines = []
|
|
75
|
+
const kr = payload && payload.keyRotation
|
|
76
|
+
lines.push('key-rotation: ' + (kr && kr.available ? (kr.healthyKeys + '/' + kr.totalKeys + ' healthy keys') : ('unavailable' + (kr && kr.reason ? ' (' + kr.reason + ')' : ''))))
|
|
77
|
+
for (const row of ((payload && payload.providers) || [])) {
|
|
78
|
+
const availability = (row.candidates || []).map((c) => c.source + '=' + (c.available ? 'yes' : 'no')).join(' ')
|
|
79
|
+
lines.push(row.provider + ': selected=' + (row.selected || 'none') + (availability ? ' ' + availability : ''))
|
|
80
|
+
}
|
|
81
|
+
return lines.join('\n')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildSetRequest(options, configPayload, env = process.env) {
|
|
85
|
+
const base = String(env.DSH_SUBS_URL || 'http://127.0.0.1:3080/dsh-subscriptions').replace(/\/$/, '')
|
|
86
|
+
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
|
|
87
|
+
if (env.DSH_AUTH_TOKEN) headers.Authorization = 'Bearer ' + env.DSH_AUTH_TOKEN
|
|
88
|
+
const config = configPayload && configPayload.config && typeof configPayload.config === 'object'
|
|
89
|
+
? JSON.parse(JSON.stringify(configPayload.config))
|
|
90
|
+
: {}
|
|
91
|
+
setNested(config, options.setKey, parseConfigValue(options.setValue))
|
|
92
|
+
return {
|
|
93
|
+
url: base + '/config',
|
|
94
|
+
init: { method: 'PUT', headers, body: JSON.stringify({ config, revision: configPayload && configPayload.revision }) },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
41
98
|
export async function run(argv = process.argv.slice(2), env = process.env, fetchImpl = fetch) {
|
|
42
99
|
const options = parseArgs(argv)
|
|
43
100
|
if (options.help) return { help: true }
|
|
44
|
-
|
|
101
|
+
let request = buildRequest(options.command === 'set' ? { ...options, command: 'config' } : options, env)
|
|
102
|
+
if (options.command === 'set') {
|
|
103
|
+
const current = await fetchImpl(request.url, request.init)
|
|
104
|
+
const text = await current.text()
|
|
105
|
+
if (!current.ok) throw new Error('HTTP ' + current.status + ': ' + text.slice(0, 500))
|
|
106
|
+
request = buildSetRequest(options, JSON.parse(text || '{}'), env)
|
|
107
|
+
}
|
|
45
108
|
const res = await fetchImpl(request.url, request.init)
|
|
46
109
|
const text = await res.text()
|
|
47
110
|
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 500))
|
|
111
|
+
if (options.command === 'sources' && !options.json) {
|
|
112
|
+
try { return { text: formatSources(JSON.parse(text || '{}')), contentType: 'text/plain' } } catch { /* fall back to raw */ }
|
|
113
|
+
}
|
|
48
114
|
return { text, contentType: res.headers.get('content-type') || '' }
|
|
49
115
|
}
|
|
50
116
|
|
|
51
117
|
async function main() {
|
|
52
118
|
const result = await run()
|
|
53
119
|
if (result.help) {
|
|
54
|
-
process.stdout.write('Usage: dsh-subscriptions <status|usage|fast> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n')
|
|
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')
|
|
55
121
|
return
|
|
56
122
|
}
|
|
57
123
|
process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
|
package/lib/client.js
CHANGED
|
@@ -1841,6 +1841,7 @@ window.__ModuleLoader__.load({
|
|
|
1841
1841
|
),
|
|
1842
1842
|
),
|
|
1843
1843
|
h('div', { className: 'dsub-sub' }, 'Floating capsule can be dragged; docked stays bottom-right.'),
|
|
1844
|
+
h(SourcesPanel, { t }),
|
|
1844
1845
|
|
|
1845
1846
|
h('div', { className: 'dsub-row' },
|
|
1846
1847
|
h('span', { className: 'dsub-h' }, t('composerQuota')),
|
|
@@ -3239,6 +3240,7 @@ window.__ModuleLoader__.load({
|
|
|
3239
3240
|
composerQuota: (data && data.composerQuota) || 'off',
|
|
3240
3241
|
composerQuotaDisplay: (data && data.composerQuotaDisplay) || 'current',
|
|
3241
3242
|
quotaWidget: (data && data.quotaWidget) || 'docked',
|
|
3243
|
+
quotaWidgetPosition: (data && data.quotaWidgetPosition) || { right: 16, bottom: 16 },
|
|
3242
3244
|
active: (data && data.active) || null,
|
|
3243
3245
|
error: null,
|
|
3244
3246
|
}
|
|
@@ -3653,20 +3655,74 @@ window.__ModuleLoader__.load({
|
|
|
3653
3655
|
)
|
|
3654
3656
|
}
|
|
3655
3657
|
|
|
3658
|
+
// #713: read-only source chain panel. Never renders key values.
|
|
3659
|
+
function SourcesPanel() {
|
|
3660
|
+
const [state, setState] = React.useState({ loading: true, data: null, error: null })
|
|
3661
|
+
const pull = () => {
|
|
3662
|
+
fetch('/dsh-subscriptions/sources', { cache: 'no-store' })
|
|
3663
|
+
.then((res) => res.json())
|
|
3664
|
+
.then((data) => setState({ loading: false, data, error: null }))
|
|
3665
|
+
.catch((err) => setState({ loading: false, data: null, error: String(err && err.message || err) }))
|
|
3666
|
+
}
|
|
3667
|
+
React.useEffect(() => {
|
|
3668
|
+
pull()
|
|
3669
|
+
const id = setInterval(pull, 30 * 1000)
|
|
3670
|
+
return () => clearInterval(id)
|
|
3671
|
+
}, [])
|
|
3672
|
+
const sourceLabel = (source) => source === 'api-key' ? 'API key' : (source === 'oauth' ? 'OAuth' : (source === 'ollama' ? 'Ollama' : 'none'))
|
|
3673
|
+
return h('div', { className: 'dsub-section-card' },
|
|
3674
|
+
h('div', { className: 'dsub-section-title' }, 'Sources', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
|
|
3675
|
+
h('div', { className: 'dsub-section-desc' }, 'Subscription → API key → Ollama. Read-only: key rotation owns its cooldowns.'),
|
|
3676
|
+
state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
|
|
3677
|
+
state.data && state.data.keyRotation && !state.data.keyRotation.available
|
|
3678
|
+
? h('div', { className: 'dsub-dim' }, 'key-rotation not installed/offline' + (state.data.keyRotation.reason ? ': ' + state.data.keyRotation.reason : ''))
|
|
3679
|
+
: null,
|
|
3680
|
+
state.data && state.data.keyRotation && state.data.keyRotation.available
|
|
3681
|
+
? h('div', { className: 'dsub-dim' }, 'key-rotation: ' + state.data.keyRotation.healthyKeys + '/' + state.data.keyRotation.totalKeys + ' healthy keys')
|
|
3682
|
+
: null,
|
|
3683
|
+
(state.data && state.data.providers ? state.data.providers : []).map((row) => h('div', { className: 'dsub-row', key: row.provider },
|
|
3684
|
+
h('span', { className: 'dsub-h' }, row.provider),
|
|
3685
|
+
h('span', { className: 'dsub-pillTag' }, sourceLabel(row.selected)),
|
|
3686
|
+
row.candidates.map((c, i) => h('span', { key: i, className: 'dsub-dim' },
|
|
3687
|
+
sourceLabel(c.source) + (c.available ? ' ✓' : ' ✕'))),
|
|
3688
|
+
)),
|
|
3689
|
+
)
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3656
3692
|
// #688: floating/docked quota capsule. Draggable unless docked; opens settings.
|
|
3657
3693
|
function QuotaCapsule(props) {
|
|
3658
3694
|
const t = (props && props.t) || ((k) => k)
|
|
3659
|
-
const [state, setState] = React.useState({ mode: 'off', active: null, remaining: null })
|
|
3695
|
+
const [state, setState] = React.useState({ mode: 'off', active: null, remaining: null, source: null })
|
|
3660
3696
|
const [pos, setPos] = React.useState({ right: 16, bottom: 16 })
|
|
3697
|
+
const posRef = React.useRef({ right: 16, bottom: 16 })
|
|
3661
3698
|
const drag = React.useRef(null)
|
|
3662
3699
|
const suppressClick = React.useRef(false)
|
|
3700
|
+
const savePosition = (next) => {
|
|
3701
|
+
fetch('/dsh-subscriptions/config', { cache: 'no-store' })
|
|
3702
|
+
.then((res) => res.json())
|
|
3703
|
+
.then((cfg) => {
|
|
3704
|
+
if (!cfg || !cfg.config) return null
|
|
3705
|
+
return fetch('/dsh-subscriptions/config', {
|
|
3706
|
+
method: 'PUT',
|
|
3707
|
+
headers: { 'content-type': 'application/json' },
|
|
3708
|
+
body: JSON.stringify({
|
|
3709
|
+
config: Object.assign({}, cfg.config, {
|
|
3710
|
+
quotaWidgetPosition: { right: Math.round(next.right), bottom: Math.round(next.bottom) },
|
|
3711
|
+
}),
|
|
3712
|
+
revision: cfg.revision,
|
|
3713
|
+
}),
|
|
3714
|
+
})
|
|
3715
|
+
})
|
|
3716
|
+
.catch(() => {})
|
|
3717
|
+
}
|
|
3663
3718
|
React.useEffect(() => {
|
|
3664
3719
|
let alive = true
|
|
3665
3720
|
const pull = () => {
|
|
3666
3721
|
refreshLoggedIn().then(async (r) => {
|
|
3667
3722
|
if (!alive) return
|
|
3668
3723
|
const mode = r.quotaWidget || 'docked'
|
|
3669
|
-
if (mode === 'off') { setState({ mode, active: null, remaining: null }); return }
|
|
3724
|
+
if (mode === 'off') { setState({ mode, active: null, remaining: null, source: null }); return }
|
|
3725
|
+
const sources = await fetch('/dsh-subscriptions/sources', { cache: 'no-store' }).then((res) => res.json()).catch(() => null)
|
|
3670
3726
|
let a = r.active
|
|
3671
3727
|
const current = currentModelProvider(props)
|
|
3672
3728
|
if (current && (!a || a.provider !== current)) {
|
|
@@ -3674,8 +3730,19 @@ window.__ModuleLoader__.load({
|
|
|
3674
3730
|
const rows = (cfg && cfg.accounts) || []
|
|
3675
3731
|
a = rows.find((row) => row && row.provider === current && row.usagePercent != null) || null
|
|
3676
3732
|
}
|
|
3677
|
-
if (!a || a.provider === 'ollama' || a.usagePercent == null) { setState({ mode, active: null, remaining: null }); return }
|
|
3678
|
-
|
|
3733
|
+
if (!a || a.provider === 'ollama' || a.usagePercent == null) { setState({ mode, active: null, remaining: null, source: null }); return }
|
|
3734
|
+
const sourceRow = (sources && Array.isArray(sources.providers) ? sources.providers : []).find((row) => row && row.provider === a.provider)
|
|
3735
|
+
const source = sourceRow ? sourceRow.selected : null
|
|
3736
|
+
if (r.quotaWidgetPosition && typeof window !== 'undefined') {
|
|
3737
|
+
const clamp = (v, max) => Math.max(4, Math.min(max, Number(v) || 0))
|
|
3738
|
+
const next = {
|
|
3739
|
+
right: clamp(r.quotaWidgetPosition.right, window.innerWidth - 40),
|
|
3740
|
+
bottom: clamp(r.quotaWidgetPosition.bottom, window.innerHeight - 40),
|
|
3741
|
+
}
|
|
3742
|
+
posRef.current = next
|
|
3743
|
+
setPos(next)
|
|
3744
|
+
}
|
|
3745
|
+
setState({ mode, active: a, remaining: Math.max(0, 100 - a.usagePercent), source })
|
|
3679
3746
|
}).catch(() => {})
|
|
3680
3747
|
}
|
|
3681
3748
|
pull()
|
|
@@ -3707,12 +3774,18 @@ window.__ModuleLoader__.load({
|
|
|
3707
3774
|
const dx = e.clientX - d.x
|
|
3708
3775
|
const dy = e.clientY - d.y
|
|
3709
3776
|
if (Math.abs(dx) + Math.abs(dy) > 3) { d.moved = true; suppressClick.current = true }
|
|
3710
|
-
|
|
3777
|
+
const next = {
|
|
3711
3778
|
right: Math.max(4, Math.min(window.innerWidth - 40, d.right - dx)),
|
|
3712
3779
|
bottom: Math.max(4, Math.min(window.innerHeight - 40, d.bottom - dy)),
|
|
3713
|
-
}
|
|
3780
|
+
}
|
|
3781
|
+
posRef.current = next
|
|
3782
|
+
setPos(next)
|
|
3783
|
+
}
|
|
3784
|
+
const onPointerUp = () => {
|
|
3785
|
+
const wasDrag = drag.current
|
|
3786
|
+
drag.current = null
|
|
3787
|
+
if (!docked && wasDrag && wasDrag.moved) savePosition(posRef.current)
|
|
3714
3788
|
}
|
|
3715
|
-
const onPointerUp = () => { drag.current = null }
|
|
3716
3789
|
return h('div', {
|
|
3717
3790
|
className: 'dsub-qw' + (docked ? ' dsub-qwDocked' : '') + tone,
|
|
3718
3791
|
style: { right: pos.right + 'px', bottom: pos.bottom + 'px' },
|
|
@@ -3723,12 +3796,13 @@ window.__ModuleLoader__.load({
|
|
|
3723
3796
|
onPointerDown,
|
|
3724
3797
|
onPointerMove,
|
|
3725
3798
|
onPointerUp,
|
|
3726
|
-
onPointerCancel:
|
|
3799
|
+
onPointerCancel: () => { drag.current = null },
|
|
3727
3800
|
onClick: () => { if (suppressClick.current) { suppressClick.current = false; return } openSettings() },
|
|
3728
3801
|
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') openSettings() },
|
|
3729
3802
|
},
|
|
3730
3803
|
h('span', { className: 'dsub-led ' + ledClass(state.active.usagePercent) }),
|
|
3731
3804
|
h('span', { className: 'dsub-qwBrand' }, brandBadge(state.active.provider).icon + ' ' + String(state.active.provider || '').toUpperCase()),
|
|
3805
|
+
state.source ? h('span', { className: 'dsub-pillTag' }, state.source === 'api-key' ? 'KEY' : (state.source === 'oauth' ? 'OAuth' : 'OLLAMA')) : null,
|
|
3732
3806
|
h('span', { className: 'dsub-qwBar' }, h('span', { className: 'dsub-qwBarFill', style: { width: state.remaining + '%', display: 'block' } })),
|
|
3733
3807
|
h('span', { className: 'dsub-qwPct' }, Math.round(state.remaining) + '%'),
|
|
3734
3808
|
)
|
package/lib/config-schema.js
CHANGED
|
@@ -99,6 +99,9 @@ export const Config = z.object({
|
|
|
99
99
|
.description('#682 Status-bar quota scope: current (model provider) or hidden.'),
|
|
100
100
|
quotaWidget: z.string().volatile().default('docked')
|
|
101
101
|
.description('#688 Quota widget placement: off, floating (draggable) or docked.'),
|
|
102
|
+
quotaWidgetPosition: z.object({ right: z.number(), bottom: z.number() }).volatile()
|
|
103
|
+
.default({ right: 16, bottom: 16 })
|
|
104
|
+
.description('#711 Last floating quota widget position in CSS pixels.'),
|
|
102
105
|
rateLimitWait: z.boolean().volatile().default(true)
|
|
103
106
|
.description('#684 Report the disclosed reset to dsh-llm-retry instead of parking the account on a guess.'),
|
|
104
107
|
rateLimitMaxWaitMs: z.number().volatile().default(21600000)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// #709: read-only bridge to @goodandready/dsh-key-rotation.
|
|
2
|
+
// This module never writes cooldown, never rotates a key and never exposes a
|
|
3
|
+
// key value. It only summarises the sanitized status route so the source chain
|
|
4
|
+
// can show which pool would be used next.
|
|
5
|
+
export const KEY_ROTATION_STATUS_PATH = '/dsh-key-rotation/status'
|
|
6
|
+
|
|
7
|
+
export function summarizeKeyRotationStatus(raw) {
|
|
8
|
+
if (!raw || !Array.isArray(raw.providers)) return { available: false, reason: 'invalid status', providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
9
|
+
let totalKeys = 0
|
|
10
|
+
let healthyKeys = 0
|
|
11
|
+
const providers = raw.providers.map((p) => {
|
|
12
|
+
const keys = (Array.isArray(p && p.keys) ? p.keys : [])
|
|
13
|
+
.filter((k) => k && k.present)
|
|
14
|
+
.map((k) => {
|
|
15
|
+
const cooldownMsLeft = Math.max(0, Number(k.cooldownMsLeft) || 0)
|
|
16
|
+
const paused = Boolean(k.paused)
|
|
17
|
+
const revoked = Boolean(k.revoked)
|
|
18
|
+
const broken = Boolean(k.broken)
|
|
19
|
+
const healthy = !paused && !revoked && !broken && cooldownMsLeft === 0
|
|
20
|
+
return {
|
|
21
|
+
ref: String(k.ref || ''),
|
|
22
|
+
active: Boolean(k.active),
|
|
23
|
+
cooldownMsLeft,
|
|
24
|
+
paused,
|
|
25
|
+
revoked,
|
|
26
|
+
broken,
|
|
27
|
+
healthy,
|
|
28
|
+
expiresAt: k.expiresAt == null ? null : Number(k.expiresAt),
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
totalKeys += keys.length
|
|
32
|
+
healthyKeys += keys.filter((k) => k.healthy).length
|
|
33
|
+
return {
|
|
34
|
+
provider: String((p && (p.providerName || p.provider)) || ''),
|
|
35
|
+
base: String((p && p.provider) || ''),
|
|
36
|
+
healthyKeys: keys.filter((k) => k.healthy).length,
|
|
37
|
+
totalKeys: keys.length,
|
|
38
|
+
keys,
|
|
39
|
+
}
|
|
40
|
+
}).filter((p) => p.provider)
|
|
41
|
+
return { available: true, providers, totalKeys, healthyKeys }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readKeyRotationStatus({ baseUrl, path = KEY_ROTATION_STATUS_PATH, fetchImpl = fetch, timeoutMs = 2000 } = {}) {
|
|
45
|
+
if (!baseUrl) return { available: false, reason: 'missing base url', providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
46
|
+
try {
|
|
47
|
+
const init = { method: 'GET', headers: { accept: 'application/json' } }
|
|
48
|
+
if (timeoutMs > 0 && typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
49
|
+
init.signal = AbortSignal.timeout(timeoutMs)
|
|
50
|
+
}
|
|
51
|
+
const res = await fetchImpl(String(baseUrl).replace(/\/$/, '') + path, init)
|
|
52
|
+
if (!res || !res.ok) return { available: false, reason: 'HTTP ' + (res && res.status), providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
53
|
+
const raw = await res.json()
|
|
54
|
+
return summarizeKeyRotationStatus(raw)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { available: false, reason: String(err && err.message || err), providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SOURCE_ORDER = ['oauth', 'api-key', 'ollama']
|
|
61
|
+
|
|
62
|
+
export function buildSourceChain({ subscriptions = [], keyRotation = null, ollama = { available: false } } = {}) {
|
|
63
|
+
const providers = new Map()
|
|
64
|
+
const entry = (provider) => {
|
|
65
|
+
const key = String(provider || '')
|
|
66
|
+
if (!providers.has(key)) providers.set(key, { provider: key, candidates: [] })
|
|
67
|
+
return providers.get(key)
|
|
68
|
+
}
|
|
69
|
+
for (const sub of (Array.isArray(subscriptions) ? subscriptions : [])) {
|
|
70
|
+
if (!sub || !sub.provider) continue
|
|
71
|
+
entry(sub.provider).candidates.push({
|
|
72
|
+
source: 'oauth',
|
|
73
|
+
available: Boolean(sub.loggedIn) && !(Number(sub.cooldownUntil) > Date.now()),
|
|
74
|
+
detail: {
|
|
75
|
+
index: sub.index == null ? null : Number(sub.index),
|
|
76
|
+
label: sub.label || null,
|
|
77
|
+
usagePercent: sub.usagePercent == null ? null : Number(sub.usagePercent),
|
|
78
|
+
cooldownUntil: sub.cooldownUntil == null ? null : Number(sub.cooldownUntil),
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
if (keyRotation && keyRotation.available) {
|
|
83
|
+
for (const pool of (keyRotation.providers || [])) {
|
|
84
|
+
if (!pool || !pool.provider) continue
|
|
85
|
+
entry(pool.provider).candidates.push({
|
|
86
|
+
source: 'api-key',
|
|
87
|
+
available: Number(pool.healthyKeys) > 0,
|
|
88
|
+
detail: { healthyKeys: Number(pool.healthyKeys) || 0, totalKeys: Number(pool.totalKeys) || 0, keys: pool.keys || [] },
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (ollama && ollama.available) {
|
|
93
|
+
const target = entry('ollama')
|
|
94
|
+
target.candidates.push({ source: 'ollama', available: true, detail: { model: ollama.model || null } })
|
|
95
|
+
}
|
|
96
|
+
const rows = [...providers.values()].map((row) => {
|
|
97
|
+
row.candidates.sort((a, b) => SOURCE_ORDER.indexOf(a.source) - SOURCE_ORDER.indexOf(b.source))
|
|
98
|
+
const selected = row.candidates.find((c) => c.available) || null
|
|
99
|
+
return { ...row, selected: selected ? selected.source : null }
|
|
100
|
+
})
|
|
101
|
+
return { order: SOURCE_ORDER.slice(), noDoubleRotation: true, providers: rows }
|
|
102
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isTrustedSettingsRequest, safeJsonHandler, writeJson } from '../http.js'
|
|
2
|
+
import { buildSourceChain, readKeyRotationStatus } from '../key-rotation-bridge.js'
|
|
3
|
+
|
|
4
|
+
export function registerSourcesRoutes(ctx, state) {
|
|
5
|
+
const { accountsView, live } = state
|
|
6
|
+
|
|
7
|
+
ctx.effect(() => ctx.webServer.register({
|
|
8
|
+
kind: 'exact',
|
|
9
|
+
path: '/dsh-subscriptions/sources',
|
|
10
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
11
|
+
if (req.method !== 'GET') {
|
|
12
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
16
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
let subscriptions = []
|
|
20
|
+
try {
|
|
21
|
+
const rows = await accountsView()
|
|
22
|
+
subscriptions = (rows || []).map((row) => ({
|
|
23
|
+
provider: row.provider,
|
|
24
|
+
index: row.index,
|
|
25
|
+
label: row.label,
|
|
26
|
+
loggedIn: Boolean(row.hasToken),
|
|
27
|
+
usagePercent: row.usagePercent,
|
|
28
|
+
cooldownUntil: row.cooldownUntil,
|
|
29
|
+
}))
|
|
30
|
+
} catch { /* keep the chain available */ }
|
|
31
|
+
const port = req.socket && req.socket.localPort
|
|
32
|
+
const keyRotation = port
|
|
33
|
+
? await readKeyRotationStatus({ baseUrl: 'http://127.0.0.1:' + port })
|
|
34
|
+
: { available: false, reason: 'no local port', providers: [], totalKeys: 0, healthyKeys: 0 }
|
|
35
|
+
const chain = buildSourceChain({
|
|
36
|
+
subscriptions,
|
|
37
|
+
keyRotation,
|
|
38
|
+
ollama: { available: live().ollamaFallback !== false },
|
|
39
|
+
})
|
|
40
|
+
writeJson(res, 200, {
|
|
41
|
+
ok: true,
|
|
42
|
+
keyRotation: {
|
|
43
|
+
available: keyRotation.available,
|
|
44
|
+
reason: keyRotation.reason || null,
|
|
45
|
+
totalKeys: keyRotation.totalKeys,
|
|
46
|
+
healthyKeys: keyRotation.healthyKeys,
|
|
47
|
+
},
|
|
48
|
+
...chain,
|
|
49
|
+
})
|
|
50
|
+
}),
|
|
51
|
+
}), 'dsh-subscriptions: /sources')
|
|
52
|
+
}
|
package/lib/routes/status.js
CHANGED
|
@@ -260,6 +260,7 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
260
260
|
fastMode: !!live().codexFastMode,
|
|
261
261
|
composerQuota: String(live().composerQuota || 'off'), composerQuotaDisplay: String(live().composerQuotaDisplay || 'current'),
|
|
262
262
|
quotaWidget: String(live().quotaWidget || 'docked'),
|
|
263
|
+
quotaWidgetPosition: live().quotaWidgetPosition || { right: 16, bottom: 16 },
|
|
263
264
|
active,
|
|
264
265
|
})
|
|
265
266
|
}),
|
package/lib/routes.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerStatusRoutes } from './routes/status.js'
|
|
|
5
5
|
import { registerOauthRoutes } from './routes/oauth.js'
|
|
6
6
|
import { registerAccountsRoutes } from './routes/accounts.js'
|
|
7
7
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
8
|
+
import { registerSourcesRoutes } from './routes/sources.js'
|
|
8
9
|
import { registerPluginUpdater } from './updater.js'
|
|
9
10
|
|
|
10
11
|
export function registerRoutes(ctx, state) {
|
|
@@ -109,4 +110,5 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&').replace(/</g,'
|
|
|
109
110
|
registerOauthRoutes(ctx, state)
|
|
110
111
|
registerAccountsRoutes(ctx, state)
|
|
111
112
|
registerProxyRoutes(ctx, state)
|
|
113
|
+
registerSourcesRoutes(ctx, state)
|
|
112
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|