Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/9496-kimi-k3-responses-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(translator): preserve authentic K3 Responses reasoning by model across providers, keep it on the matching assistant turn, and make Kimi Coding prefer client reasoning then cached replay before its empty-marker fallback (#9496)
7 changes: 6 additions & 1 deletion open-sse/handlers/responsesHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ export async function handleResponsesCore({
const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems);

// Convert Responses API format to Chat Completions format
const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider);
const convertedBody = convertResponsesApiFormat(
body,
credentials,
modelInfo?.provider,
modelInfo?.model
);

// Ensure stream is enabled
convertedBody.stream = true;
Expand Down
10 changes: 10 additions & 0 deletions open-sse/services/reasoningCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const REASONING_REPLAY_MODEL_PATTERNS = [
];

const DEEPSEEK_V4_MODEL_PATTERN = /deepseek[-/]v4[-.](flash|pro)/i;
const K3_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i;
const NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i;

export function isDeepSeekReasoningModel(params: {
provider: string;
Expand Down Expand Up @@ -94,6 +96,14 @@ export function requiresReasoningReplay(params: {
if (normalizedInterleavedField === "reasoning_content") return true;
if (normalizedInterleavedField === "reasoning_details") return false;

if (K3_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)) return true;
if (
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)
) {
return true;
}

// DeepSeek legacy reasoner family has an inverse contract: do not replay.
if (/deepseek-reasoner/i.test(normalizedModel) || /deepseek-r1/i.test(normalizedModel)) {
return false;
Expand Down
4 changes: 4 additions & 0 deletions open-sse/translator/helpers/claudeHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean {
return msg.content.some(
(block) =>
(block.type === "text" && block.text?.trim()) ||
(block.type === "thinking" && block.thinking?.trim()) ||
(block.type === "redacted_thinking" &&
typeof block.data === "string" &&
block.data.trim()) ||
block.type === "tool_use" ||
block.type === "tool_result" ||
// #7777: media-only user turns are real content — dropping them
Expand Down
30 changes: 28 additions & 2 deletions open-sse/translator/helpers/responsesApiHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,42 @@
* Convert OpenAI Responses API format to standard chat completions format.
* Delegates to the canonical translator to avoid logic duplication.
*/
import { requiresReasoningReplay } from "../../services/reasoningCache.ts";
import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts";
import { toRecord } from "../request/openai-responses/helpers.ts";

export function convertResponsesApiFormat(body, credentials = null, provider = null) {
export function convertResponsesApiFormat(
body: Record<string, unknown>,
credentials: unknown = null,
provider: unknown = null,
model: unknown = null
): Record<string, unknown> {
const bodyModel = toRecord(body).model;
const requestedModel =
typeof bodyModel === "string" && bodyModel.trim().length > 0
? bodyModel.includes("/") || typeof provider !== "string" || provider.length === 0
? bodyModel
: `${provider}/${bodyModel}`
: provider;
return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials);
const credentialRecord =
credentials && typeof credentials === "object" && !Array.isArray(credentials)
? (credentials as Record<string, unknown>)
: {};
const translationCredentials = requiresReasoningReplay({
provider: String(provider ?? ""),
model: String(model ?? ""),
allowLegacyFallback: false,
})
? { ...credentialRecord, _preserveReasoningContent: true }
: credentials;
const converted = openaiResponsesToOpenAIRequest(
requestedModel,
body,
null,
translationCredentials
);
if (!converted || typeof converted !== "object" || Array.isArray(converted)) {
throw new TypeError("Responses request conversion must produce an object");
}
return converted as Record<string, unknown>;
}
85 changes: 60 additions & 25 deletions open-sse/translator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
providerHonorsOpenAIFormatCacheControl,
resolveConnectionCacheOverride,
} from "../utils/cacheControlPolicy.ts";
import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts";
import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts";
import {
coerceToolSchemas,
Expand Down Expand Up @@ -162,7 +161,11 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean
/(^|\/)deepseek/i.test(normalizedModel) ||
normalizedProvider === "xiaomi-mimo" ||
/(^|\/)mimo/i.test(normalizedModel) ||
requiresAuthenticReasoningContent(normalizedProvider, normalizedModel)
requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
allowLegacyFallback: false,
})
);
}

Expand Down Expand Up @@ -233,6 +236,17 @@ export function translateRequest(
const connectionCacheOverride = resolveConnectionCacheOverride(
(credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData
);
const normalizedProvider = String(provider ?? "");
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresExplicitReasoningReplay = requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
allowLegacyFallback: false,
});
const preserveResponsesReasoning =
sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay;

// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
Expand Down Expand Up @@ -318,12 +332,16 @@ export function translateRequest(
options?.preserveCacheControl === true &&
providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride);
const step1Credentials =
options?.copilotClient || hasTargetHint || preserveCacheControl
options?.copilotClient ||
hasTargetHint ||
preserveCacheControl ||
preserveResponsesReasoning
? {
...(credentials && typeof credentials === "object" ? credentials : {}),
...(options?.copilotClient ? { _copilotClient: true } : {}),
...(hasTargetHint ? { _targetFormat: targetFormat } : {}),
...(preserveCacheControl ? { _preserveCacheControl: true } : {}),
...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}),
}
: credentials;
result = toOpenAI(model, result, stream, step1Credentials);
Expand Down Expand Up @@ -361,14 +379,6 @@ export function translateRequest(
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
// replay providers) and the cache re-injection further down.
const normalizedProvider = String(provider ?? "");
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresAuthenticReasoning = requiresAuthenticReasoningContent(
normalizedProvider,
normalizedModel
);
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,
Expand All @@ -377,7 +387,10 @@ export function translateRequest(
provider: normalizedProvider,
model: normalizedModel,
thinkingEnabled: hasThinkingConfig(result),
supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }),
supportsReasoning: supportsReasoning({
provider: normalizedProvider,
model: normalizedModel,
}),
interleavedField: resolvedCapabilities?.interleavedField ?? null,
});

