@goodandready/dsh-subscriptions 0.6.60 → 0.6.61

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/README.md CHANGED
@@ -98,6 +98,8 @@ graph LR
98
98
  * **Automatic 429 Failover**: When an account encounters a rate limit (`HTTP 429`, `RATE_LIMIT`, `QUOTA_EXCEEDED`), traffic instantly fails over to the next healthy account in the pool.
99
99
  * **Preemptive Quota Switching (`switchAtRemaining`)**: Automatically rotates to the next account before hitting zero if the rate-limit window reset is imminent.
100
100
  * **Dynamic Cooldown Calculation**: Parses upstream headers (`Retry-After`, `x-ratelimit-reset`, ISO dates, epoch timestamps) and auto-restores cooled-down accounts when their window resets.
101
+ * **Synchronous Decision Tracing & Routing Explanation (#517)**: Real-time decision traces capture every account selection, rotation step, failure reason, and cascade fallback without post-hoc reconstruction, exposed in WebUI telemetry and diagnostics.
102
+ * **Dynamic Model Catalog & Live Quota Visualizer (#507)**: Dynamic model catalog with pool-level health calculation (individual quarantine degrades rather than disables the pool), context window badges, capability tags (`vision`, `tools`, `reasoning`, `coding`, `fast`), and live reset countdowns.
101
103
 
102
104
  ---
103
105
 
package/README.ru.md CHANGED
@@ -98,6 +98,8 @@ graph LR
98
98
  * **Автоматическое переключение при 429**: при превышении лимита запросов трафик мгновенно переключается на следующий свободный аккаунт.
99
99
  * **Упреждающее переключение квот (`switchAtRemaining`)**: смена аккаунта до падения в ошибку, если окно сброса близко.
100
100
  * **Динамический расчёт Cooldown**: парсинг заголовков `Retry-After`, `x-ratelimit-reset`, дат ISO и миллисекундных меток с автоматическим возвратом остывших аккаунтов в строй.
101
+ * **Трассировка решений ротации и объяснение маршрутизации (#517)**: Синхронная фиксация причин выбора аккаунтов, отказов и каскадных переходов в реальном времени с кодами причин без ретроспективной реконструкции; визуализация в телеметрии настроек и диагностике.
102
+ * **Динамический каталог моделей с пуловой доступностью и квотами (#507)**: Обогащенная витрина моделей с расчетом здоровья пула (карантин одного аккаунта не блокирует модель при наличии живых), тегами возможностей (`vision`, `tools`, `reasoning`, `coding`, `fast`), размером контекста и таймером сброса квот.
101
103
 
102
104
  ---
103
105
 
package/README.zh.md CHANGED
@@ -91,6 +91,8 @@ graph LR
91
91
  ### 多账号轮换与限流保护
92
92
  * 每个供应商可挂多个账号(如 `CODEX_OAUTH_1`、`CODEX_OAUTH_2`)。
93
93
  * 遇到 429/配额耗尽自动切换到下一个健康账号;支持按剩余量提前切换和动态冷却恢复。
94
+ * **轮换决策追踪与路由解释 (#517)**:实时同步记录账号选择、轮换切换、失败原因与级联回退的完整决策链路(无需事后推导),并在 WebUI 遥测与诊断报告中可视化展示。
95
+ * **动态模型目录与配额可用性 (#507)**:基于账号池计算模型综合可用性(单个账号隔离不影响具备可用账号的模型),展示上下文窗口、能力标签(视觉、工具、推理、编码、快速)及动态配额倒计时。
94
96
 
95
97
  ### 安全与无头登录
96
98
  * OAuth 令牌绝不通过 HTTP API 返回,也不在 Web 界面渲染;仅存放于宿主加密凭据存储。
@@ -92,6 +92,20 @@ export function createAdapterManager({
92
92
  method: 'POST',
93
93
  status: 200,
94
94
  kind: 'cascade',
95
+ decisionTrace: {
96
+ attempts: [
97
+ {
98
+ attempt: 1,
99
+ kind: 'cascade_fallback',
100
+ fromProvider: provider,
101
+ toProvider: nextVendor,
102
+ model: targetModel,
103
+ reason: 'primary_pool_exhausted',
104
+ },
105
+ ],
106
+ finalOutcome: 'cascade_fallback',
107
+ chosenRef: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
108
+ },
95
109
  })
96
110
  } catch { /* ignore cascade history recording error */ }
97
111
  }
package/lib/adapter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
1
2
  import { fetchWithTimeout } from "./http.js"
2
3
  import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
3
4
  import { displayName } from './refs.js'
@@ -10,10 +11,7 @@ import { quotaSnapshot, parseBody } from './ratelimit.js'
10
11
  import { toTokenUsage } from './wire.js'
11
12
  import { isSwitchableError } from './rotate.js'
12
13
 
13
- // #94: heuristic for test/preview/beta/legacy model ids.
14
- function isDeprecatedId(id) {
15
- return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(id)
16
- }
14
+ // #94: isDeprecatedId imported from ./models-catalog.js
17
15
 
18
16
  function asLlmError(err) {
19
17
  if (err instanceof LlmError) return err
@@ -74,8 +72,19 @@ export class SubscriptionAdapter extends LlmAdapter {
74
72
  if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
75
73
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
76
74
  }
77
- // Defensive: ensure every model has the provider field set
78
- models = models.map((m) => (m ? { ...m, provider: (!m.provider || m.provider === "custom") ? provider : m.provider } : m))
75
+ // Enrich models with provider and confirmed capability tags (#507)
76
+ models = models.map((m) => {
77
+ if (!m) return m
78
+ const prov = (!m.provider || m.provider === "custom") ? provider : m.provider
79
+ const caps = inferModelCapabilities(prov, m.id)
80
+ return {
81
+ ...m,
82
+ provider: prov,
83
+ contextWindow: m.contextWindow || caps.contextWindow,
84
+ contextLabel: caps.contextLabel,
85
+ capabilities: Array.isArray(m.capabilities) && m.capabilities.length ? m.capabilities : caps.capabilities,
86
+ }
87
+ })
79
88
  return models
80
89
  }
81
90
 
@@ -127,8 +136,10 @@ export class SubscriptionAdapter extends LlmAdapter {
127
136
  const effectiveDrainSafetyPercent = options.drainSafetyPercent !== undefined
128
137
  ? options.drainSafetyPercent
129
138
  : (typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : (deps.drainSafetyPercent ?? 5))
139
+ const decisionTrace = {}
130
140
  const streamOptions = {
131
141
  ...options,
142
+ _decisionTrace: decisionTrace,
132
143
  autoPacing: effectiveAutoPacing,
133
144
  drainBeforeReset: effectiveDrainBeforeReset,
134
145
  drainSafetyPercent: effectiveDrainSafetyPercent,
@@ -218,6 +229,7 @@ export class SubscriptionAdapter extends LlmAdapter {
218
229
  kind: 'stream',
219
230
  ...(outcome === 'error' && errorCaught ? { error: errorCaught.message || String(errorCaught) } : {}),
220
231
  ...(outcome === 'runaway' ? { reason: finishReason || (opts && opts._runawayState && opts._runawayState.reason) || 'runaway_loop_detected' } : {}),
232
+ ...(decisionTrace && decisionTrace.attempts && decisionTrace.attempts.length ? { decisionTrace } : {}),
221
233
  })
222
234
  } catch { /* ignore quota capture failure */ }
223
235
  }
package/lib/client.js CHANGED
@@ -130,6 +130,15 @@ window.__ModuleLoader__.load({
130
130
  '.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
131
  '.dsub-model-chip code{font-family:ui-monospace,monospace;font-weight:600}' +
132
132
  '.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}' +
133
+ '.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}' +
134
+ '.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)}' +
135
+ '.dsub-trace-row:last-child{border-bottom:none}' +
136
+ '.dsub-model-status-available{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent)}' +
137
+ '.dsub-model-status-degraded{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}' +
138
+ '.dsub-model-status-quarantined{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}' +
139
+ '.dsub-model-status-cooldown{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}' +
140
+ '.dsub-model-status-exhausted{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}' +
141
+ '.dsub-model-status-unconfigured{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3)}' +
133
142
  '.dsub-heroBars{display:flex;flex-direction:column;gap:8px;margin-top:10px}' +
134
143
  '.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}' +
135
144
  '.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
@@ -408,6 +417,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
408
417
  "smokeFail": "Smoke test failed",
409
418
  "modelsCatalogTitle": "Curated Subscription Models",
410
419
  "modelsCatalogDesc": "Available model catalog across active subscriptions in DeepSeek Harness.",
420
+ "rotationDecisionsTitle": "Rotation & Routing Decisions",
421
+ "rotationDecisionsDesc": "Real-time decision trace explaining account selection, skips, and fallback reasons.",
422
+ "rotationAttempt": "attempt",
423
+ "rotationAttempts": "attempts",
424
+ "rotationCascaded": "Cascaded fallback",
425
+ "modelPoolAvailable": "Available",
426
+ "modelPoolDegraded": "Degraded",
427
+ "modelPoolQuarantined": "Quarantined",
428
+ "modelPoolCooldown": "Cooldown",
429
+ "modelPoolExhausted": "Exhausted",
430
+ "modelPoolUnconfigured": "Unconfigured",
411
431
  "resetCredits": "Reset credits",
412
432
  "resetCreditsHint": "ChatGPT reset cards: emergency reset for the 5-hour window. Requires a 5s confirmation.",
413
433
  "resetCreditsAction": "Reset ChatGPT Limit",
@@ -619,6 +639,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
619
639
  "smokeFail": "冒烟测试失败",
620
640
  "modelsCatalogTitle": "精选订阅模型",
621
641
  "modelsCatalogDesc": "当前 DeepSeek Harness 活跃订阅下可用模型一览。",
642
+ "rotationDecisionsTitle": "轮询调度与路由决策",
643
+ "rotationDecisionsDesc": "实时决策追踪,透明展示每次请求的账号选择、跳过与回退原因。",
644
+ "rotationAttempt": "次尝试",
645
+ "rotationAttempts": "次尝试",
646
+ "rotationCascaded": "级联回退",
647
+ "modelPoolAvailable": "就绪可用",
648
+ "modelPoolDegraded": "降级运行",
649
+ "modelPoolQuarantined": "已隔离",
650
+ "modelPoolCooldown": "冷却中",
651
+ "modelPoolExhausted": "额度耗尽",
652
+ "modelPoolUnconfigured": "未配置",
622
653
  "resetCredits": "重置 ChatGPT 额度",
623
654
  "resetCreditsHint": "ChatGPT 重置卡:紧急重置 5 小时额度窗口。扣除前需等待 5 秒倒计时确认。",
624
655
  "resetCreditsAction": "重置 ChatGPT 限制",
@@ -878,6 +909,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
878
909
  const [draft, setDraft] = React.useState(null)
879
910
  const [accounts, setAccounts] = React.useState([])
880
911
  const [providers, setProviders] = React.useState([])
912
+ const [modelsCatalog, setModelsCatalog] = React.useState([])
881
913
  const [paste, setPaste] = React.useState({})
882
914
  const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
883
915
  const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
@@ -1115,6 +1147,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1115
1147
  setDraft(JSON.parse(JSON.stringify((data && data.config) || {})))
1116
1148
  setAccounts((data && data.accounts) || [])
1117
1149
  setProviders((data && data.providers) || [])
1150
+ setModelsCatalog((data && data.modelsCatalog) || [])
1118
1151
  }
1119
1152
 
1120
1153
  const reload = () => {
@@ -1473,6 +1506,26 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1473
1506
  React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
1474
1507
  ),
1475
1508
  ) : null,
1509
+ telemetry && Array.isArray(telemetry.recentDecisions) && telemetry.recentDecisions.length ? React.createElement('details', { className: 'dsub-group', style: { marginBottom: '14px' } },
1510
+ React.createElement('summary', { className: 'dsub-groupHead' }, '🧭 ' + t('rotationDecisionsTitle')),
1511
+ React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('rotationDecisionsDesc')),
1512
+ React.createElement('div', { className: 'dsub-trace-box' },
1513
+ telemetry.recentDecisions.map((dec, idx) => {
1514
+ const reasonBadge = dec.cascaded ? ('🔀 ' + t('rotationCascaded')) : (dec.decisionReason === 'session_pinned' ? '📌 Pinned' : ('⚡ ' + dec.decisionReason))
1515
+ return React.createElement('div', { key: idx, className: 'dsub-trace-row' },
1516
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
1517
+ dec.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + dec.provider, style: { fontSize: '9px', padding: '1px 4px' } }, dec.provider.toUpperCase()) : null,
1518
+ React.createElement('span', { style: { fontWeight: 600 } }, dec.ref || '—'),
1519
+ dec.model ? React.createElement('code', { style: { fontSize: '11px', opacity: 0.8 } }, dec.model) : null,
1520
+ ),
1521
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1522
+ React.createElement('span', { className: 'dsub-model-tag' }, reasonBadge),
1523
+ React.createElement('span', { style: { fontSize: '10.5px', opacity: 0.6 } }, dec.attemptsCount > 1 ? (dec.attemptsCount + ' ' + t('rotationAttempts')) : ('1 ' + t('rotationAttempt'))),
1524
+ ),
1525
+ )
1526
+ })
1527
+ ),
1528
+ ) : null,
1476
1529
  accounts && accounts.length ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
