dsh-best-ip 0.0.0-stage → 1.1.2
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/LICENSE +21 -0
- package/README.md +119 -2
- package/bench.js +516 -0
- package/client.js +1398 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +16 -0
- package/index.js +2459 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
- package/probe.js +438 -0
- package/providers.js +271 -0
- package/store.js +218 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 otsffs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 模型节点优选 / Model Node Optimizer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 当前默认模型实测 API 节点速度,推荐稳定的 IP,并按需应用。包名:`dsh-best-ip`。
|
|
4
|
+
|
|
5
|
+
Benchmark the current DSH default model's API IPs. Testing recommends an address; applying it is a separate, explicit action. No provider credentials or personal routes are bundled.
|
|
6
|
+
|
|
7
|
+
## 界面预览
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
需要带有插件管理器、默认模型、Settings、凭据和 Web 服务的 DSH。当前兼容声明为 `>=0.2.1-rc.1`,包含 rc.1、rc.2、rc.3 及后续版本,不设版本上限;这只是安装版本范围,不代表所有后续版本都已实测兼容。Node.js 要求 `>=22`,无需单独安装运行时依赖。仅支持使用 API Key 的默认供应商,暂不支持 OAuth 或其他模型适配器。
|
|
14
|
+
|
|
15
|
+
### 从 npm 安装(推荐)
|
|
16
|
+
|
|
17
|
+
插件已发布到 npm,可直接按包名安装,无需克隆仓库:
|
|
18
|
+
|
|
19
|
+
1. 在 DSH 侧栏打开 **插件 → 添加插件 / 安装第三方插件**。
|
|
20
|
+
2. 在安装来源中填写包名:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
dsh-best-ip
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
需要固定版本时填写 `dsh-best-ip@1.1.1`。
|
|
27
|
+
3. 确认信任该插件,点击 **安装**,随后选择 **立即启用**。
|
|
28
|
+
4. 打开 **设置 → 模型节点优选**。
|
|
29
|
+
|
|
30
|
+
包页面:https://www.npmjs.com/package/dsh-best-ip
|
|
31
|
+
|
|
32
|
+
若你的 DSH 使用国内 npm 镜像且暂时提示找不到包,等待镜像同步,或手动触发 https://npmmirror.com/sync/dsh-best-ip 后再试。
|
|
33
|
+
|
|
34
|
+
### 从 GitHub 安装
|
|
35
|
+
|
|
36
|
+
适用于想跟随源码最新提交,或在插件尚未发布到 npm 时:
|
|
37
|
+
|
|
38
|
+
1. 在 DSH 侧栏打开 **插件 → 添加插件 / 安装第三方插件**。
|
|
39
|
+
2. 在安装来源中填写:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
https://github.com/otsffs/dsh-best-ip.git
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
3. 查看并确认信任该插件,点击 **安装**,随后选择 **立即启用**。
|
|
46
|
+
4. 打开 **设置 → 模型节点优选**。
|
|
47
|
+
|
|
48
|
+
正式分发建议固定实际存在的 Git tag 或 commit。Git 仓库不可匿名读取时,其他用户不能直接按上述地址安装。插件有宿主进程权限,请仅安装你信任的版本。
|
|
49
|
+
|
|
50
|
+
### 从发布包安装
|
|
51
|
+
|
|
52
|
+
维护者运行 `npm pack` 生成 `.tgz` 后,可以将它作为 GitHub Release 附件分发。在同一个安装输入框中填写该包在 **DSH Host 上的绝对路径**;这不是浏览器文件上传。远程 Web 用户应使用服务器能读取的路径。
|
|
53
|
+
|
|
54
|
+
这三种方式都可以安装同一个插件,按名字或 tgz 安装不要求仓库可匿名读取。仅创建仓库或发布 npm 不会自动进入 DSH 的 Discover 列表,Discover 需要配置第三方插件索引。
|
|
55
|
+
|
|
56
|
+
## 使用
|
|
57
|
+
|
|
58
|
+
- **自动测速**:发现有效 IP、测速并保存推荐,不改变当前 IP。
|
|
59
|
+
- **自动测速并应用**:成功后应用本轮推荐;失败或停止时保留原 IP。
|
|
60
|
+
- **最大测速 IP 数**:`10 / 任意(自定义) / 全部`,默认全部。按 ping 从低到高选取,失败的 IP 也占用名额,不用更慢地址补位。当前选择立即用于两个按钮,点击保存可长期保留。
|
|
61
|
+
- **测一下当前地址**:仅测试当前生效地址;未应用 IP 时使用系统 DNS 的 IPv4 地址,不改变选择。
|
|
62
|
+
- **停止测试**:取消在途和排队请求,保留已完成样本,不应用部分结果。已发出的请求可能已经计费。
|
|
63
|
+
- **应用此地址 / 清除并恢复系统 DNS**:显式应用或清除选择。
|
|
64
|
+
|
|
65
|
+
页面默认显示 **全部最多约12w token**,旁边的 **!** 图标悬停可查看详细预算:160 个有效 IP × 3 次 × 最多 256 输出 token = 122,880 输出 token,另计输入 token。160 是额外扫描预算,DNS 返回地址可能额外增加;实际费用取决于有效 IP、输出长度及供应商计费。
|
|
66
|
+
|
|
67
|
+
插件始终跟随目标用户的 DSH 默认供应商/模型,不携带预设供应商 ID、API 地址、凭据或已选 IP。切换默认模型不会自动测速。
|
|
68
|
+
|
|
69
|
+
## 配置和限制
|
|
70
|
+
|
|
71
|
+
安装层见 [cordis.patch.yml](<cordis.patch.yml>),其余默认值由插件提供:
|
|
72
|
+
|
|
73
|
+
| 配置 | 默认值 | 作用 |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `maxBenchmarkIps` | `0` | 0 为全部,正整数为付费测速 IP 上限 |
|
|
76
|
+
| `maxCandidates` | `160` | 额外 Cloudflare 地址探测上限,不含 DNS 返回地址 |
|
|
77
|
+
| `benchmarkRuns` | `3` | 每个入选 IP 的相同复测次数,范围 1–5 |
|
|
78
|
+
| `scanEdges` | `true` | 在 DNS 地址之外采样 Cloudflare 边缘网段 |
|
|
79
|
+
| `dnsServers` | 公共 DNS 列表 | 只用于手动发现候选;空列表仅用系统解析器 |
|
|
80
|
+
|
|
81
|
+
- 支持 HTTPS OpenAI Chat Completions、OpenAI Responses、Anthropic Messages;不猜测其他协议或特殊认证。
|
|
82
|
+
- 仅扫描/测速 IPv4。探测验证目标域名的 TLS 证书;测速使用原域名的 Host/SNI,连接候选 IP。
|
|
83
|
+
- TCP 延迟(界面的 ping)只用于选择测试顺序,不决定最终推荐。
|
|
84
|
+
- 流式速度为输出 token 数 ÷ 首 token 到响应结束的时长。无有效 usage、截断、错误或过短样本不能获得可靠推荐。
|
|
85
|
+
- 稳健得分使用 Q25、相对波动和可靠样本率:`Q25 / (1 + IQR / median) × 可靠样本率²`。至少需要 2 次可靠样本和计划次数的严格多数;原始 TPS 保留显示。
|
|
86
|
+
- 网络路径、代理、模型负载和限流都会影响结果。推荐只针对本次测试候选和工作负载,不保证全局最快。
|
|
87
|
+
- 只有显式应用的地址才影响新建 DNS 连接;不修改操作系统 DNS,也不代表已存在的连接池会立即切换。同域名的不同供应商无法通过全局 DNS 钩子独立选择冲突 IP。
|
|
88
|
+
- 设置接口仅接受本机回环客户端的同源 POST。远程 Web 管理目前不在支持范围内。
|
|
89
|
+
|
|
90
|
+
## 隐私
|
|
91
|
+
|
|
92
|
+
- 没有遥测或作者配置的采集端点。只有用户显式发起测试时才进行候选发现和付费请求。
|
|
93
|
+
- 公共 DNS 会收到要解析的供应商域名;候选 IP 会收到目标域名的 TLS SNI。测试请求会携带该供应商的 API Key,因此只应测试你信任、证书验证通过的服务。
|
|
94
|
+
- API Key 由本机 DSH 凭据服务在显式操作时读取。新测速结果不保存密钥、测试提示词、完整端点 URL 或原始错误响应;工作负载元数据只保留 provider/model、端点 origin 和数值预算。旧记录的 benchmark context 在读取时也会去敏。
|
|
95
|
+
- 测量结果在当前 profile 的 `dsh-best-ip/state.json` 中;未使用 profile 时保存到 DSH home。记录仍含域名、IP、模型及测量信息,请勿公开上传运行记录或凭据。
|
|
96
|
+
- [.gitignore](<.gitignore>) 排除环境文件、凭据、运行状态、日志和生成包;[package.json](<package.json>) 的发布白名单只包含运行时文件、界面资源、安装层、说明和许可。测试和开发脚本不随安装包分发。
|
|
97
|
+
|
|
98
|
+
## 开发与检查
|
|
99
|
+
|
|
100
|
+
从源码目录执行,无需 `npm install`:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
npm run check
|
|
104
|
+
npm test
|
|
105
|
+
npm run check:package
|
|
106
|
+
npm pack
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- 测试通过注入 DNS、HTTPS、凭据和存储替身运行;预载器阻止真实网络,并避免加载本机 DSH profile 的 schema 模块。存储测试只使用自有临时目录。
|
|
110
|
+
- `check:package` 离线执行 `npm pack --dry-run --ignore-scripts`,核对完整的发布文件白名单和通用隐私规则;它不安装、不上传、不发布。
|
|
111
|
+
- `prepack` 会执行语法、测试和包检查。没有安装时脚本或构建步骤,Git/tgz 安装直接使用成品 JavaScript。
|
|
112
|
+
- GitHub CI 在 Linux/Windows、Node 22/24 上配置了相同检查;本地静态/离线通过不等于已实际验证所有 DSH 版本或安装渠道。
|
|
113
|
+
|
|
114
|
+
## 参考与许可
|
|
115
|
+
|
|
116
|
+
- [DSH bundle 发布规范](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md)
|
|
117
|
+
- [DSH 插件管理界面](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-plugin-manager/README.md)
|
|
118
|
+
- [Cloudflare IPv4 清单](https://www.cloudflare.com/ips-v4):内置静态采样清单,不在运行时下载。
|
|
119
|
+
|
|
120
|
+
采用 [MIT License](<LICENSE>)。
|
package/bench.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// Real-request performance measurement in the harness's terms:
|
|
2
|
+
// TTFT: request start -> first non-empty text/reasoning delta (whitespace counts).
|
|
3
|
+
// TPS: provider output tokens / (response end - first token), not chunk count.
|
|
4
|
+
// This is a client-observed stream rate, not the model's isolated decode speed.
|
|
5
|
+
|
|
6
|
+
import https from 'node:https'
|
|
7
|
+
|
|
8
|
+
// Keep caller preflight validation and the wire implementation on one API list.
|
|
9
|
+
// Null/undefined retain the historical Chat Completions default.
|
|
10
|
+
export const BENCHMARK_APIS = Object.freeze(['openai-completions', 'openai-responses', 'anthropic-messages'])
|
|
11
|
+
export const supportsBenchmarkApi = (api) => BENCHMARK_APIS.includes(api ?? 'openai-completions')
|
|
12
|
+
|
|
13
|
+
function chunkText(payload) {
|
|
14
|
+
const choice = payload?.choices?.[0]
|
|
15
|
+
const delta = choice?.delta ?? choice?.message
|
|
16
|
+
if (typeof delta?.content === 'string') return delta.content
|
|
17
|
+
if (Array.isArray(delta?.content)) {
|
|
18
|
+
return delta.content.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('')
|
|
19
|
+
}
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function chunkReasoning(payload) {
|
|
24
|
+
const delta = payload?.choices?.[0]?.delta
|
|
25
|
+
for (const field of ['reasoning_content', 'reasoning', 'thinking']) {
|
|
26
|
+
if (typeof delta?.[field] === 'string' && delta[field] !== '') return delta[field]
|
|
27
|
+
}
|
|
28
|
+
return ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function anthropicPiece(payload) {
|
|
32
|
+
if (payload?.type === 'content_block_start') {
|
|
33
|
+
const block = payload.content_block
|
|
34
|
+
if (block?.type === 'text' && typeof block.text === 'string') return { text: block.text, reasoning: '' }
|
|
35
|
+
if (block?.type === 'thinking' && typeof block.thinking === 'string') return { text: '', reasoning: block.thinking }
|
|
36
|
+
}
|
|
37
|
+
if (payload?.type === 'content_block_delta') {
|
|
38
|
+
const delta = payload.delta
|
|
39
|
+
if (delta?.type === 'text_delta' && typeof delta.text === 'string') return { text: delta.text, reasoning: '' }
|
|
40
|
+
if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') return { text: '', reasoning: delta.thinking }
|
|
41
|
+
}
|
|
42
|
+
return { text: '', reasoning: '' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function responsesPiece(payload, type) {
|
|
46
|
+
if (typeof payload?.delta !== 'string') return { text: '', reasoning: '' }
|
|
47
|
+
if (type === 'response.output_text.delta') return { text: payload.delta, reasoning: '' }
|
|
48
|
+
if (type === 'response.reasoning_summary_text.delta' || type === 'response.reasoning_text.delta') {
|
|
49
|
+
return { text: '', reasoning: payload.delta }
|
|
50
|
+
}
|
|
51
|
+
// Done events and the completed response contain snapshots, not new tokens.
|
|
52
|
+
return { text: '', reasoning: '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const RESPONSES_FAILURE_EVENTS = new Set(['response.failed', 'response.incomplete', 'response.cancelled', 'response.canceled', 'error'])
|
|
56
|
+
|
|
57
|
+
function responsesError(payload, event) {
|
|
58
|
+
// Never echo Responses error bodies: they can include credentials or prompts.
|
|
59
|
+
const types = [payload?.type, event]
|
|
60
|
+
const failureType = types.find((type) => RESPONSES_FAILURE_EVENTS.has(type))
|
|
61
|
+
if (failureType !== undefined) return `stream error: ${failureType}`
|
|
62
|
+
if (payload?.error != null || payload?.response?.error != null) return 'stream error: Responses error'
|
|
63
|
+
if (types.includes('response.completed') && payload?.response?.status !== 'completed') {
|
|
64
|
+
return 'stream error: response.completed has non-completed status'
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readUsage(payload, anthropic) {
|
|
70
|
+
// message_start contains only an initial count. Without the final cumulative
|
|
71
|
+
// message_delta usage it cannot supply the numerator of a completed request.
|
|
72
|
+
if (anthropic) return payload?.type === 'message_delta' ? payload.usage?.output_tokens : undefined
|
|
73
|
+
return payload?.usage?.completion_tokens ?? payload?.usage?.output_tokens
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function streamError(payload, event) {
|
|
77
|
+
if (payload?.error === undefined && payload?.type !== 'error' && event !== 'error') return undefined
|
|
78
|
+
return 'stream error'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send one HTTPS streaming request pinned to ip. No shared agent/TLS session
|
|
83
|
+
* cache: each candidate is measured with the same cold-connection policy.
|
|
84
|
+
* requestImpl/clock permit completely isolated deterministic tests.
|
|
85
|
+
* Failures resolve to { error }; missing usage instead produces null TPS.
|
|
86
|
+
*/
|
|
87
|
+
export function measureStream({
|
|
88
|
+
provider, apiKey, ip, message, model, maxTokens = 256, timeout = 60000, signal,
|
|
89
|
+
requestImpl = https.request, clock = process.hrtime.bigint,
|
|
90
|
+
}) {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
return Promise.resolve({ ip, status: 0, cancelled: true, error: 'test stopped', ttftMs: null, totalMs: 0,
|
|
93
|
+
decodeMs: null, outputTokens: null, tps: null, usageReported: false })
|
|
94
|
+
}
|
|
95
|
+
// The parent resolves identity/auth; refuse its canonical OAuth marker here
|
|
96
|
+
// too, before parsing the endpoint or creating any request with a bearer key.
|
|
97
|
+
if (provider?.authType === 'oauth') {
|
|
98
|
+
return Promise.resolve({ ip, status: 0, error: 'OAuth benchmark authentication is temporarily unsupported',
|
|
99
|
+
ttftMs: null, totalMs: 0, decodeMs: null, outputTokens: null, tps: null, usageReported: false })
|
|
100
|
+
}
|
|
101
|
+
let base
|
|
102
|
+
try {
|
|
103
|
+
base = new URL(provider.baseURL)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return Promise.resolve({ ip, status: 0, error: 'invalid baseURL', ttftMs: null, totalMs: 0 })
|
|
106
|
+
}
|
|
107
|
+
if (base.protocol !== 'https:') {
|
|
108
|
+
return Promise.resolve({ ip, status: 0, error: 'benchmark requires an https: baseURL', ttftMs: null, totalMs: 0 })
|
|
109
|
+
}
|
|
110
|
+
const api = provider.api ?? 'openai-completions'
|
|
111
|
+
if (!supportsBenchmarkApi(api)) {
|
|
112
|
+
return Promise.resolve({ ip, status: 0, error: 'unsupported benchmark API', ttftMs: null, totalMs: 0 })
|
|
113
|
+
}
|
|
114
|
+
const anthropic = api === 'anthropic-messages'
|
|
115
|
+
const responses = api === 'openai-responses'
|
|
116
|
+
const root = base.pathname.replace(/\/+$/, '')
|
|
117
|
+
const path = `${root}/${anthropic ? 'messages' : responses ? 'responses' : 'chat/completions'}`
|
|
118
|
+
const body = JSON.stringify(responses ? {
|
|
119
|
+
model, input: [{ role: 'user', content: [{ type: 'input_text', text: message }] }],
|
|
120
|
+
max_output_tokens: maxTokens, stream: true,
|
|
121
|
+
} : {
|
|
122
|
+
model, max_tokens: maxTokens, stream: true,
|
|
123
|
+
...(anthropic ? {} : { stream_options: { include_usage: true } }),
|
|
124
|
+
messages: [{ role: 'user', content: message }],
|
|
125
|
+
})
|
|
126
|
+
const headers = {
|
|
127
|
+
host: base.host,
|
|
128
|
+
'content-type': 'application/json',
|
|
129
|
+
accept: 'text/event-stream',
|
|
130
|
+
'content-length': Buffer.byteLength(body),
|
|
131
|
+
'user-agent': 'dsh-best-ip/probe',
|
|
132
|
+
}
|
|
133
|
+
if (anthropic) headers['anthropic-version'] = '2023-06-01'
|
|
134
|
+
if (apiKey !== undefined) {
|
|
135
|
+
if (anthropic) headers['x-api-key'] = apiKey
|
|
136
|
+
else headers.authorization = `Bearer ${apiKey}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const started = clock()
|
|
141
|
+
const now = () => Number(clock() - started) / 1e6
|
|
142
|
+
let firstTokenAt = null
|
|
143
|
+
let streamEndedAt = null
|
|
144
|
+
let text = ''
|
|
145
|
+
let reasoningChars = 0
|
|
146
|
+
let outputTokens
|
|
147
|
+
let finished = false
|
|
148
|
+
let status = 0
|
|
149
|
+
let sawAnyChunk = false
|
|
150
|
+
let sawTerminal = false
|
|
151
|
+
let buffer = ''
|
|
152
|
+
let firstInput = true
|
|
153
|
+
let event = ''
|
|
154
|
+
let dataLines = []
|
|
155
|
+
let request
|
|
156
|
+
let response
|
|
157
|
+
let deadline
|
|
158
|
+
const listeners = []
|
|
159
|
+
const listen = (target, event, handler, once = false) => {
|
|
160
|
+
target[once ? 'once' : 'on'](event, handler)
|
|
161
|
+
listeners.push([target, event, handler])
|
|
162
|
+
}
|
|
163
|
+
const detach = () => {
|
|
164
|
+
for (const [target, event, handler] of listeners) target.removeListener(event, handler)
|
|
165
|
+
listeners.length = 0
|
|
166
|
+
}
|
|
167
|
+
const watchErrors = (target, handler) => {
|
|
168
|
+
// Destroying a real ClientRequest can emit an asynchronous ECONNRESET.
|
|
169
|
+
// Retain only these fail-closed guards until close, then remove them too.
|
|
170
|
+
const closed = () => {
|
|
171
|
+
target.removeListener('error', handler)
|
|
172
|
+
target.removeListener('close', closed)
|
|
173
|
+
}
|
|
174
|
+
target.on('error', handler)
|
|
175
|
+
target.once('close', closed)
|
|
176
|
+
}
|
|
177
|
+
const stop = () => finish('test stopped', true)
|
|
178
|
+
|
|
179
|
+
const finish = (failure, cancelled = false) => {
|
|
180
|
+
if (finished) return
|
|
181
|
+
finished = true
|
|
182
|
+
clearTimeout(deadline)
|
|
183
|
+
signal?.removeEventListener('abort', stop)
|
|
184
|
+
const totalMs = now()
|
|
185
|
+
const ttftMs = firstTokenAt === null ? null : Math.round(firstTokenAt)
|
|
186
|
+
response?.destroy()
|
|
187
|
+
request?.destroy()
|
|
188
|
+
detach()
|
|
189
|
+
if (failure !== undefined) {
|
|
190
|
+
const validUsage = Number.isSafeInteger(outputTokens) && outputTokens >= 0
|
|
191
|
+
// Only these locally constructed Responses diagnostics may retain detail.
|
|
192
|
+
const safeResponses = ['stream error: response.failed', 'stream error: response.incomplete',
|
|
193
|
+
'stream error: response.cancelled', 'stream error: response.canceled', 'stream error: error',
|
|
194
|
+
'stream error: Responses error', 'stream error: response.completed has non-completed status']
|
|
195
|
+
const safeError = safeResponses.includes(failure) ? failure : sampleError({ error: failure, cancelled })
|
|
196
|
+
resolve({ ip, status, error: safeError, ...(cancelled ? { cancelled: true } : {}), ttftMs,
|
|
197
|
+
totalMs: Math.round(totalMs), decodeMs: null, outputTokens: validUsage ? outputTokens : null,
|
|
198
|
+
tps: null, usageReported: validUsage })
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
// Preserve the DSH formula: the decode window ends at response completion,
|
|
202
|
+
// including usage/terminal trailers, rather than at the last text delta.
|
|
203
|
+
const decodeMs = firstTokenAt !== null && streamEndedAt !== null ? streamEndedAt - firstTokenAt : null
|
|
204
|
+
const validUsage = Number.isSafeInteger(outputTokens) && outputTokens >= 0
|
|
205
|
+
const tps = decodeMs !== null && decodeMs > 0 && validUsage && outputTokens > 0
|
|
206
|
+
? Math.round((outputTokens / decodeMs) * 1000 * 10) / 10
|
|
207
|
+
: null
|
|
208
|
+
resolve({
|
|
209
|
+
ip, status, ttftMs, totalMs: Math.round(totalMs),
|
|
210
|
+
decodeMs: decodeMs === null ? null : Math.round(decodeMs),
|
|
211
|
+
outputTokens: validUsage ? outputTokens : null,
|
|
212
|
+
tps: Number.isFinite(tps) ? tps : null,
|
|
213
|
+
usageReported: validUsage,
|
|
214
|
+
text: text.slice(0, 160), reasoningChars,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const dispatch = () => {
|
|
219
|
+
const data = dataLines.join('\n')
|
|
220
|
+
const type = event
|
|
221
|
+
dataLines = []
|
|
222
|
+
event = ''
|
|
223
|
+
if (finished || (sawTerminal && !responses)) return
|
|
224
|
+
// Failure event names are authoritative even with an empty, non-JSON or
|
|
225
|
+
// legacy DONE body. Don't let such an event turn into successful EOF.
|
|
226
|
+
if (responses && RESPONSES_FAILURE_EVENTS.has(type)) {
|
|
227
|
+
finish(`stream error: ${type}`)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if ((data === '' && type !== 'error') || type === 'ping') return
|
|
231
|
+
if (!anthropic && type !== 'error' && data.trim() === '[DONE]') {
|
|
232
|
+
// Responses requires response.completed with an actually completed
|
|
233
|
+
// response; Chat's legacy DONE sentinel is never sufficient evidence.
|
|
234
|
+
if (!responses) sawTerminal = true
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
let payload
|
|
238
|
+
try {
|
|
239
|
+
payload = JSON.parse(data)
|
|
240
|
+
} catch {
|
|
241
|
+
finish(type === 'error'
|
|
242
|
+
? (responses ? 'stream error: error' : 'stream error')
|
|
243
|
+
: 'invalid JSON in stream event')
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const failure = responses ? responsesError(payload, type) : streamError(payload, type)
|
|
247
|
+
if (failure !== undefined) {
|
|
248
|
+
finish(failure)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
// Still check Responses failure/error trailers until HTTP end, but don't
|
|
252
|
+
// count additional deltas, terminal snapshots or duplicate usage twice.
|
|
253
|
+
if (sawTerminal) return
|
|
254
|
+
const responseType = payload?.type ?? type
|
|
255
|
+
if (responses && responseType === 'response.completed') {
|
|
256
|
+
const usage = payload.response.usage?.output_tokens
|
|
257
|
+
outputTokens = Number.isSafeInteger(usage) && usage >= 0 ? usage : undefined
|
|
258
|
+
sawTerminal = true
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (anthropic && payload?.type === 'message_stop') {
|
|
262
|
+
sawTerminal = true
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (!responses) {
|
|
266
|
+
const usage = readUsage(payload, anthropic)
|
|
267
|
+
if (usage !== undefined) outputTokens = Number.isSafeInteger(usage) && usage >= 0 ? usage : undefined
|
|
268
|
+
}
|
|
269
|
+
const piece = responses ? responsesPiece(payload, responseType)
|
|
270
|
+
: anthropic ? anthropicPiece(payload) : { text: chunkText(payload), reasoning: chunkReasoning(payload) }
|
|
271
|
+
if (firstTokenAt === null && (piece.text !== '' || piece.reasoning !== '')) firstTokenAt = now()
|
|
272
|
+
reasoningChars += piece.reasoning.length
|
|
273
|
+
text += piece.text
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const line = (value) => {
|
|
277
|
+
if (value === '') {
|
|
278
|
+
dispatch()
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (value.startsWith(':')) return
|
|
282
|
+
const colon = value.indexOf(':')
|
|
283
|
+
const field = colon === -1 ? value : value.slice(0, colon)
|
|
284
|
+
let content = colon === -1 ? '' : value.slice(colon + 1)
|
|
285
|
+
if (content.startsWith(' ')) content = content.slice(1)
|
|
286
|
+
if (field === 'data') dataLines.push(content)
|
|
287
|
+
else if (field === 'event') event = content
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// SSE permits LF, CRLF and CR. Keep a trailing CR until the next chunk so
|
|
291
|
+
// splitting a CRLF pair never creates a spurious empty line/event boundary.
|
|
292
|
+
const ingest = (chunk, eof = false) => {
|
|
293
|
+
if (firstInput && chunk !== '') {
|
|
294
|
+
if (chunk.startsWith('\uFEFF')) chunk = chunk.slice(1)
|
|
295
|
+
firstInput = false
|
|
296
|
+
}
|
|
297
|
+
buffer += chunk
|
|
298
|
+
let start = 0
|
|
299
|
+
for (let index = 0; index < buffer.length && !finished; index++) {
|
|
300
|
+
const char = buffer[index]
|
|
301
|
+
if (char !== '\r' && char !== '\n') continue
|
|
302
|
+
if (char === '\r' && index === buffer.length - 1 && !eof) break
|
|
303
|
+
line(buffer.slice(start, index))
|
|
304
|
+
if (char === '\r' && buffer[index + 1] === '\n') index++
|
|
305
|
+
start = index + 1
|
|
306
|
+
}
|
|
307
|
+
buffer = buffer.slice(start)
|
|
308
|
+
if (eof && !finished) {
|
|
309
|
+
if (buffer !== '') line(buffer)
|
|
310
|
+
buffer = ''
|
|
311
|
+
// Tolerate a complete final data frame without an empty line, but never
|
|
312
|
+
// treat HTTP EOF alone as successful model completion.
|
|
313
|
+
dispatch()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
318
|
+
if (signal?.aborted) {
|
|
319
|
+
stop()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
deadline = setTimeout(() => finish('timeout'), timeout)
|
|
323
|
+
deadline.unref?.()
|
|
324
|
+
try {
|
|
325
|
+
request = requestImpl({
|
|
326
|
+
host: ip, port: base.port === '' ? 443 : Number(base.port),
|
|
327
|
+
path, method: 'POST', servername: base.hostname, headers, timeout,
|
|
328
|
+
agent: false,
|
|
329
|
+
}, (incoming) => {
|
|
330
|
+
response = incoming
|
|
331
|
+
watchErrors(response, (error) => finish(`response error: ${error.message}`))
|
|
332
|
+
if (finished) {
|
|
333
|
+
response.destroy()
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
status = response.statusCode ?? 0
|
|
337
|
+
let responseEnded = false
|
|
338
|
+
response.setEncoding('utf8')
|
|
339
|
+
listen(response, 'aborted', () => finish('response aborted'), true)
|
|
340
|
+
listen(response, 'close', () => {
|
|
341
|
+
if (!responseEnded) finish('response closed before end')
|
|
342
|
+
}, true)
|
|
343
|
+
if (status !== 200) {
|
|
344
|
+
// Drain errors without retaining provider bodies, which may echo secrets.
|
|
345
|
+
listen(response, 'data', () => {})
|
|
346
|
+
listen(response, 'end', () => {
|
|
347
|
+
responseEnded = true
|
|
348
|
+
finish(`HTTP ${status}`)
|
|
349
|
+
}, true)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
listen(response, 'data', (chunk) => {
|
|
353
|
+
sawAnyChunk = true
|
|
354
|
+
if (!finished) ingest(chunk)
|
|
355
|
+
})
|
|
356
|
+
listen(response, 'end', () => {
|
|
357
|
+
responseEnded = true
|
|
358
|
+
if (finished) return
|
|
359
|
+
ingest('', true)
|
|
360
|
+
if (finished) return
|
|
361
|
+
streamEndedAt = now()
|
|
362
|
+
if (firstTokenAt === null) finish(sawAnyChunk ? 'stream ended before any token' : 'stream ended with no data')
|
|
363
|
+
else if (!sawTerminal) finish('stream ended without completion marker')
|
|
364
|
+
else finish()
|
|
365
|
+
}, true)
|
|
366
|
+
})
|
|
367
|
+
watchErrors(request, (error) => finish(`${error.code ?? 'error'}: ${error.message}`))
|
|
368
|
+
if (finished) request.destroy()
|
|
369
|
+
else {
|
|
370
|
+
listen(request, 'timeout', () => finish('timeout'))
|
|
371
|
+
request.end(body)
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
finish(`${error.code ?? 'error'}: ${error.message}`)
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Fixed, versioned scoring policy: short streams amplify timing/buffering noise.
|
|
380
|
+
// High spikes can be artifacts, but slow tails are genuine stability evidence.
|
|
381
|
+
export const SCORING_VERSION = 1
|
|
382
|
+
export const MIN_RELIABLE_OUTPUT_TOKENS = 32
|
|
383
|
+
export const MIN_RELIABLE_DECODE_MS = 200
|
|
384
|
+
export const MIN_RELIABLE_RUNS = 2
|
|
385
|
+
export const OUTLIER_MULTIPLIER = 4
|
|
386
|
+
export const MIN_OUTLIER_RUNS = 3
|
|
387
|
+
|
|
388
|
+
const finite = (value) => Number.isFinite(value) ? value : null
|
|
389
|
+
const sortedNumbers = (values) => values.filter(Number.isFinite).sort((a, b) => a - b)
|
|
390
|
+
const median = (values) => {
|
|
391
|
+
const sorted = sortedNumbers(values)
|
|
392
|
+
if (sorted.length === 0) return null
|
|
393
|
+
const mid = Math.floor(sorted.length / 2)
|
|
394
|
+
if (sorted.length % 2 !== 0) return sorted[mid]
|
|
395
|
+
const sum = sorted[mid - 1] + sorted[mid]
|
|
396
|
+
return Number.isFinite(sum) ? sum / 2 : sorted[mid - 1] / 2 + sorted[mid] / 2
|
|
397
|
+
}
|
|
398
|
+
const range = (values) => {
|
|
399
|
+
const sorted = sortedNumbers(values)
|
|
400
|
+
return { min: sorted[0] ?? null, max: sorted[sorted.length - 1] ?? null }
|
|
401
|
+
}
|
|
402
|
+
const measurable = (run) => run !== undefined && run !== null && run.error === undefined &&
|
|
403
|
+
run.cancelled !== true && run.usageReported === true && Number.isFinite(run.tps) && run.tps > 0 &&
|
|
404
|
+
Number.isSafeInteger(run.outputTokens) && run.outputTokens > 0 &&
|
|
405
|
+
Number.isFinite(run.ttftMs) && run.ttftMs >= 0
|
|
406
|
+
|
|
407
|
+
export function sampleError(run) {
|
|
408
|
+
if (run?.cancelled === true) return 'test stopped'
|
|
409
|
+
if (run?.error === undefined) return run == null ? 'missing run' : null
|
|
410
|
+
// Provider bodies and exception messages can echo prompts, URLs or secrets.
|
|
411
|
+
// Persist a bounded error category, never their arbitrary trailing text.
|
|
412
|
+
if (typeof run.error !== 'string') return 'request failed'
|
|
413
|
+
const error = run.error
|
|
414
|
+
const http = /^HTTP (\d{3})(?::|$)/.exec(error)
|
|
415
|
+
if (http) return `HTTP ${http[1]}`
|
|
416
|
+
for (const category of ['test stopped', 'timeout', 'response aborted', 'response closed before end',
|
|
417
|
+
'stream ended before any token', 'stream ended with no data', 'stream ended without completion marker',
|
|
418
|
+
'invalid JSON in stream event', 'stream error', 'response error', 'invalid baseURL',
|
|
419
|
+
'benchmark requires an https: baseURL', 'unsupported benchmark API',
|
|
420
|
+
'OAuth benchmark authentication is temporarily unsupported']) {
|
|
421
|
+
if (error === category || error.startsWith(`${category}:`)) return category
|
|
422
|
+
}
|
|
423
|
+
const code = /^([A-Z][A-Z0-9_]{1,39})(?::|$)/.exec(error)?.[1]
|
|
424
|
+
const safeCodes = ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',
|
|
425
|
+
'ENETUNREACH', 'EHOSTUNREACH', 'EPIPE', 'EPROTO', 'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
426
|
+
'CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE']
|
|
427
|
+
return safeCodes.includes(code) ? code : 'request failed'
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Preserve transport/median display fields independently of ranking. Samples are
|
|
432
|
+
* a fixed-size allowlist per attempted run: finite raw numbers are unchanged,
|
|
433
|
+
* non-finite/missing values become null, and text/credentials are never copied.
|
|
434
|
+
* plannedRuns includes unstarted work, so stopping early cannot inflate a score.
|
|
435
|
+
* conservativeTps is nearest-rank Q25(v); relativeSpread is (Q75-Q25)/median(v).
|
|
436
|
+
* stabilityScore = Q25/(1+relativeSpread) * (reliableRuns/plannedRuns)^2.
|
|
437
|
+
* rankEligible is authoritative; rejected scores are null, not transport errors.
|
|
438
|
+
*/
|
|
439
|
+
export function summarize(runs, { plannedRuns = runs.length } = {}) {
|
|
440
|
+
plannedRuns = Number.isSafeInteger(plannedRuns) && plannedRuns >= 0
|
|
441
|
+
? Math.max(plannedRuns, runs.length) : runs.length
|
|
442
|
+
const ok = runs.filter((run) => run !== undefined && run !== null && run.error === undefined && run.cancelled !== true)
|
|
443
|
+
const valid = ok.filter(measurable)
|
|
444
|
+
const samples = Array.from(runs, (run) => {
|
|
445
|
+
const flags = []
|
|
446
|
+
if (run?.cancelled === true) flags.push('cancelled')
|
|
447
|
+
if (run?.error !== undefined && run?.cancelled !== true) flags.push('transport-error')
|
|
448
|
+
const valid = measurable(run)
|
|
449
|
+
if (!valid) flags.push('unmeasurable')
|
|
450
|
+
if (Number.isSafeInteger(run?.outputTokens) && run.outputTokens > 0 && run.outputTokens < MIN_RELIABLE_OUTPUT_TOKENS) {
|
|
451
|
+
flags.push('insufficient-tokens')
|
|
452
|
+
}
|
|
453
|
+
if (run?.decodeMs == null) flags.push('missing-decode-window')
|
|
454
|
+
else if (!Number.isFinite(run.decodeMs) || run.decodeMs < 0) flags.push('invalid-decode-window')
|
|
455
|
+
else if (run.decodeMs < MIN_RELIABLE_DECODE_MS) flags.push('insufficient-decode-window')
|
|
456
|
+
return {
|
|
457
|
+
status: finite(run?.status), tps: finite(run?.tps), ttftMs: finite(run?.ttftMs),
|
|
458
|
+
totalMs: finite(run?.totalMs), decodeMs: finite(run?.decodeMs), outputTokens: finite(run?.outputTokens),
|
|
459
|
+
usageReported: run?.usageReported === true, error: sampleError(run), cancelled: run?.cancelled === true,
|
|
460
|
+
flags, reliable: valid && run.outputTokens >= MIN_RELIABLE_OUTPUT_TOKENS &&
|
|
461
|
+
Number.isFinite(run.decodeMs) && run.decodeMs >= MIN_RELIABLE_DECODE_MS,
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
const technical = samples.filter((sample) => sample.reliable)
|
|
465
|
+
if (technical.length >= MIN_OUTLIER_RUNS) {
|
|
466
|
+
const center = median(technical.map((sample) => sample.tps))
|
|
467
|
+
for (const sample of technical) {
|
|
468
|
+
if (sample.tps / center > OUTLIER_MULTIPLIER) {
|
|
469
|
+
sample.flags.push('high-outlier')
|
|
470
|
+
sample.reliable = false
|
|
471
|
+
} else if (sample.tps < center / OUTLIER_MULTIPLIER) {
|
|
472
|
+
// Keep the low tail in v: discarding it would reward unstable routes.
|
|
473
|
+
sample.flags.push('low-tail')
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const values = sortedNumbers(samples.filter((sample) => sample.reliable).map((sample) => sample.tps))
|
|
478
|
+
const reliableRuns = values.length
|
|
479
|
+
const rankEligible = reliableRuns >= MIN_RELIABLE_RUNS && reliableRuns > plannedRuns / 2
|
|
480
|
+
const q25 = values.length > 0 ? values[Math.ceil(values.length * 0.25) - 1] : null
|
|
481
|
+
const q75 = values.length > 0 ? values[Math.ceil(values.length * 0.75) - 1] : null
|
|
482
|
+
const center = median(values)
|
|
483
|
+
const relativeSpread = center === null ? null : Math.min(Number.MAX_VALUE, (q75 - q25) / center)
|
|
484
|
+
const stabilityScore = rankEligible
|
|
485
|
+
? q25 / (1 + relativeSpread) * (reliableRuns / plannedRuns) ** 2 : null
|
|
486
|
+
const scoreReason = rankEligible
|
|
487
|
+
? `ranked using ${reliableRuns}/${plannedRuns} reliable runs; lower-quartile TPS, spread and completion penalties`
|
|
488
|
+
: reliableRuns < MIN_RELIABLE_RUNS
|
|
489
|
+
? `only ${reliableRuns}/${plannedRuns} reliable runs; at least ${MIN_RELIABLE_RUNS} are required (>=${MIN_RELIABLE_OUTPUT_TOKENS} output tokens and >=${MIN_RELIABLE_DECODE_MS}ms decode window)`
|
|
490
|
+
: `only ${reliableRuns}/${plannedRuns} reliable runs; a strict majority of planned runs is required`
|
|
491
|
+
const cancelled = samples.filter((sample) => sample.cancelled).length
|
|
492
|
+
const firstFailure = runs.find((run) => run?.error !== undefined)
|
|
493
|
+
const eligible = valid.length > runs.length / 2
|
|
494
|
+
const tps = range(valid.map((run) => run.tps))
|
|
495
|
+
const ttft = range(valid.map((run) => run.ttftMs))
|
|
496
|
+
return {
|
|
497
|
+
runs: runs.length,
|
|
498
|
+
failed: runs.length - ok.length,
|
|
499
|
+
measurable: valid.length,
|
|
500
|
+
unmeasurable: ok.length - valid.length,
|
|
501
|
+
eligible,
|
|
502
|
+
...(!eligible ? { error: valid.length === 0
|
|
503
|
+
? (firstFailure === undefined ? 'no run produced measurable TPS' : sampleError(firstFailure))
|
|
504
|
+
: `only ${valid.length}/${runs.length} runs produced measurable TPS; a strict majority is required` } : {}),
|
|
505
|
+
ttftMs: median(valid.map((run) => run.ttftMs)),
|
|
506
|
+
tps: median(valid.map((run) => run.tps)),
|
|
507
|
+
tpsMin: tps.min, tpsMax: tps.max,
|
|
508
|
+
ttftMinMs: ttft.min, ttftMaxMs: ttft.max,
|
|
509
|
+
totalMs: median(valid.map((run) => run.totalMs)),
|
|
510
|
+
outputTokens: median(valid.map((run) => run.outputTokens)),
|
|
511
|
+
usageReported: ok.length > 0 && ok.every((run) => run.usageReported === true),
|
|
512
|
+
samples, plannedRuns, completedRuns: runs.length - cancelled, cancelled,
|
|
513
|
+
reliableRuns, suspectRuns: samples.length - reliableRuns, rankEligible,
|
|
514
|
+
conservativeTps: q25, relativeSpread, stabilityScore, scoringVersion: SCORING_VERSION, scoreReason,
|
|
515
|
+
}
|
|
516
|
+
}
|