@goodandready/dsh-subscriptions 0.2.0 → 0.2.2

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
@@ -50,6 +50,9 @@ export function vendorConfig(provider, cfg) {
50
50
 
51
51
  export function createAccountStore({ credentials, getConfig, fetchImpl }) {
52
52
  const cooldowns = new Map()
53
+ const quotas = new Map()
54
+ const refreshLocks = new Map()
55
+ const refreshFailures = new Map()
53
56
  const usage = new Map()
54
57
  const usageFetched = new Map()
55
58
  const doFetch = fetchImpl || fetch
@@ -80,6 +83,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
80
83
  async function clearRef(ref) {
81
84
  await credentials.unset(credentialRef(ref))
82
85
  cooldowns.delete(ref)
86
+ quotas.delete(ref)
87
+ refreshFailures.delete(ref)
83
88
  usage.delete(ref)
84
89
  usageFetched.delete(ref)
85
90
  }
@@ -111,7 +116,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
111
116
  return {
112
117
  ...base,
113
118
  cooldownUntil: cooldowns.get(ref) || 0,
119
+ quota: quotas.get(ref) || null,
114
120
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
121
+ refreshError: refreshFailures.get(ref)?.error || '',
115
122
  validationUrl: base.validationUrl || '',
116
123
  validationMessage: base.validationMessage || '',
117
124
  accountNotice: base.accountNotice || '',
@@ -129,6 +136,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
129
136
  hasToken: !!info.configured,
130
137
  usagePercent: info.usagePercent,
131
138
  cooldownUntil: info.cooldownUntil,
139
+ quota: info.quota || null,
132
140
  label: slot.label || info.label,
133
141
  })
134
142
  }
@@ -147,17 +155,30 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
147
155
  async function ensureFresh(provider, blob, ref) {
148
156
  if (!blob.refreshToken) return blob
149
157
  if (blob.expiresAt && blob.expiresAt - SKEW_MS > Date.now()) return blob
150
- const cfg = vendorConfig(provider, getConfig())
151
- const next = await getVendor(provider).refresh(cfg, blob, doFetch)
152
- const merged = {
153
- ...blob,
154
- ...next,
155
- refreshToken: next.refreshToken || blob.refreshToken,
156
- projectId: next.projectId || blob.projectId,
157
- accountId: next.accountId || blob.accountId,
158
- }
159
- if (ref) await saveBlob(ref, merged)
160
- return merged
158
+ if (ref && refreshLocks.has(ref)) return refreshLocks.get(ref)
159
+ const promise = (async () => {
160
+ try {
161
+ const cfg = vendorConfig(provider, getConfig())
162
+ const next = await getVendor(provider).refresh(cfg, blob, doFetch)
163
+ const merged = {
164
+ ...blob,
165
+ ...next,
166
+ refreshToken: next.refreshToken || blob.refreshToken,
167
+ projectId: next.projectId || blob.projectId,
168
+ accountId: next.accountId || blob.accountId,
169
+ }
170
+ if (ref) await saveBlob(ref, merged)
171
+ if (ref) refreshFailures.delete(ref)
172
+ return merged
173
+ } catch (e) {
174
+ if (ref) refreshFailures.set(ref, { at: Date.now(), error: String(e && e.message || e) })
175
+ throw e
176
+ } finally {
177
+ if (ref) refreshLocks.delete(ref)
178
+ }
179
+ })()
180
+ if (ref) refreshLocks.set(ref, promise)
181
+ return promise
161
182
  }
162
183
 
163
184
  async function refreshUsage(provider) {
@@ -193,6 +214,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
193
214
  ensureFresh,
194
215
  refreshUsage,
195
216
  rememberCooldown(ref, until) { cooldowns.set(ref, until) },
217
+ rememberQuota(ref, snap) { if (snap) quotas.set(ref, snap); else quotas.delete(ref) },
218
+ shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
219
+ getQuota(ref) { return quotas.get(ref) || null },
196
220
  rememberUsage(ref, percent) { usage.set(ref, percent) },
197
221
  }
198
222
  }
