@goodandready/dsh-clinebot 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -9,7 +9,7 @@ window.__ModuleLoader__.load({
9
9
  const SUBTITLE = 'ClinePass subscription ($9.99/mo) with curated & custom models.'
10
10
  const ROUTE_PREFIX = '/dsh-clinebot'
11
11
 
12
- const en = {
12
+ const en = {
13
13
  title: 'ClineBot',
14
14
  subtitle: 'ClinePass subscription ($9.99/mo) with curated & custom models.',
15
15
  'settings.loading': 'Loading ClineBot settings…',
@@ -89,88 +89,98 @@ window.__ModuleLoader__.load({
89
89
  'diag.unregister_btn': 'Unregister from DSH',
90
90
  'diag.unregistering': 'Removing…',
91
91
  'diag.unregistered_msg': 'Provider removed from DSH Models',
92
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
93
+ 'update.btn': 'Update Now',
94
+ 'update.updating': 'Updating…',
95
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
96
+ 'update.failed': 'Update failed: {error}',
92
97
  }
93
98
 
94
- const ru = {
99
+ const zh = {
95
100
  title: 'ClineBot',
96
- subtitle: 'Подписка ClinePass ($9.99/мес) с каталогом проверенных моделей.',
97
- 'settings.loading': 'Загрузка настроек ClineBot…',
98
- 'settings.retry': 'Повторить попытку',
99
- 'settings.unavailable': 'Настройки ClineBot недоступны (пространство хоста ещё не готово).',
100
- 'header.title': 'Провайдер ClineBot (ClinePass)',
101
- 'header.sub': 'Подключение моделей по подписке ClinePass ($9.99/мес). OpenAI API совместимый, автоматическое отслеживание лимитов и моментальная авторегистрация в DSH.',
102
- 'badge.online': 'Сервер доступен ({latency} мс)',
103
- 'badge.offline': 'Недоступен',
104
- 'badge.key_ok': 'Ключ ✓ ({source})',
105
- 'badge.key_missing': 'Ключ отсутствует',
106
- 'badge.registered': 'Зарегистрирован в DSH ({count} мод.)',
107
- 'badge.not_registered': 'Не зарегистрирован',
108
- 'key.title': '🔑 Авторизация и API-ключ',
109
- 'key.desc': 'Ключ сохраняется в системное хранилище DSH Credentials (~/.dsh/.credentials.yaml) и никогда не попадает в открытые файлы настроек.',
101
+ subtitle: 'ClinePass 订阅服务 ($9.99/月),包含精选与自定义模型。',
102
+ 'settings.loading': '正在加载 ClineBot 设置…',
103
+ 'settings.retry': '重试',
104
+ 'settings.unavailable': 'ClineBot 设置不可用(宿主命名空间尚未就绪)。',
105
+ 'header.title': 'ClineBot (ClinePass) 模型服务商',
106
+ 'header.sub': '通过 ClinePass 订阅 ($9.99/月) 连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
107
+ 'badge.online': '服务在线 ({latency} ms)',
108
+ 'badge.offline': '主机无法连接',
109
+ 'badge.key_ok': '密钥正常 ({source})',
110
+ 'badge.key_missing': '未配置密钥',
111
+ 'badge.registered': '已接入 DSH ({count} 个模型)',
112
+ 'badge.not_registered': '未接入 DSH',
113
+ 'key.title': '🔑 授权与 API 密钥',
114
+ 'key.desc': '密钥直接加密保存在 DSH 凭据管理器 (~/.dsh/.credentials.yaml),绝不会以明文形式暴露在普通配置文件中。',
110
115
  'key.placeholder_has': '••••••••••••••••••••••••',
111
- 'key.placeholder_empty': 'Вставьте ключ ClinePass (cline_...)',
112
- 'key.show': 'Показать',
113
- 'key.hide': 'Скрыть',
114
- 'key.save': 'Сохранить ключ',
115
- 'key.saving': 'Сохранение…',
116
- 'key.login_fast': '🚀 Быстрый вход через браузер',
117
- 'key.login_waiting': 'Ожидание авторизации в браузере…',
118
- 'key.env_label': 'Переменная учётных данных: ',
119
- 'key.get_key': 'Получить ключ в консоли app.cline.bot ↗',
120
- 'key.saved_msg': 'Ключ сохранён в DSH Credentials. Валидация: {status}',
121
- 'key.empty_err': 'Введите API-ключ перед сохранением',
122
- 'accounts.title': '👥 Пул аккаунтов и авторотация (Failover)',
123
- 'accounts.desc': 'Добавьте запасные аккаунты. При исчерпании лимитов (429) ClineBot автоматически переключит запрос на следующий доступный аккаунт.',
124
- 'accounts.active_badge': 'Активен',
125
- 'accounts.pinned_badge': 'Закреплен',
126
- 'accounts.pin_btn': 'Сделать активным',
127
- 'accounts.add_btn': '+ Добавить аккаунт',
128
- 'accounts.label_placeholder': 'Название (например, Work)',
116
+ 'key.placeholder_empty': '粘贴 ClinePass 密钥 (cline_...)',
117
+ 'key.show': '显示',
118
+ 'key.hide': '隐藏',
119
+ 'key.save': '保存密钥',
120
+ 'key.saving': '正在保存…',
121
+ 'key.login_fast': '🚀 浏览器快速登录',
122
+ 'key.login_waiting': '等待在浏览器中完成授权…',
123
+ 'key.env_label': '凭据环境变量名称:',
124
+ 'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
125
+ 'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
126
+ 'key.empty_err': '保存前请输入 API 密钥',
127
+ 'accounts.title': '👥 多账号故障转移与轮换池 (Failover)',
128
+ 'accounts.desc': '添加备用账号。当某个账号触发 429 速率限制或配额耗尽时,ClineBot 会智能无缝切换至剩余额度最高的备用账号。',
129
+ 'accounts.active_badge': '当前活动',
130
+ 'accounts.pinned_badge': '固定使用',
131
+ 'accounts.pin_btn': '设为活动',
132
+ 'accounts.add_btn': '+ 添加账号',
133
+ 'accounts.label_placeholder': '账号标签(例如:工作账号)',
129
134
  'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
130
- 'quota.title': '📊 Остаток лимитов подписки (ClinePass Quota)',
131
- 'quota.desc': 'Официальные лимиты скользящих окон запросов ClinePass',
132
- 'quota.account': 'Аккаунт: {email} · Тариф: {plan}',
133
- 'quota.refresh': 'Обновить квоты',
134
- 'quota.refreshing': 'Обновление…',
135
- 'quota.refreshed_msg': 'Лимиты подписки обновлены',
136
- 'quota.window_5h': '⏱ 5-часовое скользящее окно (Rolling Limit)',
137
- 'quota.window_weekly': '📅 Недельное окно (Weekly Window)',
138
- 'quota.used': '{pct}% использовано',
139
- 'quota.remaining': 'Осталось: {pct}%',
140
- 'quota.reset_at': 'Сброс: {time}',
141
- 'models.title': '🎯 Модели в пикере DSH ({enabled} из {total} включено)',
142
- 'models.desc': 'Список синхронизируется напрямую из подписки ClinePass. Новые модели появляются автоматически. Снимите галочку, чтобы скрыть ненужную модель из чата.',
143
- 'models.sync': '🔄 Проверить модели плана',
144
- 'models.syncing': 'Проверка…',
145
- 'models.synced_msg': 'Синхронизировано моделей подписки: {total} ({discovered} получено напрямую из плана)',
146
- 'models.all': 'Все',
147
- 'models.vision': 'Только Vision',
148
- 'models.coding': 'Только Кодинг',
149
- 'models.recommended': 'Рекомендованные',
150
- 'models.th_active': 'Вкл',
151
- 'models.th_name': 'Название модели',
152
- 'models.th_id': 'Model ID',
153
- 'models.th_ctx': 'Контекст',
154
- 'models.th_caps': 'Возможности',
155
- 'models.star': 'Star',
156
- 'models.auto_new': 'Новая',
157
- 'stats.title': '📈 Статистика текущей сессии DSH',
158
- 'stats.desc': 'Локальный мониторинг запросов, задержек сети и ориентировочного расхода токенов через ClinePass.',
159
- 'stats.requests': 'Успешных запросов / Всего',
160
- 'stats.tokens': 'Оценка токенов сессии',
161
- 'stats.latency': 'Последняя задержка (Latency)',
162
- 'stats.last_req': 'Время последнего запроса',
163
- 'diag.title': '⚡ Диагностика и авторегистрация в DSH',
164
- 'diag.desc': 'ClineBot автоматически регистрируется в DSH Models при наличии ключа. Вы можете запустить проверку связи или принудительно пересинхронизировать модели.',
165
- 'diag.smoke_btn': 'Запустить Smoke Test (Ping)',
166
- 'diag.smoke_testing': 'Тестирование…',
167
- 'diag.smoke_ok': 'Smoke тест пройден! Задержка: {latency} мс',
168
- 'diag.resync_btn': 'Принудительно обновить в DSH',
169
- 'diag.resyncing': 'Обновление…',
170
- 'diag.resynced_msg': 'Провайдер обновлен в DSH Models ({count} моделей)',
171
- 'diag.unregister_btn': 'Удалить из DSH Models',
172
- 'diag.unregistering': 'Удаление…',
173
- 'diag.unregistered_msg': 'Провайдер удален из DSH Models',
135
+ 'quota.title': '📊 ClinePass 用量配额与限额监控',
136
+ 'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
137
+ 'quota.account': '账号:{email} · 套餐:{plan}',
138
+ 'quota.refresh': '刷新配额',
139
+ 'quota.refreshing': '正在刷新…',
140
+ 'quota.refreshed_msg': '订阅用量配额已刷新',
141
+ 'quota.window_5h': '⏱ 5 小时滚动限额',
142
+ 'quota.window_weekly': '📅 每周滚动窗口',
143
+ 'quota.used': '已使用 {pct}%',
144
+ 'quota.remaining': '剩余可用:{pct}%',
145
+ 'quota.reset_at': '重置时间:{time}',
146
+ 'models.title': '🎯 DSH 模型选择列表(已启用 {enabled}/{total})',
147
+ 'models.desc': '模型目录与 ClinePass 官方订阅实时同步。新订阅模型默认自动启用。取消勾选可在 DSH 对话模型列表中隐藏不需要的模型。',
148
+ 'models.sync': '🔄 同步订阅模型',
149
+ 'models.syncing': '正在同步…',
150
+ 'models.synced_msg': '已成功同步订阅模型:共 {total} 个(发现 {discovered} 个新模型)',
151
+ 'models.all': '全部模型',
152
+ 'models.vision': '仅视觉 (Vision)',
153
+ 'models.coding': '仅编程 (Coding)',
154
+ 'models.recommended': '推荐模型',
155
+ 'models.th_active': '启用',
156
+ 'models.th_name': '模型名称',
157
+ 'models.th_id': '模型标识符 (ID)',
158
+ 'models.th_ctx': '上下文窗口',
159
+ 'models.th_caps': '特性能力',
160
+ 'models.star': '推荐',
161
+ 'models.auto_new': '新上线',
162
+ 'stats.title': '📈 当前 DSH 会话请求遥测',
163
+ 'stats.desc': '本地实时监控请求次数、网络延迟以及通过 ClinePass 消耗的估算 Token 数量。',
164
+ 'stats.requests': '成功 / 总请求次数',
165
+ 'stats.tokens': '会话累计 Token 估算',
166
+ 'stats.latency': '最近一次请求延迟',
167
+ 'stats.last_req': '最近请求时间',
168
+ 'diag.title': '⚡ 诊断工具与服务接入',
169
+ 'diag.desc': '配置 API 密钥后,ClineBot 会自动向 DSH 注册模型服务。您可以在此进行连通性测试或强制重新同步。',
170
+ 'diag.smoke_btn': '执行连通性冒烟测试 (Ping)',
171
+ 'diag.smoke_testing': '正在测试…',
172
+ 'diag.smoke_ok': '冒烟测试通过!网络延迟:{latency} ms',
173
+ 'diag.resync_btn': '强制重新同步到 DSH',
174
+ 'diag.resyncing': '正在同步…',
175
+ 'diag.resynced_msg': '模型服务已重新同步到 DSH ({count} 个模型)',
176
+ 'diag.unregister_btn': '从 DSH 注销该服务',
177
+ 'diag.unregistering': '正在注销…',
178
+ 'diag.unregistered_msg': '已从 DSH 模型注册表中移除',
179
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
180
+ 'update.btn': '立即更新',
181
+ 'update.updating': '正在更新…',
182
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
183
+ 'update.failed': '更新失败:{error}',
174
184
  }
175
185
 
176
186
  function makeT(dict, fallback) {
@@ -353,7 +363,7 @@ window.__ModuleLoader__.load({
353
363
 
354
364
  function SettingsPage(props) {
355
365
  const ctx = props?.ctx
356
- const t = props?.t || makeT(ru, en)
366
+ const t = props?.t || makeT(en, en)
357
367
 
358
368
  const scope = React.useMemo(() => {
359
369
  const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
@@ -1178,7 +1188,7 @@ window.__ModuleLoader__.load({
1178
1188
  // Accordion item for Plugins section
1179
1189
  function PluginCard(props) {
1180
1190
  const [open, setOpen] = React.useState(false)
1181
- const t = props?.t || makeT(ru, en)
1191
+ const t = props?.t || makeT(en, en)
1182
1192
  React.useEffect(() => {
1183
1193
  ensureCss()
1184
1194
  }, [])
@@ -1261,7 +1271,7 @@ window.__ModuleLoader__.load({
1261
1271
  if (ctx.locale && ctx.locale.register) {
1262
1272
  if (typeof ctx.effect === 'function') {
1263
1273
  ctx.effect(() => {
1264
- const undo = [addLocale('en', en), addLocale('ru', ru)]
1274
+ const undo = [addLocale('en', en), addLocale('zh', zh)]
1265
1275
  return () => { for (const off of undo) off() }
1266
1276
  }, 'dsh-clinebot: dictionaries')
1267
1277
  } else {
@@ -2,7 +2,7 @@
2
2
  * ClineBot / ClinePass client helpers for DeepSeek Harness.
3
3
  *
4
4
  * Implements OpenAI-compatible chat completions interface, usage quota tracking,
5
- * and secure credential storage via DSH credentials service.
5
+ * Stale-While-Revalidate caching, smart failover, and secure credential storage.
6
6
  */
7
7
 
8
8
  import {
@@ -19,6 +19,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
19
19
  export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
20
20
  export const DEFAULT_TIMEOUT_MS = 15000
21
21
  export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
22
+ export const USAGE_CACHE_TTL_MS = 60000
22
23
 
23
24
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
24
25
 
@@ -130,8 +131,8 @@ function abortAfter(ms) {
130
131
  return { signal: ac.signal, cancel: () => clearTimeout(timer) }
131
132
  }
132
133
 
133
- // In-memory cache for quota queries to avoid hammering the ClinePass endpoint
134
- const usageCache = new Map()
134
+ // In-memory caches for quota queries and host health probes
135
+ export const usageCache = new Map()
135
136
  export const probeCache = new Map()
136
137
 
137
138
  export function clearProbeCache() {
@@ -155,7 +156,6 @@ export async function retryWithBackoff(fn, {
155
156
  while (true) {
156
157
  try {
157
158
  const res = await fn()
158
- // If HTTP response-like object with 5xx or 429 status and can retry
159
159
  if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
160
160
  attempt++
161
161
  const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
@@ -179,14 +179,13 @@ export async function retryWithBackoff(fn, {
179
179
 
180
180
  /**
181
181
  * Fetch official ClinePass rate limits and account quota.
182
- * Endpoints:
183
- * - GET /users/me/plan/usage-limits (5-hour, weekly, monthly rolling limits)
184
- * - GET /users/me (account metadata)
182
+ * Uses Stale-While-Revalidate (SWR) caching with keep-alive and network retry.
185
183
  */
186
184
  export async function fetchUsageLimits(baseUrl, apiKey, {
187
185
  timeoutMs = DEFAULT_TIMEOUT_MS,
188
186
  fetchImpl = fetch,
189
187
  bypassCache = false,
188
+ ttlMs = USAGE_CACHE_TTL_MS,
190
189
  } = {}) {
191
190
  const base = normalizeBaseUrl(baseUrl)
192
191
  if (!apiKey) {
@@ -196,126 +195,144 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
196
195
  const cacheKey = `cline:usage:${apiKey.slice(-8)}`
197
196
  const now = Date.now()
198
197
 
199
- if (!bypassCache && usageCache.has(cacheKey)) {
200
- const cached = usageCache.get(cacheKey)
201
- if (cached.expiresAt > now) {
202
- return cached.data
203
- }
204
- }
205
-
206
- const { signal, cancel } = abortAfter(timeoutMs)
207
- try {
208
- const headers = {
209
- Authorization: `Bearer ${apiKey}`,
210
- Accept: 'application/json',
211
- }
212
-
213
- // 1. Fetch usage limits
214
- const limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
215
- method: 'GET',
216
- headers,
217
- signal,
218
- })
198
+ const doFetch = async () => {
199
+ const { signal, cancel } = abortAfter(timeoutMs)
200
+ try {
201
+ const headers = {
202
+ Authorization: `Bearer ${apiKey}`,
203
+ Accept: 'application/json',
204
+ }
219
205
 
220
- if (!limitsRes.ok) {
221
- const errText = await limitsRes.text().catch(() => '')
222
- return {
223
- ok: false,
224
- status: limitsRes.status,
225
- error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
206
+ let limitsRes
207
+ try {
208
+ limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
209
+ method: 'GET',
210
+ headers,
211
+ signal,
212
+ keepalive: true,
213
+ })
214
+ } catch (netErr) {
215
+ // Quick 1-retry fallback on transient network drop
216
+ await new Promise((r) => setTimeout(r, 250))
217
+ limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
218
+ method: 'GET',
219
+ headers,
220
+ signal,
221
+ keepalive: true,
222
+ })
226
223
  }
227
- }
228
224
 
229
- const limitsData = await limitsRes.json().catch(() => ({}))
230
- const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
225
+ if (!limitsRes.ok) {
226
+ const errText = await limitsRes.text().catch(() => '')
227
+ const outcome = {
228
+ ok: false,
229
+ status: limitsRes.status,
230
+ error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
231
+ checkedAt: Date.now(),
232
+ }
233
+ if (!usageCache.has(cacheKey)) {
234
+ usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
235
+ }
236
+ return outcome
237
+ }
231
238
 
232
- const parseWindow = (type) => {
233
- const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
234
- if (!found) return null
235
- const percentUsed = typeof found.percentUsed === 'number'
236
- ? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
237
- : 0
238
- const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
239
- return {
240
- type,
241
- percentUsed,
242
- remainingPercent,
243
- resetsAt: found.resetsAt || null,
239
+ const limitsData = await limitsRes.json().catch(() => ({}))
240
+ const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
241
+
242
+ const parseWindow = (type) => {
243
+ const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
244
+ if (!found) return null
245
+ const percentUsed = typeof found.percentUsed === 'number'
246
+ ? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
247
+ : 0
248
+ const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
249
+ return {
250
+ type,
251
+ percentUsed,
252
+ remainingPercent,
253
+ resetsAt: found.resetsAt || null,
254
+ }
244
255
  }
245
- }
246
256
 
247
- const fiveHour = parseWindow('5-hour')
248
- const weekly = parseWindow('weekly')
249
- const monthly = parseWindow('monthly')
257
+ const fiveHour = parseWindow('5-hour')
258
+ const weekly = parseWindow('weekly')
259
+ const monthly = parseWindow('monthly')
250
260
 
251
- // 2. Fetch user metadata and plan details (optional, best-effort)
252
- let userEmail = null
253
- let createdAt = null
254
- let planDisplayName = 'ClinePass ($9.99/mo)'
255
- let dynamicModels = []
261
+ let userEmail = null
262
+ let createdAt = null
263
+ let planDisplayName = 'ClinePass ($9.99/mo)'
264
+ let dynamicModels = []
256
265
 
257
- try {
258
- const [meRes, planRes] = await Promise.all([
259
- fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal }).catch(() => null),
260
- fetchImpl(`${base}/users/me/plan`, { method: 'GET', headers, signal }).catch(() => null),
261
- ])
262
-
263
- if (meRes && meRes.ok) {
264
- const meData = await meRes.json().catch(() => ({}))
265
- const me = meData?.data || meData?.user || meData
266
- userEmail = me?.email || null
267
- createdAt = me?.createdAt || null
268
- }
266
+ try {
267
+ const [meRes, planRes] = await Promise.all([
268
+ fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal, keepalive: true }).catch(() => null),
269
+ fetchImpl(`${base}/users/me/plan`, { method: 'GET', headers, signal, keepalive: true }).catch(() => null),
270
+ ])
269
271
 
270
- if (planRes && planRes.ok) {
271
- const planData = await planRes.json().catch(() => ({}))
272
- const planObj = planData?.data?.plan || planData?.plan
273
- if (planObj?.displayName) {
274
- planDisplayName = planObj.displayName
272
+ if (meRes?.ok) {
273
+ const meData = await meRes.json().catch(() => ({}))
274
+ userEmail = meData?.data?.email || meData?.email || null
275
+ createdAt = meData?.data?.createdAt || meData?.createdAt || null
275
276
  }
276
- const includedArr = planObj?.features?.included
277
- if (Array.isArray(includedArr)) {
278
- const incStr = includedArr.find((s) => typeof s === 'string' && s.toLowerCase().includes('includes'))
279
- if (incStr) {
280
- dynamicModels = parsePlanIncludedModels(incStr)
277
+
278
+ if (planRes?.ok) {
279
+ const planData = await planRes.json().catch(() => ({}))
280
+ const plan = planData?.data || planData
281
+ if (plan?.title || plan?.name) {
282
+ planDisplayName = `${plan.title || plan.name} ($${((plan.priceInCents || 999) / 100).toFixed(2)}/mo)`
283
+ }
284
+ if (Array.isArray(plan?.includedModels)) {
285
+ dynamicModels = parsePlanIncludedModels(plan.includedModels)
281
286
  }
282
287
  }
288
+ } catch {
289
+ /* best-effort secondary details */
283
290
  }
284
- } catch {
285
- /* ignore user metadata / plan failure */
286
- }
287
291
 
288
- const result = {
289
- ok: true,
290
- plan: planDisplayName,
291
- user: {
292
- email: userEmail,
293
- createdAt,
294
- },
295
- windows: {
296
- fiveHour: fiveHour || { type: '5-hour', percentUsed: 0, remainingPercent: 100, resetsAt: null },
297
- weekly: weekly || { type: 'weekly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
298
- monthly: monthly || { type: 'monthly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
299
- },
300
- dynamicModels,
301
- checkedAt: now,
292
+ const result = {
293
+ ok: true,
294
+ plan: planDisplayName,
295
+ user: { email: userEmail, createdAt },
296
+ windows: { fiveHour, weekly, monthly },
297
+ dynamicModels,
298
+ checkedAt: Date.now(),
299
+ }
300
+
301
+ usageCache.set(cacheKey, { data: result, expiresAt: Date.now() + ttlMs, isRevalidating: false })
302
+ return result
303
+ } catch (err) {
304
+ const outcome = {
305
+ ok: false,
306
+ error: String(err?.message || err),
307
+ checkedAt: Date.now(),
308
+ }
309
+ if (!usageCache.has(cacheKey)) {
310
+ usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
311
+ }
312
+ return outcome
313
+ } finally {
314
+ cancel()
302
315
  }
316
+ }
303
317
 
304
- // Cache for 60 seconds
305
- usageCache.set(cacheKey, { expiresAt: now + 60000, data: result })
306
- return result
307
- } catch (err) {
308
- return {
309
- ok: false,
310
- error: String(err?.message || err),
318
+ if (!bypassCache && usageCache.has(cacheKey)) {
319
+ const cached = usageCache.get(cacheKey)
320
+ if (cached.expiresAt > now) {
321
+ return cached.data
311
322
  }
312
- } finally {
313
- cancel()
323
+ // SWR: return stale data immediately, revalidate asynchronously
324
+ if (!cached.isRevalidating) {
325
+ cached.isRevalidating = true
326
+ doFetch().catch(() => {})
327
+ }
328
+ return cached.data
314
329
  }
330
+
331
+ return doFetch()
315
332
  }
316
333
 
317
334
  /**
318
- * Quick network probe to verify server availability with Stale-While-Revalidate caching.
335
+ * Health check probe with SWR caching.
319
336
  */
320
337
  export async function probeHealth(baseUrl, {
321
338
  timeoutMs = DEFAULT_TIMEOUT_MS,
@@ -323,15 +340,16 @@ export async function probeHealth(baseUrl, {
323
340
  bypassCache = false,
324
341
  ttlMs = 25000,
325
342
  } = {}) {
326
- const root = normalizeBaseUrl(baseUrl)
327
- const cacheKey = `probe:${root}`
343
+ const base = normalizeBaseUrl(baseUrl)
344
+ const root = base.replace(/\/api\/v1$/i, '')
345
+ const cacheKey = `cline:health:${root}`
328
346
  const now = Date.now()
329
347
 
330
348
  const doProbe = async () => {
331
349
  const { signal, cancel } = abortAfter(timeoutMs)
332
350
  const start = Date.now()
333
351
  try {
334
- const res = await fetchImpl(root, { method: 'GET', signal, keepalive: true }).catch(async () => {
352
+ const res = await retryWithBackoff(async () => {
335
353
  return await fetchImpl(root, { method: 'HEAD', signal, keepalive: true })
336
354
  })
337
355
  const latencyMs = Date.now() - start
@@ -365,7 +383,6 @@ export async function probeHealth(baseUrl, {
365
383
  if (cached.expiresAt > now) {
366
384
  return cached.data
367
385
  }
368
- // Stale-While-Revalidate: trigger async refresh and return stale snapshot immediately
369
386
  if (!cached.isRevalidating) {
370
387
  cached.isRevalidating = true
371
388
  doProbe().catch(() => {})
@@ -492,7 +509,7 @@ export function buildPiAiProvider({
492
509
  }
493
510
 
494
511
  /**
495
- * Safely resolve key value from DSH credentials or process.env.
512
+ * Safely resolve key value from DSH credentials service or process.env.
496
513
  */
497
514
  export async function resolveKeyValue(ctx, apiKeyEnv) {
498
515
  const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
@@ -551,7 +568,28 @@ export async function resolveAccountPool(ctx, cfg) {
551
568
  }
552
569
 
553
570
  /**
554
- * Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
571
+ * Check if a cached quota entry is currently exhausted.
572
+ * Checks 5-hour rolling limit and verifies if resetsAt timestamp has already elapsed.
573
+ */
574
+ export function isAccountQuotaExhausted(usage) {
575
+ if (!usage?.windows?.fiveHour) return false
576
+ const fiveHour = usage.windows.fiveHour
577
+ if (typeof fiveHour.percentUsed !== 'number' || fiveHour.percentUsed < 95) {
578
+ return false
579
+ }
580
+ // Auto-recovery: if resetsAt is present and in the past, the account is recovered
581
+ if (fiveHour.resetsAt) {
582
+ const resetTime = new Date(fiveHour.resetsAt).getTime()
583
+ if (!Number.isNaN(resetTime) && Date.now() >= resetTime) {
584
+ return false
585
+ }
586
+ }
587
+ return true
588
+ }
589
+
590
+ /**
591
+ * Smart Quota-Aware Failover: rotates active account upon 429 or quota exhaustion.
592
+ * Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
555
593
  */
556
594
  export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
557
595
  const pool = await resolveAccountPool(ctx, cfg)
@@ -562,8 +600,33 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
562
600
 
563
601
  const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
564
602
  const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
565
- const nextIndex = (currentIndex + 1) % configured.length
566
- const nextAcc = configured[nextIndex]
603
+
604
+ // Candidate pool excluding current account if possible
605
+ const candidates = configured.filter((acc) => acc.apiKeyEnv !== active)
606
+ if (!candidates.length) {
607
+ return { rotated: false, reason, message: 'No alternative accounts configured' }
608
+ }
609
+
610
+ // Assess quota for candidates if cached in memory
611
+ let bestCandidate = null
612
+ let lowestUsagePct = Infinity
613
+
614
+ for (const cand of candidates) {
615
+ const cacheKey = `cline:usage:${cand.value.slice(-8)}`
616
+ const cached = usageCache.get(cacheKey)?.data
617
+ const isExhausted = isAccountQuotaExhausted(cached)
618
+
619
+ if (!isExhausted) {
620
+ const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
621
+ if (pct < lowestUsagePct) {
622
+ lowestUsagePct = pct
623
+ bestCandidate = cand
624
+ }
625
+ }
626
+ }
627
+
628
+ // Fallback if all candidates are either exhausted or uncached: pick next in round-robin
629
+ const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
567
630
 
568
631
  let updated = false
569
632
  if (settingsApi?.replace) {