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,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline regression for multi-key rotation (api-key mode only): mock gateway
|
|
4
|
+
* keyed by the Authorization bearer token + the real plugin code, asserting
|
|
5
|
+
*
|
|
6
|
+
* A. provider path (callAgentTool via makeSearchProvider):
|
|
7
|
+
* 1. round-robin order across apiKeys (alpha→bravo→charlie→alpha…)
|
|
8
|
+
* 2. 429 on the first-tried key → failover inside the SAME request
|
|
9
|
+
* 3. failed key is skipped while cooling
|
|
10
|
+
* 4. cooled key rejoins the rotation after keyCooldownMs
|
|
11
|
+
* 5. dropped connection (network error) → failover + cooldown
|
|
12
|
+
* 6. all keys failing → last candidate's response surfaced as-is (HTTP 429)
|
|
13
|
+
* 7. single key → no rotation, 401 reported verbatim
|
|
14
|
+
* 8. zero keys → legacy env ref still works
|
|
15
|
+
* 9. oauth mode without a token store → single-candidate path, no rotation
|
|
16
|
+
* B. bridge chat path (real bridge via apply()):
|
|
17
|
+
* rotation order, 500 failover transparent to the caller (still 200 SSE),
|
|
18
|
+
* cooldown skip, rejoin after cooldown.
|
|
19
|
+
*
|
|
20
|
+
* Module state (keyCursor/keyCooldowns) is module-global, so the two parts
|
|
21
|
+
* import index.js as DISTINCT instances (?case=…) to stay independent.
|
|
22
|
+
*
|
|
23
|
+
* Usage: node scripts/verify-rotation.mjs (no network, no credentials)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createServer } from 'node:http'
|
|
27
|
+
import { mkdtempSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { join, dirname } from 'node:path'
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
31
|
+
|
|
32
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
33
|
+
const COOLDOWN_MS = 500
|
|
34
|
+
|
|
35
|
+
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'dsh-rotation-test-'))
|
|
36
|
+
|
|
37
|
+
const KEYS = [
|
|
38
|
+
{ name: 'alpha', key: 'ck_alpha' },
|
|
39
|
+
{ name: 'bravo', key: 'ck_bravo' },
|
|
40
|
+
{ name: 'charlie', key: 'ck_charlie' },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- mock gateway
|
|
44
|
+
|
|
45
|
+
/** token → 'ok' | 'status:NNN' | 'drop' (destroy the socket = network error). */
|
|
46
|
+
const behavior = new Map()
|
|
47
|
+
const arrivals = [] // {path, auth, at} in upstream-arrival order
|
|
48
|
+
const upstream = createServer((req, res) => {
|
|
49
|
+
let raw = ''
|
|
50
|
+
req.on('data', (c) => (raw += c))
|
|
51
|
+
req.on('end', () => {
|
|
52
|
+
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
|
|
53
|
+
arrivals.push({ path: req.url, auth: token, at: Date.now() })
|
|
54
|
+
const b = behavior.get(token) ?? 'ok'
|
|
55
|
+
if (b === 'drop') {
|
|
56
|
+
req.socket.destroy()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (b.startsWith('status:')) {
|
|
60
|
+
const status = Number(b.slice(7))
|
|
61
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
62
|
+
res.end(JSON.stringify({ code: 15000 + status, msg: `mock ${status}` }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (req.url.endsWith('/chat/completions')) {
|
|
66
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
|
67
|
+
res.end([
|
|
68
|
+
'data: {"id":"mock","choices":[{"index":0,"delta":{"content":"hi"}}]}',
|
|
69
|
+
'',
|
|
70
|
+
'data: {"id":"mock","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}',
|
|
71
|
+
'',
|
|
72
|
+
'data: [DONE]',
|
|
73
|
+
'',
|
|
74
|
+
].join('\n'))
|
|
75
|
+
} else {
|
|
76
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
77
|
+
res.end(JSON.stringify({ code: 0, results: [{ url: 'https://example.com' }] }))
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- test harness
|
|
83
|
+
|
|
84
|
+
let failures = 0
|
|
85
|
+
function check(label, cond, detail = '') {
|
|
86
|
+
if (cond) {
|
|
87
|
+
console.log(` ok ${label}`)
|
|
88
|
+
} else {
|
|
89
|
+
failures++
|
|
90
|
+
console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
95
|
+
const auths = () => arrivals.map((a) => a.auth)
|
|
96
|
+
|
|
97
|
+
async function freePort() {
|
|
98
|
+
const probe = createServer()
|
|
99
|
+
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
|
|
100
|
+
const { port } = probe.address()
|
|
101
|
+
await new Promise((r) => probe.close(r))
|
|
102
|
+
return port
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
const upstreamPort = await freePort()
|
|
107
|
+
await new Promise((r) => upstream.listen(upstreamPort, '127.0.0.1', r))
|
|
108
|
+
const baseURL = `http://127.0.0.1:${upstreamPort}`
|
|
109
|
+
|
|
110
|
+
// ============================================================== A. provider path
|
|
111
|
+
console.log('\n[A] callAgentTool rotation (makeSearchProvider → withKeyRotation)')
|
|
112
|
+
const modA = await import(`${pathToFileURL(join(ROOT, 'index.js')).href}?case=provider`)
|
|
113
|
+
// Mutable settings bag: the provider reads it fresh on every call.
|
|
114
|
+
const bag = {
|
|
115
|
+
authMode: 'api-key',
|
|
116
|
+
baseURL,
|
|
117
|
+
apiKeys: KEYS,
|
|
118
|
+
activeApiKey: undefined,
|
|
119
|
+
apiKeyEnv: undefined,
|
|
120
|
+
keyCooldownMs: COOLDOWN_MS,
|
|
121
|
+
}
|
|
122
|
+
const provider = modA.makeSearchProvider(() => bag)
|
|
123
|
+
const search = () => provider.search({ query: 'q' })
|
|
124
|
+
|
|
125
|
+
console.log('\n[A1] round-robin order')
|
|
126
|
+
{
|
|
127
|
+
arrivals.length = 0
|
|
128
|
+
await search()
|
|
129
|
+
await search()
|
|
130
|
+
await search()
|
|
131
|
+
await search()
|
|
132
|
+
check('alpha→bravo→charlie→alpha',
|
|
133
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_alpha', 'ck_bravo', 'ck_charlie', 'ck_alpha']),
|
|
134
|
+
JSON.stringify(auths()))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log('\n[A2] 429 on first-tried key → failover inside the same request')
|
|
138
|
+
{
|
|
139
|
+
behavior.set('ck_bravo', 'status:429')
|
|
140
|
+
arrivals.length = 0
|
|
141
|
+
const data = await search() // cursor at bravo: bravo 429 → charlie takes over
|
|
142
|
+
check('request still succeeds', data?.sources?.length === 1, JSON.stringify(data))
|
|
143
|
+
check('bravo hit once, charlie completed it',
|
|
144
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_bravo', 'ck_charlie']), JSON.stringify(auths()))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('\n[A3] cooling key skipped while others are healthy')
|
|
148
|
+
{
|
|
149
|
+
arrivals.length = 0
|
|
150
|
+
await search() // cursor at charlie; bravo cooling → not even tried
|
|
151
|
+
check('only charlie called', JSON.stringify(auths()) === JSON.stringify(['ck_charlie']), JSON.stringify(auths()))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log('\n[A4] cooled key rejoins after keyCooldownMs')
|
|
155
|
+
{
|
|
156
|
+
behavior.set('ck_bravo', 'ok')
|
|
157
|
+
await sleep(COOLDOWN_MS + 150)
|
|
158
|
+
arrivals.length = 0
|
|
159
|
+
await search()
|
|
160
|
+
await search()
|
|
161
|
+
await search()
|
|
162
|
+
check('full rotation restored incl. bravo',
|
|
163
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_alpha', 'ck_bravo', 'ck_charlie']), JSON.stringify(auths()))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log('\n[A5] dropped connection → failover + cooldown')
|
|
167
|
+
{
|
|
168
|
+
behavior.set('ck_alpha', 'drop')
|
|
169
|
+
arrivals.length = 0
|
|
170
|
+
const data = await search() // cursor at alpha: socket dies → bravo completes
|
|
171
|
+
check('request still succeeds', data?.sources?.length === 1, JSON.stringify(data))
|
|
172
|
+
check('alpha dropped, bravo completed it',
|
|
173
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_alpha', 'ck_bravo']), JSON.stringify(auths()))
|
|
174
|
+
arrivals.length = 0
|
|
175
|
+
await search() // alpha now cooling → skipped
|
|
176
|
+
check('dropped key skipped while cooling',
|
|
177
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_bravo']), JSON.stringify(auths()))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log('\n[A6] every key failing → last candidate surfaced as-is')
|
|
181
|
+
{
|
|
182
|
+
behavior.set('ck_alpha', 'status:429')
|
|
183
|
+
behavior.set('ck_bravo', 'status:429')
|
|
184
|
+
behavior.set('ck_charlie', 'status:429')
|
|
185
|
+
await sleep(COOLDOWN_MS + 150) // clear alpha's cooldown: all three get tried
|
|
186
|
+
arrivals.length = 0
|
|
187
|
+
let err = null
|
|
188
|
+
await search().catch((e) => { err = e })
|
|
189
|
+
check('search rejects with the gateway status', err?.message.includes('HTTP 429') === true, err?.message)
|
|
190
|
+
check('gateway code/msg embedded in the error', err?.message.includes('code 15429') === true, err?.message)
|
|
191
|
+
check('each key tried exactly once',
|
|
192
|
+
arrivals.length === 3 && new Set(auths()).size === 3, JSON.stringify(auths()))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('\n[A7] single key → no rotation, 401 verbatim')
|
|
196
|
+
{
|
|
197
|
+
bag.apiKeys = [KEYS[0]]
|
|
198
|
+
behavior.set('ck_alpha', 'status:401')
|
|
199
|
+
behavior.set('ck_bravo', 'ok')
|
|
200
|
+
behavior.set('ck_charlie', 'ok')
|
|
201
|
+
arrivals.length = 0
|
|
202
|
+
let err = null
|
|
203
|
+
await search().catch((e) => { err = e })
|
|
204
|
+
check('401 reported verbatim', err?.message.includes('HTTP 401') === true, err?.message)
|
|
205
|
+
check('exactly one upstream attempt (no retry, no rotation)',
|
|
206
|
+
arrivals.length === 1 && arrivals[0].auth === 'ck_alpha', JSON.stringify(auths()))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log('\n[A8] zero keys → legacy env ref')
|
|
210
|
+
{
|
|
211
|
+
bag.apiKeys = []
|
|
212
|
+
bag.apiKeyEnv = 'CB_ROT_ENV'
|
|
213
|
+
process.env.CB_ROT_ENV = 'ck_env_fallback'
|
|
214
|
+
arrivals.length = 0
|
|
215
|
+
const data = await search()
|
|
216
|
+
check('request succeeds via env key', data?.sources?.length === 1, JSON.stringify(data))
|
|
217
|
+
check('env key used', JSON.stringify(auths()) === JSON.stringify(['ck_env_fallback']), JSON.stringify(auths()))
|
|
218
|
+
delete process.env.CB_ROT_ENV
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log('\n[A9] oauth mode → single-candidate path, never rotated')
|
|
222
|
+
{
|
|
223
|
+
bag.authMode = 'oauth' // fresh DSH_HOME: no token store → no candidate
|
|
224
|
+
arrivals.length = 0
|
|
225
|
+
let err = null
|
|
226
|
+
await search().catch((e) => { err = e })
|
|
227
|
+
check('no usable credential reported', err?.message.includes('凭据不可用') === true, err?.message)
|
|
228
|
+
check('no upstream attempt at all', arrivals.length === 0, JSON.stringify(auths()))
|
|
229
|
+
bag.authMode = 'api-key'
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ============================================================== B. bridge chat path
|
|
233
|
+
console.log('\n[B] bridge /v2/chat/completions rotation')
|
|
234
|
+
behavior.clear()
|
|
235
|
+
const modB = await import(`${pathToFileURL(join(ROOT, 'index.js')).href}?case=bridge`)
|
|
236
|
+
const bridgePort = await freePort()
|
|
237
|
+
const ctx = {
|
|
238
|
+
inject: (_deps, cb) => cb({ webServer: { register() {} }, tools: { register: () => () => {} } }),
|
|
239
|
+
web: { registerSearchProvider: () => () => {}, registerFetchProvider: () => () => {} },
|
|
240
|
+
on: () => {},
|
|
241
|
+
}
|
|
242
|
+
modB.apply(ctx, {
|
|
243
|
+
baseURL,
|
|
244
|
+
bridgePort,
|
|
245
|
+
authMode: 'api-key',
|
|
246
|
+
apiKeys: KEYS,
|
|
247
|
+
keyCooldownMs: COOLDOWN_MS,
|
|
248
|
+
})
|
|
249
|
+
await sleep(200) // let the bridge bind
|
|
250
|
+
const bridge = `http://127.0.0.1:${bridgePort}`
|
|
251
|
+
const chat = async () => {
|
|
252
|
+
const res = await fetch(`${bridge}/v2/chat/completions`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({ model: 'm1', stream: true, messages: [] }),
|
|
256
|
+
})
|
|
257
|
+
const text = await res.text() // read to END — completion, not first byte
|
|
258
|
+
return { status: res.status, text }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
console.log('\n[B1] round-robin order on the chat path')
|
|
262
|
+
{
|
|
263
|
+
arrivals.length = 0
|
|
264
|
+
await chat()
|
|
265
|
+
await chat()
|
|
266
|
+
await chat()
|
|
267
|
+
check('alpha→bravo→charlie',
|
|
268
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_alpha', 'ck_bravo', 'ck_charlie']), JSON.stringify(auths()))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log('\n[B2] 500 failover is transparent to the caller')
|
|
272
|
+
{
|
|
273
|
+
behavior.set('ck_alpha', 'status:500')
|
|
274
|
+
arrivals.length = 0
|
|
275
|
+
const r = await chat() // cursor at alpha: 500 → cooled → bravo completes
|
|
276
|
+
check('caller still gets 200', r.status === 200, `got ${r.status}`)
|
|
277
|
+
check('caller still gets the SSE stream', r.text.includes('data:') && r.text.includes('[DONE]'))
|
|
278
|
+
check('alpha hit once, bravo completed it',
|
|
279
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_alpha', 'ck_bravo']), JSON.stringify(auths()))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log('\n[B3] cooling key skipped on the bridge too')
|
|
283
|
+
{
|
|
284
|
+
arrivals.length = 0
|
|
285
|
+
const r = await chat()
|
|
286
|
+
check('request succeeds', r.status === 200)
|
|
287
|
+
check('only bravo called', JSON.stringify(auths()) === JSON.stringify(['ck_bravo']), JSON.stringify(auths()))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
console.log('\n[B4] cooled key rejoins after keyCooldownMs')
|
|
291
|
+
{
|
|
292
|
+
behavior.set('ck_alpha', 'ok')
|
|
293
|
+
await sleep(COOLDOWN_MS + 150)
|
|
294
|
+
arrivals.length = 0
|
|
295
|
+
await chat() // cursor at charlie
|
|
296
|
+
await chat() // cursor back at alpha — must succeed again
|
|
297
|
+
check('alpha rejoined the rotation',
|
|
298
|
+
JSON.stringify(auths()) === JSON.stringify(['ck_charlie', 'ck_alpha']), JSON.stringify(auths()))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console.log(failures === 0 ? '\nall rotation checks passed' : `\n${failures} check(s) FAILED`)
|
|
302
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
main().catch((err) => {
|
|
306
|
+
console.error(`verify-rotation crashed: ${err.stack ?? err.message}`)
|
|
307
|
+
process.exit(1)
|
|
308
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline regression for scripts/trae-model-catalog.mjs:
|
|
4
|
+
*
|
|
5
|
+
* 1. temp SQLite fixture with several candidate keys (good/bad/empty/odd)
|
|
6
|
+
* 2. valid JSON parses; candidate discovery + default selection (+ tie-breaks)
|
|
7
|
+
* 3. tolerates bad JSON / empty lists / missing fields / null / array ctx max
|
|
8
|
+
* 4. same model across categories dedups into one entry (categoryProfiles
|
|
9
|
+
* keeps the differing limits)
|
|
10
|
+
* 5. sensitive fields (ak/sk/api_key/refreshToken/…) recursively removed
|
|
11
|
+
* 6. serialized JSON passes the forbidden-key-name scan
|
|
12
|
+
* 7. markdown data-row count == models count
|
|
13
|
+
* 8. discoverStateDbs on a missing root returns [] (no throw); mtime sort
|
|
14
|
+
* 9. scrubSensitive unit (nested arrays/objects, exact-name matching)
|
|
15
|
+
* 10. if a real state.vscdb is discoverable: one guarded end-to-end run
|
|
16
|
+
*
|
|
17
|
+
* Usage: node scripts/verify-trae-model-catalog.mjs (no network; real-DB part
|
|
18
|
+
* only reads, via a temp copy, and writes nothing into the repo)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, existsSync, rmSync } from 'node:fs'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
discoverStateDbs, copySqliteForRead, readModelListCandidates,
|
|
28
|
+
normalizeCatalog, renderMarkdown, selectDefaultCandidate,
|
|
29
|
+
toOpenaiCompatibleId, scrubSensitive, findForbiddenKeys,
|
|
30
|
+
} from './trae-model-catalog.mjs'
|
|
31
|
+
|
|
32
|
+
let failures = 0
|
|
33
|
+
let checks = 0
|
|
34
|
+
function check(label, cond, detail = '') {
|
|
35
|
+
checks++
|
|
36
|
+
if (cond) console.log(` ok ${label}`)
|
|
37
|
+
else { failures++; console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`) }
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// fixture
|
|
43
|
+
|
|
44
|
+
function fixtureModel(overrides = {}) {
|
|
45
|
+
return {
|
|
46
|
+
config_name: 'fixture-model', name: 'fixture-model', display_name: 'Fixture Model',
|
|
47
|
+
provider: '', model_type: 'reasoning_model', multimodal: false,
|
|
48
|
+
prompt_max_tokens: 1000, max_tokens: 4096, max_turn: 100,
|
|
49
|
+
context_window_size: { default: 8000, max: [16000] },
|
|
50
|
+
is_default: false, is_preset: true, selectable: true, status: true, fee_model_level: 1,
|
|
51
|
+
reasoning_effort_options: null, temperature: null, top_p: null, top_k: null,
|
|
52
|
+
thinking_enable: null, tags: [], features: {},
|
|
53
|
+
...overrides,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildFixtureDb(dbPath) {
|
|
58
|
+
const db = new DatabaseSync(dbPath)
|
|
59
|
+
db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)')
|
|
60
|
+
|
|
61
|
+
const good = {
|
|
62
|
+
solo_agent_lite: [
|
|
63
|
+
// glm-5.3 carries credentials at top level; they must be dropped
|
|
64
|
+
fixtureModel({
|
|
65
|
+
config_name: 'glm-5.3', name: 'glm-5.3', display_name: 'GLM-5.3',
|
|
66
|
+
prompt_max_tokens: 936000, max_tokens: 64000, max_turn: 500,
|
|
67
|
+
max_turns: { default: 500, max: 2000 },
|
|
68
|
+
context_window_size: { default: 200000, max: [1000000] },
|
|
69
|
+
reasoning_effort_config: { support_thinking: true, options: ['light', 'high', 'extra_high'], default_level: 'high' },
|
|
70
|
+
ak: 'SECRET-AK-TOP', sk: 'SECRET-SK-TOP',
|
|
71
|
+
}),
|
|
72
|
+
fixtureModel({
|
|
73
|
+
config_name: 'Doubao-Seed-2.1-Pro', name: 'Doubao-Seed-2.1-Pro', display_name: 'Seed-2.1-Pro',
|
|
74
|
+
multimodal: true, context_window_size: { default: 200000, max: 200000 },
|
|
75
|
+
base_url: 'https://fixture.example.internal', hot_info: { x: 1 },
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
solo_work_lite: [
|
|
79
|
+
// same id as solo_agent_lite[0], different limits -> categoryProfiles diff
|
|
80
|
+
fixtureModel({
|
|
81
|
+
config_name: 'glm-5.3', name: 'glm-5.3', display_name: 'GLM-5.3',
|
|
82
|
+
prompt_max_tokens: 168000, max_tokens: 32000, max_turn: 200, max_turns: 200,
|
|
83
|
+
}),
|
|
84
|
+
// old-style entry: no config_name (id from name), sensitive keys nested
|
|
85
|
+
// inside features; missing ctx/max fields entirely
|
|
86
|
+
{
|
|
87
|
+
name: 'kimi-k3', display_name: 'Kimi-K3', model_type: 'chat_model', multimodal: true,
|
|
88
|
+
features: { reasoning: { enable: true }, leak: { api_key: 'SECRET-NESTED-KEY', refreshToken: 'SECRET-NESTED-RT' } },
|
|
89
|
+
},
|
|
90
|
+
// entry without any usable id -> skipped with a warning
|
|
91
|
+
{ display_name: null, provider: 'x' },
|
|
92
|
+
null,
|
|
93
|
+
],
|
|
94
|
+
assistant: [],
|
|
95
|
+
broken_category: 'not-an-array',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rows = [
|
|
99
|
+
['111222333:AI.agent.model.model_list_map', JSON.stringify(good)],
|
|
100
|
+
['444555666:AI.agent.model.model_list_map', JSON.stringify({ solo_agent_lite: [fixtureModel()] })],
|
|
101
|
+
['777888999:AI.agent.model.model_list_map', '{"broken":'], // invalid JSON
|
|
102
|
+
['123123123_AI.agent.model.model_list_map', '{}'], // valid, empty
|
|
103
|
+
['987987987:AI.agent.model.model_list_map', 'null'], // valid JSON, not an object
|
|
104
|
+
]
|
|
105
|
+
const insert = db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)')
|
|
106
|
+
for (const [k, v] of rows) insert.run(k, v)
|
|
107
|
+
db.close()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// run
|
|
112
|
+
|
|
113
|
+
const workDir = mkdtempSync(join(tmpdir(), 'verify-trae-'))
|
|
114
|
+
let catalog = null
|
|
115
|
+
let fixtureCandidates = null
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
console.log('== fixture: parse & selection ==')
|
|
119
|
+
const dbPath = join(workDir, 'state.vscdb')
|
|
120
|
+
buildFixtureDb(dbPath)
|
|
121
|
+
fixtureCandidates = readModelListCandidates(dbPath)
|
|
122
|
+
|
|
123
|
+
check('发现全部 5 个候选 key', fixtureCandidates.length === 5, `got ${fixtureCandidates.length}`)
|
|
124
|
+
const byKey = Object.fromEntries(fixtureCandidates.map((c) => [c.itemKey, c]))
|
|
125
|
+
check('合法 JSON 解析成功', byKey['111222333:AI.agent.model.model_list_map'].valid === true)
|
|
126
|
+
check('坏 JSON 被标记 invalid 且不抛', byKey['777888999:AI.agent.model.model_list_map'].valid === false
|
|
127
|
+
&& byKey['777888999:AI.agent.model.model_list_map'].parseError != null)
|
|
128
|
+
check('空对象候选 entryCount=0', byKey['123123123_AI.agent.model.model_list_map'].entryCount === 0)
|
|
129
|
+
check('JSON null 顶层不产生 entryCount', byKey['987987987:AI.agent.model.model_list_map'].entryCount === 0)
|
|
130
|
+
check('userId 从 key 前缀提取', byKey['111222333:AI.agent.model.model_list_map'].userId === '111222333')
|
|
131
|
+
const best = selectDefaultCandidate(fixtureCandidates)
|
|
132
|
+
check('默认候选=条目最多者(111…,4 条 > 444…,1 条)', best?.itemKey === '111222333:AI.agent.model.model_list_map')
|
|
133
|
+
|
|
134
|
+
// selection tie-breaks (in-memory)
|
|
135
|
+
const tieA = { valid: true, json: { a: [1] }, itemKey: 'b:key', entryCount: 1, valueBytes: 10 }
|
|
136
|
+
const tieB = { valid: true, json: { a: [1] }, itemKey: 'a:key', entryCount: 1, valueBytes: 10 }
|
|
137
|
+
const tieC = { valid: true, json: { a: [1] }, itemKey: 'z:key', entryCount: 1, valueBytes: 99 }
|
|
138
|
+
check('平手规则:value 更长者胜', selectDefaultCandidate([tieA, tieC]) === tieC)
|
|
139
|
+
check('平手规则:key 字典序最小者胜', selectDefaultCandidate([tieA, tieB]) === tieB)
|
|
140
|
+
check('全部 invalid 时返回 null', selectDefaultCandidate([{ valid: false }]) === null)
|
|
141
|
+
|
|
142
|
+
console.log('== normalizeCatalog ==')
|
|
143
|
+
catalog = normalizeCatalog(best, { generatedAt: '2026-08-23T00:00:00.000Z' })
|
|
144
|
+
const jsonText = JSON.stringify(catalog)
|
|
145
|
+
const glm = catalog.models.find((m) => m.id === 'glm-5.3')
|
|
146
|
+
const doubao = catalog.models.find((m) => m.id === 'Doubao-Seed-2.1-Pro')
|
|
147
|
+
const kimi = catalog.models.find((m) => m.id === 'kimi-k3')
|
|
148
|
+
|
|
149
|
+
check('去重后模型数=3(glm 跨两分类合并)', catalog.models.length === 3, JSON.stringify(catalog.models.map((m) => m.id)))
|
|
150
|
+
check('glm.categories 记录两个来源', glm?.categories.length === 2 && glm.categories[0] === 'solo_agent_lite' && glm.categories[1] === 'solo_work_lite')
|
|
151
|
+
check('首个出现者限制为准(promptMaxTokens=936000)', glm?.promptMaxTokens === 936000)
|
|
152
|
+
check('maxTurns 取 max_turns.max(2000)而非 max_turn(500)', glm?.maxTurns === 2000)
|
|
153
|
+
check('categoryProfiles 保留差异字段', glm?.categoryProfiles.solo_work_lite?.promptMaxTokens === 168000
|
|
154
|
+
&& glm?.categoryProfiles.solo_work_lite?.maxOutputTokens === 32000
|
|
155
|
+
&& glm?.categoryProfiles.solo_work_lite?.maxTurns === 200
|
|
156
|
+
&& Object.keys(glm.categoryProfiles.solo_agent_lite).length === 0)
|
|
157
|
+
check('数组型 contextWindowMax 保留', Array.isArray(glm?.contextWindowMax) && glm.contextWindowMax[0] === 1000000)
|
|
158
|
+
check('数值型 contextWindowMax 保留', doubao?.contextWindowMax === 200000)
|
|
159
|
+
check('openaiCompatibleId 点号转连字符(glm-5.3 -> glm-5-3)', glm?.openaiCompatibleId === 'glm-5-3'
|
|
160
|
+
&& toOpenaiCompatibleId('deepseek//deepseek-v4-pro') === 'deepseek--deepseek-v4-pro')
|
|
161
|
+
check('multimodal=false -> input=[text]', glm?.input.length === 1 && glm.input[0] === 'text')
|
|
162
|
+
check('multimodal=true -> input=[text,image]', doubao?.input.length === 2 && doubao.input[1] === 'image')
|
|
163
|
+
check('缺 config_name 时 id 回落 name', kimi?.id === 'kimi-k3' && kimi?.configName === null)
|
|
164
|
+
check('缺失字段归一为 null 而非崩溃', kimi?.promptMaxTokens === null && kimi?.contextWindowDefault === null && kimi?.maxTurns === null)
|
|
165
|
+
check('functions 按分类列出模型', catalog.functions.solo_agent_lite?.length === 2
|
|
166
|
+
&& catalog.functions.solo_work_lite?.length === 2
|
|
167
|
+
&& Array.isArray(catalog.functions.assistant) && catalog.functions.assistant.length === 0)
|
|
168
|
+
check('空 provider 归一为 null', glm?.provider === null)
|
|
169
|
+
check('reasoning_effort_config 归一为 reasoningEffortConfig',
|
|
170
|
+
glm?.reasoningEffortConfig?.supportThinking === true
|
|
171
|
+
&& JSON.stringify(glm.reasoningEffortConfig.options) === '["light","high","extra_high"]'
|
|
172
|
+
&& glm.reasoningEffortConfig.defaultLevel === 'high')
|
|
173
|
+
check('无 reasoning_effort_config 的条目归一为 null', kimi?.reasoningEffortConfig === null)
|
|
174
|
+
// 空对象 config({})归一为 null——否则"首个非 null wins"合并规则会让空
|
|
175
|
+
// config 遮蔽后续分类的真配置(终审 Minor)。
|
|
176
|
+
const shadowCat = normalizeCatalog({
|
|
177
|
+
valid: true, itemKey: 'k:AI.agent.model.model_list_map', userId: 'k', dbPath: 'p',
|
|
178
|
+
json: {
|
|
179
|
+
catA: [fixtureModel({ config_name: 'm1', reasoning_effort_config: {} })],
|
|
180
|
+
catB: [fixtureModel({ config_name: 'm1', reasoning_effort_config: { support_thinking: true, options: ['high'], default_level: 'high' } })],
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
check('空 reasoning_effort_config 不遮蔽后续分类的真配置', shadowCat.models[0]?.reasoningEffortConfig?.options?.[0] === 'high', JSON.stringify(shadowCat.models[0]?.reasoningEffortConfig))
|
|
184
|
+
|
|
185
|
+
const warnText = catalog.warnings.join('\n')
|
|
186
|
+
check('reasoning_effort_config 进白名单(不再上 warnings)', !warnText.includes('reasoning_effort_config'))
|
|
187
|
+
check('warnings 记录非数组分类', warnText.includes('broken_category'))
|
|
188
|
+
check('warnings 记录无 id 条目跳过', warnText.includes('无法确定 id'))
|
|
189
|
+
check('warnings 记录敏感字段移除(仅字段名)', warnText.includes('ak') && warnText.includes('sk'))
|
|
190
|
+
check('warnings 记录忽略的非白名单字段名(仅名字,不含值)',
|
|
191
|
+
warnText.includes('已忽略非白名单字段:base_url') && warnText.includes('已忽略非白名单字段:hot_info')
|
|
192
|
+
&& !warnText.includes('fixture.example.internal'))
|
|
193
|
+
check('忽略字段的原值不进输出', !jsonText.includes('fixture.example.internal'))
|
|
194
|
+
|
|
195
|
+
console.log('== 敏感字段递归清除 ==')
|
|
196
|
+
check('输出不含顶层 ak/sk 值', !jsonText.includes('SECRET-AK-TOP') && !jsonText.includes('SECRET-SK-TOP'))
|
|
197
|
+
check('输出不含 features 内嵌 api_key/refreshToken 值',
|
|
198
|
+
!jsonText.includes('SECRET-NESTED-KEY') && !jsonText.includes('SECRET-NESTED-RT'))
|
|
199
|
+
check('findForbiddenKeys 对成品目录零命中', findForbiddenKeys(catalog).length === 0)
|
|
200
|
+
check('输出不含明文 userId', !jsonText.includes('111222333'))
|
|
201
|
+
check('输出不含完整 itemKey', !jsonText.includes('AI.agent.model.model_list_map'))
|
|
202
|
+
|
|
203
|
+
const nested = { models: [{ temperature: 1, Authorization: 'x', nested: [{ sessionToken: 'y', encrypted_model_params: 'z', keep: 1 }] }] }
|
|
204
|
+
const scrubbed = scrubSensitive(nested)
|
|
205
|
+
check('scrubSensitive 递归清除断言', findForbiddenKeys(scrubbed).length === 0 && scrubbed.models[0].nested[0].keep === 1)
|
|
206
|
+
check('精确名匹配不误伤 promptMaxTokens 类字段', !findForbiddenKeys({ promptMaxTokens: 1, maxOutputTokens: 2, tags: ['token-free'] }).length)
|
|
207
|
+
|
|
208
|
+
console.log('== markdown ==')
|
|
209
|
+
const md = renderMarkdown(catalog)
|
|
210
|
+
const dataRows = md.split('\n').filter((l) => l.startsWith('|')).length - 2 // header + separator
|
|
211
|
+
check('Markdown 数据行数 == models 数', dataRows === catalog.models.length, `${dataRows} vs ${catalog.models.length}`)
|
|
212
|
+
check('Markdown 表头列齐全', ['id', 'openaiCompatibleId', 'displayName', 'multimodal', 'contextWindowMax', 'categories']
|
|
213
|
+
.every((col) => md.includes(` ${col} `) || md.includes(` ${col} |`)))
|
|
214
|
+
check('Markdown 按 displayName 排序', md.indexOf('GLM-5.3') < md.indexOf('Kimi-K3') && md.indexOf('Kimi-K3') < md.indexOf('Seed-2.1-Pro'))
|
|
215
|
+
check('Markdown 不含敏感值', !md.includes('SECRET-'))
|
|
216
|
+
|
|
217
|
+
console.log('== discovery & copy ==')
|
|
218
|
+
check('不存在的根目录返回空数组且不抛', discoverStateDbs(join(workDir, 'no-such-root')).length === 0)
|
|
219
|
+
const fakeRoot = join(workDir, 'users')
|
|
220
|
+
const fakeDbA = join(fakeRoot, 'alice', 'AppData', 'Roaming', 'TRAE SOLO CN', 'User', 'globalStorage', 'state.vscdb')
|
|
221
|
+
const fakeDbB = join(fakeRoot, 'bob', 'AppData', 'Roaming', 'TRAE SOLO CN', 'User', 'globalStorage', 'state.vscdb')
|
|
222
|
+
for (const p of [fakeDbA, fakeDbB]) { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, 'x') }
|
|
223
|
+
utimesSync(fakeDbA, new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'))
|
|
224
|
+
utimesSync(fakeDbB, new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z'))
|
|
225
|
+
const discovered = discoverStateDbs(fakeRoot)
|
|
226
|
+
check('发现伪数据库且按 mtime 降序', discovered.length === 2 && discovered[0].dbPath === fakeDbB)
|
|
227
|
+
|
|
228
|
+
console.log('== 平台分路(win32 无参发现直查 homedir)==')
|
|
229
|
+
// win32 下无 /mnt/c:无参调用必须直查 <home>/AppData/Roaming/<产品>/…(显式
|
|
230
|
+
// usersRoot 的逐用户扫描行为不受平台影响)。
|
|
231
|
+
const winHome = join(workDir, 'win-home')
|
|
232
|
+
const winDb = join(winHome, 'AppData', 'Roaming', 'TRAE SOLO CN', 'User', 'globalStorage', 'state.vscdb')
|
|
233
|
+
mkdirSync(join(winDb, '..'), { recursive: true })
|
|
234
|
+
writeFileSync(winDb, 'x')
|
|
235
|
+
const winFound = discoverStateDbs(undefined, { platform: 'win32', home: winHome })
|
|
236
|
+
check('win32 无参发现命中 homedir 产品目录', winFound.length === 1 && winFound[0].dbPath === winDb, JSON.stringify(winFound))
|
|
237
|
+
check('win32 home 下无库返回空数组且不抛', discoverStateDbs(undefined, { platform: 'win32', home: join(workDir, 'no-such-home') }).length === 0)
|
|
238
|
+
check('win32 显式 usersRoot 仍按逐用户扫描', discoverStateDbs(fakeRoot, { platform: 'win32', home: winHome }).length === 2)
|
|
239
|
+
|
|
240
|
+
const copyDir = join(workDir, 'copy')
|
|
241
|
+
const copied = copySqliteForRead(dbPath, copyDir)
|
|
242
|
+
writeFileSync(dbPath + '-wal', '') // 0-byte wal is a valid empty wal
|
|
243
|
+
const copied2 = copySqliteForRead(dbPath, join(workDir, 'copy2'))
|
|
244
|
+
const reopened = readModelListCandidates(copied2)
|
|
245
|
+
check('副本可读且结果一致(含 -wal 复制)', reopened.length === 5 && existsSync(copied2 + '-wal') && existsSync(copied) && !existsSync(copied + '-wal'))
|
|
246
|
+
|
|
247
|
+
console.log('== 真实数据库(若存在)==')
|
|
248
|
+
const realDbs = discoverStateDbs()
|
|
249
|
+
if (!realDbs.length) {
|
|
250
|
+
console.log(' skip 本机未发现 state.vscdb,跳过端到端真实提取')
|
|
251
|
+
} else {
|
|
252
|
+
const realDb = realDbs[0].dbPath
|
|
253
|
+
const realTmp = mkdtempSync(join(tmpdir(), 'trae-catalog-real-'))
|
|
254
|
+
try {
|
|
255
|
+
const realCopy = copySqliteForRead(realDb, realTmp)
|
|
256
|
+
const realCandidates = readModelListCandidates(realCopy)
|
|
257
|
+
for (const c of realCandidates) c.dbPath = realDb
|
|
258
|
+
check(`真实库发现候选 key(${realCandidates.length} 个)`, realCandidates.length >= 1)
|
|
259
|
+
const realBest = selectDefaultCandidate(realCandidates)
|
|
260
|
+
const realCatalog = normalizeCatalog(realBest, { generatedAt: '2026-08-23T00:00:00.000Z' })
|
|
261
|
+
check('真实库去重后模型数 >= 1', realCatalog.models.length >= 1)
|
|
262
|
+
const realJson = JSON.stringify(realCatalog)
|
|
263
|
+
check('真实库输出零敏感键命中', findForbiddenKeys(realCatalog).length === 0)
|
|
264
|
+
check('真实库输出不含明文 userId', !realCandidates.some((c) => c.userId && realJson.includes(c.userId)))
|
|
265
|
+
check('真实库输出不含完整 itemKey', !realCandidates.some((c) => realJson.includes(c.itemKey)))
|
|
266
|
+
const realMd = renderMarkdown(realCatalog)
|
|
267
|
+
const realRows = realMd.split('\n').filter((l) => l.startsWith('|')).length - 2
|
|
268
|
+
check('真实库 Markdown 行数一致', realRows === realCatalog.models.length)
|
|
269
|
+
console.log(` info 真实提取:${realCandidates.length} 候选,选用 ${realCatalog.source.itemKeyFingerprint},模型 ${realCatalog.models.length} 个,分类 ${Object.keys(realCatalog.functions).join(', ')}`)
|
|
270
|
+
console.log(` info 忽略字段(仅名字):${[...new Set(realCatalog.warnings)].slice(0, 40).join(';')}`)
|
|
271
|
+
} finally {
|
|
272
|
+
rmSync(realTmp, { recursive: true, force: true })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
rmSync(workDir, { recursive: true, force: true })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log('')
|
|
280
|
+
if (failures) {
|
|
281
|
+
console.log(`verify:trae FAILED — ${failures}/${checks} 项断言未通过`)
|
|
282
|
+
process.exit(1)
|
|
283
|
+
}
|
|
284
|
+
console.log(`verify:trae OK — ${checks} 项断言全部通过`)
|