1477
1530
  React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + (t('healthMatrix') || 'Health Matrix & Quota Runway')),
1478
1531
  React.createElement('div', { style: { padding: '8px 0', overflowX: 'auto' } },
@@ -1648,14 +1701,51 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1648
1701
  React.createElement('summary', { className: 'dsub-groupHead' }, '🎯 ' + t('modelsCatalogTitle')),
1649
1702
  React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('modelsCatalogDesc')),
1650
1703
  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')),
1704
+ (Array.isArray(modelsCatalog) && modelsCatalog.length ? modelsCatalog : [
1705
+ { id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: 'available' } },
1706
+ { id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
1707
+ { id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
1708
+ { id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: 'available' } },
1709
+ { id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: 'available' } },
1710
+ { id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: 'available' } },
1711
+ { id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: 'available' } },
1712
+ { id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: 'available' } },
1713
+ ]).map((m, idx) => {
1714
+ const poolStatus = m.pool ? m.pool.status : 'unconfigured'
1715
+ const statusClass = 'dsub-model-status-' + poolStatus
1716
+ let poolLabel = ''
1717
+ if (poolStatus === 'available') {
1718
+ poolLabel = '🟢 ' + (m.pool && m.pool.remainingPercent != null ? (m.pool.remainingPercent + '%') : t('modelPoolAvailable'))
1719
+ } else if (poolStatus === 'degraded') {
1720
+ poolLabel = '🟡 ' + t('modelPoolDegraded') + (m.pool ? ' (' + m.pool.healthyAccounts + '/' + m.pool.totalAccounts + ')' : '')
1721
+ } else if (poolStatus === 'quarantined') {
1722
+ poolLabel = '⛔ ' + t('modelPoolQuarantined')
1723
+ } else if (poolStatus === 'cooldown') {
1724
+ poolLabel = '❄️ ' + t('modelPoolCooldown')
1725
+ } else if (poolStatus === 'exhausted') {
1726
+ poolLabel = '⚠️ ' + t('modelPoolExhausted')
1727
+ } else {
1728
+ poolLabel = '⚪ ' + t('modelPoolUnconfigured')
1729
+ }
1730
+
1731
+ const tags = []
1732
+ if (m.contextLabel) tags.push(m.contextLabel)
1733
+ if (Array.isArray(m.capabilities)) {
1734
+ for (const c of m.capabilities) {
1735
+ if (c === 'vision') tags.push('Vision')
1736
+ else if (c === 'reasoning') tags.push('Reasoning')
1737
+ else if (c === 'coding') tags.push('Coding')
1738
+ else if (c === 'fast') tags.push('Fast')
1739
+ }
1740
+ }
1741
+
1742
+ return React.createElement('div', { key: (m.id || idx) + '_' + idx, className: 'dsub-model-chip' },
1743
+ m.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + m.provider, style: { fontSize: '9px', padding: '1px 4px' } }, m.provider.toUpperCase()) : null,
1744
+ React.createElement('code', null, m.name || m.id),
1745
+ tags.length ? React.createElement('span', { className: 'dsub-model-tag' }, tags.join(' · ')) : null,
1746
+ React.createElement('span', { className: 'dsub-model-tag ' + statusClass }, poolLabel),
1747
+ )
1748
+ })
1659
1749
  ),
