@goodandready/dsh-subscriptions 0.6.60 → 0.6.62

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/client.js CHANGED
@@ -122,6 +122,10 @@ window.__ModuleLoader__.load({
122
122
  '.dsub-stat-box{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:3px}' +
123
123
  '.dsub-stat-val{font-size:16.5px;font-weight:700;color:var(--dsw-alias-label-primary)}' +
124
124
  '.dsub-stat-lbl{font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
125
+ '.dsub-chart-wrap{display:flex;align-items:flex-end;gap:3px;height:52px;padding:6px 0;margin-top:8px;border-bottom:1px solid var(--dsw-alias-border-l3)}' +
126
+ '.dsub-chart-bar{flex:1;background:var(--dsw-alias-brand-primary);border-radius:2px 2px 0 0;min-height:3px;transition:height .2s ease;opacity:0.85;cursor:pointer}' +
127
+ '.dsub-chart-bar:hover{opacity:1;background:var(--dsw-alias-state-success-primary)}' +
128
+ '.dsub-chart-labels{display:flex;justify-content:space-between;font-size:10px;color:var(--dsw-alias-label-secondary);margin-top:3px}' +
125
129
  '.dsub-alert-ok{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 25%,transparent)}' +
126
130
  '.dsub-alert-bad{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 25%,transparent)}' +
127
131
  '.dsub-btn-primary{background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;border-color:transparent !important;font-weight:600}' +
@@ -130,6 +134,15 @@ window.__ModuleLoader__.load({
130
134
  '.dsub-model-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-size:11.5px;color:var(--dsw-alias-label-primary)}' +
131
135
  '.dsub-model-chip code{font-family:ui-monospace,monospace;font-weight:600}' +
132
136
  '.dsub-model-tag{font-size:10px;padding:1px 4px;border-radius:3px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-weight:600}' +
137
+ '.dsub-trace-box{padding:8px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);margin-top:8px;font-size:12px}' +
138
+ '.dsub-trace-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l3)}' +
139
+ '.dsub-trace-row:last-child{border-bottom:none}' +
140
+ '.dsub-model-status-available{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent)}' +
141
+ '.dsub-model-status-degraded{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}' +
142
+ '.dsub-model-status-quarantined{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}' +
143
+ '.dsub-model-status-cooldown{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}' +
144
+ '.dsub-model-status-exhausted{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}' +
145
+ '.dsub-model-status-unconfigured{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3)}' +
133
146
  '.dsub-heroBars{display:flex;flex-direction:column;gap:8px;margin-top:10px}' +
134
147
  '.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}' +
135
148
  '.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
@@ -408,6 +421,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
408
421
  "smokeFail": "Smoke test failed",
409
422
  "modelsCatalogTitle": "Curated Subscription Models",
410
423
  "modelsCatalogDesc": "Available model catalog across active subscriptions in DeepSeek Harness.",
424
+ "rotationDecisionsTitle": "Rotation & Routing Decisions",
425
+ "rotationDecisionsDesc": "Real-time decision trace explaining account selection, skips, and fallback reasons.",
426
+ "rotationAttempt": "attempt",
427
+ "rotationAttempts": "attempts",
428
+ "rotationCascaded": "Cascaded fallback",
429
+ "modelPoolAvailable": "Available",
430
+ "modelPoolDegraded": "Degraded",
431
+ "modelPoolQuarantined": "Quarantined",
432
+ "modelPoolCooldown": "Cooldown",
433
+ "modelPoolExhausted": "Exhausted",
434
+ "modelPoolUnconfigured": "Unconfigured",
411
435
  "resetCredits": "Reset credits",
412
436
  "resetCreditsHint": "ChatGPT reset cards: emergency reset for the 5-hour window. Requires a 5s confirmation.",
413
437
  "resetCreditsAction": "Reset ChatGPT Limit",
@@ -549,6 +573,27 @@ const cssId = 'dsh-subscriptions/settings.module.css'
549
573
  "toastReleased": "Account active and healthy",
550
574
  "toastQuotaWarning": "Quota threshold exceeded",
551
575
  "enableToasts": "In-app toast alerts",
576
+ "groupQueue": "Concurrency & Request Queue",
577
+ "enableQueue": "Enable request queue",
578
+ "enableQueueHint": "Queue excess requests when account hits concurrency limit instead of failing with 429",
579
+ "accountMaxConcurrency": "Max concurrent streams per account",
580
+ "accountMaxConcurrencyHint": "Maximum simultaneous streaming requests allowed per account (default 2)",
581
+ "maxQueueSize": "Max queue depth",
582
+ "maxQueueSizeHint": "Maximum queued requests per account before rejecting with overflow (default 20)",
583
+ "queueTimeoutSec": "Queue wait timeout (seconds)",
584
+ "queueTimeoutSecHint": "Maximum wait time in queue before timeout rejection (default 30s)",
585
+ "analyticsTitle": "Subscription & Cache Analytics",
586
+ "analyticsDesc": "Cache hit rate, token caching efficiency, TTFT latency, generation speed, and 24h activity",
587
+ "ttftMetric": "TTFT Latency",
588
+ "speedMetric": "Speed",
589
+ "queueActive": "Active Streams",
590
+ "queueWaiting": "Queued",
591
+ "queueRejected": "Rejected",
592
+ "activity24h": "24-Hour Activity Distribution",
593
+ "forecastTitle": "Quota Exhaustion Forecast",
594
+ "forecastRunway": "Estimated runway",
595
+ "forecastBurnRate": "Burn rate",
596
+ "forecastExhaustWarning": "Quota projected to exhaust before reset window",
552
597
  "field.enableToasts": "Show popup toast notifications on quarantine or quota events"
553
598
  };
554
599
 
@@ -619,6 +664,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
619
664
  "smokeFail": "冒烟测试失败",
620
665
  "modelsCatalogTitle": "精选订阅模型",
621
666
  "modelsCatalogDesc": "当前 DeepSeek Harness 活跃订阅下可用模型一览。",
667
+ "rotationDecisionsTitle": "轮询调度与路由决策",
668
+ "rotationDecisionsDesc": "实时决策追踪,透明展示每次请求的账号选择、跳过与回退原因。",
669
+ "rotationAttempt": "次尝试",
670
+ "rotationAttempts": "次尝试",
671
+ "rotationCascaded": "级联回退",
672
+ "modelPoolAvailable": "就绪可用",
673
+ "modelPoolDegraded": "降级运行",
674
+ "modelPoolQuarantined": "已隔离",
675
+ "modelPoolCooldown": "冷却中",
676
+ "modelPoolExhausted": "额度耗尽",
677
+ "modelPoolUnconfigured": "未配置",
622
678
  "resetCredits": "重置 ChatGPT 额度",
623
679
  "resetCreditsHint": "ChatGPT 重置卡:紧急重置 5 小时额度窗口。扣除前需等待 5 秒倒计时确认。",
624
680
  "resetCreditsAction": "重置 ChatGPT 限制",
@@ -760,6 +816,27 @@ const cssId = 'dsh-subscriptions/settings.module.css'
760
816
  "toastReleased": "账户已解除隔离恢复使用",
761
817
  "toastQuotaWarning": "额度预警",
762
818
  "enableToasts": "应用内弹窗提醒",
819
+ "groupQueue": "并发限制与请求队列",
820
+ "enableQueue": "启用并发请求排队",
821
+ "enableQueueHint": "当账户达到并发上限时将多余请求排队等待,避免直接触发 429 报错",
822
+ "accountMaxConcurrency": "单账户最大并发流数",
823
+ "accountMaxConcurrencyHint": "每个账户允许同时执行的最大流式请求数(默认 2)",
824
+ "maxQueueSize": "队列最大等待深度",
825
+ "maxQueueSizeHint": "每个账户允许排队的最大请求数,超限后拒绝执行(默认 20)",
826
+ "queueTimeoutSec": "排队超时时间(秒)",
827
+ "queueTimeoutSecHint": "在队列中等待槽位的最长时间,超时后自动放弃并回退(默认 30 秒)",
828
+ "analyticsTitle": "订阅与缓存深度分析",
829
+ "analyticsDesc": "缓存命中率、Token 缓存效率、首字响应延迟 (TTFT)、生成速度及 24 小时负载分布",
830
+ "ttftMetric": "首字响应延迟 (TTFT)",
831
+ "speedMetric": "生成速度",
832
+ "queueActive": "活跃流",
833
+ "queueWaiting": "排队中",
834
+ "queueRejected": "已拒绝",
835
+ "activity24h": "24 小时活动分布",
836
+ "forecastTitle": "额度消耗预测与预警",
837
+ "forecastRunway": "预计可用时间",
838
+ "forecastBurnRate": "消耗速率",
839
+ "forecastExhaustWarning": "预计在额度重置前耗尽",
763
840
  "field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
764
841
  };
765
842
  // #51: live countdown to the quota window reset (account.quota.resetAt).
@@ -878,6 +955,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
878
955
  const [draft, setDraft] = React.useState(null)
879
956
  const [accounts, setAccounts] = React.useState([])
880
957
  const [providers, setProviders] = React.useState([])
958
+ const [modelsCatalog, setModelsCatalog] = React.useState([])
881
959
  const [paste, setPaste] = React.useState({})
882
960
  const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
883
961
  const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
@@ -983,6 +1061,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
983
1061
  const [checkRes, setCheckRes] = React.useState({})
984
1062
  const [hostPing, setHostPing] = React.useState({ ok: true, latencyMs: null })
985
1063
  const [telemetry, setTelemetry] = React.useState(null)
1064
+ const [analytics, setAnalytics] = React.useState(null)
1065
+ const [queueStats, setQueueStats] = React.useState(null)
986
1066
  const [smoke, setSmoke] = React.useState({ testing: false, result: null })
987
1067
  const [updater, setUpdater] = React.useState({ checking: false, updating: false, status: null, message: '' })
988
1068
  const [backupModal, setBackupModal] = React.useState(null)
@@ -1085,6 +1165,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1085
1165
  .then((r) => r.json())
1086
1166
  .then((d) => { if (d && d.ok && d.telemetry) setTelemetry(d.telemetry) })
1087
1167
  .catch(() => {})
1168
+ fetch('/dsh-subscriptions/analytics', { cache: 'no-store' })
1169
+ .then((r) => r.json())
1170
+ .then((d) => { if (d && d.ok && d.analytics) setAnalytics(d.analytics) })
1171
+ .catch(() => {})
1172
+ fetch('/dsh-subscriptions/queue', { cache: 'no-store' })
1173
+ .then((r) => r.json())
1174
+ .then((d) => { if (d && d.ok && d.queue) setQueueStats(d.queue) })
1175
+ .catch(() => {})
1088
1176
  }
1089
1177
 
1090
1178
  const runSmokeTest = async (provider, index) => {
@@ -1115,6 +1203,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1115
1203
  setDraft(JSON.parse(JSON.stringify((data && data.config) || {})))
1116
1204
  setAccounts((data && data.accounts) || [])
1117
1205
  setProviders((data && data.providers) || [])
1206
+ setModelsCatalog((data && data.modelsCatalog) || [])
1118
1207
  }
1119
1208
 
1120
1209
  const reload = () => {
@@ -1473,6 +1562,88 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1473
1562
  React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
1474
1563
  ),
1475
1564
  ) : null,
