@goodandready/dsh-subscriptions 0.4.2 → 0.4.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.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # dsh-subscriptions
2
2
 
3
- Use **ChatGPT Codex**, **Claude**, **Grok**, **Antigravity**
3
+ Use **ChatGPT Codex**, **Claude**, **Grok**, and **Antigravity**
4
4
  subscriptions as DeepSeek Harness LLM providers. Log in from
5
- **Settings → Subscriptions**. Several accounts per provider rotate on quota
6
- inside this plugin.
5
+ **Settings → Plugins Plugin settings Subscriptions**.
6
+ Several accounts per provider rotate on quota inside this plugin.
7
7
 
8
8
  This package is original software. It speaks vendor-public OAuth and LLM HTTP
9
9
  contracts. It does not ship tools (`x_search`, image, or video).
@@ -25,19 +25,145 @@ After a `file:` install, remove then add again if you added new files under
25
25
 
26
26
  ## Settings
27
27
 
28
- 1. Open **Settings → Subscriptions**.
29
- 2. Pick a provider card and click **Connect**. Complete sign-in in the browser.
30
- 3. **Disconnect** removes that account's token so you can connect again. **Reconnect** repeats OAuth on the same slot. The × on the card also disconnects, then drops the slot.
28
+ Settings live on a collapsible card in **Settings → Plugins → Plugin settings**
29
+ (not a sidebar entry). Click **Show** to expand it.
30
+
31
+ 1. Pick a provider block and click **Connect**. Complete sign-in in the browser.
32
+ 2. **+ Add account** adds another account slot for the same provider; every
33
+ account participates in rotation.
34
+ 3. **Disconnect** removes that account's token; **Reconnect** repeats OAuth on
35
+ the same slot. The × button disconnects and drops the slot.
31
36
  4. If the provider redirects to a localhost or vendor URL that this host cannot
32
- receive, paste the full redirected URL (or the `code` value) into the account
33
- row and click **Submit code**.
37
+ receive, paste the full redirected URL (or the `code` value) into the
38
+ account row and click **Submit code**.
34
39
  5. Logged-in providers appear in the session model picker.
35
40
 
36
41
  Leave **Use this Web UI origin as OAuth redirect_uri** off unless you registered
37
42
  your own OAuth client for this origin. Vendor CLI clients typically require
38
43
  their published redirect URI plus the paste step.
39
44
 
