@goodandready/dsh-subscriptions 0.2.10 → 0.3.0

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
@@ -48,12 +48,14 @@ export function vendorConfig(provider, cfg) {
48
48
  }
49
49
  }
50
50
 
51
- export function createAccountStore({ credentials, getConfig, fetchImpl }) {
51
+ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitNotice }) {
52
52
  const cooldowns = new Map()
53
53
  const quotas = new Map()
54
54
  const refreshLocks = new Map()
55
55
  const refreshFailures = new Map()
56
56
  const usage = new Map()
57
+ const notifyThresholds = new Map()
58
+ const requestCounts = new Map()
57
59
  const windows = new Map()
58
60
  const usageFetched = new Map()
59
61
  const doFetch = fetchImpl || fetch
@@ -85,6 +87,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
85
87
  await credentials.unset(credentialRef(ref))
86
88
  cooldowns.delete(ref)
87
89
  quotas.delete(ref)
90
+ for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
91
+ for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
88
92
  refreshFailures.delete(ref)
89
93
  usage.delete(ref)
90
94
  usageFetched.delete(ref)
@@ -201,6 +205,40 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
201
205
  if (Number.isFinite(Number(snap.usedPercent))) {
202
206
  usage.set(slot.ref, Number(snap.usedPercent))
203
207
  }
208
+ // Notification thresholds: 70/90/100% — fire once per window until reset.
209
+ if (Array.isArray(snap.windows)) {
210
+ for (const win of snap.windows) {
211
+ if (!win || !Number.isFinite(Number(win.usedPercent))) continue
212
+ const pct = Number(win.usedPercent)
213
+ const key = slot.ref + ':' + win.id
214
+ const prev = notifyThresholds.get(key) || 0
215
+ const THRESHOLDS = [70, 90, 100]
216
+ for (const th of THRESHOLDS) {
217
+ if (pct >= th && prev < th) {
218
+ notifyThresholds.set(key, th)
219
+ try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch {}
220
+ break
221
+ }
222
+ }
223
+ }
224
+ }
225
+ // Notification thresholds: 70/90/100% — fire once per window until reset.
226
+ if (Array.isArray(snap.windows)) {
227
+ for (const win of snap.windows) {
228
+ if (!win || !Number.isFinite(Number(win.usedPercent))) continue
229
+ const pct = Number(win.usedPercent)
230
+ const key = slot.ref + ':' + win.id
231
+ const prev = notifyThresholds.get(key) || 0
232
+ const THRESHOLDS = [70, 90, 100]
233
+ for (const th of THRESHOLDS) {
234
+ if (pct >= th && prev < th) {
235
+ notifyThresholds.set(key, th)
236
+ try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch {}
237
+ break
238
+ }
239
+ }
240
+ }
241
+ }
204
242
  const list = Array.isArray(snap.windows) ? snap.windows : null
205
243
  if (list) {
206
244
  windows.set(slot.ref, list)
@@ -235,5 +273,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
235
273
  shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
236
274
  getQuota(ref) { return quotas.get(ref) || null },
237
275
  rememberUsage(ref, percent) { usage.set(ref, percent) },
276
+ rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
277
+ getRequestCount(ref) { return requestCounts.get(ref) || 0 },
238
278
  }
239
279
  }
package/lib/adapter.js CHANGED
@@ -106,6 +106,7 @@ export class SubscriptionAdapter extends LlmAdapter {
106
106
  return res
107
107
  }