1565
+ (analytics && (analytics.totalRequests > 0 || (analytics.hourly && analytics.hourly.some(function(b) { return b.requests > 0 })))) || (queueStats && (queueStats.totalActive > 0 || queueStats.totalQueued > 0)) ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
1566
+ React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + t('analyticsTitle')),
1567
+ React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('analyticsDesc')),
1568
+ React.createElement('div', { className: 'dsub-grid-2', style: { marginTop: '10px' } },
1569
+ React.createElement('div', { className: 'dsub-stat-box' },
1570
+ React.createElement('div', { className: 'dsub-stat-val' }, ((analytics && analytics.cache && analytics.cache.cacheHitRate) || 0) + '%'),
1571
+ React.createElement('div', { className: 'dsub-stat-lbl' }, t('cacheHitRate') + (analytics && analytics.cache ? ' (' + (analytics.cache.cacheReadTokens || 0).toLocaleString() + ' tok)' : '')),
1572
+ ),
1573
+ React.createElement('div', { className: 'dsub-stat-box' },
1574
+ React.createElement('div', { className: 'dsub-stat-val' }, (analytics && analytics.performance && analytics.performance.ttft && analytics.performance.ttft.avgMs != null ? analytics.performance.ttft.avgMs + ' ms' : '—')),
1575
+ React.createElement('div', { className: 'dsub-stat-lbl' }, t('ttftMetric') + (analytics && analytics.performance && analytics.performance.ttft && analytics.performance.ttft.p95Ms != null ? ' · p95 ' + analytics.performance.ttft.p95Ms + ' ms' : '')),
1576
+ ),
1577
+ React.createElement('div', { className: 'dsub-stat-box' },
1578
+ React.createElement('div', { className: 'dsub-stat-val' }, (analytics && analytics.performance && analytics.performance.tps && analytics.performance.tps.avg != null ? analytics.performance.tps.avg + ' tok/s' : '—')),
1579
+ React.createElement('div', { className: 'dsub-stat-lbl' }, t('speedMetric') + (analytics && analytics.performance && analytics.performance.tps && analytics.performance.tps.p95 != null ? ' · p95 ' + analytics.performance.tps.p95 : '')),
1580
+ ),
1581
+ React.createElement('div', { className: 'dsub-stat-box' },
1582
+ React.createElement('div', { className: 'dsub-stat-val' }, (queueStats ? queueStats.totalActive : 0) + ' / ' + (queueStats ? queueStats.totalQueued : 0)),
1583
+ React.createElement('div', { className: 'dsub-stat-lbl' }, t('queueActive') + ' / ' + t('queueWaiting') + (queueStats && queueStats.metrics && queueStats.metrics.totalOverflowed ? ' · ' + queueStats.metrics.totalOverflowed + ' ' + t('queueRejected') : '')),
1584
+ ),
1585
+ ),
1586
+ analytics && Array.isArray(analytics.hourly) && analytics.hourly.length ? (function() {
1587
+ var maxReq = Math.max.apply(Math, [1].concat(analytics.hourly.map(function(b) { return b.requests || 0 })))
1588
+ return React.createElement('div', { style: { marginTop: '12px' } },
1589
+ React.createElement('div', { style: { fontSize: '11.5px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('activity24h')),
1590
+ React.createElement('div', { className: 'dsub-chart-wrap' },
1591
+ analytics.hourly.map(function(b, idx) {
1592
+ var pct = Math.max(4, Math.round(((b.requests || 0) / maxReq) * 100))
1593
+ var title = b.label + ': ' + b.requests + ' req (' + b.cacheReadTokens + ' cached tok, ' + b.errorCount + ' err)'
1594
+ return React.createElement('div', {
1595
+ key: idx,
1596
+ className: 'dsub-chart-bar',
1597
+ style: {
1598
+ height: pct + '%',
1599
+ backgroundColor: b.errorCount > 0 && b.requests === b.errorCount
1600
+ ? 'var(--dsw-alias-state-error-primary)'
1601
+ : (b.cacheReadTokens > 0 ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-state-success-primary)'),
1602
+ },
1603
+ title: title,
1604
+ })
1605
+ })
1606
+ ),
1607
+ React.createElement('div', { className: 'dsub-chart-labels' },
1608
+ React.createElement('span', null, '-24h'),
1609
+ React.createElement('span', null, '-18h'),
1610
+ React.createElement('span', null, '-12h'),
1611
+ React.createElement('span', null, '-6h'),
1612
+ React.createElement('span', null, 'Now'),
1613
+ ),
1614
+ )
1615
+ })() : null,
1616
+ analytics && Array.isArray(analytics.forecast) && analytics.forecast.some(function(f) { return f.willExhaustBeforeReset || (f.burnRateTokensPerHour > 0 && f.hoursToExhaustion != null && f.hoursToExhaustion < 72) }) ? React.createElement('div', { style: { marginTop: '12px' } },
1617
+ React.createElement('div', { style: { fontSize: '11.5px', fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary)', marginBottom: '6px' } }, '⚠️ ' + t('forecastTitle')),
1618
+ analytics.forecast.filter(function(f) { return f.willExhaustBeforeReset || (f.burnRateTokensPerHour > 0 && f.hoursToExhaustion != null && f.hoursToExhaustion < 72) }).map(function(f, idx) {
1619
+ return React.createElement('div', { key: idx, className: 'dsub-alert-bad', style: { marginBottom: '6px' } },
1620
+ (f.provider ? f.provider.toUpperCase() : 'LLM') + ' (' + (f.label || f.ref) + '): ' +
1621
+ (f.willExhaustBeforeReset ? t('forecastExhaustWarning') : (t('forecastRunway') + ': ~' + f.hoursToExhaustion + 'h')) +
1622
+ ' · ' + t('forecastBurnRate') + ': ' + f.burnRateTokensPerHour.toLocaleString() + ' tok/h'
1623
+ )
1624
+ })
1625
+ ) : null,
1626
+ ) : null,
1627
+ telemetry && Array.isArray(telemetry.recentDecisions) && telemetry.recentDecisions.length ? React.createElement('details', { className: 'dsub-group', style: { marginBottom: '14px' } },
1628
+ React.createElement('summary', { className: 'dsub-groupHead' }, '🧭 ' + t('rotationDecisionsTitle')),
1629
+ React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('rotationDecisionsDesc')),
1630
+ React.createElement('div', { className: 'dsub-trace-box' },
1631
+ telemetry.recentDecisions.map((dec, idx) => {
1632
+ const reasonBadge = dec.cascaded ? ('🔀 ' + t('rotationCascaded')) : (dec.decisionReason === 'session_pinned' ? '📌 Pinned' : ('⚡ ' + dec.decisionReason))
1633
+ return React.createElement('div', { key: idx, className: 'dsub-trace-row' },
1634
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
1635
+ dec.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + dec.provider, style: { fontSize: '9px', padding: '1px 4px' } }, dec.provider.toUpperCase()) : null,
1636
+ React.createElement('span', { style: { fontWeight: 600 } }, dec.ref || '—'),
1637
+ dec.model ? React.createElement('code', { style: { fontSize: '11px', opacity: 0.8 } }, dec.model) : null,
1638
+ ),
1639
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1640
+ React.createElement('span', { className: 'dsub-model-tag' }, reasonBadge),
1641
+ React.createElement('span', { style: { fontSize: '10.5px', opacity: 0.6 } }, dec.attemptsCount > 1 ? (dec.attemptsCount + ' ' + t('rotationAttempts')) : ('1 ' + t('rotationAttempt'))),
1642
+ ),
1643
+ )
1644
+ })
1645
+ ),
1646
+ ) : null,
1476
1647
  accounts && accounts.length ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
