mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 15:12:23 +03:00
Preserves authentic plaintext reasoning continuations across Chat Completions and Responses (streaming + non-streaming), applying one target-aware reasoning transport policy before protocol translation. Fixes #10550. Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 39 files): - 446/446 focused node:test tests pass (chat-route-coverage, chatcore-translation-paths, combo-attempt-body-isolation-7847, combo-config, executor-codex, kimi-coding-translator, moonshot-k3, reasoning-cache, response-sanitizer, responses-handler, responses-translation-fixes, strip-reasoning-blobs-agentic-context-1599, translator-openai-responses-req). - 12/12 vitest tests pass (edit-connection-modal-free-models.test.tsx). - check-changelog-integrity: OK. - typecheck:core: clean. - check-complexity / check-cognitive-complexity: OK, both under baseline. - file-size: chatHelpers.ts crossed the frozen cap by +2 lines (irreducible reasoningTransportFallback option threading) — rebaselined 1017->1019 with justification, pushed to the PR branch (fix-in-place), re-validated after a base-drift re-merge against the latest release tip. Co-authored-by: jackjinke <jackjinke@users.noreply.github.com>
110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
/**
|
|
* Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, xiaomi-tokenplan
|
|
* mimo, ...) require
|
|
* `reasoning_content` to be echoed back on every assistant message in the
|
|
* conversation history. Standard OpenAI clients do not preserve that field
|
|
* across turns, so we inject a non-empty placeholder before forwarding.
|
|
*
|
|
* Without the placeholder these upstreams return:
|
|
* 400 Bad Request — reasoning_content must be passed back
|
|
*
|
|
* Ported from decolua/9router#1099 (issue #1543). Pure helper — no I/O, no
|
|
* cross-module deps — kept narrow so it can be reused by other meta-providers
|
|
* that proxy to thinking-mode models.
|
|
*/
|
|
|
|
const PLACEHOLDER = " ";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
/**
|
|
* Model-id predicates for thinking-mode families that need the echo.
|
|
* Matched case-insensitively against the resolved model id (post upstream
|
|
* routing, e.g. `oc/deepseek-v4-flash-free` for the OpenCode meta-provider).
|
|
*/
|
|
const THINKING_MODEL_PATTERNS: RegExp[] = [
|
|
/deepseek/i,
|
|
/\bkimi\b/i,
|
|
/\bk2\b/i, // moonshot kimi k2 family alias
|
|
/\bminimax\b/i,
|
|
/\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro)
|
|
];
|
|
const K3_AUTHENTIC_REASONING_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i;
|
|
const NATIVE_K27_AUTHENTIC_REASONING_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i;
|
|
|
|
/**
|
|
* K3 requires authentic reasoning regardless of which provider serves it.
|
|
* Native Moonshot K2.7 retains the same preserved-thinking contract. Empty
|
|
* protocol markers remain valid only after client content and replay miss.
|
|
*/
|
|
export function requiresAuthenticReasoningContent(provider: unknown, model: unknown): boolean {
|
|
const normalizedModel = String(model ?? "").trim();
|
|
if (K3_AUTHENTIC_REASONING_PATTERN.test(normalizedModel)) return true;
|
|
|
|
const normalizedProvider = String(provider ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return (
|
|
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
|
|
NATIVE_K27_AUTHENTIC_REASONING_PATTERN.test(normalizedModel)
|
|
);
|
|
}
|
|
|
|
export function isThinkingMessageModel(model: string | undefined | null): boolean {
|
|
if (!model || typeof model !== "string") return false;
|
|
return THINKING_MODEL_PATTERNS.some((re) => re.test(model));
|
|
}
|
|
|
|
export function shouldInjectReasoningContentPlaceholder(
|
|
provider: unknown,
|
|
model: string | undefined | null
|
|
): boolean {
|
|
const normalizedProvider = String(provider ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return (
|
|
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
|
|
!requiresAuthenticReasoningContent(normalizedProvider, model) &&
|
|
isThinkingMessageModel(model)
|
|
);
|
|
}
|
|
|
|
function hasNonEmptyReasoningContent(message: JsonRecord): boolean {
|
|
return (
|
|
typeof message.reasoning_content === "string" &&
|
|
(message.reasoning_content as string).trim().length > 0
|
|
);
|
|
}
|
|
|
|
function isAssistantMessage(value: unknown): value is JsonRecord {
|
|
return (
|
|
!!value &&
|
|
typeof value === "object" &&
|
|
!Array.isArray(value) &&
|
|
(value as JsonRecord).role === "assistant"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inject a placeholder `reasoning_content` on every assistant message in
|
|
* `body.messages` that lacks one. Returns the original object if no mutation
|
|
* was needed, or a shallow-copied body with a new messages array otherwise.
|
|
*
|
|
* No-op when the body shape is unexpected (defensive).
|
|
*/
|
|
export function injectReasoningContentForThinkingModel(body: unknown): unknown {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
const record = body as JsonRecord;
|
|
if (!Array.isArray(record.messages)) return body;
|
|
|
|
let modified = false;
|
|
const messages = record.messages.map((message) => {
|
|
if (!isAssistantMessage(message)) return message;
|
|
if (hasNonEmptyReasoningContent(message)) return message;
|
|
modified = true;
|
|
return { ...message, reasoning_content: PLACEHOLDER };
|
|
});
|
|
|
|
return modified ? { ...record, messages } : body;
|
|
}
|