Expand Down Expand Up @@ -450,7 +463,7 @@ export function translateRequest(

if (
targetFormat === FORMATS.OPENAI &&
!requiresAuthenticReasoning &&
!requiresExplicitReasoningReplay &&
result.messages &&
Array.isArray(result.messages)
) {
Expand All @@ -475,7 +488,7 @@ export function translateRequest(
// isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were
// resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip
// could honor reasoning-replay providers.
if (isReasoner && !isKimiCoding && result.messages && Array.isArray(result.messages)) {
if (isReasoner && result.messages && Array.isArray(result.messages)) {
const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel);

for (const [messageIndex, msg] of result.messages.entries()) {
Expand Down Expand Up @@ -524,29 +537,51 @@ export function translateRequest(
// Has tool_use blocks but no thinking block yet.
// Reasoning models (Kimi K2, etc.) require a thinking block before tool_use
// on multi-turn or they regenerate the same tool call infinitely.
const hasThinkingBlock = msg.content.some(
const thinkingBlock = msg.content.find(
(b) => b?.type === "thinking" || b?.type === "redacted_thinking"
);
if (hasThinkingBlock) continue;
const hasNonEmptyClientThinking =
thinkingBlock?.type === "thinking" &&
typeof thinkingBlock.thinking === "string" &&
thinkingBlock.thinking.trim().length > 0;
if (thinkingBlock && (!isKimiCoding || hasNonEmptyClientThinking)) continue;

const toolUseBlocks = msg.content.filter((b) => b?.type === "tool_use");
const firstToolUseId = toolUseBlocks[0]?.id;
const firstToolUseIdx = msg.content.findIndex((b) => b?.type === "tool_use");

// Try reasoning cache first
// Client reasoning wins above. Otherwise try authentic replay before
// retaining Kimi Code's empty protocol marker as the final fallback.
if (firstToolUseId) {
const cached = lookupReasoning(firstToolUseId);
if (cached) {
msg.content.splice(firstToolUseIdx, 0, {
type: "thinking",
thinking: cached,
});
if (thinkingBlock) {
thinkingBlock.type = "thinking";
thinkingBlock.thinking = cached;
delete thinkingBlock.data;
delete thinkingBlock.signature;
} else {
msg.content.splice(firstToolUseIdx, 0, {
type: "thinking",
thinking: cached,
});
}
recordReplay();
continue;
}
}
if (requiresAuthenticReasoning) continue;
// Fallback: inject placeholder (must be non-empty for kimi-coding)
if (isKimiCoding) {
if (thinkingBlock) {
thinkingBlock.type = "thinking";
thinkingBlock.thinking = "";
delete thinkingBlock.data;
delete thinkingBlock.signature;
} else {
msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: "" });
}
continue;
}
if (requiresExplicitReasoningReplay) continue;
msg.content.splice(firstToolUseIdx, 0, {
type: "thinking",
thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER,
Expand Down Expand Up @@ -583,7 +618,7 @@ export function translateRequest(
// Native Moonshot K3/K2.7 accepts only the real prior reasoning. If it
// was not supplied and the cache missed, leave it absent so upstream can
// enforce its contract instead of corrupting history with a placeholder.
if (requiresAuthenticReasoning) {
if (requiresExplicitReasoningReplay) {
if (msg.reasoning_content === "") delete msg.reasoning_content;
continue;
}
Expand All @@ -596,7 +631,7 @@ export function translateRequest(
// deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is
// specific to empty-string, and even that is endpoint-dependent). Omit
// the field instead; providers that genuinely enforce the contract
// (kimi-coding, moonshot authentic-reasoning) have their own paths above.
// (kimi-coding, moonshot reasoning replay) have their own paths above.
if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) {
if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) {
msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER;
Expand Down
Loading