diff --git a/changelog.d/fixes/13599-deepseek-bai-reasoning-content-echo.md b/changelog.d/fixes/13599-deepseek-bai-reasoning-content-echo.md new file mode 100644 index 0000000000..9f7e923912 --- /dev/null +++ b/changelog.d/fixes/13599-deepseek-bai-reasoning-content-echo.md @@ -0,0 +1 @@ +- **fix(providers):** echo back `reasoning_content` on `bai` DeepSeek thinking-mode follow-up turns, fixing the upstream 400 "reasoning_content must be passed back" (#13599) — thanks @afonsoft diff --git a/open-sse/config/providers/registry/bai/index.ts b/open-sse/config/providers/registry/bai/index.ts index 574362693c..6423f1c2ec 100644 --- a/open-sse/config/providers/registry/bai/index.ts +++ b/open-sse/config/providers/registry/bai/index.ts @@ -11,4 +11,7 @@ export const baiProvider: RegistryEntry = { modelsUrl: "https://api.b.ai/v1/models", models: [], passthroughModels: true, + // #13599: bai resells DeepSeek's `deepseek-reasoner` thinking-mode models, which 400 + // when a prior assistant turn is missing `reasoning_content` on a follow-up request. + requiresReasoningContentEcho: true, }; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 250d631775..8007b12627 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -144,6 +144,16 @@ export interface RegistryEntry { responsesBaseUrl?: string; /** Provider-bound replay format; omitted providers accept portable plaintext reasoning. */ reasoningTransport?: ReasoningTransport; + /** + * Thinking-mode upstreams proxied by this provider require the assistant's + * prior-turn `reasoning_content` to be echoed back on every follow-up request + * (e.g. DeepSeek-reselling gateways such as `bai`). Standard OpenAI-shaped + * clients do not preserve that field when replaying history, so when this is + * `true`, DefaultExecutor injects a placeholder via + * `open-sse/utils/reasoningContentInjector.ts` for model ids matching + * `isThinkingMessageModel()`. See issue #13599. + */ + requiresReasoningContentEcho?: boolean; /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used * for models tagged `targetFormat: "claude"` on an otherwise openai-format * provider — see registry/github/index.ts. */ diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index c267904d9f..3887515203 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -1013,18 +1013,19 @@ export class DefaultExecutor extends BaseExecutor { this.ensureThinkingBudget(withDefaults as Record, model); } - // 9router#1480: native Moonshot providers 400 when a prior assistant turn - // lacks reasoning_content. OpencodeExecutor - // already injects a placeholder for OpenCode-routed thinking models; the - // direct connections hit neither injection path. Scope to Moonshot ids so - // gateway-served models that merely match the thinking-model name pattern - // (and may reject an extra field) are unaffected. - if (this.provider === "kimi" || this.provider === "moonshot") { + // 9router#1480: native Moonshot providers 400 when a prior assistant turn lacks + // reasoning_content. Scope to Moonshot ids, or a registry entry opting in via + // `requiresReasoningContentEcho` (e.g. `bai`'s DeepSeek resale, #13599). + const reasoningEcho = + this.provider === "kimi" || + this.provider === "moonshot" || + !!getRegistryEntry(this.provider)?.requiresReasoningContentEcho; + if (reasoningEcho) { const outboundModel = typeof (withDefaults as Record)?.model === "string" ? ((withDefaults as Record).model as string) : model; - if (shouldInjectReasoningContentPlaceholder(this.provider, outboundModel)) { + if (shouldInjectReasoningContentPlaceholder(reasoningEcho, this.provider, outboundModel)) { withDefaults = injectReasoningContentForThinkingModel(withDefaults); } } diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index a1d69ebf6d..7d3f48a9e7 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -55,16 +55,20 @@ export function isThinkingMessageModel(model: string | undefined | null): boolea return THINKING_MODEL_PATTERNS.some((re) => re.test(model)); } +/** + * `providerRequiresEcho` is resolved by the caller (Moonshot/Kimi legacy check, + * or a registry entry's `requiresReasoningContentEcho` capability flag — see + * `open-sse/config/providers/shared.ts`) so the provider allowlist lives in one + * place instead of being duplicated here. See issue #13599. + */ export function shouldInjectReasoningContentPlaceholder( + providerRequiresEcho: boolean, provider: unknown, model: string | undefined | null ): boolean { - const normalizedProvider = String(provider ?? "") - .trim() - .toLowerCase(); return ( - (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && - !requiresAuthenticReasoningContent(normalizedProvider, model) && + providerRequiresEcho && + !requiresAuthenticReasoningContent(provider, model) && isThinkingMessageModel(model) ); } diff --git a/tests/unit/issue-13599-bai-deepseek-reasoning-content-echo.test.ts b/tests/unit/issue-13599-bai-deepseek-reasoning-content-echo.test.ts new file mode 100644 index 0000000000..80c56e9060 --- /dev/null +++ b/tests/unit/issue-13599-bai-deepseek-reasoning-content-echo.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +// Issue #13599: follow-up requests to DeepSeek thinking-mode models served through the +// `bai` provider (api.b.ai) are rejected upstream with +// 400 The `reasoning_content` in the thinking mode must be passed back to the API +// because standard OpenAI-shaped clients do not preserve `reasoning_content` on the prior +// assistant turn when they replay conversation history. OmniRoute already has a mechanism +// for exactly this requirement (open-sse/utils/reasoningContentInjector.ts, ported from +// 9router#1480), but DefaultExecutor.transformRequest only invoked it when +// `this.provider === "kimi" || this.provider === "moonshot"` (open-sse/executors/default.ts) — +// the `bai` DeepSeek-reselling gateway was not covered, so a follow-up turn was forwarded to +// DeepSeek with no `reasoning_content` on the prior assistant message. + +function priorTurnBody(model: string) { + return { + model, + stream: false, + messages: [ + { role: "user", content: "What is 2+2?" }, + // Standard OpenAI-shaped client history: the assistant turn carries only + // `content`. It does NOT echo back `reasoning_content` from the previous + // response, exactly like a normal ChatGPT-style client would replay it. + { role: "assistant", content: "4" }, + { role: "user", content: "Now multiply that by 10." }, + ], + }; +} + +test("DefaultExecutor injects reasoning_content for bai/deepseek follow-up turns (issue #13599)", () => { + const executor = new DefaultExecutor("bai"); + const body = priorTurnBody("bai/deepseek-reasoner"); + + const transformed = executor.transformRequest("bai/deepseek-reasoner", body, false, { + apiKey: "sk-bai-test", + }) as { messages: Array> }; + + const assistantTurn = transformed.messages.find((m) => m.role === "assistant"); + assert.ok(assistantTurn, "expected an assistant message in the transformed body"); + + // The injector's placeholder is a single space (matching the existing Moonshot/Kimi + // convention in reasoningContentInjector.ts — DeepSeek only requires the field to be + // present and non-empty, not semantically meaningful), so assert non-empty length + // directly rather than trimming. + assert.ok( + typeof assistantTurn!.reasoning_content === "string" && + (assistantTurn!.reasoning_content as string).length > 0, + "expected DefaultExecutor to inject a non-empty reasoning_content placeholder on the " + + "assistant turn for a bai/deepseek thinking-mode follow-up (it did not — this is issue #13599)" + ); +}); + +test("DefaultExecutor does not touch non-thinking-model bai follow-up turns", () => { + const executor = new DefaultExecutor("bai"); + const body = priorTurnBody("bai/gpt-4o-mini"); + + const transformed = executor.transformRequest("bai/gpt-4o-mini", body, false, { + apiKey: "sk-bai-test", + }) as { messages: Array> }; + + const assistantTurn = transformed.messages.find((m) => m.role === "assistant"); + assert.ok(assistantTurn, "expected an assistant message in the transformed body"); + assert.equal( + "reasoning_content" in assistantTurn!, + false, + "a non-thinking model must not receive an injected reasoning_content field" + ); +});