fix(deepseek): sanitize empty OpenAI-compat SSE fields

Strip dummy finish_reason/function_call payloads so downstream clients do not treat incomplete chunks as real tool calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-09-14 11:28:56 +08:00
parent 9862662bb2
commit f09a03b8b2
2 changed files with 123 additions and 1 deletions
+107 -1
View File
@@ -111,6 +111,90 @@ export function injectDeepseekThinkingDisabled(body) {
};
}
function isEmptyFunctionCall(value) {
if (value == null) return true;
if (typeof value !== 'object' || Array.isArray(value)) return false;
const name = String(value.name ?? '').trim();
const args = String(value.arguments ?? '').trim();
return !name && !args;
}
export function sanitizeOpenAiCompatChatChunk(chunk) {
if (!chunk || typeof chunk !== 'object' || Array.isArray(chunk)) {
return { chunk, changed: false };
}
const next = { ...chunk };
let changed = false;
if (Array.isArray(chunk.choices)) {
next.choices = chunk.choices.map((choice) => {
if (!choice || typeof choice !== 'object') return choice;
const patched = { ...choice };
if (patched.finish_reason === '') {
patched.finish_reason = null;
changed = true;
}
const delta = patched.delta;
if (delta && typeof delta === 'object' && !Array.isArray(delta)) {
const nextDelta = { ...delta };
if (nextDelta.content === '') {
delete nextDelta.content;
changed = true;
}
if (nextDelta.reasoning_content === '') {
delete nextDelta.reasoning_content;
changed = true;
}
if (nextDelta.refusal === '') {
delete nextDelta.refusal;
changed = true;
}
if (nextDelta.extra_fields == null) {
delete nextDelta.extra_fields;
changed = true;
}
if (Array.isArray(nextDelta.tool_calls) && nextDelta.tool_calls.length === 0) {
delete nextDelta.tool_calls;
changed = true;
}
if (isEmptyFunctionCall(nextDelta.function_call)) {
delete nextDelta.function_call;
changed = true;
}
const hasPayload = Object.keys(nextDelta).some((key) => key !== 'role');
if (nextDelta.role && !hasPayload) {
delete nextDelta.role;
changed = true;
}
patched.delta = nextDelta;
}
return patched;
});
}
return { chunk: changed ? next : chunk, changed };
}
export function sanitizeOpenAiCompatSseText(text) {
const raw = String(text ?? '');
if (!raw) return { text: raw, changed: false };
let changed = false;
const lines = raw.split('\n');
const out = lines.map((line) => {
if (!line.startsWith('data:')) return line;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') return line;
try {
const parsed = JSON.parse(payload);
const result = sanitizeOpenAiCompatChatChunk(parsed);
if (!result.changed) return line;
changed = true;
return `data: ${JSON.stringify(result.chunk)}`;
} catch {
return line;
}
});
return { text: changed ? out.join('\n') : raw, changed };
}
function decodeJsonPointerToken(value) {
return String(value ?? '').replace(/~1/g, '/').replace(/~0/g, '~');
}
@@ -339,12 +423,34 @@ export function createDeepseekNoThinkProxy({
res.end();
return;
}
const isSse = String(upstream.headers.get('content-type') ?? '')
.toLowerCase()
.includes('text/event-stream');
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let sseCarry = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
if (!isSse) {
res.write(Buffer.from(value));
continue;
}
sseCarry += decoder.decode(value, { stream: true });
const lines = sseCarry.split('\n');
sseCarry = lines.pop() ?? '';
for (const line of lines) {
const sanitized = sanitizeOpenAiCompatSseText(`${line}\n`);
res.write(sanitized.text);
}
}
if (isSse) {
sseCarry += decoder.decode();
if (sseCarry) {
const sanitized = sanitizeOpenAiCompatSseText(sseCarry);
res.write(sanitized.text);
}
}
res.end();
} catch (err) {
+16
View File
@@ -6,6 +6,7 @@ import {
deepseekDisableThinkingEnabled,
flattenLocalJsonSchemaRefs,
injectDeepseekThinkingDisabled,
sanitizeOpenAiCompatSseText,
isMoonshotApiUrl,
moonshotToolSchemaCompatEnabled,
resolveDeepseekNoThinkProxyBaseUrl,
@@ -24,6 +25,21 @@ test('injectDeepseekThinkingDisabled adds thinking.disabled when absent', () =>
assert.equal(body.model, 'deepseek-v4-flash');
});
test('sanitizeOpenAiCompatSseText strips empty finish_reason and dummy function_call', () => {
const raw = [
'data: {"choices":[{"delta":{"role":"assistant","content":"","tool_calls":[],"function_call":null},"finish_reason":""}]}',
'data: {"choices":[{"delta":{"content":"四季分明","function_call":{"name":"","arguments":""}},"finish_reason":"stop"}]}',
'data: [DONE]',
'',
].join('\n');
const { text, changed } = sanitizeOpenAiCompatSseText(raw);
assert.equal(changed, true);
assert.match(text, /"content":"四季分明"/);
assert.doesNotMatch(text, /"finish_reason":""/);
assert.doesNotMatch(text, /"function_call"/);
assert.match(text, /data: \[DONE\]/);
});
test('injectDeepseekThinkingDisabled overrides enabled thinking config', () => {
const { body, injected } = injectDeepseekThinkingDisabled({
model: 'deepseek-v4-pro',