@goodandready/dsh-subscriptions 0.7.28 → 0.7.30
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-view.js +2 -0
- package/lib/accounts.js +20 -1
- package/lib/adapter-manager.js +35 -15
- package/lib/adapter.js +44 -9
- package/lib/client.js +84 -6
- package/lib/config-schema.js +7 -1
- package/lib/index.js +5 -2
- package/lib/models-catalog.js +57 -0
- package/lib/rotate.js +69 -7
- package/lib/stream-rotate.js +9 -3
- package/lib/subscriptions.js +3 -2
- package/package.json +1 -1
package/lib/accounts-view.js
CHANGED
|
@@ -145,6 +145,7 @@ export async function createAccountsView({ live, store, fetch, pmL, pmE, concurr
|
|
|
145
145
|
// Issue 303: expose the cooldown scope and quarantine so the card can
|
|
146
146
|
// explain why only some models are blocked.
|
|
147
147
|
cooldownFamilies: info.cooldownFamilies || null,
|
|
148
|
+
cooldownModels: info.cooldownModels || null,
|
|
148
149
|
quarantineUntil: info.quarantineUntil || 0,
|
|
149
150
|
quarantineReason: info.quarantineReason || null,
|
|
150
151
|
healthScore: info.healthScore,
|
|
@@ -166,6 +167,7 @@ export async function createAccountsView({ live, store, fetch, pmL, pmE, concurr
|
|
|
166
167
|
lastDoctorVerdict: info.lastDoctorVerdict || null,
|
|
167
168
|
availableModels: info.availableModels || null,
|
|
168
169
|
poolEnabled: slot.poolEnabled !== false,
|
|
170
|
+
isDefault: !!slot.isDefault,
|
|
169
171
|
...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
|
|
170
172
|
capabilities,
|
|
171
173
|
billing,
|
package/lib/accounts.js
CHANGED
|
@@ -74,6 +74,7 @@ export function normalizeSlots(slots) {
|
|
|
74
74
|
...(slot.expiresAt ? { expiresAt: Number(slot.expiresAt) } : {}),
|
|
75
75
|
...(slot.maxConcurrency !== undefined ? { maxConcurrency: Number(slot.maxConcurrency) } : {}),
|
|
76
76
|
...(slot.poolEnabled === false ? { poolEnabled: false } : {}),
|
|
77
|
+
...(slot.isDefault ? { isDefault: true } : {}),
|
|
77
78
|
...(Array.isArray(slot.poolModels)
|
|
78
79
|
? { poolModels: [...new Set(slot.poolModels.map((id) => String(id).trim()).filter(Boolean))] }
|
|
79
80
|
: {}),
|
|
@@ -82,6 +83,12 @@ export function normalizeSlots(slots) {
|
|
|
82
83
|
return out
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
export function defaultAccountRef(slots, provider) {
|
|
87
|
+
const list = normalizeSlots(slots).filter((slot) => slot.provider === provider)
|
|
88
|
+
const marked = list.find((slot) => slot.isDefault)
|
|
89
|
+
return (marked || list[0] || null)?.ref || null
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
export function vendorConfig(provider, cfg) {
|
|
86
93
|
const d = getVendor(provider).defaults()
|
|
87
94
|
const pick = (suffix, fallback) => {
|
|
@@ -323,6 +330,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
323
330
|
quarantineUntil: quarantines.get(ref) ? quarantines.get(ref).until : 0,
|
|
324
331
|
quarantineReason: quarantines.get(ref) ? quarantines.get(ref).reason : null,
|
|
325
332
|
cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
|
|
333
|
+
cooldownModels: cooldowns.get(ref) && cooldowns.get(ref).models ? cooldowns.get(ref).models : null,
|
|
326
334
|
quota: quotas.get(ref) || null,
|
|
327
335
|
pacePerHour: quotas.get(ref)?.pacePerHour || calculateBurnRatePerHour(quotaSamples.get(ref) || [], Date.now()),
|
|
328
336
|
samples: quotaSamples.get(ref) || [],
|
|
@@ -375,6 +383,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
375
383
|
usagePercent: info.usagePercent,
|
|
376
384
|
cooldownUntil: info.cooldownUntil,
|
|
377
385
|
cooldownFamilies: info.cooldownFamilies || null,
|
|
386
|
+
cooldownModels: info.cooldownModels || null,
|
|
378
387
|
quota: info.quota || null,
|
|
379
388
|
healthScore: computeHealthScore(info.health),
|
|
380
389
|
healthBadge: healthBadge(computeHealthScore(info.health)),
|
|
@@ -389,6 +398,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
389
398
|
weight: Number(slot.weight) || 0,
|
|
390
399
|
isPro: !!slot.isPro,
|
|
391
400
|
poolEnabled: slot.poolEnabled !== false,
|
|
401
|
+
isDefault: !!slot.isDefault,
|
|
392
402
|
...(Array.isArray(slot.poolModels) ? { poolModels: slot.poolModels } : {}),
|
|
393
403
|
})
|
|
394
404
|
}
|
|
@@ -583,7 +593,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
583
593
|
recordClockSkew,
|
|
584
594
|
// #87: family-scoped cooldowns. families=null blocks the whole account
|
|
585
595
|
// (legacy behavior); a list blocks only those model families.
|
|
586
|
-
rememberCooldown(ref, until, families) {
|
|
596
|
+
rememberCooldown(ref, until, families, models = null) {
|
|
597
|
+
if (models && typeof models === 'object') {
|
|
598
|
+
const prev = cooldowns.get(ref)
|
|
599
|
+
cooldowns.set(ref, {
|
|
600
|
+
until: prev ? prev.until : 0,
|
|
601
|
+
families: prev ? prev.families : null,
|
|
602
|
+
models: { ...(prev && prev.models ? prev.models : {}), ...models },
|
|
603
|
+
})
|
|
604
|
+
return
|
|
605
|
+
}
|
|
587
606
|
if (!families || !families.length) { cooldowns.set(ref, { until: Number(until) || 0, families: null }); return }
|
|
588
607
|
const prev = cooldowns.get(ref)
|
|
589
608
|
const merged = new Set(families)
|
package/lib/adapter-manager.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { SubscriptionAdapter } from './adapter.js'
|
|
2
2
|
import { resolveFallbackVendor, mapFallbackModel } from './cascade.js'
|
|
3
|
-
import { normalizeSlots, vendorConfig } from './accounts.js'
|
|
3
|
+
import { defaultAccountRef, normalizeSlots, vendorConfig } from './accounts.js'
|
|
4
4
|
import { PROVIDERS, isProvider, oauthRef } from './refs.js'
|
|
5
5
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
6
6
|
import { getVendor } from './vendors/index.js'
|
|
7
7
|
import { Config, plainConfig } from './config-schema.js'
|
|
8
|
+
import { MODEL_CATALOG_TTL_MS, catalogSnapshotFresh } from './models-catalog.js'
|
|
8
9
|
|
|
9
10
|
export function createAdapterManager({
|
|
10
11
|
ctx,
|
|
@@ -38,6 +39,10 @@ export function createAdapterManager({
|
|
|
38
39
|
const adapter = new SubscriptionAdapter({
|
|
39
40
|
live: () => live(),
|
|
40
41
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
42
|
+
getDynamicModels: (ref) => store.getDynamicModels(ref),
|
|
43
|
+
rememberDynamicModels: (ref, models) => store.rememberDynamicModels(ref, models),
|
|
44
|
+
poolEnabled: () => live().poolEnabled !== false,
|
|
45
|
+
defaultAccountRef: (provider) => defaultAccountRef(live().slots, provider),
|
|
41
46
|
loadBlob: (ref) => store.loadBlob(ref),
|
|
42
47
|
ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
|
|
43
48
|
vendorConfig: (provider) => vendorConfig(provider, live()),
|
|
@@ -243,26 +248,28 @@ export function createAdapterManager({
|
|
|
243
248
|
}
|
|
244
249
|
}
|
|
245
250
|
|
|
246
|
-
|
|
247
|
-
const providerModelCache = new Map()
|
|
251
|
+
const lastModelRefreshAt = new Map()
|
|
248
252
|
|
|
249
|
-
async function refreshModels() {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
253
|
+
async function refreshModels({ force = false } = {}) {
|
|
254
|
+
const now = Date.now()
|
|
255
|
+
const slots = normalizeSlots(live().slots)
|
|
256
|
+
for (const slot of slots) {
|
|
257
|
+
const last = lastModelRefreshAt.get(slot.ref)
|
|
258
|
+
if (!force && catalogSnapshotFresh(last, now, MODEL_CATALOG_TTL_MS)) continue
|
|
254
259
|
try {
|
|
255
|
-
const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
|
|
256
|
-
if (!slot) continue
|
|
257
260
|
const blob = await store.loadBlob(slot.ref).catch(() => null)
|
|
258
261
|
if (!blob) continue
|
|
259
|
-
const fresh = await store.ensureFresh(provider, blob, slot.ref)
|
|
260
|
-
const cfg = vendorConfig(provider, live())
|
|
261
|
-
const liveModels = await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => null)
|
|
262
|
+
const fresh = await store.ensureFresh(slot.provider, blob, slot.ref)
|
|
263
|
+
const cfg = vendorConfig(slot.provider, live())
|
|
264
|
+
const liveModels = await getVendor(slot.provider).listModels(fresh, cfg, fetch).catch(() => null)
|
|
265
|
+
// #828: empty/broken live catalogs must not erase last-known-good.
|
|
262
266
|
if (Array.isArray(liveModels) && liveModels.length) {
|
|
263
|
-
|
|
267
|
+
if (typeof store.rememberDynamicModels === 'function') {
|
|
268
|
+
store.rememberDynamicModels(slot.ref, liveModels)
|
|
269
|
+
}
|
|
270
|
+
lastModelRefreshAt.set(slot.ref, now)
|
|
264
271
|
}
|
|
265
|
-
} catch { /*
|
|
272
|
+
} catch { /* keep the previous snapshot */ }
|
|
266
273
|
}
|
|
267
274
|
}
|
|
268
275
|
|
|
@@ -326,9 +333,22 @@ export function createAdapterManager({
|
|
|
326
333
|
}
|
|
327
334
|
eager()
|
|
328
335
|
|
|
336
|
+
// #799: refresh at startup and hourly. Per-ref snapshots younger than
|
|
337
|
+
// five minutes are skipped, and failures keep last-known-good.
|
|
338
|
+
refreshModels().catch((err) => {
|
|
339
|
+
if (logger && logger.debug) logger.debug('[dsh-subscriptions] model refresh error: ' + (err && err.message || err))
|
|
340
|
+
})
|
|
341
|
+
const modelTimer = setInterval(() => {
|
|
342
|
+
refreshModels().catch((err) => {
|
|
343
|
+
if (logger && logger.debug) logger.debug('[dsh-subscriptions] hourly model refresh error: ' + (err && err.message || err))
|
|
344
|
+
})
|
|
345
|
+
}, 60 * 60 * 1000)
|
|
346
|
+
if (modelTimer && typeof modelTimer.unref === 'function') modelTimer.unref()
|
|
347
|
+
|
|
329
348
|
return () => {
|
|
330
349
|
adapterDisposed = true
|
|
331
350
|
if (retryTimer) clearTimeout(retryTimer)
|
|
351
|
+
clearInterval(modelTimer)
|
|
332
352
|
if (handle) {
|
|
333
353
|
disposeAdapterHandle(handle)
|
|
334
354
|
handle = undefined
|
package/lib/adapter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveEffectiveReasoning, getModelReasoningCapabilities } from './reasoning-effort.js'
|
|
2
|
-
import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
|
|
2
|
+
import { inferModelCapabilities, isDeprecatedId, mergeAccountCatalogs, resolveCatalogSource } from './models-catalog.js'
|
|
3
3
|
import { fetchWithTimeout } from "./http.js"
|
|
4
4
|
import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
|
|
5
5
|
import { displayName } from './refs.js'
|
|
@@ -54,10 +54,12 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
54
54
|
|
|
55
55
|
async listModels(provider) {
|
|
56
56
|
const accounts = await this.deps.listAccounts(provider)
|
|
57
|
+
const cfg = this.deps.vendorConfig(provider)
|
|
58
|
+
const builtin = modelCatalog(provider, cfg ? cfg.models : undefined)
|
|
57
59
|
const targetAccount = accounts.find((a) => a.hasToken)
|
|
58
|
-
let
|
|
60
|
+
let targetLive = null
|
|
61
|
+
|
|
59
62
|
if (targetAccount) {
|
|
60
|
-
const cfg = this.deps.vendorConfig(provider)
|
|
61
63
|
const baseFetch = pickFetch(this.deps, targetAccount.ref)
|
|
62
64
|
const listModelsFetch = (url, init, opts) =>
|
|
63
65
|
fetchWithTimeout(baseFetch, url, init, { timeoutMs: 15000, ...(opts || {}) })
|
|
@@ -68,13 +70,39 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
68
70
|
targetAccount.ref,
|
|
69
71
|
)
|
|
70
72
|
const live = await getVendor(provider).listModels(blob, cfg, listModelsFetch)
|
|
71
|
-
if (Array.isArray(live) && live.length)
|
|
72
|
-
|
|
73
|
+
if (Array.isArray(live) && live.length) {
|
|
74
|
+
targetLive = live
|
|
75
|
+
if (typeof this.deps.rememberDynamicModels === 'function') {
|
|
76
|
+
this.deps.rememberDynamicModels(targetAccount.ref, live)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch { /* #828: keep last-known-good / built-in catalog */ }
|
|
73
80
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
|
|
82
|
+
// #797: build the picker union from each account's own catalog. Live data
|
|
83
|
+
// is only fetched for the primary account here; background refresh fills
|
|
84
|
+
// the rest without blocking the picker.
|
|
85
|
+
const entries = []
|
|
86
|
+
for (const account of accounts) {
|
|
87
|
+
if (!account || !account.hasToken) continue
|
|
88
|
+
const cached = typeof this.deps.getDynamicModels === 'function'
|
|
89
|
+
? this.deps.getDynamicModels(account.ref)
|
|
90
|
+
: null
|
|
91
|
+
const resolved = resolveCatalogSource({
|
|
92
|
+
live: account === targetAccount ? targetLive : null,
|
|
93
|
+
cached,
|
|
94
|
+
builtin,
|
|
95
|
+
})
|
|
96
|
+
entries.push({
|
|
97
|
+
ref: account.ref,
|
|
98
|
+
label: account.label || account.email || account.ref,
|
|
99
|
+
models: resolved.models,
|
|
100
|
+
source: resolved.source,
|
|
101
|
+
})
|
|
77
102
|
}
|
|
103
|
+
if (!entries.length) entries.push({ models: builtin, source: 'builtin' })
|
|
104
|
+
|
|
105
|
+
let models = mergeAccountCatalogs(entries)
|
|
78
106
|
// #94: optionally hide test/preview/beta/legacy ids from the picker.
|
|
79
107
|
if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
|
|
80
108
|
models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
|
|
@@ -165,11 +193,18 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
165
193
|
options.signal,
|
|
166
194
|
),
|
|
167
195
|
}
|
|
196
|
+
const poolEnabled = typeof deps.poolEnabled === 'function' ? deps.poolEnabled() !== false : true
|
|
197
|
+
const defaultRef = typeof deps.defaultAccountRef === 'function' ? deps.defaultAccountRef(provider) : null
|
|
168
198
|
const streamOptions = {
|
|
169
199
|
...hydratedOptions,
|
|
170
200
|
...(provider === 'codex' && getSpeedMode(sessionId) === 'fast' ? { service_tier: 'priority' } : {}),
|
|
171
201
|
...(effectiveReasoning.effort ? { reasoningEffort: effectiveReasoning.effort } : {}),
|
|
172
202
|
...(effectiveReasoning.budgetTokens ? { thinkingBudget: effectiveReasoning.budgetTokens } : {}),
|
|
203
|
+
// #830: kill switch. With no default account, use a sentinel that
|
|
204
|
+
// intentionally matches nothing, so the error is explicit and there
|
|
205
|
+
// is no silent fallback to another account.
|
|
206
|
+
...(!poolEnabled ? { onlyRef: defaultRef || '__no_default_account__' } : {}),
|
|
207
|
+
...(defaultRef ? { defaultRef } : {}),
|
|
173
208
|
_decisionTrace: decisionTrace,
|
|
174
209
|
autoPacing: effectiveAutoPacing,
|
|
175
210
|
drainBeforeReset: effectiveDrainBeforeReset,
|
|
@@ -188,7 +223,7 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
188
223
|
webhookUrl: typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl,
|
|
189
224
|
fetchImpl: deps.fetchImpl || fetch,
|
|
190
225
|
onCooldown: (account) => {
|
|
191
|
-
if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
|
|
226
|
+
if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null, account.cooldownModels || null)
|
|
192
227
|
if (typeof deps.rememberQuarantine === 'function') {
|
|
193
228
|
const currentQ = typeof deps.getQuarantine === 'function' ? deps.getQuarantine(account.ref) : null
|
|
194
229
|
if (currentQ && currentQ.attempts && currentQ.attempts > 1) {
|
package/lib/client.js
CHANGED
|
@@ -158,6 +158,12 @@ window.__ModuleLoader__.load({
|
|
|
158
158
|
"cascadingFallbackOrderHint": "Sequence of alternative providers to try when primary accounts are exhausted.",
|
|
159
159
|
"rotationStrategy": "Account Balancing Strategy",
|
|
160
160
|
"rotationStrategyHint": "Method used to distribute requests among available accounts of a provider.",
|
|
161
|
+
"rotationQuotaAware": "Quota-aware (urgent first)",
|
|
162
|
+
"poolEnabled": "Automatic account pool",
|
|
163
|
+
"poolEnabledHint": "When off, requests use only the provider default account. No silent fallback to other accounts.",
|
|
164
|
+
"mediaPoolEnabled": "Automatic media pool",
|
|
165
|
+
"mediaPoolEnabledHint": "Controls image/video account rotation separately from the text pool.",
|
|
166
|
+
"defaultAccountHint": "Used for direct routing and when the automatic pool is off.",
|
|
161
167
|
"rotationRoundRobin": "Round-Robin (Balanced rotation)",
|
|
162
168
|
"rotationLeastLoaded": "Least-Loaded (Fair-share quota)",
|
|
163
169
|
"rotationPriority": "Priority (Sequential by index/weight)",
|
|
@@ -292,6 +298,9 @@ window.__ModuleLoader__.load({
|
|
|
292
298
|
"poolModelsNone": "None",
|
|
293
299
|
"poolModelsSelected": "selected",
|
|
294
300
|
"poolModelsHint": "Automatic allows every current and future model. None removes this account from model routing. An explicit list permits only checked model ids.",
|
|
301
|
+
"poolModelsSearch": "Search models…",
|
|
302
|
+
"poolModelsCount": "{shown} shown · {selected} selected · {total} total",
|
|
303
|
+
"poolModelsNoMatches": "No models match this search.",
|
|
295
304
|
"poolModelsNoCatalog": "No model catalog yet; sync models or use Automatic.",
|
|
296
305
|
"tags": "Tags",
|
|
297
306
|
"tagsPlaceholder": "team, prod",
|
|
@@ -541,6 +550,12 @@ window.__ModuleLoader__.load({
|
|
|
541
550
|
"cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
|
|
542
551
|
"rotationStrategy": "账户负载均衡策略",
|
|
543
552
|
"rotationStrategyHint": "在同一服务商的多个可用账户之间分发请求的方式。",
|
|
553
|
+
"rotationQuotaAware": "配额感知(优先紧急)",
|
|
554
|
+
"poolEnabled": "自动账户池",
|
|
555
|
+
"poolEnabledHint": "关闭后请求只使用服务商默认账户,不会静默回退到其他账户。",
|
|
556
|
+
"mediaPoolEnabled": "自动媒体池",
|
|
557
|
+
"mediaPoolEnabledHint": "独立控制图片/视频账户轮询,与文本池分开。",
|
|
558
|
+
"defaultAccountHint": "用于直接路由,以及关闭自动账户池时的请求。",
|
|
544
559
|
"rotationRoundRobin": "轮询均衡(标准交替)",
|
|
545
560
|
"rotationLeastLoaded": "最少负载优先(配额公平共享)",
|
|
546
561
|
"rotationPriority": "优先级顺序(按序号/权重顺序)",
|
|
@@ -675,6 +690,9 @@ window.__ModuleLoader__.load({
|
|
|
675
690
|
"poolModelsNone": "无",
|
|
676
691
|
"poolModelsSelected": "已选",
|
|
677
692
|
"poolModelsHint": "自动允许当前及未来全部模型。无会将该账户移出模型调度。显式列表只允许勾选的模型 ID。",
|
|
693
|
+
"poolModelsSearch": "搜索模型…",
|
|
694
|
+
"poolModelsCount": "显示 {shown} · 已选 {selected} · 共 {total}",
|
|
695
|
+
"poolModelsNoMatches": "没有匹配的模型。",
|
|
678
696
|
"poolModelsNoCatalog": "暂无模型目录;请同步模型或使用自动。",
|
|
679
697
|
"tags": "标签",
|
|
680
698
|
"tagsPlaceholder": "team, prod",
|
|
@@ -1047,6 +1065,7 @@ window.__ModuleLoader__.load({
|
|
|
1047
1065
|
const [modelQuery, setModelQuery] = React.useState('')
|
|
1048
1066
|
const [modelSort, setModelSort] = React.useState('name')
|
|
1049
1067
|
const [modelLimit, setModelLimit] = React.useState(40)
|
|
1068
|
+
const [poolModelQuery, setPoolModelQuery] = React.useState({})
|
|
1050
1069
|
const [paste, setPaste] = React.useState({})
|
|
1051
1070
|
const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
|
|
1052
1071
|
const [loginLinks, setLoginLinks] = React.useState({}) // #595 provider:index -> fallback auth URL when a popup is blocked
|
|
@@ -2482,10 +2501,33 @@ h('div', { className: 'dsub-row' },
|
|
|
2482
2501
|
},
|
|
2483
2502
|
h('option', { value: 'round-robin' }, t('rotationRoundRobin')),
|
|
2484
2503
|
h('option', { value: 'least-loaded' }, t('rotationLeastLoaded')),
|
|
2504
|
+
h('option', { value: 'quota-aware' }, t('rotationQuotaAware')),
|
|
2485
2505
|
h('option', { value: 'priority' }, t('rotationPriority')),
|
|
2486
2506
|
),
|
|
2487
2507
|
),
|
|
2488
2508
|
),
|
|
2509
|
+
h('div', { className: 'dsub-row', style: { marginTop: 12 } },
|
|
2510
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
2511
|
+
h('input', {
|
|
2512
|
+
type: 'checkbox',
|
|
2513
|
+
checked: draft.poolEnabled !== false,
|
|
2514
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { poolEnabled: e.target.checked })),
|
|
2515
|
+
}),
|
|
2516
|
+
h('span', null, t('poolEnabled')),
|
|
2517
|
+
),
|
|
2518
|
+
h('div', { className: 'dsub-sub' }, t('poolEnabledHint')),
|
|
2519
|
+
),
|
|
2520
|
+
h('div', { className: 'dsub-row', style: { marginTop: 8 } },
|
|
2521
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
2522
|
+
h('input', {
|
|
2523
|
+
type: 'checkbox',
|
|
2524
|
+
checked: draft.mediaPoolEnabled !== false,
|
|
2525
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { mediaPoolEnabled: e.target.checked })),
|
|
2526
|
+
}),
|
|
2527
|
+
h('span', null, t('mediaPoolEnabled')),
|
|
2528
|
+
),
|
|
2529
|
+
h('div', { className: 'dsub-sub' }, t('mediaPoolEnabledHint')),
|
|
2530
|
+
),
|
|
2489
2531
|
h('div', { className: 'dsub-row', style: { marginTop: 12 } },
|
|
2490
2532
|
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
2491
2533
|
h('input', {
|
|
@@ -2753,6 +2795,14 @@ h('div', { className: 'dsub-row' },
|
|
|
2753
2795
|
.concat(poolModels || [])
|
|
2754
2796
|
.filter(Boolean)
|
|
2755
2797
|
)).sort()
|
|
2798
|
+
const poolQuery = String(poolModelQuery[key] || '').trim().toLowerCase()
|
|
2799
|
+
const visiblePoolModelIds = poolQuery
|
|
2800
|
+
? poolModelIds.filter((id) => String(id).toLowerCase().includes(poolQuery))
|
|
2801
|
+
: poolModelIds
|
|
2802
|
+
const selectedPoolCount = poolModels ? poolModels.length : poolModelIds.length
|
|
2803
|
+
const poolModeLabel = !poolModels
|
|
2804
|
+
? t('poolModelsAutomatic')
|
|
2805
|
+
: (poolModels.length ? (poolModels.length + ' ' + t('poolModelsSelected')) : t('poolModelsNone'))
|
|
2756
2806
|
const avatarSrc = safeExternalUrl(account.avatarUrl, true)
|
|
2757
2807
|
const avatarEl = (avatarSrc ? h('img', {
|
|
2758
2808
|
src: avatarSrc,
|
|
@@ -2766,6 +2816,14 @@ h('div', { className: 'dsub-row' },
|
|
|
2766
2816
|
return h('div', { className: 'dsub-card', key: key },
|
|
2767
2817
|
h('div', { className: 'dsub-row', style: { alignItems: 'center' } },
|
|
2768
2818
|
avatarEl,
|
|
2819
|
+
h('label', { className: 'dsub-ref-tag', title: t('defaultAccountHint') },
|
|
2820
|
+
h('input', {
|
|
2821
|
+
type: 'checkbox',
|
|
2822
|
+
checked: !!row.slot.isDefault,
|
|
2823
|
+
onChange: (e) => updateSlot(row.slot.provider, row.slot.index, { isDefault: e.target.checked }),
|
|
2824
|
+
}),
|
|
2825
|
+
' ★'
|
|
2826
|
+
),
|
|
2769
2827
|
h('input', {
|
|
2770
2828
|
className: 'dsub-grow',
|
|
2771
2829
|
value: row.slot.label || '',
|
|
@@ -3063,9 +3121,24 @@ h('div', { className: 'dsub-row' },
|
|
|
3063
3121
|
),
|
|
3064
3122
|
h('details', { className: 'dsub-details' },
|
|
3065
3123
|
h('summary', { className: 'dsub-summary' },
|
|
3066
|
-
'🎯 ' + t('poolModelsTitle') + ' · ' +
|
|
3124
|
+
'🎯 ' + t('poolModelsTitle') + ' · ' + poolModeLabel
|
|
3067
3125
|
),
|
|
3068
3126
|
h('div', { className: 'dsub-sub' }, t('poolModelsHint')),
|
|
3127
|
+
h('div', { className: 'dsub-row' },
|
|
3128
|
+
h('input', {
|
|
3129
|
+
className: 'dsub-inp dsub-grow',
|
|
3130
|
+
value: poolModelQuery[key] || '',
|
|
3131
|
+
placeholder: t('poolModelsSearch'),
|
|
3132
|
+
'aria-label': t('poolModelsSearch'),
|
|
3133
|
+
onChange: (e) => setPoolModelQuery((q) => Object.assign({}, q, { [key]: e.target.value })),
|
|
3134
|
+
}),
|
|
3135
|
+
),
|
|
3136
|
+
h('div', { className: 'dsub-sub' },
|
|
3137
|
+
t('poolModelsCount')
|
|
3138
|
+
.replace('{shown}', String(visiblePoolModelIds.length))
|
|
3139
|
+
.replace('{selected}', String(selectedPoolCount))
|
|
3140
|
+
.replace('{total}', String(poolModelIds.length))
|
|
3141
|
+
),
|
|
3069
3142
|
h('div', { className: 'dsub-row' },
|
|
3070
3143
|
h('button', {
|
|
3071
3144
|
type: 'button', className: 'dsub-mini' + (!poolModels ? ' dsub-btnActive' : ''),
|
|
@@ -3073,15 +3146,20 @@ h('div', { className: 'dsub-row' },
|
|
|
3073
3146
|
}, t('poolModelsAutomatic')),
|
|
3074
3147
|
h('button', {
|
|
3075
3148
|
type: 'button', className: 'dsub-mini',
|
|
3076
|
-
onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels:
|
|
3149
|
+
onClick: () => updateSlot(row.slot.provider, row.slot.index, { poolModels: visiblePoolModelIds.slice() }),
|
|
3077
3150
|
}, t('poolModelsAll')),
|
|
3078
3151
|
h('button', {
|
|
3079
3152
|
type: 'button', className: 'dsub-mini' + (poolModels && poolModels.length === 0 ? ' dsub-btnActive' : ''),
|
|
3080
|
-
onClick: () =>
|
|
3153
|
+
onClick: () => {
|
|
3154
|
+
const base = poolModels || poolModelIds
|
|
3155
|
+
updateSlot(row.slot.provider, row.slot.index, {
|
|
3156
|
+
poolModels: base.filter((id) => !visiblePoolModelIds.includes(id)),
|
|
3157
|
+
})
|
|
3158
|
+
},
|
|
3081
3159
|
}, t('poolModelsNone')),
|
|
3082
3160
|
),
|
|
3083
|
-
|
|
3084
|
-
|
|
3161
|
+
visiblePoolModelIds.length ? h('div', { className: 'dsub-row', style: { flexWrap: 'wrap', gap: 6 } },
|
|
3162
|
+
visiblePoolModelIds.map((modelId) => {
|
|
3085
3163
|
const checked = poolModels ? poolModels.includes(modelId) : true
|
|
3086
3164
|
return h('label', { key: modelId, className: 'dsub-ref-tag', title: modelId },
|
|
3087
3165
|
h('input', {
|
|
@@ -3103,7 +3181,7 @@ h('div', { className: 'dsub-row' },
|
|
|
3103
3181
|
h('code', null, modelId)
|
|
3104
3182
|
)
|
|
3105
3183
|
})
|
|
3106
|
-
) : h('div', { className: 'dsub-sub' }, t('poolModelsNoCatalog')),
|
|
3184
|
+
) : h('div', { className: 'dsub-sub' }, poolModelIds.length ? t('poolModelsNoMatches') : t('poolModelsNoCatalog')),
|
|
3107
3185
|
),
|
|
3108
3186
|
h('div', { className: 'dsub-row' },
|
|
3109
3187
|
h('span', { className: 'dsub-ref-tag' }, t('tags')),
|
package/lib/config-schema.js
CHANGED
|
@@ -36,6 +36,8 @@ export const Slot = z.object({
|
|
|
36
36
|
.description('#171 Treat this account as Pro for weighted rotation.'),
|
|
37
37
|
poolEnabled: z.boolean().default(true)
|
|
38
38
|
.description('#795 Include this account in automatic pool selection. false keeps it available for explicit/manual use only.'),
|
|
39
|
+
isDefault: z.boolean().default(false)
|
|
40
|
+
.description('#829 Mark this account as the provider default for direct/fallback routing.'),
|
|
39
41
|
poolModels: z.array(z.string()).default(undefined)
|
|
40
42
|
.description('#795 Model allowlist for automatic pool selection. Absent = every model, empty = none, explicit list = only those ids.'),
|
|
41
43
|
})
|
|
@@ -154,7 +156,11 @@ export const Config = z.object({
|
|
|
154
156
|
cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
|
|
155
157
|
.description('Configurable global priority sequence for cross-vendor fallback.'),
|
|
156
158
|
rotationStrategy: z.string().volatile().default('round-robin')
|
|
157
|
-
.description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), or priority (sequential).'),
|
|
159
|
+
.description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), quota-aware (urgent quota first), or priority (sequential).'),
|
|
160
|
+
poolEnabled: z.boolean().volatile().default(true)
|
|
161
|
+
.description('#830 Master switch for automatic account rotation. Off routes only to the provider default account and never silently falls back.'),
|
|
162
|
+
mediaPoolEnabled: z.boolean().volatile().default(true)
|
|
163
|
+
.description('#830 Separate switch for the image/video account pool.'),
|
|
158
164
|
enableSoftFailureSentinel: z.boolean().volatile().default(true)
|
|
159
165
|
.description('Intercept HTTP 200 soft-failure capacity apologies and automatically cascade to next healthy account.'),
|
|
160
166
|
cascadingChain: z.any().volatile().default({})
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { registerCustomProviderIds, registerDisplayName } from './refs.js'
|
|
|
17
17
|
import { createSubscriptionsService } from './subscriptions.js'
|
|
18
18
|
import { registerSubscriptionTools } from './native-tools.js'
|
|
19
19
|
import { detectConflicts, readProfilePluginNames, setActiveConflicts } from './coexistence.js'
|
|
20
|
-
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
20
|
+
import { createAccountStore, defaultAccountRef, normalizeSlots, vendorConfig } from './accounts.js'
|
|
21
21
|
import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
|
|
22
22
|
import { createResetCreditService } from './reset-credits.js'
|
|
23
23
|
import { maskEmail, maskLabel } from './mask.js'
|
|
@@ -389,12 +389,15 @@ export function apply(ctx, rawConfig) {
|
|
|
389
389
|
throw new Error(`unknown subscription provider: ${provider}`)
|
|
390
390
|
}
|
|
391
391
|
const accounts = await store.listAccounts(provider)
|
|
392
|
+
const mediaPool = live().mediaPoolEnabled !== false
|
|
393
|
+
const onlyRef = mediaPool ? null : defaultAccountRef(live().slots, provider)
|
|
392
394
|
const eligible = pickAccount(accounts || [], Date.now(), {
|
|
393
395
|
family: provider === 'codex' ? 'gpt' : 'grok',
|
|
394
396
|
drainBeforeReset: live().drainBeforeReset,
|
|
395
397
|
drainSafetyPercent: live().drainSafetyPercent,
|
|
398
|
+
...(onlyRef ? { onlyRef } : {}),
|
|
396
399
|
})
|
|
397
|
-
const slot = eligible || (accounts || []).find((row) => row && row.ref && row.hasToken && !poolBlockReason(row, {}))
|
|
400
|
+
const slot = eligible || (accounts || []).find((row) => row && row.ref && row.hasToken && !poolBlockReason(row, {}) && (!onlyRef || row.ref === onlyRef))
|
|
398
401
|
if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
|
399
402
|
const raw = await store.resolveRaw(slot.ref)
|
|
400
403
|
if (!raw) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
package/lib/models-catalog.js
CHANGED
|
@@ -6,6 +6,63 @@ export function isDeprecatedId(id) {
|
|
|
6
6
|
import { getVendor } from './vendors/index.js'
|
|
7
7
|
import { getAccountRemainingPercent } from './rotate.js'
|
|
8
8
|
|
|
9
|
+
export const MODEL_CATALOG_TTL_MS = 5 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
function catalogList(value) {
|
|
12
|
+
return Array.isArray(value) ? value.filter(Boolean) : []
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pick the best available catalog without ever dropping a provider.
|
|
17
|
+
* live > last-known-good cache > built-in vendor catalog.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveCatalogSource({ live, cached, builtin } = {}) {
|
|
20
|
+
const liveList = catalogList(live)
|
|
21
|
+
if (liveList.length) return { models: liveList, source: 'live' }
|
|
22
|
+
const cachedList = catalogList(cached)
|
|
23
|
+
if (cachedList.length) return { models: cachedList, source: 'cached' }
|
|
24
|
+
return { models: catalogList(builtin), source: 'builtin' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function catalogSnapshotFresh(at, nowMs = Date.now(), ttlMs = MODEL_CATALOG_TTL_MS) {
|
|
28
|
+
if (!at || !Number.isFinite(Number(at))) return false
|
|
29
|
+
return Number(nowMs) - Number(at) < Number(ttlMs)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Union per-account catalogs, keeping the accounts that can see each model.
|
|
34
|
+
* Rows are deduplicated by id and never mutated.
|
|
35
|
+
*/
|
|
36
|
+
export function mergeAccountCatalogs(entries = []) {
|
|
37
|
+
const byId = new Map()
|
|
38
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
39
|
+
if (!entry) continue
|
|
40
|
+
for (const model of catalogList(entry.models)) {
|
|
41
|
+
const id = model && (model.id || model.slug || model.name)
|
|
42
|
+
if (!id) continue
|
|
43
|
+
const existing = byId.get(id)
|
|
44
|
+
if (existing) {
|
|
45
|
+
if (entry.ref && !existing.accounts.includes(entry.ref)) existing.accounts.push(entry.ref)
|
|
46
|
+
if (entry.label && !existing.accountLabels.includes(entry.label)) existing.accountLabels.push(entry.label)
|
|
47
|
+
if (entry.source && !existing.catalogSources.includes(entry.source)) existing.catalogSources.push(entry.source)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
byId.set(id, {
|
|
51
|
+
...model,
|
|
52
|
+
accounts: entry.ref ? [entry.ref] : [],
|
|
53
|
+
accountLabels: entry.label ? [entry.label] : [],
|
|
54
|
+
catalogSources: entry.source ? [entry.source] : [],
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Array.from(byId.values())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function modelAllowlistMode(poolModels) {
|
|
62
|
+
if (!Array.isArray(poolModels)) return 'all'
|
|
63
|
+
return poolModels.length ? 'selected' : 'none'
|
|
64
|
+
}
|
|
65
|
+
|
|
9
66
|
export const CAPABILITY_TAGS = {
|
|
10
67
|
VISION: 'vision',
|
|
11
68
|
TOOLS: 'tools',
|
package/lib/rotate.js
CHANGED
|
@@ -50,7 +50,10 @@ export function modelFamily(provider, model) {
|
|
|
50
50
|
return 'standard'
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function cooldownBlocks(acc, family) {
|
|
53
|
+
export function cooldownBlocks(acc, family, model, nowMs = Date.now()) {
|
|
54
|
+
const modelUntil = acc && model && acc.cooldownModels && acc.cooldownModels[model]
|
|
55
|
+
if (modelUntil && Number(modelUntil) > Number(nowMs)) return true
|
|
56
|
+
if (model && acc && acc.cooldownModels && !acc.cooldownUntil) return false
|
|
54
57
|
const fams = acc && acc.cooldownFamilies
|
|
55
58
|
if (!Array.isArray(fams) || !fams.length) return true
|
|
56
59
|
if (!family) return true
|
|
@@ -79,6 +82,26 @@ export function getAccountRemainingPercent(acc) {
|
|
|
79
82
|
return null
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
export function earliestRecovery(accounts, nowMs = Date.now()) {
|
|
86
|
+
const now = Number(nowMs) || 0
|
|
87
|
+
let best = null
|
|
88
|
+
for (const account of Array.isArray(accounts) ? accounts : []) {
|
|
89
|
+
if (!account) continue
|
|
90
|
+
const candidates = []
|
|
91
|
+
if (account.cooldownUntil) candidates.push({ at: Number(account.cooldownUntil), reason: 'cooldown' })
|
|
92
|
+
if (account.quarantineUntil) candidates.push({ at: Number(account.quarantineUntil), reason: account.quarantineReason || 'quarantine' })
|
|
93
|
+
if (account.quota && account.quota.resetAt) candidates.push({ at: Number(account.quota.resetAt), reason: 'quota_reset' })
|
|
94
|
+
for (const [model, until] of Object.entries(account.cooldownModels || {})) {
|
|
95
|
+
if (Number(until) > now) candidates.push({ at: Number(until), reason: 'model_cooldown', model })
|
|
96
|
+
}
|
|
97
|
+
for (const item of candidates) {
|
|
98
|
+
if (!Number.isFinite(item.at) || item.at <= now) continue
|
|
99
|
+
if (!best || item.at < best.at) best = { ref: account.ref || account.id || 'unknown', ...item }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return best
|
|
103
|
+
}
|
|
104
|
+
|
|
82
105
|
export function poolBlockReason(account, opts) {
|
|
83
106
|
if (!account) return null
|
|
84
107
|
if (account.poolEnabled === false) return 'pool_disabled'
|
|
@@ -93,7 +116,11 @@ export function poolBlockReason(account, opts) {
|
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
96
|
-
const
|
|
119
|
+
const allAccounts = Array.isArray(accounts) ? accounts : []
|
|
120
|
+
const onlyRef = opts && opts.onlyRef
|
|
121
|
+
const list = onlyRef
|
|
122
|
+
? allAccounts.filter((account) => account && (account.ref || account.id) === onlyRef)
|
|
123
|
+
: allAccounts
|
|
97
124
|
const now = Number(nowMs) || 0
|
|
98
125
|
const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
|
|
99
126
|
const thr = Number.isFinite(thrRaw) ? thrRaw : 0
|
|
@@ -142,7 +169,8 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
142
169
|
pinned: true,
|
|
143
170
|
})
|
|
144
171
|
} else if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
|
|
145
|
-
const
|
|
172
|
+
const pinnedModelCooling = opts && opts.model && pinned.cooldownModels && Number(pinned.cooldownModels[opts.model] || 0) > now
|
|
173
|
+
const isCooldown = pinnedModelCooling || (pinned.cooldownUntil && Number(pinned.cooldownUntil) > now && cooldownBlocks(pinned, opts && opts.family, opts && opts.model, now))
|
|
146
174
|
const isPinnedZero = Boolean(pinned.quota && (
|
|
147
175
|
(pinned.quota.remaining != null && Number(pinned.quota.remaining) <= 0) ||
|
|
148
176
|
(pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
|
|
@@ -252,9 +280,17 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
252
280
|
continue
|
|
253
281
|
}
|
|
254
282
|
|
|
255
|
-
const
|
|
283
|
+
const modelCooling = opts && opts.model && acc.cooldownModels && Number(acc.cooldownModels[opts.model] || 0) > now
|
|
284
|
+
const isCooldown = modelCooling || (acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family, opts && opts.model, now))
|
|
256
285
|
if (isCooldown) {
|
|
257
|
-
evaluations.push({
|
|
286
|
+
evaluations.push({
|
|
287
|
+
ref,
|
|
288
|
+
eligible: false,
|
|
289
|
+
tier: null,
|
|
290
|
+
reason: modelCooling ? 'model_cooldown' : 'cooldown',
|
|
291
|
+
until: modelCooling ? acc.cooldownModels[opts.model] : acc.cooldownUntil,
|
|
292
|
+
...(modelCooling ? { model: opts.model } : {}),
|
|
293
|
+
})
|
|
258
294
|
continue
|
|
259
295
|
}
|
|
260
296
|
|
|
@@ -333,6 +369,11 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
333
369
|
const aIdx = list.indexOf(a)
|
|
334
370
|
const bIdx = list.indexOf(b)
|
|
335
371
|
if (aIdx !== -1 && bIdx !== -1 && aIdx !== bIdx) return aIdx - bIdx
|
|
372
|
+
} else if (strategy === 'quota-aware') {
|
|
373
|
+
const floor = opts && opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5
|
|
374
|
+
const aUrgency = calculateBurnUrgency(getAccountRemainingPercent(a), a.quota && a.quota.resetAt, now, floor)
|
|
375
|
+
const bUrgency = calculateBurnUrgency(getAccountRemainingPercent(b), b.quota && b.quota.resetAt, now, floor)
|
|
376
|
+
if (Math.abs(aUrgency - bUrgency) > 0.01) return bUrgency - aUrgency
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
// 0. Auto-Pacing: lower pacing risk preferred (#352)
|
|
@@ -358,7 +399,17 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
358
399
|
return (b.healthScore ?? 100) - (a.healthScore ?? 100)
|
|
359
400
|
})
|
|
360
401
|
|
|
361
|
-
|
|
402
|
+
let chosen = tier[0]
|
|
403
|
+
const strategy = (opts && opts.rotationStrategy) || 'round-robin'
|
|
404
|
+
if (strategy === 'quota-aware' && opts && opts.defaultRef) {
|
|
405
|
+
const byDefault = tier.find((account) => (account.ref || account.id) === opts.defaultRef)
|
|
406
|
+
if (byDefault && byDefault !== chosen) {
|
|
407
|
+
const floor = opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5
|
|
408
|
+
const chosenUrgency = calculateBurnUrgency(getAccountRemainingPercent(chosen), chosen.quota && chosen.quota.resetAt, now, floor)
|
|
409
|
+
const defaultUrgency = calculateBurnUrgency(getAccountRemainingPercent(byDefault), byDefault.quota && byDefault.quota.resetAt, now, floor)
|
|
410
|
+
if (defaultUrgency >= chosenUrgency - 5) chosen = byDefault
|
|
411
|
+
}
|
|
412
|
+
}
|
|
362
413
|
const chosenRef = chosen.ref || chosen.id
|
|
363
414
|
if (targetSession && chosen) {
|
|
364
415
|
pinSession(targetSession, chosenRef, {
|
|
@@ -382,6 +433,8 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
382
433
|
resetAt: chosen.quota?.resetAt || null,
|
|
383
434
|
weight: chosen.weight || (chosen.isPro ? 5 : 1),
|
|
384
435
|
healthScore: chosen.healthScore ?? 100,
|
|
436
|
+
urgency: calculateBurnUrgency(getAccountRemainingPercent(chosen), chosen.quota && chosen.quota.resetAt, now, opts && opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5),
|
|
437
|
+
defaultAccount: Boolean(opts && opts.defaultRef && chosenRef === opts.defaultRef),
|
|
385
438
|
},
|
|
386
439
|
},
|
|
387
440
|
}
|
|
@@ -405,12 +458,21 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
405
458
|
return res ? res.account : null
|
|
406
459
|
}
|
|
407
460
|
|
|
408
|
-
export function markCooldown(account, nowMs, cooldownMs, family, customUntil = null) {
|
|
461
|
+
export function markCooldown(account, nowMs, cooldownMs, family, customUntil = null, model = null) {
|
|
409
462
|
const wait = Number(cooldownMs)
|
|
410
463
|
const ms = Number.isFinite(wait) && wait > 0 ? wait : 30 * 60 * 1000
|
|
411
464
|
const until = (customUntil && Number(customUntil) > (Number(nowMs) || 0))
|
|
412
465
|
? Number(customUntil)
|
|
413
466
|
: ((Number(nowMs) || 0) + ms)
|
|
467
|
+
// #814: Claude/Antigravity quotas are model-specific. Auth/session errors
|
|
468
|
+
// are handled elsewhere as account-wide; ordinary model quota failures stay
|
|
469
|
+
// scoped to the failing model.
|
|
470
|
+
if (model && (account.provider === 'claude' || account.provider === 'antigravity')) {
|
|
471
|
+
return {
|
|
472
|
+
...account,
|
|
473
|
+
cooldownModels: { ...(account.cooldownModels || {}), [model]: until },
|
|
474
|
+
}
|
|
475
|
+
}
|
|
414
476
|
const prev = Array.isArray(account.cooldownFamilies) ? account.cooldownFamilies.slice() : []
|
|
415
477
|
const fams = family ? (prev.includes(family) ? prev : prev.concat(family)) : prev
|
|
416
478
|
return {
|
package/lib/stream-rotate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { inspectAccountCapabilities, normalizeCapabilitiesRequest } from './capabilities.js'
|
|
2
2
|
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
3
3
|
import { recordSessionCacheUsage } from './session-pin.js'
|
|
4
|
-
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily } from './rotate.js'
|
|
4
|
+
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily, earliestRecovery } from './rotate.js'
|
|
5
5
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } from './quarantine.js'
|
|
6
6
|
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
7
7
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
@@ -52,6 +52,8 @@ export async function* streamWithRotation({
|
|
|
52
52
|
const pickRes = pickAccountWithTrace(pool, nowMs(), {
|
|
53
53
|
switchAtRemaining,
|
|
54
54
|
model: options && options.model,
|
|
55
|
+
onlyRef: options && options.onlyRef,
|
|
56
|
+
defaultRef: options && options.defaultRef,
|
|
55
57
|
family: modelFamily(options && options.provider, options && options.model),
|
|
56
58
|
sessionId: options && options.sessionId,
|
|
57
59
|
tag: options && options.tag,
|
|
@@ -96,10 +98,13 @@ export async function* streamWithRotation({
|
|
|
96
98
|
lastError.retryAfterMs = Math.min(wait, maxWait)
|
|
97
99
|
lastError.resetAt = nowMs() + lastError.retryAfterMs
|
|
98
100
|
}
|
|
101
|
+
lastError.recovery = lastError.recovery || earliestRecovery(pool, nowMs())
|
|
99
102
|
throw lastError
|
|
100
103
|
}
|
|
101
|
-
const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
104
|
+
const err = /** @type {Error & { code?: string, recovery?: any, onlyRef?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
102
105
|
err.code = 'RATE_LIMIT'
|
|
106
|
+
err.recovery = earliestRecovery(pool, nowMs())
|
|
107
|
+
if (options && options.onlyRef) err.onlyRef = options.onlyRef
|
|
103
108
|
throw err
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -233,9 +238,10 @@ export async function* streamWithRotation({
|
|
|
233
238
|
|
|
234
239
|
// Move slot to cooldown and quarantine (#172, #538)
|
|
235
240
|
const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : (isAuth ? 86400000 : cooldownMs)
|
|
236
|
-
const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
|
|
241
|
+
const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil, options && options.model)
|
|
237
242
|
account.cooldownUntil = cooled.cooldownUntil
|
|
238
243
|
if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
|
|
244
|
+
if (cooled.cooldownModels) account.cooldownModels = cooled.cooldownModels
|
|
239
245
|
|
|
240
246
|
const status = Number(err && (err.status || err.statusCode) || 0)
|
|
241
247
|
const code = String(err && err.code || '')
|
package/lib/subscriptions.js
CHANGED
|
@@ -299,9 +299,10 @@ export function createSubscriptionsService(deps) {
|
|
|
299
299
|
currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
|
|
300
300
|
lastError = err
|
|
301
301
|
if (!isSwitchableError(err)) throw err
|
|
302
|
-
const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
|
|
302
|
+
const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model), null, body && body.model)
|
|
303
303
|
account.cooldownUntil = cooled.cooldownUntil
|
|
304
|
-
if (
|
|
304
|
+
if (cooled.cooldownModels) account.cooldownModels = cooled.cooldownModels
|
|
305
|
+
if (typeof deps.rememberCooldown === "function") deps.rememberCooldown(account.ref, account.cooldownUntil, cooled.cooldownFamilies || null, cooled.cooldownModels || null)
|
|
305
306
|
// try next account
|
|
306
307
|
}
|
|
307
308
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.30",
|
|
4
4
|
"description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|