diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 349809e75..61bc65616 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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 { }) } - export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { - 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 | 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 + // 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 } - // Strip from anthropic namespace - if (result.anthropic) { - result.anthropic = { ...result.anthropic } - delete result.anthropic.thinking - delete result.anthropic.reasoning + if (part.type == "tool-call") { + // this is a duplicate entry + delete part.providerOptions?.openrouter?.reasoning_details } - 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] - }) + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { + msgs = unsupportedParts(msgs, model) + msgs = normalizeMessages(msgs, model, options) + + // 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"