| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import http from 'http'; |
|
|
| const BASE = process.env.BASE_URL || 'http://localhost:3010'; |
| const url = new URL(BASE); |
|
|
| let passed = 0; |
| let failed = 0; |
|
|
| function runAnthropicTest(name, body, timeoutMs = 120000) { |
| return new Promise((resolve, reject) => { |
| const timer = setTimeout(() => reject(new Error(`超时 ${timeoutMs}ms`)), timeoutMs); |
| const data = JSON.stringify(body); |
| const req = http.request({ |
| hostname: url.hostname, port: url.port || 3010, 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) => { |
| let buf = ''; |
| const events = []; |
| 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; |
| try { events.push(JSON.parse(line.slice(6).trim())); } catch { } |
| } |
| }); |
| res.on('end', () => { clearTimeout(timer); resolve(events); }); |
| res.on('error', err => { clearTimeout(timer); reject(err); }); |
| }); |
| req.on('error', err => { clearTimeout(timer); reject(err); }); |
| req.write(data); |
| req.end(); |
| }); |
| } |
|
|
| function parseEvents(events) { |
| let thinkingContent = ''; |
| let textContent = ''; |
| let stopReason = ''; |
|
|
| for (const ev of events) { |
| if (ev.type === 'content_block_delta') { |
| if (ev.delta?.type === 'thinking_delta') thinkingContent += ev.delta.thinking || ''; |
| if (ev.delta?.type === 'text_delta') textContent += ev.delta.text || ''; |
| } |
| if (ev.type === 'message_delta') stopReason = ev.delta?.stop_reason || ''; |
| } |
| return { thinkingContent, textContent, stopReason }; |
| } |
|
|
| async function test(name, fn) { |
| try { |
| await fn(); |
| console.log(` ✅ ${name}`); |
| passed++; |
| } catch (err) { |
| console.error(` ❌ ${name}`); |
| console.error(` ${err.message}`); |
| failed++; |
| } |
| } |
|
|
| function assert(cond, msg) { |
| if (!cond) throw new Error(msg || 'Assertion failed'); |
| } |
|
|
| const TOOLS = [ |
| { |
| name: 'Write', |
| description: 'Write a file', |
| input_schema: { |
| type: 'object', |
| properties: { |
| file_path: { type: 'string' }, |
| content: { type: 'string' }, |
| }, |
| required: ['file_path', 'content'], |
| }, |
| }, |
| { |
| name: 'Read', |
| description: 'Read a file', |
| input_schema: { |
| type: 'object', |
| properties: { file_path: { type: 'string' } }, |
| required: ['file_path'], |
| }, |
| }, |
| ]; |
|
|
| console.log('\n📦 E2E: thinking 截断场景测试\n'); |
| console.log(` 服务地址: ${BASE}`); |
| console.log(` 注意:以下测试需要模型实际支持 thinking 模式\n`); |
|
|
| |
| await test('thinking 模式:thinking block 出现在正文之前,不泄漏到 text', async () => { |
| const events = await runAnthropicTest('thinking-basic', { |
| model: 'claude-sonnet-4-6-thinking', |
| max_tokens: 16000, |
| thinking: { type: 'enabled', budget_tokens: 10000 }, |
| messages: [{ |
| role: 'user', |
| content: '简单回答:1+1等于几?', |
| }], |
| stream: true, |
| }); |
|
|
| const { thinkingContent, textContent } = parseEvents(events); |
|
|
| |
| assert(thinkingContent.length > 0, `期望有 thinking block,实际为空`); |
|
|
| |
| assert( |
| !textContent.includes('<thinking>'), |
| `正文不应包含 <thinking> 标签,实际正文: ${textContent.substring(0, 200)}`, |
| ); |
| assert( |
| !textContent.includes('</thinking>'), |
| `正文不应包含 </thinking> 标签`, |
| ); |
|
|
| |
| assert(textContent.trim().length > 0, `正文应有内容,实际为空`); |
|
|
| console.log(` thinking: ${thinkingContent.length} chars, text: ${textContent.length} chars`); |
| }); |
|
|
| |
| await test('非 thinking 模式:即使模型输出 <thinking> 也不泄漏到正文', async () => { |
| |
| const events = await runAnthropicTest('thinking-leak', { |
| model: 'claude-sonnet-4-6-thinking', |
| max_tokens: 8000, |
| |
| messages: [{ |
| role: 'user', |
| content: '请用中文简短回答:什么是递归?', |
| }], |
| stream: true, |
| }); |
|
|
| const { textContent } = parseEvents(events); |
|
|
| assert( |
| !textContent.includes('<thinking>'), |
| `正文不应包含 <thinking> 开标签,实际: ${textContent.substring(0, 300)}`, |
| ); |
| assert( |
| !textContent.includes('</thinking>'), |
| `正文不应包含 </thinking> 闭标签`, |
| ); |
| console.log(` text: ${textContent.length} chars, preview: ${textContent.substring(0, 80).replace(/\n/g, '\\n')}`); |
| }); |
|
|
| |
| await test('thinking + 工具调用:工具参数完整,thinking 不泄漏', async () => { |
| const events = await runAnthropicTest('thinking-tools', { |
| model: 'claude-sonnet-4-6-thinking', |
| max_tokens: 16000, |
| thinking: { type: 'enabled', budget_tokens: 8000 }, |
| tools: TOOLS, |
| messages: [{ |
| role: 'user', |
| content: '请用 Write 工具写一个包含 50 行注释的 Python hello world 文件到 /tmp/hello.py', |
| }], |
| stream: true, |
| }); |
|
|
| const { thinkingContent, textContent } = parseEvents(events); |
|
|
| |
| 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 toolInputRaw = toolInputDeltas.map(e => e.delta.partial_json || '').join(''); |
|
|
| assert( |
| !textContent.includes('<thinking>') && !textContent.includes('</thinking>'), |
| `正文不应包含 thinking 标签,实际: ${textContent.substring(0, 200)}`, |
| ); |
|
|
| if (toolStarts.length > 0) { |
| |
| let toolInput = {}; |
| try { toolInput = JSON.parse(toolInputRaw); } catch (e) { |
| throw new Error(`工具调用参数 JSON 解析失败: ${e.message}\n原始: ${toolInputRaw.substring(0, 200)}`); |
| } |
| assert(typeof toolInput.file_path === 'string', '工具参数应包含 file_path'); |
| assert(typeof toolInput.content === 'string', '工具参数应包含 content'); |
| console.log(` thinking: ${thinkingContent.length} chars, tool: ${toolStarts[0]?.content_block?.name}, content: ${toolInput.content?.length} chars`); |
| } else { |
| |
| assert(textContent.trim().length > 0, '无工具调用时正文不应为空'); |
| console.log(` thinking: ${thinkingContent.length} chars, text: ${textContent.length} chars (无工具调用)`); |
| } |
| }); |
|
|
| |
| console.log(`\n结果:${passed} 通过,${failed} 失败\n`); |
| if (failed > 0) process.exit(1); |
|
|