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,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Qoder CN — assistant.content 形态单变量差分(provider_error 真根因定位)
|
|
4
|
+
*
|
|
5
|
+
* 背景:踩坑 #39 把报错归因到"孤儿 tool 消息",但网关侧 sanitizeToolPairing
|
|
6
|
+
* 上线后同一报错仍在。本探针只动一个变量——assistant 消息的 content 取值
|
|
7
|
+
* (null / "" / 文本 / 缺键),其余逐字段相同,打真实上游逐模型家族对比。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node scripts/probe-qoder-null-content.mjs # 默认 dmodel
|
|
11
|
+
* node scripts/probe-qoder-null-content.mjs --models dmodel,kmodel,mmodel,qmodel,auto
|
|
12
|
+
* node scripts/probe-qoder-null-content.mjs --gw # 叠加"经生产网关"对照
|
|
13
|
+
* 证据落 docs/probes/qoder-null-content-<ts>.json
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
22
|
+
const AUTH_PATH = join(DSH_HOME, 'qoder-plugin-auth.json')
|
|
23
|
+
const PROBES_DIR = join(ROOT, 'docs', 'probes')
|
|
24
|
+
const INFER = process.env.QODER_INFER ?? 'https://gateway.qoder.com.cn'
|
|
25
|
+
const GW_PORT = Number(process.env.QODER_GW_PORT ?? 3903)
|
|
26
|
+
const GAP = Number(process.env.QODER_GAP_MS ?? 1200)
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2)
|
|
29
|
+
const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : d }
|
|
30
|
+
const MODELS = (val('--models', 'dmodel')).split(',').map((s) => s.trim()).filter(Boolean)
|
|
31
|
+
const WITH_GW = argv.includes('--gw')
|
|
32
|
+
// --gw-local:用**当前工作区代码**起一个临时网关(而非 :3903 上正在跑的旧进程),
|
|
33
|
+
// 用来验证"修复后的网关出站体"在真实上游是否放行(前后对比)。
|
|
34
|
+
const LOCAL_GW = argv.includes('--gw-local')
|
|
35
|
+
|
|
36
|
+
const { readJson } = await import('../core/json-store.js')
|
|
37
|
+
const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
|
|
38
|
+
const cosy = createCosyRuntime({ wasmPath: join(ROOT, 'providers', 'qoder', 'qoder_auth.wasm') })
|
|
39
|
+
const store = readJson(AUTH_PATH)
|
|
40
|
+
const cred = { accessToken: store?.auth?.accessToken, machineId: store?.machine?.machineId, uid: store?.account?.uid }
|
|
41
|
+
if (!cred.accessToken) { console.error('未登录:先跑 probe-qoder-live.mjs --login'); process.exit(1) }
|
|
42
|
+
|
|
43
|
+
const TOOLS = [{ type: 'function', function: { name: 'get_time', description: '查询时间', parameters: { type: 'object', properties: { tz: { type: 'string' } }, required: ['tz'] } } }]
|
|
44
|
+
const call = (id) => [{ id, type: 'function', function: { name: 'get_time', arguments: '{"tz":"Asia/Shanghai"}' } }]
|
|
45
|
+
const result = (id) => ({ role: 'tool', tool_call_id: id, content: '2026-09-22 09:00:00 +08:00' })
|
|
46
|
+
const sysHead = { role: 'system', content: '你是助手' }
|
|
47
|
+
const u = (t) => ({ role: 'user', content: t })
|
|
48
|
+
|
|
49
|
+
/** 形态库:每个只动一个变量。 */
|
|
50
|
+
const SHAPES = {
|
|
51
|
+
// —— content 单变量(其余完全一致的合法工具环)——
|
|
52
|
+
'A_call_content_null': [sysHead, u('几点'), { role: 'assistant', content: null, tool_calls: call('call_a1') }, result('call_a1'), u('谢谢,直接回答')],
|
|
53
|
+
'A_call_content_empty': [sysHead, u('几点'), { role: 'assistant', content: '', tool_calls: call('call_a1') }, result('call_a1'), u('谢谢,直接回答')],
|
|
54
|
+
'A_call_content_text': [sysHead, u('几点'), { role: 'assistant', content: '我查一下', tool_calls: call('call_a1') }, result('call_a1'), u('谢谢,直接回答')],
|
|
55
|
+
'A_call_content_absent': [sysHead, u('几点'), { role: 'assistant', tool_calls: call('call_a1') }, result('call_a1'), u('谢谢,直接回答')],
|
|
56
|
+
// —— 孤儿 tool(#39 形态):无前置 assistant ——
|
|
57
|
+
'B_orphan_plain': [sysHead, u('几点'), result('call_b1'), u('谢谢')],
|
|
58
|
+
// —— 修复桩形态(sanitizeToolPairing 实际产出):补的 assistant content:null ——
|
|
59
|
+
'B_orphan_stub_null': [sysHead, u('几点'), { role: 'assistant', content: null, tool_calls: call('call_b1') }, result('call_b1'), u('谢谢')],
|
|
60
|
+
'B_orphan_stub_empty': [sysHead, u('几点'), { role: 'assistant', content: '', tool_calls: call('call_b1') }, result('call_b1'), u('谢谢')],
|
|
61
|
+
// —— tool 消息本身 content 单变量 ——
|
|
62
|
+
'C_tool_content_null': [sysHead, u('几点'), { role: 'assistant', content: '', tool_calls: call('call_c1') }, { role: 'tool', tool_call_id: 'call_c1', content: null }, u('谢谢')],
|
|
63
|
+
'C_tool_content_empty': [sysHead, u('几点'), { role: 'assistant', content: '', tool_calls: call('call_c1') }, { role: 'tool', tool_call_id: 'call_c1', content: '' }, u('谢谢')],
|
|
64
|
+
// —— 不带 tools 数组的合法环(另一个候选变量)——
|
|
65
|
+
'D_pair_without_tools': [sysHead, u('几点'), { role: 'assistant', content: '', tool_calls: call('call_d1') }, result('call_d1'), u('谢谢')],
|
|
66
|
+
// —— 纯文本对照(无工具,确认通道本身健康)——
|
|
67
|
+
'E_plain_chat': [sysHead, u('只回复两个字:收到')],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --only A,B:只跑名字前缀匹配的形态(省额度)
|
|
71
|
+
const ONLY = (val('--only', '') || '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
72
|
+
const wantShape = (name) => !ONLY.length || ONLY.some((p) => name.startsWith(p))
|
|
73
|
+
|
|
74
|
+
const evidence = { ts: new Date().toISOString(), infer: INFER, models: MODELS, gw: LOCAL_GW ? 'local(fixed code)' : `${GW_PORT}(running process)`, shapes: {}, results: [] }
|
|
75
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
76
|
+
|
|
77
|
+
function verdictOf(raw) {
|
|
78
|
+
const flat = raw.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
79
|
+
const env = flat.match(/\{"code":"provider_error".*?"details":"(.*?)"\}/)
|
|
80
|
+
if (env) {
|
|
81
|
+
let inner = env[1]
|
|
82
|
+
try { inner = JSON.parse(`"${env[1]}"`).message ?? env[1] } catch { try { inner = JSON.parse(JSON.parse(`"${env[1]}"`)).error.message } catch {} }
|
|
83
|
+
return { kind: 'PROVIDER_ERROR', text: String(inner).replace(/\s+/g, ' ').slice(0, 300) }
|
|
84
|
+
}
|
|
85
|
+
const fail = flat.match(/\{"code":"[^"]*","message":"(\[FAIL\][^"]{0,200})/)
|
|
86
|
+
if (fail) return { kind: 'NODE_FAIL', text: fail[1] }
|
|
87
|
+
const text = (flat.match(/"content":"((?:[^"\\]|\\.)*)"/) ?? [])[1]
|
|
88
|
+
const done = raw.includes('[DONE]')
|
|
89
|
+
return { kind: text ? 'OK_TEXT' : (done ? 'OK_EMPTY' : 'NO_SIGNAL'), text: (text ?? '').slice(0, 120) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function direct(model, messages, withTools = true) {
|
|
93
|
+
const body = { model, stream: true, stream_options: { include_usage: true }, messages, ...(withTools ? { tools: TOOLS, tool_choice: 'auto' } : {}) }
|
|
94
|
+
const signed = await cosy.prepareChat(cred, { endpoint: INFER, body: JSON.stringify(body), modelKey: model, modelSource: 'system' })
|
|
95
|
+
const r = await fetch(signed.url, { method: 'POST', headers: { ...signed.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: signed.body })
|
|
96
|
+
const raw = await r.text()
|
|
97
|
+
const v = verdictOf(raw)
|
|
98
|
+
return { via: 'direct', model, httpStatus: r.status, ...v, requestShapes: messages.map((m) => `${m.role}${m.tool_calls ? '+tc' : ''}${'content' in m ? `:content=${JSON.stringify(m.content)?.slice(0, 12) ?? 'undef'}` : ':no-content-key'}`) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 本地临时网关(当前工作区代码)——验证修复后的出站体
|
|
102
|
+
let gwBase = `http://127.0.0.1:${GW_PORT}`
|
|
103
|
+
let stopLocalGw = null
|
|
104
|
+
if (LOCAL_GW) {
|
|
105
|
+
const { createQoderGateway } = await import('../providers/qoder/gateway.js')
|
|
106
|
+
const runtime = { running: false, port: null, lastError: null }
|
|
107
|
+
const localGw = createQoderGateway({
|
|
108
|
+
settings: () => ({ qoderInferBaseURL: INFER, maxConcurrentPerSession: 2, upstreamFirstByteTimeoutMs: 90_000 }),
|
|
109
|
+
resolveCredential: async () => ({ authorization: `Bearer ${cred.accessToken}`, machineId: cred.machineId, uid: cred.uid }),
|
|
110
|
+
cosy, meter: { record() {} }, runtime, forensics: { logPath: () => undefined },
|
|
111
|
+
getCatalogProfiles: () => [], getCatalogEntry: () => null,
|
|
112
|
+
getModelSource: () => 'system', getModelPrefs: () => ({}),
|
|
113
|
+
})
|
|
114
|
+
stopLocalGw = localGw.listen(0)
|
|
115
|
+
for (let i = 0; i < 100 && !runtime.running; i++) await sleep(50)
|
|
116
|
+
if (!runtime.running) { console.error('本地网关起不来:', runtime.lastError); process.exit(1) }
|
|
117
|
+
gwBase = `http://127.0.0.1:${runtime.port}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function viaGw(model, messages, withTools = true) {
|
|
121
|
+
const body = { model, stream: true, stream_options: { include_usage: true }, messages, ...(withTools ? { tools: TOOLS, tool_choice: 'auto' } : {}) }
|
|
122
|
+
const r = await fetch(`${gwBase}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
123
|
+
const raw = await r.text()
|
|
124
|
+
return { via: `gw:${new URL(gwBase).port}`, model, httpStatus: r.status, ...verdictOf(raw) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`真实上游差分:models=${MODELS.join(',')} 形态=${Object.keys(SHAPES).length}${WITH_GW ? ' +生产网关对照' : ''}\n`)
|
|
128
|
+
for (const [name, messages] of Object.entries(SHAPES)) {
|
|
129
|
+
evidence.shapes[name] = messages
|
|
130
|
+
for (const model of MODELS) {
|
|
131
|
+
const withTools = !name.startsWith('D_')
|
|
132
|
+
const r = await direct(model, structuredClone(messages), withTools)
|
|
133
|
+
r.shape = name
|
|
134
|
+
evidence.results.push(r)
|
|
135
|
+
console.log(` ${name.padEnd(22)} ${model.padEnd(8)} ${r.kind.padEnd(14)} ${r.kind === 'OK_TEXT' ? `"${r.text}"` : r.text.slice(0, 150)}`)
|
|
136
|
+
await sleep(GAP)
|
|
137
|
+
}
|
|
138
|
+
if ((WITH_GW || LOCAL_GW) && /^(A_call_content_null|B_orphan_plain|B_orphan_stub_null|C_tool_content_null)$/.test(name)) {
|
|
139
|
+
for (const model of MODELS) {
|
|
140
|
+
const r = await viaGw(model, structuredClone(messages))
|
|
141
|
+
r.shape = `${name} (gateway)`
|
|
142
|
+
evidence.results.push(r)
|
|
143
|
+
console.log(` ${`${name} [GW]`.padEnd(22)} ${model.padEnd(8)} ${r.kind.padEnd(14)} ${r.kind === 'OK_TEXT' ? `"${r.text}"` : r.text.slice(0, 150)}`)
|
|
144
|
+
await sleep(GAP)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
mkdirSync(PROBES_DIR, { recursive: true })
|
|
150
|
+
const out = join(PROBES_DIR, `qoder-null-content-${Date.now()}.json`)
|
|
151
|
+
writeFileSync(out, JSON.stringify(evidence, null, 2))
|
|
152
|
+
console.log('\n证据 →', out)
|
|
153
|
+
process.exit(0)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Qoder CN — tool 配对不变量差分实验(踩坑 #39 复发调查)
|
|
4
|
+
*
|
|
5
|
+
* --offline 用宿主真实序列化器(@earendil-works/pi-ai convertMessages)跑 8 种
|
|
6
|
+
* 真实会话形态 → 打印出站 messages 序列 → 用严格校验器判违规 →
|
|
7
|
+
* 再判 sanitizeToolPairing 修复后是否仍违规(不花额度)。
|
|
8
|
+
* --live 把 offline 产出的**真实报文**打到真实上游严格模型家族:
|
|
9
|
+
* direct = 绕过修复(阳性对照),gw = 经本机 :3903 生产网关(含修复)。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/probe-qoder-pairing.mjs --offline
|
|
13
|
+
* node scripts/probe-qoder-pairing.mjs --live --model dmodel
|
|
14
|
+
* node scripts/probe-qoder-pairing.mjs --live --gw-port 3903
|
|
15
|
+
* 证据落 docs/probes/qoder-pairing-<ts>.json
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
23
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
24
|
+
const AUTH_PATH = join(DSH_HOME, 'qoder-plugin-auth.json')
|
|
25
|
+
const PROBES_DIR = join(ROOT, 'docs', 'probes')
|
|
26
|
+
const INFER = process.env.QODER_INFER ?? 'https://gateway.qoder.com.cn'
|
|
27
|
+
const PI_AI = process.env.PI_AI_PATH
|
|
28
|
+
?? 'C:/Users/21613/dev/dsh-launcher/node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js'
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2)
|
|
31
|
+
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d }
|
|
32
|
+
const LIVE = args.includes('--live')
|
|
33
|
+
const LIVE_MODEL = val('--model', 'dmodel')
|
|
34
|
+
const GW_PORT = Number(val('--gw-port', '3903'))
|
|
35
|
+
|
|
36
|
+
const { sanitizeToolPairing } = await import('../providers/tool-pairing.js')
|
|
37
|
+
const { convertMessages } = await import(pathToFileURL(PI_AI).href)
|
|
38
|
+
|
|
39
|
+
// ── 宿主侧真实 model 对象(settings.yaml llm-pi-ai.providers.qoder 的投影)──
|
|
40
|
+
// 注意 reasoning:false——现网 qoder 模型条目**不声明** reasoning,故 pi-ai 把 system
|
|
41
|
+
// prompt 序列化成 role:"system"(若为 true 会变 role:"developer",那是另一条独立
|
|
42
|
+
// 故障:上游在反序列化阶段整请求拒绝,见 probe-qoder-null-content.mjs 的 E 组)。
|
|
43
|
+
const MODEL = {
|
|
44
|
+
id: LIVE_MODEL, name: 'probe', provider: 'qoder', api: 'openai-completions',
|
|
45
|
+
baseUrl: `http://127.0.0.1:${GW_PORT}/v1`, reasoning: false,
|
|
46
|
+
input: ['text', 'image'], contextWindow: 200000, maxTokens: 32768,
|
|
47
|
+
}
|
|
48
|
+
const TOOLS = [{
|
|
49
|
+
type: 'function', function: {
|
|
50
|
+
name: 'read', description: '读文件',
|
|
51
|
+
parameters: { type: 'object', properties: { f: { type: 'number' } }, required: ['f'] },
|
|
52
|
+
},
|
|
53
|
+
}]
|
|
54
|
+
const sysPrompt = 'You are a coding agent. Use tools when helpful.'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* pi-ai detectCompat() 对本 provider 的解析结果(provider="qoder" 不在任何
|
|
58
|
+
* isNonStandard 名单里、baseUrl 是回环地址 → 判定为"标准 OpenAI 面")。
|
|
59
|
+
* getCompat 未导出,这里按 detectCompat 源码逐字段复刻(openai-completions.js
|
|
60
|
+
* :1234-1318),保证 offline 重放与宿主真实出站一致。
|
|
61
|
+
*/
|
|
62
|
+
const COMPAT = {
|
|
63
|
+
supportsStore: true,
|
|
64
|
+
supportsDeveloperRole: true,
|
|
65
|
+
supportsReasoningEffort: true,
|
|
66
|
+
supportsUsageInStreaming: true,
|
|
67
|
+
supportsFinishReason: true,
|
|
68
|
+
maxTokensField: 'max_completion_tokens',
|
|
69
|
+
requiresToolResultName: false,
|
|
70
|
+
requiresAssistantAfterToolResult: false,
|
|
71
|
+
requiresThinkingAsText: false,
|
|
72
|
+
requiresReasoningContentOnAssistantMessages: false,
|
|
73
|
+
thinkingFormat: 'openai',
|
|
74
|
+
openRouterRouting: {},
|
|
75
|
+
vercelGatewayRouting: {},
|
|
76
|
+
chatTemplateKwargs: {},
|
|
77
|
+
chatTemplateArgs: {},
|
|
78
|
+
zaiToolStream: false,
|
|
79
|
+
supportsThinkingTokenBudget: false,
|
|
80
|
+
thinkingTokenBudgetField: undefined,
|
|
81
|
+
supportsStrictMode: true,
|
|
82
|
+
supportsOpenAIGrammarTools: false,
|
|
83
|
+
cacheControlFormat: undefined,
|
|
84
|
+
sendSessionAffinityHeaders: false,
|
|
85
|
+
deferredToolsMode: undefined,
|
|
86
|
+
sessionAffinityFormat: 'openai',
|
|
87
|
+
supportsLongCacheRetention: true,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tc = (id, name, a) => ({ type: 'toolCall', id, name, arguments: a })
|
|
91
|
+
const asst = (content, stopReason) => ({
|
|
92
|
+
role: 'assistant', content, stopReason, provider: MODEL.provider, api: MODEL.api, model: MODEL.id,
|
|
93
|
+
})
|
|
94
|
+
const tr = (toolCallId, toolName, text) => ({
|
|
95
|
+
role: 'toolResult', toolCallId, toolName, isError: false,
|
|
96
|
+
content: [{ type: 'text', text }], timestamp: Date.now(),
|
|
97
|
+
})
|
|
98
|
+
const user = (text) => ({ role: 'user', content: [{ type: 'text', text }] })
|
|
99
|
+
|
|
100
|
+
/** 8 种 dsh 真实可能产生的历史形态。 */
|
|
101
|
+
const TRANSCRIPTS = {
|
|
102
|
+
normal_tool_round: [
|
|
103
|
+
user('读一下文件'),
|
|
104
|
+
asst([tc('call_A', 'read', { f: 1 })], 'tool_calls'),
|
|
105
|
+
tr('call_A', 'read', 'file content'),
|
|
106
|
+
asst([{ type: 'text', text: '内容如上' }], 'stop'),
|
|
107
|
+
],
|
|
108
|
+
aborted_carrier: [
|
|
109
|
+
user('读一下文件'),
|
|
110
|
+
asst([{ type: 'thinking', thinking: '我要读文件' }, tc('call_B', 'read', { f: 2 })], 'aborted'),
|
|
111
|
+
tr('call_B', 'read', 'b content'),
|
|
112
|
+
user('继续'),
|
|
113
|
+
],
|
|
114
|
+
error_carrier: [
|
|
115
|
+
user('读一下文件'),
|
|
116
|
+
asst([tc('call_E', 'read', { f: 5 })], 'error'),
|
|
117
|
+
tr('call_E', 'read', 'e content'),
|
|
118
|
+
user('再来'),
|
|
119
|
+
],
|
|
120
|
+
mixed_multi_turn: [
|
|
121
|
+
user('step1'),
|
|
122
|
+
asst([tc('call_A', 'read', { f: 1 })], 'tool_calls'),
|
|
123
|
+
tr('call_A', 'read', 'ok'),
|
|
124
|
+
asst([tc('call_B', 'read', { f: 2 })], 'aborted'),
|
|
125
|
+
tr('call_B', 'read', 'ran anyway'),
|
|
126
|
+
asst([tc('call_C', 'read', { f: 3 })], 'tool_calls'),
|
|
127
|
+
tr('call_C', 'read', 'hits'),
|
|
128
|
+
asst([{ type: 'text', text: '完成' }], 'stop'),
|
|
129
|
+
user('next'),
|
|
130
|
+
],
|
|
131
|
+
parallel_partial: [
|
|
132
|
+
user('parallel'),
|
|
133
|
+
asst([tc('call_A', 'read', { f: 1 }), tc('call_B', 'read', { f: 2 })], 'tool_calls'),
|
|
134
|
+
tr('call_A', 'read', 'only A'),
|
|
135
|
+
user('换个问题'),
|
|
136
|
+
],
|
|
137
|
+
aborted_no_result: [
|
|
138
|
+
user('x'), asst([tc('call_B', 'read', { f: 1 })], 'aborted'), user('重来'),
|
|
139
|
+
],
|
|
140
|
+
two_aborted_carriers: [
|
|
141
|
+
user('x'),
|
|
142
|
+
asst([tc('call_B', 'read', { f: 1 })], 'aborted'), tr('call_B', 'read', 'b'),
|
|
143
|
+
asst([tc('call_C', 'read', { f: 2 })], 'aborted'), tr('call_C', 'read', 'c'),
|
|
144
|
+
user('继续'),
|
|
145
|
+
],
|
|
146
|
+
duplicate_result: [
|
|
147
|
+
user('x'),
|
|
148
|
+
asst([tc('call_B', 'read', { f: 1 })], 'aborted'), tr('call_B', 'read', 'first'), tr('call_B', 'read', 'second'),
|
|
149
|
+
user('继续'),
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 严格校验器(模拟上游 OpenAI 兼容面那条规则的最严读法)
|
|
155
|
+
* flat : role:tool 的 id 必须出现在更早某条 assistant.tool_calls
|
|
156
|
+
* strict-group : 且必须紧跟声明它的 assistant,该 assistant 的每个 call 都要有结果
|
|
157
|
+
*/
|
|
158
|
+
function validate(messages, mode) {
|
|
159
|
+
const problems = []
|
|
160
|
+
const seen = new Set()
|
|
161
|
+
let open = null
|
|
162
|
+
const close = (i) => {
|
|
163
|
+
if (open) {
|
|
164
|
+
for (const id of open.ids) if (!open.answered.has(id)) problems.push(`L${i}: tool_call ${id} 声明了却无结果`)
|
|
165
|
+
open = null
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
messages.forEach((m, i) => {
|
|
169
|
+
if (m.role === 'assistant') {
|
|
170
|
+
close(i)
|
|
171
|
+
const ids = (m.tool_calls ?? []).map((c) => c.id)
|
|
172
|
+
for (const id of ids) seen.add(id)
|
|
173
|
+
if (ids.length) open = { ids: new Set(ids), answered: new Set() }
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (m.role === 'tool') {
|
|
177
|
+
const id = m.tool_call_id
|
|
178
|
+
if (!seen.has(id)) problems.push(`L${i}: 孤儿 tool ${id}(更早历史没有声明它的 assistant)`)
|
|
179
|
+
else if (mode === 'strict-group' && (!open || !open.ids.has(id))) problems.push(`L${i}: tool ${id} 未紧跟声明它的 assistant`)
|
|
180
|
+
if (open?.ids.has(id)) open.answered.add(id)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
close(i)
|
|
184
|
+
})
|
|
185
|
+
close(messages.length)
|
|
186
|
+
return problems
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const seq = (ms) => ms.map((m) => (m.role === 'assistant'
|
|
190
|
+
? `assistant${(m.tool_calls ?? []).length ? `[${m.tool_calls.map((c) => c.id).join('+')}]` : ''}`
|
|
191
|
+
: m.role === 'tool' ? `tool[${m.tool_call_id}]` : m.role)).join(' ')
|
|
192
|
+
|
|
193
|
+
const evidence = { ts: new Date().toISOString(), piAi: PI_AI, model: MODEL, gwPort: GW_PORT, cases: [] }
|
|
194
|
+
const built = {}
|
|
195
|
+
|
|
196
|
+
for (const [name, messages] of Object.entries(TRANSCRIPTS)) {
|
|
197
|
+
const params = convertMessages(MODEL, { systemPrompt: sysPrompt, tools: TOOLS, messages }, COMPAT, {}) ?? []
|
|
198
|
+
const pair = sanitizeToolPairing(params)
|
|
199
|
+
const before = { flat: validate(params, 'flat'), group: validate(params, 'strict-group') }
|
|
200
|
+
const after = { flat: validate(pair.messages, 'flat'), group: validate(pair.messages, 'strict-group') }
|
|
201
|
+
built[name] = { raw: params, fixed: pair.messages }
|
|
202
|
+
evidence.cases.push({ name, sequenceBefore: seq(params), sequenceAfter: seq(pair.messages), repaired: pair.repaired, violationsBefore: before, violationsAfter: after, stillBroken: after.flat.length + after.group.length > 0, rawMessages: params, fixedMessages: pair.messages })
|
|
203
|
+
console.log(`\n### ${name}`)
|
|
204
|
+
console.log(' 出站序列 :', seq(params))
|
|
205
|
+
console.log(' 修复后序列:', seq(pair.messages), JSON.stringify(pair.repaired))
|
|
206
|
+
console.log(' 修复前违规:', before.flat.length ? before.flat : 'none')
|
|
207
|
+
console.log(' 修复后违规:', after.flat.length || after.group.length ? { ...after } : 'none', after.flat.length + after.group.length > 0 ? ' <<< 仍有违规' : '')
|
|
208
|
+
}
|
|
209
|
+
console.log(`\n总计 ${evidence.cases.length} 形态,修复后仍违规 ${evidence.cases.filter((c) => c.stillBroken).length}`)
|
|
210
|
+
|
|
211
|
+
if (!LIVE) {
|
|
212
|
+
mkdirSync(PROBES_DIR, { recursive: true })
|
|
213
|
+
const out = join(PROBES_DIR, `qoder-pairing-${Date.now()}.json`)
|
|
214
|
+
writeFileSync(out, JSON.stringify(evidence, null, 2))
|
|
215
|
+
console.log('证据 →', out)
|
|
216
|
+
process.exit(0)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ══════════════════ LIVE:真实上游(严格模型家族)══════════════════
|
|
220
|
+
const { readJson } = await import('../core/json-store.js')
|
|
221
|
+
const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
|
|
222
|
+
const cosy = createCosyRuntime({ wasmPath: join(ROOT, 'providers', 'qoder', 'qoder_auth.wasm') })
|
|
223
|
+
const store = readJson(AUTH_PATH)
|
|
224
|
+
const cred = { accessToken: store?.auth?.accessToken, machineId: store?.machine?.machineId, uid: store?.account?.uid }
|
|
225
|
+
if (!cred.accessToken) { console.error('未登录:先跑 scripts/probe-qoder-live.mjs --login'); process.exit(1) }
|
|
226
|
+
|
|
227
|
+
const wrap = (messages, extra = {}) => ({ model: LIVE_MODEL, stream: true, stream_options: { include_usage: true }, messages, tools: TOOLS, tool_choice: 'auto', ...extra })
|
|
228
|
+
|
|
229
|
+
function record(label, note, status, raw) {
|
|
230
|
+
const flat = raw.replace(/\\{2,}"$/gm, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
231
|
+
const errHit = flat.match(/\{"code":"provider_error".{0,300}/) ?? flat.match(/\{"code":"\d+","message":"\[FAIL\][^"]{0,200}"/) ?? flat.match(/"error":\s*\{[^{}]{0,300}/)
|
|
232
|
+
const hasText = /"content":"[^"]/.test(flat)
|
|
233
|
+
const done = raw.includes('[DONE]')
|
|
234
|
+
const verdict = errHit ? `ERROR ${errHit[0].slice(0, 180)}` : (hasText ? 'OK 有正文' : (done ? 'OK 空正文(有 DONE)' : '?? 无正文无 DONE'))
|
|
235
|
+
console.log(` ${label.padEnd(36)} ${status} → ${verdict}${note ? ` [${note}]` : ''}`)
|
|
236
|
+
evidence.live.push({ label, note, status, verdict, errHit: errHit?.[0] ?? null, raw: raw.slice(0, 8000) })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 直连签名:绕过生产网关的修复(阳性对照)。 */
|
|
240
|
+
async function direct(label, bodyObj, note) {
|
|
241
|
+
const signed = await cosy.prepareChat(cred, { endpoint: INFER, body: JSON.stringify(bodyObj), modelKey: LIVE_MODEL, modelSource: 'system' })
|
|
242
|
+
const r = await fetch(signed.url, { method: 'POST', headers: { ...signed.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: signed.body })
|
|
243
|
+
record(label, note, `HTTP ${r.status}`, await r.text())
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 经生产网关 :3903(含 sanitizeToolPairing + 归因信封)。 */
|
|
247
|
+
async function gw(label, bodyObj, note) {
|
|
248
|
+
try {
|
|
249
|
+
const r = await fetch(`http://127.0.0.1:${GW_PORT}/v1/chat/completions`, {
|
|
250
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bodyObj),
|
|
251
|
+
})
|
|
252
|
+
record(label, note, `HTTP ${r.status}`, await r.text())
|
|
253
|
+
return true
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.log(` ${label.padEnd(36)} 网关 :${GW_PORT} 不可用(${e.message})——该例跳过`)
|
|
256
|
+
evidence.live.push({ label, note, skipped: String(e.message) })
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
evidence.live = []
|
|
262
|
+
console.log(`\n──────── LIVE model=${LIVE_MODEL} 网关=:${GW_PORT} ────────`)
|
|
263
|
+
|
|
264
|
+
// A. 阳性对照:同一份坏体,直连(不修)应当 400
|
|
265
|
+
await direct('A1_raw_orphan_direct', wrap(built.aborted_carrier.raw), 'pi-ai 裸出站(孤儿 tool)')
|
|
266
|
+
await direct('A2_raw_two_orphans_direct', wrap(built.two_aborted_carriers.raw), '两条连续孤儿 tool')
|
|
267
|
+
|
|
268
|
+
// B. 生产路径:同一份坏体经网关(修复后应当 200)
|
|
269
|
+
await gw('B1_gateway_aborted_carrier', wrap(built.aborted_carrier.raw), '网关修复孤儿→补桩')
|
|
270
|
+
await gw('B2_gateway_mixed_multi_turn', wrap(built.mixed_multi_turn.raw), '多轮混合(孤儿夹在合法环中)')
|
|
271
|
+
await gw('B3_gateway_two_aborted', wrap(built.two_aborted_carriers.raw), '连续两孤儿')
|
|
272
|
+
await gw('B4_gateway_dup_result', wrap(built.duplicate_result.raw), '同 id 重复结果')
|
|
273
|
+
await gw('B5_gateway_normal_round', wrap(built.normal_tool_round.raw), '对照:本就合法')
|
|
274
|
+
|
|
275
|
+
// C. 修复桩形态直连(我们补出来的东西严格上游收不收)
|
|
276
|
+
await direct('C1_stub_content_null', wrap([{ role: 'user', content: '读文件' },
|
|
277
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_s1', type: 'function', function: { name: 'read', arguments: '{}' } }] },
|
|
278
|
+
{ role: 'tool', tool_call_id: 'call_s1', content: '(tool result unavailable: previous attempt was interrupted)' }]), '桩:assistant content:null')
|
|
279
|
+
await direct('C2_stub_content_empty', wrap([{ role: 'user', content: '读文件' },
|
|
280
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'call_s2', type: 'function', function: { name: 'read', arguments: '{}' } }] },
|
|
281
|
+
{ role: 'tool', tool_call_id: 'call_s2', content: '(unavailable)' }]), '桩:assistant content:""')
|
|
282
|
+
await direct('C3_valid_pair', wrap([{ role: 'user', content: '读文件' },
|
|
283
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_ok', type: 'function', function: { name: 'read', arguments: '{"f":1}' } }] },
|
|
284
|
+
{ role: 'tool', tool_call_id: 'call_ok', content: 'file body' }]), '对照:合法工具环')
|
|
285
|
+
// D. 顺序读法:orphan 桩插在"合法环中间"后,前一条 tool 的结果被推到最后
|
|
286
|
+
await direct('D1_group_order_edge', wrap([
|
|
287
|
+
{ role: 'user', content: 'q' },
|
|
288
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_A', type: 'function', function: { name: 'read', arguments: '{"f":1}' } }] },
|
|
289
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_B', type: 'function', function: { name: 'read', arguments: '{}' } }] },
|
|
290
|
+
{ role: 'tool', tool_call_id: 'call_B', content: 'B 的结果' },
|
|
291
|
+
{ role: 'tool', tool_call_id: 'call_A', content: 'A 的结果' },
|
|
292
|
+
]), 'assistant[A] assistant[B] tool[B] tool[A](A 的结果不在紧跟组内)')
|
|
293
|
+
// E. developer 角色(pi-ai 对 reasoning 模型把 system 序列化成 developer)
|
|
294
|
+
await direct('E1_developer_role', wrap([{ role: 'developer', content: '你是编码助手' }, { role: 'user', content: '只回复:收到' }]), 'role:developer 是否被接受')
|
|
295
|
+
await direct('E2_system_role', wrap([{ role: 'system', content: '你是编码助手' }, { role: 'user', content: '只回复:收到' }]), 'role:system 对照')
|
|
296
|
+
// F. 只有 assistant.tool_calls 没结果 / tool 结果无 tools 数组
|
|
297
|
+
await direct('F1_calls_without_result', wrap([{ role: 'user', content: '读文件' },
|
|
298
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_Z', type: 'function', function: { name: 'read', arguments: '{}' } }] }]), 'tool_calls 缺结果')
|
|
299
|
+
await direct('F2_pair_without_tools_array', { model: LIVE_MODEL, stream: true, stream_options: { include_usage: true }, messages: [
|
|
300
|
+
{ role: 'user', content: '读文件' },
|
|
301
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: 'call_Y', type: 'function', function: { name: 'read', arguments: '{}' } }] },
|
|
302
|
+
{ role: 'tool', tool_call_id: 'call_Y', content: 'body' }] }, '合法配对但请求不带 tools 数组')
|
|
303
|
+
|
|
304
|
+
mkdirSync(PROBES_DIR, { recursive: true })
|
|
305
|
+
const outLive = join(PROBES_DIR, `qoder-pairing-${Date.now()}.json`)
|
|
306
|
+
writeFileSync(outLive, JSON.stringify(evidence, null, 2))
|
|
307
|
+
console.log('\n证据 →', outLive)
|
|
308
|
+
process.exit(0)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Qoder CN — 用量计数器差分探针(真实网络、本人账号)。
|
|
4
|
+
*
|
|
5
|
+
* 用途:聊天前后各拉一遍统计端点做差分。读数语义(2026-09-22 臂 9 定论):
|
|
6
|
+
* - `quota/usage` 的 `addOnQuota.used` = **实时**配额计数器,但**只显整数**——
|
|
7
|
+
* 0.002 级小额聊天会被取整吞掉("不计费"假阴性的来源);判别须用大额
|
|
8
|
+
* 用量(单发 ≥0.5 credits)或与读数分辨率匹配的量级;
|
|
9
|
+
* - `credits-heatmap` / `credits-summary` = **统计视图层**,延迟批处理
|
|
10
|
+
* (官方客户端自己的聊天 20 分钟内也不动),由归因链(信封+business 块+
|
|
11
|
+
* finish/tracking 上报)驱动——插件网关 0.9.9 起已对齐;
|
|
12
|
+
* - 免费档模型(如 qfmodel,帧 billable:false)不耗 credits,任何计数器都不动。
|
|
13
|
+
*
|
|
14
|
+
* 用法:
|
|
15
|
+
* node scripts/probe-qoder-quota.mjs # 基线 → 1 次聊天 → 5s/20s 后复拉
|
|
16
|
+
* node scripts/probe-qoder-quota.mjs --read-only # 只拉端点不聊天
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
25
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
26
|
+
const PROBES_DIR = join(ROOT, 'docs', 'probes')
|
|
27
|
+
const OPENAPI = process.env.QODER_OPENAPI ?? 'https://openapi.qoder.com.cn'
|
|
28
|
+
const INFER = process.env.QODER_INFER ?? 'https://gateway.qoder.com.cn'
|
|
29
|
+
const CHAT_MODEL = process.env.QODER_QUOTA_MODEL ?? 'q37fmodel' // 对照健康、单价最低档
|
|
30
|
+
const readOnly = process.argv.includes('--read-only')
|
|
31
|
+
|
|
32
|
+
const { readJson } = await import('../core/json-store.js')
|
|
33
|
+
const { createCosyRuntime } = await import('../providers/qoder/cosy.js')
|
|
34
|
+
|
|
35
|
+
const store = readJson(join(DSH_HOME, 'qoder-plugin-auth.json'))
|
|
36
|
+
if (!store?.auth?.accessToken) {
|
|
37
|
+
console.error('未登录:先跑 scripts/probe-qoder-live.mjs --login')
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
const token = store.auth.accessToken
|
|
41
|
+
const cred = { accessToken: token, machineId: store.machine?.machineId, uid: store.account?.uid }
|
|
42
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
43
|
+
|
|
44
|
+
const ENDPOINTS = [
|
|
45
|
+
['quotaUsage', 'GET', `${OPENAPI}/api/v2/quota/usage`],
|
|
46
|
+
['quotaUsageOuter', 'GET', `${OPENAPI}/api/v2/quota/usage?outerProviders=cmcc`],
|
|
47
|
+
['meUsage', 'GET', `${OPENAPI}/sash/api/v2/me/usage`],
|
|
48
|
+
['creditsHeatmap', 'GET', `${OPENAPI}/sash/api/v1/ai-conversations/credits-heatmap?days=1`],
|
|
49
|
+
['creditsSummary', 'GET', `${OPENAPI}/sash/api/v1/ai-conversations/credits-summary`],
|
|
50
|
+
['userPlan', 'GET', `${OPENAPI}/api/v2/user/plan`],
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
async function pullAll(tag) {
|
|
54
|
+
const snap = { tag, at: new Date().toISOString(), endpoints: {} }
|
|
55
|
+
for (const [name, method, url] of ENDPOINTS) {
|
|
56
|
+
const rec = {}
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}` } })
|
|
59
|
+
rec.status = res.status
|
|
60
|
+
const text = await res.text()
|
|
61
|
+
rec.body = text.length <= 20_000 ? text : text.slice(0, 20_000) + '…(截断)'
|
|
62
|
+
} catch (e) {
|
|
63
|
+
rec.netErr = String(e?.message ?? e)
|
|
64
|
+
}
|
|
65
|
+
snap.endpoints[name] = rec
|
|
66
|
+
console.log(` [${tag}] ${name.padEnd(16)} HTTP ${rec.status ?? '-'} ${rec.body ? String(rec.body).slice(0, 160) : (rec.netErr ?? '')}`)
|
|
67
|
+
await sleep(400)
|
|
68
|
+
}
|
|
69
|
+
return snap
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 提取计数器摘要用于 diff(结构未知时原样保留原文)。 */
|
|
73
|
+
function summarize(snap) {
|
|
74
|
+
const out = {}
|
|
75
|
+
for (const [name, rec] of Object.entries(snap.endpoints)) {
|
|
76
|
+
try {
|
|
77
|
+
const j = JSON.parse(rec.body)
|
|
78
|
+
out[name] = j
|
|
79
|
+
} catch { out[name] = rec.body ?? rec.netErr }
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const evidence = { at: new Date().toISOString(), openapi: OPENAPI, chatModel: CHAT_MODEL, steps: [] }
|
|
85
|
+
|
|
86
|
+
console.log('① 基线拉取:')
|
|
87
|
+
const before = await pullAll('before')
|
|
88
|
+
evidence.steps.push({ step: 'before', data: summarize(before) })
|
|
89
|
+
|
|
90
|
+
if (!readOnly) {
|
|
91
|
+
console.log('\n② 最小聊天(cosy 签名裸 OpenAI body,与插件网关出站同形态):')
|
|
92
|
+
const cosy = createCosyRuntime({ wasmPath: join(ROOT, 'providers', 'qoder', 'qoder_auth.wasm') })
|
|
93
|
+
const body = JSON.stringify({
|
|
94
|
+
model: CHAT_MODEL, stream: true, stream_options: { include_usage: true },
|
|
95
|
+
messages: [{ role: 'user', content: '只回复两个字:收到' }],
|
|
96
|
+
})
|
|
97
|
+
const chatRec = { model: CHAT_MODEL }
|
|
98
|
+
try {
|
|
99
|
+
const signed = await cosy.prepareChat(cred, { endpoint: INFER, body, modelKey: CHAT_MODEL, modelSource: 'system' })
|
|
100
|
+
chatRec.url = signed.url
|
|
101
|
+
const res = await fetch(signed.url, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { ...signed.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
104
|
+
body: signed.body,
|
|
105
|
+
})
|
|
106
|
+
chatRec.status = res.status
|
|
107
|
+
const raw = await res.text()
|
|
108
|
+
chatRec.raw = raw.slice(0, 20_000)
|
|
109
|
+
// 取尾帧 usage
|
|
110
|
+
for (const line of raw.split('\n')) {
|
|
111
|
+
if (!line.startsWith('data:')) continue
|
|
112
|
+
let frame; try { frame = JSON.parse(line.slice(5)) } catch { continue }
|
|
113
|
+
if (typeof frame?.body !== 'string' || frame.body === '[DONE]') continue
|
|
114
|
+
let chunk; try { chunk = JSON.parse(frame.body) } catch { continue }
|
|
115
|
+
if (chunk.usage) chatRec.usage = chunk.usage
|
|
116
|
+
if (chunk.choices?.[0]?.delta?.content) chatRec.text = (chatRec.text ?? '') + chunk.choices[0].delta.content
|
|
117
|
+
}
|
|
118
|
+
console.log(` chat HTTP ${chatRec.status} 正文=${JSON.stringify((chatRec.text ?? '').slice(0, 30))} usage=${JSON.stringify(chatRec.usage)}`)
|
|
119
|
+
} catch (e) {
|
|
120
|
+
chatRec.netErr = String(e?.message ?? e)
|
|
121
|
+
console.log(' chat 网络错误:', chatRec.netErr)
|
|
122
|
+
}
|
|
123
|
+
evidence.steps.push({ step: 'chat', data: chatRec })
|
|
124
|
+
|
|
125
|
+
console.log('\n③ 5s 后复拉:')
|
|
126
|
+
await sleep(5000)
|
|
127
|
+
const after5 = await pullAll('after5s')
|
|
128
|
+
evidence.steps.push({ step: 'after5s', data: summarize(after5) })
|
|
129
|
+
|
|
130
|
+
console.log('\n④ 20s 后复拉:')
|
|
131
|
+
await sleep(20_000)
|
|
132
|
+
const after25 = await pullAll('after25s')
|
|
133
|
+
evidence.steps.push({ step: 'after25s', data: summarize(after25) })
|
|
134
|
+
|
|
135
|
+
// 计数器 diff(只关心数字字段)
|
|
136
|
+
const num = (snap, path) => path.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), snap)
|
|
137
|
+
const counters = [
|
|
138
|
+
['quotaUsage', 'addOnQuota.used'], ['quotaUsage', 'userQuota.used'],
|
|
139
|
+
['quotaUsageOuter', 'addOnQuota.used'], ['quotaUsageOuter', 'userQuota.used'],
|
|
140
|
+
]
|
|
141
|
+
console.log('\n【计数器 diff】')
|
|
142
|
+
const diffs = []
|
|
143
|
+
for (const [ep, path] of counters) {
|
|
144
|
+
const pick = (snap) => { const e = snap.data[ep]; const parts = path.split('.'); let v = e; for (const p of parts) v = v?.[p]; return typeof v === 'number' ? v : null }
|
|
145
|
+
const b = pick(evidence.steps[0]), a = pick(evidence.steps[evidence.steps.length - 1])
|
|
146
|
+
const line = ` ${ep}.${path}: ${b ?? '?'} → ${a ?? '?'} ${b != null && a != null ? (a > b ? '★ 涨了 +' + (a - b) : (a === b ? '(不变)' : '(降了?!)')) : ''}`
|
|
147
|
+
console.log(line)
|
|
148
|
+
diffs.push({ counter: `${ep}.${path}`, before: b, after: a })
|
|
149
|
+
}
|
|
150
|
+
evidence.diffs = diffs
|
|
151
|
+
const grew = diffs.some((d) => d.before != null && d.after != null && d.after > d.before)
|
|
152
|
+
evidence.verdict = grew
|
|
153
|
+
? 'billing-ok-display-gap:扣费计数器在涨,缺的只是展示层归因(business 块/finish 上报)'
|
|
154
|
+
: 'not-counted:计数器不动——服务端对缺归因信封的请求可能不计入配额,需在聊天 body 明文补归因字段复测'
|
|
155
|
+
console.log('\n【结论】', evidence.verdict)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
mkdirSync(PROBES_DIR, { recursive: true })
|
|
159
|
+
const out = join(PROBES_DIR, `qoder-quota-${Date.now()}.json`)
|
|
160
|
+
writeFileSync(out, JSON.stringify(evidence, null, 2))
|
|
161
|
+
console.log('\n证据 →', out)
|
|
162
|
+
process.exit(0)
|