40
- Disconnect removes that account's blob from the host credentials store.
45
+ ## Accounts and rotation
46
+
47
+ Each provider holds any number of account slots (`CODEX_OAUTH_1`,
48
+ `CODEX_OAUTH_2`, …). On `RATE_LIMIT`, `QUOTA`, or HTTP 429 the plugin cools the
49
+ account down (`cooldownMs`, default 30 minutes) and retries the same request on
50
+ the next account of the same provider — never a different provider.
51
+
52
+ Beyond error-driven rotation, accounts are proactively skipped when:
53
+
54
+ - usage is at 100% for the current window (vendor-reported), or
55
+ - remaining quota fraction is at or below `switchAtRemaining`
56
+ (default `0.01` = 1%), or
57
+ - the window resets within one minute (no point spending the tail).
58
+
59
+ When every account is below threshold, the request still goes out on the first
60
+ exhausted account — a refusal beats silence.
61
+
62
+ ## Quota visibility
63
+
64
+ Vendors that report usage expose named windows (Claude `5h`/`7d apps`,
65
+ Codex primary/secondary, Grok credits). Each window renders as a progress bar
66
+ with percent; colors follow theme variables (normal / warning ≥70% / exhausted
67
+ 100%). The last known snapshot persists across harness restarts and refreshes
68
+ on the next successful request.
69
+
70
+ The plugin also estimates remaining requests for the primary window
71
+ (`≈ N (5h)`) once enough request history exists — hidden when data is thin.
72
+
73
+ ### Limit notifications
74
+
75
+ When a window crosses 70%, 90%, or 100%, the plugin logs a warning and emits a
76
+ `subscriptions.limit-notice` event (provider, ref, window id, usedPercent,
77
+ threshold). Each threshold fires once per window until it resets. Toggle with
78
+ `notifyLimits` in Config.
79
+
80
+ ## Background maintenance
81
+
82
+ Two timers run while the plugin is loaded:
83
+
84
+ - **Token refresh ahead**: expiring tokens are refreshed before they are needed
85
+ (`refreshAheadMs`, default 5 min). Failures back off via `refreshRetryMs`
86
+ (default 10 min) and mark the card as *reconnect required*.
87
+ - **Health probe loop**: every `probeIntervalMin` minutes (default 15, 0
88
+ disables) each connected account gets a cheap vendor check. Dead accounts
89
+ surface in the card; probes never set cooldown.
90
+
91
+ Both timers clean up on plugin dispose.
92
+
93
+ ## HTTP proxy
94
+
95
+ `POST|GET /dsh-subscriptions/proxy/{provider}/{path…}` forwards to the vendor
96
+ API on behalf of a logged-in account — through the same allowlist, rotation,
97
+ and quota accounting as model traffic. Same-origin only; no token ever appears
98
+ in a response or log. Paths outside the per-provider allowlist get 403.
99
+
100
+ Example:
101
+
102
+ ```bash
103
+ curl -X POST https://<host>:3080/dsh-subscriptions/proxy/codex/models \
104
+ -H 'Content-Type: application/json' -d '{}'
105
+ ```
106
+
107
+ ## Export / import
108
+
109
+ Tokens live in the host credentials store and normally tie the installation to
110
+ one machine. To move them:
111
+
112
+ ```bash
113
+ # export (returns an encrypted DSHE1 payload)
114
+ curl -X POST https://<host>:3080/dsh-subscriptions/export \
115
+ -H 'Content-Type: application/json' -d '{"passphrase":"strong-passphrase"}'
116
+
117
+ # import
118
+ curl -X POST https://<host>:3080/dsh-subscriptions/import \
119
+ -H 'Content-Type: application/json' \
120
+ -d '{"passphrase":"strong-passphrase","payload":"DSHE1:…"}'
121
+ ```
122
+
123
+ Bundles are AES-256-GCM encrypted with a key derived from your passphrase
124
+ (scrypt). Without the passphrase nothing decrypts; wrong passphrase fails with
125
+ a clear error. Tokens are never logged.
126
+
127
+ ## Composer provider switcher
128
+
129
+ A small widget in the composer bar shows the active subscription provider;
130
+ clicking cycles among logged-in providers without opening Settings. It does not
131
+ change the session model picker state.
132
+
133
+ ## Session header chip
134
+
135
+ A compact chip in the conversation header shows how many subscriptions are
136
+ connected. It polls `/dsh-subscriptions/status` every minute and turns green
137
+ when at least one account is active.
138
+
139
+ ## Quota reset countdown
140
+
141
+ When a vendor reports a quota window reset timestamp, the account card shows a
142
+ live `reset HH:MM:SS` countdown next to the quota bar, updating every second.
143
+
144
+ ## Slash commands
145
+
146
+ From the chat, without opening Settings:
147
+
148
+ ```
149
+ /login <provider> # start OAuth for codex|claude|grok|antigravity (opens the vendor page)
150
+ /login status # insert the connected providers into the composer
151
+ /logout <provider> # disconnect that provider
152
+ ```
153
+
154
+ ## /subscriptions page
155
+
156
+ A localhost-only summary page at `/subscriptions` lists every account slot,
157
+ connection status, usage percent, remaining quota and reset time across all
158
+ providers. Requests from non-loopback hosts get 403.
159
+
160
+ ## Import a token directly
161
+
162
+ In any account card, paste an existing refresh token (or API key) and click
163
+ **Import token** to sign in without the browser OAuth round trip. The token is
164
+ written straight to the host credentials store via
165
+ `POST /dsh-subscriptions/import-token { provider, index, refreshToken }`.
166
+
41
167
 
42
168
  ## Credential names
43
169
 
@@ -68,8 +194,9 @@ Live requests use the vendor subscription surfaces, not API-key hosts:
68
194
  Codex `chatgpt.com/backend-api/codex/responses`, Claude Messages with the
69
195
  OAuth beta header, Grok `cli-chat-proxy.grok.com` with CLI identity headers,
70
196
  and Antigravity Cloud Code Assist (`loadCodeAssist` then
71
- `streamGenerateContent`). Usage endpoints, when they answer, feed the 100%
72
- skip. If a live model list fails, the built-in catalog is used.
197
+ `streamGenerateContent`). Usage endpoints, when they answer, feed the skip
198
+ logic above. If a live model list fails, the built-in catalog is used.
199
+
73
200
  Antigravity uses a confidential Google OAuth client: set `antigravityClientId`
74
201
  and `antigravityClientSecret` in plugin Config (Settings) — nothing is baked
75
202
  into the repository.
@@ -78,14 +205,18 @@ Default model catalogs are built-in lists you can replace with
78
205
  `codexModels`, `claudeModels`, `grokModels`, `antigravityModels`
79
206
  in the plugin Config.
80
207
 
81
- ## Rotation
208
+ ### Config reference
82
209
 
