@goodandready/dsh-subscriptions 0.6.58 → 0.6.60
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 +16 -0
- package/README.ru.md +16 -0
- package/README.zh.md +15 -0
- package/lib/accounts.js +2 -2
- package/lib/alerts.js +55 -0
- package/lib/client.js +128 -3
- package/lib/config-schema.js +4 -0
- package/lib/import-auth.js +83 -2
- package/lib/quarantine.js +3 -0
- package/lib/routes/accounts.js +115 -1
- package/lib/stream-rotate.js +13 -1
- package/lib/vendors/antigravity.js +26 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,6 +145,9 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
145
145
|
| `/dsh-subscriptions/proxy-check` | POST | Latency check of a slot's proxy against its vendor base URL |
|
|
146
146
|
| `/dsh-subscriptions/oauth/device/start` | POST | Begin Codex device-code login (returns user code + verification URL) |
|
|
147
147
|
| `/dsh-subscriptions/oauth/device/poll` | POST | Poll device-code authorization status |
|
|
148
|
+
| `/dsh-subscriptions/quarantine/park` | POST | Park an account into quarantine for custom duration (`durationMs`, `reason`) |
|
|
149
|
+
| `/dsh-subscriptions/quarantine/probe` | POST | Run immediate live warmup health probe or force-release account |
|
|
150
|
+
| `/dsh-subscriptions/alerts` | GET | List recent in-app alerts and notifications |
|
|
148
151
|
|
|
149
152
|
---
|
|
150
153
|
|
|
@@ -193,6 +196,18 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
193
196
|
* **Curated Models Catalog**: Quick preview of supported model families with context windows and capability tags (Vision, Reasoning, Hybrid).
|
|
194
197
|
* **Non-Blocking Storage**: `HistoryStore` debounces disk persistence asynchronously to prevent blocking the Node.js event loop during high-throughput streaming.
|
|
195
198
|
|
|
199
|
+
### 17. 🛡️ Quarantine Management, Live Quota Countdown & In-App Toast Alerts (v0.6.59)
|
|
200
|
+
* **Manual Quarantine Control (Park & Probe)**: Directly park active accounts in quarantine (`POST /dsh-subscriptions/quarantine/park`) for maintenance or pause, or trigger an immediate live warmup probe and instant release (`POST /dsh-subscriptions/quarantine/probe`) right from the account slot card.
|
|
201
|
+
* **Dynamic Quota Window Countdown**: Live second-by-second countdown timer displayed in the `SubsPill` session widget and account settings cards, showing exact time remaining until rolling rate-limit windows (`five_hour`, `seven_day`, `primary_window`) reset.
|
|
202
|
+
* **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
|
+
* **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
|
|
204
|
+
|
|
205
|
+
### 18. 🔑 Antigravity Client Secret & Resilient CLI Refresh (v0.6.60)
|
|
206
|
+
* **Client Secret in Schema (`antigravityClientSecret`)**: Configure custom Google Cloud OAuth client secrets with automatic mask redaction (`••••••`), resolving token refresh rejections for confidential OAuth clients.
|
|
207
|
+
* **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.
|
|
208
|
+
* **Windows Credential Manager CLI Discovery**: Native `win32` discovery for Windows Credential Manager generic target `gemini:antigravity`, enabling seamless `📥 From CLI` import on Windows.
|
|
209
|
+
* **Descriptive Refresh Diagnostics**: Actionable error reporting identifying missing or invalid client credentials instead of generic upstream HTTP 400 errors.
|
|
210
|
+
|
|
196
211
|
---
|
|
197
212
|
|
|
198
213
|
## 📦 Quick Installation
|
|
@@ -226,6 +241,7 @@ dsh-subscriptions:
|
|
|
226
241
|
codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
|
|
227
242
|
codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
|
|
228
243
|
composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
|
|
244
|
+
enableToasts: true # v0.6.59: real-time in-app toast alerts on quarantine and quota events
|
|
229
245
|
# Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
|
|
230
246
|
accounts:
|
|
231
247
|
codex:
|
package/README.ru.md
CHANGED
|
@@ -145,6 +145,9 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
145
145
|
| `/dsh-subscriptions/proxy-check` | POST | Проверка задержки прокси слота через базовый URL вендора |
|
|
146
146
|
| `/dsh-subscriptions/oauth/device/start` | POST | Начать вход Codex по коду устройства (возвращает код и адрес подтверждения) |
|
|
147
147
|
| `/dsh-subscriptions/oauth/device/poll` | POST | Опрос статуса авторизации по коду устройства |
|
|
148
|
+
| `/dsh-subscriptions/quarantine/park` | POST | Временная парковка аккаунта в карантин (`durationMs`, `reason`) |
|
|
149
|
+
| `/dsh-subscriptions/quarantine/probe` | POST | Немедленный запуск прогревочного зонда или досрочный вывод из карантина |
|
|
150
|
+
| `/dsh-subscriptions/alerts` | GET | Список последних внутренних оповещений и уведомлений |
|
|
148
151
|
|
|
149
152
|
---
|
|
150
153
|
|
|
@@ -169,6 +172,18 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
169
172
|
* **Каталог моделей подписок**: Компактный список поддерживаемых моделей с размером контекста (128k, 200k, 1M) и тегами возможностей.
|
|
170
173
|
* **Асинхронная история**: Неблокирующий дебаунсинг записи `HistoryStore`, исключающий просадки event loop при интенсивном стриминге.
|
|
171
174
|
|
|
175
|
+
### 13. 🛡️ Управление карантином, живой обратный отсчет квот и всплывающие оповещения (v0.6.59)
|
|
176
|
+
* **Ручное управление карантином (Park & Probe)**: Возможность временно отправить аккаунт в карантин (`POST /dsh-subscriptions/quarantine/park`) для обслуживания или паузы, либо запустить немедленный прогревочный микро-зонд (`vendor.check`) и моментально вывести аккаунт из карантина (`POST /dsh-subscriptions/quarantine/probe`) прямо из карточки в настройках.
|
|
177
|
+
* **Динамический посекундный таймер сброса окон квот**: Интерактивный таймер в виджете сессии `SubsPill` и в карточках аккаунтов, рассчитывающий точное оставшееся время до разблокировки скользящих лимитов (`five_hour`, `seven_day`, `primary_window`).
|
|
178
|
+
* **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
|
|
179
|
+
* **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
|
|
180
|
+
|
|
181
|
+
### 14. 🔑 Antigravity Client Secret и надежный refresh CLI-токенов (v0.6.60)
|
|
182
|
+
* **Client Secret в схеме настроек (`antigravityClientSecret`)**: Поддержка ввода пользовательского Google Cloud OAuth Client Secret с автоматическим маскированием (`••••••`), устраняющая ошибки обновления токенов для конфиденциальных OAuth-клиентов.
|
|
183
|
+
* **Бесшовный refresh сессий из CLI**: Встроенные официальные публичные учетные данные Gemini / Antigravity CLI (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), предотвращающие отказ Google OAuth через 1 час после импорта через `From CLI`.
|
|
184
|
+
* **Обнаружение сессий в Windows Credential Manager**: Автоматическое сканирование хранилища учетных данных Windows (цель `gemini:antigravity`), обеспечивающее импорт сессий CLI на Windows без ручного создания файлов.
|
|
185
|
+
* **Понятная диагностика ошибок**: Информативные сообщения об ошибках при отсутствии Client Secret вместо сырых сетевых ответов HTTP 400 от Google.
|
|
186
|
+
|
|
172
187
|
---
|
|
173
188
|
|
|
174
189
|
## 📦 Быстрая установка
|
|
@@ -199,6 +214,7 @@ dsh-subscriptions:
|
|
|
199
214
|
codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
|
|
200
215
|
codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
|
|
201
216
|
composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
|
|
217
|
+
enableToasts: true # v0.6.59: всплывающие уведомления при событиях карантина и квот
|
|
202
218
|
# Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
|
|
203
219
|
accounts:
|
|
204
220
|
codex:
|
package/README.zh.md
CHANGED
|
@@ -120,6 +120,18 @@ graph LR
|
|
|
120
120
|
* **匿名诊断报告**:设置卡片内一键生成(插件/运行时版本、系统、各供应商健康状态、HTTP 状态聚合、最近错误与耗时、非敏感配置)并自动复制到剪贴板;令牌、邮箱、凭据名与代理地址严格排除。
|
|
121
121
|
* 同区块提供问题追踪器链接,便于提交 issue。
|
|
122
122
|
|
|
123
|
+
### 隔离管理、动态额度重置倒计时与应用内弹窗提醒 (v0.6.59)
|
|
124
|
+
* **手动隔离管理(暂停与预热探测)**:支持在设置面板的账户卡片中一键暂停隔离活跃账户(`POST /dsh-subscriptions/quarantine/park`),或立即触发真实上游预热微探测(`probeWarmup`)并在验证成功后即时解除隔离(`POST /dsh-subscriptions/quarantine/probe`)。
|
|
125
|
+
* **动态额度重置倒计时**:在顶部会话栏 `SubsPill` 和账户卡片中秒级动态更新,精准展示滚动配额窗口(`five_hour`、`seven_day`、`primary_window`)的剩余解锁时间。
|
|
126
|
+
* **应用内即时弹窗提醒(Toast)**:当账户进入隔离、健康恢复解除隔离或额度达到预警线时,在聊天界面弹出轻量级浮动提醒。通过 `enableToasts: true` 控制。
|
|
127
|
+
* **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
|
|
128
|
+
|
|
129
|
+
### Antigravity 客户端密钥架构与无感 CLI 令牌刷新 (v0.6.60)
|
|
130
|
+
* **配置架构密钥字段 (`antigravityClientSecret`)**:支持用户在设置中自定义配置 Google Cloud OAuth 客户端密钥(Client Secret),公共接口自动脱敏掩码 (`••••••`),彻底解决机密型 OAuth 客户端刷新令牌被拒的问题。
|
|
131
|
+
* **CLI 令牌零配置平滑刷新**:内置官方 Gemini / Antigravity CLI 客户端的公共应用凭据 (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`),使通过 `📥 从 CLI 导入` 的会话能够持久无限刷新,不再出现 1 小时后失效问题。
|
|
132
|
+
* **Windows 凭据管理器自动发现**:原生支持在 `win32` 系统下自动检测 Windows 凭据管理器中的 `gemini:antigravity` 目标,直接完成 CLI 会话导入。
|
|
133
|
+
* **清晰明确的错误指引**:在缺少密钥或上游凭据异常时抛出指引性错误,替代原始的 HTTP 400 提示。
|
|
134
|
+
|
|
123
135
|
### HTTP API 路由(`v0.4.9`)
|
|
124
136
|
| 路由 | 方法 | 用途 |
|
|
125
137
|
|---|---|---|
|
|
@@ -127,6 +139,9 @@ graph LR
|
|
|
127
139
|
| `/dsh-subscriptions/proxy-check` | POST | 检测槽位代理到供应商基础地址的延迟 |
|
|
128
140
|
| `/dsh-subscriptions/oauth/device/start` | POST | 发起 Codex 设备码登录(返回用户码与验证地址) |
|
|
129
141
|
| `/dsh-subscriptions/oauth/device/poll` | POST | 轮询设备码授权状态 |
|
|
142
|
+
| `/dsh-subscriptions/quarantine/park` | POST | 将指定账户临时暂停隔离(支持 `durationMs` 与 `reason`) |
|
|
143
|
+
| `/dsh-subscriptions/quarantine/probe` | POST | 立即执行健康预热探测或提前解除账户隔离 |
|
|
144
|
+
| `/dsh-subscriptions/alerts` | GET | 获取近期应用内告警与通知列表 |
|
|
130
145
|
|
|
131
146
|
---
|
|
132
147
|
|
package/lib/accounts.js
CHANGED
|
@@ -515,11 +515,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
515
515
|
}
|
|
516
516
|
return q
|
|
517
517
|
},
|
|
518
|
-
async probeWarmup(ref, probeFn) {
|
|
518
|
+
async probeWarmup(ref, probeFn, force = false) {
|
|
519
519
|
const q = quarantines.get(ref)
|
|
520
520
|
if (!q) return { success: true }
|
|
521
521
|
const acc = { quarantineReason: q.reason, quarantineUntil: q.until, quarantineAttempts: q.attempts || 1, status: q.status }
|
|
522
|
-
if (!needsWarmupProbe(acc)) {
|
|
522
|
+
if (!force && !needsWarmupProbe(acc)) {
|
|
523
523
|
return { success: false, reason: 'NOT_EXPIRED' }
|
|
524
524
|
}
|
|
525
525
|
quarantines.set(ref, enterProbing(acc))
|
package/lib/alerts.js
CHANGED
|
@@ -154,3 +154,58 @@ export function notifySessionExpired({
|
|
|
154
154
|
}
|
|
155
155
|
return alert
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* #509 & #510: Fires an alert when an account enters quarantine.
|
|
160
|
+
*/
|
|
161
|
+
export function notifyQuarantineEntered({
|
|
162
|
+
ref,
|
|
163
|
+
provider,
|
|
164
|
+
reason,
|
|
165
|
+
until,
|
|
166
|
+
webhookUrl = "",
|
|
167
|
+
fetchImpl = fetch,
|
|
168
|
+
nowMs = Date.now(),
|
|
169
|
+
}) {
|
|
170
|
+
const untilIso = until ? new Date(until).toISOString() : ""
|
|
171
|
+
const alert = recordAlert({
|
|
172
|
+
type: "quarantine_entered",
|
|
173
|
+
ref,
|
|
174
|
+
provider,
|
|
175
|
+
message: "Account " + (provider || "slot") + " (" + (ref || "") + ") entered quarantine (" + (reason || "RATE_LIMIT") + ")" + (untilIso ? " until " + untilIso : ""),
|
|
176
|
+
severity: "warn",
|
|
177
|
+
details: { reason, until },
|
|
178
|
+
nowMs,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if (alert && webhookUrl) {
|
|
182
|
+
dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
return alert
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* #509 & #510: Fires an alert when an account is released from quarantine.
|
|
189
|
+
*/
|
|
190
|
+
export function notifyQuarantineReleased({
|
|
191
|
+
ref,
|
|
192
|
+
provider,
|
|
193
|
+
message,
|
|
194
|
+
webhookUrl = "",
|
|
195
|
+
fetchImpl = fetch,
|
|
196
|
+
nowMs = Date.now(),
|
|
197
|
+
}) {
|
|
198
|
+
const alert = recordAlert({
|
|
199
|
+
type: "quarantine_released",
|
|
200
|
+
ref,
|
|
201
|
+
provider,
|
|
202
|
+
message: message || ("Account " + (provider || "slot") + " (" + (ref || "") + ") released from quarantine and is active"),
|
|
203
|
+
severity: "info",
|
|
204
|
+
nowMs,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
if (alert && webhookUrl) {
|
|
208
|
+
dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
|
|
209
|
+
}
|
|
210
|
+
return alert
|
|
211
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -92,6 +92,7 @@ window.__ModuleLoader__.load({
|
|
|
92
92
|
'.dsub-ledWarn{background:var(--dsw-alias-state-warn-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 70%,transparent)}' +
|
|
93
93
|
'.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-error-primary) 70%,transparent)}' +
|
|
94
94
|
'.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}' +
|
|
95
|
+
'.dsub-toast-container{position:fixed;top:16px;right:16px;z-index:10030;display:flex;flex-direction:column;gap:8px;pointer-events:none}' +'.dsub-toast{pointer-events:auto;min-width:280px;max-width:400px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 8px 24px color-mix(in srgb,black 40%,transparent);display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;line-height:1.4;animation:dsubFadeIn .2s ease-out}' +'.dsub-toast-warn{border-color:var(--dsw-alias-state-warn-primary)}' +'.dsub-toast-bad{border-color:var(--dsw-alias-state-error-primary)}' +'.dsub-toast-ok{border-color:var(--dsw-alias-state-success-primary)}' +'.dsub-toast-close{background:none;border:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:0 2px}' +'.dsub-toast-close:hover{color:var(--dsw-alias-label-primary)}' +
|
|
95
96
|
'.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb, black 65%, transparent);backdrop-filter:blur(10px);animation:dsubFadeIn .16s ease-out}' +
|
|
96
97
|
'.dsub-modal{width:min(520px,94vw);max-height:min(85vh,720px);overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px color-mix(in srgb, black 60%, transparent),0 0 0 1px color-mix(in srgb, white 7%, transparent);padding:20px;animation:dsubScaleIn .18s cubic-bezier(.16,1,.3,1)}' +
|
|
97
98
|
'@keyframes dsubFadeIn{from{opacity:0}to{opacity:1}}' +
|
|
@@ -536,7 +537,19 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
536
537
|
"backupPayloadPlaceholder": "Paste DSHE... encrypted string here",
|
|
537
538
|
"backupExportOk": "Backup exported successfully",
|
|
538
539
|
"backupImportOk": "Backup imported successfully",
|
|
539
|
-
"backupDownload": "Download .enc"
|
|
540
|
+
"backupDownload": "Download .enc",
|
|
541
|
+
"quarantinePark": "Park",
|
|
542
|
+
"quarantineParkTitle": "Park account in quarantine for 1 hour",
|
|
543
|
+
"probeWarmupNow": "Probe Now",
|
|
544
|
+
"probeWarmupTitle": "Run immediate warmup health probe",
|
|
545
|
+
"releaseQuarantine": "Release",
|
|
546
|
+
"releaseQuarantineTitle": "Release account from quarantine",
|
|
547
|
+
"resetsIn": "Resets in",
|
|
548
|
+
"toastQuarantined": "Account placed in quarantine",
|
|
549
|
+
"toastReleased": "Account active and healthy",
|
|
550
|
+
"toastQuotaWarning": "Quota threshold exceeded",
|
|
551
|
+
"enableToasts": "In-app toast alerts",
|
|
552
|
+
"field.enableToasts": "Show popup toast notifications on quarantine or quota events"
|
|
540
553
|
};
|
|
541
554
|
|
|
542
555
|
const zh = {
|
|
@@ -735,7 +748,19 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
735
748
|
"backupPayloadPlaceholder": "在此粘贴 DSHE... 加密内容",
|
|
736
749
|
"backupExportOk": "备份导出成功",
|
|
737
750
|
"backupImportOk": "备份导入成功",
|
|
738
|
-
"backupDownload": "下载 .enc 文件"
|
|
751
|
+
"backupDownload": "下载 .enc 文件",
|
|
752
|
+
"quarantinePark": "隔离",
|
|
753
|
+
"quarantineParkTitle": "将账户临时隔离 1 小时",
|
|
754
|
+
"probeWarmupNow": "立即探测",
|
|
755
|
+
"probeWarmupTitle": "立即执行健康预热探测",
|
|
756
|
+
"releaseQuarantine": "解除隔离",
|
|
757
|
+
"releaseQuarantineTitle": "立即解除账户隔离状态",
|
|
758
|
+
"resetsIn": "重置倒计时",
|
|
759
|
+
"toastQuarantined": "账户进入隔离期",
|
|
760
|
+
"toastReleased": "账户已解除隔离恢复使用",
|
|
761
|
+
"toastQuotaWarning": "额度预警",
|
|
762
|
+
"enableToasts": "应用内弹窗提醒",
|
|
763
|
+
"field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
|
|
739
764
|
};
|
|
740
765
|
// #51: live countdown to the quota window reset (account.quota.resetAt).
|
|
741
766
|
function cleanErrorMessage(raw) {
|
|
@@ -1251,6 +1276,46 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1251
1276
|
}
|
|
1252
1277
|
|
|
1253
1278
|
|
|
1279
|
+
|
|
1280
|
+
const doParkQuarantine = async (provider, index, durationMs) => {
|
|
1281
|
+
setErr("")
|
|
1282
|
+
try {
|
|
1283
|
+
const res = await fetch("/dsh-subscriptions/quarantine/park", {
|
|
1284
|
+
method: "POST",
|
|
1285
|
+
headers: { "Content-Type": "application/json" },
|
|
1286
|
+
body: JSON.stringify({ provider, index, durationMs: durationMs || 3600000, reason: "MANUAL_PARK" }),
|
|
1287
|
+
})
|
|
1288
|
+
const data = await res.json().catch(() => ({}))
|
|
1289
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || ("HTTP " + res.status))
|
|
1290
|
+
setAccounts(data.accounts || [])
|
|
1291
|
+
setInfo(t("toastQuarantined"))
|
|
1292
|
+
setTimeout(() => setInfo(""), 3000)
|
|
1293
|
+
} catch (e) { setErr(cleanErrorMessage(e.message || e)) }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const doProbeQuarantine = async (provider, index, releaseOnly) => {
|
|
1297
|
+
setErr("")
|
|
1298
|
+
const key = provider + ":" + index
|
|
1299
|
+
setChecking((c) => Object.assign({}, c, { [key]: true }))
|
|
1300
|
+
try {
|
|
1301
|
+
const res = await fetch("/dsh-subscriptions/quarantine/probe", {
|
|
1302
|
+
method: "POST",
|
|
1303
|
+
headers: { "Content-Type": "application/json" },
|
|
1304
|
+
body: JSON.stringify({ provider, index, release: Boolean(releaseOnly) }),
|
|
1305
|
+
})
|
|
1306
|
+
const data = await res.json().catch(() => ({}))
|
|
1307
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || ("HTTP " + res.status))
|
|
1308
|
+
setAccounts(data.accounts || [])
|
|
1309
|
+
if (data.success || data.released) {
|
|
1310
|
+
setInfo(t("toastReleased"))
|
|
1311
|
+
setTimeout(() => setInfo(""), 3000)
|
|
1312
|
+
} else if (data.error) {
|
|
1313
|
+
setErr(cleanErrorMessage(data.error))
|
|
1314
|
+
}
|
|
1315
|
+
} catch (e) { setErr(cleanErrorMessage(e.message || e)) }
|
|
1316
|
+
setChecking((c) => Object.assign({}, c, { [key]: false }))
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1254
1319
|
const doCheck = async (provider, index) => {
|
|
1255
1320
|
const key = provider + ':' + index
|
|
1256
1321
|
setChecking((c) => Object.assign({}, c, { [key]: true }))
|
|
@@ -1754,6 +1819,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1754
1819
|
disabled: checking[key],
|
|
1755
1820
|
onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
|
|
1756
1821
|
}, checking[key] ? t('checking') : t('check')),
|
|
1822
|
+
(account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now() ? React.createElement(React.Fragment, null,
|
|
1823
|
+
React.createElement('button', {
|
|
1824
|
+
type: 'button', className: 'dsub-mini', title: t('probeWarmupTitle'),
|
|
1825
|
+
disabled: checking[key],
|
|
1826
|
+
onClick: () => doProbeQuarantine(row.slot.provider, row.slot.index, false),
|
|
1827
|
+
}, checking[key] ? t('checking') : ('🔄 ' + t('probeWarmupNow'))),
|
|
1828
|
+
React.createElement('button', {
|
|
1829
|
+
type: 'button', className: 'dsub-mini', title: t('releaseQuarantineTitle'),
|
|
1830
|
+
disabled: checking[key],
|
|
1831
|
+
onClick: () => doProbeQuarantine(row.slot.provider, row.slot.index, true),
|
|
1832
|
+
}, ('✕ ' + t('releaseQuarantine'))),
|
|
1833
|
+
) : (on ? React.createElement('button', {
|
|
1834
|
+
type: 'button', className: 'dsub-mini', title: t('quarantineParkTitle'),
|
|
1835
|
+
onClick: () => doParkQuarantine(row.slot.provider, row.slot.index, 3600000),
|
|
1836
|
+
}, ('⏸ ' + t('quarantinePark'))) : null)),
|
|
1757
1837
|
React.createElement('button', {
|
|
1758
1838
|
type: 'button', className: 'dsub-mini', title: t('removeSlot'),
|
|
1759
1839
|
onClick: () => {
|
|
@@ -1764,6 +1844,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1764
1844
|
},
|
|
1765
1845
|
}, '\u00d7'),
|
|
1766
1846
|
),
|
|
1847
|
+
(account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now()) ? React.createElement('div', { className: 'dsub-row', style: { marginTop: 4 } },
|
|
1848
|
+
React.createElement('span', { className: 'dsub-sub', style: { color: 'var(--dsw-alias-state-error-primary)', fontVariantNumeric: 'tabular-nums' } },
|
|
1849
|
+
'⛔ ' + (account.quarantineReason || 'QUARANTINE') + ' · ' + t('resetsIn') + ' ' + formatRelativeReset(account.quarantineUntil, t('lang'), Date.now())
|
|
1850
|
+
),
|
|
1851
|
+
) : (account && account.quota && account.quota.resetAt && Number(account.quota.resetAt) > Date.now()) ? React.createElement('div', { className: 'dsub-row', style: { marginTop: 4 } },
|
|
1852
|
+
React.createElement(ResetCountdown, { resetAt: account.quota.resetAt }),
|
|
1853
|
+
) : null,
|
|
1767
1854
|
React.createElement('div', { className: 'dsub-manual' },
|
|
1768
1855
|
React.createElement('span', { className: 'dsub-sub' }, t('manualTitle')),
|
|
1769
1856
|
React.createElement('div', { className: 'dsub-row' },
|
|
@@ -2461,6 +2548,33 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2461
2548
|
function SubsPill(props) {
|
|
2462
2549
|
const t = (props && props.t) || ((k) => k)
|
|
2463
2550
|
const [state, setState] = React.useState({ logged: [], usage: 0, accounts: [], open: false, active: null, loading: false })
|
|
2551
|
+
const [toasts, setToasts] = React.useState([])
|
|
2552
|
+
const lastAlertTsRef = React.useRef(Date.now() - 30000)
|
|
2553
|
+
|
|
2554
|
+
const dismissToast = (id) => {
|
|
2555
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const pollAlerts = () => {
|
|
2559
|
+
fetch('/dsh-subscriptions/alerts?limit=5', { cache: 'no-store' })
|
|
2560
|
+
.then((res) => res.json())
|
|
2561
|
+
.then((data) => {
|
|
2562
|
+
if (data && Array.isArray(data.alerts)) {
|
|
2563
|
+
const fresh = data.alerts.filter((a) => a && a.timestamp > lastAlertTsRef.current)
|
|
2564
|
+
if (fresh.length > 0) {
|
|
2565
|
+
lastAlertTsRef.current = Math.max(...fresh.map((a) => a.timestamp))
|
|
2566
|
+
fresh.forEach((al) => {
|
|
2567
|
+
const toastId = 't_' + al.timestamp + '_' + Math.random().toString(36).slice(2, 5)
|
|
2568
|
+
const cls = al.severity === 'error' ? 'dsub-toast-bad' : (al.severity === 'info' ? 'dsub-toast-ok' : 'dsub-toast-warn')
|
|
2569
|
+
const newToast = { id: toastId, message: al.message, cls }
|
|
2570
|
+
setToasts((prev) => [newToast, ...prev].slice(0, 3))
|
|
2571
|
+
setTimeout(() => dismissToast(toastId), 5000)
|
|
2572
|
+
})
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
})
|
|
2576
|
+
.catch(() => {})
|
|
2577
|
+
}
|
|
2464
2578
|
|
|
2465
2579
|
const pull = () => {
|
|
2466
2580
|
setState((s) => Object.assign({}, s, { loading: true }))
|
|
@@ -2481,7 +2595,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2481
2595
|
|
|
2482
2596
|
React.useEffect(() => {
|
|
2483
2597
|
pull()
|
|
2484
|
-
|
|
2598
|
+
pollAlerts()
|
|
2599
|
+
const id = setInterval(() => { pull(); pollAlerts(); }, 25 * 1000)
|
|
2485
2600
|
return () => clearInterval(id)
|
|
2486
2601
|
}, [])
|
|
2487
2602
|
|
|
@@ -2617,6 +2732,16 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2617
2732
|
})
|
|
2618
2733
|
|
|
2619
2734
|
return React.createElement(React.Fragment, null,
|
|
2735
|
+
toasts.length > 0 ? React.createElement('div', { className: 'dsub-toast-container' },
|
|
2736
|
+
toasts.map((toast) => React.createElement('div', { className: 'dsub-toast ' + toast.cls, key: toast.id },
|
|
2737
|
+
React.createElement('span', null, toast.message),
|
|
2738
|
+
React.createElement('button', {
|
|
2739
|
+
type: 'button',
|
|
2740
|
+
className: 'dsub-toast-close',
|
|
2741
|
+
onClick: () => dismissToast(toast.id),
|
|
2742
|
+
}, "\u00d7"),
|
|
2743
|
+
))
|
|
2744
|
+
) : null,
|
|
2620
2745
|
React.createElement('button', {
|
|
2621
2746
|
type: 'button',
|
|
2622
2747
|
className: 'dsub-pill',
|
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.'),
|
|
@@ -108,6 +110,8 @@ export const Config = z.object({
|
|
|
108
110
|
.description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
|
|
109
111
|
alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
|
|
110
112
|
.description('#351 Quota usage percentage thresholds for firing alerts.'),
|
|
113
|
+
enableToasts: z.boolean().volatile().default(true)
|
|
114
|
+
.description('#510 In-app toast alerts for quarantine and quota threshold events.'),
|
|
111
115
|
autoPacing: z.boolean().volatile().default(true)
|
|
112
116
|
.description('#352 Predictive burn-rate load balancing between accounts.'),
|
|
113
117
|
drainBeforeReset: z.boolean().volatile().default(false)
|
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)
|
package/lib/quarantine.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export const REASON_RATE_LIMIT = 'RATE_LIMIT'
|
|
3
3
|
export const REASON_HARD_LIMIT = 'HARD_LIMIT'
|
|
4
4
|
export const REASON_REVOKED = 'TOKEN_REVOKED'
|
|
5
|
+
export const REASON_MANUAL_PARK = 'MANUAL_PARK'
|
|
5
6
|
|
|
6
7
|
export const STATUS_ACTIVE = 'active'
|
|
7
8
|
export const STATUS_QUARANTINE = 'quarantine'
|
|
@@ -13,6 +14,8 @@ export function quarantineDuration(reason) {
|
|
|
13
14
|
return 24 * 60 * 60 * 1000 // 24 hours
|
|
14
15
|
case REASON_REVOKED:
|
|
15
16
|
return 7 * 24 * 60 * 60 * 1000 // 7 days (requires re-auth)
|
|
17
|
+
case REASON_MANUAL_PARK:
|
|
18
|
+
return 60 * 60 * 1000 // 1 hour (manual park)
|
|
16
19
|
case REASON_RATE_LIMIT:
|
|
17
20
|
default:
|
|
18
21
|
return 60 * 60 * 1000 // 1 hour
|
package/lib/routes/accounts.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { exportVault, importVault } from '../vault.js'
|
|
2
|
-
import { normalizeSlots } from '../accounts.js'
|
|
2
|
+
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
3
|
+
import { getVendor } from '../vendors/index.js'
|
|
4
|
+
import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
|
|
3
5
|
import { Config, plainConfig, publicConfig } from '../config-schema.js'
|
|
4
6
|
import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
|
|
5
7
|
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
@@ -21,6 +23,118 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
21
23
|
} = state
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
|
|
27
|
+
// Issue #509: Manual park to quarantine
|
|
28
|
+
ctx.effect(() => ctx.webServer.register({
|
|
29
|
+
kind: 'exact',
|
|
30
|
+
path: '/dsh-subscriptions/quarantine/park',
|
|
31
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
32
|
+
if (req.method !== 'POST') {
|
|
33
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
37
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
let payload
|
|
41
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
42
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const provider = String(payload.provider || '')
|
|
46
|
+
const index = Number(payload.index || 1)
|
|
47
|
+
if (!isProvider(provider)) {
|
|
48
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const ref = refForSlot(provider, index)
|
|
52
|
+
if (!ref) {
|
|
53
|
+
writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const durationMs = Math.min(Math.max(60 * 1000, Number(payload.durationMs) || 3600 * 1000), 48 * 3600 * 1000)
|
|
57
|
+
const reason = String(payload.reason || 'MANUAL_PARK').slice(0, 64)
|
|
58
|
+
const until = Date.now() + durationMs
|
|
59
|
+
|
|
60
|
+
store.rememberQuarantine(ref, reason, until, 1)
|
|
61
|
+
notifyQuarantineEntered({ ref, provider, reason, until, webhookUrl: live().webhookUrl })
|
|
62
|
+
await syncAdapter()
|
|
63
|
+
|
|
64
|
+
writeJson(res, 200, { ok: true, ref, quarantineUntil: until, quarantineReason: reason, accounts: await accountsView() })
|
|
65
|
+
}),
|
|
66
|
+
}), 'dsh-subscriptions: /quarantine/park')
|
|
67
|
+
|
|
68
|
+
// Issue #509: Force probe warmup or release quarantine
|
|
69
|
+
ctx.effect(() => ctx.webServer.register({
|
|
70
|
+
kind: 'exact',
|
|
71
|
+
path: '/dsh-subscriptions/quarantine/probe',
|
|
72
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
73
|
+
if (req.method !== 'POST') {
|
|
74
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
78
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
let payload
|
|
82
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
83
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const provider = String(payload.provider || '')
|
|
87
|
+
const index = Number(payload.index || 1)
|
|
88
|
+
const forceRelease = Boolean(payload.release)
|
|
89
|
+
if (!isProvider(provider)) {
|
|
90
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const ref = refForSlot(provider, index)
|
|
94
|
+
if (!ref) {
|
|
95
|
+
writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (forceRelease) {
|
|
100
|
+
store.releaseQuarantine(ref)
|
|
101
|
+
notifyQuarantineReleased({ ref, provider, webhookUrl: live().webhookUrl })
|
|
102
|
+
await syncAdapter()
|
|
103
|
+
writeJson(res, 200, { ok: true, success: true, released: true, accounts: await accountsView() })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const probeFn = async (r) => {
|
|
108
|
+
const blob = await store.loadBlob(r)
|
|
109
|
+
if (!blob) throw new Error('no credentials for slot')
|
|
110
|
+
let vendor = null
|
|
111
|
+
try {
|
|
112
|
+
vendor = getVendor(provider)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
ctx.logger?.debug?.('getVendor(%s) probe failed: %s', provider, err?.message)
|
|
115
|
+
}
|
|
116
|
+
if (vendor && typeof vendor.check === 'function') {
|
|
117
|
+
const cfg = vendorConfig(provider, live())
|
|
118
|
+
const fetchImpl = typeof state.fetchForRef === 'function' ? state.fetchForRef(r) : fetch
|
|
119
|
+
await vendor.check(blob, cfg, fetchImpl)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = await store.probeWarmup(ref, probeFn, true)
|
|
124
|
+
if (result.success) {
|
|
125
|
+
notifyQuarantineReleased({ ref, provider, webhookUrl: live().webhookUrl })
|
|
126
|
+
}
|
|
127
|
+
await syncAdapter()
|
|
128
|
+
|
|
129
|
+
writeJson(res, 200, {
|
|
130
|
+
ok: true,
|
|
131
|
+
success: Boolean(result.success),
|
|
132
|
+
error: result.error ? String(result.error.message || result.error).slice(0, 200) : null,
|
|
133
|
+
accounts: await accountsView(),
|
|
134
|
+
})
|
|
135
|
+
}),
|
|
136
|
+
}), 'dsh-subscriptions: /quarantine/probe')
|
|
137
|
+
|
|
24
138
|
ctx.effect(() => ctx.webServer.register({
|
|
25
139
|
kind: 'exact',
|
|
26
140
|
path: '/dsh-subscriptions/reset-credits',
|
package/lib/stream-rotate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
|
|
2
2
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
|
|
3
|
-
import { notifySessionExpired } from './alerts.js'
|
|
3
|
+
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
4
4
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
5
5
|
|
|
6
6
|
export async function* streamWithRotation({
|
|
@@ -114,6 +114,18 @@ export async function* streamWithRotation({
|
|
|
114
114
|
account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
try {
|
|
118
|
+
notifyQuarantineEntered({
|
|
119
|
+
ref: account.ref,
|
|
120
|
+
provider: options && options.provider,
|
|
121
|
+
reason: account.quarantineReason || reason,
|
|
122
|
+
until: account.quarantineUntil,
|
|
123
|
+
webhookUrl: webhookUrl || (options && options.webhookUrl),
|
|
124
|
+
fetchImpl,
|
|
125
|
+
nowMs: nowMs(),
|
|
126
|
+
})
|
|
127
|
+
} catch { /* fire-and-forget alert */ }
|
|
128
|
+
|
|
117
129
|
if (reason === REASON_REVOKED) {
|
|
118
130
|
try {
|
|
119
131
|
notifySessionExpired({
|
|
@@ -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.60",
|
|
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",
|