@goodandready/dsh-subscriptions 0.4.8 → 0.4.10

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/lib/adapter.js CHANGED
@@ -121,6 +121,20 @@ export class SubscriptionAdapter extends LlmAdapter {
121
121
  })
122
122
  // поток завершился успешно: снять кулдаун и здоровье-штрафы
123
123
  if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
124
+ // #65: записать в историю запросов и стоимости.
125
+ if (typeof deps.recordHistory === 'function') {
126
+ try {
127
+ deps.recordHistory({
128
+ provider,
129
+ ref: account.ref,
130
+ model: (opts && opts.model) || null,
131
+ path: '/responses',
132
+ method: 'POST',
133
+ status: 200,
134
+ kind: 'stream',
135
+ })
136
+ } catch {}
137
+ }
124
138
  } catch (err) {
125
139
  if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
126
140
  await deps.saveBlob(account.ref, {
package/lib/client.js CHANGED
@@ -42,7 +42,15 @@ window.__ModuleLoader__.load({
42
42
  '.dsub-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
43
43
  '.dsub-chip{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:999px;font-size:12px;font-weight:600;line-height:1;white-space:nowrap;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2)}' +
44
44
  '.dsub-chipOn{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
45
+ '.dsub-chipWarn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
46
+ '.dsub-chipDanger{color:var(--dsw-alias-state-error-primary);border-color:currentColor}' +
45
47
  '.dsub-chipDot{width:6px;height:6px;border-radius:999px;background:currentColor;flex:none}' +
48
+ '.dsub-chipActive{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);font-size:12px;font-weight:600;line-height:1;white-space:nowrap;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3)}' +
49
+ '.dsub-chipActiveWarn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
50
+ '.dsub-chipActiveDanger{color:var(--dsw-alias-state-error-primary);border-color:currentColor}' +
51
+ '.dsub-chipActiveOk{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
52
+ '.dsub-chipActiveBar{height:4px;width:48px;background:var(--dsw-alias-bg-layer-1);border-radius:2px;flex:none;overflow:hidden}' +
53
+ '.dsub-chipActiveFill{height:100%;border-radius:2px;background:currentColor}' +
46
54
 
47
55
  '.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
48
56
  '.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
@@ -98,6 +106,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
98
106
  'storedAs': 'Stored as',
99
107
  'switchLabel': 'Provider',
100
108
  'chipActive': 'Subscriptions',
109
+ 'expiryLabel': 'expires',
101
110
  'slashLogin': 'Login to a subscription provider',
102
111
  'slashLogout': 'Log out a subscription provider',
103
112
  'slashStatus': 'Subscription status',
@@ -152,6 +161,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
152
161
  'storedAs': 'Хранится как',
153
162
  'switchLabel': 'Провайдер',
154
163
  'chipActive': 'Подписки',
164
+ 'expiryLabel': 'окончание',
155
165
  'slashLogin': 'Вход в провайдера подписки',
156
166
  'slashLogout': 'Выйти из провайдера подписки',
157
167
  'slashStatus': 'Статус подписок',
@@ -617,31 +627,86 @@ function PluginCard(props) {
617
627
  const res = await fetch('/dsh-subscriptions/status', { cache: 'no-store' })
618
628
  const data = await res.json().catch(() => ({}))
619
629
  const loggedIn = (data && data.loggedIn) || {}
620
- return ORDER.filter((p) => loggedIn[p])
621
- } catch { return [] }
630
+ const usage = (data && data.usagePercent) || {}
631
+ const logged = ORDER.filter((p) => loggedIn[p])
632
+ const maxUsage = logged.reduce((m, p) => Math.max(m, usage[p] || 0), 0)
633
+ return {
634
+ logged,
635
+ usage: maxUsage,
636
+ expiresAt: (data && data.expiresAt) || {},
637
+ labels: (data && data.labels) || {},
638
+ expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
639
+ active: (data && data.active) || null,
640
+ }
641
+ } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, active: null } }
622
642
  }
623
643
 
624
644
  // #52: компактный чип в шапке сессии со статусом подключённых подписок.
625
645
  function HeaderChip(props) {
626
- const [state, setState] = React.useState({ loading: true, count: 0 })
646
+ const [state, setState] = React.useState({ loading: true, count: 0, usage: 0, expiry: null, active: null })
627
647
  const t = (props && props.t) || ((k) => k)
628
648
  React.useEffect(() => {
629
649
  const pull = () => {
630
- refreshLoggedIn().then((logged) => {
631
- setState({ loading: false, count: logged.length })
632
- }).catch(() => setState({ loading: false, count: 0 }))
650
+ refreshLoggedIn().then((r) => {
651
+ // #67: ближайшее окончание подписки в пределах expiryNotifyDays.
652
+ let expiry = null
653
+ const now = Date.now()
654
+ const days = r.expiryNotifyDays || 7
655
+ for (const p of r.logged) {
656
+ const at = r.expiresAt[p]
657
+ if (!at) continue
658
+ const msLeft = at - now
659
+ if (msLeft > 0 && msLeft <= days * 24 * 60 * 60 * 1000) {
660
+ if (!expiry || at < expiry.at) expiry = { at, label: r.labels[p] || p }
661
+ }
662
+ }
663
+ setState({ loading: false, count: r.logged.length, usage: r.usage, expiry, active: r.active })
664
+ }).catch(() => setState({ loading: false, count: 0, usage: 0, expiry: null, active: null }))
633
665
  }
634
666
  pull()
635
667
  const id = setInterval(pull, 60 * 1000)
636
668
  return () => clearInterval(id)
637
669
  }, [])
670
+ // #69: если есть активная подписка — показываем её (провайдер + план + usage%).
671
+ if (state.active) {
672
+ const a = state.active
673
+ const u = typeof a.usagePercent === 'number' ? a.usagePercent : null
674
+ let acls = 'dsub-chipActive'
675
+ if (u != null) acls += u >= 100 ? ' dsub-chipActiveDanger' : (u >= 80 ? ' dsub-chipActiveDanger' : (u >= 50 ? ' dsub-chipActiveWarn' : ' dsub-chipActiveOk'))
676
+ const prov = t('provider' + a.provider.charAt(0).toUpperCase() + a.provider.slice(1)) || a.provider
677
+ const plan = a.plan || ''
678
+ const label = plan ? (prov + ' ' + plan) : prov
679
+ const pctText = u == null ? '' : Math.round(u) + '%'
680
+ return React.createElement('span', {
681
+ className: acls,
682
+ title: a.model ? (prov + ' · ' + a.model) : prov,
683
+ },
684
+ React.createElement('span', { className: 'dsub-chipDot' }),
685
+ label,
686
+ u != null
687
+ ? React.createElement('span', { className: 'dsub-chipActiveBar' },
688
+ React.createElement('span', { className: 'dsub-chipActiveFill', style: { width: Math.min(100, Math.max(0, u)) + '%' } }))
689
+ : null,
690
+ pctText ? React.createElement('span', null, pctText) : null,
691
+ )
692
+ }
638
693
  const on = state.count > 0
694
+ // #66: цвет чипа по максимальному usagePercent (50/80/100).
695
+ const u = state.usage
696
+ let cls = 'dsub-chip'
697
+ if (on) cls += u >= 100 ? ' dsub-chipDanger' : (u >= 80 ? ' dsub-chipDanger' : (u >= 50 ? ' dsub-chipWarn' : ' dsub-chipOn'))
698
+ // #67: надпись об окончании подписки.
699
+ let text = on ? String(state.count) : '—'
700
+ if (state.expiry) {
701
+ const d = new Date(state.expiry.at).toLocaleDateString()
702
+ text = t('expiryLabel') + ': ' + state.expiry.label + ' ' + d
703
+ }
639
704
  return React.createElement('span', {
640
- className: 'dsub-chip' + (on ? ' dsub-chipOn' : ''),
705
+ className: cls,
641
706
  title: on ? t('chipActive') + ': ' + state.count : t('none'),
642
707
  },
643
708
  React.createElement('span', { className: 'dsub-chipDot' }),
644
- on ? String(state.count) : '—',
709
+ text,
645
710
  )
646
711
  }
647
712
 
package/lib/history.js ADDED
@@ -0,0 +1,66 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ // #65: история запросов и стоимости. Хранится в
6
+ // ~/.dsh/storages/dsh-subscriptions/history.json (JSON-массив, новые сверху).
7
+ // Все IO best-effort и синхронные: сбой записи не должен ронять харнесс.
8
+
9
+ export const DEFAULT_HISTORY_DIR = join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
10
+
11
+ export class HistoryStore {
12
+ constructor(dir = DEFAULT_HISTORY_DIR, ttlMs = 7 * 24 * 60 * 60 * 1000) {
13
+ this.dir = dir
14
+ this.ttlMs = ttlMs
15
+ this.path = join(dir, 'history.json')
16
+ this.rows = []
17
+ this._load()
18
+ }
19
+
20
+ _load() {
21
+ try {
22
+ mkdirSync(this.dir, { recursive: true })
23
+ } catch { /* best-effort */ }
24
+ try {
25
+ const raw = JSON.parse(readFileSync(this.path, 'utf8'))
26
+ if (Array.isArray(raw)) this.rows = raw
27
+ } catch { /* absent/corrupt history is normal */ }
28
+ this._prune()
29
+ }
30
+
31
+ _prune() {
32
+ const cutoff = Date.now() - this.ttlMs
33
+ const before = this.rows.length
34
+ this.rows = this.rows.filter((r) => r && r.ts && r.ts >= cutoff)
35
+ if (this.rows.length !== before) this._persist()
36
+ }
37
+
38
+ _persist() {
39
+ try {
40
+ mkdirSync(this.dir, { recursive: true })
41
+ writeFileSync(this.path, JSON.stringify(this.rows))
42
+ } catch { /* best-effort */ }
43
+ }
44
+
45
+ /** Добавить одну запись. Возвращает длину истории. */
46
+ add(entry) {
47
+ if (!entry || typeof entry !== 'object') return this.rows.length
48
+ this.rows.unshift({ ts: Date.now(), ...entry })
49
+ this._prune()
50
+ this._persist()
51
+ return this.rows.length
52
+ }
53
+
54
+ /** Последние N записей (новые сверху). */
55
+ recent(n) {
56
+ return this.rows.slice(0, n)
57
+ }
58
+
59
+ all() {
60
+ return this.rows
61
+ }
62
+
63
+ size() {
64
+ return this.rows.length
65
+ }
66
+ }
package/lib/index.js CHANGED
@@ -13,6 +13,7 @@ import { createSubscriptionsService } from './subscriptions.js'
13
13
  import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
14
14
  import { quotaSnapshot } from './ratelimit.js'
15
15
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
16
+ import { HistoryStore } from './history.js'
16
17
  import {
17
18
  inspectGoogleAccount,
18
19
  antigravityMetadata,
@@ -32,6 +33,8 @@ const Slot = z.object({
32
33
  .description('Account slot number. Credential ref is <PROVIDER>_OAUTH_<index>.'),
33
34
  label: z.string().default('')
34
35
  .description('Optional display label. Empty uses the account email after login.'),
36
+ expiresAt: z.number().default(0)
37
+ .description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
35
38
  })
36
39
 
37
40
  const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
@@ -49,6 +52,8 @@ export const Config = z.object({
49
52
  .description('Background health-check interval in minutes. 0 disables.'),
50
53
  notifyLimits: z.boolean().default(true)
51
54
  .description('Emit log notices when usage crosses 70/90/100% of a window.'),
55
+ expiryNotifyDays: z.number().default(7)
56
+ .description('#67 Warn in the header chip this many days before a subscription expiry (expiresAt). 0 disables.'),
52
57
  slots: z.array(Slot).default(defaultSlots)
53
58
  .description('Account slots. Secrets are not stored here; only the credential ref names.'),
54
59
  useWebCallback: z.boolean().default(false)
@@ -132,6 +137,9 @@ export function apply(ctx, config) {
132
137
  try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
133
138
  },
134
139
  })
140
+ const history = new HistoryStore()
141
+ const recordHistory = (entry) => history.add(entry)
142
+
135
143
  const subscriptions = createSubscriptionsService({
136
144
  listAccounts: (provider) => store.listAccounts(provider),
137
145
  loadBlob: (ref) => store.loadBlob(ref),
@@ -149,6 +157,7 @@ export function apply(ctx, config) {
149
157
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
150
158
  rememberRequest: (ref) => store.rememberRequest(ref),
151
159
  getRequestCount: (ref) => store.getRequestCount(ref),
160
+ recordHistory,
152
161
  fetchImpl: fetch,
153
162
  })
154
163
 
@@ -205,6 +214,7 @@ export function apply(ctx, config) {
205
214
  getQuota: (ref) => store.getQuota(ref),
206
215
  refreshUsage: (provider) => store.refreshUsage(provider),
207
216
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
217
+ recordHistory,
208
218
  fetchImpl: fetch,
209
219
  })
210
220
 
@@ -508,9 +518,56 @@ export function apply(ctx, config) {
508
518
  return
509
519
  }
510
520
  const logged = await store.loggedInProviders()
521
+ // #66/#67: usagePercent (максимум по аккаунтам) и expiresAt для чипа.
522
+ const usage = {}
523
+ const expires = {}
524
+ const labels = {}
525
+ for (const slot of normalizeSlots(live().slots)) {
526
+ try {
527
+ const info = await store.describeRef(slot.ref)
528
+ if (info.usagePercent != null) {
529
+ usage[slot.provider] = Math.max(usage[slot.provider] || 0, info.usagePercent)
530
+ }
531
+ // #67: дата окончания подписки берётся из слота (вводится в настройках).
532
+ if (slot.expiresAt) {
533
+ expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
534
+ labels[slot.provider] = slot.label || info.label || slot.provider
535
+ }
536
+ } catch {}
537
+ }
538
+ // #69: активная подписка = последний успешный запрос (новые сверху).
539
+ let active = null
540
+ const last = history.recent(1)
541
+ if (last.length) {
542
+ const lastRow = last[0]
543
+ const accts = await store.listAccounts(lastRow.provider)
544
+ const acct = accts.find((a) => a.ref === lastRow.ref) || accts[0]
545
+ let plan = ''
546
+ let status = 'ok'
547
+ try {
548
+ const info = await store.describeRef(lastRow.ref)
549
+ plan = info.paidTierName || ''
550
+ if (info.validationUrl) status = 'verify'
551
+ else if (info.cooldownUntil && info.cooldownUntil > Date.now()) status = 'cooldown'
552
+ } catch {}
553
+ active = {
554
+ provider: lastRow.provider,
555
+ model: lastRow.model || null,
556
+ path: lastRow.path || null,
557
+ plan,
558
+ usagePercent: usage[lastRow.provider] != null ? usage[lastRow.provider] : null,
559
+ status,
560
+ at: lastRow.ts,
561
+ }
562
+ }
511
563
  writeJson(res, 200, {
512
564
  ok: true,
513
565
  loggedIn: Object.fromEntries(PROVIDERS.map((id) => [id, logged.includes(id)])),
566
+ usagePercent: usage,
567
+ expiresAt: expires,
568
+ labels,
569
+ expiryNotifyDays: live().expiryNotifyDays,
570
+ active,
514
571
  })
515
572
  },
516
573
  }), 'dsh-subscriptions: /status')
