Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/main/proxy/toolCalling/ToolStreamParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class ToolStreamParser {
}

const shouldReleaseText = !this.emittedToolCall
const text = this.buffer
const text = parsed.content || this.buffer
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve empty parsed content in stream flush

Using parsed.content || this.buffer treats an intentionally empty cleaned result as falsy and falls back to the raw buffered payload. When the parser removes a malformed/unsupported tool block and no user-visible text remains, flush() emits the original |CHAT2API|... block back to clients, so the marker-leak bug still reproduces at end-of-stream. Use a nullish check (parsed.content ?? this.buffer) or equivalent to preserve valid empty strings.

Useful? React with 👍 / 👎.

this.buffer = ''
this.isBufferingToolCall = false
return shouldReleaseText ? [createContentChunk(baseChunk, text, false)] : []
Expand Down
8 changes: 6 additions & 2 deletions src/main/proxy/toolCalling/protocols/anthropicToolUse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/main/proxy/toolCalling/protocols/managedBracket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,20 @@ 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,
invalidToolNames,
})
}

const cleanContent = rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim()
return createParseResult({
content: cleanContent,
toolCalls,
Expand Down
7 changes: 5 additions & 2 deletions src/main/proxy/toolCalling/protocols/managedXml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,20 @@ 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
Comment on lines +69 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dropping fenced code when cleaning invalid tool blocks

This cleanup is computed from parseable, which is content after stripFencedCodeBlocks(). In the toolCalls.length === 0 path, responses that contain an invalid tool block plus any unrelated triple-backtick code lose the entire fenced section, which is a regression from previous behavior where non-tool content was preserved. Clean against the original content (or otherwise reinsert fenced segments) so only the matched tool block is removed.

Useful? React with 👍 / 👎.


if (toolCalls.length === 0) {
return createParseResult({
content,
content: cleanContent,
toolCalls,
protocol: rawMatches.length > 0 ? 'managed_xml' : 'unknown',
rawMatches,
invalidToolNames,
})
}

const cleanContent = rawMatches.reduce((acc, raw) => acc.replace(raw, ''), parseable).trim()
return createParseResult({
content: cleanContent,
toolCalls,
Expand Down
282 changes: 282 additions & 0 deletions tests/providers/deepseek-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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");
// });
})