diff --git a/.changeset/rare-stingrays-crash.md b/.changeset/rare-stingrays-crash.md new file mode 100644 index 00000000000..a1fb487217f --- /dev/null +++ b/.changeset/rare-stingrays-crash.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fixing error on Kimi K2.5 thinking diff --git a/jetbrains/host/eslint.config.mjs b/jetbrains/host/eslint.config.mjs index d9ae2c0edab..a75c3fdd8a5 100644 --- a/jetbrains/host/eslint.config.mjs +++ b/jetbrains/host/eslint.config.mjs @@ -36,7 +36,16 @@ export default [ "no-const-assign": "off", }, }, + { + files: ["scripts/**/*.js"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + }, + }, + }, { ignores: ["dist", "deps"], }, -] \ No newline at end of file +] diff --git a/jetbrains/host/package.json b/jetbrains/host/package.json index d115db4ead8..368afb4e3f7 100644 --- a/jetbrains/host/package.json +++ b/jetbrains/host/package.json @@ -4,15 +4,17 @@ "type": "module", "scripts": { "deps:check": "node ../../jetbrains/scripts/check-dependencies.js", + "deps:ensure": "node scripts/ensure-vscode-deps.js", "deps:patch": "npm run deps:check && cd ../../deps/vscode && git reset --hard HEAD && git clean -fd && git apply ../patches/vscode/jetbrains.patch", - "deps:clean": "rm -rf ./deps/vscode/* || true", - "deps:copy": "npm run deps:check && npx cpy '../../deps/vscode/src/**' './deps/vscode' --parents", + "deps:clean": "del-cli ./deps/vscode --force", + "deps:copy": "npm run deps:check && cpy '../../deps/vscode/src/**' './deps/vscode' --parents", "clean": "del-cli ./dist", + "prebuild": "node scripts/ensure-vscode-deps.js", "build": "tsc", "build:clean": "npm run clean && npm run build", "start": "node ./dist/src/main.js", "dev": "tsc && node ./dist/src/main.js", - "bundle:package": "cp ./package.json ./dist/package.json", + "bundle:package": "cpy ./package.json ./dist", "bundle:build": "tsc --noEmit && tsup", "lint": "eslint . --ext=ts --max-warnings=0" }, diff --git a/jetbrains/host/scripts/ensure-vscode-deps.js b/jetbrains/host/scripts/ensure-vscode-deps.js new file mode 100644 index 00000000000..4334c6ce410 --- /dev/null +++ b/jetbrains/host/scripts/ensure-vscode-deps.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +/** + * Ensure JetBrains host has a local copy of VSCode sources needed for build. + * This avoids running JetBrains plugin dependency checks (Java/Gradle). + */ + +import fs from "fs" +import path from "path" +import { spawnSync } from "child_process" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const hostDir = path.resolve(__dirname, "..") +const projectRoot = path.resolve(hostDir, "..", "..") + +const vscodeDir = path.join(projectRoot, "deps", "vscode") +const sourceDir = path.join(vscodeDir, "src") +const targetDir = path.join(hostDir, "deps", "vscode") + +const expectedSourceFile = path.join(sourceDir, "vs", "base", "common", "uri.ts") +const patchMarkerSource = path.join( + sourceDir, + "vs", + "workbench", + "services", + "extensions", + "common", + "fileRPCProtocolLogger.ts", +) +const patchMarkerTarget = path.join( + targetDir, + "vs", + "workbench", + "services", + "extensions", + "common", + "fileRPCProtocolLogger.ts", +) +const patchFile = path.join(projectRoot, "deps", "patches", "vscode", "jetbrains.patch") + +function fail(message) { + console.error(message) + process.exit(1) +} + +if (!fs.existsSync(sourceDir) || !fs.existsSync(expectedSourceFile)) { + fail( + [ + "VSCode submodule is missing or incomplete.", + "Run: git submodule update --init --recursive deps/vscode", + ].join("\n"), + ) +} + +if (!fs.existsSync(patchMarkerSource)) { + const status = spawnSync("git", ["status", "--porcelain"], { + cwd: vscodeDir, + encoding: "utf8", + }) + if (status.status !== 0) { + fail("Failed to check VSCode submodule status. Ensure git is available.") + } + + if ((status.stdout || "").trim().length > 0) { + fail( + [ + "VSCode submodule has local changes. Cannot apply JetBrains patch automatically.", + "Stash or reset submodule changes, then run:", + " pnpm run deps:patch", + ].join("\n"), + ) + } + + const apply = spawnSync("git", ["apply", patchFile], { + cwd: vscodeDir, + stdio: "inherit", + }) + if (apply.status !== 0) { + fail("Failed to apply JetBrains patch to VSCode submodule.") + } + + if (!fs.existsSync(patchMarkerSource)) { + fail("JetBrains patch did not apply cleanly to the VSCode submodule.") + } +} + +if (fs.existsSync(patchMarkerTarget)) { + process.exit(0) +} + +fs.rmSync(targetDir, { recursive: true, force: true }) +fs.mkdirSync(targetDir, { recursive: true }) +fs.cpSync(sourceDir, targetDir, { recursive: true }) + +if (!fs.existsSync(patchMarkerTarget)) { + fail("Failed to copy VSCode sources into jetbrains/host/deps/vscode.") +} diff --git a/jetbrains/plugin/package.json b/jetbrains/plugin/package.json index aeaadf42434..0edcb6f63ec 100644 --- a/jetbrains/plugin/package.json +++ b/jetbrains/plugin/package.json @@ -8,23 +8,23 @@ "mkdirp": "^3.0.1" }, "scripts": { - "clean": "./gradlew clean", - "build": "./gradlew buildPlugin -PdebugMode=idea", - "run": "./gradlew runIde -PdebugMode=idea", - "run:bundle": "./gradlew runIde -PdebugMode=release", - "bundle": "./gradlew buildPlugin -PdebugMode=release", + "clean": "node scripts/run-gradle.js clean", + "build": "node scripts/run-gradle.js buildPlugin -PdebugMode=idea", + "run": "node scripts/run-gradle.js runIde -PdebugMode=idea", + "run:bundle": "node scripts/run-gradle.js runIde -PdebugMode=release", + "bundle": "node scripts/run-gradle.js buildPlugin -PdebugMode=release", "bundle:name": "node scripts/get_bundle_name.js", - "clean:kilocode": "npx del-cli ./plugins/kilocode --force && npx mkdirp ./plugins/kilocode", - "copy:kilocode": "npx cpy '../../bin-unpacked/extension/**' './plugins/kilocode/extension' --parents", - "clean:resource-kilocode": "npx del-cli ../resources/kilocode --force", - "copy:resource-kilocode": "npx cpy '../../bin-unpacked/extension/**' '../resources/kilocode' --parents", - "clean:resource-host": "npx del-cli ../resources/runtime --force", - "copy:resource-host": "npx cpy '../host/dist/**' '../resources/runtime' --parents", - "clean:resource-logs": "npx del-cli ../resources/logs --force", - "copy:resource-logs": "npx mkdirp ../resources/logs", - "clean:resource-nodemodules": "npx del-cli ../resources/node_modules --force && npx del-cli ../resources/package.json --force", + "clean:kilocode": "del-cli ./plugins/kilocode --force && mkdirp ./plugins/kilocode", + "copy:kilocode": "cpy '../../bin-unpacked/extension/**' './plugins/kilocode/extension' --parents", + "clean:resource-kilocode": "del-cli ../resources/kilocode --force", + "copy:resource-kilocode": "cpy '../../bin-unpacked/extension/**' '../resources/kilocode' --parents", + "clean:resource-host": "del-cli ../resources/runtime --force", + "copy:resource-host": "cpy '../host/dist/**' '../resources/runtime' --parents", + "clean:resource-logs": "del-cli ../resources/logs --force", + "copy:resource-logs": "mkdirp ../resources/logs", + "clean:resource-nodemodules": "del-cli ../resources/node_modules --force && del-cli ../resources/package.json --force", "copy:resource-nodemodules": "cp ../host/package.json ../resources/package.json && npm install --prefix ../resources", - "propDep": "npx del-cli ./propDep.txt --force && npm ls --omit=dev --all --parseable --prefix ../resources > ./prodDep.txt", + "propDep": "del-cli ./propDep.txt --force && npm ls --omit=dev --all --parseable --prefix ../resources > ./prodDep.txt", "sync:version": "node scripts/sync_version.js", "sync:changelog": "node scripts/update_change_notes.js" } diff --git a/jetbrains/plugin/scripts/run-gradle.js b/jetbrains/plugin/scripts/run-gradle.js new file mode 100644 index 00000000000..816f4c1c691 --- /dev/null +++ b/jetbrains/plugin/scripts/run-gradle.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +/** + * Cross-platform Gradle wrapper script that checks for Java availability + * before running Gradle commands. This allows the JetBrains plugin build + * to gracefully skip when Java is not installed (e.g., when building only + * the VSCode extension). + */ + +import { spawn } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Get the command to run from command line arguments +const args = process.argv.slice(2); +const command = args.join(' '); + +if (!command) { + console.error('Usage: node scripts/run-gradle.js '); + process.exit(1); +} + +// Check if Java is available +function checkJava() { + return new Promise((resolve) => { + const javaProcess = spawn('java', ['-version'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + + let output = ''; + let error = ''; + + javaProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + javaProcess.stderr.on('data', (data) => { + error += data.toString(); + }); + + javaProcess.on('close', (code) => { + // Java -version outputs to stderr on most systems + const versionOutput = error || output; + resolve(code === 0 && versionOutput.includes('version')); + }); + + javaProcess.on('error', () => { + resolve(false); + }); + + // Timeout after 5 seconds + setTimeout(() => { + javaProcess.kill(); + resolve(false); + }, 5000); + }); +} + +// Run Gradle command +function runGradle(command) { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const gradleCmd = isWindows ? 'gradlew.bat' : './gradlew'; + + const gradleProcess = spawn(gradleCmd, args, { + stdio: 'inherit', + shell: true, + cwd: path.join(__dirname, '..'), + }); + + gradleProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Gradle command exited with code ${code}`)); + } + }); + + gradleProcess.on('error', (err) => { + reject(err); + }); + }); +} + +async function main() { + const javaAvailable = await checkJava(); + + if (!javaAvailable) { + console.warn('Warning: Java is not installed or not available in PATH.'); + console.warn('Skipping Gradle command:', command); + console.warn('This is expected if you are only building the VSCode extension.'); + process.exit(0); + } + + try { + await runGradle(command); + } catch (error) { + console.error('Gradle command failed:', error.message); + process.exit(1); + } +} + +main(); diff --git a/packages/types/src/providers/synthetic.ts b/packages/types/src/providers/synthetic.ts index d61006ec6d2..3240f37b102 100644 --- a/packages/types/src/providers/synthetic.ts +++ b/packages/types/src/providers/synthetic.ts @@ -19,6 +19,6 @@ export const syntheticModels: Record = { supportsComputerUse: false, supportsReasoningEffort: false, supportsReasoningBudget: false, - supportedParameters: [], + // kilocode_change: omit supportedParameters so defaults apply }, } diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 7d0d2548fce..3056b4075b2 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -358,6 +358,52 @@ describe("BaseOpenAiCompatibleProvider", () => { ) }) + // kilocode_change start + it("should omit temperature when model does not support it", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + class NoTempProvider extends BaseOpenAiCompatibleProvider<"test-model"> { + constructor(apiKey: string) { + const testModels: Record<"test-model", ModelInfo> = { + "test-model": { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.5, + outputPrice: 1.5, + supportsTemperature: false, + }, + } + + super({ + providerName: "NoTempProvider", + baseURL: "https://test.example.com/v1", + defaultProviderModelId: "test-model", + providerModels: testModels, + apiKey, + }) + } + } + + const noTempHandler = new NoTempProvider("test-api-key") + const messageGenerator = noTempHandler.createMessage("system prompt", []) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("temperature") + }) + // kilocode_change end + it("should yield usage data from stream", async () => { mockCreate.mockImplementationOnce(() => { return { diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index 4493ea3cd20..1c7a2002a6c 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -221,6 +221,22 @@ describe("MoonshotHandler", () => { expect(usageChunks[0].cacheWriteTokens).toBe(0) expect(usageChunks[0].cacheReadTokens).toBe(2) }) + + // kilocode_change start + it("should omit temperature for models that do not support it", async () => { + const handlerWithFixedTempModel = new MoonshotHandler({ + ...mockOptions, + apiModelId: "kimi-k2.5", + }) + + const stream = handlerWithFixedTempModel.createMessage(systemPrompt, messages) + await stream.next() + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("temperature") + }) + // kilocode_change end }) describe("completePrompt", () => { diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index b8923210c21..d37a723975a 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -7,6 +7,7 @@ import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/ap import { XmlMatcher } from "../../utils/xml-matcher" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" +import { isModelParameterSupported } from "../transform/model-params" // kilocode_change import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { DEFAULT_HEADERS } from "./constants" @@ -85,12 +86,17 @@ export abstract class BaseOpenAiCompatibleProvider format: "openai", }) ?? undefined - const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature + // kilocode_change start + const baseTemperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature + const temperature = isModelParameterSupported(info, "temperature") ? baseTemperature : undefined + // kilocode_change end const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model, max_tokens, - temperature, + // kilocode_change start + ...(temperature !== undefined ? { temperature } : {}), + // kilocode_change end messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 8b36bce9d25..3bc803a37db 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -18,7 +18,7 @@ import { XmlMatcher } from "../../utils/xml-matcher" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { getModelParams } from "../transform/model-params" +import { getModelParams, isModelParameterSupported } from "../transform/model-params" // kilocode_change import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" @@ -153,10 +153,16 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) + // kilocode_change start + const baseTemperature = this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0) + const temperature = isModelParameterSupported(modelInfo, "temperature") ? baseTemperature : undefined + // kilocode_change end const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, - temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), + // kilocode_change start + ...(temperature !== undefined ? { temperature } : {}), + // kilocode_change end messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), diff --git a/src/api/transform/__tests__/model-params.spec.ts b/src/api/transform/__tests__/model-params.spec.ts index 3df3ec28b92..6e6892e6fef 100644 --- a/src/api/transform/__tests__/model-params.spec.ts +++ b/src/api/transform/__tests__/model-params.spec.ts @@ -220,6 +220,40 @@ describe("getModelParams", () => { }) }) + // kilocode_change start + describe("Temperature support", () => { + it("should omit temperature when supportsTemperature is false", () => { + const model: ModelInfo = { + ...baseModel, + supportsTemperature: false, + } + + const result = getModelParams({ + ...openaiParams, + settings: { modelTemperature: 0.7 }, + model, + }) + + expect(result.temperature).toBeUndefined() + }) + + it("should omit temperature when supportedParameters excludes temperature", () => { + const model: ModelInfo = { + ...baseModel, + supportedParameters: ["max_tokens"] as any, + } + + const result = getModelParams({ + ...openaiParams, + settings: { modelTemperature: 0.7 }, + model, + }) + + expect(result.temperature).toBeUndefined() + }) + }) + // kilocode_change end + describe("Reasoning Budget (Hybrid reasoning models)", () => { it("should handle requiredReasoningBudget models correctly", () => { const model: ModelInfo = { diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 1a4c7f6518d..375fb2f38f0 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -11,6 +11,14 @@ import { } from "../openai-format" import { normalizeMistralToolCallId } from "../mistral-format" +// Helper type for test assertions with reasoning_content +type AssistantMessageWithReasoning = { + role: string + content: string + reasoning_content?: string + tool_calls?: Array<{ id: string; function: { name: string } }> +} + describe("convertToOpenAiMessages", () => { it("should convert simple text messages", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ @@ -967,6 +975,212 @@ describe("convertToOpenAiMessages", () => { expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") }) }) + + describe("reasoning_content extraction for Kimi K2.5 / DeepSeek", () => { + it("should extract reasoning blocks and add as reasoning_content for Kimi K2.5 compatibility", () => { + // This test simulates the stored format after receiving from Moonshot API + // The reasoning is stored as a content block with type: "reasoning" + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning", text: "Let me analyze this step by step..." } as const, + { type: "text", text: "I'll help you with that." }, + { type: "tool_use", id: "tool_123", name: "read_file", input: { path: "test.ts" } }, + ], + }, + ] as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + expect(openAiMessages).toHaveLength(1) + + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + expect(assistantMessage.role).toBe("assistant") + // reasoning_content should be extracted from the reasoning block + expect(assistantMessage.reasoning_content).toBe("Let me analyze this step by step...") + // Content should contain only text blocks (not reasoning) + expect(assistantMessage.content).toBe("I'll help you with that.") + // Tool calls should be preserved + expect(assistantMessage.tool_calls).toBeDefined() + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls?.[0]?.id).toBe("tool_123") + expect(assistantMessage.tool_calls?.[0]?.function.name).toBe("read_file") + }) + + it("should extract thinking blocks and add as reasoning_content", () => { + // Some providers use type: "thinking" instead of "reasoning" + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { type: "thinking", thinking: "This is my thinking process..." } as const, + { type: "text", text: "Here's the answer." }, + ], + }, + ] as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + // reasoning_content should be extracted from the thinking block + expect(assistantMessage.reasoning_content).toBe("This is my thinking process...") + expect(assistantMessage.content).toBe("Here's the answer.") + }) + + it("should concatenate multiple reasoning blocks", () => { + // Multiple reasoning blocks should be concatenated with newlines + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning", text: "First thought..." } as const, + { type: "reasoning", text: "Second thought..." } as const, + { type: "text", text: "Final answer." }, + ], + }, + ] as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + // reasoning_content should concatenate all reasoning blocks + expect(assistantMessage.reasoning_content).toBe("First thought...\nSecond thought...") + expect(assistantMessage.content).toBe("Final answer.") + }) + + it("should handle assistant message with only reasoning (no tool_calls)", () => { + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning", text: "Just reasoning, no tools." } as const, + { type: "text", text: "Response." }, + ], + }, + ] as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + expect(assistantMessage.reasoning_content).toBe("Just reasoning, no tools.") + expect(assistantMessage.content).toBe("Response.") + // Should not have tool_calls + expect(assistantMessage.tool_calls).toBeUndefined() + }) + + it("should handle assistant message with no reasoning", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Simple response." }, + { type: "tool_use", id: "tool_456", name: "write_file", input: { path: "test.ts" } }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + // Should not have reasoning_content when no reasoning blocks exist + expect(assistantMessage.reasoning_content).toBeUndefined() + expect(assistantMessage.content).toBe("Simple response.") + expect(assistantMessage.tool_calls).toHaveLength(1) + }) + + it("should preserve reasoning_content when already present at message level (string content)", () => { + // This handles cases where reasoning_content is already at message level + // (e.g., when messages are converted and then re-converted) + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Here's the answer.", + reasoning_content: "This is the reasoning.", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + // reasoning_content should be preserved at message level + expect(assistantMessage.reasoning_content).toBe("This is the reasoning.") + expect(assistantMessage.content).toBe("Here's the answer.") + }) + + it("should preserve reasoning field at message level as reasoning_content", () => { + // Some providers use "reasoning" instead of "reasoning_content" + const anthropicMessages = [ + { + role: "assistant" as const, + content: "Response text.", + reasoning: "This is the reasoning.", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + // reasoning should be converted to reasoning_content + expect(assistantMessage.reasoning_content).toBe("This is the reasoning.") + expect(assistantMessage.content).toBe("Response text.") + }) + + it("should handle both reasoning blocks and reasoning_details", () => { + // Some providers may have both reasoning blocks in content AND reasoning_details + // This test ensures both are preserved correctly + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning", text: "Reasoning from content block..." } as const, + { type: "text", text: "Response." }, + { type: "tool_use", id: "tool_789", name: "execute_command", input: {} }, + ], + reasoning_details: [ + { + type: "reasoning.summary", + summary: "Summary from reasoning_details.", + format: "xai-responses-v1", + index: 0, + }, + ], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning & { reasoning_details?: ReasoningDetail[] } + + // reasoning_content should be extracted from content blocks + expect(assistantMessage.reasoning_content).toBe("Reasoning from content block...") + // reasoning_details should also be preserved + expect(assistantMessage.reasoning_details).toHaveLength(1) + expect(assistantMessage.reasoning_details?.[0]?.summary).toBe("Summary from reasoning_details.") + // Tool calls should be preserved + expect(assistantMessage.tool_calls).toHaveLength(1) + expect(assistantMessage.tool_calls?.[0]?.id).toBe("tool_789") + }) + + it("should filter out empty reasoning text", () => { + // Empty reasoning blocks should be filtered out + const anthropicMessages = [ + { + role: "assistant" as const, + content: [ + { type: "reasoning", text: "" } as const, + { type: "reasoning", text: "Valid reasoning." } as const, + { type: "text", text: "Response." }, + ], + }, + ] as Anthropic.Messages.MessageParam[] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as AssistantMessageWithReasoning + + // Only non-empty reasoning should be included + expect(assistantMessage.reasoning_content).toBe("Valid reasoning.") + expect(assistantMessage.content).toBe("Response.") + }) + }) }) describe("consolidateReasoningDetails", () => { diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index 90530b6bf31..1b29a68d9f4 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -1,5 +1,6 @@ import { type ModelInfo, + type ModelParameter, type ProviderSettings, type VerbosityLevel, type ReasoningEffortExtended, @@ -67,6 +68,20 @@ type OpenRouterModelParams = { export type ModelParams = AnthropicModelParams | OpenAiModelParams | GeminiModelParams | OpenRouterModelParams +// kilocode_change start +export function isModelParameterSupported(model: ModelInfo, parameter: ModelParameter): boolean { + if (parameter === "temperature" && model.supportsTemperature === false) { + return false + } + + if (model.supportedParameters) { + return model.supportedParameters.includes(parameter) + } + + return true +} +// kilocode_change end + // Function overloads for specific return types export function getModelParams(options: GetModelParamsOptions<"anthropic">): AnthropicModelParams export function getModelParams(options: GetModelParamsOptions<"openai">): OpenAiModelParams @@ -95,7 +110,8 @@ export function getModelParams({ format, }) - let temperature = customTemperature ?? model.defaultTemperature ?? defaultTemperature + let temperature: BaseModelParams["temperature"] = + customTemperature ?? model.defaultTemperature ?? defaultTemperature // kilocode_change let reasoningBudget: ModelParams["reasoningBudget"] = undefined let reasoningEffort: ModelParams["reasoningEffort"] = undefined let verbosity: VerbosityLevel | undefined = model.supportsVerbosity ? customVerbosity : undefined // kilocode_change @@ -148,6 +164,12 @@ export function getModelParams({ } } + // kilocode_change start + if (!isModelParameterSupported(model, "temperature")) { + temperature = undefined + } + // kilocode_change end + const params: BaseModelParams = { maxTokens, temperature, reasoningEffort, reasoningBudget, verbosity } if (format === "anthropic") { diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 5d96a3de36f..f39f9fde9b3 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -1,6 +1,65 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +/** + * Content block type for reasoning content (used by Kimi K2.5 / DeepSeek). + * This is a custom content block type that extends the standard Anthropic content blocks. + */ +export type ReasoningContentBlock = { + type: "reasoning" + text: string +} + +/** + * Content block type for thinking content (used by some providers). + * This is a custom content block type that extends the standard Anthropic content blocks. + */ +export type ThinkingContentBlock = { + type: "thinking" + thinking: string +} + +/** + * Extended content block type that includes custom reasoning and thinking blocks. + * This type is used when messages contain reasoning/thinking content blocks + * that are not part of the standard Anthropic SDK types. + */ +export type ExtendedContentBlockParam = Anthropic.Messages.ContentBlockParam | ReasoningContentBlock | ThinkingContentBlock + +/** + * Extended Anthropic message type that includes custom properties. + * This type is used when messages have been enriched with additional properties + * like reasoning_content, reasoning, or reasoning_details that are not part + * of the standard Anthropic SDK types. + */ +export type ExtendedMessageParam = Anthropic.Messages.MessageParam & { + reasoning_content?: string + reasoning?: string + reasoning_details?: ReasoningDetail[] + content?: string | ExtendedContentBlockParam[] +} + +/** + * Extended OpenAI assistant message type that includes custom properties. + * This type is used when OpenAI messages have been enriched with additional + * properties like reasoning_content or reasoning_details that are not part + * of the standard OpenAI SDK types. + */ +export type ExtendedOpenAIAssistantMessageParam = OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_details?: ReasoningDetail[] + reasoning_content?: string +} + +/** + * Extended OpenAI message type that includes custom properties. + * This type is used when OpenAI messages have been enriched with additional + * properties like reasoning_details that are not part of the standard OpenAI SDK types. + */ +export type ExtendedOpenAIMessageParam = OpenAI.Chat.ChatCompletionMessageParam & { + reasoning_details?: ReasoningDetail[] + reasoning_content?: string +} + /** * Type for OpenRouter's reasoning detail elements. * @see https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response @@ -175,9 +234,9 @@ export function sanitizeGeminiMessages( for (const msg of messages) { if (msg.role === "assistant") { - const anyMsg = msg as any - const toolCalls = anyMsg.tool_calls as OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined - const reasoningDetails = anyMsg.reasoning_details as ReasoningDetail[] | undefined + const extendedMsg = msg as ExtendedOpenAIAssistantMessageParam + const toolCalls = extendedMsg.tool_calls as OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined + const reasoningDetails = extendedMsg.reasoning_details if (Array.isArray(toolCalls) && toolCalls.length > 0) { const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0 @@ -190,8 +249,8 @@ export function sanitizeGeminiMessages( } } // Keep any textual content, but drop the tool_calls themselves - if (anyMsg.content) { - sanitized.push({ role: "assistant", content: anyMsg.content } as any) + if (extendedMsg.content) { + sanitized.push({ role: "assistant", content: extendedMsg.content }) } continue } @@ -221,9 +280,9 @@ export function sanitizeGeminiMessages( validReasoningDetails.push(...detailsWithoutId) // Build the sanitized message - const sanitizedMsg: any = { + const sanitizedMsg: ExtendedOpenAIAssistantMessageParam = { role: "assistant", - content: anyMsg.content ?? "", + content: extendedMsg.content ?? "", } if (validReasoningDetails.length > 0) { @@ -240,8 +299,8 @@ export function sanitizeGeminiMessages( } if (msg.role === "tool") { - const anyMsg = msg as any - if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) { + const toolMsg = msg as OpenAI.Chat.ChatCompletionToolMessageParam + if (toolMsg.tool_call_id && droppedToolCallIds.has(toolMsg.tool_call_id)) { // Skip tool result for dropped tool call continue } @@ -279,21 +338,21 @@ export function convertToOpenAiMessages( ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] - const mapReasoningDetails = (details: unknown): any[] | undefined => { + const mapReasoningDetails = (details: unknown): ReasoningDetail[] | undefined => { if (!Array.isArray(details)) { return undefined } - return details.map((detail: any) => { + return details.map((detail: unknown): ReasoningDetail => { // Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API // requires `store: true` to persist reasoning blocks. Since we manage // conversation state client-side, we don't use `store: true`, and sending // back the `id` field causes a 404 error. - if (detail?.format === "openai-responses-v1" && detail?.id) { - const { id, ...rest } = detail + if (detail && typeof detail === "object" && "format" in detail && detail.format === "openai-responses-v1" && "id" in detail && detail.id) { + const { id: _id, ...rest } = detail as ReasoningDetail & { id: string } return rest } - return detail + return detail as ReasoningDetail }) } @@ -306,16 +365,26 @@ export function convertToOpenAiMessages( // will convert a single text block into a string for compactness. // If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.), // we must preserve it here as well. - const messageWithDetails = anthropicMessage as any - const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { + const messageWithDetails = anthropicMessage as ExtendedMessageParam + const baseMessage: ExtendedOpenAIMessageParam = { role: anthropicMessage.role, content: anthropicMessage.content, } + // kilocode_change start: Extract reasoning_content from message-level fields + // This handles cases where reasoning_content or reasoning is already stored at the message level + // (e.g., when messages are converted and then re-converted) + if (messageWithDetails.reasoning_content) { + baseMessage.reasoning_content = messageWithDetails.reasoning_content + } else if (messageWithDetails.reasoning) { + baseMessage.reasoning_content = messageWithDetails.reasoning + } + // kilocode_change end + if (anthropicMessage.role === "assistant") { const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) if (mapped) { - ;(baseMessage as any).reasoning_details = mapped + baseMessage.reasoning_details = mapped } } @@ -481,20 +550,47 @@ export function convertToOpenAiMessages( })) // Check if the message has reasoning_details (used by Gemini 3, xAI, etc.) - const messageWithDetails = anthropicMessage as any + const messageWithDetails = anthropicMessage as ExtendedMessageParam + + // kilocode_change start: Extract reasoning blocks from content for Kimi K2.5 / DeepSeek compatibility + // Kimi K2.5 requires reasoning_content at the message level, not in content array + // During multi-step tool calling, this reasoning_content must be preserved in the context + // If reasoning_content is missing from assistant messages with tool_calls, the API returns a 400 error + const contentBlocks = Array.isArray(messageWithDetails.content) ? messageWithDetails.content : [] + const reasoningBlocks = contentBlocks.filter( + (block: ExtendedContentBlockParam): block is ReasoningContentBlock | ThinkingContentBlock => + block.type === "reasoning" || block.type === "thinking" + ) + const reasoningContent = reasoningBlocks + .map((block: ReasoningContentBlock | ThinkingContentBlock) => { + if (block.type === "reasoning") { + return block.text + } else if (block.type === "thinking") { + return block.thinking + } + return "" + }) + .filter(Boolean) + .join("\n") + // kilocode_change end - // Build message with reasoning_details BEFORE tool_calls to preserve + // Build message with reasoning_content BEFORE reasoning_details and tool_calls to preserve // the order expected by providers like Roo. Property order matters // when sending messages back to some APIs. - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { - reasoning_details?: any[] - } = { + const baseMessage: ExtendedOpenAIAssistantMessageParam = { role: "assistant", // Use empty string instead of undefined for providers like Gemini (via OpenRouter) // that require every message to have content in the "parts" field content: content ?? "", } + // kilocode_change start: Add reasoning_content for Kimi K2.5 / DeepSeek compatibility + // This must be at the message level, not inside content array + if (reasoningContent) { + baseMessage.reasoning_content = reasoningContent + } + // kilocode_change end + // Pass through reasoning_details to preserve the original shape from the API. // The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails). const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) @@ -502,7 +598,7 @@ export function convertToOpenAiMessages( baseMessage.reasoning_details = mapped } - // Add tool_calls after reasoning_details + // Add tool_calls after reasoning fields // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty if (tool_calls.length > 0) { baseMessage.tool_calls = tool_calls