package/lib/adapter.js CHANGED
@@ -76,16 +76,40 @@ export class SubscriptionAdapter extends LlmAdapter {
76
76
  accounts: await deps.listAccounts(provider),
77
77
  nowMs: () => Date.now(),
78
78
  cooldownMs: deps.cooldownMs(),
79
+ switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
79
80
  options,
80
81
  onCooldown: (account) => deps.rememberCooldown(account.ref, account.cooldownUntil),
81
82
  streamOnce: async function* (account, opts) {
82
83
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
83
84
  const vendor = getVendor(provider)
85
+ // ponytail: capture x-ratelimit headers without touching vendors
86
+ const baseFetch = deps.fetchImpl || fetch
87
+ const quotaFetch = async (url, init) => {
88
+ const res = await baseFetch(url, init)
89
+ try {
90
+ const snap = quotaSnapshot(provider, res.headers, null, Date.now())
91
+ if (snap && typeof deps.rememberQuota === "function") deps.rememberQuota(account.ref, snap)
92
+ } catch {}
93
+ if (!res.ok && res.clone) {
94
+ try {
95
+ const txt = await res.clone().text()
96
+ let j=null; try{ j=JSON.parse(txt)}catch{}
97
+ if (j) {
98
+ const b = parseBody(provider, j, Date.now())
99
+ if (b) {
100
+ const snap2 = quotaSnapshot(provider, res.headers, j, Date.now())
101
+ if (snap2) deps.rememberQuota(account.ref, snap2)
102
+ }
103
+ }
104
+ } catch {}
105
+ }
106
+ return res
107
+ }
84
108
  try {
85
109
  yield* vendor.streamOnce({
86
110
  blob,
87
111
  options: opts,
88
- fetchImpl: deps.fetchImpl || fetch,
112
+ fetchImpl: quotaFetch,
89
113
  headers: attributionHeaders(),
90
114
  config: deps.vendorConfig(provider),
91
115
  signal: opts.signal,
package/lib/client.js CHANGED
@@ -35,19 +35,82 @@ window.__ModuleLoader__.load({
35
35
  document.head.appendChild(tag)
36
36
  }
37
37
 
38
- function badgeFor(account) {
39
- if (!account || !account.configured) return 'Not connected'
40
- if (account.validationUrl) return 'Verify account'
41
- if (account.cooldownUntil && account.cooldownUntil > Date.now()) return 'Cooling down'
42
- if (account.usagePercent != null && account.usagePercent >= 100) return 'Usage 100%'
43
- return 'Connected'
38
+ // Строки карточки живут в реестре локалей: так их переводит отдельный
39
+ // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
40
+ // на него же приходится откат, если перевода нет.
41
+ const NS = 'dsh-subscriptions'
42
+ const en = {
43
+ 'notConnected': 'Not connected',
44
+ 'verifyAccount': 'Verify account',
45
+ 'coolingDown': 'Cooling down',
46
+ 'usageFull': 'Usage 100%',
47
+ 'connected': 'Connected',
48
+ 'title': 'Subscriptions',
49
+ '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
+ 'useOrigin': 'Use this Web UI origin as OAuth redirect_uri',
51
+ '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.',
52
+ 'reconnect': 'Reconnect',
53
+ 'connect': 'Connect',
54
+ 'disconnect': 'Disconnect',
55
+ 'removeSlot': 'Remove slot',
56
+ 'pastePlaceholder': 'Paste redirected URL or code',
57
+ 'submitCode': 'Submit code',
58
+ 'verifyPrefix': 'Google requires one-time account verification. ',
59
+ 'verifyLink': 'Open verification link',
60
+ 'verifySuffix': ' then reconnect.',
61
+ 'addAccount': '+ Add account',
62
+ 'save': 'Save',
63
+ 'saved': 'Saved',
64
+ 'loading': 'Loading\u2026',
65
+ 'accountLabel': 'Account',
66
+ 'plan': 'Plan',
67
+ 'storedAs': 'Stored as',
68
+ }
69
+ const ru = {
70
+ 'notConnected': 'Не подключено',
71
+ 'verifyAccount': 'Требуется проверка',
72
+ 'coolingDown': 'Пауза после лимита',
73
+ 'usageFull': 'Лимит исчерпан',
74
+ 'connected': 'Подключено',
75
+ 'title': 'Подписки',
76
+ 'intro': 'Вход по обычной пользовательской подписке. Токены остаются в хранилище учётных данных харнесса, браузер их обратно не читает. Если после «Подключить» провайдер увёл на localhost или на свою страницу, вставьте сюда адрес перехода или код.',
77
+ 'useOrigin': 'Использовать адрес этого веб-интерфейса как redirect_uri',
78
+ 'useOriginHint': 'Включайте, только если зарегистрировали собственного клиента OAuth на этот адрес. Штатным консольным клиентам провайдеров нужен их опубликованный адрес возврата и вставка кода вручную.',
79
+ 'reconnect': 'Переподключить',
80
+ 'connect': 'Подключить',
81
+ 'disconnect': 'Отключить',
82
+ 'removeSlot': 'Убрать место',
83
+ 'pastePlaceholder': 'Адрес перехода или код',
84
+ 'submitCode': 'Отправить код',
85
+ 'verifyPrefix': 'Google требует однократной проверки аккаунта. ',
86
+ 'verifyLink': 'Открыть страницу проверки',
87
+ 'verifySuffix': ' затем подключитесь заново.',
88
+ 'addAccount': '+ Добавить аккаунт',
89
+ 'save': 'Сохранить',
90
+ 'saved': 'Сохранено',
91
+ 'loading': 'Загрузка…',
92
+ 'accountLabel': 'Аккаунт',
93
+ 'plan': 'Тариф',
94
+ 'storedAs': 'Хранится как',
95
+ }
96
+
97
+ function badgeFor(account, t) {
98
+ if (!account || !account.configured) return t('notConnected')
99
+ if (account.validationUrl) return t('verifyAccount')
100
+ if (account.cooldownUntil && account.cooldownUntil > Date.now()) return t('coolingDown')
101
+ if (account.usagePercent != null && account.usagePercent >= 100) return t('usageFull')
102
+ return t('connected')
44
103
  }
45
104
 
46
- function SubsSection() {
105
+ function SubsSection(props) {
106
+ // Переводчик приходит от слота, потому что в его записи указан locale.
107
+ const t = (props && props.t) || ((key) => key)
47
108
  const [draft, setDraft] = React.useState(null)
48
109
  const [accounts, setAccounts] = React.useState([])
49
110
  const [providers, setProviders] = React.useState([])
50
111
  const [paste, setPaste] = React.useState({})
112
+ const [checkRes, setCheckRes] = React.useState({})
113
+ const [checking, setChecking] = React.useState({})
51
114
  const [saved, setSaved] = React.useState(false)
52
115
  const [err, setErr] = React.useState('')
53
116
 
@@ -67,7 +130,7 @@ window.__ModuleLoader__.load({
67
130
  return () => { alive = false }
68
131
  }, [])
69
132
 
70
- if (!draft) return React.createElement('div', { className: 'dsub-wrap' }, 'Loading\u2026')
133
+ if (!draft) return React.createElement('div', { className: 'dsub-wrap' }, t('loading'))
71
134
 
72
135
  const slots = Array.isArray(draft.slots) ? draft.slots : []
73
136
  const setSlots = (next) => setDraft((d) => Object.assign({}, d, { slots: next }))
@@ -109,6 +172,25 @@ window.__ModuleLoader__.load({
109
172
  setPaste((p) => Object.assign({}, p, { [key]: '' }))
110
173
  }
111
174
 
175
+ const doCheck = async (provider, index) => {
176
+ const key = provider + ':' + index
177
+ setChecking((c) => Object.assign({}, c, { [key]: true }))
178
+ setErr('')
179
+ try {
180
+ const res = await fetch('/dsh-subscriptions/check', {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ provider, index }),
184
+ })
185
+ const data = await res.json().catch(() => ({}))
186
+ setCheckRes((m) => Object.assign({}, m, { [key]: data }))
187
+ if (data && data.quota) {
188
+ setAccounts((prev) => prev.map((a) => a.provider===provider && a.index===index ? Object.assign({}, a, { quota: data.quota }) : a))
189
+ }
190
+ } catch (e) { setErr(String(e.message || e)) }
191
+ setChecking((c) => Object.assign({}, c, { [key]: false }))
192
+ }
193
+
112
194
  const logout = async (provider, index) => {
113
195
  setErr('')
114
196
  const res = await fetch('/dsh-subscriptions/logout', {
@@ -137,19 +219,19 @@ window.__ModuleLoader__.load({
137
219
 
138
220
  return React.createElement('div', { className: 'dsub-wrap' },
139
221
  React.createElement('div', { className: 'dsub-block' },
140
- React.createElement('div', { className: 'dsub-h' }, 'Subscriptions'),
222
+ React.createElement('div', { className: 'dsub-h' }, t('title')),
141
223
  React.createElement('div', { className: 'dsub-sub' },
142
- '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.'),
224
+ t('intro')),
143
225
  React.createElement('label', { className: 'dsub-row' },
144
226
  React.createElement('input', {
145
227
  type: 'checkbox',
146
228
  checked: !!draft.useWebCallback,
147
229
  onChange: (e) => setDraft((d) => Object.assign({}, d, { useWebCallback: e.target.checked })),
148
230
  }),
149
- React.createElement('span', null, 'Use this Web UI origin as OAuth redirect_uri'),
231
+ React.createElement('span', null, t('useOrigin')),
150
232
  ),
151
233
  React.createElement('div', { className: 'dsub-sub' },
152
- '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.'),
234
+ t('useOriginHint')),
153
235
  ),
154
236
  names.map((prov) => {
155
237
  const rows = slots
@@ -166,25 +248,30 @@ window.__ModuleLoader__.load({
166
248
  React.createElement('input', {
167
249
  className: 'dsub-grow',
168
250
  value: row.slot.label || '',
169
- placeholder: account.label || ('Account ' + row.slot.index),
251
+ placeholder: account.label || (t('accountLabel') + ' ' + row.slot.index),
170
252
  onChange: (e) => {
171
253
  const next = slots.slice()
172
254
  next[row.i] = Object.assign({}, next[row.i], { label: e.target.value })
173
255
  setSlots(next)
174
256
  },
175
257
  }),
176
- React.createElement('span', { className: 'dsub-badge' + (account.validationUrl ? ' dsub-badge-warn' : (on ? ' dsub-badge-on' : '')) }, badgeFor(account)),
258
+ React.createElement('span', { className: 'dsub-badge' + (account.validationUrl ? ' dsub-badge-warn' : (on ? ' dsub-badge-on' : '')) }, badgeFor(account, t)),
177
259
  React.createElement('button', {
178
260
  type: 'button', className: 'dsub-mini',
179
261
  onClick: () => connect(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
180
- }, on ? 'Reconnect' : 'Connect'),
262
+ }, on ? t('reconnect') : t('connect')),
181
263
  React.createElement('button', {
182
264
  type: 'button', className: 'dsub-mini',
183
265
  disabled: !on,
184
266
  onClick: () => logout(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
185
- }, 'Disconnect'),
267
+ }, t('disconnect')),
186
268
  React.createElement('button', {
187
- type: 'button', className: 'dsub-mini', title: 'Remove slot',
269
+ type: 'button', className: 'dsub-mini',
270
+ disabled: checking[key],
271
+ onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
272
+ }, checking[key] ? '...' : 'Check'),
273
+ React.createElement('button', {
274
+ type: 'button', className: 'dsub-mini', title: t('removeSlot'),
188
275
  onClick: () => {
189
276
  const slot = row.slot
190
277
  Promise.resolve(on ? logout(slot.provider, slot.index) : null)
@@ -197,49 +284,65 @@ window.__ModuleLoader__.load({
197
284
  React.createElement('input', {
198
285
  className: 'dsub-grow',
199
286
  value: paste[key] || '',
200
- placeholder: 'Paste redirected URL or code',
287
+ placeholder: t('pastePlaceholder'),
201
288
  onChange: (e) => setPaste((p) => Object.assign({}, p, { [key]: e.target.value })),
202
289
  }),
203
290
  React.createElement('button', {
204
291
  type: 'button', className: 'dsub-mini',
205
292
  onClick: () => complete(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
206
- }, 'Submit code'),
293
+ }, t('submitCode')),
207
294
  ),
208
295
  account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
296
+ account.refreshError ? React.createElement('div', { className: 'dsub-bad' }, 'reconnect required: ' + account.refreshError) : null,
297
+ (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) })(),
209
298
  account.validationUrl ? React.createElement('div', { className: 'dsub-verify' },
210
- 'Google requires one-time account verification. ',
211
- React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Open verification link'),
212
- ' then reconnect.',
299
+ t('verifyPrefix'),
300
+ React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
301
+ t('verifySuffix'),
213
302
  ) : null,
214
- account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, 'Plan: ' + account.paidTierName) : null,
215
- account.ref ? React.createElement('span', { className: 'dsub-sub' }, 'Stored as ' + account.ref) : null,
303
+ (function(){var q=account.quota;if(!q)return null;var a=[];if(q.remaining!=null&&q.limit!=null)a.push(q.remaining+"/"+q.limit);else if(q.remaining!=null)a.push(String(q.remaining));if(q.resetAt){var d=new Date(q.resetAt);a.push(d.toLocaleTimeString())}if(q.measuredAt){var m=Math.round((Date.now()-q.measuredAt)/60000);a.push(m<1?"<1m":m+"m ago")}if(!a.length)return null;return React.createElement("span",{className:"dsub-sub"},"Quota "+a.join(" \u00b7 "))})(),
304
+
305
+ account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
306
+ account.ref ? React.createElement('span', { className: 'dsub-sub' }, t('storedAs') + ' ' + account.ref) : null,
216
307
  )
217
308
  }),
218
309
  React.createElement('button', {
219
310
  type: 'button', className: 'dsub-mini',
220
311
  onClick: () => addSlot(prov.id),
221
- }, '+ Add account'),
312
+ }, t('addAccount')),
222
313
  )
223
314
  }),
224
315
  React.createElement('div', { className: 'dsub-row' },
225
316
  React.createElement('button', {
226
317
  type: 'button', className: 'dsub-save',
227
318
  onClick: () => save().catch((e) => setErr(String(e.message || e))),
228
- }, 'Save'),
229
- saved ? React.createElement('span', { className: 'dsub-ok' }, 'Saved') : null,
319
+ }, t('save')),
320
+ saved ? React.createElement('span', { className: 'dsub-ok' }, t('saved')) : null,
230
321
  err ? React.createElement('span', { className: 'dsub-bad' }, err) : null,
231
322
  ),
232
323
  )
233
324
  }
234
325
 
235
326
  function registerSettings(ctx) {
327
+ ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-subscriptions: словари')
328
+ // Подпись раздела рисует боковой список, а не наш компонент: props.t
329
+ // туда не доходит, поэтому берём переводчик, привязанный к namespace.
330
+ const t = ctx.locale.bind(NS)
236
331
  ctx.slots.inject('settings.section', () => ctx.slots.register(
237
- { name: 'settings.section', id: '@goodandready/dsh-subscriptions', order: 28, label: () => 'Subscriptions', inject: () => ({ ctx: ctx }) },
332
+ {
333
+ name: 'settings.section',
334
+ id: '@goodandready/dsh-subscriptions',
335
+ order: 28,
336
+ // locale в записи слота — то, из-за чего компонент получает props.t.
337
+ locale: NS,
338
+ label: () => t('title'),
339
+ inject: () => ({ ctx: ctx }),
340
+ },
238
341
  SubsSection,
239
342
  ))
240
343
  }
241
344
 
242
- exports.inject = ['slots']
345
+ exports.inject = ['slots', 'locale']
243
346
  exports.apply = function apply(ctx) {
244
347
  registerSettings(ctx)
245
348
  }
package/lib/index.js CHANGED
@@ -7,6 +7,8 @@ import { getVendor } from './vendors/index.js'
7
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
+ import { createSubscriptionsService } from './subscriptions.js'
11
+ import { quotaSnapshot } from './ratelimit.js'
10
12
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
11
13
  import {
12
14
  inspectGoogleAccount,
@@ -34,6 +36,12 @@ const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '
34
36
  export const Config = z.object({
35
37
  cooldownMs: z.number().default(30 * 60 * 1000)
36
38
  .description('After RATE_LIMIT/QUOTA/429, skip that account for this many milliseconds.'),
39
+ switchAtRemaining: z.number().default(0)
40
+ .description('If remaining <= this (absolute or <1 fraction), treat as exhausted before request. 0 disables.'),
41
+ refreshAheadMs: z.number().default(5 * 60 * 1000)
42
+ .description('Background refresh when expiry within this many ms.'),
43
+ refreshRetryMs: z.number().default(10 * 60 * 1000)
44
+ .description('Do not retry background refresh more often than this after failure.'),
37
45
  slots: z.array(Slot).default(defaultSlots)
38
46
  .description('Account slots. Secrets are not stored here; only the credential ref names.'),
39
47
  useWebCallback: z.boolean().default(false)
@@ -79,6 +87,17 @@ export function apply(ctx, config) {
79
87
 
80
88
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
81
89
  const store = createAccountStore({ credentials: ctx.credentials, getConfig: live, fetchImpl: fetch })
90
+ const subscriptions = createSubscriptionsService({
91
+ listAccounts: (provider) => store.listAccounts(provider),
92
+ loadBlob: (ref) => store.loadBlob(ref),
93
+ ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
94
+ vendorConfig: (provider) => vendorConfig(provider, live()),
95
+ cooldownMs: () => live().cooldownMs,
96
+ switchAtRemaining: () => live().switchAtRemaining,
97
+ rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
98
+ rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
99
+ fetchImpl: fetch,
100
+ })
82
101
 
83
102
  // Служба генерации картинок на подписке.
84
103
  //
@@ -86,6 +105,7 @@ export function apply(ctx, config) {
86
105
  // ключ доступа каждому, кто дотянется до харнесса, а служба живёт внутри
87
106
  // процесса и видна только другим плагинам. Обновлением токена по-прежнему
88
107
  // занимается один хозяин — этот плагин.
108
+ ctx.effect(() => ctx.provide('subscriptions', subscriptions), 'dsh-subscriptions: subscriptions service')
89
109
  ctx.effect(() => ctx.provide('subscriptionImages', {
90
110
  /** Провайдеры, у которых есть вход прямо сейчас. */
91
111
  async available() {
@@ -126,7 +146,10 @@ export function apply(ctx, config) {
126
146
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
127
147
  vendorConfig: (provider) => vendorConfig(provider, live()),
128
148
  cooldownMs: () => live().cooldownMs,
149
+ switchAtRemaining: () => live().switchAtRemaining,
129
150
  rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
151
+ rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
152
+ getQuota: (ref) => store.getQuota(ref),
130
153
  refreshUsage: (provider) => store.refreshUsage(provider),
131
154
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
132
155
  fetchImpl: fetch,
@@ -187,7 +210,6 @@ export function apply(ctx, config) {
187
210
  return { ref, label: blob.label || blob.email || displayName(provider) }
188
211
  }
189
212
 
190
-
191
213
  async function enrichAntigravityAccount(slot, info) {
192
214
  if (!info.configured || slot.provider !== 'antigravity') return info
193
215
  let blob
@@ -250,6 +272,8 @@ export function apply(ctx, config) {
250
272
  writable: info.writable,
251
273
  cooldownUntil: info.cooldownUntil,
252
274
  usagePercent: info.usagePercent,
275
+ quota: info.quota || null,
276
+ refreshError: info.refreshError || '',
253
277
  validationUrl: info.validationUrl || '',
254
278
  validationMessage: info.validationMessage || '',
255
279
  accountNotice: info.accountNotice || '',
@@ -277,6 +301,39 @@ export function apply(ctx, config) {
277
301
  }
278
302
  }
279
303
  }, 'dsh-subscriptions: llm adapter')
304
+ // ponytail: background refresh ahead of expiry, single timer, per-account lock + retry backoff
305
+ ctx.effect(() => {
306
+ const tick = async () => {
307
+ const cfg = live()
308
+ const ahead = Number(cfg.refreshAheadMs) || 5 * 60 * 1000
309
+ const retryMs = Number(cfg.refreshRetryMs) || 10 * 60 * 1000
310
+ const now = Date.now()
311
+ for (const slot of normalizeSlots(cfg.slots)) {
312
+ const ref = slot.ref
313
+ try {
314
+ const info = await store.describeRef(ref)
315
+ if (!info.configured) continue
316
+ if (info.cooldownUntil && info.cooldownUntil > now) continue
317
+ const raw = await store.resolveRaw(ref)
318
+ if (!raw) continue
319
+ const blob = await store.loadBlob(ref).catch(() => null)
320
+ if (!blob || !blob.refreshToken) continue
321
+ if (!blob.expiresAt) continue
322
+ if (blob.expiresAt - now > ahead) continue
323
+ if (typeof store.shouldSkipRefresh === 'function' && store.shouldSkipRefresh(ref, now, retryMs)) continue
324
+ await store.ensureFresh(slot.provider, blob, ref).catch((e) => {
325
+ try { ctx.log && ctx.log.warn && ctx.log.warn("[dsh-subscriptions] background refresh failed for " + ref + ": " + String(e && e.message || e)) } catch {}
326
+ })
327
+ } catch {}
328
+ }
329
+ }
330
+ tick().catch(() => {})
331
+ const timer = (ctx.setInterval || setInterval).bind(ctx)
332
+ const clear = (ctx.clearInterval || clearInterval).bind(ctx)
333
+ const id = timer(() => { tick().catch(() => {}) }, 60 * 1000)
334
+ return () => clear(id)
335
+ }, 'dsh-subscriptions: refresh ahead')
336
+
280
337
 
281
338
  ctx.effect(() => ctx.webServer.register({
282
339
  kind: 'exact',
@@ -316,6 +373,9 @@ export function apply(ctx, config) {
316
373
  writeJson(res, 400, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
317
374
  }
318
375
  },
376
+
377
+
378
+
319
379
  }), 'dsh-subscriptions: /config')
320
380
 
321
381
  ctx.effect(() => ctx.webServer.register({
@@ -0,0 +1,218 @@
1
+ // ponytail: per-provider header/body maps, add keys when new vendor differs
2
+ export function numberOrNull(v) {
3
+ if (v == null || String(v).trim() === "") return null
4
+ const n = Number(v)
5
+ return Number.isFinite(n) ? n : null
6
+ }
7
+
8
+ function resetAtFromValue(raw, now) {
9
+ if (raw == null) return null
10
+ const s = String(raw).trim()
11
+ if (!s) return null
12
+ // ISO date?
13
+ if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
14
+ const t = Date.parse(s)
15
+ return Number.isFinite(t) ? t : null
16
+ }
17
+ const n = Number(s)
18
+ if (!Number.isFinite(n)) return null
19
+ // if looks like epoch seconds (>1e9) -> ms
20
+ if (n > 1e9) {
21
+ // epoch seconds or ms? if >1e12 already ms
22
+ return n > 1e12 ? n : n * 1000
23
+ }
24
+ // otherwise seconds-from-now (Retry-After)
25
+ if (n >= 0 && n < 7 * 24 * 3600) return now + n * 1000
26
+ return null
27
+ }
28
+
29
+ function getHeader(headers, name) {
30
+ if (!headers) return null
31
+ const want = name.toLowerCase()
32
+ // Headers instance
33
+ if (typeof headers.get === "function") {
34
+ // try direct, then lowercase
35
+ let v = headers.get(name)
36
+ if (v != null) return v
37
+ // Headers are case-insensitive, but some impls need lower
38
+ v = headers.get(want)
39
+ if (v != null) return v
40
+ // iterate
41
+ if (typeof headers.forEach === "function") {
42
+ let found = null
43
+ headers.forEach((val, key) => {
44
+ if (String(key).toLowerCase() === want) found = val
45
+ })
46
+ return found
47
+ }
48
+ return null
49
+ }
50
+ // plain object
51
+ if (typeof headers === "object") {
52
+ for (const [k, v] of Object.entries(headers)) {
53
+ if (String(k).toLowerCase() === want) return v
54
+ }
55
+ // also handle array of pairs
56
+ if (Array.isArray(headers)) {
57
+ for (const [k,v] of headers) if (String(k).toLowerCase()===want) return v
58
+ }
59
+ }
60
+ return null
61
+ }
62
+
63
+ const HEADER_REMAINING = [
64
+ "x-ratelimit-remaining",
65
+ "x-ratelimit-remaining-requests",
66
+ "x-ratelimit-remaining-tokens",
67
+ "ratelimit-remaining",
68
+ "x-ratelimit-remaining-minute",
69
+ ]
70
+ const HEADER_LIMIT = [
71
+ "x-ratelimit-limit",
72
+ "x-ratelimit-limit-requests",
73
+ "x-ratelimit-limit-tokens",
74
+ "ratelimit-limit",
75
+ ]
76
+ const HEADER_RESET = [
77
+ "x-ratelimit-reset",
78
+ "x-ratelimit-reset-requests",
79
+ "x-ratelimit-reset-tokens",
80
+ "ratelimit-reset",
81
+ "retry-after",
82
+ "x-retry-after",
83
+ "x-ratelimit-reset-minute",
84
+ ]
85
+
86
+ // per-provider body field tables — add provider key to extend without touching parser
87
+ export const PROVIDER_BODY_FIELDS = {
88
+ codex: {
89
+ remaining: ["remaining", "remaining_requests", "rate_limit_remaining", "quota_remaining", "requests_remaining"],
90
+ limit: ["limit", "limit_requests", "quota_limit", "rate_limit", "max_requests"],
91
+ reset: ["reset", "reset_at", "resetAt", "reset_time", "retry_after", "reset_after"],
92
+ },
93
+ claude: {
94
+ remaining: ["remaining", "remaining_requests", "rate_limit_remaining"],
95
+ limit: ["limit", "quota", "rate_limit"],
96
+ reset: ["reset", "reset_at", "retry_after"],
97
+ },
98
+ grok: {
99
+ remaining: ["remaining", "creditRemaining", "remainingCredits"],
100
+ limit: ["limit", "monthlyLimit", "creditLimit"],
101
+ reset: ["reset", "resetAt", "retry_after"],
102
+ },
103
+ antigravity: {
104
+ remaining: ["remaining", "quotaRemaining"],
105
+ limit: ["limit", "quotaLimit"],
106
+ reset: ["reset", "resetAt", "retry_after"],
107
+ },
108
+ _common: {
109
+ remaining: ["remaining", "remaining_requests", "requests_remaining", "quota_remaining", "rate_limit_remaining", "rateLimitRemaining", "creditRemaining"],
110
+ limit: ["limit", "limit_requests", "quota_limit", "rate_limit", "rateLimit", "creditLimit", "monthlyLimit"],
111
+ reset: ["reset", "reset_at", "resetAt", "reset_time", "retry_after", "retryAfter", "reset_after", "rateLimitReset"],
112
+ },
113
+ }
114
+
115
+ function pickBodyField(obj, keys) {
116
+ if (!obj || typeof obj !== "object") return null
117
+ for (const k of keys) {
118
+ if (k in obj) {
119
+ const v = obj[k]
120
+ if (v != null && String(v) !== "") return v
121
+ }
122
+ // case-insensitive fallback
123
+ const low = k.toLowerCase()
124
+ for (const [ok, ov] of Object.entries(obj)) {
125
+ if (String(ok).toLowerCase() === low && ov != null && String(ov) !== "") return ov
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ function deepest(obj, keys, depth=0) {
132
+ if (!obj || typeof obj !== "object" || depth>4) return null
133
+ const direct = pickBodyField(obj, keys)
134
+ if (direct != null) return direct
135
+ for (const v of Object.values(obj)) {
136
+ if (v && typeof v === "object") {
137
+ const found = deepest(v, keys, depth+1)
138
+ if (found != null) return found
139
+ }
140
+ }
141
+ return null
142
+ }
143
+
144
+ export function parseHeaders(headers, nowMs = Date.now()) {
145
+ let remaining = null, limit = null, resetAt = null
146
+ for (const h of HEADER_REMAINING) {
147
+ const v = getHeader(headers, h)
148
+ const n = numberOrNull(v)
149
+ if (n != null) { remaining = n; break }
150
+ }
151
+ for (const h of HEADER_LIMIT) {
152
+ const v = getHeader(headers, h)
153
+ const n = numberOrNull(v)
154
+ if (n != null) { limit = n; break }
155
+ }
156
+ for (const h of HEADER_RESET) {
157
+ const v = getHeader(headers, h)
158
+ if (v != null && String(v) !== "") {
159
+ const t = resetAtFromValue(v, nowMs)
160
+ if (t != null) { resetAt = t; break }
161
+ const n = numberOrNull(v)
162
+ if (n != null) { resetAt = n > 1e9 ? (n>1e12?n:n*1000) : nowMs + n*1000; break }
163
+ }
164
+ }
165
+ if (remaining==null && limit==null && resetAt==null) return null
166
+ return { remaining, limit, resetAt }
167
+ }
168
+
169
+ export function parseBody(provider, json, nowMs = Date.now()) {
170
+ if (!json || typeof json !== "object") return null
171
+ const table = PROVIDER_BODY_FIELDS[provider] || PROVIDER_BODY_FIELDS._common
172
+ const common = PROVIDER_BODY_FIELDS._common
173
+ const rk = [...(table.remaining||[]), ...common.remaining]
174
+ const lk = [...(table.limit||[]), ...common.limit]
175
+ const sk = [...(table.reset||[]), ...common.reset]
176
+ const rawRem = deepest(json, [...new Set(rk)])
177
+ const rawLim = deepest(json, [...new Set(lk)])
178
+ const rawReset = deepest(json, [...new Set(sk)])
179
+ const remaining = numberOrNull(rawRem)
180
+ const limit = numberOrNull(rawLim)
181
+ let resetAt = null
182
+ if (rawReset != null) resetAt = resetAtFromValue(rawReset, nowMs)
183
+ if (remaining==null && limit==null && resetAt==null) return null
184
+ return { remaining, limit, resetAt }
185
+ }
186
+
187
+ export function mergeQuota(headerPart, bodyPart) {
188
+ if (!headerPart && !bodyPart) return null
189
+ const remaining = headerPart?.remaining ?? bodyPart?.remaining ?? null
190
+ const limit = headerPart?.limit ?? bodyPart?.limit ?? null
191
+ const resetAt = headerPart?.resetAt ?? bodyPart?.resetAt ?? null
192
+ if (remaining==null && limit==null && resetAt==null) return null
193
+ return { remaining, limit, resetAt }
194
+ }
195
+
196
+ export function quotaSnapshot(provider, headers, bodyJson, nowMs = Date.now()) {
197
+ const h = parseHeaders(headers, nowMs)
198
+ const b = bodyJson ? parseBody(provider, bodyJson, nowMs) : null
199
+ const merged = mergeQuota(h, b)
200
+ if (!merged) return null
201
+ const { remaining, limit, resetAt } = merged
202
+ let usedPercent = null
203
+ if (remaining!=null && limit!=null && limit>0) {
204
+ usedPercent = ((limit - remaining) / limit) * 100
205
+ if (usedPercent < 0) usedPercent = 0
206
+ if (usedPercent > 100) usedPercent = 100
207
+ }
208
+ return { remaining, limit, resetAt: resetAt ?? null, usedPercent, measuredAt: nowMs }
209
+ }
210
+
211
+ export function formatQuota(q) {
212
+ if (!q) return ""
213
+ const parts = []
214
+ if (q.remaining!=null && q.limit!=null) parts.push(q.remaining+"/"+q.limit)
215
+ else if (q.remaining!=null) parts.push("осталось "+q.remaining)
216
+ if (q.resetAt) parts.push("сброс "+new Date(q.resetAt).toLocaleString())
217
+ return parts.join(" · ")
218
+ }
package/lib/rotate.js CHANGED
@@ -1,26 +1,56 @@
1
1
  const SWITCH_CODES = new Set([
2
- 'RATE_LIMIT',
3
- 'QUOTA',
4
- 'QUOTA_EXCEEDED',
2
+ "RATE_LIMIT",
3
+ "QUOTA",
4
+ "QUOTA_EXCEEDED",
5
5
  ])
6
6
 
7
7
  export function isSwitchableError(err) {
8
- if (!err || typeof err !== 'object') return false
8
+ if (!err || typeof err !== "object") return false
9
9
  const code = err.code || (err.failure && err.failure.code)
10
10
  if (SWITCH_CODES.has(code)) return true
11
11
  const status = err.status || err.statusCode
12
12
  return status === 429
13
13
  }
14
14
 
15
- export function pickAccount(accounts, nowMs) {
15
+ export function pickAccount(accounts, nowMs, opts) {
16
16
  const list = Array.isArray(accounts) ? accounts : []
17
17
  const now = Number(nowMs) || 0
18
- for (const account of list) {
19
- if (!account || !account.hasToken) continue
20
- if (account.usagePercent != null && Number(account.usagePercent) >= 100) continue
21
- if (account.cooldownUntil && Number(account.cooldownUntil) > now) continue
22
- return account
18
+ const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
19
+ const thr = Number.isFinite(thrRaw) ? thrRaw : 0
20
+ function isQuotaExhausted(acc) {
21
+ if (!thr || thr <= 0) return false
22
+ const q = acc.quota
23
+ if (!q) return false
24
+ if (q.resetAt && Number(q.resetAt) <= now) return false
25
+ if (q.remaining == null) return false
26
+ if (q.limit != null && q.limit > 0 && thr > 0 && thr < 1) {
27
+ const frac = q.remaining / q.limit
28
+ return frac <= thr
29
+ }
30
+ return q.remaining <= thr
23
31
  }
32
+ function isUsageExhausted(acc) {
33
+ if (acc.usagePercent == null || Number(acc.usagePercent) < 100) return false
34
+ const q = acc.quota
35
+ if (q && q.resetAt && Number(q.resetAt) <= now) return false
36
+ return true
37
+ }
38
+ const tiers = [[], [], []]
39
+ const fallback = []
40
+ for (const acc of list) {
41
+ if (!acc || !acc.hasToken) continue
42
+ const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now
43
+ const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
44
+ if (isCooldown || exhausted) {
45
+ tiers[2].push(acc)
46
+ fallback.push(acc)
47
+ continue
48
+ }
49
+ if (!acc.quota) tiers[1].push(acc)
50
+ else tiers[0].push(acc)
51
+ }
52
+ for (const tier of tiers) if (tier.length) return tier[0]
53
+ if (fallback.length) return fallback[0]
24
54
  return null
25
55
  }
26
56
 
@@ -4,6 +4,7 @@ export async function* streamWithRotation({
4
4
  accounts,
5
5
  nowMs,
6
6
  cooldownMs,
7
+ switchAtRemaining,
7
8
  streamOnce,
8
9
  options,
9
10
  onCooldown,
@@ -12,7 +13,7 @@ export async function* streamWithRotation({
12
13
  let lastError = null
13
14
  const tried = new Set()
14
15
  while (true) {
15
- const account = pickAccount(pool, nowMs())
16
+ const account = pickAccount(pool, nowMs(), { switchAtRemaining })
16
17
  if (!account) {
17
18
  if (lastError) throw lastError
18
19
  const err = new Error('no usable subscription account for this provider')
@@ -0,0 +1,174 @@
1
+ import { isProvider } from "./refs.js"
2
+ import { pickAccount, markCooldown, isSwitchableError } from "./rotate.js"
3
+ import { quotaSnapshot } from "./ratelimit.js"
4
+ import { getVendor } from "./vendors/index.js"
5
+
6
+ // ponytail: allowlist per provider — add path to extend without touching request logic
7
+ export const ALLOWLIST = {
8
+ codex: ["/responses", "/models", "/images/generations", "/backend-api/codex/responses", "/backend-api/codex/models", "/backend-api/codex/images/generations"],
9
+ claude: ["/v1/messages", "/api/oauth/profile", "/api/oauth/usage", "/v1/models"],
10
+ grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations"],
11
+ antigravity: ["/v1/models", "/v1/generateContent", "/v1/streamGenerateContent", "/v1/loadCodeAssist", "/v1/fetchAvailableModels", "/v1/countTokens", "/v1/internal:loadCodeAssist"],
12
+ }
13
+
14
+ export function isAllowed(provider, path) {
15
+ if (!isProvider(provider)) return false
16
+ const list = ALLOWLIST[provider] || []
17
+ let p = String(path || "")
18
+ // strip query and hash
19
+ p = p.split("?")[0].split("#")[0]
20
+ try {
21
+ if (p.startsWith("http://") || p.startsWith("https://")) p = new URL(p).pathname
22
+ } catch {}
23
+ if (!p.startsWith("/")) p = "/" + p
24
+ for (const allowed of list) {
25
+ const a = allowed.split("?")[0]
26
+ if (p === a) return true
27
+ if (p.startsWith(a + "/")) return true
28
+ if (p.startsWith(a + "?")) return true
29
+ }
30
+ return false
31
+ }
32
+
33
+ function baseFor(provider, cfg) {
34
+ if (provider === "codex") return (cfg.baseUrl || "https://chatgpt.com/backend-api/codex").replace(/\/$/, "")
35
+ if (provider === "grok") return (cfg.baseUrl || "https://api.x.ai/v1").replace(/\/$/, "")
36
+ if (provider === "claude") return "https://api.anthropic.com"
37
+ if (provider === "antigravity") return "https://cloudcode-pa.googleapis.com"
38
+ return (cfg.baseUrl || "").replace(/\/$/, "")
39
+ }
40
+
41
+ function headersFor(provider, blob, cfg) {
42
+ const h = {}
43
+ if (provider === "codex" && blob.accountId) {
44
+ h["chatgpt-account-id"] = blob.accountId
45
+ h["ChatGPT-Account-ID"] = blob.accountId
46
+ h["originator"] = cfg.originator || "codex_cli_rs"
47
+ }
48
+ if (provider === "grok" && String(cfg.baseUrl || "").includes("grok.com")) {
49
+ h["X-XAI-Token-Auth"] = "xai-grok-cli"
50
+ h["x-grok-client-identifier"] = "grok-shell"
51
+ h["x-grok-client-version"] = cfg.clientVersion || "0.2.103"
52
+ }
53
+ if (provider === "antigravity" && blob.projectId) {
54
+ h["x-goog-user-project"] = blob.projectId
55
+ }
56
+ return h
57
+ }
58
+
59
+ export function createSubscriptionsService(deps) {
60
+ return {
61
+ async available(provider) {
62
+ if (provider) {
63
+ if (!isProvider(provider)) return false
64
+ const accs = await deps.listAccounts(provider)
65
+ return accs.some((a) => a.hasToken)
66
+ }
67
+ const providers = ["codex", "claude", "grok", "antigravity"]
68
+ const out = []
69
+ for (const p of providers) {
70
+ const accs = await deps.listAccounts(p)
71
+ if (accs.some((a) => a.hasToken)) out.push(p)
72
+ }
73
+ return out
74
+ },
75
+ async request({ provider, path, method = "GET", headers = {}, body, signal }) {
76
+ if (!isProvider(provider)) {
77
+ const err = new Error("unknown provider: " + provider)
78
+ err.code = "PROVIDER"
79
+ throw err
80
+ }
81
+ if (!isAllowed(provider, path)) {
82
+ const err = new Error("path not allowed for " + provider + ": " + path)
83
+ err.code = "FORBIDDEN"
84
+ throw err
85
+ }
86
+ const list = await deps.listAccounts(provider)
87
+ const thr = typeof deps.switchAtRemaining === "function" ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0)
88
+ const cooldownMs = typeof deps.cooldownMs === "function" ? deps.cooldownMs() : (deps.cooldownMs ?? 30 * 60 * 1000)
89
+ // copy pool for rotation
90
+ const pool = list.map((a) => ({ ...a }))
91
+ const tried = new Set()
92
+ let lastError = null
93
+ while (true) {
94
+ const account = pickAccount(pool, Date.now(), { switchAtRemaining: thr })
95
+ if (!account) {
96
+ if (lastError) throw lastError
97
+ const err = new Error("no usable subscription account for this provider")
98
+ err.code = "AUTH"
99
+ throw err
100
+ }
101
+ if (tried.has(account.ref)) {
102
+ if (lastError) throw lastError
103
+ const err = new Error("all subscription accounts failed")
104
+ err.code = "RATE_LIMIT"
105
+ throw err
106
+ }
107
+ tried.add(account.ref)
108
+ try {
109
+ const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
110
+ const cfg = deps.vendorConfig(provider)
111
+ const base = baseFor(provider, cfg)
112
+ // normalize path
113
+ let p = String(path)
114
+ if (p.startsWith("http://") || p.startsWith("https://")) {
115
+ // full URL, use as is
116
+ } else {
117
+ if (!p.startsWith("/")) p = "/" + p
118
+ // for codex, path /responses should become base + /responses
119
+ // for claude, path /v1/messages with base https://api.anthropic.com => https://api.anthropic.com/v1/messages
120
+ p = base + p
121
+ }
122
+ const url = p
123
+ const extraHeaders = headersFor(provider, blob, cfg)
124
+ const fetchImpl = deps.fetchImpl || fetch
125
+ const res = await fetchImpl(url, {
126
+ method,
127
+ headers: {
128
+ Authorization: "Bearer " + blob.accessToken,
129
+ Accept: "application/json",
130
+ ...extraHeaders,
131
+ ...headers,
132
+ ...(body && typeof body === "object" && !(body instanceof Uint8Array) && !(typeof body === "string") ? { "Content-Type": "application/json" } : {}),
133
+ },
134
+ body: body && typeof body === "object" && !(body instanceof Uint8Array) && typeof body !== "string" ? JSON.stringify(body) : body,
135
+ signal,
136
+ })
137
+ // capture quota
138
+ try {
139
+ const snap = quotaSnapshot(provider, res.headers, null, Date.now())
140
+ if (snap && typeof deps.rememberQuota === "function") deps.rememberQuota(account.ref, snap)
141
+ } catch {}
142
+ if (!res.ok && res.clone) {
143
+ try {
144
+ const txt = await res.clone().text()
145
+ let j = null
146
+ try { j = JSON.parse(txt) } catch {}
147
+ if (j) {
148
+ const snap2 = quotaSnapshot(provider, res.headers, j, Date.now())
149
+ if (snap2 && typeof deps.rememberQuota === "function") deps.rememberQuota(account.ref, snap2)
150
+ }
151
+ } catch {}
152
+ }
153
+ if (!res.ok) {
154
+ const txt = await res.text().catch(() => "")
155
+ const err = new Error("vendor http " + res.status + (txt ? ": " + txt.slice(0, 200) : ""))
156
+ err.status = res.status
157
+ if (res.status === 429) err.code = "RATE_LIMIT"
158
+ else if (res.status === 402) err.code = "QUOTA"
159
+ else err.code = "VENDOR"
160
+ throw err
161
+ }
162
+ return res
163
+ } catch (err) {
164
+ lastError = err
165
+ if (!isSwitchableError(err)) throw err
166
+ const cooled = markCooldown(account, Date.now(), cooldownMs)
167
+ account.cooldownUntil = cooled.cooldownUntil
168
+ if (typeof deps.rememberCooldown === "function") deps.rememberCooldown(account.ref, account.cooldownUntil)
169
+ // try next account
170
+ }
171
+ }
172
+ },
173
+ }
174
+ }
@@ -114,6 +114,18 @@ export async function refresh(cfg, blob, fetchImpl) {
114
114
  })
115
115
  }
116
116
 
117
+
118
+ export async function check(blob, cfg, fetchImpl) {
119
+ const impl = fetchImpl || fetch
120
+ try {
121
+ const models = await fetchAvailableModels(impl, blob.accessToken, headersFor(blob.projectId || ""), cfg.models || defaults().models, id)
122
+ return { ok: true, raw: { models } }
123
+ } catch (e) {
124
+ // fallback to quota check
125
+ throw e
126
+ }
127
+ }
128
+
117
129
  export async function listModels(blob, cfg, fetchImpl) {
118
130
  const fallback = cfg.models || defaults().models
119
131
  return fetchAvailableModels(fetchImpl || fetch, blob.accessToken, headersFor(blob.projectId || ''), fallback, id)
@@ -91,6 +91,15 @@ export async function listModels(blob, cfg) {
91
91
  return modelCatalog(id, cfg.models || defaults().models)
92
92
  }
93
93
 
94
+
95
+ export async function check(blob, _cfg, fetchImpl) {
96
+ const impl = fetchImpl || fetch
97
+ const res = await impl(PROFILE, { headers: oauthHeaders(blob.accessToken) })
98
+ if (!res.ok) throw httpError(res.status, await res.text())
99
+ const json = await readJson(res)
100
+ return { ok: true, raw: json }
101
+ }
102
+
94
103
  export async function usage(blob, _cfg, fetchImpl) {
95
104
  try {
96
105
  const res = await (fetchImpl || fetch)(USAGE, { headers: oauthHeaders(blob.accessToken) })
@@ -130,6 +130,20 @@ export async function listModels(blob, cfg, fetchImpl) {
130
130
  return catalog
131
131
  }
132
132
 
133
+
134
+ export async function check(blob, cfg, fetchImpl) {
135
+ const base = (cfg.baseUrl || defaults().baseUrl).replace(/\/$/, "")
136
+ const version = cfg.clientVersion || defaults().clientVersion
137
+ const impl = fetchImpl || fetch
138
+ const res = await impl(base + "/models?client_version=" + encodeURIComponent(version), {
139
+ headers: identityHeaders(blob, cfg, { Accept: "application/json" }),
140
+ })
141
+ if (!res.ok) throw httpError(res.status, await res.text())
142
+ const json = await readJson(res)
143
+ // quota headers may contain limits
144
+ return { ok: true, raw: json }
145
+ }
146
+
133
147
  export async function usage(blob, cfg, fetchImpl) {
134
148
  try {
135
149
  const res = await (fetchImpl || fetch)(USAGE, {
@@ -186,6 +186,15 @@ export async function listModels(blob, cfg, fetchImpl) {
186
186
  }
187
187
  }
188
188
 
189
+
190
+ export async function check(blob, cfg, fetchImpl) {
191
+ const impl = fetchImpl || fetch
192
+ const res = await impl(MODELS, { headers: { Authorization: "Bearer " + blob.accessToken, Accept: "application/json" } })
193
+ if (!res.ok) throw httpError(res.status, await res.text())
194
+ const json = await readJson(res)
195
+ return { ok: true, raw: json }
196
+ }
197
+
189
198
  export async function usage(blob, cfg, fetchImpl) {
190
199
  try {
191
200
  const res = await (fetchImpl || fetch)(BILLING, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",