From 6df9eb06cd6b689336b7cfb3766b3119588cc07d Mon Sep 17 00:00:00 2001 From: myhearton <22749035@qq.com> Date: Mon, 25 May 2026 17:15:53 +0800 Subject: [PATCH] fix: strip |CHAT2API| markers when no valid tool calls parsed; add DeepSeek E2E tests --- .gitignore | 2 + .../proxy/toolCalling/ToolStreamParser.ts | 2 +- .../toolCalling/protocols/anthropicToolUse.ts | 8 +- .../toolCalling/protocols/managedBracket.ts | 7 +- .../proxy/toolCalling/protocols/managedXml.ts | 7 +- tests/providers/deepseek-e2e.test.ts | 282 ++++++++++++++++++ 6 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 tests/providers/deepseek-e2e.test.ts diff --git a/.gitignore b/.gitignore index d98ffcd..5d4cd44 100644 --- a/.gitignore +++ b/.gitignore @@ -52,9 +52,11 @@ coverage/ # Misc *.pem *.p12 +mitm/ # Next.js experimental app (not part of Electron build) src/renderer/next-app/ # Backup backup/ +.prettierrc diff --git a/src/main/proxy/toolCalling/ToolStreamParser.ts b/src/main/proxy/toolCalling/ToolStreamParser.ts index c21bd3e..72910c3 100644 --- a/src/main/proxy/toolCalling/ToolStreamParser.ts +++ b/src/main/proxy/toolCalling/ToolStreamParser.ts @@ -86,7 +86,7 @@ export class ToolStreamParser { } const shouldReleaseText = !this.emittedToolCall - const text = this.buffer + const text = parsed.content || this.buffer this.buffer = '' this.isBufferingToolCall = false return shouldReleaseText ? [createContentChunk(baseChunk, text, false)] : [] diff --git a/src/main/proxy/toolCalling/protocols/anthropicToolUse.ts b/src/main/proxy/toolCalling/protocols/anthropicToolUse.ts index a64ff3a..111884d 100644 --- a/src/main/proxy/toolCalling/protocols/anthropicToolUse.ts +++ b/src/main/proxy/toolCalling/protocols/anthropicToolUse.ts @@ -57,9 +57,13 @@ Use Anthropic-style tool invocation only when this protocol is enabled.` } } + const cleanContent = rawMatches.length > 0 + ? rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim() + : content + if (toolCalls.length === 0) { return createParseResult({ - content, + content: cleanContent, toolCalls, protocol: rawMatches.length > 0 ? 'anthropic_tool_use' : 'unknown', rawMatches, @@ -68,7 +72,7 @@ Use Anthropic-style tool invocation only when this protocol is enabled.` } return createParseResult({ - content: rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim(), + content: cleanContent, toolCalls, protocol: 'anthropic_tool_use', rawMatches, diff --git a/src/main/proxy/toolCalling/protocols/managedBracket.ts b/src/main/proxy/toolCalling/protocols/managedBracket.ts index f13b23e..bcc55f7 100644 --- a/src/main/proxy/toolCalling/protocols/managedBracket.ts +++ b/src/main/proxy/toolCalling/protocols/managedBracket.ts @@ -58,9 +58,13 @@ When calling tools, respond with only this block: } } + const cleanContent = rawMatches.length > 0 + ? rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim() + : content + if (toolCalls.length === 0) { return createParseResult({ - content, + content: cleanContent, toolCalls, protocol: rawMatches.length > 0 ? 'managed_bracket' : 'unknown', rawMatches, @@ -68,7 +72,6 @@ When calling tools, respond with only this block: }) } - const cleanContent = rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim() return createParseResult({ content: cleanContent, toolCalls, diff --git a/src/main/proxy/toolCalling/protocols/managedXml.ts b/src/main/proxy/toolCalling/protocols/managedXml.ts index e1c3afc..1517cb7 100644 --- a/src/main/proxy/toolCalling/protocols/managedXml.ts +++ b/src/main/proxy/toolCalling/protocols/managedXml.ts @@ -66,9 +66,13 @@ Tool results will be provided as Chat2API XML result blocks: toolCalls, }) + const cleanContent = rawMatches.length > 0 + ? rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim() + : content + if (toolCalls.length === 0) { return createParseResult({ - content, + content: cleanContent, toolCalls, protocol: rawMatches.length > 0 ? 'managed_xml' : 'unknown', rawMatches, @@ -76,7 +80,6 @@ Tool results will be provided as Chat2API XML result blocks: }) } - const cleanContent = rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim() return createParseResult({ content: cleanContent, toolCalls, diff --git a/tests/providers/deepseek-e2e.test.ts b/tests/providers/deepseek-e2e.test.ts new file mode 100644 index 0000000..2b0c12f --- /dev/null +++ b/tests/providers/deepseek-e2e.test.ts @@ -0,0 +1,282 @@ +import dotenv from 'dotenv' +import assert from 'node:assert/strict' +import http from 'node:http' +import test, { before, describe } from 'node:test' +dotenv.config() + +const PROXY_HOST = process.env.CHAT2API_HOST || '127.0.0.1' +const PROXY_PORT = parseInt(process.env.CHAT2API_PORT || '10701', 10) +const PROXY_BASE = `http://${PROXY_HOST}:${PROXY_PORT}` +const PROMPT = 'hello' + +let proxyAvailable = false + +function request( + method: string, + path: string, + body?: unknown, +): Promise<{ + status: number + headers: http.IncomingHttpHeaders + data: string +}> { + return new Promise((resolve, reject) => { + const url = new URL(path, PROXY_BASE) + const options: http.RequestOptions = { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.CHAT2API_API_KEY || ''}`, + }, + timeout: 60000, + } + + const req = http.request(options, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + resolve({ + status: res.statusCode || 0, + headers: res.headers, + data: Buffer.concat(chunks).toString(), + }) + }) + }) + + req.on('timeout', () => { + req.destroy() + reject(new Error('Request timed out')) + }) + req.on('error', reject) + + if (body) { + req.write(JSON.stringify(body)) + } + req.end() + }) +} + +describe('DeepSeek E2E', () => { + before(async () => { + try { + const { status } = await request('GET', '/health') + if (status === 200) { + proxyAvailable = true + console.log(`[E2E] Proxy is running at ${PROXY_BASE}`) + } else { + console.log(`[E2E] Proxy health check returned status ${status}`) + } + } catch (err) { + console.log( + `[E2E] Proxy is not available at ${PROXY_BASE}: ${err instanceof Error ? err.message : err}`, + ) + console.log( + '[E2E] Start Chat2API and ensure a DeepSeek account is configured before running E2E tests.', + ) + } + }) + + // test('deepseek-v4-pro-think-search: non-stream chat with prompt "hello"', async (t) => { + // if (!proxyAvailable) { + // t.skip(); + // return; + // } + + // const { status, data } = await request("POST", "/v1/chat/completions", { + // model: "deepseek-v4-pro-think-search", + // messages: [{ role: "user", content: PROMPT }], + // stream: false, + // }); + + // if (status === 401) { + // console.log( + // "[E2E] API key required - set CHAT2API_API_KEY env var or disable API key in config", + // ); + // } + + // assert.equal( + // status, + // 200, + // `Expected 200, got ${status}: ${data.slice(0, 500)}`, + // ); + + // const body = JSON.parse(data); + // assert.ok(body.id, "Response should have an id"); + // assert.equal(body.object, "chat.completion"); + // assert.equal(body.model, "deepseek-v4-pro-think-search"); + // assert.ok( + // Array.isArray(body.choices) && body.choices.length > 0, + // "Response should have choices", + // ); + // assert.equal(body.choices[0].message.role, "assistant"); + // assert.ok( + // typeof body.choices[0].message.content === "string", + // "Response should have text content", + // ); + // assert.ok( + // body.choices[0].message.content.length > 0, + // "Response content should not be empty", + // ); + // assert.ok(body.usage, "Response should include usage info"); + // }); + + // test('deepseek-v4-flash-think: non-stream chat with prompt "hello"', async (t) => { + // if (!proxyAvailable) { + // t.skip(); + // return; + // } + + // const { status, data } = await request("POST", "/v1/chat/completions", { + // model: "deepseek-v4-flash-think", + // messages: [{ role: "user", content: PROMPT }], + // stream: false, + // }); + + // if (status === 401) { + // console.log( + // "[E2E] API key required - set CHAT2API_API_KEY env var or disable API key in config", + // ); + // } + + // assert.equal( + // status, + // 200, + // `Expected 200, got ${status}: ${data.slice(0, 500)}`, + // ); + + // const body = JSON.parse(data); + // assert.ok(body.id, "Response should have an id"); + // assert.equal(body.object, "chat.completion"); + // assert.equal(body.model, "deepseek-v4-flash-think"); + // assert.ok( + // Array.isArray(body.choices) && body.choices.length > 0, + // "Response should have choices", + // ); + // assert.equal(body.choices[0].message.role, "assistant"); + // assert.ok( + // typeof body.choices[0].message.content === "string", + // "Response should have text content", + // ); + // assert.ok( + // body.choices[0].message.content.length > 0, + // "Response content should not be empty", + // ); + // assert.ok(body.usage, "Response should include usage info"); + // }); + + test('deepseek-v4-pro-think-search: stream chat with prompt "hello"', async (t) => { + if (!proxyAvailable) { + t.skip() + return + } + + const { status, headers, data } = await request('POST', '/v1/chat/completions', { + model: 'deepseek-v4-pro-think-search', + messages: [{ role: 'user', content: 'hello. stream. pro' }], + stream: true, + }) + + if (status === 401) { + console.log( + '[E2E] API key required - set CHAT2API_API_KEY env var or disable API key in config', + ) + } + + assert.equal(status, 200, `Expected 200, got ${status}`) + assert.ok( + headers['content-type']?.includes('text/event-stream'), + 'Response should be SSE', + ) + + console.log('=== DeepSeek E2E Response ===') + // console.log("Response:", data); + + const lines = data.split('\n').filter((l) => l.startsWith('data:')) + assert.ok(lines.length > 0, 'SSE stream should contain data lines') + + const lastLine = lines[lines.length - 1].trim() + assert.equal(lastLine, 'data: [DONE]', 'Stream should end with [DONE]') + + let hasContent = false + for (let i = 0; i < lines.length - 1; i++) { + const json = lines[i].slice(5).trim() + if (!json) continue + try { + const chunk = JSON.parse(json) + assert.ok(chunk.id, 'Chunk should have an id') + assert.equal(chunk.object, 'chat.completion.chunk') + assert.equal(chunk.model, 'deepseek-v4-pro-think-search') + assert.ok( + Array.isArray(chunk.choices) && chunk.choices.length > 0, + 'Chunk should have choices', + ) + if (chunk.choices[0].delta?.content) { + hasContent = true + } + } catch { + // skip unparseable lines + } + } + assert.ok(hasContent, 'At least one chunk should contain content') + }) + + // test('deepseek-v4-flash-think: stream chat with prompt "hello"', async (t) => { + // if (!proxyAvailable) { + // t.skip(); + // return; + // } + + // const { status, headers, data } = await request( + // "POST", + // "/v1/chat/completions", + // { + // model: "deepseek-v4-flash-think", + // messages: [{ role: "user", content: "hello. stream. flash" }], + // stream: true, + // }, + // ); + + // if (status === 401) { + // console.log( + // "[E2E] API key required - set CHAT2API_API_KEY env var or disable API key in config", + // ); + // } + + // assert.equal(status, 200, `Expected 200, got ${status}`); + // assert.ok( + // headers["content-type"]?.includes("text/event-stream"), + // "Response should be SSE", + // ); + + // const lines = data.split("\n").filter((l) => l.startsWith("data:")); + // assert.ok(lines.length > 0, "SSE stream should contain data lines"); + + // const lastLine = lines[lines.length - 1].trim(); + // assert.equal(lastLine, "data: [DONE]", "Stream should end with [DONE]"); + + // let hasContent = false; + // for (let i = 0; i < lines.length - 1; i++) { + // const json = lines[i].slice(5).trim(); + // if (!json) continue; + // try { + // const chunk = JSON.parse(json); + // assert.ok(chunk.id, "Chunk should have an id"); + // assert.equal(chunk.object, "chat.completion.chunk"); + // assert.equal(chunk.model, "deepseek-v4-flash-think"); + // assert.ok( + // Array.isArray(chunk.choices) && chunk.choices.length > 0, + // "Chunk should have choices", + // ); + // if (chunk.choices[0].delta?.content) { + // hasContent = true; + // } + // } catch { + // // skip unparseable lines + // } + // } + // assert.ok(hasContent, "At least one chunk should contain content"); + // }); +})