Allow reasoning for Claude models on the Kilo gateway and restore preserved reasoning

This commit is contained in:
Christiaan Arnoldus
2026-02-20 16:33:17 +01:00
parent 29a6495d88
commit 71e8658f0a
+38 -143
View File
@@ -72,29 +72,6 @@ export namespace ProviderTransform {
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
// kilocode_change - skip toolCallId normalization for OpenRouter/Kilo Gateway
// OpenRouter handles tool call IDs differently and modifying messages can break
// thinking/redacted_thinking blocks which must remain unchanged per Anthropic API
if (
model.api.id.includes("claude") &&
model.api.npm !== "@openrouter/ai-sdk-provider" &&
model.api.npm !== "@kilocode/kilo-gateway"
) {
return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {
return {
...part,
toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"),
}
}
return part
})
}
return msg
})
}
if (
model.providerID === "mistral" ||
model.api.id.toLowerCase().includes("mistral") ||
@@ -257,81 +234,38 @@ export namespace ProviderTransform {
})
}
// kilocode_change - function added
function fixDuplicateReasoning(msgs: ModelMessage[]) {
for (const msg of msgs) {
if (!Array.isArray(msg.content)) {
continue
}
let isFirstToolCall = true
for (const part of msg.content) {
if (part.type === "reasoning") {
// this entry is corrupt
delete part.providerOptions?.openrouter?.reasoning_details
}
if (part.type === "tool-call" && isFirstToolCall) {
isFirstToolCall = false
continue
}
if (part.type == "tool-call") {
// this is a duplicate entry
delete part.providerOptions?.openrouter?.reasoning_details
}
}
}
}
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
// kilocode_change - identify OpenRouter/Kilo Gateway for thinking block stripping
const isOpenRouterOrKilo =
model.api.npm === "@openrouter/ai-sdk-provider" || model.api.npm === "@kilocode/kilo-gateway"
// kilocode_change - strip thinking/reasoning blocks for OpenRouter/Kilo Gateway
// Anthropic's API requires thinking blocks to be EXACTLY unchanged, but our storage
// reconstructs them which counts as modification. Stripping them is safe because
// the reasoning was already shown to the user and doesn't need to be sent back.
if (isOpenRouterOrKilo) {
// Helper to strip reasoning-related data from provider options/metadata
const stripReasoningData = (opts: Record<string, any> | undefined) => {
if (!opts) return undefined // Return undefined instead of empty object to clean up
const result = { ...opts }
// Strip from openrouter namespace
if (result.openrouter) {
result.openrouter = { ...result.openrouter }
delete result.openrouter.reasoning_details
delete result.openrouter.reasoning
delete result.openrouter.thinking
}
// Strip from kilo namespace
if (result.kilo) {
result.kilo = { ...result.kilo }
delete result.kilo.reasoning_details
delete result.kilo.reasoning
delete result.kilo.thinking
}
// Strip from anthropic namespace
if (result.anthropic) {
result.anthropic = { ...result.anthropic }
delete result.anthropic.thinking
delete result.anthropic.reasoning
}
return result
}
msgs = msgs.flatMap((msg): ModelMessage[] => {
// Handle string content (just strip metadata)
if (!Array.isArray(msg.content)) {
const result = { ...msg, providerOptions: stripReasoningData(msg.providerOptions) }
if ("experimental_providerMetadata" in msg) {
;(result as any).experimental_providerMetadata = stripReasoningData(
(msg as any).experimental_providerMetadata,
)
}
return [result]
}
// Filter out reasoning parts from content and strip metadata from remaining parts
const filtered = msg.content
.filter(
(part: any) => part.type !== "thinking" && part.type !== "redacted_thinking" && part.type !== "reasoning",
)
.map((part: any) => ({
...part,
providerOptions: stripReasoningData(part.providerOptions),
providerMetadata: stripReasoningData(part.providerMetadata),
experimental_providerMetadata: stripReasoningData(part.experimental_providerMetadata),
}))
// Providers may reject empty array content; drop empty messages.
if (filtered.length === 0) return []
// Also strip from message-level options/metadata
const result = { ...msg, content: filtered, providerOptions: stripReasoningData(msg.providerOptions) }
if ("experimental_providerMetadata" in msg) {
;(result as any).experimental_providerMetadata = stripReasoningData(
(msg as any).experimental_providerMetadata,
)
}
return [result as ModelMessage]
})
// kilocode_change - workaround for @openrouter/ai-sdk-provider v1 duplicating reasoning
// fixed in https://github.com/OpenRouterTeam/ai-sdk-provider/pull/344/
if (model.api.npm === "@kilocode/kilo-gateway") {
fixDuplicateReasoning(msgs)
}
if (
@@ -448,57 +382,19 @@ export namespace ProviderTransform {
// kilocode_change start
case "@kilocode/kilo-gateway":
// kilocode_change - adaptive thinking with effort levels
// TODO: Enable when @ai-sdk/anthropic supports thinking.type: "adaptive"
const ADAPTIVE_THINKING_ENABLED = false
if (ADAPTIVE_THINKING_ENABLED && id.includes("claude-opus-4-6")) {
if (model.id.includes("claude")) {
// for models that support adaptive thinking, effort is ignored
// for models that don't support adaptive thinking, effort is translated into a token budget
return {
low: {
thinking: { type: "adaptive" },
output_config: { effort: "low" },
},
medium: {
thinking: { type: "adaptive" },
output_config: { effort: "medium" },
},
high: {
thinking: { type: "adaptive" },
output_config: { effort: "high" },
},
max: {
thinking: { type: "adaptive" },
output_config: { effort: "max" },
},
none: { reasoning: { enabled: false } },
low: { reasoning: { enabled: true, effort: "low" }, verbosity: "low" },
medium: { reasoning: { enabled: true, effort: "medium" }, verbosity: "medium" },
high: { reasoning: { enabled: true, effort: "high" }, verbosity: "high" },
max: { reasoning: { enabled: true, effort: "xhigh" }, verbosity: "max" },
}
}
// kilocode_change - Claude models via Kilo Gateway: no reasoning variants
// (reasoning is broken due to OpenRouter SDK duplicating reasoning_details)
if (
model.id.includes("claude") ||
model.id.includes("anthropic") ||
model.api.id.includes("claude") ||
model.api.id.includes("anthropic")
) {
return {}
}
// GPT models via Kilo need encrypted reasoning content to avoid org_id mismatch
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
// kilocode_change - Codex models use object-based reasoning format for OpenRouter
// OpenRouter expects { reasoning: { effort: "high" } } format
// See: https://openrouter.ai/docs/api/api-reference/chat/send-chat-completion-request#request.body.reasoning
if (model.id.includes("codex")) {
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
}
return Object.fromEntries(
OPENAI_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
]),
)
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
// kilocode_change end
// TODO: YOU CANNOT SET max_tokens if this is set!!!
@@ -842,8 +738,7 @@ export namespace ProviderTransform {
result["textVerbosity"] = "low"
}
// kilocode_change - include kilo provider for encrypted reasoning content
if (input.model.providerID.startsWith("opencode") || input.model.api.npm === "@kilocode/kilo-gateway") {
if (input.model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = input.sessionID
result["include"] = ["reasoning.encrypted_content"]
result["reasoningSummary"] = "auto"