@@ -869,6 +926,25 @@ export function apply(ctx, config) {
869
926
  }), 'dsh-subscriptions: /import-token')
870
927
 
871
928
  // #50: сводная страница /subscriptions (localhost-only).
929
+ // #65: история запросов и стоимости (JSON).
930
+ ctx.effect(() => ctx.webServer.register({
931
+ kind: 'exact',
932
+ path: '/dsh-subscriptions/history',
933
+ handler: async (req, res) => {
934
+ if (req.method !== 'GET') {
935
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
936
+ return
937
+ }
938
+ const host = (req.headers.host || '').split(':')[0]
939
+ if (host !== 'localhost' && host !== '127.0.0.1') {
940
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
941
+ return
942
+ }
943
+ const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
944
+ writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
945
+ },
946
+ }), 'dsh-subscriptions: /history')
947
+
872
948
  ctx.effect(() => ctx.webServer.register({
873
949
  kind: 'exact',
874
950
  path: '/dsh-subscriptions/subscriptions',
@@ -925,6 +1001,18 @@ h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
925
1001
  <div class="dim">/subscriptions — localhost only</div>
926
1002
  <div class="stats"><span><b>${connected}</b> connected</span><span><b>${accounts.length}</b> accounts</span><span><b>${slots.length}</b> slots</span></div>
927
1003
  ${body}
1004
+ <h2>History</h2>
1005
+ <div class="dim">last 10 · <a href="/dsh-subscriptions/history?limit=100">show 100</a></div>
1006
+ <div class="hist" id="hist"></div>
1007
+ <script>
1008
+ fetch('/dsh-subscriptions/history?limit=10').then(r=>r.json()).then(d=>{
1009
+ const el=document.getElementById('hist')
1010
+ if(!d||!d.items||!d.items.length){el.textContent='No requests yet.';return}
1011
+ el.innerHTML='<table class="grid"><tr><th>time</th><th>provider</th><th>model</th><th>path</th><th>status</th></tr>'+
1012
+ d.items.map(i=>'<tr><td>'+new Date(i.ts).toLocaleString()+'</td><td>'+esc(i.provider)+'</td><td>'+esc(i.model||'')+'</td><td>'+esc(i.path)+'</td><td>'+i.status+'</td></tr>').join('')+'</table>'
1013
+ }).catch(()=>{})
1014
+ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
1015
+ </script>
928
1016
  </body></html>`)
929
1017
  },
930
1018
  }), 'dsh-subscriptions: /subscriptions')
@@ -159,6 +159,20 @@ export function createSubscriptionsService(deps) {
159
159
  else err.code = "VENDOR"
160
160
  throw err
161
161
  }
162
+ // #65: записать в историю запросов и стоимости.
163
+ if (typeof deps.recordHistory === "function") {
164
+ try {
165
+ deps.recordHistory({
166
+ provider,
167
+ ref: account.ref,
168
+ model: (body && typeof body === "object" && body.model) || null,
169
+ path,
170
+ method,
171
+ status: res.status || 200,
172
+ kind: "request",
173
+ })
174
+ } catch {}
175
+ }
162
176
  return res
163
177
  } catch (err) {
164
178
  lastError = err
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, and Antigravity subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",