@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/CHANGELOG.md +13 -0
- package/README.md +15 -2
- package/README.ru.md +199 -0
- package/README.zh.md +201 -0
- package/docs/README.ru.md +14 -1
- package/docs/README.zh.md +14 -1
- package/docs/design/DESIGN.md +7 -1
- package/lib/client.js +91 -81
- package/lib/cline-client.js +177 -114
- package/lib/index.js +61 -148
- package/lib/updater.js +245 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
99
|
+
const zh = {
|
|
95
100
|
title: 'ClineBot',
|
|
96
|
-
subtitle: '
|
|
97
|
-
'settings.loading': '
|
|
98
|
-
'settings.retry': '
|
|
99
|
-
'settings.unavailable': '
|
|
100
|
-
'header.title': '
|
|
101
|
-
'header.sub': '
|
|
102
|
-
'badge.online': '
|
|
103
|
-
'badge.offline': '
|
|
104
|
-
'badge.key_ok': '
|
|
105
|
-
'badge.key_missing': '
|
|
106
|
-
'badge.registered': '
|
|
107
|
-
'badge.not_registered': '
|
|
108
|
-
'key.title': '🔑
|
|
109
|
-
'key.desc': '
|
|
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': '
|
|
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': '
|
|
120
|
-
'key.saved_msg': '
|
|
121
|
-
'key.empty_err': '
|
|
122
|
-
'accounts.title': '👥
|
|
123
|
-
'accounts.desc': '
|
|
124
|
-
'accounts.active_badge': '
|
|
125
|
-
'accounts.pinned_badge': '
|
|
126
|
-
'accounts.pin_btn': '
|
|
127
|
-
'accounts.add_btn': '+
|
|
128
|
-
'accounts.label_placeholder': '
|
|
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': '📊
|
|
131
|
-
'quota.desc': '
|
|
132
|
-
'quota.account': '
|
|
133
|
-
'quota.refresh': '
|
|
134
|
-
'quota.refreshing': '
|
|
135
|
-
'quota.refreshed_msg': '
|
|
136
|
-
'quota.window_5h': '⏱ 5
|
|
137
|
-
'quota.window_weekly': '📅
|
|
138
|
-
'quota.used': '{pct}%
|
|
139
|
-
'quota.remaining': '
|
|
140
|
-
'quota.reset_at': '
|
|
141
|
-
'models.title': '🎯
|
|
142
|
-
'models.desc': '
|
|
143
|
-
'models.sync': '🔄
|
|
144
|
-
'models.syncing': '
|
|
145
|
-
'models.synced_msg': '
|
|
146
|
-
'models.all': '
|
|
147
|
-
'models.vision': '
|
|
148
|
-
'models.coding': '
|
|
149
|
-
'models.recommended': '
|
|
150
|
-
'models.th_active': '
|
|
151
|
-
'models.th_name': '
|
|
152
|
-
'models.th_id': '
|
|
153
|
-
'models.th_ctx': '
|
|
154
|
-
'models.th_caps': '
|
|
155
|
-
'models.star': '
|
|
156
|
-
'models.auto_new': '
|
|
157
|
-
'stats.title': '📈
|
|
158
|
-
'stats.desc': '
|
|
159
|
-
'stats.requests': '
|
|
160
|
-
'stats.tokens': '
|
|
161
|
-
'stats.latency': '
|
|
162
|
-
'stats.last_req': '
|
|
163
|
-
'diag.title': '⚡
|
|
164
|
-
'diag.desc': '
|
|
165
|
-
'diag.smoke_btn': '
|
|
166
|
-
'diag.smoke_testing': '
|
|
167
|
-
'diag.smoke_ok': '
|
|
168
|
-
'diag.resync_btn': '
|
|
169
|
-
'diag.resyncing': '
|
|
170
|
-
'diag.resynced_msg': '
|
|
171
|
-
'diag.unregister_btn': '
|
|
172
|
-
'diag.unregistering': '
|
|
173
|
-
'diag.unregistered_msg': '
|
|
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(
|
|
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(
|
|
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('
|
|
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 {
|
package/lib/cline-client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
percentUsed
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
257
|
+
const fiveHour = parseWindow('5-hour')
|
|
258
|
+
const weekly = parseWindow('weekly')
|
|
259
|
+
const monthly = parseWindow('monthly')
|
|
250
260
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
let dynamicModels = []
|
|
261
|
+
let userEmail = null
|
|
262
|
+
let createdAt = null
|
|
263
|
+
let planDisplayName = 'ClinePass ($9.99/mo)'
|
|
264
|
+
let dynamicModels = []
|
|
256
265
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
if (
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
usageCache.
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
*
|
|
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
|
|
327
|
-
const
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
566
|
-
|
|
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) {
|