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,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live integration probe for the Qoder CN channel (REAL network, own account).
4
+ *
5
+ * Device-flow facts were extracted from the official CLI bundle
6
+ * (@qodercn-ai/qoderclicn 1.1.57, deobfuscated); this script is the manual
7
+ * calibration step that needs a real login to confirm the live envelopes.
8
+ *
9
+ * node scripts/probe-qoder-live.mjs --login
10
+ * Real browser device flow: prints the authorization URL (open it while
11
+ * logged in to qoder.cn), polls /api/v1/deviceToken/poll (404 = pending)
12
+ * until the token arrives, then pulls userinfo. Tokens never print
13
+ * (masked only). Auth store: ~/.dsh/qoder-plugin-auth.json (DSH_HOME
14
+ * respected) — one login serves both probe and plugin.
15
+ *
16
+ * node scripts/probe-qoder-live.mjs --catalog
17
+ * GET <infer>/api/v2/model/list?Encode=1 with the stored token; dumps raw
18
+ * body to docs/probes/qoder-catalog-raw.bin and tries decodings
19
+ * (identity / base64 / gzip / deflate / brotli / zstd) to establish the
20
+ * Encode=1 format.
21
+ *
22
+ * node scripts/probe-qoder-live.mjs --chat "文本" [--model <key>] [--tools]
23
+ * COSY 签名(providers/qoder/cosy.js)+ 加密 POST 到 infer 节点
24
+ * (gateway.qoder.com.cn 的 agent_chat_generation),拆 SSE 信封聚合正文
25
+ * 并落原始证据到 docs/probes/qoder-chat-live-*.json。
26
+ * (2026-09-20 起:旧的 api2-v2 OpenAI 面裸 Bearer 形态对本账号 401,
27
+ * 已废弃——别再改回去。)
28
+ *
29
+ * node scripts/probe-qoder-live.mjs --userinfo # identity check
30
+ * node scripts/probe-qoder-live.mjs --refresh # force one refresh cycle
31
+ */
32
+
33
+ import { writeFileSync, mkdirSync } from 'node:fs'
34
+ import { homedir } from 'node:os'
35
+ import { join, dirname } from 'node:path'
36
+ import { randomUUID, createHash, randomBytes } from 'node:crypto'
37
+ import { fileURLToPath } from 'node:url'
38
+ import zlib from 'node:zlib'
39
+
40
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
41
+ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
42
+ const AUTH_PATH = join(DSH_HOME, 'qoder-plugin-auth.json')
43
+ const PROBES_DIR = join(ROOT, 'docs', 'probes')
44
+
45
+ const EP = {
46
+ openapi: process.env.QODER_OPENAPI ?? 'https://openapi.qoder.com.cn',
47
+ base: process.env.QODER_BASE ?? 'https://qoder.cn',
48
+ infer: process.env.QODER_INFER ?? 'https://gateway.qoder.com.cn',
49
+ modelServer: process.env.QODER_MODEL_SERVER ?? 'https://api2-v2.qoder.sh',
50
+ }
51
+ const CLIENT_ID_PROD = 'e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb'
52
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000 // bundle 常量:5 分钟
53
+ const POLL_INTERVAL_MS = 1000
54
+
55
+ const { readJson, writeJson } = await import('../core/json-store.js')
56
+
57
+ const mask = (t) => (typeof t === 'string' && t.length > 12 ? `${t.slice(0, 6)}…${t.slice(-4)}(${t.length} 字符)` : '(none)')
58
+ const b64url = (buf) => Buffer.from(buf).toString('base64url')
59
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
60
+ const ensureDir = (p) => mkdirSync(p, { recursive: true })
61
+
62
+ const mkVerifier = () => {
63
+ const n = 43 + Math.floor(86 * Math.random())
64
+ const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
65
+ const bytes = randomBytes(n)
66
+ return [...bytes].map((b) => charset[b % 66]).join('')
67
+ }
68
+ const mkMachineId = () => randomBytes(24).toString('hex') // 48 hex;bundle 机器 id 形态为长 hex 串
69
+
70
+ function normalizeExpiry(v) {
71
+ if (v == null || v === '') return null
72
+ if (typeof v === 'number' || (typeof v === 'string' && /^\d+$/.test(v.trim()))) {
73
+ const n = Number(v)
74
+ if (!Number.isFinite(n) || n <= 0) return null
75
+ if (n > 1e12) return n
76
+ if (n > 1e9) return n * 1000
77
+ return Date.now() + n * 1000
78
+ }
79
+ const p = Date.parse(String(v)) // 实测:expires_at 常为 ISO 字符串
80
+ return Number.isFinite(p) && p > 0 ? p : null
81
+ }
82
+
83
+ function currentToken() {
84
+ const store = readJson(AUTH_PATH)
85
+ const t = store.auth?.accessToken
86
+ if (!t) { console.error('未登录(先跑 --login)'); process.exit(1) }
87
+ return { store, token: t }
88
+ }
89
+
90
+ // ─────────────────────────────────────────────────────────── login
91
+ async function login() {
92
+ const store = readJson(AUTH_PATH)
93
+ if (store.auth?.accessToken) {
94
+ console.log('已有登录态:', store.account ?? '', '令牌至', store.auth.expiresAt ? new Date(store.auth.expiresAt).toISOString() : '(未知)')
95
+ }
96
+ const verifier = mkVerifier()
97
+ const challenge = b64url(createHash('sha256').update(verifier).digest())
98
+ const nonce = randomUUID()
99
+ const machineId = store.machine?.machineId ?? mkMachineId()
100
+
101
+ const authUrl = `${EP.base}/device/selectAccounts?challenge=${challenge}&challenge_method=S256&nonce=${nonce}&machine_id=${machineId}&client_id=${CLIENT_ID_PROD}`
102
+ console.log('\n在浏览器打开并完成授权(5 分钟内有效):\n')
103
+ console.log(authUrl, '\n')
104
+
105
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS
106
+ let pollCount = 0
107
+ while (Date.now() < deadline) {
108
+ pollCount++
109
+ const url = `${EP.openapi}/api/v1/deviceToken/poll?nonce=${nonce}&verifier=${verifier}&challenge_method=S256`
110
+ let res, text
111
+ try {
112
+ res = await fetch(url, { headers: { Accept: 'application/json' } })
113
+ text = await res.text().catch(() => '')
114
+ } catch (err) {
115
+ console.error(`\n轮询网络错误(第 ${pollCount} 次):`, err?.cause?.code ?? err?.message)
116
+ await sleep(POLL_INTERVAL_MS)
117
+ continue
118
+ }
119
+ if (res.status === 404) {
120
+ if (pollCount % 10 === 1) process.stdout.write(`等待授权…(第 ${pollCount} 次 404)\n`)
121
+ await sleep(POLL_INTERVAL_MS)
122
+ continue
123
+ }
124
+ let body = null
125
+ try { body = JSON.parse(text) } catch { /* keep raw */ }
126
+ if (!res.ok) {
127
+ console.error(`\n轮询失败 HTTP ${res.status}:`, text.slice(0, 300))
128
+ if (body && body.errorCode && body.errorCode !== 'NotFound') process.exit(1)
129
+ await sleep(POLL_INTERVAL_MS)
130
+ continue
131
+ }
132
+ if (body?.token) {
133
+ console.log(`\n\n登录成功 ✓(第 ${pollCount} 次轮询)`)
134
+ const auth = {
135
+ accessToken: body.token,
136
+ refreshToken: body.refresh_token ?? null,
137
+ expiresAt: normalizeExpiry(body.expires_at),
138
+ refreshExpiresAt: normalizeExpiry(body.refresh_token_expires_at),
139
+ loginMethod: 'browser',
140
+ }
141
+ console.log('token 响应键:', Object.keys(body).join(', '))
142
+ console.log('accessToken:', mask(auth.accessToken))
143
+ console.log('refreshToken:', mask(auth.refreshToken))
144
+ let account = null
145
+ try {
146
+ const ui = await fetch(`${EP.openapi}/api/v1/userinfo`, { headers: { Accept: 'application/json', Authorization: `Bearer ${auth.accessToken}` } })
147
+ const ub = await ui.json().catch(() => null)
148
+ if (ub) {
149
+ account = { uid: ub.uid ?? ub.user_id ?? ub.id ?? null, name: ub.name ?? ub.username ?? null, email: ub.email ?? null }
150
+ console.log('userinfo:', JSON.stringify(account))
151
+ ensureDir(PROBES_DIR)
152
+ writeFileSync(join(PROBES_DIR, `qoder-userinfo-${Date.now()}.json`), JSON.stringify({ status: ui.status, body: ub }, null, 2))
153
+ }
154
+ } catch (e) { console.log('userinfo 拉取失败:', e?.message) }
155
+ writeJson(AUTH_PATH, { machine: { machineId }, auth, account })
156
+ console.log('已写入', AUTH_PATH)
157
+ process.exit(0)
158
+ }
159
+ if (pollCount % 10 === 1) console.log(`200 无 token(第 ${pollCount} 次):`, text.slice(0, 200))
160
+ await sleep(POLL_INTERVAL_MS)
161
+ }
162
+ console.error('\n超时(5 分钟未完成授权)')
163
+ process.exit(1)
164
+ }
165
+
166
+ // ─────────────────────────────────────────────────────────── refresh
167
+ async function refresh() {
168
+ const { store } = currentToken()
169
+ const rt = store.auth?.refreshToken
170
+ if (!rt) { console.error('无 refreshToken'); process.exit(1) }
171
+ const res = await fetch(`${EP.openapi}/api/v1/deviceToken/refresh`, {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
174
+ body: JSON.stringify({ refresh_token: rt, machine_id: store.machine?.machineId ?? '' }),
175
+ })
176
+ const text = await res.text().catch(() => '')
177
+ console.log('refresh HTTP', res.status)
178
+ console.log(text.slice(0, 800))
179
+ let body = null
180
+ try { body = JSON.parse(text) } catch { /* raw */ }
181
+ if (res.ok && body?.device_token) {
182
+ const auth = {
183
+ accessToken: body.device_token,
184
+ refreshToken: body.refresh_token ?? rt,
185
+ expiresAt: normalizeExpiry(body.expires_at),
186
+ refreshExpiresAt: normalizeExpiry(body.refresh_token_expires_at),
187
+ loginMethod: store.auth.loginMethod ?? 'browser',
188
+ }
189
+ writeJson(AUTH_PATH, { ...store, auth })
190
+ console.log('refresh 成功 ✓ 新 accessToken:', mask(auth.accessToken))
191
+ }
192
+ }
193
+
194
+ // ─────────────────────────────────────────────────────────── userinfo
195
+ async function userinfo() {
196
+ const { token } = currentToken()
197
+ const res = await fetch(`${EP.openapi}/api/v1/userinfo`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } })
198
+ const text = await res.text().catch(() => '')
199
+ console.log('userinfo HTTP', res.status)
200
+ console.log(text.slice(0, 1000))
201
+ ensureDir(PROBES_DIR)
202
+ writeFileSync(join(PROBES_DIR, `qoder-userinfo-${Date.now()}.json`), JSON.stringify({ status: res.status, body: text.slice(0, 20000) }, null, 2))
203
+ }
204
+
205
+ // ─────────────────────────────────────────────────────────── catalog
206
+ async function catalog() {
207
+ const { token } = currentToken()
208
+ const url = `${EP.infer}/api/v2/model/list?Encode=1`
209
+ const res = await fetch(url, { headers: { Accept: '*/*', Authorization: `Bearer ${token}` } })
210
+ const buf = Buffer.from(await res.arrayBuffer())
211
+ console.log('catalog HTTP', res.status, 'bytes', buf.length, 'content-type', res.headers.get('content-type'))
212
+ ensureDir(PROBES_DIR)
213
+ const rawPath = join(PROBES_DIR, 'qoder-catalog-raw.bin')
214
+ writeFileSync(rawPath, buf)
215
+ console.log('原始响应 →', rawPath)
216
+ const head = buf.subarray(0, 64)
217
+ console.log('head hex:', head.toString('hex'))
218
+ console.log('head utf8:', head.toString('utf8').replace(/[^\x20-\x7e]/g, '.'))
219
+
220
+ const attempts = []
221
+ const tryDecode = (name, fn) => {
222
+ try {
223
+ const out = fn()
224
+ attempts.push({ name, ok: true, out, sample: out.subarray(0, 200).toString('utf8').replace(/[^\x20-\x7e\u4e00-\u9fff]/g, '.') })
225
+ } catch (e) { attempts.push({ name, ok: false, err: String(e?.message ?? e).slice(0, 80) }) }
226
+ }
227
+ tryDecode('identity-utf8', () => buf)
228
+ tryDecode('base64-text', () => Buffer.from(buf.toString('utf8').trim(), 'base64'))
229
+ tryDecode('gzip', () => zlib.gunzipSync(buf))
230
+ tryDecode('deflate', () => zlib.inflateSync(buf))
231
+ tryDecode('brotli', () => zlib.brotliDecompressSync(buf))
232
+ if (typeof zlib.zstdDecompressSync === 'function') tryDecode('zstd', () => zlib.zstdDecompressSync(buf))
233
+ for (const a of attempts) {
234
+ console.log(a.ok ? ` [${a.name}] len=${a.out.length} → ${a.sample.slice(0, 140)}` : ` [${a.name}] 失败:${a.err}`)
235
+ }
236
+ const jsonHit = attempts.find((a) => a.ok && /^\s*[[{]/.test(a.sample))
237
+ if (jsonHit) {
238
+ console.log('\n★ 可直接解析的 JSON 形态:', jsonHit.name)
239
+ try {
240
+ const parsed = JSON.parse(jsonHit.out.toString('utf8'))
241
+ writeFileSync(join(PROBES_DIR, 'qoder-catalog-decoded.json'), JSON.stringify(parsed, null, 2))
242
+ const arr = Array.isArray(parsed) ? parsed : (parsed.models ?? parsed.data ?? null)
243
+ console.log('模型条数:', Array.isArray(arr) ? arr.length : '(非数组,见解码文件)')
244
+ if (Array.isArray(arr)) console.log('首条键集:', Object.keys(arr[0] ?? {}).join(', '))
245
+ } catch (e) { console.log('解析失败:', e?.message) }
246
+ }
247
+ }
248
+
249
+ // ─────────────────────────────────────────────────────────── chat
250
+ // ─────────────────────────────────────────────────────────── chat
251
+ // 2026-09-20 打通后的真实形态:OpenAI 面(api2-v2,裸 Bearer)对本账号 401
252
+ // 不可用;可用面 = COSY 签名 + WASM 加密 POST 到 infer 节点(region 发现:
253
+ // gateway.qoder.com.cn)的 /algo/api/v2/service/pro/sse/agent_chat_generation,
254
+ // 响应是 SSE 信封(data:{body:"<OpenAI chunk JSON>"})。签名由插件自带的
255
+ // providers/qoder/cosy.js 完成(手写胶水 + 官方 wasm)。
256
+ async function chat(text, { model, tools, stream }) {
257
+ const { store, token } = currentToken()
258
+ const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
259
+ const cosy = createCosyRuntime({ wasmPath: join(ROOT, 'providers', 'qoder', 'qoder_auth.wasm') })
260
+ const cred = { accessToken: token, machineId: store.machine?.machineId, uid: store.account?.uid }
261
+ const payload = {
262
+ model,
263
+ stream: true, // 上游恒流式;stream=false 只是本地聚合
264
+ stream_options: { include_usage: true },
265
+ messages: [{ role: 'user', content: text }],
266
+ }
267
+ if (tools) {
268
+ payload.tools = [{
269
+ type: 'function',
270
+ function: {
271
+ name: 'get_current_time',
272
+ description: 'Get the current local time for a given timezone.',
273
+ parameters: { type: 'object', properties: { timezone: { type: 'string', description: 'IANA timezone, e.g. Asia/Shanghai' } }, required: ['timezone'] },
274
+ },
275
+ }]
276
+ payload.tool_choice = 'auto'
277
+ }
278
+ const signed = await cosy.prepareChat(cred, {
279
+ endpoint: EP.infer, body: JSON.stringify(payload), modelKey: model, modelSource: 'system',
280
+ })
281
+ const res = await fetch(signed.url, {
282
+ method: 'POST',
283
+ headers: { ...signed.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
284
+ body: signed.body,
285
+ })
286
+ console.log(`chat model=${model} tools=${Boolean(tools)} → HTTP`, res.status, res.headers.get('content-type'), '\n url:', signed.url)
287
+ const hdrs = Object.fromEntries([...res.headers].slice(0, 30))
288
+ const raw = await res.text()
289
+ // 拆信封:data:{body: "<chunk json>"} / body:"[DONE]" / 尾帧计时
290
+ let text2 = ''
291
+ let usage = null
292
+ const toolCalls = []
293
+ for (const line of raw.split('\n')) {
294
+ if (!line.startsWith('data:')) continue
295
+ let frame
296
+ try { frame = JSON.parse(line.slice(5)) } catch { continue }
297
+ if (typeof frame.body !== 'string' || frame.body === '[DONE]') continue
298
+ let chunk
299
+ try { chunk = JSON.parse(frame.body) } catch { continue }
300
+ const d = chunk.choices?.[0]?.delta
301
+ if (d?.content) text2 += d.content
302
+ if (d?.tool_calls) toolCalls.push(...d.tool_calls)
303
+ if (chunk.usage) usage = chunk.usage
304
+ }
305
+ console.log('\n聚合正文:', JSON.stringify(text2.slice(0, 500)))
306
+ if (toolCalls.length) console.log('tool_calls:', JSON.stringify(toolCalls).slice(0, 400))
307
+ if (usage) console.log('usage:', JSON.stringify(usage))
308
+ ensureDir(PROBES_DIR)
309
+ const out = join(PROBES_DIR, `qoder-chat-live-${Date.now()}${stream ? '-stream' : ''}${tools ? '-tools' : ''}.json`)
310
+ writeFileSync(out, JSON.stringify({ request: payload, signedUrl: signed.url, status: res.status, headers: hdrs, body: raw.slice(0, 200_000) }, null, 2))
311
+ console.log('\n证据 →', out)
312
+ }
313
+
314
+ // ─────────────────────────────────────────────────────────── dispatch
315
+ const args = process.argv.slice(2)
316
+ const has = (f) => args.includes(f)
317
+ const val = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined }
318
+
319
+ if (has('--login')) await login()
320
+ else if (has('--refresh')) await refresh()
321
+ else if (has('--userinfo')) await userinfo()
322
+ else if (has('--catalog')) await catalog()
323
+ else if (has('--chat')) {
324
+ const text = val('--chat') ?? '你好'
325
+ const model = val('--model') ?? (() => {
326
+ try {
327
+ const dec = readJson(join(PROBES_DIR, 'qoder-catalog-decoded.json'))
328
+ const arr = Array.isArray(dec) ? dec : (dec.models ?? dec.data ?? [])
329
+ const k = arr?.[0]?.key ?? arr?.[0]?.id
330
+ if (k) { console.log('(未给 --model,采用目录首条:', k, ')'); return k }
331
+ } catch { /* */ }
332
+ return 'auto'
333
+ })()
334
+ await chat(text, { model, tools: has('--tools'), stream: !has('--no-stream') })
335
+ if (has('--tools') && !has('--no-stream')) await chat(text, { model, tools: true, stream: false })
336
+ } else {
337
+ console.log(`用法:
338
+ --login 真实设备流登录(浏览器授权)
339
+ --refresh 强制刷新一次
340
+ --userinfo 账号信息
341
+ --catalog 模型目录(Encode=1 解码尝试)
342
+ --chat "文本" [--model <key>] [--tools] [--no-stream]`)
343
+ process.exit(1)
344
+ }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Qoder CN — 差分矩阵探测(真实账号,只读对话)。
4
+ *
5
+ * 目的:把"上游报错"的触发条件逐变量隔离——同一请求体,只改一个字段,
6
+ * 看上游是成功回文本还是回业务错误信封。用于回答两个悬案:
7
+ * 1) Qwen3.8-Flash(qfmodel)为什么报
8
+ * {"code":"400","message":"[FAIL]node:oa_qwen-plus-main msg:Execution failed: null"}
9
+ * ——而 Qoder 客户端里能用?(变量:reasoning_effort / max_completion_tokens /
10
+ * tools / modelSource / 多轮)
11
+ * 2) "Messages with role 'tool' must be a response to a preceding message with
12
+ * 'tool_calls'" 的触发形态(工具回灌序列)。
13
+ *
14
+ * 用法:
15
+ * node scripts/probe-qoder-matrix.mjs --suite flash # qfmodel 变量矩阵
16
+ * node scripts/probe-qoder-matrix.mjs --suite tools # 工具回灌形态矩阵
17
+ * node scripts/probe-qoder-matrix.mjs --suite all
18
+ * node scripts/probe-qoder-matrix.mjs --suite flash --only baseline,max_tokens
19
+ * 证据落 docs/probes/qoder-matrix-<ts>.json(含请求体明文与 SSE 原始响应)。
20
+ */
21
+
22
+ import { mkdirSync, writeFileSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { dirname, join } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
28
+ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
29
+ const AUTH_PATH = join(DSH_HOME, 'qoder-plugin-auth.json')
30
+ const PROBES_DIR = join(ROOT, 'docs', 'probes')
31
+ const INFER = process.env.QODER_INFER ?? 'https://gateway.qoder.com.cn'
32
+ const GAP_MS = Number(process.env.QODER_GAP_MS ?? 1500)
33
+
34
+ const { readJson } = await import('../core/json-store.js')
35
+ const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
36
+
37
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
38
+
39
+ function cred() {
40
+ const store = readJson(AUTH_PATH)
41
+ const accessToken = store?.auth?.accessToken
42
+ if (!accessToken) { console.error('未登录:先跑 scripts/probe-qoder-live.mjs --login'); process.exit(1) }
43
+ return { cred: { accessToken, machineId: store.machine?.machineId, uid: store.account?.uid }, store }
44
+ }
45
+
46
+ /** SSE 信封 → 结构化结论。 */
47
+ function dissect(raw) {
48
+ const out = { text: '', reasoning: '', toolCalls: [], finish: null, usage: null, errorObjects: [], frames: 0, done: false, eventErrors: [] }
49
+ let lastEvent = null
50
+ for (const line of raw.split('\n')) {
51
+ const t = line.trim()
52
+ if (!t) { lastEvent = null; continue }
53
+ if (t.startsWith('event:')) { lastEvent = t.slice(6).trim(); continue }
54
+ if (!t.startsWith('data:')) continue
55
+ const payload = t.slice(5).trim()
56
+ if (lastEvent === 'error') { out.eventErrors.push(payload.slice(0, 500)); continue }
57
+ let frame
58
+ try { frame = JSON.parse(payload) } catch { continue }
59
+ if (typeof frame?.body !== 'string') continue
60
+ if (frame.body === '[DONE]') { out.done = true; continue }
61
+ out.frames++
62
+ let chunk
63
+ try { chunk = JSON.parse(frame.body) } catch { out.errorObjects.push({ raw: frame.body.slice(0, 300) }); continue }
64
+ if (chunk.code !== undefined || (typeof chunk.message === 'string' && !Array.isArray(chunk.choices))) {
65
+ out.errorObjects.push({ code: chunk.code ?? null, message: chunk.message ?? null, statusCodeValue: frame.statusCodeValue ?? null })
66
+ continue
67
+ }
68
+ const delta = chunk.choices?.[0]?.delta
69
+ if (delta?.content) out.text += delta.content
70
+ if (delta?.reasoning_content) out.reasoning += delta.reasoning_content
71
+ for (const tc of delta?.tool_calls ?? []) out.toolCalls.push(tc)
72
+ if (chunk.choices?.[0]?.finish_reason) out.finish = chunk.choices[0].finish_reason
73
+ if (chunk.usage) out.usage = chunk.usage
74
+ }
75
+ return out
76
+ }
77
+
78
+ const WASM_PATH = join(ROOT, 'providers', 'qoder', 'qoder_auth.wasm')
79
+ const cosy = createCosyRuntime({ wasmPath: WASM_PATH })
80
+
81
+ const evidence = { ts: new Date().toISOString(), infer: INFER, cases: [] }
82
+
83
+ async function chat(label, bodyObj, { modelSource = 'system', modelKey = bodyObj.model, note } = {}) {
84
+ const body = JSON.stringify(bodyObj)
85
+ const signed = await cosy.prepareChat(cred().cred, { endpoint: INFER, body, modelKey, modelSource })
86
+ let status = 0, raw = '', netErr = null
87
+ try {
88
+ const r = await fetch(signed.url, {
89
+ method: 'POST',
90
+ headers: { ...signed.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
91
+ body: signed.body,
92
+ })
93
+ status = r.status
94
+ raw = await r.text()
95
+ } catch (e) { netErr = String(e?.message ?? e) }
96
+ const d = dissect(raw)
97
+ const verdict = d.errorObjects.length || d.eventErrors.length || status !== 200
98
+ ? `ERROR ${d.errorObjects[0]?.message ?? d.eventErrors[0] ?? `HTTP ${status}`}`
99
+ : (d.text.trim() ? `OK "${d.text.trim().slice(0, 60)}"` : (d.toolCalls.length ? `OK tools=${d.toolCalls.length}` : 'OK 空文本'))
100
+ console.log(`\n== ${label}${note ? `(${note})` : ''}\n HTTP ${status}${netErr ? ` netErr=${netErr}` : ''} frames=${d.frames} → ${verdict}`)
101
+ if (d.toolCalls.length) console.log(' tool_calls:', JSON.stringify(d.toolCalls).slice(0, 200))
102
+ if (d.usage) console.log(' usage:', JSON.stringify(d.usage))
103
+ evidence.cases.push({ label, note, modelKey, modelSource, request: bodyObj, status, netErr, dissect: { ...d, text: d.text.slice(0, 2000), reasoning: d.reasoning.slice(0, 2000) }, raw: raw.slice(0, 60_000) })
104
+ await sleep(GAP_MS)
105
+ return d
106
+ }
107
+
108
+ const SUITES = {
109
+ /** qfmodel 变量矩阵:每例只动一个字段。 */
110
+ async flash() {
111
+ const base = { model: 'qfmodel', stream: true, stream_options: { include_usage: true }, messages: [{ role: 'user', content: '只回复两个字:收到' }] }
112
+ await chat('baseline', base, { note: '最小体(对照)' })
113
+ await chat('regression_max', { ...base, model: 'qmodel_38max' }, { note: '同账号回归对照:38max' })
114
+ await chat('effort_medium', { ...base, reasoning_effort: 'medium' })
115
+ await chat('effort_max', { ...base, reasoning_effort: 'max' }, { note: '设置卡里 qfmodel 的现存值' })
116
+ await chat('max_tokens_default', { ...base, max_completion_tokens: 32768 }, { note: '镜像默认输出上限' })
117
+ await chat('max_tokens_2048', { ...base, max_completion_tokens: 2048 })
118
+ await chat('tools_only', { ...base, tools: [{ type: 'function', function: { name: 'get_time', description: '取当前时间', parameters: { type: 'object', properties: { tz: { type: 'string' } }, required: ['tz'] } } }], tool_choice: 'auto' })
119
+ await chat('multi_turn', { ...base, messages: [{ role: 'system', content: '你是助手' }, { role: 'user', content: '我叫小明' }, { role: 'assistant', content: '好的' }, { role: 'user', content: '我叫什么' }] })
120
+ await chat('source_user', base, { modelSource: 'user', note: 'X-Model-Source 变量' })
121
+ await chat('source_unknown', base, { modelSource: 'zzz', note: '未知 source 是否被静默改派' })
122
+ },
123
+
124
+ /** 工具拒绝面是否按模型家族分裂:同一坏体打多个模型。 */
125
+ async reject() {
126
+ const bad = (model) => ({
127
+ model, stream: true, stream_options: { include_usage: true },
128
+ messages: [
129
+ { role: 'user', content: '查询时间' },
130
+ { role: 'tool', tool_call_id: 'call_probe_missing', content: '2026-09-22 05:00:00 +08:00' },
131
+ ],
132
+ })
133
+ for (const m of ['auto', 'qmodel_38max', 'qmodel', 'dmodel', 'gmodel', 'kmodel', 'mmodel']) {
134
+ await chat(`orphan_tool@${m}`, bad(m), { note: '孤儿 tool 消息(无前置 tool_calls)' })
135
+ }
136
+ await chat('mismatch_id@qmodel', {
137
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
138
+ messages: [
139
+ { role: 'user', content: '现在几点?必须调用工具查询' },
140
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_aaa', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"Asia/Shanghai"}' } }] },
141
+ { role: 'tool', tool_call_id: 'call_bbb', content: '2026-09-22 05:00:00 +08:00' },
142
+ ],
143
+ }, { note: 'tool_call_id 与前置 tool_calls.id 不匹配' })
144
+ await chat('partial_results@qmodel', {
145
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
146
+ messages: [
147
+ { role: 'user', content: '现在几点?必须调用工具查询' },
148
+ {
149
+ role: 'assistant', content: null,
150
+ tool_calls: [
151
+ { id: 'call_aaa', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"Asia/Shanghai"}' } },
152
+ { id: 'call_bbb', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"UTC"}' } },
153
+ ],
154
+ },
155
+ { role: 'tool', tool_call_id: 'call_aaa', content: '2026-09-22 05:00:00 +08:00' },
156
+ ],
157
+ }, { note: '双 tool_calls 只回一个结果' })
158
+ },
159
+
160
+ /** 修复策略验证:孤儿 tool / 缺结果的 tool_calls 的替代形态能不能过上游。 */
161
+ async repair() {
162
+ const tools = [{ type: 'function', function: { name: 'get_current_time', description: '查询当前时间', parameters: { type: 'object', properties: { timezone: { type: 'string' } }, required: ['timezone'] } } }]
163
+ const mk = (messages) => ({ model: 'qmodel', stream: true, stream_options: { include_usage: true }, messages, tools, tool_choice: 'auto' })
164
+ const plain = { role: 'user', content: '你好,请只回复两个字:收到' }
165
+
166
+ await chat('drop_orphan_tool', mk([
167
+ plain,
168
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_abc123', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"Asia/Shanghai"}' } }] },
169
+ { role: 'tool', tool_call_id: 'call_abc123', content: '(tool result unavailable: previous attempt was interrupted)' },
170
+ ]), { note: 'R1:孤儿 tool 直接丢弃后,工具调用已从历史消失' })
171
+
172
+ await chat('synthesize_missing_tool_result', mk([
173
+ plain,
174
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_abc123', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"Asia/Shanghai"}' } }] },
175
+ { role: 'tool', tool_call_id: 'call_abc123', content: '(tool result unavailable: previous attempt was interrupted)' },
176
+ { role: 'user', content: '算了,直接说你好' },
177
+ ]), { note: 'R2:合成缺失的 tool 结果,保住 assistant.tool_calls 配对' })
178
+
179
+ await chat('pure_orphan_without_toolcalls', mk([
180
+ plain,
181
+ { role: 'tool', tool_call_id: 'call_zzz', content: '(unavailable)' },
182
+ ]), { note: 'R3:既无 tool_calls 也无助手消息的纯孤儿 tool' })
183
+
184
+ await chat('duplicate_assistant_tool_calls', mk([
185
+ plain,
186
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_abc123', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"Asia/Shanghai"}' } }] },
187
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_abc123', type: 'function', function: { name: 'get_current_time', arguments: '{"timezone":"Asia/Shanghai"}' } }] },
188
+ { role: 'tool', tool_call_id: 'call_abc123', content: '2026-09-22 05:00:00 +08:00' },
189
+ ]), { note: 'R4:重复 assistant(同 id)' })
190
+ },
191
+
192
+ /** 工具回灌形态矩阵:定位 tool 角色报错的触发序列。 */
193
+ async tools() {
194
+ const tools = [{ type: 'function', function: { name: 'get_current_time', description: '查询当前时间', parameters: { type: 'object', properties: { timezone: { type: 'string' } }, required: ['timezone'] } } }]
195
+ const first = await chat('tool_call_gen', {
196
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
197
+ messages: [{ role: 'user', content: '现在几点?必须调用工具查询,时区用 Asia/Shanghai。' }],
198
+ tools, tool_choice: 'auto',
199
+ }, { note: '先让模型产 tool_calls(reasoning 模型 qmodel)' })
200
+
201
+ const tc = first.toolCalls[0]
202
+ const callId = tc?.id ?? 'call_probe_1'
203
+ const name = tc?.function?.name ?? 'get_current_time'
204
+ const args = '{"timezone":"Asia/Shanghai"}'
205
+ console.log(` → 取出 callId=${callId} name=${name} args=${args}`)
206
+
207
+ await chat('tool_result_openai_shape', {
208
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
209
+ messages: [
210
+ { role: 'user', content: '现在几点?必须调用工具查询,时区用 Asia/Shanghai。' },
211
+ { role: 'assistant', content: null, tool_calls: [{ id: callId, type: 'function', function: { name, arguments: args } }] },
212
+ { role: 'tool', tool_call_id: callId, content: '2026-09-22 05:00:00 +08:00' },
213
+ ],
214
+ tools, tool_choice: 'auto',
215
+ }, { note: '标准 OpenAI 工具回灌(content:null)' })
216
+
217
+ await chat('tool_result_assistant_empty_string', {
218
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
219
+ messages: [
220
+ { role: 'user', content: '现在几点?必须调用工具查询,时区用 Asia/Shanghai。' },
221
+ { role: 'assistant', content: '', tool_calls: [{ id: callId, type: 'function', function: { name, arguments: args } }] },
222
+ { role: 'tool', tool_call_id: callId, content: '2026-09-22 05:00:00 +08:00' },
223
+ ],
224
+ tools, tool_choice: 'auto',
225
+ }, { note: 'assistant content:""(pi-ai 非 reasoning 路径形态)' })
226
+
227
+ await chat('tool_result_reasoning_content', {
228
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
229
+ messages: [
230
+ { role: 'user', content: '现在几点?必须调用工具查询,时区用 Asia/Shanghai。' },
231
+ { role: 'assistant', content: null, reasoning_content: '用户想知道现在几点,我需要调用工具。', tool_calls: [{ id: callId, type: 'function', function: { name, arguments: args } }] },
232
+ { role: 'tool', tool_call_id: callId, content: '2026-09-22 05:00:00 +08:00' },
233
+ ],
234
+ tools, tool_choice: 'auto',
235
+ }, { note: 'assistant 带 reasoning_content + tool_calls(pi-ai reasoning 路径形态)' })
236
+
237
+ await chat('tool_result_assistant_text', {
238
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
239
+ messages: [
240
+ { role: 'user', content: '现在几点?必须调用工具查询,时区用 Asia/Shanghai。' },
241
+ { role: 'assistant', content: '我来查一下。', tool_calls: [{ id: callId, type: 'function', function: { name, arguments: args } }] },
242
+ { role: 'tool', tool_call_id: callId, content: '2026-09-22 05:00:00 +08:00' },
243
+ ],
244
+ tools, tool_choice: 'auto',
245
+ }, { note: 'assistant 带正文 + tool_calls' })
246
+
247
+ await chat('tool_no_preceding_assistant', {
248
+ model: 'qmodel', stream: true, stream_options: { include_usage: true },
249
+ messages: [
250
+ { role: 'user', content: '查询时间' },
251
+ { role: 'tool', tool_call_id: callId, content: '2026-09-22 05:00:00 +08:00' },
252
+ ],
253
+ tools, tool_choice: 'auto',
254
+ }, { note: '故意坏体:tool 无前置 tool_calls(阳性对照)' })
255
+ },
256
+ }
257
+
258
+ const args = process.argv.slice(2)
259
+ const val = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined }
260
+ const has = (f) => args.includes(f)
261
+ const suite = val('--suite') ?? 'all'
262
+
263
+ mkdirSync(PROBES_DIR, { recursive: true })
264
+ const names = suite === 'all' ? ['reject', 'flash', 'tools'] : [suite]
265
+
266
+ for (const n of names) {
267
+ if (!SUITES[n]) { console.error(`未知 suite: ${n}(可用:flash / tools / all)`); process.exit(1) }
268
+ console.log(`\n──────── suite: ${n} ────────`)
269
+ await SUITES[n]()
270
+ }
271
+
272
+ const out = join(PROBES_DIR, `qoder-matrix-${Date.now()}.json`)
273
+ writeFileSync(out, JSON.stringify(evidence, null, 2))
274
+ console.log('\n证据 →', out)