108
108
  try {
109
+ if (typeof deps.rememberRequest === 'function') deps.rememberRequest(account.ref)
109
110
  yield* vendor.streamOnce({
110
111
  blob,
111
112
  options: opts,
package/lib/client.js CHANGED
@@ -15,8 +15,8 @@ window.__ModuleLoader__.load({
15
15
  '.dsub-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
16
16
  '.dsub-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
17
17
  '.dsub-description{color:var(--dsw-alias-label-secondary);font-size:13px}' +
18
- '.dsub-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}' +
19
- '.dsub-chevronOpen{transform:rotate(180deg)}' +
18
+ '.dsub-chev{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}' +
19
+ '.dsub-chevOpen{transform:rotate(180deg)}' +
20
20
  '.dsub-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
21
21
  // existing dsub classes kept for SubsSection internals
22
22
  '.dsub-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:760px}' +
@@ -37,7 +37,12 @@ window.__ModuleLoader__.load({
37
37
  '.dsub-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
38
38
  '.dsub-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
39
39
  '.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
40
- '.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warning-primary)}' +
40
+ '.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
41
+ '.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}' +
42
+ '.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-warning-primary)}' +
43
+ '.dsub-barFull .dsub-barFill{background:var(--dsw-alias-state-error-primary)}' +
44
+ '.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
45
+ '.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warning-primary)}' + +
41
46
  '.dsub-verify a{color:var(--dsw-alias-brand-primary)}'
42
47
 
43
48
  const cssId = 'dsh-subscriptions/settings.module.css'
@@ -82,6 +87,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
82
87
  'accountLabel': 'Account',
83
88
  'plan': 'Plan',
84
89
  'storedAs': 'Stored as',
90
+ 'switchLabel': 'Provider',
91
+ 'none': 'none',
92
+ 'forecast': '≈',
85
93
  }
86
94
  const ru = {
87
95
  'notConnected': 'Не подключено',
@@ -112,6 +120,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
112
120
  'accountLabel': 'Аккаунт',
113
121
  'plan': 'Тариф',
114
122
  'storedAs': 'Хранится как',
123
+ 'switchLabel': 'Провайдер',
124
+ 'none': 'нет',
125
+ 'forecast': '≈',
115
126
  }
116
127
 
117
128
  function badgeFor(account, t) {
@@ -124,7 +135,22 @@ const cssId = 'dsh-subscriptions/settings.module.css'
124
135
 
125
136
  // Карточка во вкладке «Плагины» рисует свой заголовок и сворачивание:
126
137
  // ядро даёт только рамку списка.
127
- function PluginCard(props) {
138
+ // Ядровый значок раскрытия. Без защищённого require вместо IconChevronDownOutline14 может упасть вся клиентская половина.
139
+ let ChevronIcon = null
140
+ try {
141
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
142
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
143
+ } catch (noPrimitives) {
144
+ ChevronIcon = null
145
+ }
146
+ function FallbackChevron(props) {
147
+ return React.createElement('svg', { className: props.className, width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none' },
148
+ React.createElement('path', { d: 'M3.5 5.25L7 8.75l3.5-3.5', stroke: 'currentColor', 'stroke-width': 1.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }),
149
+ )
150
+ }
151
+ const Chevron = ChevronIcon || FallbackChevron
152
+
153
+ function PluginCard(props) {
128
154
  const t = (props && props.t) || ((key) => key)
129
155
  const [open, setOpen] = React.useState(false)
130
156
  return React.createElement('div', { className: 'dsub-block' },
@@ -343,25 +369,36 @@ const cssId = 'dsh-subscriptions/settings.module.css'
343
369
  (function(){
344
370
  var parts=[]
345
371
  var wins=account.usage
372
+ function bar(label,pct){
373
+ var cls='dsub-bar'+(pct>=100?' dsub-barFull':(pct>=70?' dsub-barWarn':''))
374
+ return React.createElement('div',{className:'dsub-barRow',key:label},
375
+ React.createElement('span',null,label),
376
+ React.createElement('div',{className:cls},
377
+ React.createElement('div',{className:'dsub-barFill',style:{width:Math.min(100,Math.max(0,pct))+'%'}})
378
+ ),
379
+ React.createElement('span',null,Math.round(pct)+'%'),
380
+ )
381
+ }
346
382
  if(Array.isArray(wins)&&wins.length){
347
383
  wins.forEach(function(w){
348
- if(w&&w.usedPercent!=null)parts.push(t('limit')+' '+(w.ru||w.id)+': '+Math.round(w.usedPercent)+'/100%')
384
+ if(w&&w.usedPercent!=null)parts.push(bar((w.ru||w.id),w.usedPercent))
349
385
  })
350
386
  }
351
387
  var q=account.quota
352
- if(q){
353
- var qa=[]
354
- if(q.remaining!=null&&q.limit!=null)qa.push(q.remaining+'/'+q.limit)
355
- else if(q.remaining!=null)qa.push(String(q.remaining))
356
- if(q.usedPercent!=null)qa.push(Math.round(q.usedPercent)+'%')
357
- if(q.resetAt){var d=new Date(q.resetAt);qa.push(d.toLocaleTimeString())}
358
- if(qa.length)parts.push(t('quota')+': '+qa.join(' / '))
359
- }
360
- if(!wins&&!q&&account.usagePercent!=null)parts.push(Math.round(account.usagePercent)+'/100%')
388
+ if(q&&q.usedPercent!=null)parts.push(bar(t('quota'),q.usedPercent))
389
+ if(!wins&&!q&&account.usagePercent!=null)parts.push(bar(t('quota'),account.usagePercent))
361
390
  var at=(q&&q.measuredAt)||(account.usageAt||0)
362
- if(at){var m=Math.round((Date.now()-at)/60000);if(m>=1)parts.push(m+'m')}
391
+ if(at){var m=Math.round((Date.now()-at)/60000);if(m>=1)parts.push(React.createElement('span',{className:'dsub-sub',key:'ago'},m+'m'))}
392
+ if(Array.isArray(wins)&&wins.length&&account.requests){
393
+ var w0=wins[0]
394
+ if(w0&&w0.usedPercent!=null&&w0.usedPercent<100){
395
+ var remaining=100-w0.usedPercent
396
+ var est=Math.floor(remaining/(w0.usedPercent/account.requests))
397
+ if(est>0)parts.push(React.createElement('span',{className:'dsub-sub',key:'fc'},t('forecast')+' '+est+' ('+(w0.ru||w0.id)+')'))
398
+ }
399
+ }
363
400
  if(!parts.length)return null
364
- return React.createElement('span',{className:'dsub-sub'},parts.join(' \u00b7 '))
401
+ return React.createElement('div',{className:'dsub-block'},parts)
365
402
  })(),
366
403
 
367
404
  account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
@@ -404,7 +441,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
404
441
  React.createElement('div', { className: 'dsub-description' }, t('cardIntro')),
405
442
  ),
406
443
  React.createElement('svg', {
407
- className: 'dsub-chevron' + (open ? ' dsub-chevronOpen' : ''),
444
+ className: 'dsub-chev' + (open ? ' dsub-chevOpen' : ''),
408
445
  width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
409
446
  },
410
447
  React.createElement('path', {
@@ -473,9 +510,69 @@ const cssId = 'dsh-subscriptions/settings.module.css'
473
510
  ))
474
511
  }
475
512
 
513
+ // Горячий переключатель провайдера в строке композера: клик циклично
514
+ // меняет активного провайдера подписки для следующего запроса.
515
+ let activeIdx = 0
516
+ let labelEl = null
517
+ const ORDER = ['codex', 'claude', 'grok', 'antigravity']
518
+
519
+ async function refreshLoggedIn() {
520
+ try {
521
+ const res = await fetch('/dsh-subscriptions/status', { cache: 'no-store' })
522
+ const data = await res.json().catch(() => ({}))
523
+ const loggedIn = (data && data.loggedIn) || {}
524
+ return ORDER.filter((p) => loggedIn[p])
525
+ } catch { return [] }
526
+ }
527
+
528
+ function registerComposerSwitch(ctx) {
529
+ ctx.effect(() => {
530
+ const render = () => {
531
+ if (!labelEl) return
532
+ refreshLoggedIn().then((logged) => {
533
+ if (!labelEl) return
534
+ const current = logged.length ? logged[activeIdx % logged.length] : null
535
+ labelEl.textContent = t('switchLabel') + ': ' + (current || t('none'))
536
+ }).catch(() => {})
537
+ }
538
+ ctx.slots.inject('composer.action', () =>
539
+ ctx.slots.register(
540
+ {
541
+ name: 'composer.action',
542
+ id: 'dsh-subscriptions-provider-switch',
543
+ order: 31,
544
+ label: () => {
545
+ // label используется ядром до монтирования; держим актуальным
546
+ setTimeout(render, 0)
547
+ return t('switchLabel')
548
+ },
549
+ },
550
+ (props) => {
551
+ const el = React.createElement('button', {
552
+ onClick: async () => {
553
+ const logged = await refreshLoggedIn()
554
+ if (!logged.length) return
555
+ activeIdx = (activeIdx + 1) % logged.length
556
+ render()
557
+ },
558
+ }, '')
559
+ // Обновить текст в смонтированном элементе
560
+ setTimeout(() => {
561
+ labelEl = el
562
+ render()
563
+ }, 0)
564
+ return el
565
+ },
566
+ ),
567
+ )
568
+ render()
569
+ }, 'dsh-subscriptions: composer provider switch')
570
+ }
571
+
476
572
  exports.inject = ['slots', 'locale']
477
573
  exports.apply = function apply(ctx) {
478
574
  registerSettings(ctx)
575
+ registerComposerSwitch(ctx)
479
576
  }
480
577
  return module.exports
481
578
  },
package/lib/crypto.js ADDED
@@ -0,0 +1,28 @@
1
+ import { scryptSync, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
2
+
3
+ // AES-256-GCM с ключом из passphrase (scrypt, без хранения соли отдельно —
4
+ // соль в начале блока).
5
+ export function encryptWithPassphrase(plainText, passphrase) {
6
+ const salt = randomBytes(16)
7
+ const key = scryptSync(String(passphrase), salt, 32)
8
+ const iv = randomBytes(12)
9
+ const cipher = createCipheriv("aes-256-gcm", key, iv)
10
+ const encrypted = Buffer.concat([cipher.update(String(plainText), "utf8"), cipher.final()])
11
+ const tag = cipher.getAuthTag()
12
+ // формат: magic + salt + iv + tag + data
13
+ return "DSHE1:" + Buffer.concat([salt, iv, tag, encrypted]).toString("base64")
14
+ }
15
+
16
+ export function decryptWithPassphrase(payload, passphrase) {
17
+ const raw = String(payload || "")
18
+ if (!raw.startsWith("DSHE1:")) throw new Error("неизвестный формат экспорта")
19
+ const buf = Buffer.from(raw.slice(6), "base64")
20
+ const salt = buf.subarray(0, 16)
21
+ const iv = buf.subarray(16, 28)
22
+ const tag = buf.subarray(28, 44)
23
+ const data = buf.subarray(44)
24
+ const key = scryptSync(String(passphrase), salt, 32)
25
+ const decipher = createDecipheriv("aes-256-gcm", key, iv)
26
+ decipher.setAuthTag(tag)
27
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8")
28
+ }
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ import { SubscriptionAdapter } from './adapter.js'
8
8
  import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
9
9
  import { parseBlob } from './blob.js'
10
10
  import { createSubscriptionsService } from './subscriptions.js'
11
+ import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
11
12
  import { quotaSnapshot } from './ratelimit.js'
12
13
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
13
14
  import {
@@ -36,12 +37,16 @@ const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '
36
37
  export const Config = z.object({
37
38
  cooldownMs: z.number().default(30 * 60 * 1000)
38
39
  .description('After RATE_LIMIT/QUOTA/429, skip that account for this many milliseconds.'),
39
- switchAtRemaining: z.number().default(0)
40
+ switchAtRemaining: z.number().default(0.01)
40
41
  .description('If remaining <= this (absolute or <1 fraction), treat as exhausted before request. 0 disables.'),
41
42
  refreshAheadMs: z.number().default(5 * 60 * 1000)
42
43
  .description('Background refresh when expiry within this many ms.'),
43
44
  refreshRetryMs: z.number().default(10 * 60 * 1000)
44
45
  .description('Do not retry background refresh more often than this after failure.'),
46
+ probeIntervalMin: z.number().default(15)
47
+ .description('Background health-check interval in minutes. 0 disables.'),
48
+ notifyLimits: z.boolean().default(true)
49
+ .description('Emit log notices when usage crosses 70/90/100% of a window.'),
45
50
  slots: z.array(Slot).default(defaultSlots)
46
51
  .description('Account slots. Secrets are not stored here; only the credential ref names.'),
47
52
  useWebCallback: z.boolean().default(false)
@@ -86,7 +91,17 @@ export function apply(ctx, config) {
86
91
  })
87
92
 
88
93
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
89
- const store = createAccountStore({ credentials: ctx.credentials, getConfig: live, fetchImpl: fetch })
94
+ const store = createAccountStore({
95
+ credentials: ctx.credentials,
96
+ getConfig: live,
97
+ fetchImpl: fetch,
98
+ onLimitNotice: (provider, ref, win, threshold) => {
99
+ if (!live().notifyLimits) return
100
+ const label = win.ru || win.en || win.id
101
+ try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}: лимит ${label} на ${threshold}%`) } catch {}
102
+ try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
103
+ },
104
+ })
90
105
  const subscriptions = createSubscriptionsService({
91
106
  listAccounts: (provider) => store.listAccounts(provider),
92
107
  loadBlob: (ref) => store.loadBlob(ref),
@@ -96,6 +111,8 @@ export function apply(ctx, config) {
96
111
  switchAtRemaining: () => live().switchAtRemaining,
97
112
  rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
98
113
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
114
+ rememberRequest: (ref) => store.rememberRequest(ref),
115
+ getRequestCount: (ref) => store.getRequestCount(ref),
99
116
  fetchImpl: fetch,
100
117
  })
101
118
 
@@ -274,6 +291,7 @@ export function apply(ctx, config) {
274
291
  usagePercent: info.usagePercent,
275
292
  quota: info.quota || null,
276
293
  usage: info.usage || null,
294
+ requests: store.getRequestCount(slot.ref) || null,
277
295
  refreshError: info.refreshError || '',
278
296
  validationUrl: info.validationUrl || '',
279
297
  validationMessage: info.validationMessage || '',
@@ -335,6 +353,50 @@ export function apply(ctx, config) {
335
353
  return () => clear(id)
336
354
  }, 'dsh-subscriptions: refresh ahead')
337
355
 
356
+ // Фоновый health-check: раз в N минут прогоняет дешёвый check по всем
357
+ // подключённым аккаунтам. Мёртвые помечаются через describeRef, cooldown
358
+ // не ставится (как в /check).
359
+ ctx.effect(() => {
360
+ const tick = async () => {
361
+ const cfg = live()
362
+ const mins = Number(cfg.probeIntervalMin)
363
+ if (!Number.isFinite(mins) || mins <= 0) return
364
+ const now = Date.now()
365
+ for (const slot of normalizeSlots(cfg.slots)) {
366
+ const ref = slot.ref
367
+ try {
368
+ const info = await store.describeRef(ref)
369
+ if (!info.configured) continue
370
+ const raw = await store.resolveRaw(ref)
371
+ if (!raw) continue
372
+ const blob = await store.loadBlob(ref).catch(() => null)
373
+ if (!blob || !blob.refreshToken) continue
374
+ const fresh = await store.ensureFresh(slot.provider, blob, ref).catch(() => null)
375
+ if (!fresh) continue
376
+ const vendor = getVendor(slot.provider)
377
+ if (typeof vendor.check !== 'function') continue
378
+ const cfg2 = vendorConfig(slot.provider, live())
379
+ const probeFetch = async (u, i) => fetch(u, i)
380
+ await vendor.check(fresh, cfg2, probeFetch).catch((e) => {
381
+ try { ctx.log && ctx.log.warn && ctx.log.warn("[dsh-subscriptions] probe " + ref + ": " + String(e && e.message || e).slice(0, 200)) } catch {}
382
+ })
383
+ } catch {}
384
+ }
385
+ }
386
+ let lastProbeAt = 0
387
+ const wrapped = async () => {
388
+ const cfg = live()
389
+ const mins = Number(cfg.probeIntervalMin)
390
+ if (!Number.isFinite(mins) || mins <= 0) return
391
+ if (Date.now() - lastProbeAt < mins * 60 * 1000) return
392
+ lastProbeAt = Date.now()
393
+ await tick()
394
+ }
395
+ wrapped().catch(() => {})
396
+ const timer = setInterval(() => { wrapped().catch(() => {}) }, 60 * 1000)
397
+ return () => clearInterval(timer)
398
+ }, 'dsh-subscriptions: probe loop')
399
+
338
400
 
339
401
  ctx.effect(() => ctx.webServer.register({
340
402
  kind: 'exact',
@@ -559,6 +621,151 @@ export function apply(ctx, config) {
559
621
  },
560
622
  }), 'dsh-subscriptions: /check')
561
623
 
624
+ // HTTP-прокси к API провайдера через subscriptions.request.
625
+ // Same-origin only, allowlist путей, ротация и квота как у моделей.
626
+ // Токен наружу не отдаётся — наружу только ответ провайдера.
627
+ ctx.effect(() => ctx.webServer.register({
628
+ kind: 'prefix',
629
+ path: '/dsh-subscriptions/proxy/',
630
+ handler: async (req, res) => {
631
+ if (req.method !== 'POST' && req.method !== 'GET') {
632
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
633
+ return
634
+ }
635
+ if (!isTrustedSettingsRequest(req)) {
636
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
637
+ return
638
+ }
639
+ const url = new URL(req.url || '/', 'http://localhost')
640
+ const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
641
+ const provider = parts[0]
642
+ const restPath = '/' + parts.slice(1).join('/')
643
+ if (!isProvider(provider)) {
644
+ writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
645
+ return
646
+ }
647
+ let body
648
+ if (req.method === 'POST') {
649
+ try {
650
+ body = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8'))
651
+ } catch {
652
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
653
+ return
654
+ }
655
+ }
656
+ try {
657
+ const out = await subscriptions.request({
658
+ provider,
659
+ path: restPath,
660
+ method: req.method === 'POST' ? 'POST' : 'GET',
661
+ body,
662
+ headers: {},
663
+ })
664
+ const text = await out.text()
665
+ try {
666
+ const json = JSON.parse(text)
667
+ writeJson(res, out.status || 200, json)
668
+ } catch {
669
+ res.writeHead(out.status || 200, { 'Content-Type': 'application/json' })
670
+ res.end(text)
671
+ }
672
+ } catch (e) {
673
+ const status = e && e.status ? e.status : (e && e.code === 'FORBIDDEN' ? 403 : (e && e.code === 'AUTH' ? 401 : 502))
674
+ writeJson(res, status, { ok: false, error: { code: e && e.code || 'VENDOR', message: String(e && e.message || e).slice(0, 300) } })
675
+ }
676
+ },
677
+ }), 'dsh-subscriptions: proxy')
678
+
679
+ // Экспорт зашифрованного бандла токенов. Токены не логгируются.
680
+ ctx.effect(() => ctx.webServer.register({
681
+ kind: 'exact',
682
+ path: '/dsh-subscriptions/export',
683
+ handler: async (req, res) => {
684
+ if (req.method !== 'POST') {
685
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
686
+ return
687
+ }
688
+ if (!isTrustedSettingsRequest(req)) {
689
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
690
+ return
691
+ }
692
+ let payload
693
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
694
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
695
+ return
696
+ }
697
+ const passphrase = payload.passphrase
698
+ if (!passphrase || typeof passphrase !== 'string') {
699
+ writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'нужен passphrase' } })
700
+ return
701
+ }
702
+ try {
703
+ const accounts = []
704
+ for (const slot of normalizeSlots(live().slots)) {
705
+ try {
706
+ const blob = await store.loadBlob(slot.ref)
707
+ accounts.push({ ref: slot.ref, provider: slot.provider, index: slot.index, label: slot.label || blob.label || '', blob })
708
+ } catch { /* skip missing */ }
709
+ }
710
+ if (!accounts.length) {
711
+ writeJson(res, 200, { ok: false, error: { code: 'empty', message: 'нет подключённых аккаунтов' } })
712
+ return
713
+ }
714
+ const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
715
+ const encrypted = encryptWithPassphrase(bundle, passphrase)
716
+ writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
717
+ } catch (e) {
718
+ writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
719
+ }
720
+ },
721
+ }), 'dsh-subscriptions: /export')
722
+
723
+ // Импорт зашифрованного бандла.
724
+ ctx.effect(() => ctx.webServer.register({
725
+ kind: 'exact',
726
+ path: '/dsh-subscriptions/import',
727
+ handler: async (req, res) => {
728
+ if (req.method !== 'POST') {
729
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
730
+ return
731
+ }
732
+ if (!isTrustedSettingsRequest(req)) {
733
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
734
+ return
735
+ }
736
+ let payload
737
+ try { payload = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8') || '{}') } catch {
738
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
739
+ return
740
+ }
741
+ const { passphrase, payload: encrypted } = payload
742
+ if (!passphrase || !encrypted) {
743
+ writeJson(res, 400, { ok: false, error: { code: 'params', message: 'нужны passphrase и payload' } })
744
+ return
745
+ }
746
+ let bundle
747
+ try {
748
+ bundle = JSON.parse(decryptWithPassphrase(encrypted, passphrase))
749
+ } catch (e) {
750
+ writeJson(res, 400, { ok: false, error: { code: 'decrypt', message: 'неверный passphrase или повреждённый бандл' } })
751
+ return
752
+ }
753
+ if (!bundle || !Array.isArray(bundle.accounts)) {
754
+ writeJson(res, 400, { ok: false, error: { code: 'format', message: 'неверный формат бандла' } })
755
+ return
756
+ }
757
+ let imported = 0
758
+ for (const row of bundle.accounts) {
759
+ try {
760
+ await store.saveBlob(row.ref, row.blob)
761
+ imported++
762
+ } catch { /* skip broken */ }
763
+ }
764
+ await syncAdapter()
765
+ writeJson(res, 200, { ok: true, imported, total: bundle.accounts.length, accounts: await accountsView() })
766
+ },
767
+ }), 'dsh-subscriptions: /import')
768
+
562
769
  ctx.effect(() => ctx.webServer.register({
563
770
  kind: 'exact',
564
771
  path: '/dsh-subscriptions/logout',
package/lib/rotate.js CHANGED
@@ -23,11 +23,19 @@ export function pickAccount(accounts, nowMs, opts) {
23
23
  if (!q) return false
24
24
  if (q.resetAt && Number(q.resetAt) <= now) return false
25
25
  if (q.remaining == null) return false
26
+ // Если до сброса меньше минуты — переключаемся, не тратя остаток,
27
+ // независимо от порога (аккаунт всё равно скоро обнулится).
28
+ if (q.resetAt && Number(q.resetAt) - now < 60000) return true
29
+ // Если остаток меньше порога — считаем исчерпанным, чтобы не тратить
30
+ // последние проценты перед отказом.
31
+ let below = false
26
32
  if (q.limit != null && q.limit > 0 && thr > 0 && thr < 1) {
27
33
  const frac = q.remaining / q.limit
28
- return frac <= thr
34
+ below = frac <= thr
35
+ } else {
36
+ below = q.remaining <= thr
29
37
  }
30
- return q.remaining <= thr
38
+ return below
31
39
  }
32
40
  function isUsageExhausted(acc) {
33
41
  if (acc.usagePercent == null || Number(acc.usagePercent) < 100) return false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
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",