1660
1750
  ),
1661
1751
  React.createElement('details', { className: 'dsub-details' },
@@ -46,6 +46,19 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
46
46
  runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
47
47
  providers,
48
48
  requests: { total: rows.length, byStatus, lastErrors },
49
+ decisions: {
50
+ recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
51
+ ts: r.ts,
52
+ provider: r.provider,
53
+ ref: r.ref,
54
+ outcome: r.decisionTrace.finalOutcome || 'success',
55
+ attempts: (r.decisionTrace.attempts || []).map((a) => ({
56
+ ref: a.ref,
57
+ status: a.status,
58
+ decision: a.decisionReason || a.kind,
59
+ })),
60
+ })),
61
+ },
49
62
  settings: {
50
63
  cooldownMs: cfg.cooldownMs,
51
64
  switchAtRemaining: cfg.switchAtRemaining,
package/lib/history.js CHANGED
@@ -261,6 +261,22 @@ export class HistoryStore {
261
261
  }
262
262
 
263
263
  const first = this.rows[0] // newest first
264
+ const recentDecisions = []
265
+ for (const r of this.rows) {
266
+ if (r && r.decisionTrace) {
267
+ recentDecisions.push({
268
+ ts: r.ts,
269
+ provider: r.provider,
270
+ ref: r.ref,
271
+ model: r.model || null,
272
+ decisionReason: r.decisionTrace.finalOutcome || (r.decisionTrace.attempts && r.decisionTrace.attempts[0] && r.decisionTrace.attempts[0].decisionReason) || 'selected',
273
+ attemptsCount: Array.isArray(r.decisionTrace.attempts) ? r.decisionTrace.attempts.length : 1,
274
+ cascaded: r.decisionTrace.finalOutcome === 'cascade_fallback' || r.kind === 'cascade',
275
+ })
276
+ if (recentDecisions.length >= 10) break
277
+ }
278
+ }
279
+
264
280
  return {
265
281
  totalRequests: total,
266
282
  requests24h: count24h,
@@ -279,6 +295,7 @@ export class HistoryStore {
279
295
  ? Math.round((totalCacheReadTokens / totalPromptTokens) * 1000) / 10
280
296
  : 0,
281
297
  accounts: this.accountsTelemetry(),
298
+ recentDecisions,
282
299
  }
283
300
  }
284
301
  }
