@goodandready/dsh-subscriptions 0.2.5 → 0.2.7

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/accounts.js CHANGED
@@ -54,6 +54,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
54
54
  const refreshLocks = new Map()
55
55
  const refreshFailures = new Map()
56
56
  const usage = new Map()
57
+ const windows = new Map()
57
58
  const usageFetched = new Map()
58
59
  const doFetch = fetchImpl || fetch
59
60
 
@@ -107,6 +108,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
107
108
  const blob = parseBlob(await resolveRaw(ref))
108
109
  base.label = blob.label || blob.email || ''
109
110
  base.email = blob.email || ''
111
+ if (Array.isArray(blob.usage)) { base.usage = blob.usage; base.usageAt = blob.usageAt || 0 }
110
112
  if (blob.validationUrl) base.validationUrl = blob.validationUrl
111
113
  if (blob.validationMessage) base.validationMessage = blob.validationMessage
112
114
  if (blob.accountNotice) base.accountNotice = blob.accountNotice
@@ -117,6 +119,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
117
119
  ...base,
118
120
  cooldownUntil: cooldowns.get(ref) || 0,
119
121
  quota: quotas.get(ref) || null,
122
+ usage: windows.get(ref) || base.usage || null,
120
123
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
121
124
  refreshError: refreshFailures.get(ref)?.error || '',
122
125
  validationUrl: base.validationUrl || '',
@@ -194,9 +197,23 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
194
197
  const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
195
198
  const snap = await vendor.usage(blob, cfg, doFetch)
196
199
  usageFetched.set(slot.ref, Date.now())
197
- if (snap && Number.isFinite(Number(snap.usedPercent))) {
200
+ if (!snap) continue
201
+ if (Number.isFinite(Number(snap.usedPercent))) {
198
202
  usage.set(slot.ref, Number(snap.usedPercent))
199
203
  }
204
+ const list = Array.isArray(snap.windows) ? snap.windows : null
205
+ if (list) {
206
+ windows.set(slot.ref, list)
207
+ // persist last known windows so the card survives restarts
208
+ try {
209
+ const prev = Array.isArray(blob.usage) ? JSON.stringify(blob.usage) : ''
210
+ if (prev !== JSON.stringify(list)) {
211
+ await saveBlob(slot.ref, { ...blob, usage: list, usageAt: Date.now() })
212
+ } else if (!blob.usageAt) {
213
+ await saveBlob(slot.ref, { ...blob, usageAt: Date.now() })
214
+ }
215
+ } catch { /* persistence best-effort */ }
216
+ }
200
217
  } catch {
201
218
  usageFetched.set(slot.ref, Date.now())
202
219
  }
package/lib/blob.js CHANGED
@@ -16,6 +16,8 @@ export function serializeBlob(obj) {
16
16
  email: asString(obj && obj.email),
17
17
  accountId: asString(obj && obj.accountId),
18
18
  projectId: asString(obj && obj.projectId),
19
+ ...(Array.isArray(obj && obj.usage) ? { usage: obj.usage } : {}),
20
+ ...((obj && obj.usageAt) ? { usageAt: Number(obj.usageAt) } : {}),
19
21
  })
20
22
  }
21
23
 
@@ -30,6 +32,8 @@ export function parseBlob(text) {
30
32
  email: asString(obj.email),
31
33
  accountId: asString(obj.accountId),
32
34
  projectId: asString(obj.projectId),
35
+ ...(Array.isArray(obj.usage) ? { usage: obj.usage } : {}),
36
+ ...(obj.usageAt ? { usageAt: Number(obj.usageAt) } : {}),
33
37
  }
34
38
  }
35
39
 
