fix(providers): echo reasoning_content for bai DeepSeek thinking-mode follow-ups (#13599) (#13807)

Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-16 06:10:13 -03:00
committed by GitHub
parent 3fd2440f8a
commit 771c4ce513
6 changed files with 102 additions and 13 deletions

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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. */

View File

@@ -1013,18 +1013,19 @@ export class DefaultExecutor extends BaseExecutor {
this.ensureThinkingBudget(withDefaults as Record<string, unknown>, 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<string, unknown>)?.model === "string"
? ((withDefaults as Record<string, unknown>).model as string)
: model;
if (shouldInjectReasoningContentPlaceholder(this.provider, outboundModel)) {
if (shouldInjectReasoningContentPlaceholder(reasoningEcho, this.provider, outboundModel)) {
withDefaults = injectReasoningContentForThinkingModel(withDefaults);
}
}

View File

@@ -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)
);
}

View File

@@ -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<Record<string, unknown>> };
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<Record<string, unknown>> };
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"
);
});