@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 +35 -11
- package/lib/adapter.js +25 -1
- package/lib/client.js +31 -1
- package/lib/index.js +61 -1
- package/lib/ratelimit.js +218 -0
- package/lib/rotate.js +40 -10
- package/lib/stream-rotate.js +2 -1
- package/lib/subscriptions.js +174 -0
- package/lib/vendors/antigravity.js +12 -0
- package/lib/vendors/claude.js +9 -0
- package/lib/vendors/codex.js +14 -0
- package/lib/vendors/grok.js +9 -0
- package/package.json +1 -1
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
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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:
|
|
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
|
-
|
|
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({
|
package/lib/ratelimit.js
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
"RATE_LIMIT",
|
|
3
|
+
"QUOTA",
|
|
4
|
+
"QUOTA_EXCEEDED",
|
|
5
5
|
])
|
|
6
6
|
|
|
7
7
|
export function isSwitchableError(err) {
|
|
8
|
-
if (!err || typeof err !==
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
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
|
|
package/lib/stream-rotate.js
CHANGED
|
@@ -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)
|
package/lib/vendors/claude.js
CHANGED
|
@@ -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) })
|
package/lib/vendors/codex.js
CHANGED
|
@@ -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, {
|
package/lib/vendors/grok.js
CHANGED
|
@@ -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