@goodandready/dsh-subscriptions 0.4.7 → 0.4.9

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,6 +42,8 @@ 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}' +
46
48
 
47
49
  '.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
@@ -98,6 +100,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
98
100
  'storedAs': 'Stored as',
99
101
  'switchLabel': 'Provider',
100
102
  'chipActive': 'Subscriptions',
103
+ 'expiryLabel': 'expires',
101
104
  'slashLogin': 'Login to a subscription provider',
102
105
  'slashLogout': 'Log out a subscription provider',
103
106
  'slashStatus': 'Subscription status',
@@ -152,6 +155,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
152
155
  'storedAs': 'Хранится как',
153
156
  'switchLabel': 'Провайдер',
154
157
  'chipActive': 'Подписки',
158
+ 'expiryLabel': 'окончание',
155
159
  'slashLogin': 'Вход в провайдера подписки',
156
160
  'slashLogout': 'Выйти из провайдера подписки',
157
161
  'slashStatus': 'Статус подписок',
@@ -467,7 +471,8 @@ function PluginCard(props) {
467
471
  // в карточке не оставалось подчёркиваний из чужого протокола.
468
472
  function windowLabel(w){
469
473
  var id=String((w&&w.id)||'')
470
- if(w&&w.ru)return w.ru
474
+ var given=w&&(w.ru||w.en)
475
+ if(given&&given!==id)return given
471
476
  if(id==='primary_window')return t('windowPrimary')
472
477
  if(id==='secondary_window')return t('windowSecondary')
473
478
  if(!id)return t('quota')
@@ -616,31 +621,62 @@ function PluginCard(props) {
616
621
  const res = await fetch('/dsh-subscriptions/status', { cache: 'no-store' })
617
622
  const data = await res.json().catch(() => ({}))
618
623
  const loggedIn = (data && data.loggedIn) || {}
619
- return ORDER.filter((p) => loggedIn[p])
620
- } catch { return [] }
624
+ const usage = (data && data.usagePercent) || {}
625
+ const logged = ORDER.filter((p) => loggedIn[p])
626
+ const maxUsage = logged.reduce((m, p) => Math.max(m, usage[p] || 0), 0)
627
+ return {
628
+ logged,
629
+ usage: maxUsage,
630
+ expiresAt: (data && data.expiresAt) || {},
631
+ labels: (data && data.labels) || {},
632
+ expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
633
+ }
634
+ } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7 } }
621
635
  }
622
636
 
623
637
  // #52: компактный чип в шапке сессии со статусом подключённых подписок.
624
638
  function HeaderChip(props) {
625
- const [state, setState] = React.useState({ loading: true, count: 0 })
639
+ const [state, setState] = React.useState({ loading: true, count: 0, usage: 0, expiry: null })
626
640
  const t = (props && props.t) || ((k) => k)
627
641
  React.useEffect(() => {
628
642
  const pull = () => {
629
- refreshLoggedIn().then((logged) => {
630
- setState({ loading: false, count: logged.length })
631
- }).catch(() => setState({ loading: false, count: 0 }))
643
+ refreshLoggedIn().then((r) => {
644
+ // #67: ближайшее окончание подписки в пределах expiryNotifyDays.
645
+ let expiry = null
646
+ const now = Date.now()
647
+ const days = r.expiryNotifyDays || 7
648
+ for (const p of r.logged) {
649
+ const at = r.expiresAt[p]
650
+ if (!at) continue
651
+ const msLeft = at - now
652
+ if (msLeft > 0 && msLeft <= days * 24 * 60 * 60 * 1000) {
653
+ if (!expiry || at < expiry.at) expiry = { at, label: r.labels[p] || p }
654
+ }
655
+ }
656
+ setState({ loading: false, count: r.logged.length, usage: r.usage, expiry })
657
+ }).catch(() => setState({ loading: false, count: 0, usage: 0, expiry: null }))
632
658
  }
633
659
  pull()
634
660
  const id = setInterval(pull, 60 * 1000)
635
661
  return () => clearInterval(id)
636
662
  }, [])
637
663
  const on = state.count > 0
664
+ // #66: цвет чипа по максимальному usagePercent (50/80/100).
665
+ const u = state.usage
666
+ let cls = 'dsub-chip'
667
+ if (on) cls += u >= 100 ? ' dsub-chipDanger' : (u >= 80 ? ' dsub-chipDanger' : (u >= 50 ? ' dsub-chipWarn' : ' dsub-chipOn'))
668
+ // #67: надпись об окончании подписки.
669
+ let text = on ? String(state.count) : '—'
670
+ if (state.expiry) {
671
+ const d = new Date(state.expiry.at).toLocaleDateString()
672
+ text = t('expiryLabel') + ': ' + state.expiry.label + ' ' + d
673
+ }
638
674
  return React.createElement('span', {
639
- className: 'dsub-chip' + (on ? ' dsub-chipOn' : ''),
675
+ className: cls,
640
676
  title: on ? t('chipActive') + ': ' + state.count : t('none'),
641
677
  },
642
678
  React.createElement('span', { className: 'dsub-chipDot' }),
643
- on ? String(state.count) : '—',
679
+ text,
644
680
  )
645
681
  }
