@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.
@@ -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
- const request = buildRequest(options, env)
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
- setState({ mode, active: a, remaining: Math.max(0, 100 - a.usagePercent) })
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
- setPos({
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: onPointerUp,
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
  )
@@ -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
+ }
@@ -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,'&amp;').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",
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",