1477
1648
  React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + (t('healthMatrix') || 'Health Matrix & Quota Runway')),
1478
1649
  React.createElement('div', { style: { padding: '8px 0', overflowX: 'auto' } },
@@ -1587,6 +1758,57 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1587
1758
  React.createElement('div', { className: 'dsub-sub' },
1588
1759
  t('expiryNotifyDaysHint')),
1589
1760
  ),
1761
+ React.createElement('details', { className: 'dsub-group', open: true },
1762
+ React.createElement('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
1763
+ React.createElement('label', { className: 'dsub-row' },
1764
+ React.createElement('input', {
1765
+ type: 'checkbox',
1766
+ checked: draft.enableQueue !== false,
1767
+ onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { enableQueue: e.target.checked }) }) },
1768
+ }),
1769
+ React.createElement('span', null, t('enableQueue')),
1770
+ ),
1771
+ React.createElement('div', { className: 'dsub-sub' }, t('enableQueueHint')),
1772
+ React.createElement('div', { className: 'dsub-row' },
1773
+ React.createElement('span', { className: 'dsub-h' }, t('accountMaxConcurrency')),
1774
+ React.createElement('input', {
1775
+ type: 'number',
1776
+ className: 'dsub-inp',
1777
+ style: { width: '80px', minWidth: '80px' },
1778
+ min: 1,
1779
+ max: 20,
1780
+ value: draft.accountMaxConcurrency != null ? draft.accountMaxConcurrency : 2,
1781
+ onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { accountMaxConcurrency: Math.max(1, Number(e.target.value) || 2) }) }) },
1782
+ }),
1783
+ ),
1784
+ React.createElement('div', { className: 'dsub-sub' }, t('accountMaxConcurrencyHint')),
1785
+ React.createElement('div', { className: 'dsub-row' },
1786
+ React.createElement('span', { className: 'dsub-h' }, t('maxQueueSize')),
1787
+ React.createElement('input', {
1788
+ type: 'number',
1789
+ className: 'dsub-inp',
1790
+ style: { width: '80px', minWidth: '80px' },
1791
+ min: 1,
1792
+ max: 100,
1793
+ value: draft.maxQueueSize != null ? draft.maxQueueSize : 20,
1794
+ onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { maxQueueSize: Math.max(1, Number(e.target.value) || 20) }) }) },
1795
+ }),
1796
+ ),
1797
+ React.createElement('div', { className: 'dsub-sub' }, t('maxQueueSizeHint')),
1798
+ React.createElement('div', { className: 'dsub-row' },
1799
+ React.createElement('span', { className: 'dsub-h' }, t('queueTimeoutSec')),
1800
+ React.createElement('input', {
1801
+ type: 'number',
1802
+ className: 'dsub-inp',
1803
+ style: { width: '80px', minWidth: '80px' },
1804
+ min: 1,
1805
+ max: 120,
1806
+ value: Math.round((draft.queueTimeoutMs != null ? draft.queueTimeoutMs : 30000) / 1000),
1807
+ onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { queueTimeoutMs: Math.max(1, Number(e.target.value) || 30) * 1000 }) }) },
1808
+ }),
1809
+ ),
1810
+ React.createElement('div', { className: 'dsub-sub' }, t('queueTimeoutSecHint')),
1811
+ ),
1590
1812
  React.createElement('details', { className: 'dsub-group', open: true },
1591
1813
  React.createElement('summary', { className: 'dsub-groupHead' }, t('groupProvider')),
1592
1814
  React.createElement('label', { className: 'dsub-row' },
@@ -1648,14 +1870,51 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1648
1870
  React.createElement('summary', { className: 'dsub-groupHead' }, '🎯 ' + t('modelsCatalogTitle')),
1649
1871
  React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('modelsCatalogDesc')),
1650
1872
  React.createElement('div', { className: 'dsub-model-grid' },
1651
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'gpt-4o'), React.createElement('span', { className: 'dsub-model-tag' }, '128k · Vision')),
1652
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'o1 / o3-mini'), React.createElement('span', { className: 'dsub-model-tag' }, '200k · Reasoning')),
1653
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'claude-3-7-sonnet'), React.createElement('span', { className: 'dsub-model-tag' }, '200k · Hybrid')),
1654
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'claude-3-5-sonnet'), React.createElement('span', { className: 'dsub-model-tag' }, '200k · Coding')),
1655
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'grok-beta'), React.createElement('span', { className: 'dsub-model-tag' }, '128k · Speed')),
1656
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'gemini-2.5-pro'), React.createElement('span', { className: 'dsub-model-tag' }, '1M · Context')),
1657
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'kimi-k1.5'), React.createElement('span', { className: 'dsub-model-tag' }, '128k · Math')),
1658
- React.createElement('div', { className: 'dsub-model-chip' }, React.createElement('code', null, 'glm-4-plus'), React.createElement('span', { className: 'dsub-model-tag' }, '128k · Chinese')),
1873
+ (Array.isArray(modelsCatalog) && modelsCatalog.length ? modelsCatalog : [
1874
+ { id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: 'available' } },
1875
+ { id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
1876
+ { id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
1877
+ { id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: 'available' } },
1878
+ { id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: 'available' } },
1879
+ { id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: 'available' } },
1880
+ { id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: 'available' } },
1881
+ { id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: 'available' } },
1882
+ ]).map((m, idx) => {
1883
+ const poolStatus = m.pool ? m.pool.status : 'unconfigured'
1884
+ const statusClass = 'dsub-model-status-' + poolStatus
1885
+ let poolLabel = ''
1886
+ if (poolStatus === 'available') {
1887
+ poolLabel = '🟢 ' + (m.pool && m.pool.remainingPercent != null ? (m.pool.remainingPercent + '%') : t('modelPoolAvailable'))
1888
+ } else if (poolStatus === 'degraded') {
1889
+ poolLabel = '🟡 ' + t('modelPoolDegraded') + (m.pool ? ' (' + m.pool.healthyAccounts + '/' + m.pool.totalAccounts + ')' : '')
1890
+ } else if (poolStatus === 'quarantined') {
1891
+ poolLabel = '⛔ ' + t('modelPoolQuarantined')
1892
+ } else if (poolStatus === 'cooldown') {
1893
+ poolLabel = '❄️ ' + t('modelPoolCooldown')
1894
+ } else if (poolStatus === 'exhausted') {
1895
+ poolLabel = '⚠️ ' + t('modelPoolExhausted')
1896
+ } else {
1897
+ poolLabel = '⚪ ' + t('modelPoolUnconfigured')
1898
+ }
1899
+
1900
+ const tags = []
1901
+ if (m.contextLabel) tags.push(m.contextLabel)
1902
+ if (Array.isArray(m.capabilities)) {
1903
+ for (const c of m.capabilities) {
1904
+ if (c === 'vision') tags.push('Vision')
1905
+ else if (c === 'reasoning') tags.push('Reasoning')
1906
+ else if (c === 'coding') tags.push('Coding')
1907
+ else if (c === 'fast') tags.push('Fast')
1908
+ }
1909
+ }
1910
+
1911
+ return React.createElement('div', { key: (m.id || idx) + '_' + idx, className: 'dsub-model-chip' },
1912
+ m.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + m.provider, style: { fontSize: '9px', padding: '1px 4px' } }, m.provider.toUpperCase()) : null,
1913
+ React.createElement('code', null, m.name || m.id),
1914
+ tags.length ? React.createElement('span', { className: 'dsub-model-tag' }, tags.join(' · ')) : null,
1915
+ React.createElement('span', { className: 'dsub-model-tag ' + statusClass }, poolLabel),
1916
+ )
1917
+ })
1659
1918
  ),