@@ -0,0 +1,240 @@
1
+ import { PROVIDERS, displayName } from './refs.js'
2
+
3
+ export function isDeprecatedId(id) {
4
+ return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(String(id || ''))
5
+ }
6
+ import { getVendor } from './vendors/index.js'
7
+ import { getAccountRemainingPercent } from './rotate.js'
8
+
9
+ export const CAPABILITY_TAGS = {
10
+ VISION: 'vision',
11
+ TOOLS: 'tools',
12
+ REASONING: 'reasoning',
13
+ CODING: 'coding',
14
+ FAST: 'fast',
15
+ MATH: 'math',
16
+ }
17
+
18
+ /**
19
+ * Infer context window and capability tags for a given provider and model.
20
+ * @param {string} provider
21
+ * @param {string} modelId
22
+ * @returns {{ contextWindow: number, contextLabel: string, capabilities: string[] }}
23
+ */
24
+ export function inferModelCapabilities(provider, modelId) {
25
+ const id = String(modelId || '').toLowerCase()
26
+ const p = String(provider || '').toLowerCase()
27
+ const caps = new Set([CAPABILITY_TAGS.TOOLS])
28
+ let contextWindow = 128000
29
+ let contextLabel = '128k'
30
+
31
+ // Vision detection
32
+ if (
33
+ id.includes('vision') ||
34
+ id.includes('gpt-4o') ||
35
+ id.includes('gpt-5') ||
36
+ id.includes('gpt-6') ||
37
+ id.includes('claude') ||
38
+ id.includes('gemini') ||
39
+ id.includes('grok-2') ||
40
+ id.includes('grok-3')
41
+ ) {
42
+ caps.add(CAPABILITY_TAGS.VISION)
43
+ }
44
+
45
+ // Reasoning detection
46
+ if (
47
+ id.includes('thinking') ||
48
+ id.includes('reasoning') ||
49
+ id.includes('o1') ||
50
+ id.includes('o3') ||
51
+ id.includes('gpt-5') ||
52
+ id.includes('gpt-6') ||
53
+ id.includes('claude-opus') ||
54
+ id.includes('claude-sonnet-5') ||
55
+ id.includes('claude-fable') ||
56
+ id.includes('claude-3-7') ||
57
+ id.includes('k1.5') ||
58
+ id.includes('r1')
59
+ ) {
60
+ caps.add(CAPABILITY_TAGS.REASONING)
61
+ }
62
+
63
+ // Coding detection
64
+ if (
65
+ id.includes('codex') ||
66
+ id.includes('coder') ||
67
+ id.includes('coding') ||
68
+ id.includes('sonnet') ||
69
+ id.includes('gpt-5') ||
70
+ id.includes('gpt-6') ||
71
+ id.includes('sol') ||
72
+ id.includes('terra') ||
73
+ id.includes('luna') ||
74
+ p === 'codex' ||
75
+ p === 'copilot' ||
76
+ p === 'cursor'
77
+ ) {
78
+ caps.add(CAPABILITY_TAGS.CODING)
79
+ }
80
+
81
+ // Fast / Speed detection
82
+ if (id.includes('flash') || id.includes('mini') || id.includes('haiku') || id.includes('small') || id.includes('fast')) {
83
+ caps.add(CAPABILITY_TAGS.FAST)
84
+ }
85
+
86
+ // Context window mapping
87
+ if (p === 'antigravity' && (id.includes('gemini-2.5') || id.includes('gemini-3'))) {
88
+ contextWindow = id.includes('pro') ? 2000000 : 1000000
89
+ contextLabel = id.includes('pro') ? '2M' : '1M'
90
+ } else if (id.includes('grok-3') || id.includes('1m')) {
91
+ contextWindow = 1000000
92
+ contextLabel = '1M'
93
+ } else if (
94
+ id.includes('claude-') ||
95
+ id.includes('o1') ||
96
+ id.includes('o3') ||
97
+ id.includes('gpt-5') ||
98
+ id.includes('gpt-6') ||
99
+ id.includes('kimi') ||
100
+ id.includes('200k')
101
+ ) {
102
+ contextWindow = 200000
103
+ contextLabel = '200k'
104
+ }
105
+
106
+ return {
107
+ contextWindow,
108
+ contextLabel,
109
+ capabilities: Array.from(caps),
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Calculates pool-wide status and quota for a provider from the account list.
115
+ * @param {string} provider
116
+ * @param {Array<object>} accounts
117
+ * @param {number} [nowMs]
118
+ * @returns {object}
119
+ */
120
+ export function calculatePoolStatus(provider, accounts = [], nowMs = Date.now()) {
121
+ const p = String(provider || '').toLowerCase()
122
+ const matching = (accounts || []).filter((a) => a && String(a.provider).toLowerCase() === p)
123
+
124
+ if (!matching.length) {
125
+ return {
126
+ status: 'unconfigured',
127
+ totalAccounts: 0,
128
+ healthyAccounts: 0,
129
+ quarantinedAccounts: 0,
130
+ cooldownAccounts: 0,
131
+ remainingPercent: null,
132
+ resetAt: null,
133
+ }
134
+ }
135
+
136
+ let healthyCount = 0
137
+ let quarantinedCount = 0
138
+ let cooldownCount = 0
139
+ let maxRemainingPercent = null
140
+ let nearestResetAt = null
141
+
142
+ for (const acc of matching) {
143
+ const isQuarantined = Boolean(acc.quarantineUntil && Number(acc.quarantineUntil) > nowMs)
144
+ const isCooldown = Boolean(acc.cooldownUntil && Number(acc.cooldownUntil) > nowMs)
145
+ const rem = getAccountRemainingPercent(acc)
146
+ const isExhausted = (rem !== null && rem <= 0) || (acc.usagePercent != null && Number(acc.usagePercent) >= 100)
147
+ const hasToken = Boolean(acc.configured && acc.writable !== false)
148
+
149
+ if (isQuarantined) quarantinedCount++
150
+ if (isCooldown) cooldownCount++
151
+
152
+ if (hasToken && !isQuarantined && !isCooldown && !isExhausted) {
153
+ healthyCount++
154
+ if (rem !== null) {
155
+ maxRemainingPercent = maxRemainingPercent === null ? rem : Math.max(maxRemainingPercent, rem)
156
+ }
157
+ }
158
+
159
+ const resetAt = acc.quota?.resetAt ? Number(acc.quota.resetAt) : null
160
+ if (resetAt && resetAt > nowMs) {
161
+ if (!nearestResetAt || resetAt < nearestResetAt) {
162
+ nearestResetAt = resetAt
163
+ }
164
+ }
165
+ }
166
+
167
+ let status = 'available'
168
+ if (healthyCount === 0) {
169
+ if (quarantinedCount === matching.length) {
170
+ status = 'quarantined'
171
+ } else if (cooldownCount === matching.length) {
172
+ status = 'cooldown'
173
+ } else {
174
+ status = 'exhausted'
175
+ }
176
+ } else if (healthyCount < matching.length) {
177
+ status = 'degraded'
178
+ }
179
+
180
+ return {
181
+ status,
182
+ totalAccounts: matching.length,
183
+ healthyAccounts: healthyCount,
184
+ quarantinedAccounts: quarantinedCount,
185
+ cooldownAccounts: cooldownCount,
186
+ remainingPercent: maxRemainingPercent !== null ? Math.round(maxRemainingPercent) : null,
187
+ resetAt: nearestResetAt,
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Builds the enriched model catalog across all supported subscription vendors.
193
+ * @param {object} options
194
+ * @param {Array<object>} options.accounts
195
+ * @param {object} [options.liveConfig]
196
+ * @param {boolean} [options.hideDeprecated]
197
+ * @param {number} [options.nowMs]
198
+ * @returns {Array<object>}
199
+ */
200
+ export function buildEnrichedCatalog({
201
+ accounts = [],
202
+ liveConfig = {},
203
+ hideDeprecated = false,
204
+ nowMs = Date.now(),
205
+ } = {}) {
206
+ const result = []
207
+
208
+ for (const provId of PROVIDERS) {
209
+ const vendor = getVendor(provId)
210
+ const pool = calculatePoolStatus(provId, accounts, nowMs)
211
+ const modelsKey = `${provId}Models`
212
+ let rawList = liveConfig && Array.isArray(liveConfig[modelsKey]) && liveConfig[modelsKey].length
213
+ ? liveConfig[modelsKey]
214
+ : (vendor.defaults ? vendor.defaults().models : [])
215
+
216
+ if (!Array.isArray(rawList)) rawList = []
217
+
218
+ for (const item of rawList) {
219
+ const modelId = typeof item === 'string' ? item : (item.id || item.slug || item.name)
220
+ if (!modelId) continue
221
+ if (hideDeprecated && isDeprecatedId(modelId)) continue
222
+
223
+ const modelName = (typeof item === 'object' && (item.name || item.display_name)) ? (item.name || item.display_name) : modelId
224
+ const caps = inferModelCapabilities(provId, modelId)
225
+
226
+ result.push({
227
+ id: modelId,
228
+ name: modelName,
229
+ provider: provId,
230
+ providerName: displayName(provId),
231
+ contextWindow: caps.contextWindow,
232
+ contextLabel: caps.contextLabel,
233
+ capabilities: caps.capabilities,
234
+ pool,
235
+ })
236
+ }
237
+ }
238
+
239
+ return result
240
+ }
package/lib/rotate.js CHANGED
@@ -63,7 +63,7 @@ export function getAccountRemainingPercent(acc) {
63
63
  return null
64
64
  }
65
65
 
66
- export function pickAccount(accounts, nowMs, opts) {
66
+ export function pickAccountWithTrace(accounts, nowMs, opts) {
67
67
  const list = Array.isArray(accounts) ? accounts : []
68
68
  const now = Number(nowMs) || 0
69
69
  const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
@@ -72,6 +72,8 @@ export function pickAccount(accounts, nowMs, opts) {
72
72
  const targetTag = opts && opts.tag
73
73
  const targetSession = opts && opts.sessionId
74
74
 
75
+ const evaluations = []
76
+
75
77
  function isQuotaExhausted(acc) {
76
78
  if (!thr || thr <= 0) return false
77
79
  const q = acc.quota
@@ -96,7 +98,7 @@ export function pickAccount(accounts, nowMs, opts) {
96
98
  return true
97
99
  }
98
100
 
99
- // Sticky Session Pinning (#170)
101
+ // Sticky Session Pinning (#170, #517)
100
102
  if (targetSession) {
101
103
  const pinnedRef = getPinnedAccountRef(targetSession)
102
104
  if (pinnedRef) {
@@ -108,8 +110,42 @@ export function pickAccount(accounts, nowMs, opts) {
108
110
  (pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
109
111
  ) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
110
112
  if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
111
- return pinned
113
+ evaluations.push({
114
+ ref: pinnedRef,
115
+ eligible: true,
116
+ tier: 0,
117
+ reason: 'session_pinned',
118
+ pinned: true,
119
+ })
120
+ return {
121
+ account: pinned,
122
+ trace: {
123
+ chosenRef: pinnedRef,
124
+ decisionReason: 'session_pinned',
125
+ tier: 0,
126
+ evaluations,
127
+ selectionMetrics: {
128
+ pinned: true,
129
+ remainingPercent: getAccountRemainingPercent(pinned),
130
+ healthScore: pinned.healthScore ?? 100,
131
+ },
132
+ },
133
+ }
134
+ } else {
135
+ evaluations.push({
136
+ ref: pinnedRef,
137
+ eligible: false,
138
+ reason: isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted',
139
+ pinned: true,
140
+ })
112
141
  }
142
+ } else if (pinned) {
143
+ evaluations.push({
144
+ ref: pinnedRef,
145
+ eligible: false,
146
+ reason: !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine',
147
+ pinned: true,
148
+ })
113
149
  }
114
150
  }
115
151
  }
@@ -121,7 +157,12 @@ export function pickAccount(accounts, nowMs, opts) {
121
157
  const tiers = [[], [], []]
122
158
 
123
159
  for (const acc of list) {
124
- if (!acc || !acc.hasToken) continue
160
+ if (!acc) continue
161
+ const ref = acc.ref || acc.id || 'unknown'
162
+ if (!acc.hasToken) {
163
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'no_token' })
164
+ continue
165
+ }
125
166
 
126
167
  if (!opts || opts.autoPacing !== false) {
127
168
  if (acc.pacingRisk === undefined) {
@@ -143,34 +184,62 @@ export function pickAccount(accounts, nowMs, opts) {
143
184
  }
144
185
 
145
186
  // VIP Slot Reservation (#175)
146
- if (acc.vipOnly && !isVipRequest) continue
187
+ if (acc.vipOnly && !isVipRequest) {
188
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'vip_reserved' })
189
+ continue
190
+ }
147
191
 
148
192
  // Tag-based filtering (#173)
149
- if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag)) continue
193
+ if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag)) {
194
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'tag_mismatch' })
195
+ continue
196
+ }
150
197
 
151
198
  const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
199
+ if (isCooldown) {
200
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'cooldown', until: acc.cooldownUntil })
201
+ continue
202
+ }
203
+
152
204
  const inQuarantine = isQuarantined(acc, now)
