@goodandready/dsh-clinebot 0.6.2 → 0.6.4
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 +7 -1
- package/README.ru.md +7 -1
- package/README.zh.md +7 -1
- package/lib/adapter.js +6 -1
- package/lib/budget.js +37 -0
- package/lib/client.js +129 -11
- package/lib/config.js +11 -0
- package/lib/index.js +5 -2
- package/lib/routes/settings.js +12 -1
- package/lib/stats-export.js +11 -0
- package/lib/stats-metrics.js +68 -0
- package/lib/stats-storage.js +50 -1
- package/lib/transport/cline-transport.js +1 -1
- package/lib/tui-settings.js +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.4] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Analytics UX**: latency percentiles P50/P95 (compact histograms), a clickable weekday×hour heatmap with cell detail, JSON export at `GET /stats/export?format=json`, and configurable `statsRetentionDays` for day/hour/session telemetry (default 95, 0 keeps everything).
|
|
12
|
+
- Retention is exposed in Settings → ClineBot → Advanced and the TUI; JSON export is available beside CSV.
|
|
13
|
+
|
|
14
|
+
## [0.6.3] - 2026-10-21
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Quota forecast**: each 5-hour, weekly and monthly quota row now shows an estimated burn-down (`~2h 15m left`, `holds until reset`, `no usage yet`) based on the current pace and reset time. Forecasts appear in the sidebar and the center-column panel.
|
|
18
|
+
- **Budget guard**: optional `dailyBudgetUsd` and `monthlyBudgetUsd` limits (0 disables). The adapter rejects new ClineBot requests with `BUDGET_EXCEEDED` once the local estimated spend for the UTC day/month reaches the limit; both fields are available in Settings → ClineBot → Advanced and the TUI.
|
|
19
|
+
- Budget fields are wired through `publicConfig`, and the budget guard is covered by unit/integration tests.
|
|
20
|
+
|
|
8
21
|
## [0.6.2] - 2026-10-21
|
|
9
22
|
|
|
10
23
|
### Removed
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h3>Native ClineBot / ClinePass Provider Companion for DeepSeek Harness</h3>
|
|
6
6
|
|
|
7
|
-
<p><b>0.6.
|
|
7
|
+
<p><b>0.6.4</b> adds P50/P95 latency, clickable heatmap detail, JSON export and configurable retention on top of quota forecasts and spend guards. Chat runs on the native <code>ctx.llm</code> adapter and requires DSH <code>0.2.1-rc.2</code>; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
@@ -197,6 +197,9 @@ dsh-clinebot:
|
|
|
197
197
|
your-reasoning-model: high
|
|
198
198
|
modelContextOverrides: []
|
|
199
199
|
statsPath: ~/.dsh/clinebot-stats.json
|
|
200
|
+
statsRetentionDays: 95
|
|
201
|
+
dailyBudgetUsd: 0
|
|
202
|
+
monthlyBudgetUsd: 0
|
|
200
203
|
timeoutMs: 30000
|
|
201
204
|
smokeTimeoutMs: 60000
|
|
202
205
|
streamIdleTimeoutMs: 60000
|
|
@@ -218,6 +221,9 @@ dsh-clinebot:
|
|
|
218
221
|
| `modelReasoningDefaults` | `object` | `{}` | Configured default reasoning effort per model (`low`, `medium`, `high`, `max`) |
|
|
219
222
|
| `modelContextOverrides` | `array` | `[]` | User-defined model context length and max token overrides |
|
|
220
223
|
| `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Persistent on-disk path for token usage analytics |
|
|
224
|
+
| `statsRetentionDays` | `number` | `95` | Retention for day/hour/session telemetry; 0 keeps everything |
|
|
225
|
+
| `dailyBudgetUsd` | `number` | `0` | Optional estimated daily spend guard; 0 disables it |
|
|
226
|
+
| `monthlyBudgetUsd` | `number` | `0` | Optional estimated monthly spend guard; 0 disables it |
|
|
221
227
|
| `accounts` | `array` | `[]` | Additional accounts for multi-account failover and quota rotation (`[{ label, apiKeyEnv }]`) |
|
|
222
228
|
| `activeAccount` | `string` | `""` | Manually pinned active account env name (empty for least-used auto routing) |
|
|
223
229
|
| `timeoutMs` | `number` | `15000` | HTTP probe timeout in milliseconds |
|
package/README.ru.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h3>Нативное подключение провайдера ClineBot / ClinePass для DeepSeek Harness</h3>
|
|
6
6
|
|
|
7
|
-
<p><b>0.6.
|
|
7
|
+
<p><b>0.6.4</b> добавляет P50/P95 задержки, кликабельный heatmap, JSON-экспорт и настраиваемый retention поверх прогноза квоты и лимитов трат. Чат работает через нативный адаптер <code>ctx.llm</code>, требуется DSH <code>0.2.1-rc.2</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
@@ -166,6 +166,9 @@ dsh-clinebot:
|
|
|
166
166
|
your-reasoning-model: high
|
|
167
167
|
modelContextOverrides: []
|
|
168
168
|
statsPath: ~/.dsh/clinebot-stats.json
|
|
169
|
+
statsRetentionDays: 95
|
|
170
|
+
dailyBudgetUsd: 0
|
|
171
|
+
monthlyBudgetUsd: 0
|
|
169
172
|
timeoutMs: 30000
|
|
170
173
|
smokeTimeoutMs: 60000
|
|
171
174
|
streamIdleTimeoutMs: 60000
|
|
@@ -187,6 +190,9 @@ dsh-clinebot:
|
|
|
187
190
|
| `modelReasoningDefaults` | `object` | `{}` | Значения thinking effort по умолчанию для моделей (`low`, `medium`, `high`, `max`) |
|
|
188
191
|
| `modelContextOverrides` | `array` | `[]` | Пользовательские переопределения размера контекста и лимита токенов |
|
|
189
192
|
| `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Путь к файлу персистентной аналитики использования токенов |
|
|
193
|
+
| `statsRetentionDays` | `number` | `95` | Срок хранения дневной/часовой/сессионной телеметрии; 0 — хранить всё |
|
|
194
|
+
| `dailyBudgetUsd` | `number` | `0` | Опциональный дневной лимит оценочных трат; 0 отключает |
|
|
195
|
+
| `monthlyBudgetUsd` | `number` | `0` | Опциональный месячный лимит оценочных трат; 0 отключает |
|
|
190
196
|
| `accounts` | `array` | `[]` | Дополнительные аккаунты для ротации квоты и failover (`[{ label, apiKeyEnv }]`) |
|
|
191
197
|
| `activeAccount` | `string` | `""` | Принудительно закреплённый аккаунт (пусто для автоматического выбора least-used) |
|
|
192
198
|
| `timeoutMs` | `number` | `15000` | Таймаут HTTP-запросов (мс) |
|
package/README.zh.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h3>适用于 DeepSeek Harness 的 ClineBot / ClinePass 原生模型提供商伴侣插件</h3>
|
|
6
6
|
|
|
7
|
-
<p><b>0.6.
|
|
7
|
+
<p><b>0.6.4</b> 添加 P50/P95 延迟、可点击热力图、JSON 导出和可配置保留期,并保留配额预测与花费上限。聊天使用原生 <code>ctx.llm</code> 适配器,需要 DSH <code>0.2.1-rc.2</code>;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
@@ -168,6 +168,9 @@ dsh-clinebot:
|
|
|
168
168
|
your-reasoning-model: high
|
|
169
169
|
modelContextOverrides: []
|
|
170
170
|
statsPath: ~/.dsh/clinebot-stats.json
|
|
171
|
+
statsRetentionDays: 95
|
|
172
|
+
dailyBudgetUsd: 0
|
|
173
|
+
monthlyBudgetUsd: 0
|
|
171
174
|
timeoutMs: 30000
|
|
172
175
|
smokeTimeoutMs: 60000
|
|
173
176
|
streamIdleTimeoutMs: 60000
|
|
@@ -189,6 +192,9 @@ dsh-clinebot:
|
|
|
189
192
|
| `modelReasoningDefaults` | `object` | `{}` | 各模型默认思考强度配置 (`low`, `medium`, `high`, `max`) |
|
|
190
193
|
| `modelContextOverrides` | `array` | `[]` | 用户自定义模型上下文窗口与最大 Token 数量覆盖 |
|
|
191
194
|
| `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | 本地持久化 Token 用量统计文件路径 |
|
|
195
|
+
| `statsRetentionDays` | `number` | `95` | 日/小时/会话遥测保留天数;0 表示永久保留 |
|
|
196
|
+
| `dailyBudgetUsd` | `number` | `0` | 可选的每日估算花费上限;0 表示关闭 |
|
|
197
|
+
| `monthlyBudgetUsd` | `number` | `0` | 可选的每月估算花费上限;0 表示关闭 |
|
|
192
198
|
| `accounts` | `array` | `[]` | 额外轮转账号池 (`[{ label, apiKeyEnv }]`) |
|
|
193
199
|
| `activeAccount` | `string` | `""` | 手动指定的主账号(为空时自动按最低配额消耗优先路由) |
|
|
194
200
|
| `timeoutMs` | `number` | `15000` | HTTP 请求探测超时时间(毫秒) |
|
package/lib/adapter.js
CHANGED
|
@@ -12,7 +12,8 @@ import { getAllModels, findModel, formatModelDescription } from './models.js'
|
|
|
12
12
|
import { resolveAccountPool } from './account-pool.js'
|
|
13
13
|
import { resolveSessionAccount, markAccountCooldown } from './session-router.js'
|
|
14
14
|
import { recordSessionRequest } from './telemetry.js'
|
|
15
|
-
import { recordUsage } from './stats-storage.js'
|
|
15
|
+
import { recordUsage, getBudgetSpend } from './stats-storage.js'
|
|
16
|
+
import { budgetStatus, budgetErrorMessage } from './budget.js'
|
|
16
17
|
import { estimateCost } from './model-prices.js'
|
|
17
18
|
import { buildOpenAiPayload } from './adapter-messages.js'
|
|
18
19
|
import { streamJsonCompletion, streamOpenAiChunks } from './adapter-stream.js'
|
|
@@ -244,6 +245,10 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
244
245
|
if (!pub.enabled) {
|
|
245
246
|
throw new LlmError('ClineBot provider is disabled', 'PROVIDER_DISABLED')
|
|
246
247
|
}
|
|
248
|
+
const budget = budgetStatus(pub, getBudgetSpend())
|
|
249
|
+
if (budget.blocked) {
|
|
250
|
+
throw new LlmError(budgetErrorMessage(budget), 'BUDGET_EXCEEDED')
|
|
251
|
+
}
|
|
247
252
|
|
|
248
253
|
const signal = options.signal
|
|
249
254
|
const sessionId = options.sessionId ? String(options.sessionId) : ''
|
package/lib/budget.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimated-spend budget guard.
|
|
3
|
+
*
|
|
4
|
+
* ClinePass does not expose a per-token invoice; spend comes from the local
|
|
5
|
+
* price table and is therefore explicit about being an estimate. A guard of 0
|
|
6
|
+
* disables the corresponding limit.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ dailyBudgetUsd?: number, monthlyBudgetUsd?: number }} config
|
|
11
|
+
* @param {{ dayCost?: number, monthCost?: number }} spend
|
|
12
|
+
* @returns {{ blocked: boolean, scope: 'daily'|'monthly'|null, spent: number, limit: number }}
|
|
13
|
+
*/
|
|
14
|
+
export function budgetStatus(config, spend) {
|
|
15
|
+
const dailyLimit = Math.max(0, Number(config?.dailyBudgetUsd) || 0)
|
|
16
|
+
const monthlyLimit = Math.max(0, Number(config?.monthlyBudgetUsd) || 0)
|
|
17
|
+
const dayCost = Math.max(0, Number(spend?.dayCost) || 0)
|
|
18
|
+
const monthCost = Math.max(0, Number(spend?.monthCost) || 0)
|
|
19
|
+
|
|
20
|
+
if (dailyLimit > 0 && dayCost >= dailyLimit) {
|
|
21
|
+
return { blocked: true, scope: 'daily', spent: dayCost, limit: dailyLimit }
|
|
22
|
+
}
|
|
23
|
+
if (monthlyLimit > 0 && monthCost >= monthlyLimit) {
|
|
24
|
+
return { blocked: true, scope: 'monthly', spent: monthCost, limit: monthlyLimit }
|
|
25
|
+
}
|
|
26
|
+
return { blocked: false, scope: null, spent: 0, limit: 0 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Human-readable error for an exhausted budget guard.
|
|
31
|
+
* @param {ReturnType<typeof budgetStatus>} status
|
|
32
|
+
*/
|
|
33
|
+
export function budgetErrorMessage(status) {
|
|
34
|
+
if (!status?.blocked) return ''
|
|
35
|
+
const label = status.scope === 'monthly' ? 'Monthly' : 'Daily'
|
|
36
|
+
return `${label} ClineBot budget reached (estimated $${status.spent.toFixed(4)} of $${status.limit.toFixed(2)}). Raise the limit in Settings -> ClineBot -> Advanced.`
|
|
37
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -112,6 +112,10 @@ const en = {
|
|
|
112
112
|
'quota.countdown': 'Resets in {time}',
|
|
113
113
|
'quota.resetting': 'Resetting…',
|
|
114
114
|
'quota.warning_low': 'Quota is below 10%. Requests may be throttled soon.',
|
|
115
|
+
'quota.forecast_no_usage': 'No usage yet',
|
|
116
|
+
'quota.forecast_holds': 'Holds until reset at the current pace',
|
|
117
|
+
'quota.forecast_left': '~{time} left at the current pace',
|
|
118
|
+
'quota.forecast_exhausted': 'Exhausted',
|
|
115
119
|
'quota.exhausted_5h': '5-hour rolling limit is almost exhausted ({percentUsed}%). New requests may be rejected until quota reset.',
|
|
116
120
|
'quota.warning_5h': 'Low quota warning: {percentUsed}% of 5-hour limit consumed (<10% remaining).',
|
|
117
121
|
'quota.warning_weekly': 'Low weekly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
|
|
@@ -191,7 +195,7 @@ const en = {
|
|
|
191
195
|
'stats.requests': 'Successful / Total Requests',
|
|
192
196
|
'stats.tokens': 'Estimated Session Tokens',
|
|
193
197
|
'stats.reasoning_tokens': 'Reasoning Tokens (Thinking)',
|
|
194
|
-
'stats.export_csv': 'Export CSV',
|
|
198
|
+
'stats.export_csv': 'Export CSV', 'stats.export_json': 'Export JSON',
|
|
195
199
|
'stats.latency': 'Last Request Latency',
|
|
196
200
|
'stats.last_req': 'Last Request Time',
|
|
197
201
|
'models.custom_add_title': 'Custom Models',
|
|
@@ -256,6 +260,8 @@ const en = {
|
|
|
256
260
|
'settings.base_url': 'API base URL', 'settings.base_url_hint': 'ClinePass API root. Remote endpoints must use https://.',
|
|
257
261
|
'settings.timeout': 'Connect timeout (ms)', 'settings.timeout_hint': 'Maximum wait for the upstream response headers.', 'settings.smoke_timeout': 'Smoke-test timeout (ms)', 'settings.smoke_timeout_hint': 'Maximum wait for the diagnostic chat completion.',
|
|
258
262
|
'settings.stream_idle': 'Stream idle timeout (ms)', 'settings.stream_idle_hint': 'Maximum gap between SSE chunks before the stream is treated as dead.', 'settings.max_payload': 'Max multimodal payload (MB)', 'settings.max_payload_hint': 'Oldest images are evicted once the request exceeds this budget.',
|
|
263
|
+
'settings.stats_retention': 'Stats retention (days)', 'settings.stats_retention_hint': 'Trim day/hour/session telemetry older than this. 0 keeps everything.',
|
|
264
|
+
'settings.daily_budget': 'Daily budget (USD)', 'settings.daily_budget_hint': 'Estimated spend guard for the current UTC day. 0 disables it.', 'settings.monthly_budget': 'Monthly budget (USD)', 'settings.monthly_budget_hint': 'Estimated spend guard for the current UTC month. 0 disables it.',
|
|
259
265
|
'settings.stats_path': 'Stats file path', 'settings.stats_path_hint': 'Persistent JSON telemetry path (supports ~/).', 'settings.models_cache_path': 'Models cache path', 'settings.models_cache_path_hint': 'Offline model catalogue cache (supports ~/).',
|
|
260
266
|
'providerCard.title': 'ClineBot (ClinePass)',
|
|
261
267
|
'providerCard.hint': 'Paste a ClinePass API key or manage the full account pool in Settings → ClineBot.',
|
|
@@ -281,7 +287,9 @@ const en = {
|
|
|
281
287
|
'analytics.heatmap_empty': 'No hourly data yet. Send a message to start the heatmap.',
|
|
282
288
|
'analytics.input': 'Input', 'analytics.output': 'Output', 'analytics.reasoning': 'Reasoning',
|
|
283
289
|
'analytics.cache_read': 'Cache read', 'analytics.cache_write': 'Cache write',
|
|
284
|
-
'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency',
|
|
290
|
+
'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency',
|
|
291
|
+
'analytics.p50_latency': 'P50 latency', 'analytics.p95_latency': 'P95 latency',
|
|
292
|
+
'analytics.heatmap_selected': '{day} {hour}:00 — {count} requests. Click the cell again to clear.', 'analytics.errors': 'Errors',
|
|
285
293
|
'analytics.no_key': 'ClinePass key is not configured.',
|
|
286
294
|
'analytics.no_key_hint': 'Open Settings → ClineBot and paste a key, or import a discovered local key.',
|
|
287
295
|
'analytics.health_error': 'ClinePass endpoint is unreachable.',
|
|
@@ -389,6 +397,10 @@ const zh = {
|
|
|
389
397
|
'quota.countdown': '{time}后重置',
|
|
390
398
|
'quota.resetting': '正在重置',
|
|
391
399
|
'quota.warning_low': '额度低于 10%,请求可能很快会受到限制。',
|
|
400
|
+
'quota.forecast_no_usage': '尚未使用',
|
|
401
|
+
'quota.forecast_holds': '按当前速度可坚持到重置',
|
|
402
|
+
'quota.forecast_left': '按当前速度约剩 {time}',
|
|
403
|
+
'quota.forecast_exhausted': '已耗尽',
|
|
392
404
|
'quota.exhausted_5h': '5 小时滚动限额已接近耗尽(已用 {percentUsed}%)。在额度重置前新请求可能会被拒绝。',
|
|
393
405
|
'quota.warning_5h': '低额度警告:5 小时限额已消耗 {percentUsed}%(剩余不足 10%)。',
|
|
394
406
|
'quota.warning_weekly': '周限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
|
|
@@ -468,7 +480,7 @@ const zh = {
|
|
|
468
480
|
'stats.requests': '成功 / 总请求次数',
|
|
469
481
|
'stats.tokens': '会话累计 Token 估算',
|
|
470
482
|
'stats.reasoning_tokens': '深度思考 Token 消耗',
|
|
471
|
-
'stats.export_csv': '导出 CSV',
|
|
483
|
+
'stats.export_csv': '导出 CSV', 'stats.export_json': '导出 JSON',
|
|
472
484
|
'stats.latency': '最近一次请求延迟',
|
|
473
485
|
'stats.last_req': '最近请求时间',
|
|
474
486
|
'models.custom_add_title': '自定义模型',
|
|
@@ -533,6 +545,8 @@ const zh = {
|
|
|
533
545
|
'settings.base_url': 'API 基础地址', 'settings.base_url_hint': 'ClinePass API 根地址。远程端点必须使用 https://。',
|
|
534
546
|
'settings.timeout': '连接超时(毫秒)', 'settings.timeout_hint': '等待上游响应头的最大时间。', 'settings.smoke_timeout': '烟雾测试超时(毫秒)', 'settings.smoke_timeout_hint': '等待诊断聊天补全的最大时间。',
|
|
535
547
|
'settings.stream_idle': '流空闲超时(毫秒)', 'settings.stream_idle_hint': 'SSE 数据块之间的最大间隔,超过后视为断流。', 'settings.max_payload': '多模态负载上限(MB)', 'settings.max_payload_hint': '请求超过预算时会淘汰最旧的图片。',
|
|
548
|
+
'settings.stats_retention': '统计保留天数', 'settings.stats_retention_hint': '超过该天数的日/小时/会话遥测会被清理。0 表示永久保留。',
|
|
549
|
+
'settings.daily_budget': '每日预算(USD)', 'settings.daily_budget_hint': '按当前 UTC 日估算的花费上限。0 表示关闭。', 'settings.monthly_budget': '每月预算(USD)', 'settings.monthly_budget_hint': '按当前 UTC 月估算的花费上限。0 表示关闭。',
|
|
536
550
|
'settings.stats_path': '统计文件路径', 'settings.stats_path_hint': '持久化 JSON 遥测路径(支持 ~/)。', 'settings.models_cache_path': '模型缓存路径', 'settings.models_cache_path_hint': '离线模型目录缓存(支持 ~/)。',
|
|
537
551
|
'providerCard.title': 'ClineBot(ClinePass)',
|
|
538
552
|
'providerCard.hint': '粘贴 ClinePass API 密钥,或前往“设置 → ClineBot”管理完整账户池。',
|
|
@@ -559,6 +573,8 @@ const zh = {
|
|
|
559
573
|
'analytics.input': '输入', 'analytics.output': '输出', 'analytics.reasoning': '推理',
|
|
560
574
|
'analytics.cache_read': '缓存读取', 'analytics.cache_write': '缓存写入',
|
|
561
575
|
'analytics.avg_ttft': '平均首字延迟', 'analytics.avg_latency': '平均延迟', 'analytics.errors': '错误',
|
|
576
|
+
'analytics.p50_latency': 'P50 延迟', 'analytics.p95_latency': 'P95 延迟',
|
|
577
|
+
'analytics.heatmap_selected': '{day} {hour}:00 — {count} 次请求。再次点击可清除。',
|
|
562
578
|
'analytics.no_key': '尚未配置 ClinePass 密钥。',
|
|
563
579
|
'analytics.no_key_hint': '打开“设置 → ClineBot”粘贴密钥,或导入已发现的本地密钥。',
|
|
564
580
|
'analytics.health_error': 'ClinePass 端点不可达。',
|
|
@@ -785,6 +801,7 @@ function ensureCss() {
|
|
|
785
801
|
.cb-progress-meta, .cb-bar-meta{flex:1 1 140px;min-width:0;margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;text-align:right;white-space:normal;overflow-wrap:anywhere}
|
|
786
802
|
.cb-progress-meta-bad{color:var(--dsw-alias-state-error-primary)}
|
|
787
803
|
.cb-progress-low{display:inline-flex;align-items:center;gap:4px;margin-left:6px;font-size:11px;color:var(--dsw-alias-state-error-primary)}
|
|
804
|
+
.cb-progress-forecast{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
|
|
788
805
|
.cb-progress-track, .cb-bar-track{width:100%;height:6px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
|
|
789
806
|
.cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
|
|
790
807
|
.cb-progress-bar-warn{height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-state-warn-primary);transition:width .3s ease}
|
|
@@ -827,6 +844,7 @@ function ensureCss() {
|
|
|
827
844
|
.cb-footBar{display:block;height:4px;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-bg-layer-2);overflow:hidden}
|
|
828
845
|
.cb-footFill{display:block;height:100%;border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-brand-primary);transition:width .3s ease}
|
|
829
846
|
.cb-footFillWarn{background:var(--dsw-alias-state-error-primary)}
|
|
847
|
+
.cb-footForecast{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:14px}
|
|
830
848
|
.cb-footPct{margin-left:auto;flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:12px;font-weight:600;line-height:16px;font-variant-numeric:tabular-nums}
|
|
831
849
|
.cb-footPctWarn{color:var(--dsw-alias-state-error-primary)}
|
|
832
850
|
.cb-railButton{box-sizing:border-box;flex:none;display:inline-flex;justify-content:center;align-items:center;width:36px;height:36px;margin:0 0 4px;padding:0;border:.5px solid transparent;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
@@ -1021,7 +1039,9 @@ function ensureCss() {
|
|
|
1021
1039
|
.cb-heat-hour{color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center}
|
|
1022
1040
|
.cb-heat-day{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px;white-space:nowrap}
|
|
1023
1041
|
.cb-heat-cell,.cb-heat-swatch{display:block;border-radius:3px;background:var(--dsw-alias-bg-layer-2)}
|
|
1024
|
-
.cb-heat-cell{height:14px}
|
|
1042
|
+
.cb-heat-cell{height:14px;border:0;padding:0;cursor:pointer}
|
|
1043
|
+
.cb-heat-cell:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
1044
|
+
.cb-heat-cell.cb-heat-selected{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
1025
1045
|
.cb-heat-swatch{width:12px;height:12px;border-radius:2px}
|
|
1026
1046
|
.cb-heat-l0{background:var(--dsw-alias-bg-layer-2)}
|
|
1027
1047
|
.cb-heat-l1{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-bg-layer-2))}
|
|
@@ -1453,13 +1473,45 @@ function formatResetTime(isoString) {
|
|
|
1453
1473
|
}
|
|
1454
1474
|
}
|
|
1455
1475
|
|
|
1456
|
-
function
|
|
1476
|
+
function formatForecastDuration(ms) {
|
|
1477
|
+
const totalMinutes = Math.max(1, Math.round(Number(ms) / 60000))
|
|
1478
|
+
const days = Math.floor(totalMinutes / 1440)
|
|
1479
|
+
const hours = Math.floor((totalMinutes % 1440) / 60)
|
|
1480
|
+
const minutes = totalMinutes % 60
|
|
1481
|
+
if (days > 0) return `${days}d ${hours}h`
|
|
1482
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
1483
|
+
return `${minutes}m`
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function quotaForecast(window, windowMs, t) {
|
|
1487
|
+
const used = Number(window?.percentUsed)
|
|
1488
|
+
const remaining = Number.isFinite(Number(window?.remainingPercent))
|
|
1489
|
+
? Number(window.remainingPercent)
|
|
1490
|
+
: 100 - used
|
|
1491
|
+
const resetAt = new Date(window?.resetsAt).getTime()
|
|
1492
|
+
const span = Number(windowMs)
|
|
1493
|
+
if (!Number.isFinite(used) || !Number.isFinite(resetAt) || !Number.isFinite(span) || span <= 0) return ''
|
|
1494
|
+
const untilReset = resetAt - Date.now()
|
|
1495
|
+
if (untilReset <= 0) return ''
|
|
1496
|
+
if (remaining <= 0) return t('quota.forecast_exhausted')
|
|
1497
|
+
if (used <= 0) return t('quota.forecast_no_usage')
|
|
1498
|
+
const elapsed = Math.max(1, span - untilReset)
|
|
1499
|
+
const burnPerMs = used / elapsed
|
|
1500
|
+
if (!Number.isFinite(burnPerMs) || burnPerMs <= 0) return ''
|
|
1501
|
+
const etaMs = remaining / burnPerMs
|
|
1502
|
+
if (!Number.isFinite(etaMs) || etaMs <= 0) return ''
|
|
1503
|
+
if (etaMs >= untilReset) return t('quota.forecast_holds')
|
|
1504
|
+
return t('quota.forecast_left', { time: formatForecastDuration(etaMs) })
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, windowMs, t }) {
|
|
1457
1508
|
const pct = Math.max(0, Math.min(100, Math.round(percentUsed || 0)))
|
|
1458
1509
|
const isWarn = pct >= 80 && pct < 90
|
|
1459
1510
|
const isBad = pct >= 90
|
|
1460
1511
|
const barClass = isBad ? 'cb-progress-bar-bad' : isWarn ? 'cb-progress-bar-warn' : 'cb-progress-bar-fill'
|
|
1461
1512
|
const resetCountdown = formatResetCountdown(resetsAt)
|
|
1462
1513
|
const resetClock = formatResetTime(resetsAt)
|
|
1514
|
+
const forecast = windowMs ? quotaForecast({ percentUsed, remainingPercent, resetsAt }, windowMs, t) : ''
|
|
1463
1515
|
|
|
1464
1516
|
return React.createElement(
|
|
1465
1517
|
'div',
|
|
@@ -1493,7 +1545,8 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
1493
1545
|
'div',
|
|
1494
1546
|
{ className: 'cb-progress-track' },
|
|
1495
1547
|
React.createElement('div', { className: barClass, style: { width: `${pct}%` } })
|
|
1496
|
-
)
|
|
1548
|
+
),
|
|
1549
|
+
forecast ? React.createElement('div', { className: 'cb-progress-forecast' }, forecast) : null
|
|
1497
1550
|
)
|
|
1498
1551
|
}
|
|
1499
1552
|
|
|
@@ -1717,6 +1770,30 @@ function AdvancedSection({ draft, onStage, t }) {
|
|
|
1717
1770
|
type: 'number',
|
|
1718
1771
|
onChange: numeric('maxPayloadMb', 0),
|
|
1719
1772
|
}),
|
|
1773
|
+
React.createElement(AdvancedField, {
|
|
1774
|
+
id: 'cb-retention',
|
|
1775
|
+
label: t('settings.stats_retention'),
|
|
1776
|
+
hint: t('settings.stats_retention_hint'),
|
|
1777
|
+
value: current.statsRetentionDays,
|
|
1778
|
+
type: 'number',
|
|
1779
|
+
onChange: numeric('statsRetentionDays', -1),
|
|
1780
|
+
}),
|
|
1781
|
+
React.createElement(AdvancedField, {
|
|
1782
|
+
id: 'cb-daily-budget',
|
|
1783
|
+
label: t('settings.daily_budget'),
|
|
1784
|
+
hint: t('settings.daily_budget_hint'),
|
|
1785
|
+
value: current.dailyBudgetUsd,
|
|
1786
|
+
type: 'number',
|
|
1787
|
+
onChange: numeric('dailyBudgetUsd', -1),
|
|
1788
|
+
}),
|
|
1789
|
+
React.createElement(AdvancedField, {
|
|
1790
|
+
id: 'cb-monthly-budget',
|
|
1791
|
+
label: t('settings.monthly_budget'),
|
|
1792
|
+
hint: t('settings.monthly_budget_hint'),
|
|
1793
|
+
value: current.monthlyBudgetUsd,
|
|
1794
|
+
type: 'number',
|
|
1795
|
+
onChange: numeric('monthlyBudgetUsd', -1),
|
|
1796
|
+
}),
|
|
1720
1797
|
React.createElement(AdvancedField, {
|
|
1721
1798
|
id: 'cb-stats-path',
|
|
1722
1799
|
label: t('settings.stats_path'),
|
|
@@ -2444,6 +2521,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
2444
2521
|
percentUsed: usage.windows.fiveHour.percentUsed || 0,
|
|
2445
2522
|
remainingPercent: usage.windows.fiveHour.remainingPercent || 100,
|
|
2446
2523
|
resetsAt: usage.windows.fiveHour.resetsAt,
|
|
2524
|
+
windowMs: 5 * 60 * 60 * 1000,
|
|
2447
2525
|
t,
|
|
2448
2526
|
})
|
|
2449
2527
|
: null,
|
|
@@ -2452,6 +2530,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
2452
2530
|
percentUsed: usage?.windows?.weekly?.percentUsed || 0,
|
|
2453
2531
|
remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
|
|
2454
2532
|
resetsAt: usage?.windows?.weekly?.resetsAt,
|
|
2533
|
+
windowMs: 7 * 24 * 60 * 60 * 1000,
|
|
2455
2534
|
t,
|
|
2456
2535
|
}),
|
|
2457
2536
|
usage?.windows?.monthly
|
|
@@ -2460,6 +2539,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, sho
|
|
|
2460
2539
|
percentUsed: usage.windows.monthly.percentUsed || 0,
|
|
2461
2540
|
remainingPercent: usage.windows.monthly.remainingPercent || 100,
|
|
2462
2541
|
resetsAt: usage.windows.monthly.resetsAt,
|
|
2542
|
+
windowMs: 30 * 24 * 60 * 60 * 1000,
|
|
2463
2543
|
t,
|
|
2464
2544
|
})
|
|
2465
2545
|
: null
|
|
@@ -3455,6 +3535,16 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
3455
3535
|
React.createElement(
|
|
3456
3536
|
'div',
|
|
3457
3537
|
{ className: 'cb-section-actions' },
|
|
3538
|
+
React.createElement(
|
|
3539
|
+
'a',
|
|
3540
|
+
{
|
|
3541
|
+
href: '/dsh-clinebot/stats/export?format=json',
|
|
3542
|
+
download: 'clinebot-stats.json',
|
|
3543
|
+
className: 'cb-btn cb-btn-link',
|
|
3544
|
+
},
|
|
3545
|
+
React.createElement(CbIcon, { name: 'download', size: 13 }),
|
|
3546
|
+
` ${t('stats.export_json')}`
|
|
3547
|
+
),
|
|
3458
3548
|
React.createElement(
|
|
3459
3549
|
'a',
|
|
3460
3550
|
{
|
|
@@ -3780,7 +3870,14 @@ function SidebarQuota({ ctx, wide, t: propT, open: openPanel }) {
|
|
|
3780
3870
|
className: warn ? 'cb-footFill cb-footFillWarn' : 'cb-footFill',
|
|
3781
3871
|
style: { width: hasData ? `${Math.min(100, Math.max(2, value))}%` : '0%' },
|
|
3782
3872
|
})
|
|
3783
|
-
)
|
|
3873
|
+
),
|
|
3874
|
+
hasData
|
|
3875
|
+
? React.createElement(
|
|
3876
|
+
'span',
|
|
3877
|
+
{ className: 'cb-footForecast' },
|
|
3878
|
+
quotaForecast(window.value, window.key === 'fiveHour' ? 5 * 60 * 60 * 1000 : window.key === 'weekly' ? 7 * 24 * 60 * 60 * 1000 : 30 * 24 * 60 * 60 * 1000, t)
|
|
3879
|
+
)
|
|
3880
|
+
: null
|
|
3784
3881
|
)
|
|
3785
3882
|
}),
|
|
3786
3883
|
rows.some((window) => windowPct(window) !== null)
|
|
@@ -3886,7 +3983,7 @@ function heatHourLabel(hour) {
|
|
|
3886
3983
|
return hour % 3 === 0 ? String(hour).padStart(2, '0') : ''
|
|
3887
3984
|
}
|
|
3888
3985
|
|
|
3889
|
-
function HeatmapView({ heatmap, model, onModelChange, t }) {
|
|
3986
|
+
function HeatmapView({ heatmap, model, onModelChange, onSelect, selected, t }) {
|
|
3890
3987
|
if (!heatmap || !Array.isArray(heatmap.grid)) {
|
|
3891
3988
|
return React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
|
|
3892
3989
|
}
|
|
@@ -3950,10 +4047,14 @@ function HeatmapView({ heatmap, model, onModelChange, t }) {
|
|
|
3950
4047
|
React.Fragment,
|
|
3951
4048
|
{ key: `day-${day}` },
|
|
3952
4049
|
React.createElement('span', { className: 'cb-heat-day' }, label),
|
|
3953
|
-
(grid[day] || []).map((value, hour) => React.createElement('
|
|
4050
|
+
(grid[day] || []).map((value, hour) => React.createElement('button', {
|
|
3954
4051
|
key: `cell-${day}-${hour}`,
|
|
3955
|
-
|
|
4052
|
+
type: 'button',
|
|
4053
|
+
className: `cb-heat-cell cb-heat-l${level(value)}${selected?.day === day && selected?.hour === hour ? ' cb-heat-selected' : ''}`,
|
|
3956
4054
|
title: `${label} ${String(hour).padStart(2, '0')}:00 — ${value} ${t('analytics.requests')}`,
|
|
4055
|
+
'aria-label': `${label} ${String(hour).padStart(2, '0')}:00 — ${value}`,
|
|
4056
|
+
'aria-pressed': selected?.day === day && selected?.hour === hour,
|
|
4057
|
+
onClick: () => onSelect && onSelect({ day, hour, value, label }),
|
|
3957
4058
|
}))
|
|
3958
4059
|
))
|
|
3959
4060
|
),
|
|
@@ -4068,6 +4169,7 @@ function QuotaPanelView({
|
|
|
4068
4169
|
percentUsed: selectedUsage.windows.fiveHour.percentUsed || 0,
|
|
4069
4170
|
remainingPercent: selectedUsage.windows.fiveHour.remainingPercent || 100,
|
|
4070
4171
|
resetsAt: selectedUsage.windows.fiveHour.resetsAt,
|
|
4172
|
+
windowMs: 5 * 60 * 60 * 1000,
|
|
4071
4173
|
t,
|
|
4072
4174
|
})
|
|
4073
4175
|
: null,
|
|
@@ -4077,6 +4179,7 @@ function QuotaPanelView({
|
|
|
4077
4179
|
percentUsed: selectedUsage.windows.weekly.percentUsed || 0,
|
|
4078
4180
|
remainingPercent: selectedUsage.windows.weekly.remainingPercent || 100,
|
|
4079
4181
|
resetsAt: selectedUsage.windows.weekly.resetsAt,
|
|
4182
|
+
windowMs: 7 * 24 * 60 * 60 * 1000,
|
|
4080
4183
|
t,
|
|
4081
4184
|
})
|
|
4082
4185
|
: null,
|
|
@@ -4086,6 +4189,7 @@ function QuotaPanelView({
|
|
|
4086
4189
|
percentUsed: selectedUsage.windows.monthly.percentUsed || 0,
|
|
4087
4190
|
remainingPercent: selectedUsage.windows.monthly.remainingPercent || 100,
|
|
4088
4191
|
resetsAt: selectedUsage.windows.monthly.resetsAt,
|
|
4192
|
+
windowMs: 30 * 24 * 60 * 60 * 1000,
|
|
4089
4193
|
t,
|
|
4090
4194
|
})
|
|
4091
4195
|
: null,
|
|
@@ -4122,6 +4226,7 @@ function DashboardDialog(props) {
|
|
|
4122
4226
|
const [selectedAccount, setSelectedAccount] = React.useState('')
|
|
4123
4227
|
const [analyticsDays, setAnalyticsDays] = React.useState(7)
|
|
4124
4228
|
const [heatmapModel, setHeatmapModel] = React.useState('')
|
|
4229
|
+
const [selectedHeat, setSelectedHeat] = React.useState(null)
|
|
4125
4230
|
|
|
4126
4231
|
const loadData = React.useCallback(async () => {
|
|
4127
4232
|
setLoading(true)
|
|
@@ -4296,6 +4401,8 @@ function DashboardDialog(props) {
|
|
|
4296
4401
|
[t('analytics.cache_write'), fmtTokens(totals.cacheWriteTokens)],
|
|
4297
4402
|
[t('analytics.avg_ttft'), fmtMs(totals.avgTtftMs)],
|
|
4298
4403
|
[t('analytics.avg_latency'), fmtMs(totals.avgLatencyMs)],
|
|
4404
|
+
[t('analytics.p50_latency'), fmtMs(totals.p50LatencyMs)],
|
|
4405
|
+
[t('analytics.p95_latency'), fmtMs(totals.p95LatencyMs)],
|
|
4299
4406
|
[t('analytics.errors'), String(Math.round(Number(totals.failed) || 0))],
|
|
4300
4407
|
]
|
|
4301
4408
|
: []
|
|
@@ -4448,8 +4555,19 @@ function DashboardDialog(props) {
|
|
|
4448
4555
|
heatmap: analytics?.heatmap,
|
|
4449
4556
|
model: heatmapModel,
|
|
4450
4557
|
onModelChange: setHeatmapModel,
|
|
4558
|
+
selected: selectedHeat,
|
|
4559
|
+
onSelect: (cell) => setSelectedHeat((current) => (
|
|
4560
|
+
current && current.day === cell.day && current.hour === cell.hour ? null : cell
|
|
4561
|
+
)),
|
|
4451
4562
|
t,
|
|
4452
|
-
})
|
|
4563
|
+
}),
|
|
4564
|
+
selectedHeat
|
|
4565
|
+
? React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_selected', {
|
|
4566
|
+
day: selectedHeat.label,
|
|
4567
|
+
hour: String(selectedHeat.hour).padStart(2, '0'),
|
|
4568
|
+
count: selectedHeat.value,
|
|
4569
|
+
}))
|
|
4570
|
+
: null
|
|
4453
4571
|
)
|
|
4454
4572
|
|
|
4455
4573
|
const panelBody = React.createElement(
|
package/lib/config.js
CHANGED
|
@@ -99,6 +99,12 @@ export const Config = z.object({
|
|
|
99
99
|
.description('User-defined custom gateway models.').volatile(),
|
|
100
100
|
statsPath: z.string().default('~/.dsh/clinebot-stats.json')
|
|
101
101
|
.description('Local on-disk persistent path for token usage analytics.').volatile(),
|
|
102
|
+
statsRetentionDays: z.number().min(0).max(3650).default(95)
|
|
103
|
+
.description('Retention for day/hour/session telemetry in days. 0 keeps everything.').volatile(),
|
|
104
|
+
dailyBudgetUsd: z.number().min(0).default(0)
|
|
105
|
+
.description('Optional daily estimated-spend guard in USD. 0 disables the guard.').volatile(),
|
|
106
|
+
monthlyBudgetUsd: z.number().min(0).default(0)
|
|
107
|
+
.description('Optional monthly estimated-spend guard in USD. 0 disables the guard.').volatile(),
|
|
102
108
|
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
|
|
103
109
|
.description('HTTP probe timeout in milliseconds.').volatile(),
|
|
104
110
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
@@ -238,6 +244,11 @@ export function publicConfig(cfg) {
|
|
|
238
244
|
modelRoutes: cfg?.modelRoutes && typeof cfg.modelRoutes === 'object' ? cfg.modelRoutes : {},
|
|
239
245
|
customModels: custom,
|
|
240
246
|
statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
|
|
247
|
+
statsRetentionDays: Number.isFinite(Number(cfg?.statsRetentionDays))
|
|
248
|
+
? Math.max(0, Math.min(3650, Number(cfg.statsRetentionDays)))
|
|
249
|
+
: 95,
|
|
250
|
+
dailyBudgetUsd: Math.max(0, Number(cfg?.dailyBudgetUsd) || 0),
|
|
251
|
+
monthlyBudgetUsd: Math.max(0, Number(cfg?.monthlyBudgetUsd) || 0),
|
|
241
252
|
disabledModels: disabledList,
|
|
242
253
|
filterModelsByPlan,
|
|
243
254
|
visibleModels,
|
package/lib/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { ClineBotAdapter } from './adapter.js'
|
|
|
20
20
|
import { registerSlashCommand } from './slash-command.js'
|
|
21
21
|
import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
|
|
22
22
|
import { resolveAccountPool } from './account-pool.js'
|
|
23
|
-
import { loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
|
|
23
|
+
import { loadStats, flushStats, resolvePath, switchStatsStorage, setStatsRetentionDays } from './stats-storage.js'
|
|
24
24
|
import { service, migrateLegacyPiAiProvider } from './services.js'
|
|
25
25
|
import { applyTuiSettings } from './tui-settings.js'
|
|
26
26
|
|
|
@@ -91,10 +91,13 @@ export function apply(ctx, config) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
let activeStatsPath = resolvePath(publicConfig(live()).statsPath)
|
|
94
|
+
setStatsRetentionDays(publicConfig(live()).statsRetentionDays)
|
|
94
95
|
loadStats(activeStatsPath)
|
|
95
96
|
|
|
96
97
|
const syncStatsStorage = (cfg) => {
|
|
97
|
-
const
|
|
98
|
+
const pub = publicConfig(cfg)
|
|
99
|
+
setStatsRetentionDays(pub.statsRetentionDays)
|
|
100
|
+
const nextPath = resolvePath(pub.statsPath)
|
|
98
101
|
if (nextPath !== activeStatsPath) {
|
|
99
102
|
switchStatsStorage(nextPath)
|
|
100
103
|
activeStatsPath = nextPath
|
package/lib/routes/settings.js
CHANGED
|
@@ -4,6 +4,7 @@ import { publicConfig, plainConfig, Config } from '../config.js'
|
|
|
4
4
|
import { buildStatus } from '../provider-sync.js'
|
|
5
5
|
import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
|
|
6
6
|
import { getStatsSummary, getSessionStats, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
|
|
7
|
+
import { exportStatsAsJson } from '../stats-export.js'
|
|
7
8
|
import { priceTable } from '../model-prices.js'
|
|
8
9
|
|
|
9
10
|
export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
|
|
@@ -241,11 +242,21 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
241
242
|
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
242
243
|
try {
|
|
243
244
|
let detailed = false
|
|
245
|
+
let format = 'csv'
|
|
244
246
|
try {
|
|
245
|
-
|
|
247
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
248
|
+
detailed = url.searchParams.get('detailed') === '1'
|
|
249
|
+
format = url.searchParams.get('format') === 'json' ? 'json' : 'csv'
|
|
246
250
|
} catch {
|
|
247
251
|
detailed = false
|
|
248
252
|
}
|
|
253
|
+
if (format === 'json') {
|
|
254
|
+
res.statusCode = 200
|
|
255
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
256
|
+
res.setHeader('Content-Disposition', 'attachment; filename="clinebot-stats.json"')
|
|
257
|
+
res.end(exportStatsAsJson())
|
|
258
|
+
return
|
|
259
|
+
}
|
|
249
260
|
const csv = exportStatsAsCsv(detailed)
|
|
250
261
|
res.statusCode = 200
|
|
251
262
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getStatsSummary } from './stats-storage.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export the complete persisted telemetry document as JSON.
|
|
5
|
+
*/
|
|
6
|
+
export function exportStatsAsJson() {
|
|
7
|
+
return JSON.stringify({
|
|
8
|
+
exportedAt: new Date().toISOString(),
|
|
9
|
+
stats: getStatsSummary(),
|
|
10
|
+
}, null, 2)
|
|
11
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact latency histograms and retention pruning for the stats store.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const LATENCY_BOUNDS = [50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 51200, 102400, Infinity]
|
|
6
|
+
|
|
7
|
+
export function latencyBucketIndex(ms) {
|
|
8
|
+
const value = Math.max(0, Number(ms) || 0)
|
|
9
|
+
const index = LATENCY_BOUNDS.findIndex((bound) => value <= bound)
|
|
10
|
+
return index < 0 ? LATENCY_BOUNDS.length - 1 : index
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mergeHistogram(target, source) {
|
|
14
|
+
if (!Array.isArray(source)) return target
|
|
15
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
16
|
+
target[index] = Number(target[index] || 0) + Number(source[index] || 0)
|
|
17
|
+
}
|
|
18
|
+
return target
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function percentileFromHistogram(histogram, percentile) {
|
|
22
|
+
if (!Array.isArray(histogram)) return 0
|
|
23
|
+
const total = histogram.reduce((sum, value) => sum + Number(value || 0), 0)
|
|
24
|
+
if (total <= 0) return 0
|
|
25
|
+
const target = Math.max(1, Math.ceil((Math.max(1, Math.min(99, percentile)) / 100) * total))
|
|
26
|
+
let seen = 0
|
|
27
|
+
for (let index = 0; index < histogram.length; index += 1) {
|
|
28
|
+
seen += Number(histogram[index] || 0)
|
|
29
|
+
if (seen >= target) {
|
|
30
|
+
const bound = LATENCY_BOUNDS[index]
|
|
31
|
+
return bound === Infinity ? LATENCY_BOUNDS[LATENCY_BOUNDS.length - 2] : bound
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function localDateKey(date) {
|
|
38
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Prune time buckets and sessions older than the configured retention window.
|
|
43
|
+
* @param {Record<string, any>} stats
|
|
44
|
+
* @param {number} days 0 keeps everything
|
|
45
|
+
* @param {number} [now]
|
|
46
|
+
*/
|
|
47
|
+
export function pruneRetentionOnStats(stats, days, now = Date.now()) {
|
|
48
|
+
const retentionDays = Math.max(0, Number(days) || 0)
|
|
49
|
+
if (!stats || retentionDays <= 0) return
|
|
50
|
+
const cutoffMs = now - retentionDays * 24 * 60 * 60 * 1000
|
|
51
|
+
const cutoffDay = new Date(cutoffMs).toISOString().slice(0, 10)
|
|
52
|
+
const cutoffMonth = cutoffDay.slice(0, 7)
|
|
53
|
+
const cutoffHourDay = localDateKey(new Date(cutoffMs))
|
|
54
|
+
for (const key of Object.keys(stats.byDay || {})) {
|
|
55
|
+
if (key < cutoffDay) delete stats.byDay[key]
|
|
56
|
+
}
|
|
57
|
+
for (const key of Object.keys(stats.byMonth || {})) {
|
|
58
|
+
if (key < cutoffMonth) delete stats.byMonth[key]
|
|
59
|
+
}
|
|
60
|
+
for (const container of [stats.byHour, stats.byHourModel]) {
|
|
61
|
+
for (const key of Object.keys(container || {})) {
|
|
62
|
+
if (key.slice(0, 10) < cutoffHourDay) delete container[key]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [key, bucket] of Object.entries(stats.bySession || {})) {
|
|
66
|
+
if (Number(bucket?.lastAt || 0) < cutoffMs) delete stats.bySession[key]
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/stats-storage.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
latencyBucketIndex,
|
|
13
|
+
mergeHistogram,
|
|
14
|
+
percentileFromHistogram,
|
|
15
|
+
pruneRetentionOnStats,
|
|
16
|
+
} from './stats-metrics.js'
|
|
11
17
|
|
|
12
18
|
const MAX_SESSIONS = 500
|
|
13
19
|
const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
|
|
@@ -16,6 +22,17 @@ const HEATMAP_RETENTION_MS = 95 * 24 * 60 * 60 * 1000
|
|
|
16
22
|
let inMemoryStats = createEmptyStats()
|
|
17
23
|
let saveTimer = null
|
|
18
24
|
let activeStatsPath = ''
|
|
25
|
+
let statsRetentionDays = 95
|
|
26
|
+
|
|
27
|
+
export function setStatsRetentionDays(days) {
|
|
28
|
+
statsRetentionDays = Math.max(0, Math.min(3650, Number(days) || 0))
|
|
29
|
+
pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
|
|
30
|
+
return statsRetentionDays
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getStatsRetentionDays() {
|
|
34
|
+
return statsRetentionDays
|
|
35
|
+
}
|
|
19
36
|
|
|
20
37
|
function emptyBucket() {
|
|
21
38
|
return {
|
|
@@ -122,6 +139,7 @@ export function loadStats(customPath) {
|
|
|
122
139
|
byHourModel: parsed.byHourModel || {},
|
|
123
140
|
}
|
|
124
141
|
backfillHourlyFromSessions()
|
|
142
|
+
pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
|
|
125
143
|
return inMemoryStats
|
|
126
144
|
}
|
|
127
145
|
}
|
|
@@ -142,6 +160,7 @@ export function loadStats(customPath) {
|
|
|
142
160
|
} catch {}
|
|
143
161
|
}
|
|
144
162
|
inMemoryStats = createEmptyStats()
|
|
163
|
+
pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
|
|
145
164
|
return inMemoryStats
|
|
146
165
|
}
|
|
147
166
|
|
|
@@ -203,6 +222,11 @@ function addToBucket(bucket, entry) {
|
|
|
203
222
|
else bucket.unpricedRequests += 1
|
|
204
223
|
bucket.ttftMsSum += entry.ttftMs
|
|
205
224
|
bucket.latencyMsSum += entry.latencyMs
|
|
225
|
+
if (entry.latencyMs > 0) {
|
|
226
|
+
if (!Array.isArray(bucket.latencyHistogram)) bucket.latencyHistogram = []
|
|
227
|
+
const index = latencyBucketIndex(entry.latencyMs)
|
|
228
|
+
bucket.latencyHistogram[index] = Number(bucket.latencyHistogram[index] || 0) + 1
|
|
229
|
+
}
|
|
206
230
|
bucket.firstAt = bucket.firstAt || entry.now
|
|
207
231
|
bucket.lastAt = entry.now
|
|
208
232
|
if (entry.model) bucket.model = entry.model
|
|
@@ -335,6 +359,7 @@ export function recordUsage({
|
|
|
335
359
|
if (!inMemoryStats.byHourModel[hourKey]) inMemoryStats.byHourModel[hourKey] = {}
|
|
336
360
|
inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
|
|
337
361
|
pruneHours(now)
|
|
362
|
+
pruneRetentionOnStats(inMemoryStats, statsRetentionDays, now)
|
|
338
363
|
if (sessionId) {
|
|
339
364
|
const key = String(sessionId)
|
|
340
365
|
incrementBucket(inMemoryStats.bySession, key, entry)
|
|
@@ -361,6 +386,21 @@ export function getSessionStats(sessionId) {
|
|
|
361
386
|
return bucket ? JSON.parse(JSON.stringify(bucket)) : null
|
|
362
387
|
}
|
|
363
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Current UTC day/month estimated spend for budget guards.
|
|
391
|
+
* @param {number} [now]
|
|
392
|
+
*/
|
|
393
|
+
export function getBudgetSpend(now = Date.now()) {
|
|
394
|
+
const dayKey = new Date(now).toISOString().slice(0, 10)
|
|
395
|
+
const monthKey = dayKey.slice(0, 7)
|
|
396
|
+
return {
|
|
397
|
+
dayKey,
|
|
398
|
+
monthKey,
|
|
399
|
+
dayCost: Number(inMemoryStats.byDay?.[dayKey]?.costUsd || 0),
|
|
400
|
+
monthCost: Number(inMemoryStats.byMonth?.[monthKey]?.costUsd || 0),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
364
404
|
export function resetStats(customPath) {
|
|
365
405
|
inMemoryStats = createEmptyStats()
|
|
366
406
|
saveStatsSync(customPath)
|
|
@@ -435,6 +475,7 @@ export function getAnalytics(query = {}) {
|
|
|
435
475
|
: inMemoryStats.byModel
|
|
436
476
|
|
|
437
477
|
const groups = []
|
|
478
|
+
const totalsHistogram = []
|
|
438
479
|
for (const [key, bucket] of Object.entries(source || {})) {
|
|
439
480
|
const at = groupBy === 'day' ? Date.parse(`${key}T00:00:00Z`) : Number(bucket.lastAt || 0)
|
|
440
481
|
if (Number.isFinite(at) && (at < from || at > to)) continue
|
|
@@ -442,6 +483,9 @@ export function getAnalytics(query = {}) {
|
|
|
442
483
|
if (query.account && bucket.accountId && bucket.accountId !== query.account && key !== query.account) continue
|
|
443
484
|
const requests = Number(bucket.requests || 0)
|
|
444
485
|
const successful = Number(bucket.successful || 0)
|
|
486
|
+
const histogram = Array.isArray(bucket.latencyHistogram) ? bucket.latencyHistogram : []
|
|
487
|
+
mergeHistogram(totalsHistogram, histogram)
|
|
488
|
+
const avgLatencyMs = requests > 0 ? Number(bucket.latencyMsSum || 0) / requests : 0
|
|
445
489
|
groups.push({
|
|
446
490
|
key,
|
|
447
491
|
label: key,
|
|
@@ -460,7 +504,9 @@ export function getAnalytics(query = {}) {
|
|
|
460
504
|
unpricedRequests: Number(bucket.unpricedRequests || 0),
|
|
461
505
|
successRate: requests > 0 ? (successful / requests) * 100 : 0,
|
|
462
506
|
avgTtftMs: successful > 0 ? Number(bucket.ttftMsSum || 0) / successful : 0,
|
|
463
|
-
avgLatencyMs
|
|
507
|
+
avgLatencyMs,
|
|
508
|
+
p50LatencyMs: percentileFromHistogram(histogram, 50) || avgLatencyMs,
|
|
509
|
+
p95LatencyMs: percentileFromHistogram(histogram, 95) || avgLatencyMs,
|
|
464
510
|
ttftMsSum: Number(bucket.ttftMsSum || 0),
|
|
465
511
|
latencyMsSum: Number(bucket.latencyMsSum || 0),
|
|
466
512
|
model: bucket.model || '',
|
|
@@ -499,6 +545,8 @@ export function getAnalytics(query = {}) {
|
|
|
499
545
|
totals.successRate = totals.requests > 0 ? (totals.successful / totals.requests) * 100 : 0
|
|
500
546
|
totals.avgTtftMs = totals.successful > 0 ? totals.ttftMsSum / totals.successful : 0
|
|
501
547
|
totals.avgLatencyMs = totals.requests > 0 ? totals.latencyMsSum / totals.requests : 0
|
|
548
|
+
totals.p50LatencyMs = percentileFromHistogram(totalsHistogram, 50) || totals.avgLatencyMs
|
|
549
|
+
totals.p95LatencyMs = percentileFromHistogram(totalsHistogram, 95) || totals.avgLatencyMs
|
|
502
550
|
|
|
503
551
|
return {
|
|
504
552
|
range: { from, to },
|
|
@@ -544,3 +592,4 @@ export function exportStatsAsCsv(detailed = false) {
|
|
|
544
592
|
}
|
|
545
593
|
return lines.join('\n')
|
|
546
594
|
}
|
|
595
|
+
|
package/lib/tui-settings.js
CHANGED
|
@@ -114,6 +114,42 @@ export function buildTuiSection({ live }) {
|
|
|
114
114
|
return Number.isInteger(value) && value >= -1 ? { kind: 'set', value } : undefined
|
|
115
115
|
},
|
|
116
116
|
},
|
|
117
|
+
{
|
|
118
|
+
path: ['statsRetentionDays'],
|
|
119
|
+
group: 'advanced',
|
|
120
|
+
kind: 'number',
|
|
121
|
+
label: 'Stats retention (days)',
|
|
122
|
+
hint: 'Trim day/hour/session telemetry; 0 keeps everything.',
|
|
123
|
+
format: (value) => (Number(value) > 0 ? String(Number(value)) : ''),
|
|
124
|
+
parse: (text) => {
|
|
125
|
+
const value = Number(text)
|
|
126
|
+
return Number.isFinite(value) && value >= 0 ? { kind: 'set', value } : { kind: 'clear' }
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
path: ['dailyBudgetUsd'],
|
|
131
|
+
group: 'advanced',
|
|
132
|
+
kind: 'number',
|
|
133
|
+
label: 'Daily budget (USD)',
|
|
134
|
+
hint: 'Estimated spend guard; 0 disables it.',
|
|
135
|
+
format: (value) => (Number(value) > 0 ? String(Number(value)) : ''),
|
|
136
|
+
parse: (text) => {
|
|
137
|
+
const value = Number(text)
|
|
138
|
+
return Number.isFinite(value) && value >= 0 ? { kind: 'set', value } : { kind: 'clear' }
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
path: ['monthlyBudgetUsd'],
|
|
143
|
+
group: 'advanced',
|
|
144
|
+
kind: 'number',
|
|
145
|
+
label: 'Monthly budget (USD)',
|
|
146
|
+
hint: 'Estimated spend guard; 0 disables it.',
|
|
147
|
+
format: (value) => (Number(value) > 0 ? String(Number(value)) : ''),
|
|
148
|
+
parse: (text) => {
|
|
149
|
+
const value = Number(text)
|
|
150
|
+
return Number.isFinite(value) && value >= 0 ? { kind: 'set', value } : { kind: 'clear' }
|
|
151
|
+
},
|
|
152
|
+
},
|
|
117
153
|
]
|
|
118
154
|
|
|
119
155
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|