1660
1919
  ),
1661
1920
  React.createElement('details', { className: 'dsub-details' },
@@ -0,0 +1,222 @@
1
+ // Concurrency limiting, bounded queue, and cooperative cancellation per account/provider.
2
+ // Standardized error codes: QUEUE_OVERFLOW, QUEUE_TIMEOUT, ABORT_ERR, QUEUE_DISPOSED.
3
+
4
+ /**
5
+ * @param {string} message
6
+ * @param {string} code
7
+ * @param {number} [status]
8
+ * @returns {Error & { code: string, status?: number }}
9
+ */
10
+ function createQueueError(message, code, status) {
11
+ const err = /** @type {Error & { code: string, status?: number }} */ (new Error(message))
12
+ err.code = code
13
+ if (status !== undefined) err.status = status
14
+ return err
15
+ }
16
+
17
+ export class ConcurrencyQueue {
18
+ constructor(options = {}) {
19
+ this.maxConcurrency = options.maxConcurrency !== undefined ? Number(options.maxConcurrency) : 2
20
+ this.maxQueueSize = options.maxQueueSize !== undefined ? Number(options.maxQueueSize) : 20
21
+ this.timeoutMs = options.timeoutMs !== undefined ? Number(options.timeoutMs) : 30000
22
+ this.enabled = options.enabled !== undefined ? Boolean(options.enabled) : true
23
+
24
+ /** @type {Map<string, number>} */
25
+ this._active = new Map()
26
+ /** @type {Map<string, Array<{ id: string, resolve: Function, reject: Function, timer: any, signal: any, abortHandler: Function | null, enqueuedAt: number }>>} */
27
+ this._waiting = new Map()
28
+
29
+ this._stats = {
30
+ totalAcquired: 0,
31
+ totalReleased: 0,
32
+ totalQueued: 0,
33
+ totalTimedOut: 0,
34
+ totalOverflowed: 0,
35
+ totalAborted: 0,
36
+ }
37
+ }
38
+
39
+ configure(options = {}) {
40
+ if (options.maxConcurrency !== undefined) this.maxConcurrency = Number(options.maxConcurrency)
41
+ if (options.maxQueueSize !== undefined) this.maxQueueSize = Number(options.maxQueueSize)
42
+ if (options.timeoutMs !== undefined) this.timeoutMs = Number(options.timeoutMs)
43
+ if (options.enabled !== undefined) this.enabled = Boolean(options.enabled)
44
+ }
45
+
46
+ async acquire(key = 'default', options = {}) {
47
+ if (!this.enabled) {
48
+ return () => {}
49
+ }
50
+
51
+ const signal = options.signal
52
+ if (signal && signal.aborted) {
53
+ const err = createQueueError('Request aborted before acquiring queue slot', 'ABORT_ERR')
54
+ err.name = 'AbortError'
55
+ this._stats.totalAborted++
56
+ throw err
57
+ }
58
+
59
+ const k = String(key || 'default')
60
+ const active = this._active.get(k) || 0
61
+ const limit = options.maxConcurrency !== undefined ? Number(options.maxConcurrency) : this.maxConcurrency
62
+
63
+ if (active < limit) {
64
+ this._active.set(k, active + 1)
65
+ this._stats.totalAcquired++
66
+ return this._createRelease(k)
67
+ }
68
+
69
+ // Queue is required
70
+ const queueList = this._waiting.get(k) || []
71
+ const maxQ = options.maxQueueSize !== undefined ? Number(options.maxQueueSize) : this.maxQueueSize
72
+
73
+ if (queueList.length >= maxQ) {
74
+ this._stats.totalOverflowed++
75
+ const err = createQueueError(`Concurrency queue overflow for ${k} (max queue size: ${maxQ})`, 'QUEUE_OVERFLOW', 429)
76
+ throw err
77
+ }
78
+
79
+ const timeoutDuration = options.timeoutMs !== undefined ? Number(options.timeoutMs) : this.timeoutMs
80
+ this._stats.totalQueued++
81
+
82
+ return new Promise((resolve, reject) => {
83
+ let timer = null
84
+ let abortHandler = null
85
+ const id = Math.random().toString(36).slice(2)
86
+
87
+ const cleanup = () => {
88
+ if (timer) clearTimeout(timer)
89
+ if (signal && abortHandler && typeof signal.removeEventListener === 'function') {
90
+ signal.removeEventListener('abort', abortHandler)
91
+ }
92
+ }
93
+
94
+ if (timeoutDuration > 0) {
95
+ timer = setTimeout(() => {
96
+ cleanup()
97
+ this._removeFromWaiting(k, id)
98
+ this._stats.totalTimedOut++
99
+ const err = createQueueError(`Concurrency queue wait timeout for ${k} after ${timeoutDuration}ms`, 'QUEUE_TIMEOUT', 504)
100
+ reject(err)
101
+ }, timeoutDuration)
102
+ }
103
+
104
+ if (signal) {
105
+ abortHandler = () => {
106
+ cleanup()
107
+ this._removeFromWaiting(k, id)
108
+ this._stats.totalAborted++
109
+ const err = createQueueError('Request aborted while waiting in concurrency queue', 'ABORT_ERR')
110
+ err.name = 'AbortError'
111
+ reject(err)
112
+ }
113
+ signal.addEventListener('abort', abortHandler, { once: true })
114
+ }
115
+
116
+ const item = {
117
+ id,
118
+ resolve: () => {
119
+ cleanup()
120
+ this._stats.totalAcquired++
121
+ resolve(this._createRelease(k))
122
+ },
123
+ reject: (err) => {
124
+ cleanup()
125
+ reject(err)
126
+ },
127
+ timer,
128
+ signal,
129
+ abortHandler,
130
+ enqueuedAt: Date.now(),
131
+ }
132
+
133
+ if (!this._waiting.has(k)) {
134
+ this._waiting.set(k, [])
135
+ }
136
+ this._waiting.get(k).push(item)
137
+ })
138
+ }
139
+
140
+ _createRelease(k) {
141
+ let released = false
142
+ return () => {
143
+ if (released) return
144
+ released = true
145
+ this._release(k)
146
+ }
147
+ }
148
+
149
+ _release(k) {
150
+ this._stats.totalReleased++
151
+ const waiting = this._waiting.get(k)
152
+ if (waiting && waiting.length > 0) {
153
+ const next = waiting.shift()
154
+ if (waiting.length === 0) this._waiting.delete(k)
155
+ // Hand over slot directly to next queued consumer
156
+ next.resolve()
157
+ } else {
158
+ const active = this._active.get(k) || 0
159
+ if (active <= 1) {
160
+ this._active.delete(k)
161
+ } else {
162
+ this._active.set(k, active - 1)
163
+ }
164
+ }
165
+ }
166
+
167
+ _removeFromWaiting(k, id) {
168
+ const list = this._waiting.get(k)
169
+ if (!list) return
170
+ const idx = list.findIndex((item) => item.id === id)
171
+ if (idx !== -1) {
172
+ list.splice(idx, 1)
173
+ if (list.length === 0) this._waiting.delete(k)
174
+ }
175
+ }
176
+
177
+ stats() {
178
+ let totalActive = 0
179
+ let totalQueued = 0
180
+ const byKey = {}
181
+
182
+ for (const [k, count] of this._active) {
183
+ totalActive += count
184
+ if (!byKey[k]) byKey[k] = { active: 0, queued: 0 }
185
+ byKey[k].active = count
186
+ }
187
+
188
+ for (const [k, list] of this._waiting) {
189
+ totalQueued += list.length
190
+ if (!byKey[k]) byKey[k] = { active: 0, queued: 0 }
191
+ byKey[k].queued = list.length
192
+ }
193
+
194
+ return {
195
+ enabled: this.enabled,
196
+ maxConcurrency: this.maxConcurrency,
197
+ maxQueueSize: this.maxQueueSize,
198
+ timeoutMs: this.timeoutMs,
199
+ totalActive,
200
+ totalQueued,
201
+ byKey,
202
+ metrics: { ...this._stats },
203
+ }
204
+ }
205
+
206
+ dispose() {
207
+ for (const [, list] of this._waiting) {
208
+ for (const item of list) {
209
+ if (item.timer) clearTimeout(item.timer)
210
+ if (item.signal && item.abortHandler && typeof item.signal.removeEventListener === 'function') {
211
+ item.signal.removeEventListener('abort', item.abortHandler)
212
+ }
213
+ const err = createQueueError('Concurrency queue was disposed', 'QUEUE_DISPOSED')
214
+ item.reject(err)
215
+ }
216
+ }
217
+ this._waiting.clear()
218
+ this._active.clear()
219
+ }
220
+ }
221
+
222
+ export const defaultConcurrencyQueue = new ConcurrencyQueue()
@@ -118,6 +118,14 @@ export const Config = z.object({
118
118
  .description('#485 Prioritize accounts with closest reset window to burn expiring quota first.'),
119
119
  drainSafetyPercent: z.number().volatile().default(5)
120
120
  .description('#485 Safety margin percent: avoid draining accounts below this threshold when alternatives exist.'),
121
+ enableQueue: z.boolean().volatile().default(true)
122
+ .description('#518 Bounded concurrency queue to prevent bursts of 429 errors.'),
123
+ accountMaxConcurrency: z.number().volatile().default(2)
124
+ .description('#518 Max concurrent requests per account before queuing.'),
125
+ maxQueueSize: z.number().volatile().default(20)
126
+ .description('#518 Max requests waiting in queue before reject.'),
127
+ queueTimeoutMs: z.number().volatile().default(30000)
128
+ .description('#518 Max queue wait time in ms before timeout.'),
121
129
  customVendors: z.array(z.any()).volatile().default([])
122
130
  .description('Declarative OpenAI-Responses-compatible providers. See README.'),
123
131
  })
@@ -1,3 +1,5 @@
1
+ import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
+ import { calculateCacheMetrics } from './analytics.js'
1
3
  import { maskText } from './mask.js'
2
4
  import { normalizeSlots } from './accounts.js'
3
5
  import { PROVIDERS } from './refs.js'
@@ -46,6 +48,21 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
46
48
  runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
47
49
  providers,
48
50
  requests: { total: rows.length, byStatus, lastErrors },
51
+ cache: calculateCacheMetrics(rows),
52
+ queue: defaultConcurrencyQueue.stats(),
53
+ decisions: {
54
+ recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
55
+ ts: r.ts,
56
+ provider: r.provider,
57
+ ref: r.ref,
58
+ outcome: r.decisionTrace.finalOutcome || 'success',
59
+ attempts: (r.decisionTrace.attempts || []).map((a) => ({
60
+ ref: a.ref,
61
+ status: a.status,
62
+ decision: a.decisionReason || a.kind,
63
+ })),
64
+ })),
65
+ },
49
66
  settings: {
50
67
  cooldownMs: cfg.cooldownMs,
51
68
  switchAtRemaining: cfg.switchAtRemaining,