@goodandready/dsh-subscriptions 0.5.6 → 0.5.21

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.
Files changed (48) hide show
  1. package/lib/atomic-lock.js +35 -0
  2. package/lib/backoff.js +26 -0
  3. package/lib/client.js +211 -15
  4. package/lib/coalesce-stream.js +22 -0
  5. package/lib/code-assist.js +4 -4
  6. package/lib/cookie-bridge.js +8 -0
  7. package/lib/credit-detect.js +22 -0
  8. package/lib/encrypted-bundle.js +46 -0
  9. package/lib/import-auth.js +74 -7
  10. package/lib/importers/coding-agents.js +37 -0
  11. package/lib/index.js +35 -7
  12. package/lib/latency-heatmap.js +26 -0
  13. package/lib/latency-score.js +36 -0
  14. package/lib/multi-tenant.js +16 -0
  15. package/lib/network-benchmark.js +23 -0
  16. package/lib/pkce-store.js +28 -0
  17. package/lib/preflight-tokens.js +23 -0
  18. package/lib/proactive-refresh.js +37 -0
  19. package/lib/prompt-cache-warmer.js +44 -0
  20. package/lib/quarantine.js +38 -0
  21. package/lib/ratelimit-parser.js +24 -0
  22. package/lib/reconnect-stream.js +21 -0
  23. package/lib/reset-toast.js +38 -0
  24. package/lib/revocation.js +25 -0
  25. package/lib/rotate.js +70 -17
  26. package/lib/savings-calculator.js +14 -0
  27. package/lib/session-pin.js +29 -0
  28. package/lib/state-recovery.js +33 -0
  29. package/lib/stream-rotate.js +34 -11
  30. package/lib/telemetry.js +28 -0
  31. package/lib/token-speedometer.js +26 -0
  32. package/lib/vendors/antigravity.js +18 -5
  33. package/lib/vendors/claude-cli.js +24 -0
  34. package/lib/vendors/cody.js +72 -0
  35. package/lib/vendors/copilot.js +194 -0
  36. package/lib/vendors/cursor.js +1 -1
  37. package/lib/vendors/ernie.js +93 -0
  38. package/lib/vendors/glm.js +1 -1
  39. package/lib/vendors/index.js +26 -1
  40. package/lib/vendors/jetbrains.js +79 -0
  41. package/lib/vendors/kimi.js +1 -1
  42. package/lib/vendors/perplexity.js +79 -0
  43. package/lib/vendors/qwen.js +89 -0
  44. package/lib/vendors/replit.js +72 -0
  45. package/lib/vendors/spark.js +77 -0
  46. package/lib/wire.js +4 -2
  47. package/lib/zero-trace-logger.js +27 -0
  48. package/package.json +2 -5
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
- const body = await readBody(req).catch(() => null)
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 slot = normalizeSlots(live().slots).find((s) => s.provider === body.provider && s.index === (Number(body.index) || 1))
1165
- const ref = slot ? slot.ref : `${body.provider.toUpperCase()}_OAUTH_${Number(body.index) || 1}`
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
- writeJson(res, 200, { ok: true, ref, provider: body.provider, email: blob.email || '' })
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
- "RATE_LIMIT",
3
- "QUOTA",
4
- "QUOTA_EXCEEDED",
5
+ 'RATE_LIMIT',
6
+ 'QUOTA',
7
+ 'QUOTA_EXCEEDED',
5
8
  ])
6
9
 
7
10
  export function isSwitchableError(err) {
8
- if (!err || typeof err !== "object") return false
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
- if (isCooldown || exhausted) {
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
- // Внутри здоровых тиров предпочитаем аккаунт с более высоким health-счётом
109
+
80
110
  for (const tier of tiers) {
81
111
  if (tier.length) {
82
- tier.sort((a, b) => (b.healthScore || 100) - (a.healthScore || 100))
83
- return tier[0]
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
+ }
@@ -0,0 +1,29 @@
1
+ const sessionPins = new Map()
2
+ const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 mins
3
+
4
+ export function pinSession(sessionId, accountRef, ttlMs = DEFAULT_TTL_MS) {
5
+ if (!sessionId || !accountRef) return
6
+ sessionPins.set(sessionId, {
7
+ accountRef,
8
+ expiresAt: Date.now() + ttlMs
9
+ })
10
+ }
11
+
12
+ export function getPinnedAccountRef(sessionId) {
13
+ if (!sessionId) return null
14
+ const entry = sessionPins.get(sessionId)
15
+ if (!entry) return null
16
+ if (Date.now() > entry.expiresAt) {
17
+ sessionPins.delete(sessionId)
18
+ return null
19
+ }
20
+ return entry.accountRef
21
+ }
22
+
23
+ export function unpinSession(sessionId) {
24
+ if (sessionId) sessionPins.delete(sessionId)
25
+ }
26
+
27
+ export function clearSessionPins() {
28
+ sessionPins.clear()
29
+ }