83
- On `RATE_LIMIT`, `QUOTA`, or HTTP 429 the plugin cools that account down
84
- (default 30 minutes) and retries the **same provider** on the next account.
85
- It never switches to a different provider. Accounts at 100% usage (when the
86
- vendor reports usage) are skipped.
87
-
88
- This plugin does not call `dsh-key-rotation`.
210
+ | Key | Default | Meaning |
211
+ |---|---|---|
212
+ | `cooldownMs` | 1800000 | Cooldown after RATE_LIMIT/QUOTA/429 |
213
+ | `switchAtRemaining` | 0.01 | Skip account when remaining ≤ this (fraction <1 or absolute ≥1); 0 disables |
214
+ | `refreshAheadMs` | 300000 | Refresh tokens expiring within this window |
215
+ | `refreshRetryMs` | 600000 | Backoff after a failed background refresh |
216
+ | `probeIntervalMin` | 15 | Account health-check interval, minutes; 0 disables |
217
+ | `notifyLimits` | true | Emit notices when usage crosses 70/90/100% |
218
+ | `useWebCallback` | false | Use Web UI origin as OAuth redirect_uri |
219
+ | `<vendor>Models` | built-in | Replace default model catalog per vendor |
89
220
 
90
221
  ## Identity
91
222
 
@@ -99,4 +230,4 @@ These three names must match:
99
230
 
100
231
  ## License
101
232
 
102
- MIT
233
+ MIT
package/lib/accounts.js CHANGED
@@ -55,6 +55,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
55
55
  const refreshFailures = new Map()
56
56
  const usage = new Map()
57
57
  const notifyThresholds = new Map()
58
+ const health = new Map()
58
59
  const requestCounts = new Map()
59
60
  const windows = new Map()
60
61
  const usageFetched = new Map()
@@ -124,6 +125,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
124
125
  cooldownUntil: cooldowns.get(ref) || 0,
125
126
  quota: quotas.get(ref) || null,
126
127
  usage: windows.get(ref) || base.usage || null,
128
+ health: health.get(ref) || null,
127
129
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
128
130
  refreshError: refreshFailures.get(ref)?.error || '',
129
131
  validationUrl: base.validationUrl || '',
@@ -273,6 +275,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
273
275
  shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
274
276
  getQuota(ref) { return quotas.get(ref) || null },
275
277
  rememberUsage(ref, percent) { usage.set(ref, percent) },
278
+ getHealth(ref) { return health.get(ref) || null },
279
+ setHealth(ref, h) { health.set(ref, h) },
280
+ recordSwitch(ref) { health.set(ref, recordSwitch(health.get(ref))) },
281
+ recordExhaust(ref) { health.set(ref, recordExhaust(health.get(ref))) },
282
+ recordBroken(ref) { health.set(ref, recordBroken(health.get(ref))) },
283
+ recordSuccess(ref) {
284
+ // успешный запрос сбрасывает кулдаун и возвращает аккаунт в строй
285
+ cooldowns.delete(ref)
286
+ if (health.has(ref)) health.set(ref, null)
287
+ },
276
288
  rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
277
289
  getRequestCount(ref) { return requestCounts.get(ref) || 0 },
278
290
  }
