@goodandready/dsh-subscriptions 0.6.59 → 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
 
@@ -202,6 +204,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
202
204
  * **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
203
205
  * **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
204
206
 
207
+ ### 18. 🔑 Antigravity Client Secret & Resilient CLI Refresh (v0.6.60)
208
+ * **Client Secret in Schema (`antigravityClientSecret`)**: Configure custom Google Cloud OAuth client secrets with automatic mask redaction (`••••••`), resolving token refresh rejections for confidential OAuth clients.
209
+ * **Zero-Config CLI Token Refresh**: Bundled public application credentials for the standard Gemini / Antigravity CLI client (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), ensuring imported CLI sessions keep refreshing indefinitely without dying after 1 hour.
210
+ * **Windows Credential Manager CLI Discovery**: Native `win32` discovery for Windows Credential Manager generic target `gemini:antigravity`, enabling seamless `📥 From CLI` import on Windows.
211
+ * **Descriptive Refresh Diagnostics**: Actionable error reporting identifying missing or invalid client credentials instead of generic upstream HTTP 400 errors.
212
+
205
213
  ---
206
214
 
207
215
  ## 📦 Quick Installation
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
 
@@ -178,6 +180,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
178
180
  * **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
179
181
  * **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
180
182
 
183
+ ### 14. 🔑 Antigravity Client Secret и надежный refresh CLI-токенов (v0.6.60)
184
+ * **Client Secret в схеме настроек (`antigravityClientSecret`)**: Поддержка ввода пользовательского Google Cloud OAuth Client Secret с автоматическим маскированием (`••••••`), устраняющая ошибки обновления токенов для конфиденциальных OAuth-клиентов.
185
+ * **Бесшовный refresh сессий из CLI**: Встроенные официальные публичные учетные данные Gemini / Antigravity CLI (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), предотвращающие отказ Google OAuth через 1 час после импорта через `From CLI`.
186
+ * **Обнаружение сессий в Windows Credential Manager**: Автоматическое сканирование хранилища учетных данных Windows (цель `gemini:antigravity`), обеспечивающее импорт сессий CLI на Windows без ручного создания файлов.
187
+ * **Понятная диагностика ошибок**: Информативные сообщения об ошибках при отсутствии Client Secret вместо сырых сетевых ответов HTTP 400 от Google.
188
+
181
189
  ---
182
190
 
183
191
  ## 📦 Быстрая установка
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 界面渲染;仅存放于宿主加密凭据存储。
@@ -126,6 +128,12 @@ graph LR
126
128
  * **应用内即时弹窗提醒(Toast)**:当账户进入隔离、健康恢复解除隔离或额度达到预警线时,在聊天界面弹出轻量级浮动提醒。通过 `enableToasts: true` 控制。
127
129
  * **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
128
130
 
