From cbb23d265066c160171f9d131a2d95bdda64f60c Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 4 Aug 2026 10:03:40 +0800 Subject: [PATCH] fix(proxy): recognize DeepSeek reasoning stream content Fixes #1394 --- .../proxy/stream-gate/frame-classifier.ts | 3 ++- .../proxy/stream-gate-content-gate.test.ts | 18 +++++++++++++ .../stream-gate-forwarder-integration.test.ts | 25 +++++++++++++++++++ .../stream-gate-frame-classifier.test.ts | 14 +++++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts b/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts index 0db984c66..f89995484 100644 --- a/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts +++ b/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts @@ -97,9 +97,10 @@ const STREAM_SIGNALS: Record = { "openai-chat": { contentRules: [ { - // chunk 无事件名;delta 携带 content/tool_calls/refusal/audio 即内容 + // chunk 无事件名;delta 携带 content/reasoning/tool_calls/refusal/audio 即内容 anyPaths: [ "choices.#.delta.content", + "choices.#.delta.reasoning_content", "choices.#.delta.tool_calls.#.function.arguments", "choices.#.delta.function_call.arguments", "choices.#.delta.refusal", diff --git a/tests/unit/proxy/stream-gate-content-gate.test.ts b/tests/unit/proxy/stream-gate-content-gate.test.ts index 10cb9066d..f0bd9324e 100644 --- a/tests/unit/proxy/stream-gate-content-gate.test.ts +++ b/tests/unit/proxy/stream-gate-content-gate.test.ts @@ -216,6 +216,24 @@ describe("runStreamContentGate", () => { } }); + it("openai-chat: DeepSeek reasoning_content commits before the default event cap", async () => { + const reasoningFrames = Array.from( + { length: 65 }, + (_, index) => + `data: {"choices":[{"delta":{"reasoning_content":"reasoning step ${index}"}}]}\n\n` + ); + const reader = readerFromChunks(reasoningFrames); + const result = await runStreamContentGate(reader, { + ...GATE_OPTIONS, + family: "openai-chat", + }); + + expect(result.committed).toBe(true); + if (!result.committed) return; + expect(await drainPrefix(result.prefixChunks)).toBe(reasoningFrames[0]); + expect(result.readerDone).toBe(false); + }); + it("gemini: usage-only chunks buffer until content commits", async () => { const reader = readerFromChunks([ 'data: {"usageMetadata":{"totalTokenCount":1}}\n\n', diff --git a/tests/unit/proxy/stream-gate-forwarder-integration.test.ts b/tests/unit/proxy/stream-gate-forwarder-integration.test.ts index 263a264a6..8ce3c6296 100644 --- a/tests/unit/proxy/stream-gate-forwarder-integration.test.ts +++ b/tests/unit/proxy/stream-gate-forwarder-integration.test.ts @@ -606,6 +606,31 @@ describe("F1 stream content gate x ProxyForwarder sequential path", () => { } ); + test("Replay owner 将 OpenAI-compatible DeepSeek reasoning_content 视为首个有效内容", async () => { + const provider = createProvider({ + id: 1, + name: "deepseek-reasoning", + providerType: "openai-compatible", + }); + const session = createSession(); + session.setProvider(provider); + attachReplayOwner(session, REPLAY_GATE_CASES[1]); + + const reasoningFrames = Array.from({ length: 65 }, (_, index) => + sseFrame(null, { choices: [{ delta: { reasoning_content: `reasoning step ${index}` } }] }) + ); + const doForward = spyOnDoForward(); + doForward.mockImplementationOnce(async () => createSseResponse(reasoningFrames)); + + const response = await ProxyForwarder.send(session); + const text = await response.text(); + + expect(doForward).toHaveBeenCalledTimes(1); + expect(text).toBe(reasoningFrames.join("")); + expect(mocks.pickRandomProviderWithExclusion).not.toHaveBeenCalled(); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + }); + test("Replay owner 在所有 precommit attempt 失败后立即释放所有权", async () => { const provider = createProvider({ id: 1, name: "replay-only", providerType: "codex" }); const session = createSession(); diff --git a/tests/unit/proxy/stream-gate-frame-classifier.test.ts b/tests/unit/proxy/stream-gate-frame-classifier.test.ts index b3b4ae08b..0a139bbeb 100644 --- a/tests/unit/proxy/stream-gate-frame-classifier.test.ts +++ b/tests/unit/proxy/stream-gate-frame-classifier.test.ts @@ -159,10 +159,17 @@ describe("classifyFrame: anthropic", () => { }); describe("classifyFrame: openai-chat", () => { - it("content: delta content / tool arguments / refusal / audio", () => { + it("content: delta content / reasoning / tool arguments / refusal / audio", () => { expect(classifyFrame("openai-chat", null, '{"choices":[{"delta":{"content":"hi"}}]}')).toBe( "content" ); + expect( + classifyFrame( + "openai-chat", + null, + '{"choices":[{"delta":{"reasoning_content":"reasoning step"}}]}' + ) + ).toBe("content"); expect( classifyFrame( "openai-chat", @@ -197,10 +204,13 @@ describe("classifyFrame: openai-chat", () => { ).toBe("neutral"); }); - it("neutral: empty string content delta", () => { + it("neutral: empty string content or reasoning delta", () => { expect(classifyFrame("openai-chat", null, '{"choices":[{"delta":{"content":""}}]}')).toBe( "neutral" ); + expect( + classifyFrame("openai-chat", null, '{"choices":[{"delta":{"reasoning_content":""}}]}') + ).toBe("neutral"); }); it("error: in-stream error payload", () => {