| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import http from 'http'; |
|
|
| const BASE = process.env.BASE_URL || 'http://localhost:3010'; |
| const url = new URL(BASE); |
|
|
| function runAnthropicTest(name, body, timeout = 60000) { |
| return new Promise((resolve, reject) => { |
| const timer = setTimeout(() => { reject(new Error('超时 ' + timeout + 'ms')); }, timeout); |
| const data = JSON.stringify(body); |
| const req = http.request({ |
| hostname: url.hostname, port: url.port, path: '/v1/messages', method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', 'x-api-key': 'test', |
| 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(data), |
| }, |
| }, (res) => { |
| const start = Date.now(); |
| let events = []; |
| let buf = ''; |
|
|
| res.on('data', (chunk) => { |
| buf += chunk.toString(); |
| const lines = buf.split('\n'); |
| buf = lines.pop(); |
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| const payload = line.slice(6).trim(); |
| if (payload === '[DONE]') continue; |
| try { |
| const ev = JSON.parse(payload); |
| events.push({ ...ev, _ts: Date.now() - start }); |
| } catch { } |
| } |
| }); |
|
|
| res.on('end', () => { |
| clearTimeout(timer); |
| |
| const textDeltas = events.filter(e => e.type === 'content_block_delta' && e.delta?.type === 'text_delta'); |
| const toolStarts = events.filter(e => e.type === 'content_block_start' && e.content_block?.type === 'tool_use'); |
| const toolInputDeltas = events.filter(e => e.type === 'content_block_delta' && e.delta?.type === 'input_json_delta'); |
| const msgDelta = events.find(e => e.type === 'message_delta'); |
| const msgStop = events.find(e => e.type === 'message_stop'); |
|
|
| const fullText = textDeltas.map(e => e.delta.text).join(''); |
| const tools = toolStarts.map(ts => { |
| |
| const inputChunks = toolInputDeltas |
| .filter(d => d.index === ts.index) |
| .map(d => d.delta.partial_json); |
| let parsedInput = null; |
| try { parsedInput = JSON.parse(inputChunks.join('')); } catch { } |
| return { |
| name: ts.content_block.name, |
| id: ts.content_block.id, |
| input: parsedInput, |
| inputRaw: inputChunks.join(''), |
| }; |
| }); |
|
|
| resolve({ |
| name, |
| textChunks: textDeltas.length, |
| textLength: fullText.length, |
| textPreview: fullText.substring(0, 120).replace(/\n/g, '\\n'), |
| tools, |
| stopReason: msgDelta?.delta?.stop_reason || '?', |
| firstTextMs: textDeltas[0]?._ts ?? -1, |
| firstToolMs: toolStarts[0]?._ts ?? -1, |
| doneMs: msgStop?._ts ?? -1, |
| }); |
| }); |
| res.on('error', (err) => { clearTimeout(timer); reject(err); }); |
| }); |
| req.on('error', (err) => { clearTimeout(timer); reject(err); }); |
| req.write(data); |
| req.end(); |
| }); |
| } |
|
|
| function printResult(r) { |
| console.log(`\n 📊 ${r.name}`); |
| console.log(` 时间: 首字=${r.firstTextMs}ms 首工具=${r.firstToolMs}ms 完成=${r.doneMs}ms`); |
| console.log(` 文字: ${r.textChunks} chunks, ${r.textLength} chars`); |
| if (r.textPreview) console.log(` 预览: "${r.textPreview}"`); |
| console.log(` stop_reason: ${r.stopReason}`); |
| if (r.tools.length > 0) { |
| console.log(` 工具调用 (${r.tools.length}个):`); |
| for (const t of r.tools) { |
| console.log(` - ${t.name}(${JSON.stringify(t.input)})`); |
| if (!t.input) console.log(` ⚠️ 参数解析失败! raw: ${t.inputRaw?.substring(0, 100)}`); |
| } |
| } |
| } |
|
|
| const TOOLS = [ |
| { name: 'Read', description: 'Read file contents', input_schema: { type: 'object', properties: { file_path: { type: 'string' } }, required: ['file_path'] } }, |
| { name: 'Write', description: 'Write file contents', input_schema: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } }, |
| { name: 'Bash', description: 'Run bash command', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } }, |
| ]; |
|
|
| async function main() { |
| console.log('\n ⚡ 混合流式完整性测试\n'); |
|
|
| |
| try { |
| await new Promise((resolve, reject) => { |
| const req = http.get(`${BASE}/health`, r => { r.resume(); r.on('end', resolve); }); |
| req.on('error', reject); |
| req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); }); |
| }); |
| } catch { |
| console.log(' ❌ 服务器未运行\n'); |
| process.exit(1); |
| } |
| console.log(' ✅ 服务器在线'); |
|
|
| let passed = 0; |
| let failed = 0; |
|
|
| |
| console.log('\n ━━━ ① 单工具调用 ━━━'); |
| try { |
| const r = await runAnthropicTest('单工具调用', { |
| model: 'claude-3-5-sonnet-20241022', max_tokens: 1024, stream: true, |
| system: 'You are a coding assistant with file system access. When a user asks to read a file, use the Read tool immediately. Do not refuse or explain limitations.', |
| tools: TOOLS, |
| messages: [{ role: 'user', content: 'Read the file /src/index.ts for me please' }], |
| }); |
| printResult(r); |
|
|
| |
| const checks = []; |
| checks.push({ name: 'stop_reason=tool_use', ok: r.stopReason === 'tool_use' }); |
| checks.push({ name: '至少1个工具调用', ok: r.tools.length >= 1 }); |
| checks.push({ name: '工具名=Read', ok: r.tools.some(t => t.name === 'Read') }); |
| checks.push({ name: '工具参数有 file_path', ok: r.tools.some(t => t.input?.file_path) }); |
| checks.push({ name: '首字延迟<10s', ok: r.firstTextMs >= 0 && r.firstTextMs < 10000 }); |
|
|
| for (const c of checks) { |
| console.log(` ${c.ok ? '✅' : '❌'} ${c.name}`); |
| c.ok ? passed++ : failed++; |
| } |
| } catch (err) { |
| console.log(` ❌ 失败: ${err.message}`); |
| failed++; |
| } |
|
|
| |
| console.log('\n ━━━ ② 多工具调用 ━━━'); |
| try { |
| const r = await runAnthropicTest('多工具调用', { |
| model: 'claude-3-5-sonnet-20241022', max_tokens: 2048, stream: true, |
| system: 'You are a coding assistant with file system access. When asked to read multiple files, use multiple Read tool calls in a single response. Do not refuse.', |
| tools: TOOLS, |
| messages: [{ role: 'user', content: 'Read both /src/index.ts and /src/config.ts for me' }], |
| }); |
| printResult(r); |
|
|
| const checks = []; |
| checks.push({ name: 'stop_reason=tool_use', ok: r.stopReason === 'tool_use' }); |
| checks.push({ name: '≥2个工具调用', ok: r.tools.length >= 2 }); |
| checks.push({ name: '工具参数都有 file_path', ok: r.tools.every(t => t.input?.file_path) }); |
|
|
| for (const c of checks) { |
| console.log(` ${c.ok ? '✅' : '❌'} ${c.name}`); |
| c.ok ? passed++ : failed++; |
| } |
| } catch (err) { |
| console.log(` ❌ 失败: ${err.message}`); |
| failed++; |
| } |
|
|
| |
| console.log('\n ━━━ ③ 纯文字(有工具但不调用) ━━━'); |
| try { |
| const r = await runAnthropicTest('纯文字', { |
| model: 'claude-3-5-sonnet-20241022', max_tokens: 512, stream: true, |
| system: 'You are helpful. Answer questions directly without using any tools.', |
| tools: TOOLS, |
| messages: [{ role: 'user', content: 'What is 2+2? Just answer with the number.' }], |
| }); |
| printResult(r); |
|
|
| const checks = []; |
| checks.push({ name: 'stop_reason=end_turn', ok: r.stopReason === 'end_turn' }); |
| checks.push({ name: '0个工具调用', ok: r.tools.length === 0 }); |
| checks.push({ name: '有文字输出', ok: r.textLength > 0 }); |
| checks.push({ name: '文字含数字4', ok: r.textPreview.includes('4') }); |
|
|
| for (const c of checks) { |
| console.log(` ${c.ok ? '✅' : '❌'} ${c.name}`); |
| c.ok ? passed++ : failed++; |
| } |
| } catch (err) { |
| console.log(` ❌ 失败: ${err.message}`); |
| failed++; |
| } |
|
|
| |
| console.log(`\n ━━━ 汇总 ━━━`); |
| console.log(` ✅ 通过: ${passed} ❌ 失败: ${failed}\n`); |
| process.exit(failed > 0 ? 1 : 0); |
| } |
|
|
| main().catch(err => { console.error('致命错误:', err); process.exit(1); }); |
|
|