@goodandready/dsh-subscriptions 0.5.6 → 0.5.22
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/adapter.js +2 -0
- package/lib/atomic-lock.js +35 -0
- package/lib/backoff.js +26 -0
- package/lib/client.js +211 -15
- package/lib/coalesce-stream.js +22 -0
- package/lib/code-assist.js +4 -4
- package/lib/cookie-bridge.js +8 -0
- package/lib/credit-detect.js +22 -0
- package/lib/encrypted-bundle.js +46 -0
- package/lib/import-auth.js +74 -7
- package/lib/importers/coding-agents.js +37 -0
- package/lib/index.js +35 -7
- package/lib/latency-heatmap.js +26 -0
- package/lib/latency-score.js +36 -0
- package/lib/multi-tenant.js +16 -0
- package/lib/network-benchmark.js +23 -0
- package/lib/pkce-store.js +28 -0
- package/lib/preflight-tokens.js +23 -0
- package/lib/proactive-refresh.js +37 -0
- package/lib/prompt-cache-warmer.js +44 -0
- package/lib/quarantine.js +38 -0
- package/lib/ratelimit-parser.js +24 -0
- package/lib/reconnect-stream.js +21 -0
- package/lib/reset-toast.js +38 -0
- package/lib/revocation.js +25 -0
- package/lib/rotate.js +70 -17
- package/lib/savings-calculator.js +14 -0
- package/lib/session-pin.js +29 -0
- package/lib/state-recovery.js +33 -0
- package/lib/stream-rotate.js +34 -11
- package/lib/telemetry.js +28 -0
- package/lib/token-speedometer.js +26 -0
- package/lib/vendors/antigravity.js +16 -3
- package/lib/vendors/claude-cli.js +24 -0
- package/lib/vendors/cody.js +72 -0
- package/lib/vendors/copilot.js +194 -0
- package/lib/vendors/cursor.js +4 -4
- package/lib/vendors/ernie.js +93 -0
- package/lib/vendors/glm.js +4 -4
- package/lib/vendors/index.js +26 -1
- package/lib/vendors/jetbrains.js +79 -0
- package/lib/vendors/kimi.js +4 -4
- package/lib/vendors/kiro.js +3 -3
- package/lib/vendors/perplexity.js +79 -0
- package/lib/vendors/qwen.js +89 -0
- package/lib/vendors/replit.js +72 -0
- package/lib/vendors/spark.js +77 -0
- package/lib/wire.js +4 -2
- package/lib/zero-trace-logger.js +27 -0
- package/package.json +2 -5
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export async function discoverCodingAgentConfigs() {
|
|
6
|
+
const configs = {}
|
|
7
|
+
|
|
8
|
+
// 1. Aider (.aider.conf.yml)
|
|
9
|
+
try {
|
|
10
|
+
const p = join(homedir(), '.aider.conf.yml')
|
|
11
|
+
const text = await readFile(p, 'utf8')
|
|
12
|
+
configs.aider = {
|
|
13
|
+
name: 'Aider Config',
|
|
14
|
+
path: p,
|
|
15
|
+
found: true,
|
|
16
|
+
raw: text
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// not present
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 2. Roo-Code / Roo-Cline settings
|
|
23
|
+
try {
|
|
24
|
+
const p = join(homedir(), '.roo-code', 'settings.json')
|
|
25
|
+
const text = await readFile(p, 'utf8')
|
|
26
|
+
configs.roocode = {
|
|
27
|
+
name: 'Roo-Code Config',
|
|
28
|
+
path: p,
|
|
29
|
+
found: true,
|
|
30
|
+
raw: text
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// not present
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return configs
|
|
37
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -375,7 +375,7 @@ export function apply(ctx, config) {
|
|
|
375
375
|
verifier: row.verifier,
|
|
376
376
|
challenge: row.challenge,
|
|
377
377
|
state: row.state,
|
|
378
|
-
}, code, fetchForRef(ref))
|
|
378
|
+
}, code, fetchForRef(ref) || fetch)
|
|
379
379
|
const slots = normalizeSlots(live().slots)
|
|
380
380
|
const slot = slots.find((s) => s.ref === ref)
|
|
381
381
|
if (slot && slot.label) blob.label = slot.label
|
|
@@ -925,7 +925,7 @@ export function apply(ctx, config) {
|
|
|
925
925
|
}
|
|
926
926
|
try {
|
|
927
927
|
const cfg = vendorConfig(provider, live())
|
|
928
|
-
const start = await vendor.deviceStart(cfg, fetchForRef(oauthRef(provider, index)))
|
|
928
|
+
const start = await vendor.deviceStart(cfg, (fetchForRef(oauthRef(provider, index)) || fetch))
|
|
929
929
|
const state = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
|
930
930
|
sweepPending(Date.now())
|
|
931
931
|
pending.set(state, {
|
|
@@ -974,7 +974,7 @@ export function apply(ctx, config) {
|
|
|
974
974
|
const vendor = getVendor(row.provider)
|
|
975
975
|
try {
|
|
976
976
|
const cfg = vendorConfig(row.provider, live())
|
|
977
|
-
const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, fetchForRef(row.ref))
|
|
977
|
+
const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, (fetchForRef(row.ref) || fetch))
|
|
978
978
|
if (out.status !== 'authorized') {
|
|
979
979
|
writeJson(res, 200, { ok: true, status: out.status })
|
|
980
980
|
return
|
|
@@ -1154,17 +1154,45 @@ export function apply(ctx, config) {
|
|
|
1154
1154
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1155
1155
|
return
|
|
1156
1156
|
}
|
|
1157
|
-
|
|
1157
|
+
let body
|
|
1158
|
+
try {
|
|
1159
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}')
|
|
1160
|
+
} catch {
|
|
1161
|
+
body = null
|
|
1162
|
+
}
|
|
1158
1163
|
if (!body || !body.provider) {
|
|
1159
1164
|
writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'missing provider' } })
|
|
1160
1165
|
return
|
|
1161
1166
|
}
|
|
1162
1167
|
try {
|
|
1163
1168
|
const blob = await loadLocalCliBlob(body.provider)
|
|
1164
|
-
const
|
|
1165
|
-
const
|
|
1169
|
+
const prov = body.provider
|
|
1170
|
+
const idx = Number(body.index) || 1
|
|
1171
|
+
const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
|
|
1172
|
+
const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
|
|
1173
|
+
if (!exists && settingsApi) {
|
|
1174
|
+
const nextSlots = curSlots.concat([{
|
|
1175
|
+
provider: prov,
|
|
1176
|
+
index: idx,
|
|
1177
|
+
label: blob.email || '',
|
|
1178
|
+
}])
|
|
1179
|
+
const parsed = Config({ ...live(), slots: stripLegacySlots(nextSlots) })
|
|
1180
|
+
await settingsApi.replace(parsed)
|
|
1181
|
+
syncCustomVendors()
|
|
1182
|
+
}
|
|
1183
|
+
const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
|
|
1184
|
+
const ref = slot ? slot.ref : `${prov.toUpperCase()}_OAUTH_${idx}`
|
|
1166
1185
|
await store.saveBlob(ref, blob)
|
|
1167
|
-
|
|
1186
|
+
await syncAdapter()
|
|
1187
|
+
refreshModels().catch(() => {})
|
|
1188
|
+
writeJson(res, 200, {
|
|
1189
|
+
ok: true,
|
|
1190
|
+
ref,
|
|
1191
|
+
provider: prov,
|
|
1192
|
+
email: blob.email || '',
|
|
1193
|
+
accounts: await accountsView(),
|
|
1194
|
+
config: publicConfig(live()),
|
|
1195
|
+
})
|
|
1168
1196
|
} catch (e) {
|
|
1169
1197
|
writeJson(res, 400, { ok: false, error: { message: String(e && e.message || e) } })
|
|
1170
1198
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class LatencyHeatmap {
|
|
2
|
+
constructor() {
|
|
3
|
+
// 24-hour slots: map provider -> array of 24 hourly averages
|
|
4
|
+
this.matrix = new Map()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
recordHourlyPing(provider, hourIndex, latencyMs) {
|
|
8
|
+
const p = provider || 'unknown'
|
|
9
|
+
if (!this.matrix.has(p)) {
|
|
10
|
+
this.matrix.set(p, new Array(24).fill(null))
|
|
11
|
+
}
|
|
12
|
+
const arr = this.matrix.get(p)
|
|
13
|
+
const idx = Math.max(0, Math.min(23, hourIndex))
|
|
14
|
+
arr[idx] = latencyMs
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getHeatmap(provider) {
|
|
18
|
+
const arr = this.matrix.get(provider) || new Array(24).fill(null)
|
|
19
|
+
return arr.map((latency) => {
|
|
20
|
+
if (latency == null) return { latency: null, color: 'gray' }
|
|
21
|
+
if (latency < 400) return { latency, color: 'green' }
|
|
22
|
+
if (latency < 1200) return { latency, color: 'yellow' }
|
|
23
|
+
return { latency, color: 'red' }
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class LatencyTracker {
|
|
2
|
+
constructor(sampleLimit = 10) {
|
|
3
|
+
this.samples = new Map()
|
|
4
|
+
this.limit = sampleLimit
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
record(slotRef, latencyMs) {
|
|
8
|
+
if (!slotRef || typeof latencyMs !== 'number' || latencyMs < 0) return
|
|
9
|
+
if (!this.samples.has(slotRef)) {
|
|
10
|
+
this.samples.set(slotRef, [])
|
|
11
|
+
}
|
|
12
|
+
const arr = this.samples.get(slotRef)
|
|
13
|
+
arr.push(latencyMs)
|
|
14
|
+
if (arr.length > this.limit) arr.shift()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getAverage(slotRef) {
|
|
18
|
+
const arr = this.samples.get(slotRef)
|
|
19
|
+
if (!arr || !arr.length) return null
|
|
20
|
+
const sum = arr.reduce((acc, v) => acc + v, 0)
|
|
21
|
+
return Math.round(sum / arr.length)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getHealthScore(slotRef) {
|
|
25
|
+
const avg = this.getAverage(slotRef)
|
|
26
|
+
if (avg == null) return 100
|
|
27
|
+
if (avg < 300) return 100
|
|
28
|
+
if (avg < 800) return 85
|
|
29
|
+
if (avg < 2000) return 60
|
|
30
|
+
return 30
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clear() {
|
|
34
|
+
this.samples.clear()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function filterAccountsForUser(accounts, userContext) {
|
|
2
|
+
const list = Array.isArray(accounts) ? accounts : []
|
|
3
|
+
if (!userContext || userContext.isAdmin) {
|
|
4
|
+
return list // Admin sees all
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const username = userContext.username || userContext.userId || userContext.sub
|
|
8
|
+
if (!username) return []
|
|
9
|
+
|
|
10
|
+
return list.filter((acc) => {
|
|
11
|
+
if (!acc.allowedUsers || !Array.isArray(acc.allowedUsers)) {
|
|
12
|
+
return true // Public in pool
|
|
13
|
+
}
|
|
14
|
+
return acc.allowedUsers.includes(username)
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export async function benchmarkEndpoint(url, fetchImpl) {
|
|
2
|
+
const impl = fetchImpl || fetch
|
|
3
|
+
const start = Date.now()
|
|
4
|
+
try {
|
|
5
|
+
const res = await impl(url, { method: 'HEAD', signal: AbortSignal.timeout(4000) })
|
|
6
|
+
const ttfb = Date.now() - start
|
|
7
|
+
return {
|
|
8
|
+
url,
|
|
9
|
+
ok: res.ok,
|
|
10
|
+
status: res.status,
|
|
11
|
+
latencyMs: ttfb,
|
|
12
|
+
grade: ttfb < 300 ? 'A' : (ttfb < 800 ? 'B' : 'C')
|
|
13
|
+
}
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return {
|
|
16
|
+
url,
|
|
17
|
+
ok: false,
|
|
18
|
+
error: err.message,
|
|
19
|
+
latencyMs: Date.now() - start,
|
|
20
|
+
grade: 'F'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const pkceStates = new Map()
|
|
2
|
+
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
|
3
|
+
|
|
4
|
+
export function storePkceState(state, verifier) {
|
|
5
|
+
if (!state || !verifier) return
|
|
6
|
+
pkceStates.set(state, {
|
|
7
|
+
verifier,
|
|
8
|
+
createdAt: Date.now()
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function consumePkceState(state) {
|
|
13
|
+
if (!state) return null
|
|
14
|
+
const entry = pkceStates.get(state)
|
|
15
|
+
if (!entry) return null
|
|
16
|
+
|
|
17
|
+
// Single-use consumption (prevent INVALID_REPLAY_STATE)
|
|
18
|
+
pkceStates.delete(state)
|
|
19
|
+
|
|
20
|
+
if (Date.now() - entry.createdAt > STATE_TTL_MS) {
|
|
21
|
+
return null // expired
|
|
22
|
+
}
|
|
23
|
+
return entry.verifier
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearPkceStates() {
|
|
27
|
+
pkceStates.clear()
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function estimateTokens(text) {
|
|
2
|
+
if (!text || typeof text !== 'string') return 0
|
|
3
|
+
// Average English: ~4 chars per token; CJK/Code: ~1.5 - 2 chars per token
|
|
4
|
+
const hasCjk = /[\u4e00-\u9fa5]/.test(text)
|
|
5
|
+
const ratio = hasCjk ? 2 : 3.8
|
|
6
|
+
return Math.max(1, Math.ceil(text.length / ratio))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateContextLimit(messages, contextWindowLimit = 128000) {
|
|
10
|
+
let totalChars = 0
|
|
11
|
+
for (const m of messages || []) {
|
|
12
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')
|
|
13
|
+
totalChars += content.length
|
|
14
|
+
}
|
|
15
|
+
const estimated = estimateTokens(totalChars > 0 ? 'x'.repeat(totalChars) : '')
|
|
16
|
+
const safe = estimated <= contextWindowLimit * 0.95
|
|
17
|
+
return {
|
|
18
|
+
estimatedTokens: estimated,
|
|
19
|
+
limit: contextWindowLimit,
|
|
20
|
+
isSafe: safe,
|
|
21
|
+
marginTokens: Math.max(0, Math.floor(contextWindowLimit * 0.95 - estimated))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class ProactiveTokenRefreshDaemon {
|
|
2
|
+
constructor({ refreshLeadMs = 15 * 60 * 1000, checkIntervalMs = 60 * 1000 } = {}) {
|
|
3
|
+
this.refreshLeadMs = refreshLeadMs // refresh 15 min before token expires
|
|
4
|
+
this.checkIntervalMs = checkIntervalMs
|
|
5
|
+
this.timer = null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
start(getAccountsFn, refreshFn) {
|
|
9
|
+
if (this.timer) return
|
|
10
|
+
this.timer = setInterval(async () => {
|
|
11
|
+
try {
|
|
12
|
+
const accounts = (typeof getAccountsFn === 'function' && await getAccountsFn()) || []
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
for (const acc of accounts) {
|
|
15
|
+
if (!acc || !acc.expiresAt || !acc.refreshToken) continue
|
|
16
|
+
const expiresAt = Number(acc.expiresAt)
|
|
17
|
+
if (expiresAt - now <= this.refreshLeadMs && expiresAt > now) {
|
|
18
|
+
if (typeof refreshFn === 'function') {
|
|
19
|
+
await refreshFn(acc)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// silent catch on daemon tick
|
|
25
|
+
}
|
|
26
|
+
}, this.checkIntervalMs)
|
|
27
|
+
|
|
28
|
+
if (this.timer.unref) this.timer.unref()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
stop() {
|
|
32
|
+
if (this.timer) {
|
|
33
|
+
clearInterval(this.timer)
|
|
34
|
+
this.timer = null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const warmedSlots = new Map()
|
|
2
|
+
|
|
3
|
+
export class PromptCacheWarmer {
|
|
4
|
+
constructor(ttlMs = 4.5 * 60 * 1000) {
|
|
5
|
+
this.ttlMs = ttlMs // default warm-up every 4.5 min before 5 min Anthropic cache expires
|
|
6
|
+
this.activeTimers = new Map()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
touchSession(sessionId, slotRef, warmFn) {
|
|
10
|
+
if (!sessionId || !slotRef || typeof warmFn !== 'function') return
|
|
11
|
+
if (this.activeTimers.has(sessionId)) {
|
|
12
|
+
clearTimeout(this.activeTimers.get(sessionId))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const timer = setTimeout(async () => {
|
|
16
|
+
try {
|
|
17
|
+
await warmFn(slotRef, sessionId)
|
|
18
|
+
warmedSlots.set(sessionId, Date.now())
|
|
19
|
+
} catch {
|
|
20
|
+
// quiet error on background warm-up
|
|
21
|
+
}
|
|
22
|
+
}, this.ttlMs)
|
|
23
|
+
|
|
24
|
+
if (timer.unref) timer.unref()
|
|
25
|
+
this.activeTimers.set(sessionId, timer)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
cancelSession(sessionId) {
|
|
29
|
+
if (this.activeTimers.has(sessionId)) {
|
|
30
|
+
clearTimeout(this.activeTimers.get(sessionId))
|
|
31
|
+
this.activeTimers.delete(sessionId)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isWarmed(sessionId) {
|
|
36
|
+
return warmedSlots.has(sessionId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear() {
|
|
40
|
+
for (const t of this.activeTimers.values()) clearTimeout(t)
|
|
41
|
+
this.activeTimers.clear()
|
|
42
|
+
warmedSlots.clear()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const REASON_RATE_LIMIT = 'RATE_LIMIT'
|
|
2
|
+
export const REASON_HARD_LIMIT = 'HARD_LIMIT'
|
|
3
|
+
export const REASON_REVOKED = 'TOKEN_REVOKED'
|
|
4
|
+
|
|
5
|
+
export function quarantineDuration(reason) {
|
|
6
|
+
switch (reason) {
|
|
7
|
+
case REASON_HARD_LIMIT:
|
|
8
|
+
return 24 * 60 * 60 * 1000 // 24 hours
|
|
9
|
+
case REASON_REVOKED:
|
|
10
|
+
return 7 * 24 * 60 * 60 * 1000 // 7 days (requires re-auth)
|
|
11
|
+
case REASON_RATE_LIMIT:
|
|
12
|
+
default:
|
|
13
|
+
return 60 * 60 * 1000 // 1 hour
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function putInQuarantine(account, reason, nowMs = Date.now()) {
|
|
18
|
+
const duration = quarantineDuration(reason)
|
|
19
|
+
return {
|
|
20
|
+
...account,
|
|
21
|
+
quarantineReason: reason,
|
|
22
|
+
quarantineUntil: nowMs + duration,
|
|
23
|
+
status: 'quarantine'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isQuarantined(account, nowMs = Date.now()) {
|
|
28
|
+
if (!account || !account.quarantineUntil) return false
|
|
29
|
+
return Number(account.quarantineUntil) > Number(nowMs)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function releaseFromQuarantine(account) {
|
|
33
|
+
const next = { ...account }
|
|
34
|
+
delete next.quarantineReason
|
|
35
|
+
delete next.quarantineUntil
|
|
36
|
+
if (next.status === 'quarantine') next.status = 'active'
|
|
37
|
+
return next
|
|
38
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function parseRateLimitHeaders(headers) {
|
|
2
|
+
if (!headers || typeof headers !== 'object') return null
|
|
3
|
+
|
|
4
|
+
const get = (key) => {
|
|
5
|
+
const k = Object.keys(headers).find((h) => h.toLowerCase() === key.toLowerCase())
|
|
6
|
+
return k ? headers[k] : null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const remainingTokens = get('x-ratelimit-remaining-tokens')
|
|
10
|
+
const resetTokens = get('x-ratelimit-reset-tokens')
|
|
11
|
+
const remainingRequests = get('x-ratelimit-remaining-requests')
|
|
12
|
+
const resetRequests = get('x-ratelimit-reset-requests')
|
|
13
|
+
|
|
14
|
+
if (!remainingTokens && !resetTokens && !remainingRequests && !resetRequests) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
remainingTokens: remainingTokens != null ? Number(remainingTokens) : null,
|
|
20
|
+
resetTokensTime: resetTokens || null,
|
|
21
|
+
remainingRequests: remainingRequests != null ? Number(remainingRequests) : null,
|
|
22
|
+
resetRequestsTime: resetRequests || null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function* streamWithReconnect(streamProducer, { maxReconnects = 2 } = {}) {
|
|
2
|
+
let reconnects = 0
|
|
3
|
+
let emittedCount = 0
|
|
4
|
+
|
|
5
|
+
while (true) {
|
|
6
|
+
try {
|
|
7
|
+
for await (const chunk of streamProducer(emittedCount)) {
|
|
8
|
+
emittedCount++
|
|
9
|
+
yield chunk
|
|
10
|
+
}
|
|
11
|
+
return
|
|
12
|
+
} catch (err) {
|
|
13
|
+
reconnects++
|
|
14
|
+
if (reconnects > maxReconnects) {
|
|
15
|
+
throw err
|
|
16
|
+
}
|
|
17
|
+
// Brief pause before reconnect
|
|
18
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export class ResetNotificationManager {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.scheduledAlerts = new Map()
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
scheduleResetNotice(accountRef, resetAt, onResetAlert) {
|
|
7
|
+
if (!accountRef || !resetAt || resetAt <= Date.now()) return
|
|
8
|
+
if (this.scheduledAlerts.has(accountRef)) {
|
|
9
|
+
clearTimeout(this.scheduledAlerts.get(accountRef))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const delay = Math.max(0, resetAt - Date.now())
|
|
13
|
+
const timer = setTimeout(() => {
|
|
14
|
+
this.scheduledAlerts.delete(accountRef)
|
|
15
|
+
if (typeof onResetAlert === 'function') {
|
|
16
|
+
onResetAlert({
|
|
17
|
+
accountRef,
|
|
18
|
+
message: `Subscription quota reset! Slot ${accountRef} is ready to use.`
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
}, delay)
|
|
22
|
+
|
|
23
|
+
if (timer.unref) timer.unref()
|
|
24
|
+
this.scheduledAlerts.set(accountRef, timer)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cancel(accountRef) {
|
|
28
|
+
if (this.scheduledAlerts.has(accountRef)) {
|
|
29
|
+
clearTimeout(this.scheduledAlerts.get(accountRef))
|
|
30
|
+
this.scheduledAlerts.delete(accountRef)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clear() {
|
|
35
|
+
for (const t of this.scheduledAlerts.values()) clearTimeout(t)
|
|
36
|
+
this.scheduledAlerts.clear()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const REVOKED_ERRORS = new Set([
|
|
2
|
+
'invalid_grant',
|
|
3
|
+
'token_revoked',
|
|
4
|
+
'account_deactivated',
|
|
5
|
+
'session_expired'
|
|
6
|
+
])
|
|
7
|
+
|
|
8
|
+
export function isTokenRevokedError(err) {
|
|
9
|
+
if (!err) return false
|
|
10
|
+
const msg = String(err.message || err.error || err.code || '').toLowerCase()
|
|
11
|
+
for (const pattern of REVOKED_ERRORS) {
|
|
12
|
+
if (msg.includes(pattern)) return true
|
|
13
|
+
}
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function handleAccountRevocation(account) {
|
|
18
|
+
return {
|
|
19
|
+
...account,
|
|
20
|
+
hasToken: false,
|
|
21
|
+
status: 'revoked',
|
|
22
|
+
needsReauth: true,
|
|
23
|
+
revokedAt: Date.now()
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
+
import { isQuarantined } from './quarantine.js'
|
|
2
|
+
import { getPinnedAccountRef, pinSession } from './session-pin.js'
|
|
3
|
+
|
|
1
4
|
const SWITCH_CODES = new Set([
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
'RATE_LIMIT',
|
|
6
|
+
'QUOTA',
|
|
7
|
+
'QUOTA_EXCEEDED',
|
|
5
8
|
])
|
|
6
9
|
|
|
7
10
|
export function isSwitchableError(err) {
|
|
8
|
-
if (!err || typeof err !==
|
|
11
|
+
if (!err || typeof err !== 'object') return false
|
|
9
12
|
const code = err.code || (err.failure && err.failure.code)
|
|
10
13
|
if (SWITCH_CODES.has(code)) return true
|
|
11
14
|
const status = err.status || err.statusCode
|
|
12
|
-
return status === 429
|
|
15
|
+
return status === 429 || status === 500 || status === 502 || status === 503
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
// #87: family classification. Cooldowns are scoped to the model family that
|
|
16
|
-
// hit the limit, so a reasoning 429 does not block standard models.
|
|
17
18
|
export function modelFamily(provider, model) {
|
|
18
19
|
const id = String(model || '')
|
|
19
20
|
if (provider === 'claude') return /thinking/i.test(id) ? 'reasoning' : 'standard'
|
|
@@ -22,8 +23,6 @@ export function modelFamily(provider, model) {
|
|
|
22
23
|
return 'standard'
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
// Legacy cooldowns (no family list) block everything; scoped ones block only
|
|
26
|
-
// their own family. An unknown family never blocks a differently-scoped cooldown.
|
|
27
26
|
export function cooldownBlocks(acc, family) {
|
|
28
27
|
const fams = acc && acc.cooldownFamilies
|
|
29
28
|
if (!Array.isArray(fams) || !fams.length) return true
|
|
@@ -36,17 +35,17 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
36
35
|
const now = Number(nowMs) || 0
|
|
37
36
|
const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
|
|
38
37
|
const thr = Number.isFinite(thrRaw) ? thrRaw : 0
|
|
38
|
+
const isVipRequest = Boolean(opts && opts.vip)
|
|
39
|
+
const targetTag = opts && opts.tag
|
|
40
|
+
const targetSession = opts && opts.sessionId
|
|
41
|
+
|
|
39
42
|
function isQuotaExhausted(acc) {
|
|
40
43
|
if (!thr || thr <= 0) return false
|
|
41
44
|
const q = acc.quota
|
|
42
45
|
if (!q) return false
|
|
43
46
|
if (q.resetAt && Number(q.resetAt) <= now) return false
|
|
44
47
|
if (q.remaining == null) return false
|
|
45
|
-
// Если до сброса меньше минуты — переключаемся, не тратя остаток,
|
|
46
|
-
// независимо от порога (аккаунт всё равно скоро обнулится).
|
|
47
48
|
if (q.resetAt && Number(q.resetAt) - now < 60000) return true
|
|
48
|
-
// Если остаток меньше порога — считаем исчерпанным, чтобы не тратить
|
|
49
|
-
// последние проценты перед отказом.
|
|
50
49
|
let below = false
|
|
51
50
|
if (q.limit != null && q.limit > 0 && thr > 0 && thr < 1) {
|
|
52
51
|
const frac = q.remaining / q.limit
|
|
@@ -56,33 +55,87 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
56
55
|
}
|
|
57
56
|
return below
|
|
58
57
|
}
|
|
58
|
+
|
|
59
59
|
function isUsageExhausted(acc) {
|
|
60
60
|
if (acc.usagePercent == null || Number(acc.usagePercent) < 100) return false
|
|
61
61
|
const q = acc.quota
|
|
62
62
|
if (q && q.resetAt && Number(q.resetAt) <= now) return false
|
|
63
63
|
return true
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
// Sticky Session Pinning (#170)
|
|
67
|
+
if (targetSession) {
|
|
68
|
+
const pinnedRef = getPinnedAccountRef(targetSession)
|
|
69
|
+
if (pinnedRef) {
|
|
70
|
+
const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
|
|
71
|
+
if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
|
|
72
|
+
const isCooldown = pinned.cooldownUntil && Number(pinned.cooldownUntil) > now && cooldownBlocks(pinned, opts && opts.family)
|
|
73
|
+
if (!isCooldown && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
|
|
74
|
+
return pinned
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3-Tier priority:
|
|
81
|
+
// Tier 0: Has known quota & healthy
|
|
82
|
+
// Tier 1: Quota unknown (null) & healthy
|
|
83
|
+
// Tier 2: Cooldown, quarantine or exhausted
|
|
65
84
|
const tiers = [[], [], []]
|
|
66
85
|
const fallback = []
|
|
86
|
+
|
|
67
87
|
for (const acc of list) {
|
|
68
88
|
if (!acc || !acc.hasToken) continue
|
|
89
|
+
|
|
90
|
+
// VIP Slot Reservation (#175)
|
|
91
|
+
if (acc.vipOnly && !isVipRequest) continue
|
|
92
|
+
|
|
93
|
+
// Tag-based filtering (#173)
|
|
94
|
+
if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag)) continue
|
|
95
|
+
|
|
69
96
|
const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
|
|
97
|
+
const inQuarantine = isQuarantined(acc, now)
|
|
70
98
|
const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
|
|
71
|
-
|
|
99
|
+
|
|
100
|
+
if (isCooldown || inQuarantine || exhausted) {
|
|
72
101
|
tiers[2].push(acc)
|
|
73
102
|
fallback.push(acc)
|
|
74
103
|
continue
|
|
75
104
|
}
|
|
105
|
+
|
|
76
106
|
if (!acc.quota) tiers[1].push(acc)
|
|
77
107
|
else tiers[0].push(acc)
|
|
78
108
|
}
|
|
79
|
-
|
|
109
|
+
|
|
80
110
|
for (const tier of tiers) {
|
|
81
111
|
if (tier.length) {
|
|
82
|
-
tier.sort((a, b) =>
|
|
83
|
-
|
|
112
|
+
tier.sort((a, b) => {
|
|
113
|
+
// 1. Least Remaining Window (#167)
|
|
114
|
+
if (a.quota && b.quota && a.quota.resetAt && b.quota.resetAt) {
|
|
115
|
+
const aReset = Number(a.quota.resetAt)
|
|
116
|
+
const bReset = Number(b.quota.resetAt)
|
|
117
|
+
if (Math.abs(aReset - bReset) > 300000) {
|
|
118
|
+
return aReset - bReset
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. Weighted Round Robin: Pro/Team weight (#171)
|
|
123
|
+
const aWeight = a.weight || (a.isPro ? 5 : 1)
|
|
124
|
+
const bWeight = b.weight || (b.isPro ? 5 : 1)
|
|
125
|
+
if (aWeight !== bWeight) return bWeight - aWeight
|
|
126
|
+
|
|
127
|
+
// 3. Health Score
|
|
128
|
+
return (b.healthScore || 100) - (a.healthScore || 100)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const chosen = tier[0]
|
|
132
|
+
if (targetSession && chosen) {
|
|
133
|
+
pinSession(targetSession, chosen.ref || chosen.id)
|
|
134
|
+
}
|
|
135
|
+
return chosen
|
|
84
136
|
}
|
|
85
137
|
}
|
|
138
|
+
|
|
86
139
|
if (fallback.length) return fallback[0]
|
|
87
140
|
return null
|
|
88
141
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function calculateCacheSavings({ cachedTokens = 0, totalInputTokens = 0, ratePerMillion = 3.0 } = {}) {
|
|
2
|
+
const hitRate = totalInputTokens > 0 ? Math.round((cachedTokens / totalInputTokens) * 100) : 0
|
|
3
|
+
// Anthropic / OpenAI prompt cache discount is typically 90% (cached tokens cost 10% of standard input)
|
|
4
|
+
const fullCost = (cachedTokens / 1_000_000) * ratePerMillion
|
|
5
|
+
const discountedCost = fullCost * 0.1
|
|
6
|
+
const savedDollars = Math.round((fullCost - discountedCost) * 100) / 100
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
cachedTokens,
|
|
10
|
+
totalInputTokens,
|
|
11
|
+
hitRatePercent: hitRate,
|
|
12
|
+
estimatedSavedDollars: savedDollars
|
|
13
|
+
}
|
|
14
|
+
}
|