205
+ if (inQuarantine) {
206
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'quarantine', until: acc.quarantineUntil, quarantineReason: acc.quarantineReason })
207
+ continue
208
+ }
209
+
153
210
  const isHardExhausted = (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
211
+ if (isHardExhausted) {
212
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'hard_exhausted', usagePercent: acc.usagePercent })
213
+ continue
214
+ }
215
+
154
216
  const isZeroQuota = Boolean(acc.quota && (
155
217
  (acc.quota.remaining != null && Number(acc.quota.remaining) <= 0) ||
156
218
  (acc.quota.remainingPercent != null && Number(acc.quota.remainingPercent) <= 0)
157
219
  ) && (!acc.quota.resetAt || Number(acc.quota.resetAt) > now))
158
-
159
- if (isCooldown || inQuarantine || isHardExhausted || isZeroQuota) {
220
+ if (isZeroQuota) {
221
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'zero_quota' })
160
222
  continue
161
223
  }
162
224
 
163
225
  const softExhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
164
226
  if (softExhausted) {
165
227
  tiers[2].push(acc)
228
+ evaluations.push({ ref, eligible: true, tier: 2, reason: 'soft_exhausted', remainingPercent: getAccountRemainingPercent(acc) })
166
229
  continue
167
230
  }
168
231
 
