测试 copilot-api streaming 模式下 tool_calls arguments 是否被截断。 已知 macOS/Windows 可能截断 >2000 字符的 arguments。 用法: COPILOT_API_URL=http://localhost:4141 npx tsx diagnose-toolcall-truncation.ts 🐉 Authored by 敖丙 (RAKU)
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* copilot-api streaming tool call 截断诊断脚本
|
|
*
|
|
* 测试 copilot-api 的 streaming response 是否会截断 tool_calls 的 arguments。
|
|
* 已知问题:非 Linux 平台(Windows/macOS)的 copilot-api 在 streaming 模式下
|
|
* 可能截断长 tool call arguments(>2000 字符)。
|
|
*
|
|
* 用法:
|
|
* COPILOT_API_URL=http://localhost:4141 npx tsx diagnose-toolcall-truncation.ts
|
|
*
|
|
* 需要 copilot-api 在本地运行。
|
|
*
|
|
* 🐉 Created by 敖丙 (RAKU) — 2026-03-31
|
|
*/
|
|
|
|
const COPILOT_API_URL = process.env.COPILOT_API_URL || "http://localhost:4141";
|
|
const MODEL = process.env.MODEL || "claude-sonnet-4.6";
|
|
|
|
interface StreamDelta {
|
|
tool_calls?: Array<{
|
|
index: number;
|
|
function?: { name?: string; arguments?: string };
|
|
}>;
|
|
}
|
|
|
|
async function testStreamingToolCall(charTarget: number): Promise<{
|
|
passed: boolean;
|
|
expected: number;
|
|
received: number;
|
|
truncated: boolean;
|
|
error?: string;
|
|
}> {
|
|
const prompt = `You must call the "echo_back" tool with a JSON argument "data" containing a string of exactly ${charTarget} 'x' characters. Do not explain, just call the tool immediately. The data field should be: "${"x".repeat(charTarget)}"`;
|
|
|
|
const body = {
|
|
model: MODEL,
|
|
stream: true,
|
|
messages: [
|
|
{ role: "system", content: "You are a test assistant. Always use tools when instructed. Never refuse." },
|
|
{ role: "user", content: prompt },
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "echo_back",
|
|
description: "Echoes back the data string. Used for testing.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
data: { type: "string", description: "The string to echo back" },
|
|
},
|
|
required: ["data"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
tool_choice: { type: "function", function: { name: "echo_back" } },
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(`${COPILOT_API_URL}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: "Bearer dummy" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
return { passed: false, expected: charTarget, received: 0, truncated: false, error: `HTTP ${resp.status}: ${await resp.text()}` };
|
|
}
|
|
|
|
// Parse SSE stream
|
|
const text = await resp.text();
|
|
const lines = text.split("\n").filter((l) => l.startsWith("data: ") && l !== "data: [DONE]");
|
|
|
|
let collectedArgs = "";
|
|
let toolName = "";
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const json = JSON.parse(line.slice(6));
|
|
const delta: StreamDelta = json.choices?.[0]?.delta || {};
|
|
if (delta.tool_calls) {
|
|
for (const tc of delta.tool_calls) {
|
|
if (tc.function?.name) toolName = tc.function.name;
|
|
if (tc.function?.arguments) collectedArgs += tc.function.arguments;
|
|
}
|
|
}
|
|
} catch {
|
|
// skip non-JSON lines
|
|
}
|
|
}
|
|
|
|
// Try to parse the collected arguments
|
|
let dataLength = 0;
|
|
try {
|
|
const parsed = JSON.parse(collectedArgs);
|
|
dataLength = (parsed.data || "").length;
|
|
} catch {
|
|
// Arguments might be truncated JSON
|
|
const match = collectedArgs.match(/"data"\s*:\s*"(x+)/);
|
|
dataLength = match ? match[1].length : 0;
|
|
}
|
|
|
|
const truncated = dataLength < charTarget * 0.9; // Allow 10% tolerance (LLM might not generate exact count)
|
|
return {
|
|
passed: !truncated,
|
|
expected: charTarget,
|
|
received: dataLength,
|
|
truncated,
|
|
};
|
|
} catch (err: any) {
|
|
return { passed: false, expected: charTarget, received: 0, truncated: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log("╔══════════════════════════════════════════════════════════════╗");
|
|
console.log("║ copilot-api Streaming Tool Call 截断诊断 ║");
|
|
console.log("╚══════════════════════════════════════════════════════════════╝");
|
|
console.log();
|
|
console.log(`API: ${COPILOT_API_URL}`);
|
|
console.log(`Model: ${MODEL}`);
|
|
console.log();
|
|
|
|
// Test connectivity first
|
|
try {
|
|
const ping = await fetch(`${COPILOT_API_URL}/v1/models`, {
|
|
headers: { Authorization: "Bearer dummy" },
|
|
});
|
|
if (!ping.ok) {
|
|
console.error(`❌ API 连接失败: HTTP ${ping.status}`);
|
|
process.exit(1);
|
|
}
|
|
console.log("✅ API 连接正常\n");
|
|
} catch (err: any) {
|
|
console.error(`❌ API 连接失败: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const testSizes = [500, 1000, 2000, 3000, 5000];
|
|
let allPassed = true;
|
|
|
|
console.log("测试 | 目标字符数 | 实收字符数 | 结果");
|
|
console.log("-----|----------|----------|------");
|
|
|
|
for (const size of testSizes) {
|
|
process.stdout.write(` ${size.toString().padStart(4)} |`);
|
|
const result = await testStreamingToolCall(size);
|
|
|
|
if (result.error) {
|
|
console.log(` ${size.toString().padStart(8)} | | ❌ ${result.error}`);
|
|
allPassed = false;
|
|
continue;
|
|
}
|
|
|
|
const status = result.passed ? "✅ PASS" : "❌ TRUNCATED";
|
|
const ratio = ((result.received / result.expected) * 100).toFixed(0);
|
|
console.log(` ${result.expected.toString().padStart(8)} | ${result.received.toString().padStart(8)} | ${status} (${ratio}%)`);
|
|
|
|
if (!allPassed && result.truncated) allPassed = false;
|
|
if (result.truncated) allPassed = false;
|
|
}
|
|
|
|
console.log();
|
|
if (allPassed) {
|
|
console.log("🎉 所有测试通过!Streaming tool call 没有截断问题。");
|
|
} else {
|
|
console.log("⚠️ 检测到截断!建议:");
|
|
console.log(" 1. 检查 copilot-api 版本和平台");
|
|
console.log(" 2. Linux 平台通常没有此问题");
|
|
console.log(" 3. macOS/Windows 可以考虑:");
|
|
console.log(" a) 升级 copilot-api 到最新版");
|
|
console.log(" b) 使用 stream-strip-proxy 中间层");
|
|
console.log(" c) 关闭 streaming(性能代价大)");
|
|
console.log(" 4. 参考 RAKU 的经验:迁移到 Linux 后问题消失");
|
|
}
|
|
|
|
process.exit(allPassed ? 0 : 1);
|
|
}
|
|
|
|
main().catch(console.error);
|