dsh-opencode-go-router 0.1.5 → 0.2.0
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.en.md +20 -0
- package/CHANGELOG.md +20 -0
- package/README.md +13 -4
- package/README.zh-CN.md +13 -4
- package/cordis.patch.yml +1 -1
- package/lib/client.js +380 -0
- package/lib/core.js +1 -1
- package/lib/impl.js +2 -1
- package/lib/monitor.js +27 -0
- package/lib/store.js +168 -1
- package/package.json +11 -3
package/CHANGELOG.en.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Versioning follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.2.0] - 2026-11-08
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Persists one performance record per silent switch: the slow trigger samples plus the average TTFT and throughput of the first `sampleSize` requests after the switch. The window freezes once full.
|
|
11
|
+
- Adds a settings-page performance panel in the DSH Web host showing before/after speed, percentage change, and sampling progress, refreshed every five seconds.
|
|
12
|
+
- Exposes a top-level `history` array from the status route; routes written by older versions are backfilled as `backfilled` rows.
|
|
13
|
+
- Adds the client bundle (`dsh.client`) and a registration test.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- The settings-page trigger summary no longer shows the throughput threshold as `?`.
|
|
18
|
+
- The `cordis.patch.yml` insert name now matches the npm package name so the Host does not skip the client `dsh.client` declaration.
|
|
19
|
+
|
|
20
|
+
## [0.1.6] - 2026-11-08
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed path portability in the test suite on Linux CI and wait for pending state writes before removing temporary directories.
|
|
25
|
+
|
|
6
26
|
## [0.1.5] - 2026-11-08
|
|
7
27
|
|
|
8
28
|
First public release.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
本项目的所有重要变更都记录在这里,版本号遵循
|
|
4
4
|
[语义化版本](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [0.2.0] - 2026-11-08
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- 持久化每次静默切换的性能记录:切换前的触发样本,以及切换后首批 `sampleSize` 条请求的平均 TTFT/吞吐;采样窗口满后冻结。
|
|
11
|
+
- 在 DSH Web 宿主中新增插件设置页性能面板,展示每次切换的前后速度、变化百分比和采样进度;每五秒刷新。
|
|
12
|
+
- 状态路由新增顶层 `history` 数组;旧版本留下的路由会补录为历史行并标记 `backfilled`。
|
|
13
|
+
- 新增客户端 bundle(`dsh.client`)及注册测试。
|
|
14
|
+
|
|
15
|
+
### 修复
|
|
16
|
+
|
|
17
|
+
- 插件设置页的触发条件不再把吞吐阈值显示成 `?`。
|
|
18
|
+
- `cordis.patch.yml` 的插入名与 npm 包名保持一致,避免客户端的 `dsh.client` 声明被宿主跳过。
|
|
19
|
+
|
|
20
|
+
## [0.1.6] - 2026-11-08
|
|
21
|
+
|
|
22
|
+
### 修复
|
|
23
|
+
|
|
24
|
+
- 修复测试在 Linux CI 上的路径可移植性问题,并在清理临时目录前等待状态文件写入完成。
|
|
25
|
+
|
|
6
26
|
## [0.1.5] - 2026-11-08
|
|
7
27
|
|
|
8
28
|
首个公开版本。
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ The plugin does not compress anything itself and does not change the prompt or t
|
|
|
30
30
|
- **Routing.** A persisted route maps the DSH session id to a generated `ses_...` value. The `llm-pi-ai/request-headers` waterfall runs after pi-ai assembles its header set and before the request is sent, so setting `x-opencode-session` there reaches the wire. In the reference setup a companion injector such as `dsh-opencode-ip-guard` supplies a baseline value equal to the DSH session id; when a route exists this plugin replaces it, and the injector's no-overwrite fallback leaves the value alone.
|
|
31
31
|
- **No billion-context, no effect.** If the sessions directory or the session state file is absent, the monitor stays idle. The header hook has no route and does nothing. No exception is thrown into DSH.
|
|
32
32
|
|
|
33
|
-
The first fold seen after a plugin start is a baseline and never causes rotation. Rotation happens on a later fold, matching "at the next fold rebuild". The first system prompt seen in a session is only a fingerprint baseline; a rewrite rotation requires a subsequent observed change. The current session is considered eligible only while its latest ordinary conversation request is an OpenCode Go DeepSeek-series request.
|
|
33
|
+
The first fold seen after a plugin start is a baseline and never causes rotation; this baseline rule applies to the fold path only. Rotation happens on a later fold, matching "at the next fold rebuild". The system-prompt-rewrite path is independent of any fold: even when the session has never recorded a fold, a changed system prompt with slow samples can rotate on the rewrite request itself (the fold cache condition is bypassed). The first system prompt seen in a session is only a fingerprint baseline; a rewrite rotation requires a subsequent observed change. The current session is considered eligible only while its latest ordinary conversation request is an OpenCode Go DeepSeek-series request.
|
|
34
34
|
|
|
35
35
|
A rotation changes only the upstream routing id. billion-context keeps writing the same state file for the DSH session, so the monitor keeps following it; after recording the fold that triggered the rotation it advances its fold cursor. The same rebuild cannot rotate twice, but a later rebuild can rotate again.
|
|
36
36
|
|
|
@@ -100,8 +100,9 @@ Because the plugin entry point reloads `lib/impl.js` with a cache-busting query
|
|
|
100
100
|
|
|
101
101
|
## Status route
|
|
102
102
|
|
|
103
|
-
`GET /opencode-router/status` returns JSON with the effective config, persisted routes, and per-session monitor state:
|
|
103
|
+
`GET /opencode-router/status` returns JSON with the effective config, persisted routes, rotation history, and per-session monitor state:
|
|
104
104
|
|
|
105
|
+
- `history` — recent silent switches, newest first, with `sessionId`, `at`, `reason`, `foldSeq`, `cacheHitPct`, the routing `value`, before/after TTFT and throughput, sample progress, and a `backfilled` marker;
|
|
105
106
|
- `eligibility` — the configured providers and the fixed `deepseek` model prefix;
|
|
106
107
|
- `baselineReady` and `lastFoldSeq` — fold cursor;
|
|
107
108
|
- `latestFold` — latest fold record (including `hPct`);
|
|
@@ -116,9 +117,17 @@ Because the plugin entry point reloads `lib/impl.js` with a cache-busting query
|
|
|
116
117
|
|
|
117
118
|
The route is localhost-only when the host's web server is bound to localhost. Treat the JSON as diagnostics.
|
|
118
119
|
|
|
120
|
+
## Settings page
|
|
121
|
+
|
|
122
|
+
In the DSH Web client, open the plugin manager and select this bundle. Its detail page renders a **Silent switch performance log** above the bundle configuration rows.
|
|
123
|
+
|
|
124
|
+
For each silent switch it shows the time, a short session id, the trigger (`Fold + speed` or `System rewrite`), the fold number and cache hit behind the decision, average TTFT and throughput before the switch, the first `sampleSize` eligible samples after it, and the percentage change. Rows refresh every five seconds from `GET /opencode-router/status`. The after window is frozen once it has `sampleSize` samples, so later traffic does not rewrite history.
|
|
125
|
+
|
|
126
|
+
Routes created before 0.2.0 are backfilled as one row per still-known route and marked `Reconstructed`; their after values come from samples retained after that rotation. The panel lives in the Web client and does not affect host routing.
|
|
127
|
+
|
|
119
128
|
## Privacy and safety
|
|
120
129
|
|
|
121
|
-
- Stored data is limited to DSH session ids, generated routing ids, fold numbers, cache percentages, TTFT, throughput, and timestamps. No prompts, messages, tool output, or API keys are stored.
|
|
130
|
+
- Stored data is limited to DSH session ids, generated routing ids, fold numbers, cache percentages, TTFT, throughput, rotation history, and timestamps. No prompts, messages, tool output, or API keys are stored.
|
|
122
131
|
- Every host callback catches its own errors. A missing or malformed billion-context state file only produces a log entry.
|
|
123
132
|
- Rotating a header is a routing hint, not a guarantee that the gateway picks a different physical backend. If the gateway ignores unknown or new session values, the plugin cannot force a move.
|
|
124
133
|
- billion-context's state file is an internal format. If its layout changes, the monitor may see no folds and stay idle; the plugin will not corrupt anything.
|
|
@@ -129,7 +138,7 @@ The route is localhost-only when the host's web server is bound to localhost. Tr
|
|
|
129
138
|
node --test
|
|
130
139
|
```
|
|
131
140
|
|
|
132
|
-
The suite covers fold parsing, the cache/performance decision, system-prompt extraction/fingerprinting, the system-rewrite decision, DeepSeek-only eligibility, state persistence, the monitor state machine, header routing, and
|
|
141
|
+
The suite covers fold parsing, the cache/performance decision, system-prompt extraction/fingerprinting, the system-rewrite decision, DeepSeek-only eligibility, state persistence, rotation history, the monitor state machine, header routing, stream measurement, and the client bundle registration.
|
|
133
142
|
|
|
134
143
|
## License
|
|
135
144
|
|
package/README.zh-CN.md
CHANGED
|
@@ -30,7 +30,7 @@ OpenCode Go 通过请求头 `x-opencode-session` 把一条会话固定到一个
|
|
|
30
30
|
- **路由。** 持久化 "DSH 会话 id -> 生成的 `ses_...` 值" 的映射;`llm-pi-ai/request-headers` 瀑布在 pi-ai 组装完请求头之后、真正发请求之前执行,所以在这里设置 `x-opencode-session` 能到达线上请求。参考配置里由伴生插件(如 `dsh-opencode-ip-guard`)提供兜底值(等于 DSH 会话 id);有路由时本插件覆盖它,伴生插件的"已有头不覆盖"规则则保证原值不被二次改动。
|
|
31
31
|
- **没有 billion-context 也能安全存在。** 找不到 sessions 目录或会话状态文件时,监控器保持空闲;请求头钩子没有可用路由时什么也不做;不会向 DSH 抛异常。
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
插件启动后看到的第一次折叠只作为基线,绝不触发轮换;这条基线规则只约束折叠路径,轮换发生在之后的折叠,符合"在下一次折叠重建时轮换"。系统提示词改写路径不依赖任何折叠:即使会话从未记录过折叠,只要系统提示词发生变化且最近样本性能慢,也可以在改写请求本身上轮换(折叠缓存条件被绕过)。会话中第一次看到的系统提示词只作为指纹基线;改写轮换需要之后观察到实际变化。只有会话最近一次普通对话请求仍是 OpenCode Go 的 DeepSeek 系列请求时,该会话才处于合格状态。
|
|
34
34
|
|
|
35
35
|
轮换只改变上游路由 id。billion-context 仍在同一个状态文件里记录这个 DSH 会话,监控器继续跟踪该文件;记录完触发轮换的那次折叠后,它会把折叠游标推进到该处。同一次重建不会触发两次轮换,但之后的另一次重建仍可再次轮换。
|
|
36
36
|
|
|
@@ -100,8 +100,9 @@ dsh plugin add dsh-opencode-go-router --profile <profile>
|
|
|
100
100
|
|
|
101
101
|
## 状态路由
|
|
102
102
|
|
|
103
|
-
`GET /opencode-router/status` 返回 JSON
|
|
103
|
+
`GET /opencode-router/status` 返回 JSON:生效配置、已持久化路由、轮换性能记录、每个会话的监控状态:
|
|
104
104
|
|
|
105
|
+
- `history` —— 最近的静默切换记录,最新在前,字段包括 `sessionId`、`at`、`reason`、`foldSeq`、`cacheHitPct`、路由 `value`、切换前后 TTFT/吞吐、样本进度和 `backfilled` 标记;
|
|
105
106
|
- `eligibility` —— 配置的 provider 与固定的 `deepseek` 模型前缀;
|
|
106
107
|
- `baselineReady` 与 `lastFoldSeq` —— 折叠游标;
|
|
107
108
|
- `latestFold` —— 最新折叠记录(含 `hPct`);
|
|
@@ -116,9 +117,17 @@ dsh plugin add dsh-opencode-go-router --profile <profile>
|
|
|
116
117
|
|
|
117
118
|
如果宿主 Web 服务只监听 localhost,该路由也只在 localhost 可达。JSON 仅供诊断。
|
|
118
119
|
|
|
120
|
+
## 设置页
|
|
121
|
+
|
|
122
|
+
在 DSH Web 客户端打开插件管理器,选中本插件。详情页会在 bundle 配置行上方显示 **静默切换性能记录**。
|
|
123
|
+
|
|
124
|
+
每条记录展示切换时间、短会话 id、触发原因(`折叠 + 性能` 或 `系统提示词改写`)、触发时的折叠序号与缓存命中率、切换前的平均 TTFT/吞吐、切换后首批 `sampleSize` 条合格样本,以及变化百分比。数据来自 `GET /opencode-router/status`,每五秒刷新一次。切换后的采样窗口凑满 `sampleSize` 条后即冻结,之后的流量不会改写历史。
|
|
125
|
+
|
|
126
|
+
0.2.0 之前已经写入的路由会按“每个仍保留的路由一行”补录,并标记为 `补录`;其切换后数值来自该次轮换之后仍保留的样本。面板只存在于 Web 客户端,不影响宿主侧路由。
|
|
127
|
+
|
|
119
128
|
## 隐私与安全
|
|
120
129
|
|
|
121
|
-
- 只保存 DSH 会话 id、生成的路由 id、折叠序号、缓存百分比、TTFT
|
|
130
|
+
- 只保存 DSH 会话 id、生成的路由 id、折叠序号、缓存百分比、TTFT、吞吐、轮换性能记录和时间戳;不保存提示词、消息、工具输出或 API key。
|
|
122
131
|
- 所有宿主回调都自行捕获异常。billion-context 状态文件缺失或损坏只会产生日志。
|
|
123
132
|
- 轮换请求头只是路由提示,不保证网关一定换到另一台物理后端。如果网关忽略新的会话值,插件无法强制迁移。
|
|
124
133
|
- billion-context 的状态文件是内部格式。若其结构变化,监控器可能看不到折叠并保持空闲,但不会破坏任何数据。
|
|
@@ -129,7 +138,7 @@ dsh plugin add dsh-opencode-go-router --profile <profile>
|
|
|
129
138
|
node --test
|
|
130
139
|
```
|
|
131
140
|
|
|
132
|
-
测试覆盖折叠解析、缓存/性能判定、系统提示词提取与指纹、系统提示词改写判定、仅 DeepSeek
|
|
141
|
+
测试覆盖折叠解析、缓存/性能判定、系统提示词提取与指纹、系统提示词改写判定、仅 DeepSeek 资格、状态持久化、轮换性能记录、监控状态机、请求头路由、流式耗时测量和客户端 bundle 注册。
|
|
133
142
|
|
|
134
143
|
## 许可证
|
|
135
144
|
|
package/cordis.patch.yml
CHANGED
package/lib/client.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings-page performance panel for dsh-opencode-go-router.
|
|
3
|
+
*
|
|
4
|
+
* This is a hand-written client bundle: the DSH web client loads it through
|
|
5
|
+
* window.__ModuleLoader__.load, and the only runtime dependency is React.
|
|
6
|
+
* It renders inside the plugin detail page, so it is scoped to this bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: 'dsh-opencode-go-router',
|
|
11
|
+
factory: function (require) {
|
|
12
|
+
var module = { exports: {} };
|
|
13
|
+
var exports = module.exports;
|
|
14
|
+
var React = require('react');
|
|
15
|
+
var h = React.createElement;
|
|
16
|
+
var NS = 'opencode-router';
|
|
17
|
+
var REFRESH_MS = 5000;
|
|
18
|
+
|
|
19
|
+
var zh = {
|
|
20
|
+
title: '静默切换性能记录',
|
|
21
|
+
subtitle: '每次因缓存丢失或系统提示词改写而切换上游后,对比切换前后请求的速度。',
|
|
22
|
+
empty: '还没有静默切换记录。',
|
|
23
|
+
loading: '正在读取状态…',
|
|
24
|
+
error: '读取状态失败:',
|
|
25
|
+
retry: '重试',
|
|
26
|
+
criteria: '触发条件:缓存命中 ≤ {cache}% 且 TTFT ≥ {ttft}s 或吞吐 ≤ {tps} tok/s;系统提示词改写可以绕过缓存条件。',
|
|
27
|
+
summary: '共 {total} 次切换,{complete} 次已采满样本',
|
|
28
|
+
avg: '已采满样本的平均变化:TTFT {ttft},吞吐 {tps}',
|
|
29
|
+
before: '切换前',
|
|
30
|
+
after: '切换后',
|
|
31
|
+
change: '变化',
|
|
32
|
+
ttft: 'TTFT',
|
|
33
|
+
tps: '吞吐',
|
|
34
|
+
fold: '折叠',
|
|
35
|
+
noFold: '无折叠',
|
|
36
|
+
cache: '缓存命中',
|
|
37
|
+
samples: '样本',
|
|
38
|
+
reasonFold: '折叠 + 性能',
|
|
39
|
+
reasonRewrite: '系统提示词改写',
|
|
40
|
+
backfilled: '补录',
|
|
41
|
+
waiting: '等待样本',
|
|
42
|
+
none: '—',
|
|
43
|
+
updated: '更新于',
|
|
44
|
+
version: '版本',
|
|
45
|
+
incomplete: '采样中',
|
|
46
|
+
complete: '已完成',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
var en = {
|
|
50
|
+
title: 'Silent switch performance log',
|
|
51
|
+
subtitle: 'Speed before and after each upstream switch caused by cache loss or a system-prompt rewrite.',
|
|
52
|
+
empty: 'No silent switches recorded yet.',
|
|
53
|
+
loading: 'Reading status…',
|
|
54
|
+
error: 'Failed to read status: ',
|
|
55
|
+
retry: 'Retry',
|
|
56
|
+
criteria: 'Triggers: cache hit ≤ {cache}% and TTFT ≥ {ttft}s or throughput ≤ {tps} tok/s; a system-prompt rewrite bypasses the cache condition.',
|
|
57
|
+
summary: '{total} switches, {complete} with a complete sample window',
|
|
58
|
+
avg: 'Average change over complete windows: TTFT {ttft}, throughput {tps}',
|
|
59
|
+
before: 'Before',
|
|
60
|
+
after: 'After',
|
|
61
|
+
change: 'Change',
|
|
62
|
+
ttft: 'TTFT',
|
|
63
|
+
tps: 'Throughput',
|
|
64
|
+
fold: 'Fold',
|
|
65
|
+
noFold: 'No fold',
|
|
66
|
+
cache: 'Cache hit',
|
|
67
|
+
samples: 'Samples',
|
|
68
|
+
reasonFold: 'Fold + speed',
|
|
69
|
+
reasonRewrite: 'System rewrite',
|
|
70
|
+
backfilled: 'Reconstructed',
|
|
71
|
+
waiting: 'Waiting for samples',
|
|
72
|
+
none: '—',
|
|
73
|
+
updated: 'Updated',
|
|
74
|
+
version: 'Version',
|
|
75
|
+
incomplete: 'Sampling',
|
|
76
|
+
complete: 'Complete',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function fill(template, values) {
|
|
80
|
+
return String(template).replace(/\{(\w+)\}/g, function (_, key) {
|
|
81
|
+
return values && values[key] != null ? String(values[key]) : '';
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatTime(ms) {
|
|
86
|
+
if (!Number.isFinite(Number(ms))) return '';
|
|
87
|
+
try {
|
|
88
|
+
return new Date(Number(ms)).toLocaleString(undefined, {
|
|
89
|
+
month: '2-digit',
|
|
90
|
+
day: '2-digit',
|
|
91
|
+
hour: '2-digit',
|
|
92
|
+
minute: '2-digit',
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
return String(ms);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shortSession(id) {
|
|
100
|
+
var text = String(id == null ? '' : id);
|
|
101
|
+
if (text.indexOf('session-') === 0) text = text.slice(8);
|
|
102
|
+
return text.length > 12 ? text.slice(0, 8) + '…' : text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatTtft(ms) {
|
|
106
|
+
var value = Number(ms);
|
|
107
|
+
if (!Number.isFinite(value) || value < 0) return '—';
|
|
108
|
+
if (value < 1000) return Math.round(value) + ' ms';
|
|
109
|
+
return (value / 1000).toFixed(1) + ' s';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatTps(tps) {
|
|
113
|
+
var value = Number(tps);
|
|
114
|
+
if (!Number.isFinite(value) || value <= 0) return '—';
|
|
115
|
+
return (value >= 10 ? value.toFixed(0) : value.toFixed(1)) + ' tok/s';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatPct(value) {
|
|
119
|
+
var n = Number(value);
|
|
120
|
+
if (!Number.isFinite(n)) return '—';
|
|
121
|
+
return (n >= 0 ? '+' : '') + n.toFixed(1) + '%';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function changePct(before, after) {
|
|
125
|
+
var b = Number(before);
|
|
126
|
+
var a = Number(after);
|
|
127
|
+
if (!Number.isFinite(b) || !Number.isFinite(a) || b === 0) return null;
|
|
128
|
+
return ((a - b) / b) * 100;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function useStatus() {
|
|
132
|
+
var [state, setState] = React.useState({ loading: true, error: '', data: null });
|
|
133
|
+
var [reloadAt, setReloadAt] = React.useState(0);
|
|
134
|
+
React.useEffect(function () {
|
|
135
|
+
var alive = true;
|
|
136
|
+
var timer = null;
|
|
137
|
+
async function load() {
|
|
138
|
+
try {
|
|
139
|
+
var response = await fetch('/opencode-router/status', { cache: 'no-store' });
|
|
140
|
+
if (!response.ok) throw new Error('HTTP ' + String(response.status));
|
|
141
|
+
var data = await response.json();
|
|
142
|
+
if (alive) setState({ loading: false, error: '', data: data });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (alive) {
|
|
145
|
+
setState(function (previous) {
|
|
146
|
+
return {
|
|
147
|
+
loading: false,
|
|
148
|
+
error: String(error && error.message ? error.message : error),
|
|
149
|
+
data: previous.data,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (alive) timer = setTimeout(load, REFRESH_MS);
|
|
155
|
+
}
|
|
156
|
+
load();
|
|
157
|
+
return function () {
|
|
158
|
+
alive = false;
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
};
|
|
161
|
+
}, [reloadAt]);
|
|
162
|
+
return { state: state, retry: function () { setReloadAt(function (value) { return value + 1; }); } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Metric(props) {
|
|
166
|
+
return h('div', { style: { minWidth: 0 } }, [
|
|
167
|
+
h('div', { key: 'label', style: { fontSize: 11, opacity: 0.65, marginBottom: 3 } }, props.label),
|
|
168
|
+
h('div', { key: 'value', style: { fontSize: 14, fontWeight: 600, whiteSpace: 'nowrap', color: props.color || 'inherit' } }, props.value),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ChangeMetric(props) {
|
|
173
|
+
var pct = changePct(props.before, props.after);
|
|
174
|
+
var improved = pct != null && (props.lowerIsBetter ? pct < 0 : pct > 0);
|
|
175
|
+
return h(Metric, {
|
|
176
|
+
label: props.label,
|
|
177
|
+
value: pct == null ? '—' : formatPct(pct),
|
|
178
|
+
color: pct == null ? 'inherit' : (improved ? '#1f9d55' : '#d64545'),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function HistoryRow(props) {
|
|
183
|
+
var entry = props.entry;
|
|
184
|
+
var t = props.t;
|
|
185
|
+
var beforeTtft = entry.beforeTtftMs;
|
|
186
|
+
var beforeTps = entry.beforeTps;
|
|
187
|
+
var afterTtft = entry.afterTtftMs;
|
|
188
|
+
var afterTps = entry.afterTps;
|
|
189
|
+
var hasAfter = Number.isFinite(Number(afterTtft)) || Number.isFinite(Number(afterTps));
|
|
190
|
+
var reason = entry.reason === 'system-rewrite' ? t('reasonRewrite') : t('reasonFold');
|
|
191
|
+
var reasonColor = entry.reason === 'system-rewrite' ? '#7c5cff' : '#0b7fab';
|
|
192
|
+
var progress = String(entry.afterSamples || 0) + '/' + String(entry.afterTarget || 5);
|
|
193
|
+
var afterLabel = hasAfter ? t('after') : t('waiting');
|
|
194
|
+
var metaParts = [
|
|
195
|
+
entry.foldSeq == null ? t('noFold') : t('fold') + ' #' + String(entry.foldSeq),
|
|
196
|
+
entry.cacheHitPct == null ? t('cache') + ' —' : t('cache') + ' ' + String(entry.cacheHitPct) + '%',
|
|
197
|
+
t('samples') + ' ' + progress,
|
|
198
|
+
];
|
|
199
|
+
return h('div', {
|
|
200
|
+
key: entry.id,
|
|
201
|
+
style: {
|
|
202
|
+
border: '1px solid var(--dsh-border-subtle, rgba(127,127,127,0.22))',
|
|
203
|
+
borderRadius: 10,
|
|
204
|
+
padding: '10px 12px',
|
|
205
|
+
background: 'var(--dsh-surface-secondary, rgba(127,127,127,0.045))',
|
|
206
|
+
},
|
|
207
|
+
}, [
|
|
208
|
+
h('div', { key: 'head', style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 } }, [
|
|
209
|
+
h('span', { key: 'time', style: { fontSize: 13, fontWeight: 600 } }, formatTime(entry.at)),
|
|
210
|
+
h('span', { key: 'session', style: { fontSize: 12, opacity: 0.65, fontFamily: 'monospace' } }, shortSession(entry.sessionId)),
|
|
211
|
+
h('span', {
|
|
212
|
+
key: 'reason',
|
|
213
|
+
style: {
|
|
214
|
+
fontSize: 11,
|
|
215
|
+
lineHeight: '18px',
|
|
216
|
+
padding: '0 7px',
|
|
217
|
+
borderRadius: 999,
|
|
218
|
+
color: reasonColor,
|
|
219
|
+
border: '1px solid ' + reasonColor + '55',
|
|
220
|
+
background: reasonColor + '14',
|
|
221
|
+
},
|
|
222
|
+
}, reason),
|
|
223
|
+
entry.backfilled ? h('span', {
|
|
224
|
+
key: 'backfilled',
|
|
225
|
+
title: t('backfilled'),
|
|
226
|
+
style: { fontSize: 10, opacity: 0.6, border: '1px dashed currentColor', borderRadius: 999, padding: '0 6px' },
|
|
227
|
+
}, t('backfilled')) : null,
|
|
228
|
+
h('span', { key: 'state', style: { marginLeft: 'auto', fontSize: 11, opacity: 0.65 } },
|
|
229
|
+
entry.afterComplete ? t('complete') : t('incomplete')),
|
|
230
|
+
]),
|
|
231
|
+
h('div', {
|
|
232
|
+
key: 'metrics',
|
|
233
|
+
style: {
|
|
234
|
+
display: 'grid',
|
|
235
|
+
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
|
236
|
+
gap: 10,
|
|
237
|
+
paddingTop: 8,
|
|
238
|
+
borderTop: '1px dashed var(--dsh-border-subtle, rgba(127,127,127,0.22))',
|
|
239
|
+
},
|
|
240
|
+
}, [
|
|
241
|
+
h('div', { key: 'before' }, [
|
|
242
|
+
h('div', { key: 'title', style: { fontSize: 11, opacity: 0.6, marginBottom: 5 } }, t('before')),
|
|
243
|
+
h('div', { key: 'values', style: { display: 'flex', gap: 12, flexWrap: 'wrap' } }, [
|
|
244
|
+
h(Metric, { key: 'ttft', label: t('ttft'), value: formatTtft(beforeTtft) }),
|
|
245
|
+
h(Metric, { key: 'tps', label: t('tps'), value: formatTps(beforeTps) }),
|
|
246
|
+
]),
|
|
247
|
+
]),
|
|
248
|
+
h('div', { key: 'after' }, [
|
|
249
|
+
h('div', { key: 'title', style: { fontSize: 11, opacity: 0.6, marginBottom: 5 } }, afterLabel),
|
|
250
|
+
h('div', { key: 'values', style: { display: 'flex', gap: 12, flexWrap: 'wrap' } }, hasAfter ? [
|
|
251
|
+
h(Metric, { key: 'ttft', label: t('ttft'), value: formatTtft(afterTtft) }),
|
|
252
|
+
h(Metric, { key: 'tps', label: t('tps'), value: formatTps(afterTps) }),
|
|
253
|
+
] : [
|
|
254
|
+
h('div', { key: 'waiting', style: { fontSize: 12, opacity: 0.55 } }, t('waiting') + ' ' + progress),
|
|
255
|
+
]),
|
|
256
|
+
]),
|
|
257
|
+
h('div', { key: 'change' }, [
|
|
258
|
+
h('div', { key: 'title', style: { fontSize: 11, opacity: 0.6, marginBottom: 5 } }, t('change')),
|
|
259
|
+
h('div', { key: 'values', style: { display: 'flex', gap: 12, flexWrap: 'wrap' } }, [
|
|
260
|
+
h(ChangeMetric, { key: 'ttft', label: t('ttft'), before: beforeTtft, after: afterTtft, lowerIsBetter: true }),
|
|
261
|
+
h(ChangeMetric, { key: 'tps', label: t('tps'), before: beforeTps, after: afterTps, lowerIsBetter: false }),
|
|
262
|
+
]),
|
|
263
|
+
]),
|
|
264
|
+
]),
|
|
265
|
+
h('div', { key: 'meta', style: { marginTop: 8, fontSize: 11, opacity: 0.55 } }, metaParts.join(' · ')),
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function averageChange(entries, field, lowerIsBetter) {
|
|
270
|
+
var values = entries
|
|
271
|
+
.map(function (entry) {
|
|
272
|
+
var pct = changePct(entry['before' + field], entry['after' + field]);
|
|
273
|
+
return pct;
|
|
274
|
+
})
|
|
275
|
+
.filter(function (value) { return Number.isFinite(value); });
|
|
276
|
+
if (values.length === 0) return null;
|
|
277
|
+
var total = values.reduce(function (sum, value) { return sum + value; }, 0);
|
|
278
|
+
return total / values.length;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function PerformancePanel(props) {
|
|
282
|
+
var hook = useStatus();
|
|
283
|
+
var state = hook.state;
|
|
284
|
+
var t = typeof props.t === 'function' ? props.t : function (key) { return en[key] || key; };
|
|
285
|
+
var data = state.data || {};
|
|
286
|
+
var history = Array.isArray(data.history) ? data.history : [];
|
|
287
|
+
var completed = history.filter(function (entry) {
|
|
288
|
+
return entry.afterComplete === true && Number.isFinite(Number(entry.afterTtftMs));
|
|
289
|
+
});
|
|
290
|
+
var avgTtft = averageChange(completed, 'TtftMs', true);
|
|
291
|
+
var avgTps = averageChange(completed, 'Tps', false);
|
|
292
|
+
var config = data.config || {};
|
|
293
|
+
var criteria = fill(t('criteria'), {
|
|
294
|
+
cache: config.cacheHitThresholdPct == null ? '?' : config.cacheHitThresholdPct,
|
|
295
|
+
ttft: config.ttftThresholdMs == null ? '?' : (Number(config.ttftThresholdMs) / 1000).toFixed(0),
|
|
296
|
+
tps: config.throughputThresholdTps == null ? '?' : config.throughputThresholdTps,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (props.view && props.view !== 'page') return null;
|
|
300
|
+
|
|
301
|
+
var body;
|
|
302
|
+
if (state.loading && history.length === 0) {
|
|
303
|
+
body = h('div', { style: { fontSize: 13, opacity: 0.65, padding: '12px 0' } }, t('loading'));
|
|
304
|
+
} else if (state.error && history.length === 0) {
|
|
305
|
+
body = h('div', { style: { padding: '10px 0' } }, [
|
|
306
|
+
h('div', { key: 'error', role: 'alert', style: { color: '#d64545', fontSize: 13 } }, t('error') + state.error),
|
|
307
|
+
h('button', {
|
|
308
|
+
key: 'retry',
|
|
309
|
+
type: 'button',
|
|
310
|
+
onClick: hook.retry,
|
|
311
|
+
style: { marginTop: 8, padding: '4px 10px', cursor: 'pointer' },
|
|
312
|
+
}, t('retry')),
|
|
313
|
+
]);
|
|
314
|
+
} else if (history.length === 0) {
|
|
315
|
+
body = h('div', { style: { fontSize: 13, opacity: 0.65, padding: '12px 0' } }, t('empty'));
|
|
316
|
+
} else {
|
|
317
|
+
body = h('div', {
|
|
318
|
+
style: {
|
|
319
|
+
display: 'flex',
|
|
320
|
+
flexDirection: 'column',
|
|
321
|
+
gap: 8,
|
|
322
|
+
maxHeight: 520,
|
|
323
|
+
overflowY: 'auto',
|
|
324
|
+
paddingRight: 4,
|
|
325
|
+
},
|
|
326
|
+
}, history.map(function (entry) {
|
|
327
|
+
return h(HistoryRow, { key: entry.id, entry: entry, t: t });
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
var summary = history.length > 0 ? fill(t('summary'), {
|
|
332
|
+
total: history.length,
|
|
333
|
+
complete: completed.length,
|
|
334
|
+
}) : '';
|
|
335
|
+
var avg = (avgTtft != null || avgTps != null) ? fill(t('avg'), {
|
|
336
|
+
ttft: avgTtft == null ? '—' : formatPct(avgTtft),
|
|
337
|
+
tps: avgTps == null ? '—' : formatPct(avgTps),
|
|
338
|
+
}) : '';
|
|
339
|
+
|
|
340
|
+
return h('div', {
|
|
341
|
+
style: {
|
|
342
|
+
display: 'flex',
|
|
343
|
+
flexDirection: 'column',
|
|
344
|
+
gap: 10,
|
|
345
|
+
fontSize: 13,
|
|
346
|
+
lineHeight: 1.45,
|
|
347
|
+
},
|
|
348
|
+
}, [
|
|
349
|
+
h('div', { key: 'head', style: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'baseline' } }, [
|
|
350
|
+
h('div', { key: 'title', style: { fontSize: 15, fontWeight: 650 } }, t('title')),
|
|
351
|
+
h('div', { key: 'ver', style: { marginLeft: 'auto', fontSize: 11, opacity: 0.55 } },
|
|
352
|
+
t('version') + ' ' + String(data.version || '?') + (data.updatedAt ? ' · ' + t('updated') + ' ' + formatTime(data.updatedAt) : '')),
|
|
353
|
+
]),
|
|
354
|
+
h('div', { key: 'subtitle', style: { fontSize: 12, opacity: 0.65 } }, t('subtitle')),
|
|
355
|
+
h('div', { key: 'criteria', style: { fontSize: 11, opacity: 0.6 } }, criteria),
|
|
356
|
+
summary ? h('div', { key: 'summary', style: { display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 12 } }, [
|
|
357
|
+
h('span', { key: 'count' }, summary),
|
|
358
|
+
avg ? h('span', { key: 'avg', style: { opacity: 0.75 } }, avg) : null,
|
|
359
|
+
]) : null,
|
|
360
|
+
state.error ? h('div', { key: 'stale', style: { color: '#d64545', fontSize: 11 } }, t('error') + state.error) : null,
|
|
361
|
+
body,
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function apply(ctx) {
|
|
366
|
+
ctx.effect(function () {
|
|
367
|
+
return ctx.locale.register(NS, { zh: zh, en: en });
|
|
368
|
+
});
|
|
369
|
+
ctx.slots.inject('plugins.bundle.config', function* () {
|
|
370
|
+
yield ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-opencode-go-router', locale: NS }, PerformancePanel);
|
|
371
|
+
yield ctx.slots.register({ name: 'plugins.bundle.config', key: 'opencode-router', locale: NS }, PerformancePanel);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
var inject = ['slots', 'locale'];
|
|
376
|
+
exports.apply = apply;
|
|
377
|
+
exports.inject = inject;
|
|
378
|
+
return module.exports;
|
|
379
|
+
},
|
|
380
|
+
});
|
package/lib/core.js
CHANGED
package/lib/impl.js
CHANGED
|
@@ -233,7 +233,7 @@ function registerStatusRoute(ctx, getStatus, logger) {
|
|
|
233
233
|
export async function mount(ctx, rawConfig) {
|
|
234
234
|
const logger = createLogger(ctx);
|
|
235
235
|
const config = normalizeConfig(rawConfig);
|
|
236
|
-
const store = new RouterStore({ statePath: config.statePath, logger });
|
|
236
|
+
const store = new RouterStore({ statePath: config.statePath, logger, sampleSize: config.sampleSize });
|
|
237
237
|
const monitor = new RouterMonitor({ config, store, logger });
|
|
238
238
|
let disposed = false;
|
|
239
239
|
// Observable proof that the waterfall actually stamped a request. This is
|
|
@@ -289,6 +289,7 @@ export async function mount(ctx, rawConfig) {
|
|
|
289
289
|
updatedAt: snapshot.updatedAt,
|
|
290
290
|
routes: snapshot.routes,
|
|
291
291
|
routing: { ...routingStats },
|
|
292
|
+
history: store.history(100),
|
|
292
293
|
sessions: monitor.status(),
|
|
293
294
|
};
|
|
294
295
|
};
|
package/lib/monitor.js
CHANGED
|
@@ -130,6 +130,31 @@ export class SessionMonitor {
|
|
|
130
130
|
this.debug('target model ' + (next ? 'eligible' : 'not eligible') + ' for ' + this.sessionId);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Persist one row for the settings page: the slow samples that justified the
|
|
135
|
+
* switch and, later, the first samples on the new upstream.
|
|
136
|
+
*/
|
|
137
|
+
recordRotationHistory(record, performance) {
|
|
138
|
+
if (this.config.dryRun) return null;
|
|
139
|
+
try {
|
|
140
|
+
return this.store.addRotation({
|
|
141
|
+
sessionId: this.sessionId,
|
|
142
|
+
at: record.rotatedAt,
|
|
143
|
+
value: record.value,
|
|
144
|
+
reason: record.reason,
|
|
145
|
+
foldSeq: record.foldSeq,
|
|
146
|
+
cacheHitPct: record.cacheHitPct,
|
|
147
|
+
beforeTtftMs: record.avgTtftMs,
|
|
148
|
+
beforeTps: record.avgTps,
|
|
149
|
+
beforeSamples: performance && Number.isFinite(performance.count) ? performance.count : this.config.sampleSize,
|
|
150
|
+
afterTarget: this.config.sampleSize,
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.warn('failed to record rotation history: ' + errorText(error));
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
/**
|
|
134
159
|
* Compare the initial system prompt of an outgoing request with the previous
|
|
135
160
|
* one. The first observation only establishes a baseline. A fingerprint
|
|
@@ -191,6 +216,7 @@ export class SessionMonitor {
|
|
|
191
216
|
};
|
|
192
217
|
if (!this.config.dryRun) {
|
|
193
218
|
this.store.setRoute(this.sessionId, record);
|
|
219
|
+
this.recordRotationHistory(record, decision.performance);
|
|
194
220
|
}
|
|
195
221
|
this.rotations += 1;
|
|
196
222
|
this.systemRewriteRotations += 1;
|
|
@@ -386,6 +412,7 @@ export class SessionMonitor {
|
|
|
386
412
|
};
|
|
387
413
|
if (!this.config.dryRun) {
|
|
388
414
|
this.store.setRoute(this.sessionId, record);
|
|
415
|
+
this.recordRotationHistory(record, decision.performance);
|
|
389
416
|
}
|
|
390
417
|
this.rotations += 1;
|
|
391
418
|
const message = 'rotated x-opencode-session for ' + this.sessionId
|
package/lib/store.js
CHANGED
|
@@ -10,9 +10,10 @@ import fsp from 'node:fs/promises';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
|
|
12
12
|
const STATE_VERSION = 1;
|
|
13
|
+
const HISTORY_LIMIT = 100;
|
|
13
14
|
|
|
14
15
|
function emptyState() {
|
|
15
|
-
return { version: STATE_VERSION, updatedAt: 0, routes: {}, metrics: {} };
|
|
16
|
+
return { version: STATE_VERSION, updatedAt: 0, routes: {}, metrics: {}, history: [] };
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function finiteOrNull(value) {
|
|
@@ -36,6 +37,66 @@ function sanitizeRoute(entry) {
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
function clampCount(value) {
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
43
|
+
return Math.min(50, Math.round(n));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function historyId(sessionId, at) {
|
|
47
|
+
return String(sessionId) + '@' + String(at);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function historyAverage(samples, at, target) {
|
|
51
|
+
const after = samples
|
|
52
|
+
.filter((sample) => Number.isFinite(Number(sample.at)) && Number(sample.at) >= at)
|
|
53
|
+
.sort((a, b) => Number(a.at) - Number(b.at))
|
|
54
|
+
.slice(0, target);
|
|
55
|
+
if (after.length === 0) {
|
|
56
|
+
return { ttftMs: null, tps: null, count: 0, complete: false };
|
|
57
|
+
}
|
|
58
|
+
let ttftTotal = 0;
|
|
59
|
+
let tpsTotal = 0;
|
|
60
|
+
for (const sample of after) {
|
|
61
|
+
ttftTotal += Number(sample.ttftMs);
|
|
62
|
+
tpsTotal += Number(sample.tps);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ttftMs: Math.round((ttftTotal / after.length) * 10) / 10,
|
|
66
|
+
tps: Math.round((tpsTotal / after.length) * 100) / 100,
|
|
67
|
+
count: after.length,
|
|
68
|
+
complete: after.length >= target,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sanitizeHistory(entry) {
|
|
73
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
74
|
+
const sessionId = String(entry.sessionId == null ? '' : entry.sessionId).slice(0, 256);
|
|
75
|
+
const at = finiteOrNull(entry.at);
|
|
76
|
+
if (sessionId === '' || at == null) return null;
|
|
77
|
+
const afterTarget = Math.max(1, Math.min(50, clampCount(entry.afterTarget) || 5));
|
|
78
|
+
const value = typeof entry.value === 'string' ? entry.value.slice(0, 256) : '';
|
|
79
|
+
return {
|
|
80
|
+
id: typeof entry.id === 'string' && entry.id !== '' ? entry.id.slice(0, 512) : historyId(sessionId, at),
|
|
81
|
+
sessionId,
|
|
82
|
+
at,
|
|
83
|
+
value,
|
|
84
|
+
reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : 'unknown',
|
|
85
|
+
foldSeq: finiteOrNull(entry.foldSeq),
|
|
86
|
+
cacheHitPct: finiteOrNull(entry.cacheHitPct),
|
|
87
|
+
beforeTtftMs: finiteOrNull(entry.beforeTtftMs),
|
|
88
|
+
beforeTps: finiteOrNull(entry.beforeTps),
|
|
89
|
+
beforeSamples: clampCount(entry.beforeSamples),
|
|
90
|
+
afterTtftMs: finiteOrNull(entry.afterTtftMs),
|
|
91
|
+
afterTps: finiteOrNull(entry.afterTps),
|
|
92
|
+
afterSamples: clampCount(entry.afterSamples),
|
|
93
|
+
afterTarget,
|
|
94
|
+
afterComplete: entry.afterComplete === true,
|
|
95
|
+
afterUpdatedAt: finiteOrNull(entry.afterUpdatedAt),
|
|
96
|
+
backfilled: entry.backfilled === true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
39
100
|
function sanitizeMetric(entry) {
|
|
40
101
|
if (!entry || typeof entry !== 'object') return null;
|
|
41
102
|
const ttftMs = finiteOrNull(entry.ttftMs);
|
|
@@ -68,6 +129,13 @@ function sanitizeState(raw) {
|
|
|
68
129
|
if (samples.length > 0) state.metrics[String(sessionId)] = samples;
|
|
69
130
|
}
|
|
70
131
|
}
|
|
132
|
+
if (Array.isArray(raw.history)) {
|
|
133
|
+
state.history = raw.history
|
|
134
|
+
.map(sanitizeHistory)
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.sort((a, b) => a.at - b.at)
|
|
137
|
+
.slice(-HISTORY_LIMIT);
|
|
138
|
+
}
|
|
71
139
|
return state;
|
|
72
140
|
}
|
|
73
141
|
|
|
@@ -78,7 +146,9 @@ export class RouterStore {
|
|
|
78
146
|
this.logger = opts.logger || console;
|
|
79
147
|
this.now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
80
148
|
this.debounceMs = Number.isFinite(Number(opts.debounceMs)) ? Number(opts.debounceMs) : 150;
|
|
149
|
+
this.sampleSize = Math.max(1, Math.min(50, clampCount(opts.sampleSize) || 5));
|
|
81
150
|
this.state = this.load();
|
|
151
|
+
this.seedHistoryFromRoutes();
|
|
82
152
|
this.dirty = false;
|
|
83
153
|
this.timer = null;
|
|
84
154
|
this.chain = Promise.resolve();
|
|
@@ -132,6 +202,102 @@ export class RouterStore {
|
|
|
132
202
|
return clean;
|
|
133
203
|
}
|
|
134
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Record one silent routing switch. The before values come from the samples
|
|
207
|
+
* that justified it; the after values are filled in as the first requests on
|
|
208
|
+
* the new upstream finish, then frozen when the sample target is reached.
|
|
209
|
+
*/
|
|
210
|
+
addRotation(entry) {
|
|
211
|
+
const clean = sanitizeHistory(entry);
|
|
212
|
+
if (!clean) return null;
|
|
213
|
+
const existing = this.state.history.find((item) => item.id === clean.id);
|
|
214
|
+
if (existing) return { ...existing };
|
|
215
|
+
this.state.history.push(clean);
|
|
216
|
+
this.trimHistory();
|
|
217
|
+
this.refreshHistoryForSession(clean.sessionId);
|
|
218
|
+
this.touch();
|
|
219
|
+
return { ...this.state.history.find((item) => item.id === clean.id) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Existing route files predate the history array. Reconstruct one row per
|
|
224
|
+
* still-known route so the settings page has something to show immediately;
|
|
225
|
+
* after values use whatever samples remain from after that rotation.
|
|
226
|
+
*/
|
|
227
|
+
seedHistoryFromRoutes() {
|
|
228
|
+
let changed = false;
|
|
229
|
+
for (const [sessionId, route] of Object.entries(this.state.routes)) {
|
|
230
|
+
const at = Number(route && route.rotatedAt);
|
|
231
|
+
if (!Number.isFinite(at)) continue;
|
|
232
|
+
const id = historyId(sessionId, at);
|
|
233
|
+
if (this.state.history.some((item) => item.id === id)) continue;
|
|
234
|
+
const entry = sanitizeHistory({
|
|
235
|
+
id,
|
|
236
|
+
sessionId,
|
|
237
|
+
at,
|
|
238
|
+
value: route.value,
|
|
239
|
+
reason: route.reason,
|
|
240
|
+
foldSeq: route.foldSeq,
|
|
241
|
+
cacheHitPct: route.cacheHitPct,
|
|
242
|
+
beforeTtftMs: route.avgTtftMs,
|
|
243
|
+
beforeTps: route.avgTps,
|
|
244
|
+
beforeSamples: this.sampleSize,
|
|
245
|
+
afterTarget: this.sampleSize,
|
|
246
|
+
backfilled: true,
|
|
247
|
+
});
|
|
248
|
+
if (!entry) continue;
|
|
249
|
+
this.state.history.push(entry);
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
if (!changed) return;
|
|
253
|
+
this.state.history.sort((a, b) => a.at - b.at);
|
|
254
|
+
this.trimHistory();
|
|
255
|
+
for (const entry of this.state.history) this.refreshHistoryForSession(entry.sessionId, false);
|
|
256
|
+
this.touch();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
trimHistory() {
|
|
260
|
+
if (this.state.history.length > HISTORY_LIMIT) {
|
|
261
|
+
this.state.history = this.state.history.slice(-HISTORY_LIMIT);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
refreshHistoryForSession(sessionId, notify = true) {
|
|
266
|
+
const key = String(sessionId == null ? '' : sessionId);
|
|
267
|
+
if (key === '') return;
|
|
268
|
+
const samples = Array.isArray(this.state.metrics[key]) ? this.state.metrics[key] : [];
|
|
269
|
+
let changed = false;
|
|
270
|
+
for (const entry of this.state.history) {
|
|
271
|
+
if (entry.sessionId !== key || entry.afterComplete) continue;
|
|
272
|
+
const summary = historyAverage(samples, entry.at, entry.afterTarget);
|
|
273
|
+
if (summary.count === 0) continue;
|
|
274
|
+
if (entry.afterSamples !== summary.count
|
|
275
|
+
|| entry.afterTtftMs !== summary.ttftMs
|
|
276
|
+
|| entry.afterTps !== summary.tps
|
|
277
|
+
|| entry.afterComplete !== summary.complete) {
|
|
278
|
+
entry.afterSamples = summary.count;
|
|
279
|
+
entry.afterTtftMs = summary.ttftMs;
|
|
280
|
+
entry.afterTps = summary.tps;
|
|
281
|
+
entry.afterComplete = summary.complete;
|
|
282
|
+
entry.afterUpdatedAt = this.now();
|
|
283
|
+
changed = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (changed && notify) this.touch();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
history(limit = HISTORY_LIMIT) {
|
|
290
|
+
const requested = Number(limit);
|
|
291
|
+
const count = Number.isFinite(requested)
|
|
292
|
+
? Math.max(1, Math.min(HISTORY_LIMIT, Math.round(requested)))
|
|
293
|
+
: HISTORY_LIMIT;
|
|
294
|
+
return this.state.history
|
|
295
|
+
.slice()
|
|
296
|
+
.sort((a, b) => b.at - a.at)
|
|
297
|
+
.slice(0, count)
|
|
298
|
+
.map((entry) => ({ ...entry }));
|
|
299
|
+
}
|
|
300
|
+
|
|
135
301
|
recordSample(sessionId, sample, maxSamples) {
|
|
136
302
|
const key = String(sessionId == null ? '' : sessionId);
|
|
137
303
|
const clean = sanitizeMetric(sample);
|
|
@@ -140,6 +306,7 @@ export class RouterStore {
|
|
|
140
306
|
const list = Array.isArray(this.state.metrics[key]) ? this.state.metrics[key] : [];
|
|
141
307
|
list.push(clean);
|
|
142
308
|
this.state.metrics[key] = list.slice(-limit);
|
|
309
|
+
this.refreshHistoryForSession(key, false);
|
|
143
310
|
this.touch();
|
|
144
311
|
}
|
|
145
312
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-opencode-go-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: rotate OpenCode Go routing after billion-context cache-losing folds or system-prompt rewrites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./lib/index.js",
|
|
9
|
-
"./package.json": "./package.json"
|
|
9
|
+
"./package.json": "./package.json",
|
|
10
|
+
"./client": "./lib/client.js"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"lib",
|
|
@@ -24,6 +25,13 @@
|
|
|
24
25
|
},
|
|
25
26
|
"bundle": {
|
|
26
27
|
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web",
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-locale",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
|
34
|
+
]
|
|
27
35
|
}
|
|
28
36
|
},
|
|
29
37
|
"keywords": [
|
|
@@ -56,7 +64,7 @@
|
|
|
56
64
|
"node": ">=20"
|
|
57
65
|
},
|
|
58
66
|
"scripts": {
|
|
59
|
-
"check": "node --check lib/index.js && node --check lib/core.js && node --check lib/impl.js && node --check lib/monitor.js && node --check lib/store.js && node --check test/core.test.js && node --check test/impl.test.js && node --check test/monitor.test.js && node --check test/store.test.js",
|
|
67
|
+
"check": "node --check lib/index.js && node --check lib/core.js && node --check lib/impl.js && node --check lib/monitor.js && node --check lib/store.js && node --check lib/client.js && node --check test/core.test.js && node --check test/impl.test.js && node --check test/monitor.test.js && node --check test/store.test.js && node --check test/client.test.js && node --check test/package.test.js",
|
|
60
68
|
"test": "node --test",
|
|
61
69
|
"prepack": "npm run check && npm test",
|
|
62
70
|
"prepublishOnly": "npm run check && npm test"
|