mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
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:
committed by
GitHub
parent
3fd2440f8a
commit
771c4ce513
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user