package/lib/adapter.js CHANGED
@@ -78,7 +78,10 @@ export class SubscriptionAdapter extends LlmAdapter {
78
78
  cooldownMs: deps.cooldownMs(),
79
79
  switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
80
80
  options,
81
- onCooldown: (account) => deps.rememberCooldown(account.ref, account.cooldownUntil),
81
+ onCooldown: (account) => {
82
+ deps.rememberCooldown(account.ref, account.cooldownUntil)
83
+ if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
84
+ },
82
85
  streamOnce: async function* (account, opts) {
83
86
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
84
87
  const vendor = getVendor(provider)
@@ -116,6 +119,8 @@ export class SubscriptionAdapter extends LlmAdapter {
116
119
  signal: opts.signal,
117
120
  saveBlob: (next) => deps.saveBlob(account.ref, next),
118
121
  })
122
+ // поток завершился успешно: снять кулдаун и здоровье-штрафы
123
+ if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
119
124
  } catch (err) {
120
125
  if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
121
126
  await deps.saveBlob(account.ref, {
package/lib/client.js CHANGED
@@ -5,6 +5,10 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
7
 
8
+ // Модульный переводчик: доступен всем подпискам компонентов и хелперам.
9
+ let t = (key) => key
10
+ const setT = (fn) => { t = fn }
11
+
8
12
  const CSS =
9
13
  // card — same structure as core PluginCard (bash, agent-loop, web-search)
10
14
  '.dsub-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;transition:border-color .16s,background .16s}' +
@@ -36,6 +40,10 @@ window.__ModuleLoader__.load({
36
40
  '.dsub-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}' +
37
41
  '.dsub-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
38
42
  '.dsub-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
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
+ '.dsub-chipOn{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
45
+ '.dsub-chipDot{width:6px;height:6px;border-radius:999px;background:currentColor;flex:none}' +
46
+
39
47
  '.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
40
48
  '.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
41
49
  '.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}' +
@@ -88,8 +96,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
88
96
  'plan': 'Plan',
89
97
  'storedAs': 'Stored as',
90
98
  'switchLabel': 'Provider',
99
+ 'chipActive': 'Subscriptions',
100
+ 'slashLogin': 'Login to a subscription provider',
101
+ 'slashLogout': 'Log out a subscription provider',
102
+ 'slashStatus': 'Subscription status',
91
103
  'none': 'none',
92
104
  'forecast': '≈',
105
+ 'resetLabel': 'сброс',
106
+ 'resetLabel': 'reset',
93
107
  }
94
108
  const ru = {
95
109
  'notConnected': 'Не подключено',
@@ -121,10 +135,32 @@ const cssId = 'dsh-subscriptions/settings.module.css'
121
135
  'plan': 'Тариф',
122
136
  'storedAs': 'Хранится как',
123
137
  'switchLabel': 'Провайдер',
138
+ 'chipActive': 'Подписки',
139
+ 'slashLogin': 'Вход в провайдера подписки',
140
+ 'slashLogout': 'Выйти из провайдера подписки',
141
+ 'slashStatus': 'Статус подписок',
124
142
  'none': 'нет',
125
143
  'forecast': '≈',
126
144
  }
127
145
 
146
+ // #51: live countdown to the quota window reset (account.quota.resetAt).
147
+ function ResetCountdown(props) {
148
+ const [now, setNow] = React.useState(Date.now())
149
+ React.useEffect(() => {
150
+ const id = setInterval(() => setNow(Date.now()), 1000)
151
+ return () => clearInterval(id)
152
+ }, [])
153
+ const ms = (props.resetAt || 0) - now
154
+ if (ms <= 0) return null
155
+ const total = Math.floor(ms / 1000)
156
+ const h = Math.floor(total / 3600)
157
+ const m = Math.floor((total % 3600) / 60)
158
+ const sec = total % 60
159
+ const pad = (n) => String(n).padStart(2, '0')
160
+ return React.createElement('span', { className: 'dsub-sub', style: { fontVariantNumeric: 'tabular-nums' } },
161
+ t('resetLabel') + ' ' + pad(h) + ':' + pad(m) + ':' + pad(sec))
162
+ }
163
+
128
164
  function badgeFor(account, t) {
129
165
  if (!account || !account.configured) return t('notConnected')
130
166
  if (account.validationUrl) return t('verifyAccount')
@@ -178,6 +214,7 @@ function PluginCard(props) {
178
214
  const [checkRes, setCheckRes] = React.useState({})
179
215
  const [checking, setChecking] = React.useState({})
180
216
  const [saved, setSaved] = React.useState(false)
217
+ const [tokenDraft, setTokenDraft] = React.useState({})
181
218
  const [err, setErr] = React.useState('')
182
219
 
183
220
  const applyPayload = (data) => {
@@ -269,6 +306,21 @@ function PluginCard(props) {
269
306
  setAccounts(data.accounts || [])
270
307
  }
271
308
 
309
+ const importToken = async (provider, index) => {
310
+ setErr('')
311
+ const tok = (tokenDraft[provider + ':' + index] || '').trim()
312
+ if (!tok) return
313
+ const res = await fetch('/dsh-subscriptions/import-token', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({ provider, index, refreshToken: tok }),
317
+ })
318
+ const data = await res.json().catch(() => ({}))
319
+ if (!res.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
320
+ setAccounts(data.accounts || [])
321
+ setTokenDraft((d) => Object.assign({}, d, { [provider + ':' + index]: '' }))
322
+ }
323
+
272
324
  const addSlot = (provider) => {
273
325
  const used = slots.filter((s) => s.provider === provider).map((s) => s.index)
274
326
  let index = 1
@@ -358,6 +410,18 @@ function PluginCard(props) {
358
410
  onClick: () => complete(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
359
411
  }, t('submitCode')),
360
412
  ),
413
+ React.createElement('div', { className: 'dsub-row' },
414
+ React.createElement('input', {
415
+ className: 'dsub-grow',
416
+ value: tokenDraft[key] || '',
417
+ placeholder: t('importTokenPlace'),
418
+ onChange: (e) => setTokenDraft((d) => Object.assign({}, d, { [key]: e.target.value })),
419
+ }),
420
+ React.createElement('button', {
421
+ type: 'button', className: 'dsub-mini',
422
+ onClick: () => importToken(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
423
+ }, t('importToken')),
424
+ ),
361
425
  account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
362
426
  account.refreshError ? React.createElement('div', { className: 'dsub-bad' }, 'reconnect required: ' + account.refreshError) : null,
363
427
  (function(){ var r=checkRes[key]; if(!r) return null; var txt=r.ok ? ('ok ' + (r.email||'')) : ('fail ' + (r.error && r.error.message || '')); var cls=r.ok ? 'dsub-ok' : 'dsub-bad'; var q=r.quota; if(q && q.remaining!=null) txt += ' quota:'+q.remaining+(q.limit!=null?'/'+q.limit:''); return React.createElement('div', {className: cls}, txt) })(),
@@ -386,6 +450,7 @@ function PluginCard(props) {
386
450
  }
387
451
  var q=account.quota
388
452
  if(q&&q.usedPercent!=null)parts.push(bar(t('quota'),q.usedPercent))
453
+ if(q&&q.resetAt)parts.push(React.createElement(ResetCountdown,{key:'reset',resetAt:q.resetAt}))
389
454
  if(!wins&&!q&&account.usagePercent!=null)parts.push(bar(t('quota'),account.usagePercent))
390
455
  var at=(q&&q.measuredAt)||(account.usageAt||0)
391
456
  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'))}
@@ -477,7 +542,7 @@ function PluginCard(props) {
477
542
  return () => { for (const off of undo) off() }
478
543
  }, 'dsh-subscriptions: словари')
479
544
  // Подписи вне компонента берут переводчик, привязанный к namespace.
480
- const t = ctx.locale.bind(NS)
545
+ setT(ctx.locale.bind(NS))
481
546
  // Штатное место — вкладка «Плагины»: карточка со своим заголовком и
482
547
  // сворачиванием вместо строки в боковом списке. Ключ регистрации обязан
483
548
  // равняться пространству настроек, иначе вкладка молча не покажет слот.
@@ -525,6 +590,46 @@ function PluginCard(props) {
525
590
  } catch { return [] }
526
591
  }
527
592
 
593
+ // #52: компактный чип в шапке сессии со статусом подключённых подписок.
594
+ function HeaderChip(props) {
595
+ const [state, setState] = React.useState({ loading: true, count: 0 })
596
+ const t = (props && props.t) || ((k) => k)
597
+ React.useEffect(() => {
598
+ const pull = () => {
599
+ refreshLoggedIn().then((logged) => {
600
+ setState({ loading: false, count: logged.length })
601
+ }).catch(() => setState({ loading: false, count: 0 }))
602
+ }
603
+ pull()
604
+ const id = setInterval(pull, 60 * 1000)
605
+ return () => clearInterval(id)
606
+ }, [])
607
+ const on = state.count > 0
608
+ return React.createElement('span', {
609
+ className: 'dsub-chip' + (on ? ' dsub-chipOn' : ''),
610
+ title: on ? t('chipActive') + ': ' + state.count : t('none'),
611
+ },
612
+ React.createElement('span', { className: 'dsub-chipDot' }),
613
+ on ? String(state.count) : '—',
614
+ )
615
+ }
616
+
617
+ function registerHeaderChip(ctx) {
618
+ ctx.effect(() => {
619
+ ctx.slots.inject('conversation.session.header.utilities', () =>
620
+ ctx.slots.register(
621
+ {
622
+ name: 'conversation.session.header.utilities',
623
+ id: 'dsh-subscriptions-header-chip',
624
+ order: 5,
625
+ locale: NS,
626
+ },
627
+ (props) => React.createElement(HeaderChip, { t }),
628
+ ),
629
+ )
630
+ }, 'dsh-subscriptions: header chip')
631
+ }
632
+
528
633
  function registerComposerSwitch(ctx) {
529
634
  ctx.effect(() => {
530
635
  const render = () => {
@@ -569,10 +674,75 @@ function PluginCard(props) {
569
674
  }, 'dsh-subscriptions: composer provider switch')
570
675
  }
571
676
 
572
- exports.inject = ['slots', 'locale']
677
+ // #43: slash-команды входа/выхода/статуса прямо из чата.
678
+ function registerSlashCommands(ctx) {
679
+ ctx.effect(() => {
680
+ const triggers = ctx.get('inputTriggers')
681
+ if (!triggers) return () => {}
682
+ const providerMatch = new RegExp('^(' + ORDER.join('|') + ')$')
683
+ const run = (cmd, line) => {
684
+ const args = (line.trim().replace(/^\/\S+\s*/, '')).split(/\s+/).filter(Boolean)
685
+ const prov = args[0]
686
+ if (cmd === 'logout') {
687
+ if (!prov || !providerMatch.test(prov)) return
688
+ fetch('/dsh-subscriptions/logout', {
689
+ method: 'POST',
690
+ headers: { 'Content-Type': 'application/json' },
691
+ body: JSON.stringify({ provider: prov, index: 1 }),
692
+ }).catch(() => {})
693
+ return
694
+ }
695
+ if (cmd === 'status' || (cmd === 'login' && prov === 'status')) { refreshLoggedIn().catch(() => {}); return }
696
+ // login <provider>
697
+ if (!prov || !providerMatch.test(prov)) return
698
+ fetch('/dsh-subscriptions/oauth/start?provider=' + encodeURIComponent(prov) + '&index=1', { cache: 'no-store' })
699
+ .then((r) => r.json()).then((data) => {
700
+ if (data && data.url) window.open(data.url, '_blank', 'noopener')
701
+ }).catch(() => {})
702
+ }
703
+ const line = (line) => line.trim()
704
+ const sources = [
705
+ { name: 'login', description: t('slashLogin') },
706
+ { name: 'logout', description: t('slashLogout') },
707
+ ]
708
+ const disposers = sources.map((src) =>
709
+ triggers.registerSource({
710
+ trigger: '/',
711
+ name: src.name,
712
+ order: 40,
713
+ description: src.description,
714
+ candidates: (_s, req) => {
715
+ if (req.position !== 'leading') return Promise.resolve([])
716
+ const q = req.query.trim().toLowerCase()
717
+ const name = src.name
718
+ if (q !== '' && !name.startsWith(q)) return Promise.resolve([])
719
+ return Promise.resolve([{ name, description: src.description }])
720
+ },
721
+ matchEnter: (_session, l) => {
722
+ const t2 = line(l)
723
+ const tok = t2.split(/\s+/)[0]
724
+ if (tok !== '/' + src.name) return Promise.resolve(undefined)
725
+ // '/login status' surfaces the connected providers as composer text.
726
+ if (src.name === 'login' && /\bstatus\b/.test(t2)) {
727
+ return refreshLoggedIn().then((logged) =>
728
+ ({ text: 'Connected: ' + (logged.length ? logged.join(', ') : 'none') }))
729
+ }
730
+ run(src.name, t2)
731
+ return Promise.resolve('handled')
732
+ },
733
+ }),
734
+ )
735
+ return () => { for (const off of disposers) off() }
736
+ }, 'dsh-subscriptions: slash commands')
737
+ }
738
+
739
+ exports.inject = ['slots', 'locale', 'sessions']
573
740
  exports.apply = function apply(ctx) {
741
+ setT(ctx.locale.bind(NS))
574
742
  registerSettings(ctx)
743
+ registerHeaderChip(ctx)
575
744
  registerComposerSwitch(ctx)
745
+ registerSlashCommands(ctx)
576
746
  }
577
747
  return module.exports
578
748
  },
package/lib/health.js ADDED
@@ -0,0 +1,38 @@
1
+ // Health-счёт аккаунта для умной ротации.
2
+ // Числа как в dsh-key-rotation pool.js: свитч 5, исчерпание 10, битый ключ 15.
3
+ const PENALTY = { switch: 5, exhaust: 10, broken: 15 }
4
+
5
+ export function emptyHealth() {
6
+ return { switches: 0, exhaustions: 0, broken: 0 }
7
+ }
8
+
9
+ export function recordSwitch(h) {
10
+ const cur = h || emptyHealth()
11
+ return { ...cur, switches: (cur.switches || 0) + 1 }
12
+ }
13
+
14
+ export function recordExhaust(h) {
15
+ const cur = h || emptyHealth()
16
+ return { ...cur, exhaustions: (cur.exhaustions || 0) + 1 }
17
+ }
18
+
19
+ export function recordBroken(h) {
20
+ const cur = h || emptyHealth()
21
+ return { ...cur, broken: (cur.broken || 0) + 1 }
22
+ }
23
+
24
+ export function computeHealthScore(h) {
25
+ const H = h || emptyHealth()
26
+ const score = 100
27
+ - (H.switches || 0) * PENALTY.switch
28
+ - (H.exhaustions || 0) * PENALTY.exhaust
29
+ - (H.broken || 0) * PENALTY.broken
30
+ return Math.max(0, Math.min(100, score))
31
+ }
32
+
33
+ export function healthBadge(score) {
34
+ if (score == null) return 'unknown'
35
+ if (score >= 80) return 'healthy'
36
+ if (score >= 50) return 'tired'
37
+ return 'broken'
38
+ }
package/lib/index.js CHANGED
@@ -140,6 +140,12 @@ export function apply(ctx, config) {
140
140
  cooldownMs: () => live().cooldownMs,
141
141
  switchAtRemaining: () => live().switchAtRemaining,
142
142
  rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
143
+ recordSuccess: (ref) => store.recordSuccess(ref),
144
+ getHealth: (ref) => store.getHealth(ref),
145
+ recordSwitch: (ref) => store.recordSwitch(ref),
146
+ recordExhaust: (ref) => store.recordExhaust(ref),
147
+ recordBroken: (ref) => store.recordBroken(ref),
148
+ setHealth: (ref, h) => store.setHealth(ref, h),
143
149
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
144
150
  rememberRequest: (ref) => store.rememberRequest(ref),
145
151
  getRequestCount: (ref) => store.getRequestCount(ref),
@@ -223,6 +229,26 @@ export function apply(ctx, config) {
223
229
  }
224
230
  }
225
231
 
232
+ // #46: after a login lands, eagerly refresh the live model catalog so the
233
+ // model picker shows the new provider within ~1 min instead of waiting.
234
+ let lastModelRefresh = 0
235
+ async function refreshModels() {
236
+ if (Date.now() - lastModelRefresh < 60 * 1000) return
237
+ lastModelRefresh = Date.now()
238
+ const providers = await store.loggedInProviders()
239
+ for (const provider of providers) {
240
+ try {
241
+ const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
242
+ if (!slot) continue
243
+ const blob = await store.loadBlob(slot.ref).catch(() => null)
244
+ if (!blob) continue
245
+ const fresh = await store.ensureFresh(provider, blob, slot.ref)
246
+ const cfg = vendorConfig(provider, live())
247
+ await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => {})
248
+ } catch {}
249
+ }
250
+ }
251
+
226
252
  function sweepPending(now) {
227
253
  for (const [state, row] of pending) {
228
254
  if (now - row.createdAt > PENDING_TTL_MS) pending.delete(state)
@@ -254,6 +280,7 @@ export function apply(ctx, config) {
254
280
  await store.saveBlob(ref, blob)
255
281
  pending.delete(row.state)
256
282
  await syncAdapter()
283
+ refreshModels().catch(() => {})
257
284
  return { ref, label: blob.label || blob.email || displayName(provider) }
258
285
  }
259
286
 
@@ -797,6 +824,111 @@ export function apply(ctx, config) {
797
824
  },
798
825
  }), 'dsh-subscriptions: /import')
799
826
 
827
+ // #45: импорт существующего refresh token / API key без OAuth-флоу.
828
+ ctx.effect(() => ctx.webServer.register({
829
+ kind: 'exact',
830
+ path: '/dsh-subscriptions/import-token',
831
+ handler: async (req, res) => {
832
+ if (req.method !== 'POST') {
833
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
834
+ return
835
+ }
836
+ if (!isTrustedSettingsRequest(req)) {
837
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
838
+ return
839
+ }
840
+ let payload
841
+ try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
842
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
843
+ return
844
+ }
845
+ const provider = payload.provider
846
+ const index = Number(payload.index || '1')
847
+ const refreshToken = payload.refreshToken
848
+ const apiKey = payload.apiKey
849
+ if (!isProvider(provider)) {
850
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
851
+ return
852
+ }
853
+ if (!refreshToken && !apiKey) {
854
+ writeJson(res, 400, { ok: false, error: { code: 'token', message: 'нужен refreshToken или apiKey' } })
855
+ return
856
+ }
857
+ try {
858
+ const ref = oauthRef(provider, index)
859
+ const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
860
+ if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
861
+ await store.saveBlob(ref, blob)
862
+ await syncAdapter()
863
+ refreshModels().catch(() => {})
864
+ writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
865
+ } catch (e) {
866
+ writeJson(res, 400, { ok: false, error: { code: 'import', message: String(e && e.message || e) } })
867
+ }
868
+ },
869
+ }), 'dsh-subscriptions: /import-token')
870
+
871
+ // #50: сводная страница /subscriptions (localhost-only).
872
+ ctx.effect(() => ctx.webServer.register({
873
+ kind: 'exact',
874
+ path: '/subscriptions',
875
+ handler: async (req, res) => {
876
+ if (req.method !== 'GET') {
877
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
878
+ return
879
+ }
880
+ const host = (req.headers.host || '').split(':')[0]
881
+ if (host !== 'localhost' && host !== '127.0.0.1') {
882
+ writeHtml(res, 403, '<!doctype html><meta charset="utf-8"><p>Subscriptions overview is localhost-only.</p>')
883
+ return
884
+ }
885
+ let cfg, accounts
886
+ try {
887
+ const out = await configResponse()
888
+ cfg = out.config
889
+ accounts = out.accounts || []
890
+ } catch (e) {
891
+ writeHtml(res, 500, '<!doctype html><meta charset="utf-8"><p>Failed to load: ' + escapeHtml(String(e && e.message || e)) + '</p>')
892
+ return
893
+ }
894
+ const rows = accounts.map((a) => {
895
+ const pct = a.usagePercent != null ? a.usagePercent : (a.quota && a.quota.usedPercent) || null
896
+ const rem = a.quota && a.quota.remaining != null ? a.quota.remaining : null
897
+ const lim = a.quota && a.quota.limit != null ? a.quota.limit : null
898
+ const reset = a.quota && a.quota.resetAt ? new Date(a.quota.resetAt).toLocaleString() : ''
899
+ const status = a.validationUrl ? 'verify' : (a.cooldownUntil && a.cooldownUntil > Date.now() ? 'cooldown' : (a.configured ? 'ok' : 'none'))
900
+ return '<tr><td>' + escapeHtml(a.provider) + '</td><td>' + (a.index||1) + '</td>' +
901
+ '<td>' + esc(a.label || a.email || '') + '</td><td>' + status + '</td>' +
902
+ '<td>' + (pct != null ? Math.round(pct) + '%' : '—') + '</td>' +
903
+ '<td>' + (rem != null ? (rem + (lim != null ? '/' + lim : '')) : '—') + '</td>' +
904
+ '<td>' + esc(reset) + '</td><td>' + esc(a.refreshError || '') + '</td></div>'
905
+ })
906
+ const body = rows.length
907
+ ? '<div class="grid">' + rows.join('') + '</div>'
908
+ : '<p class="empty">No accounts connected yet.</p>'
909
+ const slots = cfg.slots || []
910
+ const connected = accounts.filter((a) => a.configured).length
911
+ writeHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Subscriptions</title>
912
+ <style>
913
+ body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#0d1117;color:#e6edf3}
914
+ h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
915
+ .stats{display:flex;gap:24px;margin:16px 0;font-size:13px}
916
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}
917
+ .card{border:1px solid #30363d;border-radius:10px;padding:14px;background:#161b22}
918
+ .card b{display:block;margin-bottom:4px}
919
+ .status{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid #30363d}
920
+ .status.ok{color:#3fb950;border-color:#238636} .status.none{color:#8b949e}
921
+ .status.cooldown{color:#d29922;border-color:#9e6a03} .status.verify{color:#d29922;border-color:#9e6a03}
922
+ .meta{color:#8b949e;font-size:12px}
923
+ </style></head><body>
924
+ <h1>Subscriptions</h1>
925
+ <div class="dim">/subscriptions — localhost only</div>
926
+ <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
+ ${body}
928
+ </body></html>`)
929
+ },
930
+ }), 'dsh-subscriptions: /subscriptions')
931
+
800
932
  ctx.effect(() => ctx.webServer.register({
801
933
  kind: 'exact',
802
934
  path: '/dsh-subscriptions/logout',
@@ -818,6 +950,7 @@ export function apply(ctx, config) {
818
950
  const ref = oauthRef(payload.provider, payload.index)
819
951
  await store.clearRef(ref)
820
952
  await syncAdapter()
953
+ refreshModels().catch(() => {})
821
954
  writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
822
955
  } catch (e) {
823
956
  writeJson(res, 400, { ok: false, error: { code: 'logout', message: String(e && e.message || e) } })
package/lib/rotate.js CHANGED
@@ -57,7 +57,13 @@ export function pickAccount(accounts, nowMs, opts) {
57
57
  if (!acc.quota) tiers[1].push(acc)
58
58
  else tiers[0].push(acc)
59
59
  }
60
- for (const tier of tiers) if (tier.length) return tier[0]
60
+ // Внутри здоровых тиров предпочитаем аккаунт с более высоким health-счётом
61
+ for (const tier of tiers) {
62
+ if (tier.length) {
63
+ tier.sort((a, b) => (b.healthScore || 100) - (a.healthScore || 100))
64
+ return tier[0]
65
+ }
66
+ }
61
67
  if (fallback.length) return fallback[0]
62
68
  return null
63
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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",