646
682
 
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,30 @@ 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
+ }
511
538
  writeJson(res, 200, {
512
539
  ok: true,
513
540
  loggedIn: Object.fromEntries(PROVIDERS.map((id) => [id, logged.includes(id)])),
541
+ usagePercent: usage,
542
+ expiresAt: expires,
543
+ labels,
544
+ expiryNotifyDays: live().expiryNotifyDays,
514
545
  })
515
546
  },
516
547
  }), 'dsh-subscriptions: /status')
@@ -869,6 +900,25 @@ export function apply(ctx, config) {
869
900
  }), 'dsh-subscriptions: /import-token')
870
901
 
871
902
  // #50: сводная страница /subscriptions (localhost-only).
903
+ // #65: история запросов и стоимости (JSON).
904
+ ctx.effect(() => ctx.webServer.register({
905
+ kind: 'exact',
906
+ path: '/dsh-subscriptions/history',
907
+ handler: async (req, res) => {
908
+ if (req.method !== 'GET') {
909
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
910
+ return
911
+ }
912
+ const host = (req.headers.host || '').split(':')[0]
913
+ if (host !== 'localhost' && host !== '127.0.0.1') {
914
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
915
+ return
916
+ }
917
+ const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
918
+ writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
919
+ },
920
+ }), 'dsh-subscriptions: /history')
921
+
872
922
  ctx.effect(() => ctx.webServer.register({
873
923
  kind: 'exact',
874
924
  path: '/dsh-subscriptions/subscriptions',
@@ -925,6 +975,18 @@ h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
925
975
  <div class="dim">/subscriptions — localhost only</div>
926
976
  <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
977
  ${body}
978
+ <h2>History</h2>
979
+ <div class="dim">last 10 · <a href="/dsh-subscriptions/history?limit=100">show 100</a></div>
980
+ <div class="hist" id="hist"></div>
981
+ <script>
982
+ fetch('/dsh-subscriptions/history?limit=10').then(r=>r.json()).then(d=>{
983
+ const el=document.getElementById('hist')
984
+ if(!d||!d.items||!d.items.length){el.textContent='No requests yet.';return}
985
+ el.innerHTML='<table class="grid"><tr><th>time</th><th>provider</th><th>model</th><th>path</th><th>status</th></tr>'+
986
+ 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>'
987
+ }).catch(()=>{})
988
+ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
989
+ </script>
928
990
  </body></html>`)
929
991
  },
930
992
  }), '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/lib/usage.js CHANGED
@@ -58,6 +58,8 @@ const WINDOW_LABELS = {
58
58
  weekly_limit_7_days: { ru: "7д", en: "7d" },
59
59
  primary: { ru: "осн.", en: "primary" },
60
60
  secondary: { ru: "втор.", en: "secondary" },
61
+ primary_window: { ru: "осн.", en: "primary" },
62
+ secondary_window: { ru: "втор.", en: "secondary" },
61
63
  monthly: { ru: "месяц", en: "month" },
62
64
  credits: { ru: "кредиты", en: "credits" },
63
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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",