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.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. 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
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * developer-rejection boundary probe (Hermes exploration) — 三轮合并版(2026-08-29)
4
+ * Usage: node scripts/hermes-probe-dev-role.mjs [--round 1|2|3](默认 1)
5
+ *
6
+ * 轮次考古(臂表/判据/落盘文件名/日志格式均保持各轮原样):
7
+ * --round 1 = 原 hermes-probe-dev-role.mjs (C0–C7,落盘 dev-role-boundary-<date>.jsonl)
8
+ * --round 2 = 原 hermes-probe-dev-role2.mjs (P1–P7,落盘 dev-role-boundary2-<date>.jsonl)
9
+ * --round 3 = 原 hermes-probe-dev-role3.mjs (S1–S6,落盘 dev-role-boundary3-<date>.jsonl)
10
+ *
11
+ * ── ROUND 1 (2026-08-19) ─────────────────────────────────────────────
12
+ * Understanding-only continuation of topic 6 (content-moderation.md).
13
+ * Kimi's probe pinned the gate: L1 gateway pre-routing check, literal
14
+ * role:"developer" → HTTP 500 / 11128.
15
+ *
16
+ * This round maps the BOUNDARY of that literal check, to understand what the
17
+ * validator sees and does NOT see. Emphasis: understanding, not bypass.
18
+ * We never use a variant to actually run a harmful/rejected request end-to-end
19
+ * to completion — rejected arms simply confirm the boundary, passing arms
20
+ * are max_tokens=8 stub completions that answer "OK".
21
+ *
22
+ * Arms (all max_tokens=8, 2.5s spacing, one account, read-only intent):
23
+ * case variants : role:"Developer", "DEVELOPER", "developer " (trailing space)
24
+ * unicode variants : fullwidth developer, "dev\u0065loper" (normalized-form test via JSON escape)
25
+ * structural variants: developer as object with extra keys, array-of-roles (role in content), role:"system" containing "developer" text
26
+ * control : role:"system" (should pass 200)
27
+ *
28
+ * The goal: answer — is the gate an exact byte match on the string
29
+ * "developer", or does it normalize (case/unicode/whitespace) before match?
30
+ * This is a *classification* result (which variants are rejected vs passed),
31
+ * NOT a circumvention — findings feed docs/rules content only.
32
+ *
33
+ * ── ROUND 2 ──────────────────────────────────────────────────────────
34
+ * Round 1 established: gate is exact-literal byte match on lowercase "developer".
35
+ * Round 2 maps POSITION and STRUCTURE sensitivity of that literal check:
36
+ *
37
+ * P1 developer as SECOND message (after user) → still rejected? (position)
38
+ * P2 developer LAST message (after user+assistant) → still rejected?
39
+ * P3 mixed: system + developer + user → affects?
40
+ * P4 two developers in different messages → counted individually?
41
+ * P5 role:"developer " + role:"developer" together → does trailing-space one trigger?
42
+ * P6 role as string "developer" but in a nested object (tool message) → does tool path check?
43
+ * P7 control: all system + user → pass
44
+ *
45
+ * All max_tokens=8, 2.5s spacing, understanding-only (no completion of any
46
+ * rejected path; passing arms only answer "OK").
47
+ *
48
+ * ── ROUND 3 ──────────────────────────────────────────────────────────
49
+ * R1: exact-literal match on lowercase "developer" in role field.
50
+ * R2: position-independent; per-message independent check; role field only.
51
+ *
52
+ * R3 maps the *shape* the validator can see — i.e. HOW it enumerates
53
+ * messages. Questions:
54
+ *
55
+ * S1 duplicate role fields in ONE message object → validator reads first? last? any?
56
+ * S2 role as non-string (number) → parse tolerance?
57
+ * S3 role missing entirely (bare content) → does it 400/accept?
58
+ * S4 role:"developer" with content as array → content shape affects the check? (true content block form)
59
+ * S5 multi-content blocks (one developer, one user) → same object, mixed roles?
60
+ * S6 control again
61
+ *
62
+ * All max_tokens=8, 2.5s, understanding-only.
63
+ */
64
+ import { readFileSync, existsSync, appendFileSync } from 'node:fs'
65
+ import { homedir } from 'node:os'
66
+ import { join } from 'node:path'
67
+
68
+ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
69
+ const GATEWAY = 'https://copilot.tencent.com'
70
+ const SPACING_MS = 2500
71
+ const MODEL = 'glm-5.2'
72
+
73
+ function resolveKey() {
74
+ try {
75
+ const cfg = JSON.parse(readFileSync(join(DSH_HOME, 'codebuddy-plugin.json'), 'utf8'))
76
+ const active = (cfg.apiKeys ?? []).find((k) => k.name === cfg.activeApiKey)
77
+ if (active?.key) return active.key
78
+ } catch { /* fall through */ }
79
+ if (process.env.CODEBUDDY_API_KEY) return process.env.CODEBUDDY_API_KEY
80
+ const credFile = join(DSH_HOME, '.credentials.yaml')
81
+ if (existsSync(credFile)) {
82
+ const m = readFileSync(credFile, 'utf8').match(/^\s*CODEBUDDY_API_KEY:\s*["']?([^"'\s]+)["']?\s*$/m)
83
+ if (m) return m[1]
84
+ }
85
+ throw new Error('no CodeBuddy credential found')
86
+ }
87
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
88
+ const redact = (v) => typeof v === 'string' ? v.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<redacted>') : v
89
+ const SYS = 'You are a concise test agent. Answer with exactly: OK'
90
+
91
+ // ─────────────────────────── ROUND 1(原 hermes-probe-dev-role.mjs) ───────────────────────────
92
+
93
+ const redactR1 = (v) => typeof v === 'string'
94
+ ? v.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<redacted>').replace(/\b\d{6,}\b/g, '<redacted>')
95
+ : v
96
+
97
+ // Each variant: a function returning [role, content] or null (skip)
98
+ const ARMS_R1 = [
99
+ { name: 'C0-control-system', role: 'system', note: 'control: system must pass', expectPass: true },
100
+ { name: 'C1-developer-baseline', role: 'developer', note: 'baseline: literal developer must be rejected', expectPass: false },
101
+ { name: 'C2-case-Developer', role: 'Developer', note: 'capital D: case-sensitive?', expectPass: null },
102
+ { name: 'C3-case-DEVELOPER', role: 'DEVELOPER', note: 'all caps', expectPass: null },
103
+ { name: 'C4-trailing-space', role: 'developer ', note: 'trailing space inside role value', expectPass: null },
104
+ { name: 'C5-fullwidth', role: 'developer', note: 'fullwidth unicode homoglyph', expectPass: null },
105
+ { name: 'C6-escaped-e', role: 'dev\u0065loper', note: 'JSON escape \\u0065 = literal e: should equal developer', expectPass: false },
106
+ { name: 'C7-role-in-content', role: 'user', content: `developer message`, note: 'role user but content mentions developer: gate on content or role?', expectPass: true, useContent: true },
107
+ ]
108
+
109
+ async function runRound1() {
110
+ const OUT = join('docs', 'probes', `dev-role-boundary-${new Date().toISOString().slice(0, 10)}.jsonl`)
111
+ const key = resolveKey()
112
+ const results = []
113
+ for (const arm of ARMS_R1) {
114
+ const role = arm.useContent ? 'user' : arm.role
115
+ const content = arm.useContent
116
+ ? (arm.content || `developer: ${SYS}`)
117
+ : SYS
118
+ const messages = [{ role, content }]
119
+ const body = {
120
+ model: MODEL,
121
+ stream: true,
122
+ max_tokens: 8,
123
+ messages,
124
+ }
125
+ try {
126
+ const started = Date.now()
127
+ const resp = await fetch(`${GATEWAY}/v2/chat/completions`, {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Content-Type': 'application/json',
131
+ 'Authorization': `Bearer ${key}`,
132
+ 'User-Agent': 'CLI/unknown CodeBuddy/2.136.0',
133
+ },
134
+ body: JSON.stringify(body),
135
+ })
136
+ const text = await resp.text()
137
+ const ms = Date.now() - started
138
+ let code = null, msg = null
139
+ try { const j = JSON.parse(text); code = j.code; msg = j.msg } catch { /* sse */ }
140
+ const raw = text.slice(0, 200)
141
+ const entry = {
142
+ type: 'probe',
143
+ at: new Date().toISOString(),
144
+ name: arm.name,
145
+ role: role,
146
+ expectPass: arm.expectPass,
147
+ note: arm.note,
148
+ status: resp.status,
149
+ code,
150
+ msg,
151
+ ms,
152
+ raw: redactR1(raw),
153
+ }
154
+ results.push(entry)
155
+ appendFileSync(OUT, JSON.stringify(entry) + '\n')
156
+ console.log(`${arm.name.padEnd(26)} status=${resp.status} code=${code ?? '-'} msg=${(msg ?? '').slice(0, 40)} ${ms}ms`)
157
+ } catch (e) {
158
+ const entry = { type: 'probe', at: new Date().toISOString(), name: arm.name, error: String(e) }
159
+ results.push(entry)
160
+ appendFileSync(OUT, JSON.stringify(entry) + '\n')
161
+ console.log(`${arm.name.padEnd(26)} ERROR ${e}`)
162
+ }
163
+ await sleep(SPACING_MS)
164
+ }
165
+ console.log(`\nDone. ${results.length} arms → ${OUT}`)
166
+ }
167
+
168
+ // ─────────────────────────── ROUND 2(原 hermes-probe-dev-role2.mjs) ───────────────────────────
169
+
170
+ // messages builder per arm
171
+ const ARMS_R2 = [
172
+ { name: 'P7-control', mk: () => [{ role: 'system', content: SYS }, { role: 'user', content: 'hi' }], note: 'control: no developer at all, expect 200' },
173
+ { name: 'P1-dev-second', mk: () => [{ role: 'user', content: 'hi' }, { role: 'developer', content: SYS }, { role: 'user', content: 'ok?' }], note: 'developer in middle position' },
174
+ { name: 'P2-dev-last', mk: () => [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hi' }, { role: 'developer', content: SYS }], note: 'developer as last message' },
175
+ { name: 'P3-sys-dev-user', mk: () => [{ role: 'system', content: SYS }, { role: 'developer', content: SYS }, { role: 'user', content: 'hi' }], note: 'system then developer then user' },
176
+ { name: 'P4-two-dev', mk: () => [{ role: 'developer', content: SYS }, { role: 'developer', content: SYS }, { role: 'user', content: 'hi' }], note: 'two developer messages' },
177
+ { name: 'P5-space-then-dev', mk: () => [{ role: 'developer ', content: SYS }, { role: 'developer', content: SYS }, { role: 'user', content: 'hi' }], note: 'trailing-space dev (passes) then real dev (rejects?)' },
178
+ { name: 'P6-tool-dev', mk: () => [{ role: 'system', content: SYS }, { role: 'user', content: 'hi' }, { role: 'assistant', content: 'call tool', tool_calls: [{ id: 't1', type: 'function', function: { name: 'x', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 't1', content: 'developer deep inside' }], note: 'developer string only inside tool content' },
179
+ ]
180
+
181
+ async function runRound2() {
182
+ const OUT = join('docs', 'probes', `dev-role-boundary2-${new Date().toISOString().slice(0, 10)}.jsonl`)
183
+ const key = resolveKey()
184
+ for (const arm of ARMS_R2) {
185
+ const messages = arm.mk()
186
+ const body = { model: MODEL, stream: true, max_tokens: 8, messages }
187
+ try {
188
+ const started = Date.now()
189
+ const resp = await fetch(`${GATEWAY}/v2/chat/completions`, {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`, 'User-Agent': 'CLI/unknown CodeBuddy/2.136.0' },
192
+ body: JSON.stringify(body),
193
+ })
194
+ const text = await resp.text()
195
+ const ms = Date.now() - started
196
+ let code = null, msg = null
197
+ try { const j = JSON.parse(text); code = j.code; msg = j.msg } catch { /* sse */ }
198
+ const entry = { type: 'probe', at: new Date().toISOString(), name: arm.name, note: arm.note, status: resp.status, code, msg, ms, raw: redact(text.slice(0, 120)) }
199
+ appendFileSync(OUT, JSON.stringify(entry) + '\n')
200
+ console.log(`${arm.name.padEnd(20)} status=${resp.status} code=${code ?? '-'} msg=${(msg ?? '').slice(0, 44)} ${ms}ms`)
201
+ } catch (e) {
202
+ appendFileSync(OUT, JSON.stringify({ type: 'probe', at: new Date().toISOString(), name: arm.name, error: String(e) }) + '\n')
203
+ console.log(`${arm.name.padEnd(20)} ERROR ${e}`)
204
+ }
205
+ await sleep(SPACING_MS)
206
+ }
207
+ console.log(`\nDone → ${OUT}`)
208
+ }
209
+
210
+ // ─────────────────────────── ROUND 3(原 hermes-probe-dev-role3.mjs) ───────────────────────────
211
+
212
+ // raw body per arm (we need raw object control for structural probes)
213
+ const ARMS_R3 = [
214
+ { name: 'S6-control', mk: () => ({ messages: [{ role: 'system', content: SYS }, { role: 'user', content: 'hi' }] }), note: 'control' },
215
+ { name: 'S1-dup-role', mk: () => ({ messages: [{ role: 'developer', role2: 'user', content: SYS }, { role: 'user', content: 'hi' }] }), note: 'two role-ish keys in one object' },
216
+ { name: 'S2-role-number', mk: () => ({ messages: [{ role: 1, content: SYS }, { role: 'user', content: 'hi' }] }), note: 'role as integer' },
217
+ { name: 'S3-no-role', mk: () => ({ messages: [{ content: SYS }, { role: 'user', content: 'hi' }] }), note: 'message without any role field' },
218
+ { name: 'S4-dev-array', mk: () => ({ messages: [{ role: 'developer', content: [{ type: 'text', text: SYS }] }, { role: 'user', content: 'hi' }] }), note: 'developer with content as array of blocks' },
219
+ { name: 'S5-mixed-blocks', mk: () => ({ messages: [{ role: 'user', content: [{ type: 'text', text: SYS }] }], extra: { role: 'developer' } }), note: 'developer only in top-level extra (not messages array)' },
220
+ ]
221
+
222
+ async function runRound3() {
223
+ const OUT = join('docs', 'probes', `dev-role-boundary3-${new Date().toISOString().slice(0, 10)}.jsonl`)
224
+ const key = resolveKey()
225
+ for (const arm of ARMS_R3) {
226
+ const base = arm.mk()
227
+ const body = { model: MODEL, stream: true, max_tokens: 8, ...base }
228
+ try {
229
+ const started = Date.now()
230
+ const resp = await fetch(`${GATEWAY}/v2/chat/completions`, {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`, 'User-Agent': 'CLI/unknown CodeBuddy/2.136.0' },
233
+ body: JSON.stringify(body),
234
+ })
235
+ const text = await resp.text()
236
+ const ms = Date.now() - started
237
+ let code = null, msg = null
238
+ try { const j = JSON.parse(text); code = j.code; msg = j.msg } catch { /* sse */ }
239
+ const entry = { type: 'probe', at: new Date().toISOString(), name: arm.name, note: arm.note, status: resp.status, code, msg, ms, raw: redact(text.slice(0, 120)) }
240
+ appendFileSync(OUT, JSON.stringify(entry) + '\n')
241
+ console.log(`${arm.name.padEnd(18)} status=${resp.status} code=${code ?? '-'} msg=${(msg ?? '').slice(0, 44)} ${ms}ms`)
242
+ } catch (e) {
243
+ appendFileSync(OUT, JSON.stringify({ type: 'probe', at: new Date().toISOString(), name: arm.name, error: String(e) }) + '\n')
244
+ console.log(`${arm.name.padEnd(18)} ERROR ${e}`)
245
+ }
246
+ await sleep(SPACING_MS)
247
+ }
248
+ console.log(`\nDone → ${OUT}`)
249
+ }
250
+
251
+ // ─────────────────────────── 轮次调度 ───────────────────────────
252
+
253
+ function argValue(flag) {
254
+ const i = process.argv.indexOf(flag)
255
+ return i > 0 ? process.argv[i + 1] : null
256
+ }
257
+ const ROUNDS = { 1: runRound1, 2: runRound2, 3: runRound3 }
258
+ const ROUND = argValue('--round') ?? '1'
259
+ if (process.argv.includes('--help') || !ROUNDS[ROUND]) {
260
+ console.log('Usage: node scripts/hermes-probe-dev-role.mjs [--round 1|2|3](默认 1;轮次对应见文件头注释)')
261
+ process.exit(process.argv.includes('--help') ? 0 : 1)
262
+ }
263
+ ROUNDS[ROUND]().catch((e) => { console.error(e); process.exit(1) })
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Latency measurement + error reproduction for 识图 (bridge aggregation) and
4
+ * /agenttool search — item 2 tooling. Repeatable by construction: fixed
5
+ * payloads, fixed iteration count, every iteration appended as JSONL.
6
+ *
7
+ * Paths measured:
8
+ * vision-aggregate : POST {bridge}/v2/chat/completions, stream absent, with
9
+ * image content — the describe-image shape; the caller
10
+ * waits for the FULL upstream stream (bridge aggregates).
11
+ * vision-stream : same payload with stream:true — TTFB/total split
12
+ * separates upstream generation time from bridge work.
13
+ * search : makeSearchProvider().search() — the production
14
+ * /agenttool/v1/search code path (credentials included).
15
+ * search-direct : (mock mode) raw fetch to the gateway, bypassing the
16
+ * provider — isolates provider-side overhead.
17
+ *
18
+ * Modes:
19
+ * --mock (default) mock gateway + the real bridge on ephemeral ports.
20
+ * No credentials needed; quantifies bridge/provider overhead
21
+ * against a controlled upstream delay.
22
+ * --real live gateway: search resolves the key from
23
+ * ~/.dsh/codebuddy-plugin.json; vision goes through the already
24
+ * running dsh bridge (--bridge-port, default 3901).
25
+ *
26
+ * Options: --iters N (20) · --out PATH (/tmp/codebuddy-latency.jsonl)
27
+ * --bridge-port N (3901) · --model ID · --query STR
28
+ * --upstream-delay MS (mock, 300) · --stream-drip MS (mock, 25)
29
+ *
30
+ * Records: {ts, mode, path, iter, ok, ms, ttfbMs?, bytesIn, status, err?}.
31
+ * Summary (min/p50/p95/max per path) is printed and appended to the file.
32
+ */
33
+
34
+ import { createServer } from 'node:http'
35
+ import { appendFileSync, mkdtempSync, readFileSync } from 'node:fs'
36
+ import { homedir, tmpdir } from 'node:os'
37
+ import { join, dirname } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+
40
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
41
+
42
+ // ------------------------------------------------------------------ args
43
+ const args = process.argv.slice(2)
44
+ const opt = (name, dflt) => {
45
+ const i = args.indexOf(`--${name}`)
46
+ return i >= 0 ? args[i + 1] : dflt
47
+ }
48
+ const MODE = args.includes('--real') ? 'real' : 'mock'
49
+ const ITERS = Number(opt('iters', 20))
50
+ const OUT = opt('out', '/tmp/codebuddy-latency.jsonl')
51
+ const BRIDGE_PORT = Number(opt('bridge-port', 3901))
52
+ const UPSTREAM_DELAY = Number(opt('upstream-delay', 300))
53
+ const STREAM_DRIP = Number(opt('stream-drip', 25))
54
+ const QUERY = opt('query', '腾讯 CodeBuddy 是什么')
55
+ const MODEL = opt('model', MODE === 'real' ? 'deepseek-v4-flash' : 'm1')
56
+
57
+ // 1x1 red PNG — valid image, tiny upload.
58
+ const RED_PNG_DATA_URL =
59
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
60
+
61
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
62
+ const records = []
63
+ function record(rec) {
64
+ records.push(rec)
65
+ appendFileSync(OUT, JSON.stringify(rec) + '\n')
66
+ }
67
+
68
+ // ------------------------------------------------------------------ mock gateway
69
+ function startMockGateway() {
70
+ const server = createServer((req, res) => {
71
+ let raw = ''
72
+ req.on('data', (c) => (raw += c))
73
+ req.on('end', () => {
74
+ if (req.url.endsWith('/chat/completions')) {
75
+ setTimeout(() => {
76
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
77
+ const chunks = ['a red ', 'dot on ', 'a white ', 'background.']
78
+ let i = 0
79
+ const drip = () => {
80
+ if (i < chunks.length) {
81
+ res.write(`data: {"id":"mock","choices":[{"index":0,"delta":{"content":${JSON.stringify(chunks[i++])}}}]}\n\n`)
82
+ setTimeout(drip, STREAM_DRIP)
83
+ } else {
84
+ res.end('data: {"id":"mock","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}}\n\ndata: [DONE]\n\n')
85
+ }
86
+ }
87
+ drip()
88
+ }, UPSTREAM_DELAY)
89
+ return
90
+ }
91
+ setTimeout(() => {
92
+ res.writeHead(200, { 'Content-Type': 'application/json' })
93
+ res.end(JSON.stringify({ code: 0, results: [{ url: 'https://example.com', title: 'x', snippet: 'y' }] }))
94
+ }, UPSTREAM_DELAY)
95
+ })
96
+ })
97
+ return new Promise((resolve) => {
98
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }))
99
+ })
100
+ }
101
+
102
+ async function freePort() {
103
+ const probe = createServer()
104
+ await new Promise((r) => probe.listen(0, '127.0.0.1', r))
105
+ const { port } = probe.address()
106
+ await new Promise((r) => probe.close(r))
107
+ return port
108
+ }
109
+
110
+ // ------------------------------------------------------------------ probes
111
+ function visionPayload(stream) {
112
+ return {
113
+ model: MODEL,
114
+ ...(stream ? { stream: true } : {}),
115
+ messages: [
116
+ {
117
+ role: 'user',
118
+ content: [
119
+ { type: 'text', text: 'Describe this image in one short sentence.' },
120
+ { type: 'image_url', image_url: { url: RED_PNG_DATA_URL } },
121
+ ],
122
+ },
123
+ ],
124
+ }
125
+ }
126
+
127
+ async function probeVision(bridgeBase, stream, iter) {
128
+ const t0 = Date.now()
129
+ let ttfbMs = null
130
+ try {
131
+ const res = await fetch(`${bridgeBase}/v2/chat/completions`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify(visionPayload(stream)),
135
+ })
136
+ ttfbMs = Date.now() - t0
137
+ const text = await res.text()
138
+ const ms = Date.now() - t0
139
+ const ok = res.status === 200 && (stream ? text.includes('[DONE]') : text.includes('chat.completion'))
140
+ record({
141
+ ts: t0, mode: MODE, path: stream ? 'vision-stream' : 'vision-aggregate',
142
+ iter, ok, ms, ttfbMs, bytesIn: text.length, status: res.status,
143
+ ...(ok ? {} : { err: text.slice(0, 300) }),
144
+ })
145
+ } catch (err) {
146
+ record({ ts: t0, mode: MODE, path: stream ? 'vision-stream' : 'vision-aggregate', iter, ok: false, ms: Date.now() - t0, err: String(err?.message ?? err) })
147
+ }
148
+ }
149
+
150
+ async function probeSearch(searchFn, iter, path = 'search') {
151
+ const t0 = Date.now()
152
+ try {
153
+ const out = await searchFn()
154
+ record({ ts: t0, mode: MODE, path, iter, ok: true, ms: Date.now() - t0, bytesIn: JSON.stringify(out).length, status: 200 })
155
+ } catch (err) {
156
+ // The error text IS the evidence: keep it verbatim (gateway code/msg or
157
+ // the network-layer cause chain).
158
+ record({ ts: t0, mode: MODE, path, iter, ok: false, ms: Date.now() - t0, err: String(err?.message ?? err) })
159
+ }
160
+ }
161
+
162
+ // ------------------------------------------------------------------ stats
163
+ function summarize(pathFilter) {
164
+ const rows = records.filter((r) => r.path === pathFilter)
165
+ const oks = rows.filter((r) => r.ok).map((r) => r.ms).sort((a, b) => a - b)
166
+ const fails = rows.filter((r) => !r.ok)
167
+ const pct = (p) => (oks.length ? oks[Math.min(oks.length - 1, Math.floor((p / 100) * oks.length))] : null)
168
+ const ttfbs = rows.filter((r) => r.ok && r.ttfbMs != null).map((r) => r.ttfbMs).sort((a, b) => a - b)
169
+ const line = {
170
+ kind: 'summary', mode: MODE, path: pathFilter, n: rows.length, failed: fails.length,
171
+ min: oks[0] ?? null, p50: pct(50), p95: pct(95), max: oks[oks.length - 1] ?? null,
172
+ ttfbP50: ttfbs.length ? ttfbs[Math.floor(ttfbs.length / 2)] : null,
173
+ }
174
+ appendFileSync(OUT, JSON.stringify(line) + '\n')
175
+ console.log(` ${pathFilter.padEnd(18)} n=${String(line.n).padStart(3)} fail=${line.failed} min=${line.min} p50=${line.p50} p95=${line.p95} max=${line.max}${line.ttfbP50 != null ? ` ttfbP50=${line.ttfbP50}` : ''}`)
176
+ for (const f of fails.slice(0, 5)) console.log(` fail#${f.iter} ${f.ms}ms :: ${f.err}`)
177
+ }
178
+
179
+ // ------------------------------------------------------------------ main
180
+ async function main() {
181
+ // Env must be set BEFORE importing index.js — it captures DSH_HOME at
182
+ // module load (verify-bridge.mjs does the same).
183
+ if (MODE === 'mock') {
184
+ process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'dsh-latency-'))
185
+ process.env.CODEBUDDY_API_KEY = 'ck_latency_test_key'
186
+ }
187
+ const { apply, Config, makeSearchProvider } = await import(new URL('../index.js', import.meta.url).href)
188
+ const AUTH_MODE_OVERRIDE = opt('auth-mode', null) // real mode: e.g. --auth-mode api-key
189
+
190
+ if (MODE === 'mock') {
191
+ const { server, port: gwPort } = await startMockGateway()
192
+ const bridgePort = await freePort()
193
+ const ctx = {
194
+ inject: (_deps, cb) => cb({ webServer: { register() {} } }),
195
+ web: { registerSearchProvider: () => () => {}, registerFetchProvider: () => () => {} },
196
+ on: () => {},
197
+ }
198
+ apply(ctx, { baseURL: `http://127.0.0.1:${gwPort}`, bridgePort })
199
+ await sleep(250)
200
+ const bridgeBase = `http://127.0.0.1:${bridgePort}`
201
+ const settings = () => Config({ baseURL: `http://127.0.0.1:${gwPort}` })
202
+ const provider = makeSearchProvider(settings)
203
+
204
+ console.log(`[mock] upstream delay ${UPSTREAM_DELAY}ms + drip ${STREAM_DRIP}ms/chunk, ${ITERS} iters per path`)
205
+ for (let i = 0; i < ITERS; i++) {
206
+ await probeVision(bridgeBase, false, i)
207
+ await probeVision(bridgeBase, true, i)
208
+ await probeSearch(() => provider.search({ query: QUERY }), i)
209
+ await probeSearch(
210
+ () => fetch(`http://127.0.0.1:${gwPort}/agenttool/v1/search`, {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ck_latency_test_key' },
213
+ body: JSON.stringify({ query: QUERY, type: 'text2text', max_results: 5 }),
214
+ }).then(async (r) => {
215
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
216
+ return r.json()
217
+ }),
218
+ i,
219
+ 'search-direct',
220
+ )
221
+ }
222
+ server.close()
223
+ } else {
224
+ // real: vision through the running dsh bridge, search direct to gateway
225
+ const settingsFile = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'codebuddy-plugin.json')
226
+ const settings = () => Config({
227
+ ...JSON.parse(readFileSync(settingsFile, 'utf8')),
228
+ ...(AUTH_MODE_OVERRIDE ? { authMode: AUTH_MODE_OVERRIDE } : {}),
229
+ })
230
+ const provider = makeSearchProvider(settings)
231
+ const bridgeBase = `http://127.0.0.1:${BRIDGE_PORT}`
232
+ console.log(`[real] bridge :${BRIDGE_PORT}, model ${MODEL}, ${ITERS} iters per path`)
233
+ for (let i = 0; i < ITERS; i++) {
234
+ await probeVision(bridgeBase, false, i)
235
+ await probeVision(bridgeBase, true, i)
236
+ await probeSearch(() => provider.search({ query: QUERY }), i)
237
+ }
238
+ }
239
+
240
+ console.log('\nsummary (ms):')
241
+ summarize('vision-aggregate')
242
+ summarize('vision-stream')
243
+ summarize('search')
244
+ if (MODE === 'mock') summarize('search-direct')
245
+ const totalFails = records.filter((r) => !r.ok).length
246
+ console.log(`\n${records.length} records → ${OUT} (${totalFails} failures)`)
247
+ process.exit(0)
248
+ }
249
+
250
+ main().catch((err) => {
251
+ console.error(`measure-latency crashed: ${err.stack ?? err.message}`)
252
+ process.exit(1)
253
+ })