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:
+107
-1
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user