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,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)
|