169
- if (!acc.quota) tiers[1].push(acc)
170
- else tiers[0].push(acc)
232
+ if (!acc.quota) {
233
+ tiers[1].push(acc)
234
+ evaluations.push({ ref, eligible: true, tier: 1, reason: 'healthy_unknown_quota' })
235
+ } else {
236
+ tiers[0].push(acc)
237
+ evaluations.push({ ref, eligible: true, tier: 0, reason: 'healthy_known_quota', remainingPercent: getAccountRemainingPercent(acc) })
238
+ }
171
239
  }
172
240
 
173
- for (const tier of tiers) {
241
+ for (let tierIdx = 0; tierIdx < tiers.length; tierIdx++) {
242
+ const tier = tiers[tierIdx]
174
243
  if (tier.length) {
175
244
  tier.sort((a, b) => {
176
245
  // #485: Smart Quota Drainer - prioritize higher burn urgency first
@@ -206,14 +275,46 @@ export function pickAccount(accounts, nowMs, opts) {
206
275
  })
207
276
 
208
277
  const chosen = tier[0]
278
+ const chosenRef = chosen.ref || chosen.id
209
279
  if (targetSession && chosen) {
210
- pinSession(targetSession, chosen.ref || chosen.id)
280
+ pinSession(targetSession, chosenRef)
281
+ }
282
+ return {
283
+ account: chosen,
284
+ trace: {
285
+ chosenRef,
286
+ decisionReason: tierIdx === 0 ? 'healthy_tier0' : tierIdx === 1 ? 'healthy_tier1' : 'tier2_fallback',
287
+ tier: tierIdx,
288
+ evaluations,
289
+ selectionMetrics: {
290
+ tier: tierIdx,
291
+ burnUrgency: chosen.burnUrgency != null ? Number(chosen.burnUrgency) : null,
292
+ pacingRisk: chosen.pacingRisk != null ? Number(chosen.pacingRisk) : null,
293
+ remainingPercent: getAccountRemainingPercent(chosen),
294
+ resetAt: chosen.quota?.resetAt || null,
295
+ weight: chosen.weight || (chosen.isPro ? 5 : 1),
296
+ healthScore: chosen.healthScore ?? 100,
297
+ },
298
+ },
211
299
  }
212
- return chosen
213
300
  }
214
301
  }
215
302
 
216
- return null
303
+ return {
304
+ account: null,
305
+ trace: {
306
+ chosenRef: null,
307
+ decisionReason: 'no_usable_account',
308
+ tier: null,
309
+ evaluations,
310
+ selectionMetrics: null,
311
+ },
312
+ }
313
+ }
314
+
315
+ export function pickAccount(accounts, nowMs, opts) {
316
+ const res = pickAccountWithTrace(accounts, nowMs, opts)
317
+ return res ? res.account : null
217
318
  }
218
319
 
219
320
  export function markCooldown(account, nowMs, cooldownMs, family) {
@@ -1,3 +1,4 @@
1
+ import { buildEnrichedCatalog } from '../models-catalog.js'
1
2
  // #374: Sensitive GET routes protected by same-origin check
2
3
  import { readFileSync } from 'node:fs'
3
4
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
@@ -36,12 +37,18 @@ export function registerStatusRoutes(ctx, state) {
36
37
  }
37
38
 
38
39
  async function configResponse() {
40
+ const accts = await accountsView()
39
41
  return {
40
42
  ok: true,
41
43
  revision: getCurrentRevision(),
42
44
  config: publicConfig(live()),
43
- accounts: await accountsView(),
45
+ accounts: accts,
44
46
  providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
47
+ modelsCatalog: buildEnrichedCatalog({
48
+ accounts: accts,
49
+ liveConfig: live(),
50
+ hideDeprecated: !!live().hideDeprecatedModels,
51
+ }),
45
52
  }
46
53
  }
47
54
 
@@ -239,6 +246,30 @@ export function registerStatusRoutes(ctx, state) {
239
246
  }), 'dsh-subscriptions: /status')
