From f09a03b8b2c2b71194ab1962c00c501900533cfe Mon Sep 17 00:00:00 2001 From: John Date: Mon, 14 Sep 2026 11:28:56 +0800 Subject: [PATCH] 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 --- deepseek-no-think-proxy.mjs | 108 ++++++++++++++++++++++++++++++- deepseek-no-think-proxy.test.mjs | 16 +++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/deepseek-no-think-proxy.mjs b/deepseek-no-think-proxy.mjs index 2310248..dec84a4 100644 --- a/deepseek-no-think-proxy.mjs +++ b/deepseek-no-think-proxy.mjs @@ -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) { diff --git a/deepseek-no-think-proxy.test.mjs b/deepseek-no-think-proxy.test.mjs index 356ebef..2447803 100644 --- a/deepseek-no-think-proxy.test.mjs +++ b/deepseek-no-think-proxy.test.mjs @@ -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',