@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 +35 -11
- package/lib/adapter.js +25 -1
- package/lib/client.js +132 -29
- 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
|
@@ -35,19 +35,82 @@ window.__ModuleLoader__.load({
|
|
|
35
35
|
document.head.appendChild(tag)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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' }, '
|
|
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' }, '
|
|
222
|
+
React.createElement('div', { className: 'dsub-h' }, t('title')),
|
|
141
223
|
React.createElement('div', { className: 'dsub-sub' },
|
|
142
|
-
'
|
|
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, '
|
|
231
|
+
React.createElement('span', null, t('useOrigin')),
|
|
150
232
|
),
|
|
151
233
|
React.createElement('div', { className: 'dsub-sub' },
|
|
152
|
-
'
|
|
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 || ('
|
|
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 ? '
|
|
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
|
-
}, '
|
|
267
|
+
}, t('disconnect')),
|
|
186
268
|
React.createElement('button', {
|
|
187
|
-
type: 'button', className: 'dsub-mini',
|
|
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: '
|
|
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
|
-
}, '
|
|
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
|
-
'
|
|
211
|
-
React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, '
|
|
212
|
-
'
|
|
299
|
+
t('verifyPrefix'),
|
|
300
|
+
React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
|
|
301
|
+
t('verifySuffix'),
|
|
213
302
|
) : null,
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}, '
|
|
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
|
-
}, '
|
|
229
|
-
saved ? React.createElement('span', { className: 'dsub-ok' }, '
|
|
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
|
-
{
|
|
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({
|
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