package/lib/client.js CHANGED
@@ -46,6 +46,9 @@ window.__ModuleLoader__.load({
46
46
  'usageFull': 'Usage 100%',
47
47
  'connected': 'Connected',
48
48
  'title': 'Subscriptions',
49
+ 'cardIntro': 'Subscription accounts, rotation and limits.',
50
+ 'show': 'Show',
51
+ 'hide': 'Hide',
49
52
  'intro': 'Log in with a consumer subscription. Tokens stay on the host credentials store. The browser never reads them back. After Connect, if the provider lands on localhost or a vendor page, paste the redirected URL or the code here.',
50
53
  'useOrigin': 'Use this Web UI origin as OAuth redirect_uri',
51
54
  'useOriginHint': 'Leave off unless you registered your own OAuth client for this origin. Vendor CLI clients usually require their published redirect URI plus the paste step.',
@@ -73,6 +76,9 @@ window.__ModuleLoader__.load({
73
76
  'usageFull': 'Лимит исчерпан',
74
77
  'connected': 'Подключено',
75
78
  'title': 'Подписки',
79
+ 'cardIntro': 'Аккаунты подписок, ротация и лимиты.',
80
+ 'show': 'Показать',
81
+ 'hide': 'Скрыть',
76
82
  'intro': 'Вход по обычной пользовательской подписке. Токены остаются в хранилище учётных данных харнесса, браузер их обратно не читает. Если после «Подключить» провайдер увёл на localhost или на свою страницу, вставьте сюда адрес перехода или код.',
77
83
  'useOrigin': 'Использовать адрес этого веб-интерфейса как redirect_uri',
78
84
  'useOriginHint': 'Включайте, только если зарегистрировали собственного клиента OAuth на этот адрес. Штатным консольным клиентам провайдеров нужен их опубликованный адрес возврата и вставка кода вручную.',
@@ -102,6 +108,26 @@ window.__ModuleLoader__.load({
102
108
  return t('connected')
103
109
  }
104
110
 
111
+ // Карточка во вкладке «Плагины» рисует свой заголовок и сворачивание:
112
+ // ядро даёт только рамку списка.
113
+ function PluginCard(props) {
114
+ const t = (props && props.t) || ((key) => key)
115
+ const [open, setOpen] = React.useState(false)
116
+ return React.createElement('div', { className: 'dsub-block' },
117
+ React.createElement('div', { className: 'dsub-row' },
118
+ React.createElement('span', { className: 'dsub-h' }, t('title')),
119
+ React.createElement('span', { className: 'dsub-sub' }, t('cardIntro')),
120
+ React.createElement('span', { style: { flex: 1 } }),
121
+ React.createElement('button', {
122
+ type: 'button',
123
+ className: 'dsub-mini',
124
+ onClick: () => setOpen(!open),
125
+ }, open ? t('hide') : t('show')),
126
+ ),
127
+ open ? React.createElement(SubsSection, props) : null,
128
+ )
129
+ }
130
+
105
131
  function SubsSection(props) {
106
132
  // Переводчик приходит от слота, потому что в его записи указан locale.
107
133
  const t = (props && props.t) || ((key) => key)
@@ -301,17 +327,27 @@ window.__ModuleLoader__.load({
301
327
  t('verifySuffix'),
302
328
  ) : null,
303
329
  (function(){
304
- var q=account.quota, up=account.usagePercent, a=[];
330
+ var parts=[]
331
+ var wins=account.usage
332
+ if(Array.isArray(wins)&&wins.length){
333
+ wins.forEach(function(w){
334
+ if(w&&w.usedPercent!=null)parts.push(t('limit')+' '+(w.ru||w.id)+': '+Math.round(w.usedPercent)+'/100%')
335
+ })
336
+ }
337
+ var q=account.quota
305
338
  if(q){
306
- if(q.remaining!=null&&q.limit!=null)a.push(q.remaining+'/'+q.limit);
307
- else if(q.remaining!=null)a.push(String(q.remaining));
308
- if(q.usedPercent!=null)a.push(Math.round(q.usedPercent)+'%');
309
- if(q.resetAt){var d=new Date(q.resetAt);a.push(d.toLocaleTimeString())}
310
- if(q.measuredAt){var m=Math.round((Date.now()-q.measuredAt)/60000);a.push(m<1?'<1m':m+'m')}
339
+ var qa=[]
340
+ if(q.remaining!=null&&q.limit!=null)qa.push(q.remaining+'/'+q.limit)
341
+ else if(q.remaining!=null)qa.push(String(q.remaining))
342
+ if(q.usedPercent!=null)qa.push(Math.round(q.usedPercent)+'%')
343
+ if(q.resetAt){var d=new Date(q.resetAt);qa.push(d.toLocaleTimeString())}
344
+ if(qa.length)parts.push(t('quota')+': '+qa.join(' / '))
311
345
  }
312
- if(up!=null&&!q)a.push(Math.round(up)+'%');
313
- if(!a.length)return null;
314
- return React.createElement('span',{className:'dsub-sub'},t('quota')+' '+a.join(' \u00b7 '));
346
+ if(!wins&&!q&&account.usagePercent!=null)parts.push(Math.round(account.usagePercent)+'/100%')
347
+ var at=(q&&q.measuredAt)||(account.usageAt||0)
348
+ if(at){var m=Math.round((Date.now()-at)/60000);if(m>=1)parts.push(m+'m')}
349
+ if(!parts.length)return null
350
+ return React.createElement('span',{className:'dsub-sub'},parts.join(' \u00b7 '))
315
351
  })(),
316
352
 
317
353
  account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
@@ -337,9 +373,26 @@ window.__ModuleLoader__.load({
337
373
 
338
374
  function registerSettings(ctx) {
339
375
  ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-subscriptions: словари')
340
- // Подпись раздела рисует боковой список, а не наш компонент: props.t
341
- // туда не доходит, поэтому берём переводчик, привязанный к namespace.
376
+ // Подписи вне компонента берут переводчик, привязанный к namespace.
342
377
  const t = ctx.locale.bind(NS)
378
+ // Штатное место — вкладка «Плагины»: карточка со своим заголовком и
379
+ // сворачиванием вместо строки в боковом списке. Ключ регистрации обязан
380
+ // равняться пространству настроек, иначе вкладка молча не покажет слот.
381
+ let moved = false
382
+ try {
383
+ moved = !!ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
384
+ {
385
+ name: 'settings.plugin.item',
386
+ key: NS,
387
+ locale: NS,
388
+ inject: () => ({ ctx: ctx }),
389
+ },
390
+ PluginCard,
391
+ ))
392
+ } catch { moved = false }
393
+ if (moved) return
394
+ // Запасной путь для сборок без settings.plugin.item: прежний раздел,
395
+ // чтобы настройки не пропали.
343
396
  ctx.slots.inject('settings.section', () => ctx.slots.register(
344
397
  {
345
398
  name: 'settings.section',
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { httpError, readJson } from './wire.js'
3
3
  import { validationFromLoadCodeAssist, validationFromHttpError, noticeFromLoadCodeAssist, validationRequiredError } from './google-validation.js'
4
- import { asUsageSnapshot, deepestUsedPercent } from './usage.js'
4
+ import { asUsageSnapshot, usageWindows, deepestUsedPercent } from './usage.js'
5
5
 
6
6
  export const CODE_ASSIST_PROD = 'https://cloudcode-pa.googleapis.com/v1internal'
7
7
  export const CODE_ASSIST_STREAM = 'https://daily-cloudcode-pa.googleapis.com/v1internal'
@@ -188,7 +188,9 @@ export async function fetchAvailableModels(fetchImpl, token, extraHeaders, fallb
188
188
  export async function retrieveQuotaPercent(fetchImpl, token, extraHeaders) {
189
189
  try {
190
190
  const json = await postAssist(fetchImpl, 'retrieveUserQuota', token, {}, extraHeaders)
191
- return asUsageSnapshot(deepestUsedPercent(json))
191
+ const snap = asUsageSnapshot(deepestUsedPercent(json))
192
+ if (snap) snap.windows = usageWindows(json)
193
+ return snap
192
194
  } catch {
193
195
  return null
194
196
  }
package/lib/index.js CHANGED
@@ -273,6 +273,7 @@ export function apply(ctx, config) {
273
273
  cooldownUntil: info.cooldownUntil,
274
274
  usagePercent: info.usagePercent,
275
275
  quota: info.quota || null,
276
+ usage: info.usage || null,
276
277
  refreshError: info.refreshError || '',
277
278
  validationUrl: info.validationUrl || '',
278
279
  validationMessage: info.validationMessage || '',
package/lib/usage.js CHANGED
@@ -48,4 +48,56 @@ export function asUsageSnapshot(percent) {
48
48
  const usedPercent = numberOrNull(percent)
49
49
  if (usedPercent == null) return null
50
50
  return { usedPercent }
51
- }
51
+ }
52
+ // Named limit windows reported by vendors (five_hour, seven_day, primary...).
53
+ // Label map is a data table: extend it when a vendor adds a window name.
54
+ const WINDOW_LABELS = {
55
+ five_hour: { ru: "5ч", en: "5h" },
56
+ seven_day: { ru: "7д", en: "7d" },
57
+ seven_day_oauth_apps: { ru: "7д прил.", en: "7d apps" },
58
+ weekly_limit_7_days: { ru: "7д", en: "7d" },
59
+ primary: { ru: "осн.", en: "primary" },
60
+ secondary: { ru: "втор.", en: "secondary" },
61
+ monthly: { ru: "месяц", en: "month" },
62
+ credits: { ru: "кредиты", en: "credits" },
63
+ }
64
+
65
+ function windowLabel(id) {
66
+ const known = WINDOW_LABELS[id]
67
+ return known || { ru: id, en: id }
68
+ }
69
+
70
+ // Walk vendor usage JSON; every node carrying a utilization-style number
71
+ // becomes a named window keyed by its JSON key.
72
+ export function usageWindows(obj) {
73
+ const out = []
74
+ const seen = new Set()
75
+ function walk(value, key, depth) {
76
+ if (!value || typeof value !== "object" || depth > 6) return
77
+ const pct = numberOrNull(
78
+ value.utilization ?? value.used_percent ?? value.usedPercent ?? value.creditUsagePercent ?? value.used_percentage,
79
+ )
80
+ if (pct != null && key && !seen.has(key)) {
81
+ seen.add(key)
82
+ const label = windowLabel(key)
83
+ out.push({ id: key, ru: label.ru, en: label.en, usedPercent: pct })
84
+ }
85
+ const frac = numberOrNull(value.remainingFraction)
86
+ if (frac != null && key && !seen.has(key)) {
87
+ seen.add(key)
88
+ const label = windowLabel(key)
89
+ out.push({ id: key, ru: label.ru, en: label.en, usedPercent: (1 - frac) * 100 })
90
+ }
91
+ const ufrac = numberOrNull(value.usedFraction)
92
+ if (ufrac != null && key && !seen.has(key)) {
93
+ seen.add(key)
94
+ const label = windowLabel(key)
95
+ out.push({ id: key, ru: label.ru, en: label.en, usedPercent: ufrac * 100 })
96
+ }
97
+ for (const [k, child] of Object.entries(value)) {
98
+ if (child && typeof child === "object") walk(child, k, depth + 1)
99
+ }
100
+ }
101
+ walk(obj, "", 0)
102
+ return out.length ? out : null
103
+ }
@@ -2,7 +2,7 @@ import { buildAuthorizeUrl } from '../oauth.js'
2
2
  import { anthropicPayload, modelCatalog } from '../messages.js'
3
3
  import { jsonTokenRequest, anthropicStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
4
4
  import { emailFromToken } from '../jwt.js'
5
- import { asUsageSnapshot, deepestUsedPercent } from '../usage.js'
5
+ import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
6
6
 
7
7
  export const id = 'claude'
8
8
 
@@ -104,7 +104,9 @@ export async function usage(blob, _cfg, fetchImpl) {
104
104
  try {
105
105
  const res = await (fetchImpl || fetch)(USAGE, { headers: oauthHeaders(blob.accessToken) })
106
106
  const json = await readJson(res)
107
- return asUsageSnapshot(deepestUsedPercent(json))
107
+ const snap = asUsageSnapshot(deepestUsedPercent(json))
108
+ if (snap) snap.windows = usageWindows(json)
109
+ return snap
108
110
  } catch {
109
111
  return null
110
112
  }
@@ -3,7 +3,7 @@ import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { codexResponsesBody, modelCatalog } from '../messages.js'
4
4
  import { chatgptAccountId, emailFromToken } from '../jwt.js'
5
5
  import { formTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from '../wire.js'
6
- import { asUsageSnapshot, deepestUsedPercent } from '../usage.js'
6
+ import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
7
7
 
8
8
  export const id = 'codex'
9
9
 
@@ -150,7 +150,9 @@ export async function usage(blob, cfg, fetchImpl) {
150
150
  headers: identityHeaders(blob, cfg, { Accept: 'application/json' }),
151
151
  })
152
152
  const json = await readJson(res)
153
- return asUsageSnapshot(deepestUsedPercent(json))
153
+ const snap = asUsageSnapshot(deepestUsedPercent(json))
154
+ if (snap) snap.windows = usageWindows(json)
155
+ return snap
154
156
  } catch {
155
157
  return null
156
158
  }
@@ -2,7 +2,7 @@ import { buildAuthorizeUrl } from '../oauth.js'
2
2
  import { modelCatalog, codexResponsesBody } from '../messages.js'
3
3
  import { formTokenRequest, codexResponsesStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
4
4
  import { emailFromToken } from '../jwt.js'
5
- import { asUsageSnapshot, grokBillingPercent } from '../usage.js'
5
+ import { asUsageSnapshot, usageWindows, grokBillingPercent } from '../usage.js'
6
6
 
7
7
  export const id = 'grok'
8
8
 
@@ -201,7 +201,9 @@ export async function usage(blob, cfg, fetchImpl) {
201
201
  headers: { ...identityHeaders(blob, cfg), Accept: 'application/json' },
202
202
  })
203
203
  const json = await readJson(res)
204
- return asUsageSnapshot(grokBillingPercent(json))
204
+ const snap = asUsageSnapshot(grokBillingPercent(json))
205
+ if (snap) snap.windows = usageWindows(json)
206
+ return snap
205
207
  } catch {
206
208
  return null
207
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",