dsh-tap 0.20.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/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- package/scripts/verify-trae-provider.mjs +1451 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify-qoder-provider.mjs — Qoder CN 通道离线回归(mock 上游,断言响应完成)。
|
|
4
|
+
*
|
|
5
|
+
* 锁定的契约(证据 docs/reverse/qoder-cn.md):
|
|
6
|
+
* [1] PKCE 形态:verifier 长度 43–128、charset 合法、challenge = base64url(SHA256)
|
|
7
|
+
* [2] normalizeExpiry 三形态(相对秒 / 绝对秒 / 绝对毫秒)
|
|
8
|
+
* [3] tokenFrom 双形态(poll 给 token,refresh 给 device_token)
|
|
9
|
+
* [4] 设备流快乐路径:404 pending ×N → 200 出令牌 → 落盘 + 账户信息
|
|
10
|
+
* [5] 授权 URL 形态 + 出宿主门禁(非 https / 域外 / javascript: 一律拒)
|
|
11
|
+
* [6] refresh 成功回写;失败置 needsRelogin
|
|
12
|
+
* [7] 临期自动刷新(resolveQoderCredential 内联)
|
|
13
|
+
* [8] logout 代际守卫:在飞 poll 拿到的迟到令牌不落盘
|
|
14
|
+
* [9] machine_id 自持且跨登录复用
|
|
15
|
+
* [10] 视图绝不外泄令牌与 machine_id
|
|
16
|
+
* [11] 轮询期网络抖动不判失败
|
|
17
|
+
* [15] 目录投影:context_config 变体表 → variants 清单 + contextWindow 覆盖纯函数
|
|
18
|
+
* [16] 出站补默认:prefs.effort(off=省略参数)+ profile.maxTokens,不覆盖客户端
|
|
19
|
+
* [17] qoderModelSetPrefs 全链路:文件层 → 镜像 → GET 契约(DSH_HOME 隔离起真组合根)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
import { mkdtempSync } from 'node:fs'
|
|
25
|
+
import { tmpdir } from 'node:os'
|
|
26
|
+
import { join, dirname } from 'node:path'
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
createQoderOAuth, createPkce, createMachineId, normalizeExpiry, QODER_CLIENT_ID,
|
|
30
|
+
} from '../providers/qoder/oauth.js'
|
|
31
|
+
|
|
32
|
+
let pass = 0, fail = 0
|
|
33
|
+
const ok = (cond, label, extra = '') => {
|
|
34
|
+
if (cond) { pass++; console.log(` ok ${label}`) }
|
|
35
|
+
else { fail++; console.log(` FAIL ${label} ${extra}`) }
|
|
36
|
+
}
|
|
37
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
38
|
+
|
|
39
|
+
// ── mock 上游 ──────────────────────────────────────────────────────────────
|
|
40
|
+
const mock = {
|
|
41
|
+
pollScript: [], // 每项:{status, body} 或 'netfail'
|
|
42
|
+
pollHits: 0,
|
|
43
|
+
refreshReply: null, // {status, body}
|
|
44
|
+
refreshHits: 0,
|
|
45
|
+
refreshBodySeen: null,
|
|
46
|
+
userinfo: null, // {status, body}
|
|
47
|
+
userinfoHits: 0,
|
|
48
|
+
lastPollQuery: null,
|
|
49
|
+
}
|
|
50
|
+
const handlers = {
|
|
51
|
+
'GET /api/v1/deviceToken/poll': (req, res, q) => {
|
|
52
|
+
mock.pollHits++
|
|
53
|
+
mock.lastPollQuery = Object.fromEntries(q)
|
|
54
|
+
const step = mock.pollScript[Math.min(mock.pollHits - 1, mock.pollScript.length - 1)]
|
|
55
|
+
if (step === 'netfail') { req.socket.destroy(); return }
|
|
56
|
+
res.writeHead(step.status, { 'Content-Type': 'application/json' })
|
|
57
|
+
res.end(typeof step.body === 'string' ? step.body : JSON.stringify(step.body))
|
|
58
|
+
},
|
|
59
|
+
'POST /api/v1/deviceToken/refresh': async (req, res) => {
|
|
60
|
+
mock.refreshHits++
|
|
61
|
+
const raw = Buffer.concat(await collect(req)).toString('utf8')
|
|
62
|
+
try { mock.refreshBodySeen = JSON.parse(raw) } catch { mock.refreshBodySeen = raw }
|
|
63
|
+
const step = mock.refreshReply ?? { status: 200, body: { device_token: 'dt-new' } }
|
|
64
|
+
res.writeHead(step.status, { 'Content-Type': 'application/json' })
|
|
65
|
+
res.end(JSON.stringify(step.body))
|
|
66
|
+
},
|
|
67
|
+
'GET /api/v1/userinfo': (req, res) => {
|
|
68
|
+
mock.userinfoHits++
|
|
69
|
+
const step = mock.userinfo ?? { status: 200, body: { uid: 'u-1', name: 'tester', email: 't@e.cn' } }
|
|
70
|
+
res.writeHead(step.status, { 'Content-Type': 'application/json' })
|
|
71
|
+
res.end(JSON.stringify(step.body))
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
const collect = (req) => new Promise((resolve) => {
|
|
75
|
+
const chunks = []
|
|
76
|
+
req.on('data', (c) => chunks.push(c))
|
|
77
|
+
req.on('end', () => resolve(chunks))
|
|
78
|
+
})
|
|
79
|
+
const server = createServer((req, res) => {
|
|
80
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
81
|
+
const h = handlers[`${req.method} ${url.pathname}`]
|
|
82
|
+
if (!h) { res.writeHead(404); res.end('{}'); return }
|
|
83
|
+
Promise.resolve(h(req, res, url.searchParams)).catch(() => { res.writeHead(500); res.end() })
|
|
84
|
+
})
|
|
85
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
86
|
+
const BASE = `http://127.0.0.1:${server.address().port}`
|
|
87
|
+
const SETTINGS = {
|
|
88
|
+
qoderOpenapiBaseURL: BASE,
|
|
89
|
+
qoderLoginHost: BASE,
|
|
90
|
+
qoderClientId: QODER_CLIENT_ID,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 文件层(真实 store,避免 mock 掉原子写纪律)
|
|
94
|
+
const { readJson, writeJson } = await import('../core/json-store.js')
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), 'qoder-verify-'))
|
|
96
|
+
const AUTH_PATH = join(dir, 'qoder-plugin-auth.json')
|
|
97
|
+
let store = {}
|
|
98
|
+
const readAuth = () => store
|
|
99
|
+
const writeAuth = (v) => { store = v; writeJson(AUTH_PATH, v) }
|
|
100
|
+
const oauth = createQoderOAuth({ readAuth, writeAuth })
|
|
101
|
+
|
|
102
|
+
// ── [1] PKCE ───────────────────────────────────────────────────────────────
|
|
103
|
+
console.log('\n[1] PKCE 形态')
|
|
104
|
+
{
|
|
105
|
+
const charset = /^[A-Za-z0-9\-._~]+$/
|
|
106
|
+
let good = true, lenOk = true
|
|
107
|
+
for (let i = 0; i < 60; i++) {
|
|
108
|
+
const { verifier, challenge } = createPkce()
|
|
109
|
+
if (verifier.length < 43 || verifier.length > 128) lenOk = false
|
|
110
|
+
if (!charset.test(verifier)) good = false
|
|
111
|
+
if (!/^[A-Za-z0-9_-]+$/.test(challenge)) good = false
|
|
112
|
+
const expect = createHash('sha256').update(verifier).digest().toString('base64url')
|
|
113
|
+
if (challenge !== expect) good = false
|
|
114
|
+
}
|
|
115
|
+
ok(lenOk, 'verifier 长度恒在 43–128')
|
|
116
|
+
ok(good, 'charset 合法且 challenge=S256(verifier)')
|
|
117
|
+
ok(new Set(Array.from({ length: 40 }, () => createPkce().verifier)).size === 40, 'verifier 逐次随机')
|
|
118
|
+
ok(/^[0-9a-f]{48}$/.test(createMachineId()), 'machine_id = 48 hex')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── [2] 有效期归一 ─────────────────────────────────────────────────────────
|
|
122
|
+
console.log('\n[2] normalizeExpiry 三形态')
|
|
123
|
+
{
|
|
124
|
+
const now = 1_700_000_000_000
|
|
125
|
+
ok(normalizeExpiry(3600, now) === now + 3_600_000, '相对秒 → now+δ')
|
|
126
|
+
ok(normalizeExpiry(1_700_001_000, now) === 1_700_001_000_000, '绝对秒 → ms')
|
|
127
|
+
ok(normalizeExpiry(now + 5000, now) === now + 5000, '绝对毫秒原样')
|
|
128
|
+
ok(normalizeExpiry('1700001000', now) === 1_700_001_000_000, '字符串数值也收')
|
|
129
|
+
ok(normalizeExpiry(0, now) === null && normalizeExpiry(null, now) === null && normalizeExpiry('x', now) === null, '非法值 → null')
|
|
130
|
+
// 2026-09-19 实测:expires_at 是 ISO 字符串(早先按数字解 → NaN → 落 0,令"永不过期
|
|
131
|
+
// 但立即该刷新",真实登录联调才暴露)
|
|
132
|
+
ok(normalizeExpiry('2026-09-19T13:00:00Z') === Date.parse('2026-09-19T13:00:00Z'), 'ISO 字符串 → epoch ms')
|
|
133
|
+
ok(normalizeExpiry('2026-09-19T13:00:00.000Z') === Date.parse('2026-09-19T13:00:00Z'), '带毫秒 ISO 也收')
|
|
134
|
+
ok(normalizeExpiry('2026-09-19T13:00:00Z', now) > now, 'ISO 解析不再退化为 0')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── [5] 授权 URL 与门禁 ────────────────────────────────────────────────────
|
|
138
|
+
console.log('\n[5] 授权 URL 形态与出宿主门禁')
|
|
139
|
+
{
|
|
140
|
+
const verdicts = []
|
|
141
|
+
for (const host of ['https://evil.example', 'http://x.cn/', 'javascript:alert(1)', 'not a url', 'https://qoder.cn.evil.example']) {
|
|
142
|
+
// startOAuth 是 async:门禁失败以 rejection 呈现(组合根 await + catch)。
|
|
143
|
+
let r = '未拒'
|
|
144
|
+
try { await createQoderOAuth({ readAuth, writeAuth }).startOAuth({ ...SETTINGS, qoderLoginHost: host }) }
|
|
145
|
+
catch (e) { r = /必须使用 https|不在 Qoder 官方登录站点族|合法 URL/.test(e.message) ? '拒' : '意外:' + e.message }
|
|
146
|
+
verdicts.push(`${host}:${r}`)
|
|
147
|
+
}
|
|
148
|
+
ok(verdicts.every((x) => x.endsWith('拒')), `门禁拒绝越域/非法基址/后缀伪装域(${verdicts.join(' | ')})`)
|
|
149
|
+
|
|
150
|
+
// 官方站点族必须放行(收紧不得把自己人挡掉)
|
|
151
|
+
for (const host of ['https://qoder.cn', 'https://qoder.com.cn']) {
|
|
152
|
+
const o = createQoderOAuth({ readAuth, writeAuth })
|
|
153
|
+
const { authUrl } = await o.startOAuth({ ...SETTINGS, qoderLoginHost: host, qoderOpenapiBaseURL: BASE })
|
|
154
|
+
ok(new URL(authUrl).origin === host, `官方域放行:${host}`)
|
|
155
|
+
await o.logout()
|
|
156
|
+
}
|
|
157
|
+
// 门禁失败时不留副作用:不激活 pending、不写 machine_id
|
|
158
|
+
{
|
|
159
|
+
const o = createQoderOAuth({ readAuth: () => ({}), writeAuth: () => { throw new Error('不应发生写入') } })
|
|
160
|
+
let threw = false
|
|
161
|
+
try { await o.startOAuth({ ...SETTINGS, qoderLoginHost: 'https://evil.example' }) } catch { threw = true }
|
|
162
|
+
ok(threw && o.oauthStatus().pending === false, '越域拒绝时不激活 pending 且不写盘')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
mock.pollScript = [{ status: 404, body: { errorCode: 'NotFound' } }]
|
|
166
|
+
const { authUrl } = await oauth.startOAuth(SETTINGS)
|
|
167
|
+
const u = new URL(authUrl)
|
|
168
|
+
ok(u.pathname === '/device/selectAccounts', '授权页路径 /device/selectAccounts')
|
|
169
|
+
ok(u.searchParams.get('challenge_method') === 'S256', 'challenge_method=S256')
|
|
170
|
+
ok(u.searchParams.get('client_id') === QODER_CLIENT_ID, 'client_id = prod uuid')
|
|
171
|
+
ok(/^[A-Za-z0-9_-]{43,128}$/.test(u.searchParams.get('challenge')), 'challenge 为 b64url')
|
|
172
|
+
ok(u.searchParams.get('machine_id')?.length === 48, 'machine_id 带上')
|
|
173
|
+
await oauth.logout()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── [4][9] 设备流快乐路径 + machine_id 复用 ────────────────────────────────
|
|
177
|
+
console.log('\n[4] 设备流:404 pending → 200 出令牌;[9] machine_id 复用')
|
|
178
|
+
{
|
|
179
|
+
const mid0 = oauth.ensureMachineId()
|
|
180
|
+
mock.pollHits = 0
|
|
181
|
+
mock.userinfoHits = 0
|
|
182
|
+
mock.pollScript = [
|
|
183
|
+
{ status: 404, body: { errorCode: 'NotFound', errorMessage: 'Not found' } },
|
|
184
|
+
{ status: 404, body: { errorCode: 'NotFound' } },
|
|
185
|
+
{ status: 404, body: { errorCode: 'NotFound' } },
|
|
186
|
+
{ status: 200, body: { token: 'qt-access-1', refresh_token: 'drt-r-1', expires_at: 3600, refresh_token_expires_at: 86400 } },
|
|
187
|
+
]
|
|
188
|
+
const { authUrl } = await oauth.startOAuth(SETTINGS)
|
|
189
|
+
ok(oauth.oauthStatus().pending === true, 'pending 置位')
|
|
190
|
+
for (let i = 0; i < 40 && oauth.oauthStatus().pending; i++) await sleep(120)
|
|
191
|
+
const st = oauth.oauthStatus()
|
|
192
|
+
ok(st.signedIn === true, `登录落定(轮询 ${mock.pollHits} 次,authUrl 已给出)`, st.error)
|
|
193
|
+
ok(st.pending === false, 'pending 收敛')
|
|
194
|
+
ok(store.auth.accessToken === 'qt-access-1', 'accessToken 写入')
|
|
195
|
+
ok(store.auth.refreshToken === 'drt-r-1', 'refreshToken 写入(drt- 前缀原样保留)')
|
|
196
|
+
ok(Math.abs(store.auth.expiresAt - (Date.now() + 3_600_000)) < 20_000, 'expiresAt 由相对秒算出')
|
|
197
|
+
ok(store.account?.uid === 'u-1' && store.account.nickname === 'tester', '账户信息拉取并入')
|
|
198
|
+
ok(mock.userinfoHits === 1, 'userinfo 恰好一次')
|
|
199
|
+
ok(mock.lastPollQuery?.challenge_method === 'S256' && /^[0-9a-f-]{36}$/.test(mock.lastPollQuery?.nonce ?? ''), 'poll 带 nonce/challenge_method')
|
|
200
|
+
ok(mock.lastPollQuery?.verifier?.length >= 43, 'poll 带 verifier(授权页只有 challenge,轮询用全量)')
|
|
201
|
+
ok(oauth.ensureMachineId() === mid0, 'machine_id 跨登录稳定')
|
|
202
|
+
ok(new URL(authUrl).searchParams.get('machine_id') === mid0, '授权页 machine_id 与存储一致')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── [10] 视图不泄密 ─────────────────────────────────────────────────────────
|
|
206
|
+
console.log('\n[10] 视图字段脱敏')
|
|
207
|
+
{
|
|
208
|
+
const v = oauth.oauthStatus()
|
|
209
|
+
const flat = JSON.stringify(v) + JSON.stringify(Object.keys(v))
|
|
210
|
+
ok(!flat.includes('qt-access-1') && !flat.includes('drt-r-1'), '令牌不在视图内')
|
|
211
|
+
ok(!flat.includes(store.machine.machineId), 'machine_id 不在视图内')
|
|
212
|
+
ok(v.accessTokenExpiresAt > Date.now(), '过期时间以 epoch 出视图')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── [7] 临期自动刷新 ────────────────────────────────────────────────────────
|
|
216
|
+
console.log('\n[7] 临期自动刷新')
|
|
217
|
+
{
|
|
218
|
+
store.auth = { ...store.auth, expiresAt: Date.now() + 1000 } // 触发临期
|
|
219
|
+
mock.refreshHits = 0
|
|
220
|
+
mock.refreshReply = { status: 200, body: { device_token: 'qt-access-2', refresh_token: 'drt-r-2', expires_at: 7200 } }
|
|
221
|
+
const cred = await oauth.resolveQoderCredential(SETTINGS)
|
|
222
|
+
ok(cred?.authorization === 'Bearer qt-access-2', '返回新令牌')
|
|
223
|
+
ok(mock.refreshHits === 1, '刷新恰好一次')
|
|
224
|
+
ok(mock.refreshBodySeen?.refresh_token === 'drt-r-2' || mock.refreshBodySeen?.refresh_token === 'drt-r-1', '请求体带 refresh_token')
|
|
225
|
+
ok(/^[0-9a-f]{48}$/.test(mock.refreshBodySeen?.machine_id ?? ''), '请求体带 machine_id')
|
|
226
|
+
ok(store.auth.accessToken === 'qt-access-2' && store.auth.refreshToken === 'drt-r-2', '新令牌对回写')
|
|
227
|
+
ok(cred.uid === 'u-1' && cred.machineId === store.machine.machineId, 'cred 带 uid/machineId(/algo 面备用)')
|
|
228
|
+
// 未临期不再刷新
|
|
229
|
+
const h = mock.refreshHits
|
|
230
|
+
await oauth.resolveQoderCredential(SETTINGS)
|
|
231
|
+
ok(mock.refreshHits === h, '未临期不触发刷新')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── [6] refresh 失败 → needsRelogin ────────────────────────────────────────
|
|
235
|
+
console.log('\n[6] refresh 失败形态')
|
|
236
|
+
{
|
|
237
|
+
store.auth = { ...store.auth, expiresAt: Date.now() + 1000 }
|
|
238
|
+
const rExpiryBefore = store.auth.refreshExpiresAt
|
|
239
|
+
mock.refreshReply = { status: 400, body: { errorCode: 'DeviceRefreshTokenPrefixInvalid', errorMessage: 'invalid refresh_token: must start with drt-' } }
|
|
240
|
+
const cred = await oauth.resolveQoderCredential(SETTINGS)
|
|
241
|
+
ok(cred === null, '刷新失败不出凭据')
|
|
242
|
+
ok(oauth.oauthStatus().needsRelogin === true, 'needsRelogin 置位(令牌在但已不可续)')
|
|
243
|
+
mock.refreshReply = { status: 200, body: { device_token: 'qt-access-3', expires_at: 3600 } }
|
|
244
|
+
const cred2 = await oauth.resolveQoderCredential(SETTINGS)
|
|
245
|
+
ok(cred2?.authorization === 'Bearer qt-access-3', '再刷新成功')
|
|
246
|
+
ok(oauth.oauthStatus().needsRelogin === false, '刷新成功自愈')
|
|
247
|
+
ok(store.auth.refreshToken === 'drt-r-2', '响应不带 refresh_token 时沿用旧值')
|
|
248
|
+
ok(store.auth.refreshExpiresAt === rExpiryBefore, '响应不带 refresh 有效期时沿用旧值')
|
|
249
|
+
ok(store.auth.loginMethod === 'browser', 'loginMethod 不被刷新丢弃')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── [8] logout 代际守卫 ─────────────────────────────────────────────────────
|
|
253
|
+
console.log('\n[8] logout 使在飞 poll 失效')
|
|
254
|
+
{
|
|
255
|
+
writeAuth({ machine: store.machine }) // 清令牌,保留 machine
|
|
256
|
+
mock.pollScript = [
|
|
257
|
+
{ status: 404, body: {} }, { status: 404, body: {} }, { status: 404, body: {} },
|
|
258
|
+
{ status: 404, body: {} }, { status: 404, body: {} },
|
|
259
|
+
{ status: 200, body: { token: 'late-token', expires_at: 3600 } },
|
|
260
|
+
]
|
|
261
|
+
mock.pollHits = 0
|
|
262
|
+
await oauth.startOAuth(SETTINGS)
|
|
263
|
+
await sleep(700) // 让它进入轮询中
|
|
264
|
+
await oauth.logout()
|
|
265
|
+
const before = JSON.stringify(store)
|
|
266
|
+
await sleep(2500) // 越过"迟到令牌"那一步
|
|
267
|
+
ok(JSON.stringify(store) === before, 'logout 后迟到令牌未落盘')
|
|
268
|
+
ok(!JSON.stringify(store).includes('late-token'), 'late-token 未进存储')
|
|
269
|
+
ok(oauth.oauthStatus().pending === false, 'logout 后 pending 收敛')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── [11] 网络抖动容错 ───────────────────────────────────────────────────────
|
|
273
|
+
console.log('\n[11] 轮询期网络抖动不判失败')
|
|
274
|
+
{
|
|
275
|
+
writeAuth({ machine: store.machine })
|
|
276
|
+
mock.pollHits = 0
|
|
277
|
+
mock.pollScript = ['netfail', 'netfail', { status: 200, body: { token: 'qt-net', expires_at: 3600 } }]
|
|
278
|
+
await oauth.startOAuth(SETTINGS)
|
|
279
|
+
for (let i = 0; i < 40 && oauth.oauthStatus().pending; i++) await sleep(120)
|
|
280
|
+
const st = oauth.oauthStatus()
|
|
281
|
+
ok(st.signedIn === true, '抖动后仍完成登录')
|
|
282
|
+
ok(st.error === '', `无错误残留(error=${st.error})`)
|
|
283
|
+
await oauth.logout()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── 非 JSON 404 与 400 业务码 ───────────────────────────────────────────────
|
|
287
|
+
console.log('\n[12] 失败形态:非 JSON / 业务错误码')
|
|
288
|
+
{
|
|
289
|
+
writeAuth({ machine: store.machine })
|
|
290
|
+
mock.pollHits = 0
|
|
291
|
+
mock.pollScript = [{ status: 400, body: { errorCode: 'DeviceTokenVerifierRequired', errorMessage: 'verifier is required' } }]
|
|
292
|
+
await oauth.startOAuth(SETTINGS)
|
|
293
|
+
for (let i = 0; i < 30 && oauth.oauthStatus().pending; i++) await sleep(100)
|
|
294
|
+
const st = oauth.oauthStatus()
|
|
295
|
+
ok(st.signedIn === false, '400 不产出登录')
|
|
296
|
+
ok(/DeviceTokenVerifierRequired/.test(st.error), `错误带业务码(${st.error})`)
|
|
297
|
+
await oauth.logout()
|
|
298
|
+
|
|
299
|
+
mock.pollHits = 0
|
|
300
|
+
mock.pollScript = [{ status: 404, body: '<html>nginx</html>' }]
|
|
301
|
+
await oauth.startOAuth(SETTINGS)
|
|
302
|
+
await sleep(1400)
|
|
303
|
+
ok(oauth.oauthStatus().error === '' || oauth.oauthStatus().pending, '裸 HTML 404 不误判为失败')
|
|
304
|
+
await oauth.logout()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── [13] 真实线缆形态回放锁(2026-09-19 真实登录捕获的 poll 响应键集)────────
|
|
308
|
+
console.log('\n[13] 真实 poll 响应形态回放')
|
|
309
|
+
{
|
|
310
|
+
// 真机捕获键集:id, token, user_id, code_challenge, code_challenge_method, nonce,
|
|
311
|
+
// expires_at, refresh_token_id, created_at, updated_at, refresh_token,
|
|
312
|
+
// expires_in, refresh_token_expires_in, refresh_token_expires_at
|
|
313
|
+
const real = {
|
|
314
|
+
id: 'dt-abc', token: 'dt-realshape', user_id: '019efdac-1bf3-792a-a63d-1b94c1ea9fb0',
|
|
315
|
+
code_challenge: 'x', code_challenge_method: 'S256', nonce: 'n-1',
|
|
316
|
+
expires_at: new Date(Date.now() + 36e5).toISOString(), refresh_token_id: 'rtr-1',
|
|
317
|
+
created_at: '2026-09-19T11:03:28.128Z', updated_at: '2026-09-19T11:03:28.128Z',
|
|
318
|
+
refresh_token: 'drt-realshape', expires_in: 86400,
|
|
319
|
+
refresh_token_expires_in: 2592000, refresh_token_expires_at: '2026-10-19T11:03:28.128Z',
|
|
320
|
+
}
|
|
321
|
+
writeAuth({ machine: { machineId: 'f'.repeat(48) } })
|
|
322
|
+
mock.pollHits = 0
|
|
323
|
+
mock.pollScript = [{ status: 200, body: real }]
|
|
324
|
+
mock.userinfo = { status: 200, body: { id: real.user_id, name: 'aliyun1985984145' } }
|
|
325
|
+
await oauth.startOAuth(SETTINGS)
|
|
326
|
+
for (let i = 0; i < 40 && oauth.oauthStatus().pending; i++) await sleep(120)
|
|
327
|
+
ok(store.auth.accessToken === 'dt-realshape', 'dt- 设备令牌入库')
|
|
328
|
+
ok(store.auth.refreshToken === 'drt-realshape', 'drt- 刷新令牌入库')
|
|
329
|
+
ok(store.auth.expiresAt === Date.parse(real.expires_at), 'expires_at(ISO) 正确解析为 epoch', String(store.auth.expiresAt))
|
|
330
|
+
ok(store.auth.expiresAt > Date.now(), '有效期落在未来(旧实现这里会写成 0)')
|
|
331
|
+
ok(store.auth.refreshExpiresAt === Date.parse(real.refresh_token_expires_at), 'refresh_token_expires_at(ISO) 解析')
|
|
332
|
+
ok(oauth.oauthStatus().account?.uid === real.user_id, 'uid 经 userinfo 补全(poll 只给 user_id)')
|
|
333
|
+
ok(oauth.oauthStatus().needsRelogin === false, '新鲜登录不误报需重登')
|
|
334
|
+
mock.userinfo = null
|
|
335
|
+
await oauth.logout()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── [14] 翻译网关:真实 WASM 签名 + mock infer 上游(2026-09-20 打通的形态锁)──
|
|
339
|
+
console.log('\n[14] 翻译网关:COSY 信封 ↔ OpenAI 流式/非流式翻译')
|
|
340
|
+
{
|
|
341
|
+
const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
|
|
342
|
+
const { createQoderGateway } = await import('../providers/qoder/gateway.js')
|
|
343
|
+
const { fileURLToPath } = await import('node:url')
|
|
344
|
+
const wasmPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'providers', 'qoder', 'qoder_auth.wasm')
|
|
345
|
+
|
|
346
|
+
// mock infer 上游:认 URL 形态(agent_chat_generation),回脚本化 SSE 信封
|
|
347
|
+
let inferScript = 'normal'
|
|
348
|
+
let inferBodySeen = null
|
|
349
|
+
const infer = createServer((req, res) => {
|
|
350
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
351
|
+
if (!url.pathname.endsWith('/algo/api/v2/service/pro/sse/agent_chat_generation')) {
|
|
352
|
+
res.writeHead(404); res.end(); return
|
|
353
|
+
}
|
|
354
|
+
const chunks = []
|
|
355
|
+
req.on('data', (c) => chunks.push(c))
|
|
356
|
+
req.on('end', () => {
|
|
357
|
+
inferBodySeen = Buffer.concat(chunks).toString('utf8')
|
|
358
|
+
if (inferScript === 'http401') {
|
|
359
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
360
|
+
res.end('{"error":"unauthorized"}')
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
|
364
|
+
if (inferScript === 'errorframe') {
|
|
365
|
+
res.end('event:error\ndata:{"stackTrace":[{"methodName":"x"}],"msgInfo":"boom"}\n\n')
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (inferScript === 'failframe') {
|
|
369
|
+
// 带内失败帧:HTTP 200 信封装业务错误(2026-09-22 实测 qfmodel 上游节点挂)
|
|
370
|
+
res.end('data:' + JSON.stringify({ headers: { 'Content-Type': ['application/json'] }, body: '{"code":"400","message":"[FAIL]node:oa_qwen-plus-main msg:Execution failed: null"}', statusCodeValue: 400, statusCode: 'BAD_REQUEST' }) + '\n\n'
|
|
371
|
+
+ 'event:finish\n'
|
|
372
|
+
+ 'data:' + JSON.stringify({ firstTokenDuration: 1, totalDuration: 2, serverDuration: 2 }) + '\n\n')
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
const chunk = (delta, extra = {}) => JSON.stringify({ choices: [{ delta, index: 0, ...(extra.finish ? { finish_reason: extra.finish } : {}) }], created: 1, id: 'c1', model: 'auto', object: 'chat.completion.chunk' })
|
|
376
|
+
const env = (body) => `data:${JSON.stringify({ headers: { 'Content-Type': ['application/json'] }, body, statusCodeValue: 200, statusCode: 'OK' })}\n\n`
|
|
377
|
+
const usageChunk = JSON.stringify({ choices: [], created: 1, id: 'c1', model: 'auto', object: 'chat.completion.chunk', usage: { billable: true, prompt_tokens: 10, completion_tokens: 2, total_tokens: 12, credits: 0.5 } })
|
|
378
|
+
res.end(
|
|
379
|
+
env(chunk({ content: '你' }))
|
|
380
|
+
+ env(chunk({ content: '好' }, { finish: 'stop' }))
|
|
381
|
+
+ env(usageChunk)
|
|
382
|
+
+ env('[DONE]')
|
|
383
|
+
+ `data:${JSON.stringify({ firstTokenDuration: 1, totalDuration: 2, serverDuration: 2 })}\n\n`,
|
|
384
|
+
)
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
await new Promise((r) => infer.listen(0, '127.0.0.1', r))
|
|
388
|
+
const inferOrigin = `http://127.0.0.1:${infer.address().port}`
|
|
389
|
+
|
|
390
|
+
const metered = []
|
|
391
|
+
const cosy = createCosyRuntime({ wasmPath })
|
|
392
|
+
const runtime = { running: false, port: null, lastError: null }
|
|
393
|
+
const gateway = createQoderGateway({
|
|
394
|
+
settings: () => ({ qoderInferBaseURL: inferOrigin, maxConcurrentPerSession: 4, upstreamFirstByteTimeoutMs: 5000 }),
|
|
395
|
+
resolveCredential: async () => ({ authorization: 'Bearer dt-test', machineId: 'a'.repeat(48), uid: 'u-1' }),
|
|
396
|
+
cosy,
|
|
397
|
+
meter: { record: (r) => metered.push(r) },
|
|
398
|
+
runtime,
|
|
399
|
+
forensics: { logPath: () => undefined },
|
|
400
|
+
getCatalogProfiles: () => [{ id: 'auto', name: 'Auto' }],
|
|
401
|
+
getModelSource: () => 'system',
|
|
402
|
+
})
|
|
403
|
+
const stopGw = gateway.listen(0)
|
|
404
|
+
for (let i = 0; i < 50 && !runtime.running; i++) await sleep(50)
|
|
405
|
+
ok(runtime.running === true, '网关监听成功')
|
|
406
|
+
const gw = `http://127.0.0.1:${runtime.port}`
|
|
407
|
+
|
|
408
|
+
// 流式:信封 chunk 逐帧透传 + [DONE] 收尾 + usage.credits → credit 计量
|
|
409
|
+
const resp = await fetch(`${gw}/v1/chat/completions`, {
|
|
410
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
411
|
+
body: JSON.stringify({ model: 'auto', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
|
|
412
|
+
})
|
|
413
|
+
const sseText = await resp.text()
|
|
414
|
+
ok(resp.status === 200, '流式 200')
|
|
415
|
+
ok(sseText.includes('"content":"你"') && sseText.includes('"content":"好"'), '内容增量逐帧透传')
|
|
416
|
+
ok(sseText.includes('"finish_reason":"stop"'), 'finish_reason 透传')
|
|
417
|
+
ok(sseText.trimEnd().endsWith('data: [DONE]'), '[DONE] 收尾')
|
|
418
|
+
ok(inferBodySeen !== null && inferBodySeen.length > 0, '上游收到加密 body', String(inferBodySeen).slice(0, 40))
|
|
419
|
+
ok(metered.length === 1 && metered[0].usage.credit === 0.5 && metered[0].usage.total_tokens === 12,
|
|
420
|
+
'计量落盘且 credits→credit 归一', JSON.stringify(metered[0]?.usage))
|
|
421
|
+
|
|
422
|
+
// 非流式:聚合成单个 chat.completion
|
|
423
|
+
const resp2 = await fetch(`${gw}/v1/chat/completions`, {
|
|
424
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
425
|
+
body: JSON.stringify({ model: 'auto', stream: false, messages: [{ role: 'user', content: 'hi' }] }),
|
|
426
|
+
})
|
|
427
|
+
const agg = await resp2.json()
|
|
428
|
+
ok(agg.object === 'chat.completion' && agg.choices[0].message.content === '你好', '非流式聚合正文')
|
|
429
|
+
ok(agg.choices[0].finish_reason === 'stop' && agg.usage.total_tokens === 12, '非流式聚合 finish/usage')
|
|
430
|
+
|
|
431
|
+
// 错误帧:流中段 error event → 错误 chunk + [DONE]
|
|
432
|
+
inferScript = 'errorframe'
|
|
433
|
+
const resp3 = await fetch(`${gw}/v1/chat/completions`, {
|
|
434
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
435
|
+
body: JSON.stringify({ model: 'auto', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
|
|
436
|
+
})
|
|
437
|
+
const errSse = await resp3.text()
|
|
438
|
+
ok(errSse.includes('"error"') && errSse.trimEnd().endsWith('data: [DONE]'), 'error 帧 → 错误 chunk + [DONE]')
|
|
439
|
+
|
|
440
|
+
// 带内失败帧(HTTP 200 信封装业务错误,实测 qfmodel 上游节点挂的形态)→ 绝不静默空响应
|
|
441
|
+
inferScript = 'failframe'
|
|
442
|
+
const respF = await fetch(`${gw}/v1/chat/completions`, {
|
|
443
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
444
|
+
body: JSON.stringify({ model: 'qfmodel', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
|
|
445
|
+
})
|
|
446
|
+
const failSse = await respF.text()
|
|
447
|
+
ok(failSse.includes('qoder upstream error') && failSse.includes('oa_qwen-plus-main') && failSse.trimEnd().endsWith('data: [DONE]'), '带内失败帧 → 流式错误 chunk + [DONE]')
|
|
448
|
+
const respF2 = await fetch(`${gw}/v1/chat/completions`, {
|
|
449
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
450
|
+
body: JSON.stringify({ model: 'qfmodel', stream: false, messages: [{ role: 'user', content: 'hi' }] }),
|
|
451
|
+
})
|
|
452
|
+
const failJson = await respF2.json()
|
|
453
|
+
ok(respF2.status === 502 && failJson.error.code === 'qoder_upstream_error' && /oa_qwen-plus-main/.test(failJson.error.message ?? ''),
|
|
454
|
+
'带内失败帧 → 非流式 502 + 上游错误详情')
|
|
455
|
+
inferScript = 'normal'
|
|
456
|
+
|
|
457
|
+
// 上游 401 → 状态与错误码透传
|
|
458
|
+
inferScript = 'http401'
|
|
459
|
+
const resp4 = await fetch(`${gw}/v1/chat/completions`, {
|
|
460
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
461
|
+
body: JSON.stringify({ model: 'auto', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
|
|
462
|
+
})
|
|
463
|
+
const err401 = await resp4.json()
|
|
464
|
+
ok(resp4.status === 401 && err401.error.code === 'qoder_401', '上游 401 透传')
|
|
465
|
+
inferScript = 'normal'
|
|
466
|
+
|
|
467
|
+
// Host 门:非回环 Host 拒绝(fetch 会覆盖伪造 Host——必须原生客户端)
|
|
468
|
+
const resp5 = await new Promise((resolve, reject) => {
|
|
469
|
+
const r = httpRequest({ host: '127.0.0.1', port: runtime.port, path: '/v1/models', method: 'GET', headers: { Host: 'evil.example.com' } }, (rs) => {
|
|
470
|
+
rs.resume() // 消费响应(踩坑 #29:paused 流永不 close)
|
|
471
|
+
rs.on('end', () => resolve({ status: rs.statusCode }))
|
|
472
|
+
})
|
|
473
|
+
r.on('error', reject)
|
|
474
|
+
r.end()
|
|
475
|
+
})
|
|
476
|
+
ok(resp5.status === 403, 'Host 门拒绝非回环')
|
|
477
|
+
|
|
478
|
+
// /v1/models 来自目录
|
|
479
|
+
const resp6 = await fetch(`${gw}/v1/models`)
|
|
480
|
+
const models6 = await resp6.json()
|
|
481
|
+
ok(Array.isArray(models6.data) && models6.data[0]?.id === 'auto', '/v1/models 出目录')
|
|
482
|
+
|
|
483
|
+
await stopGw()
|
|
484
|
+
infer.close()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── [18] 出站 tool 配对 + 可见性体检(sanitizeToolPairing + developer 重写 + 网关接线)
|
|
488
|
+
// 根因两次修正:① pi-ai 删 error/aborted 的 assistant 留 toolResult(孤儿 tool);
|
|
489
|
+
// ② 宿主每个工具轮都发 content:null,严格上游把它当"不存在的消息"(主触发)。
|
|
490
|
+
console.log('\n[18] 出站 tool 配对 + 可见性修复(sanitizeToolPairing + 网关接线)')
|
|
491
|
+
{
|
|
492
|
+
const { sanitizeToolPairing, createQoderGateway } = await import('../providers/qoder/gateway.js')
|
|
493
|
+
|
|
494
|
+
const pairOk = (msgs) => {
|
|
495
|
+
const ids = new Set()
|
|
496
|
+
for (const m of msgs) {
|
|
497
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) for (const tc of m.tool_calls) ids.add(tc.id)
|
|
498
|
+
else if (m.role === 'tool') { if (!ids.has(m.tool_call_id)) return false }
|
|
499
|
+
else if (m.role === 'assistant') ids.clear()
|
|
500
|
+
}
|
|
501
|
+
return true
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// 纯函数:pi-ai 的真实产物(system,user,tool,user——assistant 被 stopReason=error 删掉)
|
|
505
|
+
const orphan = sanitizeToolPairing([
|
|
506
|
+
{ role: 'system', content: 'sys' },
|
|
507
|
+
{ role: 'user', content: '现在几点?' },
|
|
508
|
+
{ role: 'tool', tool_call_id: 'call_abc123', name: 'get_current_time', content: '2026-09-22 05:00:00 +08:00' },
|
|
509
|
+
{ role: 'user', content: '谢谢' },
|
|
510
|
+
])
|
|
511
|
+
ok(pairOk(orphan.messages) === true, '孤儿 tool → 补 assistant 桩后配对合法')
|
|
512
|
+
ok(orphan.repaired.orphans === 1 && orphan.messages[2]?.role === 'assistant'
|
|
513
|
+
&& orphan.messages[2]?.tool_calls?.[0]?.id === 'call_abc123'
|
|
514
|
+
&& orphan.messages[2]?.tool_calls?.[0]?.function?.name === 'get_current_time',
|
|
515
|
+
'桩保留 tool_call_id 与工具名', JSON.stringify(orphan.messages[2]))
|
|
516
|
+
ok(orphan.messages[3]?.role === 'tool' && orphan.messages[3]?.tool_call_id === 'call_abc123', '原 tool 结果保留在桩之后')
|
|
517
|
+
|
|
518
|
+
// 纯函数:tool_call 无结果 → 补"不可用"结果(同一故障的另一半)
|
|
519
|
+
const missing = sanitizeToolPairing([
|
|
520
|
+
{ role: 'user', content: 'hi' },
|
|
521
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_x', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
522
|
+
{ role: 'user', content: '算了' },
|
|
523
|
+
])
|
|
524
|
+
ok(missing.repaired.synthesized === 1 && missing.messages[2]?.role === 'tool'
|
|
525
|
+
&& missing.messages[2]?.tool_call_id === 'call_x' && /unavailable/.test(missing.messages[2]?.content ?? ''),
|
|
526
|
+
'tool_calls 缺结果 → 合成不可用结果', JSON.stringify(missing.messages.map((m) => m.role)))
|
|
527
|
+
ok(pairOk(missing.messages) === true, '合成后配对合法')
|
|
528
|
+
|
|
529
|
+
// 纯函数:同 id 的重复结果 → 丢弃(上游会当孤儿 400)
|
|
530
|
+
const dup = sanitizeToolPairing([
|
|
531
|
+
{ role: 'user', content: 'hi' },
|
|
532
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_dup', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
533
|
+
{ role: 'tool', tool_call_id: 'call_dup', content: 'r1' },
|
|
534
|
+
{ role: 'tool', tool_call_id: 'call_dup', content: 'r2' },
|
|
535
|
+
])
|
|
536
|
+
ok(dup.repaired.duplicates === 1 && dup.messages.filter((m) => m.role === 'tool').length === 1,
|
|
537
|
+
'同 id 重复 tool 结果 → 只留第一个', JSON.stringify(dup.messages.map((m) => m.role)))
|
|
538
|
+
|
|
539
|
+
// 纯函数:**主触发形态**——合法工具环但 assistant content:null(宿主对每个工具轮
|
|
540
|
+
// 都这么发,见 providers/tool-pairing.js 文件头 P-A)→ 必须补成 '',且补完配对仍合法
|
|
541
|
+
const invisible = sanitizeToolPairing([
|
|
542
|
+
{ role: 'system', content: 'sys' },
|
|
543
|
+
{ role: 'user', content: '现在几点?' },
|
|
544
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_n1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
545
|
+
{ role: 'tool', tool_call_id: 'call_n1', content: 'r' },
|
|
546
|
+
])
|
|
547
|
+
ok(invisible.repaired.invisible === 1 && invisible.repaired.orphans === 0,
|
|
548
|
+
'assistant content:null → 计入 invisible 修复', JSON.stringify(invisible.repaired))
|
|
549
|
+
ok(invisible.messages[2].content === '' && pairOk(invisible.messages) === true,
|
|
550
|
+
'null 载体补成空串后工具环合法', JSON.stringify(invisible.messages[2]))
|
|
551
|
+
ok(JSON.stringify(Object.keys(invisible.messages[2])) === JSON.stringify(['role', 'content', 'tool_calls']),
|
|
552
|
+
'键序不变(spread 原位覆盖,不产生重排)')
|
|
553
|
+
|
|
554
|
+
// 纯函数:tool 结果自身 content:null 同样不可见(上游报"insufficient tool messages")
|
|
555
|
+
const invisibleTool = sanitizeToolPairing([
|
|
556
|
+
{ role: 'user', content: 'hi' },
|
|
557
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'call_n2', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
558
|
+
{ role: 'tool', tool_call_id: 'call_n2', content: null },
|
|
559
|
+
])
|
|
560
|
+
ok(invisibleTool.messages[2].content === '' && invisibleTool.repaired.invisible === 1,
|
|
561
|
+
'tool 消息 content:null → 补空串', JSON.stringify(invisibleTool.messages[2]))
|
|
562
|
+
|
|
563
|
+
// 纯函数:content 缺失(连键都没有)同样归一
|
|
564
|
+
const absent = sanitizeToolPairing([
|
|
565
|
+
{ role: 'user', content: 'hi' },
|
|
566
|
+
{ role: 'assistant', tool_calls: [{ id: 'call_n3', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
567
|
+
{ role: 'tool', tool_call_id: 'call_n3', content: 'r' },
|
|
568
|
+
])
|
|
569
|
+
ok(absent.messages[1].content === '' && absent.repaired.invisible === 1, 'content 键缺失 → 补空串')
|
|
570
|
+
|
|
571
|
+
// 纯函数:修复桩自身必须用 ''——用 null 会把修复变成新的坏体(09-22 首版翻车点)
|
|
572
|
+
const stubShape = sanitizeToolPairing([
|
|
573
|
+
{ role: 'user', content: 'hi' },
|
|
574
|
+
{ role: 'tool', tool_call_id: 'call_s', content: 'r' },
|
|
575
|
+
])
|
|
576
|
+
ok(stubShape.messages[1].content === '' && 'content' in stubShape.messages[1],
|
|
577
|
+
'孤儿桩 content 为空串而非 null', JSON.stringify(stubShape.messages[1]))
|
|
578
|
+
|
|
579
|
+
// 纯函数:合法(content 为非空串)历史零改动——修复不得碰正常请求
|
|
580
|
+
const clean = [
|
|
581
|
+
{ role: 'user', content: 'hi' },
|
|
582
|
+
{ role: 'assistant', content: '我查一下', tool_calls: [{ id: 'call_ok', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
583
|
+
{ role: 'tool', tool_call_id: 'call_ok', content: 'result' },
|
|
584
|
+
]
|
|
585
|
+
const untouched = sanitizeToolPairing(clean)
|
|
586
|
+
ok(untouched.repaired.orphans === 0 && untouched.repaired.synthesized === 0
|
|
587
|
+
&& untouched.repaired.invisible === 0 && untouched.repaired.duplicates === 0
|
|
588
|
+
&& JSON.stringify(untouched.messages) === JSON.stringify(clean), '合法历史逐字节不变')
|
|
589
|
+
|
|
590
|
+
// 网关接线:stub cosy(捕获签名前的**明文** body,避开 WASM 密文)
|
|
591
|
+
const signedBodies = []
|
|
592
|
+
const stubCosy = {
|
|
593
|
+
prepareChat: async (_cred, { body, modelKey }) => {
|
|
594
|
+
signedBodies.push({ body: JSON.parse(body), modelKey })
|
|
595
|
+
return { url: 'http://127.0.0.1:1/unused', headers: {}, body: '{}' }
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
const metered2 = []
|
|
599
|
+
const runtime2 = { running: false, port: null, lastError: null }
|
|
600
|
+
const gw2 = createQoderGateway({
|
|
601
|
+
settings: () => ({ qoderInferBaseURL: 'https://example.invalid', maxConcurrentPerSession: 4, upstreamFirstByteTimeoutMs: 3000 }),
|
|
602
|
+
resolveCredential: async () => ({ authorization: 'Bearer dt-test', machineId: 'a'.repeat(48), uid: 'u-1' }),
|
|
603
|
+
cosy: stubCosy,
|
|
604
|
+
meter: { record: (r) => metered2.push(r) },
|
|
605
|
+
runtime: runtime2,
|
|
606
|
+
forensics: { logPath: () => undefined },
|
|
607
|
+
getCatalogProfiles: () => [],
|
|
608
|
+
getModelSource: () => 'system',
|
|
609
|
+
})
|
|
610
|
+
const stopGw2 = gw2.listen(0)
|
|
611
|
+
for (let i = 0; i < 50 && !runtime2.running; i++) await sleep(50)
|
|
612
|
+
const call = (messages, stream) => fetch(`http://127.0.0.1:${runtime2.port}/v1/chat/completions`, {
|
|
613
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
614
|
+
body: JSON.stringify({ model: 'qmodel', stream, messages }),
|
|
615
|
+
})
|
|
616
|
+
await call([
|
|
617
|
+
{ role: 'user', content: '现在几点?' },
|
|
618
|
+
{ role: 'tool', tool_call_id: 'call_live_1', name: 'get_current_time', content: '2026-09-22 05:00:00 +08:00' },
|
|
619
|
+
], false).then((r) => r.text()) // 上游 stub 不可达 → 500/502,但签名前的明文已捕获
|
|
620
|
+
const seen = signedBodies.at(-1)?.body
|
|
621
|
+
ok(seen?.messages?.[1]?.role === 'assistant' && seen?.messages?.[2]?.role === 'tool',
|
|
622
|
+
'网关出站前插入 assistant 桩(明文可见)', JSON.stringify(seen?.messages?.map((m) => m.role)))
|
|
623
|
+
ok(seen?.messages?.[1]?.content === '', '出站桩 content 为 ""(严格上游不认 null 载体)', JSON.stringify(seen?.messages?.[1]?.content))
|
|
624
|
+
ok(seen?.stream === true && seen?.stream_options?.include_usage === true, '强制流式与 usage 语义未被修复流程破坏')
|
|
625
|
+
ok(seen?.model === 'qmodel', 'model 字段原样')
|
|
626
|
+
|
|
627
|
+
// 宿主真实出站形态(developer 系统提示 + null content 工具轮)经网关后必须双双纠正
|
|
628
|
+
await call([
|
|
629
|
+
{ role: 'developer', content: '你是编码助手' },
|
|
630
|
+
{ role: 'user', content: '现在几点?' },
|
|
631
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_live_2', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
632
|
+
{ role: 'tool', tool_call_id: 'call_live_2', content: 'r' },
|
|
633
|
+
], false).then((r) => r.text())
|
|
634
|
+
const seen2 = signedBodies.at(-1)?.body
|
|
635
|
+
ok(seen2?.messages?.[0]?.role === 'system', 'developer → system 出站重写(明文可见)', JSON.stringify(seen2?.messages?.[0]?.role))
|
|
636
|
+
ok(seen2?.messages?.[2]?.content === '' && seen2?.messages?.[2]?.tool_calls?.[0]?.id === 'call_live_2',
|
|
637
|
+
'null content 工具轮 → 出站补成 "",tool_calls 原样保留', JSON.stringify(seen2?.messages?.[2]))
|
|
638
|
+
ok(JSON.stringify(seen2?.messages?.map((m) => m.role)) === JSON.stringify(['system', 'user', 'assistant', 'tool']),
|
|
639
|
+
'修复不增删消息(该形态本就配对合法)', JSON.stringify(seen2?.messages?.map((m) => m.role)))
|
|
640
|
+
|
|
641
|
+
await stopGw2()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── [15] 目录投影:明文形态 + 过滤 + sources 映射 ───────────────────────────
|
|
645
|
+
console.log('\n[15] 目录投影(fetchQoderCatalog)')
|
|
646
|
+
{
|
|
647
|
+
const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
|
|
648
|
+
const { fetchQoderCatalog, applyQoderContextVariant, qoderReasoningEfforts, qoderEffortTiers } = await import('../providers/qoder/catalog.js')
|
|
649
|
+
const { fileURLToPath } = await import('node:url')
|
|
650
|
+
const wasmPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'providers', 'qoder', 'qoder_auth.wasm')
|
|
651
|
+
const cosy = createCosyRuntime({ wasmPath })
|
|
652
|
+
const catServer = createServer((req, res) => {
|
|
653
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
654
|
+
res.end(JSON.stringify({
|
|
655
|
+
chat: [
|
|
656
|
+
{ key: 'auto', format: 'openai', source: 'system', enable: true, display_name: 'Auto', is_vl: true, max_input_tokens: 180000 },
|
|
657
|
+
{ key: 'qmodel_38max', format: 'openai', source: 'system', enable: true, display_name: 'Qwen3.8-Max', is_vl: true, context_config: { '200K': { token_count: 200000, is_default: true }, '1M': { token_count: 1000000 } }, thinking_config: { disabled: {}, enabled: { efforts: { low: {}, medium: { is_default: true }, xhigh: {} }, is_default: true } } },
|
|
658
|
+
{ key: 'disabled-m', format: 'openai', enable: false },
|
|
659
|
+
{ key: 'other-fmt', format: 'anthropic', enable: true },
|
|
660
|
+
],
|
|
661
|
+
}))
|
|
662
|
+
})
|
|
663
|
+
await new Promise((r) => catServer.listen(0, '127.0.0.1', r))
|
|
664
|
+
const catOrigin = `http://127.0.0.1:${catServer.address().port}`
|
|
665
|
+
const cred = { accessToken: 'dt-test', machineId: 'a'.repeat(48), uid: 'u-1' }
|
|
666
|
+
const result = await fetchQoderCatalog(cosy, cred, catOrigin)
|
|
667
|
+
ok(result.profiles.length === 2, 'enable+format 过滤后 2 条', String(result.profiles.length))
|
|
668
|
+
ok(result.profiles[1].contextWindow === 200000, 'contextWindow 取 context_config 默认档')
|
|
669
|
+
ok(result.profiles[0].contextWindow === 180000, '无 context_config 回落 max_input_tokens')
|
|
670
|
+
ok(result.profiles[0].input.includes('image') && result.profiles[1].input.includes('image'), 'is_vl → input 含 image')
|
|
671
|
+
ok(JSON.stringify(result.profiles[1].reasoningEfforts) === JSON.stringify({ off: null, low: 'low', medium: 'medium', xhigh: 'xhigh' }), 'thinking_config → reasoningEfforts(disabled→off:null,efforts 键照抄档位名)')
|
|
672
|
+
ok(result.profiles[0].reasoningEfforts === undefined, '无 thinking_config → 不出档位表(条目形状不变)')
|
|
673
|
+
ok(qoderReasoningEfforts({ key: 'x', thinking_config: { disabled: {}, enabled: { is_default: true } } }) === null, 'enabled 无命名档位 → null(不摆空档位)')
|
|
674
|
+
ok(qoderReasoningEfforts({ key: 'x' }) === null && qoderReasoningEfforts(null) === null, '无 thinking_config / 空条目 → null')
|
|
675
|
+
ok(qoderReasoningEfforts({ key: 'x', thinking_config: { enabled: { efforts: { high: {}, max: { is_default: true } } } } })?.off === undefined, 'enabled 无 disabled → 无 off 档(不臆造关思考)')
|
|
676
|
+
ok(JSON.stringify(result.efforts.qmodel_38max) === JSON.stringify(['off', 'low', 'medium', 'xhigh']), '展示档位清单(设置卡 select 真源)= 线值表的键')
|
|
677
|
+
ok(Array.isArray(result.efforts.auto) && result.efforts.auto.length === 0, '无 thinking_config → 展示档位空数组(不出控件)')
|
|
678
|
+
ok(JSON.stringify(qoderEffortTiers({ key: 'x', thinking_config: { enabled: { efforts: { light: {}, high: {} } } } })) === JSON.stringify(['light', 'high']), '档位拼写照抄目录(Trae/其他上游专有拼写也不改写)')
|
|
679
|
+
ok(result.sources.qmodel_38max === 'system', 'sources 映射保留')
|
|
680
|
+
ok(JSON.stringify(result.variants.qmodel_38max) === JSON.stringify([
|
|
681
|
+
{ name: '200K', tokenCount: 200000, isDefault: true },
|
|
682
|
+
{ name: '1M', tokenCount: 1000000, isDefault: false },
|
|
683
|
+
]), 'variants 逐模型清单(name/tokenCount/isDefault)')
|
|
684
|
+
ok(Array.isArray(result.variants.auto) && result.variants.auto.length === 0, '无 context_config → variants 空数组')
|
|
685
|
+
ok(applyQoderContextVariant(result.profiles[1], '1M', result.variants.qmodel_38max).contextWindow === 1000000, '选中变体 → contextWindow 覆盖')
|
|
686
|
+
ok(applyQoderContextVariant(result.profiles[1], '404K', result.variants.qmodel_38max).contextWindow === 200000, '变体名不在目录 → 维持默认档')
|
|
687
|
+
ok(applyQoderContextVariant(result.profiles[1], undefined, result.variants.qmodel_38max) === result.profiles[1], '未选变体 → profile 原样(镜像形状不变)')
|
|
688
|
+
catServer.close()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── [16] 出站补默认注入:prefs.effort + profile.maxTokens ───────────────────
|
|
692
|
+
console.log('\n[16] 网关出站注入:思考强度与输出上限只补默认、不覆盖客户端')
|
|
693
|
+
{
|
|
694
|
+
const { createQoderGateway } = await import('../providers/qoder/gateway.js')
|
|
695
|
+
const seen = [] // mock 上游收到的 bodyJson(= prepareChat 的 body 参数)
|
|
696
|
+
const infer = createServer((req, res) => {
|
|
697
|
+
const chunks = []
|
|
698
|
+
req.on('data', (c) => chunks.push(c))
|
|
699
|
+
req.on('end', () => {
|
|
700
|
+
seen.push(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
701
|
+
const env = (body) => `data:${JSON.stringify({ headers: { 'Content-Type': ['application/json'] }, body, statusCodeValue: 200, statusCode: 'OK' })}\n\n`
|
|
702
|
+
const chunk = JSON.stringify({ choices: [{ delta: { content: 'ok' }, index: 0, finish_reason: 'stop' }], created: 1, id: 'c1', model: 'auto', object: 'chat.completion.chunk' })
|
|
703
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
|
704
|
+
res.end(env(chunk) + env('[DONE]'))
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
await new Promise((r) => infer.listen(0, '127.0.0.1', r))
|
|
708
|
+
const inferOrigin = `http://127.0.0.1:${infer.address().port}`
|
|
709
|
+
|
|
710
|
+
// stub cosy:prepareChat 原样透传 body——真 WASM 会加密 body,mock 看不到
|
|
711
|
+
// 明文;这里要断言的正是 prepareChat 收到的 upstream 组装结果。
|
|
712
|
+
const stubCosy = {
|
|
713
|
+
prepareChat: async (_cred, { endpoint, body }) => ({
|
|
714
|
+
url: `${endpoint}/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`,
|
|
715
|
+
headers: {},
|
|
716
|
+
body,
|
|
717
|
+
}),
|
|
718
|
+
}
|
|
719
|
+
let prefs = {}
|
|
720
|
+
const runtime16 = { running: false, port: null, lastError: null }
|
|
721
|
+
const gateway16 = createQoderGateway({
|
|
722
|
+
settings: () => ({ qoderInferBaseURL: inferOrigin, maxConcurrentPerSession: 4, upstreamFirstByteTimeoutMs: 5000 }),
|
|
723
|
+
resolveCredential: async () => ({ authorization: 'Bearer dt-test', machineId: 'a'.repeat(48), uid: 'u-1' }),
|
|
724
|
+
cosy: stubCosy,
|
|
725
|
+
meter: { record: () => {} },
|
|
726
|
+
runtime: runtime16,
|
|
727
|
+
forensics: { logPath: () => undefined },
|
|
728
|
+
getCatalogProfiles: () => [
|
|
729
|
+
{ id: 'auto', name: 'Auto', contextWindow: 180000, maxTokens: 32768, input: ['text', 'image'] },
|
|
730
|
+
{ id: 'm1', name: 'M1', contextWindow: 200000, maxTokens: 8192, input: ['text'] },
|
|
731
|
+
],
|
|
732
|
+
getModelSource: () => 'system',
|
|
733
|
+
getModelPrefs: () => prefs,
|
|
734
|
+
})
|
|
735
|
+
const stopGw16 = gateway16.listen(0)
|
|
736
|
+
for (let i = 0; i < 50 && !runtime16.running; i++) await sleep(50)
|
|
737
|
+
const gw16 = `http://127.0.0.1:${runtime16.port}`
|
|
738
|
+
const chat16 = (payload) => fetch(`${gw16}/v1/chat/completions`, {
|
|
739
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
740
|
+
body: JSON.stringify({ stream: false, messages: [{ role: 'user', content: 'hi' }], ...payload }),
|
|
741
|
+
}).then((r) => r.json())
|
|
742
|
+
const last = () => seen[seen.length - 1]
|
|
743
|
+
|
|
744
|
+
prefs = { m1: { effort: 'high' } }
|
|
745
|
+
await chat16({ model: 'm1' })
|
|
746
|
+
ok(last().reasoning_effort === 'high', 'prefs.effort=high → 上游 body 见 reasoning_effort:"high"')
|
|
747
|
+
ok(last().max_completion_tokens === 8192, '客户端未带 → 补 max_completion_tokens=profile.maxTokens')
|
|
748
|
+
|
|
749
|
+
await chat16({ model: 'm1', reasoning_effort: 'low' })
|
|
750
|
+
ok(last().reasoning_effort === 'low', '客户端带 effort → 尊重客户端不覆盖')
|
|
751
|
+
|
|
752
|
+
prefs = { m1: { effort: 'off' } }
|
|
753
|
+
await chat16({ model: 'm1' })
|
|
754
|
+
ok(!('reasoning_effort' in last()), "effort='off' → 省略参数(不发 'off' 线值)")
|
|
755
|
+
|
|
756
|
+
prefs = {}
|
|
757
|
+
await chat16({ model: 'm1' })
|
|
758
|
+
ok(!('reasoning_effort' in last()), '未设 prefs → 不注入 reasoning_effort')
|
|
759
|
+
ok(last().max_completion_tokens === 8192, '无 prefs 但有目录 profile → 仍补 max_completion_tokens')
|
|
760
|
+
|
|
761
|
+
await chat16({ model: 'auto' })
|
|
762
|
+
ok(last().max_completion_tokens === 32768, 'auto 目录 profile → 补 max_completion_tokens=32768')
|
|
763
|
+
|
|
764
|
+
await chat16({ model: 'm1', max_tokens: 500 })
|
|
765
|
+
ok(last().max_tokens === 500 && !('max_completion_tokens' in last()), '客户端带 max_tokens → 透传且不补 max_completion_tokens')
|
|
766
|
+
|
|
767
|
+
await chat16({ model: 'ghost' })
|
|
768
|
+
ok(!('max_completion_tokens' in last()) && !('reasoning_effort' in last()), '目录外模型 → 双不注入')
|
|
769
|
+
|
|
770
|
+
await stopGw16()
|
|
771
|
+
infer.close()
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── [17] 组合根端到端:prefs 文件层 → 镜像 → GET 契约(DSH_HOME 隔离沙箱)────
|
|
775
|
+
console.log('\n[17] qoderModelSetPrefs 全链路:apply() 起真组合根,mock 目录上游')
|
|
776
|
+
{
|
|
777
|
+
const { readFileSync, existsSync } = await import('node:fs')
|
|
778
|
+
const YAML = (await import('yaml')).default
|
|
779
|
+
const { readJson } = await import('../core/json-store.js')
|
|
780
|
+
|
|
781
|
+
// DSH_HOME 必须在 import('../index.js') 之前落点(路径常量模块加载时固化)
|
|
782
|
+
const dir17 = mkdtempSync(join(tmpdir(), 'qoder-e2e-'))
|
|
783
|
+
process.env.DSH_HOME = dir17
|
|
784
|
+
// 预置已登录凭据(远有效期 → 不触发 refresh)
|
|
785
|
+
writeJson(join(dir17, 'qoder-plugin-auth.json'), {
|
|
786
|
+
auth: { accessToken: 'dt-e2e', refreshToken: 'drt-e2e', expiresAt: Date.now() + 3_600_000, loginMethod: 'browser' },
|
|
787
|
+
machine: { machineId: 'a'.repeat(48) },
|
|
788
|
+
account: { uid: 'u-e2e' },
|
|
789
|
+
})
|
|
790
|
+
const cat = createServer((req, res) => {
|
|
791
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
792
|
+
if (url.pathname.endsWith('/api/v2/model/list')) {
|
|
793
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
794
|
+
res.end(JSON.stringify({
|
|
795
|
+
chat: [
|
|
796
|
+
{ key: 'auto', format: 'openai', source: 'system', enable: true, display_name: 'Auto', is_vl: true, max_input_tokens: 180000 },
|
|
797
|
+
{ key: 'qmodel_38max', format: 'openai', source: 'system', enable: true, display_name: 'Qwen3.8-Max', is_vl: true, context_config: { '200K': { token_count: 200000, is_default: true }, '1M': { token_count: 1000000 } }, thinking_config: { disabled: {}, enabled: { efforts: { low: {}, medium: { is_default: true }, xhigh: {} }, is_default: true } } },
|
|
798
|
+
],
|
|
799
|
+
}))
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
803
|
+
res.end('{}')
|
|
804
|
+
})
|
|
805
|
+
await new Promise((r) => cat.listen(0, '127.0.0.1', r))
|
|
806
|
+
const catOrigin = `http://127.0.0.1:${cat.address().port}`
|
|
807
|
+
|
|
808
|
+
// 空闲端口给翻译网关(qoderBridgePort 必须 ≥1)
|
|
809
|
+
const probe = createServer()
|
|
810
|
+
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
|
|
811
|
+
const gwPort = probe.address().port
|
|
812
|
+
await new Promise((r) => probe.close(r))
|
|
813
|
+
|
|
814
|
+
// 生产形态:启用开关与网关参数走文件层——镜像函数按文件层解析
|
|
815
|
+
// qoderEnabled(Config({...readFileLayer()})),不经 apply 的 entry config
|
|
816
|
+
writeJson(join(dir17, 'codebuddy-plugin.json'), {
|
|
817
|
+
qoderEnabled: true,
|
|
818
|
+
qoderBridgePort: gwPort,
|
|
819
|
+
qoderInferBaseURL: catOrigin,
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
let routeHandler = null
|
|
823
|
+
let disposeFn = null
|
|
824
|
+
const tap = await import('../index.js')
|
|
825
|
+
tap.apply({
|
|
826
|
+
inject: (services, cb) => {
|
|
827
|
+
if (services.includes('webServer')) cb({ webServer: { register: (route) => { routeHandler = route.handler } } })
|
|
828
|
+
// tools/settings 注入不回调:对应同步空转,不影响本块断言面
|
|
829
|
+
},
|
|
830
|
+
on: (event, cb) => { if (event === 'dispose') disposeFn = cb },
|
|
831
|
+
}, {
|
|
832
|
+
bridgeEnabled: false, // 不起 codebuddy 桥
|
|
833
|
+
searchEnabled: false, // 不注册 web 搜索/抓取 provider
|
|
834
|
+
imageGenEnabled: false,
|
|
835
|
+
traeEnabled: false,
|
|
836
|
+
baseURL: catOrigin, // codebuddy 侧启动同步也指向 mock(404 快速失败)
|
|
837
|
+
})
|
|
838
|
+
ok(typeof routeHandler === 'function', 'apply() 注册 /dsh-tap/settings 路由')
|
|
839
|
+
|
|
840
|
+
const ui = createServer((req, res) => routeHandler(req, res))
|
|
841
|
+
await new Promise((r) => ui.listen(0, '127.0.0.1', r))
|
|
842
|
+
const uiOrigin = `http://127.0.0.1:${ui.address().port}`
|
|
843
|
+
const call = async (method, body) => {
|
|
844
|
+
const res = await fetch(`${uiOrigin}/dsh-tap/settings`, {
|
|
845
|
+
method,
|
|
846
|
+
headers: { 'content-type': 'application/json', origin: uiOrigin },
|
|
847
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
848
|
+
})
|
|
849
|
+
return { status: res.status, json: await res.json().catch(() => null) }
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// 等 apply() 里 syncQoderBridge 的目录同步落镜:catalogState 与镜像写在
|
|
853
|
+
// 同一 then 链,但视图 sync 标志先于镜像可见——以镜像文件落盘为准
|
|
854
|
+
const SETTINGS_YAML = join(dir17, 'settings.yaml')
|
|
855
|
+
const FILE_LAYER = join(dir17, 'codebuddy-plugin.json')
|
|
856
|
+
let g = null
|
|
857
|
+
for (let i = 0; i < 100; i++) {
|
|
858
|
+
g = (await call('GET')).json
|
|
859
|
+
if (g?.qoder?.models?.sync && existsSync(SETTINGS_YAML)) break
|
|
860
|
+
await sleep(100)
|
|
861
|
+
}
|
|
862
|
+
ok(g?.qoder?.models?.sync?.count === 2 && existsSync(SETTINGS_YAML), '目录同步并镜像落盘(2 模型)', JSON.stringify(g?.qoder?.models?.sync))
|
|
863
|
+
ok(JSON.stringify(g.qoder.models.variants.qmodel_38max) === JSON.stringify([
|
|
864
|
+
{ name: '200K', tokenCount: 200000, isDefault: true },
|
|
865
|
+
{ name: '1M', tokenCount: 1000000, isDefault: false },
|
|
866
|
+
]), 'GET 契约:qoder.models.variants 逐模型清单')
|
|
867
|
+
ok(Array.isArray(g.qoder.models.variants.auto) && g.qoder.models.variants.auto.length === 0, 'GET 契约:无变体模型 → 空数组')
|
|
868
|
+
ok(JSON.stringify(g.qoder.models.efforts.qmodel_38max) === JSON.stringify(['off', 'low', 'medium', 'xhigh']), 'GET 契约:qoder.models.efforts 逐模型档位(设置卡 select 真源)')
|
|
869
|
+
ok(Array.isArray(g.qoder.models.efforts.auto) && g.qoder.models.efforts.auto.length === 0, 'GET 契约:目录未声明命名档位 → 空数组(不出控件)')
|
|
870
|
+
ok(JSON.stringify(g.qoder.models.modelPrefs) === '{}', 'GET 契约:初始 modelPrefs 为空字典')
|
|
871
|
+
ok(!JSON.stringify(g).includes('dt-e2e'), 'GET 响应脱敏:access token 不进设置视图(审计 [26] 同纪律)')
|
|
872
|
+
|
|
873
|
+
let doc = YAML.parse(readFileSync(SETTINGS_YAML, 'utf8'))
|
|
874
|
+
let block = doc['llm-pi-ai'].providers.qoder
|
|
875
|
+
ok(block.models.length === 2, '镜像块铺 2 模型')
|
|
876
|
+
ok(JSON.stringify(Object.keys(block.models.find((m) => m.id === 'qmodel_38max')).sort()) === JSON.stringify(['contextWindow', 'id', 'input', 'maxTokens', 'name', 'reasoningEfforts']), '镜像条目形状:variants 不进 settings.yaml,档位表进 reasoningEfforts')
|
|
877
|
+
ok(JSON.stringify(block.models.find((m) => m.id === 'qmodel_38max').reasoningEfforts) === JSON.stringify({ off: null, low: 'low', medium: 'medium', xhigh: 'xhigh' }), '镜像带目录声明的档位表(宿主 Model/Effort 选择器数据源)')
|
|
878
|
+
ok(JSON.stringify(block.compat) === JSON.stringify({ thinkingFormat: 'openai', supportsReasoningEffort: true }), '镜像块声明 compat(pi-ai 才会把选中档位写成 reasoning_effort)')
|
|
879
|
+
ok(block.models.find((m) => m.id === 'qmodel_38max').contextWindow === 200000, '未选变体 → contextWindow 维持目录默认档')
|
|
880
|
+
|
|
881
|
+
let r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'xhigh', contextVariant: '1M' } } } })
|
|
882
|
+
ok(r.status === 200 && r.json.ok === true, 'qoderModelSetPrefs → 200 ok(目录声明的档位 xhigh)')
|
|
883
|
+
ok(JSON.stringify(readJson(FILE_LAYER).qoderModelPrefs) === JSON.stringify({ qmodel_38max: { effort: 'xhigh', contextVariant: '1M' } }), '写文件层 qoderModelPrefs(只存已设置键)')
|
|
884
|
+
doc = YAML.parse(readFileSync(SETTINGS_YAML, 'utf8'))
|
|
885
|
+
ok(doc['llm-pi-ai'].providers.qoder.models.find((m) => m.id === 'qmodel_38max').contextWindow === 1000000, '镜像 contextWindow = 选中变体 token_count')
|
|
886
|
+
ok(r.json.qoder.models.modelPrefs.qmodel_38max.contextVariant === '1M', 'POST 响应回带 qoder 区读侧一致')
|
|
887
|
+
|
|
888
|
+
const yamlBefore = readFileSync(SETTINGS_YAML, 'utf8')
|
|
889
|
+
const layerBefore = readFileSync(FILE_LAYER, 'utf8')
|
|
890
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'xhigh', contextVariant: '1M' } } } })
|
|
891
|
+
ok(r.json.ok === true, '重复提交同值 → ok')
|
|
892
|
+
ok(readFileSync(SETTINGS_YAML, 'utf8') === yamlBefore && readFileSync(FILE_LAYER, 'utf8') === layerBefore, '幂等:settings.yaml 与文件层逐字节不变')
|
|
893
|
+
|
|
894
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'off' } } } })
|
|
895
|
+
ok(r.json.ok === true && JSON.stringify(readJson(FILE_LAYER).qoderModelPrefs) === JSON.stringify({ qmodel_38max: { effort: 'off' } }), 'prefs 完整替换:contextVariant 被清掉')
|
|
896
|
+
doc = YAML.parse(readFileSync(SETTINGS_YAML, 'utf8'))
|
|
897
|
+
ok(doc['llm-pi-ai'].providers.qoder.models.find((m) => m.id === 'qmodel_38max').contextWindow === 200000, '变体清掉 → 镜像回落目录默认档')
|
|
898
|
+
|
|
899
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: {} } } })
|
|
900
|
+
ok(r.json.ok === true && JSON.stringify(readJson(FILE_LAYER).qoderModelPrefs ?? {}) === '{}', '空 prefs = 删记录回默认')
|
|
901
|
+
|
|
902
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'ultra' } } } })
|
|
903
|
+
ok(r.status === 400 && /effort 档位必须是该模型目录声明的/.test(r.json?.error ?? ''), '非法档位 → 400 且报错列出该模型自己的档位表')
|
|
904
|
+
ok(/off\/low\/medium\/xhigh/.test(r.json?.error ?? ''), '错误消息里的档位表 = 目录声明(不是固定五档)')
|
|
905
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'max' } } } })
|
|
906
|
+
ok(r.status === 400, '目录未声明的档位(max)新设被拒(存量兼容不放行新值)')
|
|
907
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { contextVariant: '404K' } } } })
|
|
908
|
+
ok(r.status === 400 && /没有名为/.test(r.json?.error ?? ''), '未知变体名 → 400 带原因')
|
|
909
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'ghost', prefs: { effort: 'high' } } } })
|
|
910
|
+
ok(r.status === 400 && /不在 Qoder 目录里/.test(r.json?.error ?? ''), '目录外模型 → 400 带原因')
|
|
911
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'auto', prefs: { contextVariant: '200K' } } } })
|
|
912
|
+
ok(r.status === 400, '无变体模型拒收 contextVariant')
|
|
913
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'auto', prefs: { effort: 'low' } } } })
|
|
914
|
+
ok(r.status === 400 && /未声明思考档位/.test(r.json?.error ?? ''), '目录未声明档位的模型:effort 拒收(不出控件也不收写入)')
|
|
915
|
+
|
|
916
|
+
// R3 存量兼容:旧版本写下的、目录未声明的档位(如 max)不静默丢,且可随全量
|
|
917
|
+
// 期望态重发(只改上下文长度不该被判非法)。
|
|
918
|
+
writeJson(FILE_LAYER, { ...readJson(FILE_LAYER), qoderModelPrefs: { qmodel_38max: { effort: 'max' } } })
|
|
919
|
+
g = (await call('GET')).json
|
|
920
|
+
ok(g.qoder.models.modelPrefs.qmodel_38max?.effort === 'max', 'R3:存量未声明档位读侧保留(不静默丢)')
|
|
921
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'max', contextVariant: '1M' } } } })
|
|
922
|
+
ok(r.status === 200 && r.json.ok === true, 'R3:存量值可随全量期望态重发(不被判非法)')
|
|
923
|
+
r = await call('POST', { patch: { qoderModelSetPrefs: { id: 'qmodel_38max', prefs: { effort: 'medium', contextVariant: '1M' } } } })
|
|
924
|
+
ok(r.status === 200 && readJson(FILE_LAYER).qoderModelPrefs.qmodel_38max.effort === 'medium', 'R3:改成目录声明档位正常(存量值不锁死)')
|
|
925
|
+
|
|
926
|
+
if (disposeFn) disposeFn()
|
|
927
|
+
ui.close()
|
|
928
|
+
cat.close()
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ── [19] 用量归因:出站信封归因字段 + 收尾 business/finish 与 tracking 上报 ──
|
|
932
|
+
// (2026-09-22 逆向:裸 OpenAI body 不落入官方用量统计;官方客户端每轮结束
|
|
933
|
+
// 补两条 COSY 签名上报。证据 docs/probes/qoder-attribution-*.json)
|
|
934
|
+
console.log('\n[19] 用量归因信封与收尾上报(官方客户端同构)')
|
|
935
|
+
{
|
|
936
|
+
const { createQoderGateway } = await import('../providers/qoder/gateway.js')
|
|
937
|
+
const isUuid = (v) => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v)
|
|
938
|
+
const upstreamBodies = []
|
|
939
|
+
const reportCalls = [] // { path, mode, body }
|
|
940
|
+
const infer19 = createServer((req, res) => {
|
|
941
|
+
const chunks = []
|
|
942
|
+
req.on('data', (c) => chunks.push(c))
|
|
943
|
+
req.on('end', () => {
|
|
944
|
+
const path = req.url.split('?')[0]
|
|
945
|
+
if (path.endsWith('/agent_chat_generation')) {
|
|
946
|
+
upstreamBodies.length // no-op
|
|
947
|
+
const env = (body) => `data:${JSON.stringify({ headers: {}, body, statusCodeValue: 200, statusCode: 'OK' })}\n\n`
|
|
948
|
+
const chunk = JSON.stringify({ choices: [{ delta: { content: 'ok' }, index: 0, finish_reason: 'stop' }], created: 1, id: 'c1', model: 'qmodel_38max', object: 'chat.completion.chunk' })
|
|
949
|
+
const usageFrame = JSON.stringify({ choices: [], created: 1, id: 'c1', model: 'qmodel_38max', object: 'chat.completion.chunk', usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, credits: 0.001 } })
|
|
950
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
|
951
|
+
res.end(env(chunk) + env(usageFrame) + env('[DONE]'))
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
955
|
+
res.end('success')
|
|
956
|
+
})
|
|
957
|
+
})
|
|
958
|
+
await new Promise((r) => infer19.listen(0, '127.0.0.1', r))
|
|
959
|
+
const origin19 = `http://127.0.0.1:${infer19.address().port}`
|
|
960
|
+
const stubCosy19 = {
|
|
961
|
+
prepareChat: async (_cred, { endpoint, body }) => {
|
|
962
|
+
upstreamBodies.push(JSON.parse(body)) // 签名前明文
|
|
963
|
+
return { url: `${endpoint}/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`, headers: {}, body }
|
|
964
|
+
},
|
|
965
|
+
prepareSigned: async (_cred, { path, mode, body }) => {
|
|
966
|
+
reportCalls.push({ path, mode, body })
|
|
967
|
+
return { url: `${origin19}${path}`, headers: {}, body }
|
|
968
|
+
},
|
|
969
|
+
}
|
|
970
|
+
const runtime19 = { running: false, port: null, lastError: null }
|
|
971
|
+
const gw19 = createQoderGateway({
|
|
972
|
+
settings: () => ({ qoderInferBaseURL: origin19, maxConcurrentPerSession: 4, upstreamFirstByteTimeoutMs: 5000 }),
|
|
973
|
+
resolveCredential: async () => ({ authorization: 'Bearer dt-test', machineId: 'a'.repeat(48), uid: 'u-1' }),
|
|
974
|
+
cosy: stubCosy19,
|
|
975
|
+
meter: { record: () => {} },
|
|
976
|
+
runtime: runtime19,
|
|
977
|
+
forensics: { logPath: () => undefined },
|
|
978
|
+
getCatalogProfiles: () => [{ id: 'qmodel_38max', name: 'Qwen3.8-Max', contextWindow: 200000, maxTokens: 32768, input: ['text', 'image'] }],
|
|
979
|
+
getCatalogEntry: (id) => (id === 'qmodel_38max' ? { key: 'qmodel_38max', display_name: 'Qwen3.8-Max', is_vl: true, is_reasoning: false, max_input_tokens: 262144, source: 'system' } : null),
|
|
980
|
+
getModelSource: () => 'system',
|
|
981
|
+
})
|
|
982
|
+
const stopGw19 = gw19.listen(0)
|
|
983
|
+
for (let i = 0; i < 50 && !runtime19.running; i++) await sleep(50)
|
|
984
|
+
const call19 = (sid) => fetch(`http://127.0.0.1:${runtime19.port}/v1/chat/completions`, {
|
|
985
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'x-session-id': sid },
|
|
986
|
+
body: JSON.stringify({ model: 'qmodel_38max', stream: true, messages: [{ role: 'user', content: '归因信封测试 prompt' }] }),
|
|
987
|
+
}).then((r) => r.text())
|
|
988
|
+
await call19('sess-a')
|
|
989
|
+
await call19('sess-a')
|
|
990
|
+
await call19('sess-b')
|
|
991
|
+
// 上报是 fire-and-forget,等它们落地
|
|
992
|
+
for (let i = 0; i < 40 && reportCalls.length < 6; i++) await sleep(50)
|
|
993
|
+
|
|
994
|
+
const env1 = upstreamBodies[0]
|
|
995
|
+
ok(isUuid(env1?.session_id) && isUuid(env1?.request_id) && isUuid(env1?.request_set_id), '信封携带 UUID 形态 session_id/request_id/request_set_id')
|
|
996
|
+
ok(env1?.chat_record_id === env1?.request_id, 'chat_record_id = request_id')
|
|
997
|
+
ok(env1?.chat_task === 'FREE_INPUT' && env1?.source === 1 && env1?.version === '3' && env1?.is_reply === true && env1?.is_retry === false, 'chat_task/source:1/version:"3"/is_reply 恒值对齐')
|
|
998
|
+
ok(env1?.agent_id === 'agent_common' && env1?.task_id === 'common' && env1?.session_type === 'qoderclicn', 'agent_id/task_id/session_type 对齐官方 CLI')
|
|
999
|
+
ok(env1?.model_config?.key === 'qmodel_38max' && env1?.model_config?.display_name === 'Qwen3.8-Max' && env1?.model_config?.max_input_tokens === 262144 && env1?.model_config?.is_vl === true, 'model_config 取自目录原始条目')
|
|
1000
|
+
ok(env1?.business?.id === env1?.request_set_id && env1?.business?.stage === 'processing' && env1?.business?.product === 'cli', 'business 块 id=request_set_id、stage=processing')
|
|
1001
|
+
ok(env1?.chat_context?.text === '归因信封测试 prompt', 'chat_context.text = 最后一条 user 文本')
|
|
1002
|
+
ok(upstreamBodies[1]?.session_id === env1.session_id && upstreamBodies[2]?.session_id !== env1.session_id, 'session_id 按 dsh 会话稳定、跨会话区分')
|
|
1003
|
+
|
|
1004
|
+
const finish = reportCalls.filter((c) => c.path.includes('business/finish'))
|
|
1005
|
+
const track = reportCalls.filter((c) => c.path.includes('/api/v1/tracking'))
|
|
1006
|
+
ok(finish.length === 3 && finish.every((c) => c.mode === 'auth'), '每次完成发 business/finish(prepareRequest mode auth)')
|
|
1007
|
+
ok(track.length === 3 && track.every((c) => c.mode === 'sign'), '每次完成发 tracking(mode sign)')
|
|
1008
|
+
const f0 = JSON.parse(JSON.parse(finish[0].body).payload)
|
|
1009
|
+
ok(f0.event_type === 'BUSINESS_FINISH' && f0.event_data.business.id === env1.request_set_id && f0.event_data.session_id === env1.session_id && f0.event_data.business.stage === 'complete', 'BUSINESS_FINISH join key 与信封同源、stage=complete')
|
|
1010
|
+
const t0 = JSON.parse(track[0].body)[0]
|
|
1011
|
+
const item0 = t0?.event_data?.items?.[0] ?? {}
|
|
1012
|
+
ok(t0?.event_type === 'qodercli-back-flow-agent-query-finish' && t0.business_id === env1.request_set_id && item0.total_credits === 0.001 && item0.total_input_tokens === 10 && item0.actual_model === 'qmodel_38max', 'tracking 聚合 usage.credits/tokens,business_id 同源')
|
|
1013
|
+
|
|
1014
|
+
await stopGw19()
|
|
1015
|
+
infer19.close()
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
server.close()
|
|
1019
|
+
console.log(`\n=== verify-qoder-provider: ${pass} 通过 / ${fail} 失败 ===`)
|
|
1020
|
+
process.exit(fail ? 1 : 0)
|