240
247
 
241
248
 
249
+ ctx.effect(() => ctx.webServer.register({
250
+ kind: 'exact',
251
+ path: '/dsh-subscriptions/models',
252
+ handler: safeJsonHandler(async (req, res) => {
253
+ if (req.method !== 'GET') {
254
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
255
+ return
256
+ }
257
+ if (!isTrustedSettingsRequest(req, ctx)) {
258
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
259
+ return
260
+ }
261
+ const accts = await accountsView()
262
+ writeJson(res, 200, {
263
+ ok: true,
264
+ models: buildEnrichedCatalog({
265
+ accounts: accts,
266
+ liveConfig: live(),
267
+ hideDeprecated: !!live().hideDeprecatedModels,
268
+ }),
269
+ })
270
+ }),
271
+ }), 'dsh-subscriptions: /models')
272
+
242
273
  ctx.effect(() => ctx.webServer.register({
243
274
  kind: 'exact',
244
275
  path: '/dsh-subscriptions/diagnostics',
@@ -46,3 +46,7 @@ export function getPinnedAccountRef(sessionId) {
46
46
  }
47
47
  return entry.accountRef
48
48
  }
49
+
50
+ export function clearSessionPins() {
51
+ sessionPins.clear()
52
+ }
@@ -1,4 +1,4 @@
1
- import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
1
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
2
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
3
  import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
4
4
  import { withRunawayGuard } from './runaway-guard.js'
@@ -11,6 +11,7 @@ export async function* streamWithRotation({
11
11
  streamOnce,
12
12
  options,
13
13
  onCooldown,
14
+ onDecisionTrace,
14
15
  cascadeFallback, // #349: optional cross-vendor cascading fallback generator
15
16
  offlineFallback, // #174: optional fallback generator if all accounts exhausted
16
17
  webhookUrl,
@@ -20,8 +21,28 @@ export async function* streamWithRotation({
20
21
  let lastError = null
21
22
  const tried = new Set()
22
23
 
24
+ const decisionTrace = {
25
+ sessionId: options && options.sessionId,
26
+ provider: options && options.provider,
27
+ model: options && options.model,
28
+ attempts: [],
29
+ finalOutcome: null,
30
+ chosenRef: null,
31
+ }
32
+
33
+ const syncTrace = (outcome, extra = {}) => {
34
+ decisionTrace.finalOutcome = outcome
35
+ Object.assign(decisionTrace, extra)
36
+ if (options && options._decisionTrace) {
37
+ Object.assign(options._decisionTrace, decisionTrace)
38
+ }
39
+ if (typeof onDecisionTrace === 'function') {
40
+ try { onDecisionTrace(decisionTrace) } catch { /* ignore trace callback error */ }
41
+ }
42
+ }
43
+
23
44
  while (true) {
24
- const account = pickAccount(pool, nowMs(), {
45
+ const pickRes = pickAccountWithTrace(pool, nowMs(), {
25
46
  switchAtRemaining,
26
47
  family: modelFamily(options && options.provider, options && options.model),
27
48
  sessionId: options && options.sessionId,
@@ -31,24 +52,52 @@ export async function* streamWithRotation({
31
52
  drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
32
53
  drainSafetyPercent: options && (options.drainSafetyPercent !== undefined ? options.drainSafetyPercent : (options.config && options.config.drainSafetyPercent)),
33
54
  })
55
+ const account = pickRes.account
34
56
 
35
57
  if (!account || tried.has(account.ref || account.id)) {
36
58
  if (cascadeFallback) {
37
- // #349 Cascading Cross-Vendor Fallback
59
+ // #349 Cascading Cross-Vendor Fallback (#517 trace)
60
+ decisionTrace.attempts.push({
61
+ attempt: decisionTrace.attempts.length + 1,
62
+ kind: 'cascade_fallback',
63
+ reason: 'primary_pool_exhausted',
64
+ lastError: lastError ? (lastError.code || lastError.message) : null,
65
+ })
66
+ syncTrace('cascade_fallback')
38
67
  yield* cascadeFallback(options, lastError)
39
68
  return
40
69
  }
41
70
  if (offlineFallback) {
42
- // #174 Local Mock Server Offline Fallback
71
+ // #174 Local Mock Server Offline Fallback (#517 trace)
72
+ decisionTrace.attempts.push({
73
+ attempt: decisionTrace.attempts.length + 1,
74
+ kind: 'offline_fallback',
75
+ reason: 'all_accounts_exhausted',
76
+ lastError: lastError ? (lastError.code || lastError.message) : null,
77
+ })
78
+ syncTrace('offline_fallback')
43
79
  yield* offlineFallback(options, lastError)
44
80
  return
45
81
  }
82
+ syncTrace('exhausted')
46
83
  if (lastError) throw lastError
47
84
  const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
48
85
  err.code = 'RATE_LIMIT'
49
86
  throw err
50
87
  }
51
88
 
89
+ const currentAttempt = {
90
+ attempt: decisionTrace.attempts.length + 1,
91
+ ref: account.ref || account.id,
92
+ decisionReason: pickRes.trace.decisionReason,
93
+ tier: pickRes.trace.tier,
94
+ selectionMetrics: pickRes.trace.selectionMetrics,
95
+ evaluations: pickRes.trace.evaluations,
96
+ status: 'pending',
97
+ }
98
+ decisionTrace.attempts.push(currentAttempt)
99
+ decisionTrace.chosenRef = account.ref || account.id
100
+
52
101
  tried.add(account.ref || account.id)
53
102
 
54
103
  let firstChunkDelivered = false
@@ -72,6 +121,8 @@ export async function* streamWithRotation({
72
121
  firstChunkDelivered = true
73
122
  yield chunk
74
123
  }
124
+ currentAttempt.status = 'success'
125
+ syncTrace('success', { chosenRef: account.ref || account.id })
75
126
  return
76
127
  } catch (err) {
77
128
  lastError = err
@@ -82,12 +133,21 @@ export async function* streamWithRotation({
82
133
  // Transient region 400 error: retry same account up to 2 times before rotating (#391 / GH #9)
83
134
  if (isRegionError(err) && regionRetries < 2 && (!options || !options.signal || !options.signal.aborted)) {
84
135
  regionRetries++
136
+ currentAttempt.regionRetries = regionRetries
85
137
  await new Promise((r) => setTimeout(r, 400 * regionRetries))
86
138
  if (options && options.signal && options.signal.aborted) throw err
87
139
  continue
88
140
  }
89
141
 
90
- if (!isSwitchableError(err)) throw err
142
+ if (!isSwitchableError(err)) {
143
+ currentAttempt.status = 'fatal_error'
144
+ currentAttempt.error = err ? (err.message || String(err)) : 'unknown'
145
+ syncTrace('fatal_error')
146
+ throw err
147
+ }
148
+
149
+ currentAttempt.status = 'switched'
150
+ currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
91
151
 
92
152
  // Move slot to cooldown and quarantine (#172)
93
153
  const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
@@ -114,6 +174,9 @@ export async function* streamWithRotation({
114
174
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
115
175
  }
116
176
 
177
+ currentAttempt.quarantineReason = account.quarantineReason || reason
178
+ currentAttempt.quarantineUntil = account.quarantineUntil
179
+
117
180
  try {
118
181
  notifyQuarantineEntered({
119
182
  ref: account.ref,
@@ -1,5 +1,5 @@
1
1
  import { isProvider } from "./refs.js"
2
- import { pickAccount, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
2
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
3
3
  import { quotaSnapshot } from "./ratelimit.js"
4
4
  import { pickFetch } from "./proxy.js"
5
5
  import { fetchWithTimeout } from "./http.js"
@@ -118,14 +118,16 @@ export function createSubscriptionsService(deps) {
118
118
  // copy pool for rotation
119
119
  const pool = list.map((a) => ({ ...a }))
120
120
  const tried = new Set()
121
+ const attempts = []
121
122
  let lastError = null
122
123
  while (true) {
123
- const account = pickAccount(pool, Date.now(), {
124
+ const pickRes = pickAccountWithTrace(pool, Date.now(), {
124
125
  switchAtRemaining: thr,
125
126
  family: modelFamily(provider, body && body.model),
126
127
  drainBeforeReset: typeof deps.drainBeforeReset === 'function' ? deps.drainBeforeReset() : deps.drainBeforeReset,
127
128
  drainSafetyPercent: typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : deps.drainSafetyPercent,
128
129
  })
130
+ const account = pickRes.account
129
131
  if (!account) {
130
132
  if (lastError) throw lastError
131
133
  const err = new Error("no usable subscription account for this provider")
@@ -139,6 +141,16 @@ export function createSubscriptionsService(deps) {
139
141
  throw err
140
142
  }
141
143
  tried.add(account.ref)
144
+ const currentAttempt = {
145
+ attempt: attempts.length + 1,
146
+ ref: account.ref,
147
+ decisionReason: pickRes.trace.decisionReason,
148
+ tier: pickRes.trace.tier,
149
+ selectionMetrics: pickRes.trace.selectionMetrics,
150
+ evaluations: pickRes.trace.evaluations,
151
+ status: 'pending',
152
+ }
153
+ attempts.push(currentAttempt)
142
154
  try {
143
155
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
144
156
  const cfg = deps.vendorConfig(provider)
@@ -235,7 +247,8 @@ export function createSubscriptionsService(deps) {
235
247
  else err.code = "VENDOR"
236
248
  throw err
237
249
  }
238
- // #65: record in the request and cost history.
250
+ currentAttempt.status = 'success'
251
+ // #65: record in the request and cost history with decision trace (#517)
239
252
  if (typeof deps.recordHistory === "function") {
240
253
  try {
241
254
  deps.recordHistory({
@@ -247,11 +260,18 @@ export function createSubscriptionsService(deps) {
247
260
  status: res.status || 200,
248
261
  ms: Date.now() - t0,
249
262
  kind: "request",
263
+ decisionTrace: {
264
+ attempts,
265
+ finalOutcome: 'success',
266
+ chosenRef: account.ref,
267
+ },
250
268
  })
251
269
  } catch { /* ignore subscriptions error */ }
252
270
  }
253
271
  return res
254
272
  } catch (err) {
273
+ currentAttempt.status = 'failed'
274
+ currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
255
275
  lastError = err
256
276
  if (!isSwitchableError(err)) throw err
257
277
  const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.60",
3
+ "version": "0.6.61",
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",