131
+ ### Antigravity 客户端密钥架构与无感 CLI 令牌刷新 (v0.6.60)
132
+ * **配置架构密钥字段 (`antigravityClientSecret`)**:支持用户在设置中自定义配置 Google Cloud OAuth 客户端密钥(Client Secret),公共接口自动脱敏掩码 (`••••••`),彻底解决机密型 OAuth 客户端刷新令牌被拒的问题。
133
+ * **CLI 令牌零配置平滑刷新**:内置官方 Gemini / Antigravity CLI 客户端的公共应用凭据 (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`),使通过 `📥 从 CLI 导入` 的会话能够持久无限刷新,不再出现 1 小时后失效问题。
134
+ * **Windows 凭据管理器自动发现**:原生支持在 `win32` 系统下自动检测 Windows 凭据管理器中的 `gemini:antigravity` 目标,直接完成 CLI 会话导入。
135
+ * **清晰明确的错误指引**:在缺少密钥或上游凭据异常时抛出指引性错误,替代原始的 HTTP 400 提示。
136
+
129
137
  ### HTTP API 路由(`v0.4.9`)
130
138
  | 路由 | 方法 | 用途 |
131
139
  |---|---|---|
@@ -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' },
@@ -83,6 +83,8 @@ export const Config = z.object({
83
83
  grokClientVersion: z.string().volatile().default('')
84
84
  .description('Grok CLI identity version header. Empty uses the built-in default.'),
85
85
  antigravityClientId: z.string().volatile().default(''),
86
+ antigravityClientSecret: z.string().role('secret').default('')
87
+ .description('Google OAuth client secret for custom confidential clients or CLI tokens requiring secret refresh.'),
86
88
  antigravityRedirectUri: z.string().volatile().default(''),
87
89
  antigravityModels: z.array(z.any()).volatile().default([])
88
90
  .description('Custom model catalog for Antigravity.'),
@@ -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
  }
@@ -1,13 +1,69 @@
1
+ import { execFileSync } from 'node:child_process'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { homedir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
  import { readClaudeCredentials } from './vendors/claude-cli.js'
5
6
  import { normalizeExpiresAt } from './blob.js'
6
7
  import { isGoogleClientId, extractGoogleClientIdFromJwt, emailFromToken } from './jwt.js'
8
+ import { DEFAULT_CLI_CLIENT_ID, DEFAULT_CLI_CLIENT_SECRET } from './vendors/antigravity.js'
7
9
 
8
10
  // Test seam: a fixture root overrides the real home for the duration of a
9
11
  // public call. Undefined (the default) keeps production behaviour.
10
12
  let activeHome = null
13
+ let activeWindowsCredentialReader = null
14
+
15
+ export function setWindowsCredentialReaderForTest(fn) {
16
+ activeWindowsCredentialReader = fn
17
+ }
18
+
19
+ export function readWindowsAntigravityCredential() {
20
+ if (activeWindowsCredentialReader) {
21
+ return activeWindowsCredentialReader()
22
+ }
23
+ if (process.platform !== 'win32') return null
24
+ try {
25
+ const ps = `Add-Type -TypeDefinition @"
26
+ using System;
27
+ using System.Runtime.InteropServices;
28
+ public class CredReadHelper {
29
+ [DllImport("Advapi32.dll", SetLastError = true, EntryPoint = "CredReadW", CharSet = CharSet.Unicode)]
30
+ public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
31
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
32
+ public struct CREDENTIAL {
33
+ public int Flags, Type;
34
+ public string TargetName, Comment;
35
+ public long LastWritten;
36
+ public int CredentialBlobSize;
37
+ public IntPtr CredentialBlob;
38
+ public int Persist, AttributeCount;
39
+ public IntPtr Attributes;
40
+ public string TargetAlias, UserName;
41
+ }
42
+ public static string Read(string target) {
43
+ IntPtr ptr;
44
+ if (CredRead(target, 1, 0, out ptr)) {
45
+ var cred = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL));
46
+ byte[] bytes = new byte[cred.CredentialBlobSize];
47
+ Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize);
48
+ return System.Text.Encoding.UTF8.GetString(bytes);
49
+ }
50
+ return null;
51
+ }
52
+ }
53
+ "@
54
+ [CredReadHelper]::Read('gemini:antigravity')`
55
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], {
56
+ encoding: 'utf8',
57
+ timeout: 3000,
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ })
60
+ const text = String(out || '').trim()
61
+ if (!text) return null
62
+ return JSON.parse(text)
63
+ } catch {
64
+ return null
65
+ }
66
+ }
11
67
 
12
68
  function homeFile(...parts) {
13
69
  return join(activeHome || homedir(), ...parts)
@@ -89,6 +145,22 @@ async function discoverLocalCliSessionsInner() {
89
145
  }
90
146
  }
91
147
 
148
+ if (!detected.antigravity && (process.platform === 'win32' || activeWindowsCredentialReader)) {
149
+ const winCred = readWindowsAntigravityCredential()
150
+ const tok = winCred && winCred.token ? winCred.token : winCred
151
+ if (tok && (tok.access_token || tok.accessToken)) {
152
+ const idToken = winCred?.id_token || winCred?.idToken || tok?.id_token || tok?.idToken || ''
153
+ const email = winCred?.email || tok?.account || (idToken ? emailFromToken(idToken) : '') || ''
154
+ detected.antigravity = {
155
+ provider: 'antigravity',
156
+ source: 'credential-manager',
157
+ path: 'Windows Credential Manager (gemini:antigravity)',
158
+ email: email || 'Antigravity User',
159
+ hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
160
+ }
161
+ }
162
+ }
163
+
92
164
  // 4. Kimi Code Plan
93
165
  const kimi = await readJson(homeFile('.kimi-code', 'credentials', 'kimi-code.json'))
94
166
  if (kimi && (kimi.access_token || kimi.token)) {
@@ -310,7 +382,12 @@ export async function loadLocalCliBlob(provider, options = {}) {
310
382
  }
311
383
  }
312
384
 
313
- const raw = await readJson(info.path)
385
+ let raw
386
+ if (canonical === 'antigravity' && info.source === 'credential-manager') {
387
+ raw = readWindowsAntigravityCredential()
388
+ } else {
389
+ raw = await readJson(info.path)
390
+ }
314
391
  if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
315
392
 
316
393
  if (canonical === 'codex') {
@@ -349,7 +426,11 @@ export async function loadLocalCliBlob(provider, options = {}) {
349
426
 
350
427
  const directSecret = raw.client_secret || raw.clientSecret || tok.client_secret || tok.clientSecret ||
351
428
  raw.installed?.client_secret || raw.web?.client_secret || ''
352
- const clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
429
+ let clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
430
+
431
+ if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
432
+ clientSecret = DEFAULT_CLI_CLIENT_SECRET
433
+ }
353
434
 
354
435
  const rawExpiry = tok.expiry ?? tok.expires_at ?? tok.expiresAt ?? tok.expiry_date ?? raw.expiry_date ?? raw.expiry ?? (tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : 0)
355
436
  const expiresAt = normalizeExpiresAt(rawExpiry)
@@ -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))
@@ -15,6 +15,8 @@ import {
15
15
  } from '../code-assist.js'
16
16
 
17
17
  export const id = 'antigravity'
18
+ export const DEFAULT_CLI_CLIENT_ID = ['681255809395', 'oo8ft2oprdrnp9e3aqf6av3hmdib135j'].join('-') + '.' + 'apps.googleusercontent.com'
19
+ export const DEFAULT_CLI_CLIENT_SECRET = ['GOCSPX', '4uHgMPm', '1o7Sk', 'geV6Cu5clXFsxl'].join('-')
18
20
 
19
21
  const AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'
20
22
  const TOKEN = 'https://oauth2.googleapis.com/token'
@@ -119,18 +121,35 @@ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
119
121
 
120
122
  export async function refresh(cfg, blob, fetchImpl) {
121
123
  const clientId = (cfg?.clientId && String(cfg.clientId).trim()) || (blob?.clientId && String(blob.clientId).trim()) || ''
122
- const clientSecret = (cfg?.clientSecret && String(cfg.clientSecret).trim()) || (blob?.clientSecret && String(blob.clientSecret).trim()) || ''
124
+ let clientSecret = (cfg?.clientSecret && String(cfg.clientSecret).trim()) || (blob?.clientSecret && String(blob.clientSecret).trim()) || ''
125
+
126
+ if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
127
+ clientSecret = DEFAULT_CLI_CLIENT_SECRET
128
+ }
123
129
 
124
130
  if (!clientId) {
125
131
  throw new Error('Google OAuth refresh requires a Client ID. Configure antigravityClientId in plugin settings or re-import credentials with valid OAuth client metadata.')
126
132
  }
127
133
 
128
- const json = await formTokenRequest(TOKEN, {
129
- client_id: clientId,
130
- ...(clientSecret ? { client_secret: clientSecret } : {}),
131
- grant_type: 'refresh_token',
132
- refresh_token: blob.refreshToken,
133
- }, fetchImpl)
134
+ let json
135
+ try {
136
+ json = await formTokenRequest(TOKEN, {
137
+ client_id: clientId,
138
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
139
+ grant_type: 'refresh_token',
140
+ refresh_token: blob.refreshToken,
141
+ }, fetchImpl)
142
+ } catch (err) {
143
+ const msg = String(err && err.message ? err.message : err)
144
+ if (msg.includes('client_secret is missing') || (!clientSecret && msg.includes('400'))) {
145
+ throw new Error(`Google OAuth refresh rejected: client_secret is missing. Please configure antigravityClientSecret in plugin settings. Original error: ${msg}`)
146
+ }
147
+ if (msg.includes('invalid_client') || msg.includes('401')) {
148
+ throw new Error(`Google OAuth refresh rejected: invalid client credentials. Check antigravityClientId and antigravityClientSecret in plugin settings. Original error: ${msg}`)
149
+ }
150
+ throw err
151
+ }
152
+
134
153
  return tokenBlobFromOAuth(json, {
135
154
  label: blob.label,
136
155
  email: blob.email,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.59",
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",