@goodandready/dsh-subscriptions 0.2.1 → 0.2.3

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
@@ -109,6 +109,8 @@ window.__ModuleLoader__.load({
109
109
  const [accounts, setAccounts] = React.useState([])
110
110
  const [providers, setProviders] = React.useState([])
111
111
  const [paste, setPaste] = React.useState({})
112
+ const [checkRes, setCheckRes] = React.useState({})
113
+ const [checking, setChecking] = React.useState({})
112
114
  const [saved, setSaved] = React.useState(false)
113
115
  const [err, setErr] = React.useState('')
114
116
 
@@ -170,6 +172,25 @@ window.__ModuleLoader__.load({
170
172
  setPaste((p) => Object.assign({}, p, { [key]: '' }))
171
173
  }
172
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
+
173
194
  const logout = async (provider, index) => {
174
195
  setErr('')
175
196
  const res = await fetch('/dsh-subscriptions/logout', {
@@ -244,6 +265,11 @@ window.__ModuleLoader__.load({
244
265
  disabled: !on,
245
266
  onClick: () => logout(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
246
267
  }, t('disconnect')),
268
+ React.createElement('button', {
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'),
247
273
  React.createElement('button', {
248
274
  type: 'button', className: 'dsub-mini', title: t('removeSlot'),
249
275
  onClick: () => {
@@ -267,12 +293,16 @@ window.__ModuleLoader__.load({
267
293
  }, t('submitCode')),
268
294
  ),
269
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) })(),
270
298
  account.validationUrl ? React.createElement('div', { className: 'dsub-verify' },
271
299
  t('verifyPrefix'),
272
300
  React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
273
301
  t('verifySuffix'),
274
302
  ) : null,
275
- account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : 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,
276
306
  account.ref ? React.createElement('span', { className: 'dsub-sub' }, t('storedAs') + ' ' + account.ref) : null,
277
307
  )
278
308
  }),
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 = setInterval
332
+ const clear = clearInterval
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.1",
3
+ "version": "0.2.3",
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",