@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 +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/adapter-manager.js +14 -0
- package/lib/adapter.js +18 -6
- package/lib/client.js +98 -8
- package/lib/config-schema.js +2 -0
- package/lib/diagnostics.js +13 -0
- package/lib/history.js +17 -0
- package/lib/import-auth.js +83 -2
- package/lib/models-catalog.js +240 -0
- package/lib/rotate.js +115 -14
- package/lib/routes/status.js +32 -1
- package/lib/session-pin.js +4 -0
- package/lib/stream-rotate.js +68 -5
- package/lib/subscriptions.js +23 -3
- package/lib/vendors/antigravity.js +26 -7
- package/package.json +1 -1
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
|
|---|---|---|
|
package/lib/adapter-manager.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
78
|
-
models = models.map((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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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' },
|
package/lib/config-schema.js
CHANGED
|
@@ -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.'),
|
package/lib/diagnostics.js
CHANGED
|
@@ -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
|
}
|
package/lib/import-auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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))
|
|
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
|
-
|
|
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)
|
|
170
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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) {
|
package/lib/routes/status.js
CHANGED
|
@@ -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:
|
|
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',
|
package/lib/session-pin.js
CHANGED
package/lib/stream-rotate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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))
|
|
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,
|
package/lib/subscriptions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isProvider } from "./refs.js"
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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",
|