diff --git a/.changeset/fix-case-insensitive-model-search.md b/.changeset/fix-case-insensitive-model-search.md new file mode 100644 index 00000000000..0e42a39f8e9 --- /dev/null +++ b/.changeset/fix-case-insensitive-model-search.md @@ -0,0 +1,10 @@ +--- +"webview-ui": patch +--- + +Fix case-insensitive model search in ModelPicker + +Users can now search for models regardless of casing. For example, searching for "kimi k2.5" will find models like "Kimi-K2.5-Instruct". This fixes model discovery issues when using Azure Cognitive Services or other OpenAI-compatible providers that return models with different casing. + +Before: Search was case-sensitive, making it hard to find models +After: Search is case-insensitive for better discoverability diff --git a/.changeset/fix-kimi-model-search.md b/.changeset/fix-kimi-model-search.md new file mode 100644 index 00000000000..60ffea14ba0 --- /dev/null +++ b/.changeset/fix-kimi-model-search.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Kimi model search and add Kimi models as fallback for OpenAI Compatible provider diff --git a/packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts b/packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts index 46867e7f6f2..e0737b98add 100644 --- a/packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts +++ b/packages/agent-runtime/src/host/__tests__/VSCode.applyEdit.spec.ts @@ -4,7 +4,7 @@ import path from "path" import { createVSCodeAPIMock, Uri, WorkspaceEdit, Position, Range } from "../VSCode.js" describe("WorkspaceAPI.applyEdit", () => { - const tempDir = path.join(process.cwd(), "packages/agent-runtime/src/host/__tests__/__tmp__") + const tempDir = path.join(__dirname, "__tmp__") const filePath = path.join(tempDir, "apply-edit.txt") beforeEach(() => { diff --git a/src/api/providers/__tests__/mistral-fim.spec.ts b/src/api/providers/__tests__/mistral-fim.spec.ts index dffd26ebac5..c980fab7bc3 100644 --- a/src/api/providers/__tests__/mistral-fim.spec.ts +++ b/src/api/providers/__tests__/mistral-fim.spec.ts @@ -4,18 +4,32 @@ // Mock vscode first to avoid import errors vitest.mock("vscode", () => ({})) -import { MistralHandler } from "../mistral" -import { ApiHandlerOptions } from "../../../shared/api" -import { streamSse } from "../../../services/autocomplete/continuedev/core/fetch/stream" +// Mock the Mistral SDK +const mockFimStream = vitest.fn() +vitest.mock("@mistralai/mistralai", () => ({ + Mistral: vitest.fn().mockImplementation((config: any) => ({ + fim: { stream: mockFimStream }, + chat: { stream: vitest.fn(), complete: vitest.fn() }, + _config: config, + })), +})) -// Mock the stream module -vitest.mock("../../../services/autocomplete/continuedev/core/fetch/stream", () => ({ - streamSse: vitest.fn(), +// Mock TelemetryService for error handling tests +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), + }, + }, })) // Mock delay vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) +import { Mistral } from "@mistralai/mistralai" +import { MistralHandler } from "../mistral" +import { ApiHandlerOptions } from "../../../shared/api" + describe("MistralHandler FIM support", () => { const mockOptions: ApiHandlerOptions = { mistralApiKey: "test-api-key", @@ -73,20 +87,14 @@ describe("MistralHandler FIM support", () => { apiModelId: "codestral-latest", }) - // Mock streamSse to return the expected data - ;(streamSse as any).mockImplementation(async function* () { - yield { choices: [{ delta: { content: "chunk1" } }] } - yield { choices: [{ delta: { content: "chunk2" } }] } - yield { choices: [{ delta: { content: "chunk3" } }] } - }) - - const mockResponse = { - ok: true, - status: 200, - statusText: "OK", - } as Response - - global.fetch = vitest.fn().mockResolvedValue(mockResponse) + // Mock the SDK's fim.stream to return an async iterable of events + mockFimStream.mockResolvedValue( + (async function* () { + yield { data: { choices: [{ delta: { content: "chunk1" } }] } } + yield { data: { choices: [{ delta: { content: "chunk2" } }] } } + yield { data: { choices: [{ delta: { content: "chunk3" } }] } } + })(), + ) const chunks: string[] = [] const fimHandler = handler.fimSupport() @@ -97,7 +105,14 @@ describe("MistralHandler FIM support", () => { } expect(chunks).toEqual(["chunk1", "chunk2", "chunk3"]) - expect(streamSse).toHaveBeenCalledWith(mockResponse) + expect(mockFimStream).toHaveBeenCalledWith( + expect.objectContaining({ + model: "codestral-latest", + prompt: "prefix", + suffix: "suffix", + stream: true, + }), + ) }) it("handles errors correctly", async () => { @@ -106,53 +121,27 @@ describe("MistralHandler FIM support", () => { apiModelId: "codestral-latest", }) - const mockResponse = { - ok: false, - status: 400, - statusText: "Bad Request", - text: vitest.fn().mockResolvedValue("Invalid request"), - } - - global.fetch = vitest.fn().mockResolvedValue(mockResponse) + // Mock the SDK throwing an error (SDK handles HTTP errors internally) + mockFimStream.mockRejectedValue(new Error("FIM request failed")) const fimHandler = handler.fimSupport() expect(fimHandler).toBeDefined() const generator = fimHandler!.streamFim("prefix", "suffix") - await expect(generator.next()).rejects.toThrow("FIM streaming failed: 400 Bad Request - Invalid request") + await expect(generator.next()).rejects.toThrow("Mistral FIM completion error: FIM request failed") }) it("uses correct endpoint for codestral models", async () => { + // Create handler with codestral model — should use codestral.mistral.ai const handler = new MistralHandler({ ...mockOptions, apiModelId: "codestral-latest", }) - ;(streamSse as any).mockImplementation(async function* () { - yield { choices: [{ delta: { content: "test" } }] } - }) - - const mockResponse = { - ok: true, - status: 200, - statusText: "OK", - } as Response - - global.fetch = vitest.fn().mockResolvedValue(mockResponse) - - const fimHandler = handler.fimSupport() - expect(fimHandler).toBeDefined() - const generator = fimHandler!.streamFim("prefix", "suffix") - await generator.next() - - expect(global.fetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: "https://codestral.mistral.ai/v1/fim/completions", - }), + // Verify the Mistral client was constructed with the codestral URL + expect(Mistral).toHaveBeenCalledWith( expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer test-api-key", - }), + serverURL: "https://codestral.mistral.ai", + apiKey: "test-api-key", }), ) }) @@ -164,28 +153,12 @@ describe("MistralHandler FIM support", () => { mistralCodestralUrl: "https://custom.codestral.url", }) - ;(streamSse as any).mockImplementation(async function* () { - yield { choices: [{ delta: { content: "test" } }] } - }) - - const mockResponse = { - ok: true, - status: 200, - statusText: "OK", - } as Response - - global.fetch = vitest.fn().mockResolvedValue(mockResponse) - - const fimHandler = handler.fimSupport() - expect(fimHandler).toBeDefined() - const generator = fimHandler!.streamFim("prefix", "suffix") - await generator.next() - - expect(global.fetch).toHaveBeenCalledWith( + // Verify the Mistral client was constructed with the custom URL + expect(Mistral).toHaveBeenCalledWith( expect.objectContaining({ - href: "https://custom.codestral.url/v1/fim/completions", + serverURL: "https://custom.codestral.url", + apiKey: "test-api-key", }), - expect.any(Object), ) }) }) diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index 41d9746c2e8..4ea02885661 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -15,12 +15,9 @@ import { ApiHandlerOptions } from "../../shared/api" import { convertToMistralMessages } from "../transform/mistral-format" import { ApiStream } from "../transform/stream" -import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { DEFAULT_HEADERS } from "./constants" // kilocode_change -import { streamSse } from "../../services/autocomplete/continuedev/core/fetch/stream" // kilocode_change import type { CompletionUsage } from "./openrouter" // kilocode_change import type { FimHandler } from "./kilocode/FimHandler" // kilocode_change @@ -258,56 +255,50 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand ): AsyncGenerator { const { id: model, maxTokens } = this.getModel() - // Get the base URL for the model - // copy pasted from constructor, be sure to keep in sync - const baseUrl = model.startsWith("codestral-") - ? this.options.mistralCodestralUrl || "https://codestral.mistral.ai" - : "https://api.mistral.ai" - - const endpoint = new URL("v1/fim/completions", baseUrl) - - const headers: Record = { - ...DEFAULT_HEADERS, - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${this.options.mistralApiKey}`, - } - // temperature: 0.2 is mentioned as a sane example in mistral's docs const temperature = 0.2 const requestMaxTokens = 256 - const response = await fetch(endpoint, { - method: "POST", - body: JSON.stringify({ - model, - prompt: prefix, - suffix, - max_tokens: Math.min(requestMaxTokens, maxTokens ?? requestMaxTokens), - temperature, - stream: true, - }), - headers, - }) + const request = { + model, + temperature, + maxTokens: Math.min(requestMaxTokens, maxTokens ?? requestMaxTokens), + stream: true, + prompt: prefix, + suffix, + } - if (!response.ok) { - const errorText = await response.text() - throw new Error(`FIM streaming failed: ${response.status} ${response.statusText} - ${errorText}`) + let response + try { + response = await this.client.fim.stream(request) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, model, "streamFim") + TelemetryService.instance.captureException(apiError) + throw new Error(`Mistral FIM completion error: ${errorMessage}`) } - for await (const data of streamSse(response)) { - const content = data.choices?.[0]?.delta?.content - if (content) { + for await (const ev of response) { + const data = ev.data + + const content = data.choices[0]?.delta.content + if (typeof content === "string") { yield content + } else if (content !== null && content !== undefined) { + for (const chunk of content) { + if (chunk.type === "text") { + yield chunk.text + } + } } // Call usage callback when available // Note: Mistral FIM API returns usage in the final chunk with prompt_tokens and completion_tokens if (data.usage && onUsage) { onUsage({ - prompt_tokens: data.usage.prompt_tokens, - completion_tokens: data.usage.completion_tokens, - total_tokens: data.usage.total_tokens, + prompt_tokens: data.usage.promptTokens, + completion_tokens: data.usage.completionTokens, + total_tokens: data.usage.totalTokens, }) } } diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c1bd66facd7..863498f044d 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -887,6 +887,9 @@ export class NativeToolCallParser { params, partial: false, // Native tool calls are always complete when yielded nativeArgs, + // Preserve original args for API history to maintain format consistency + // This ensures line_ranges stays as [[1, 50]] instead of being converted to lineRanges + rawInput: args, } // Preserve original name for API history when an alias was used diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 1627b1005fb..e629b1a97c2 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -169,6 +169,49 @@ describe("NativeToolCallParser", () => { ]) } }) + + it("should preserve rawInput with original line_ranges format for API history", () => { + const toolCall = { + id: "toolu_123", + name: "read_file" as const, + arguments: JSON.stringify({ + files: [ + { + path: "src/core/task/Task.ts", + line_ranges: [ + [1920, 1990], + [2060, 2120], + ], + }, + ], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + // Verify nativeArgs has converted format (lineRanges with objects) + const nativeArgs = result.nativeArgs as { + files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> + } + expect(nativeArgs.files[0].lineRanges).toEqual([ + { start: 1920, end: 1990 }, + { start: 2060, end: 2120 }, + ]) + + // Verify rawInput preserves original format (line_ranges with tuples) + expect(result.rawInput).toBeDefined() + const rawInput = result.rawInput as { + files: Array<{ path: string; line_ranges?: Array<[number, number]> }> + } + expect(rawInput.files[0].line_ranges).toEqual([ + [1920, 1990], + [2060, 2120], + ]) + } + }) }) }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 38510d20d24..20f23a506c4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3781,8 +3781,11 @@ export class Task extends EventEmitter implements TaskLike { continue } seenToolUseIds.add(sanitizedId) - // nativeArgs is already in the correct API format for all tools - const input = toolUse.nativeArgs || toolUse.params + // Use rawInput to preserve original API format for history consistency. + // This ensures parameters like line_ranges stay as [[1, 50]] instead of + // being converted to lineRanges with object format [{ start: 1, end: 50 }]. + // Fall back to nativeArgs for tools that don't have rawInput, then to params for legacy. + const input = toolUse.rawInput || toolUse.nativeArgs || toolUse.params // Use originalName (alias) if present for API history consistency. // When tool aliases are used (e.g., "edit_file" -> "search_and_replace"), diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 4a2bf415925..4fe727ff39e 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -150,6 +150,12 @@ export interface ToolUse { toolUseId?: string // kilocode_change // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + /** + * The raw input object from the API, preserving original parameter names and formats. + * Used for saving to conversation history to maintain API format consistency. + * For example, read_file keeps `line_ranges` as `[[1, 50]]` instead of converting to `lineRanges`. + */ + rawInput?: Record } /** diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index c0a554909b4..cbe549eda2e 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -99,6 +99,21 @@ export const ModelPicker = ({ const [searchValue, setSearchValue] = useState("") + // kilocode_change: Case-insensitive search with dash/space normalization + const normalizeForSearch = (str: string) => str.toLowerCase().replace(/[-_\s]/g, "") + + const filteredPreferredIds = useMemo(() => { + if (!searchValue.trim()) return preferredModelIds + const searchNormalized = normalizeForSearch(searchValue) + return preferredModelIds.filter((id) => normalizeForSearch(id).includes(searchNormalized)) + }, [preferredModelIds, searchValue]) + + const filteredRestIds = useMemo(() => { + if (!searchValue.trim()) return restModelIds + const searchNormalized = normalizeForSearch(searchValue) + return restModelIds.filter((id) => normalizeForSearch(id).includes(searchNormalized)) + }, [restModelIds, searchValue]) + const onSelect = useCallback( (modelId: string) => { if (!modelId) { @@ -180,7 +195,7 @@ export const ModelPicker = ({ - +
)} - {/* kilocode_change start: Section headers for recommended and all models */} - {preferredModelIds.length > 0 && ( - - {preferredModelIds.map((model) => ( + {/* kilocode_change start: Section headers for recommended and all models with case-insensitive search */} + {filteredPreferredIds.length > 0 && ( + + {filteredPreferredIds.map((model) => ( )} - {restModelIds.length > 0 && ( + {filteredRestIds.length > 0 && ( - {restModelIds.map((model) => ( + {filteredRestIds.map((model) => ( { + const fetchedModels = openAiModels ?? {} + if (isKimiEndpoint) { + return { ...moonshotModels, ...fetchedModels } + } + return fetchedModels + }, [isKimiEndpoint, openAiModels]) + + const defaultModelId = isKimiEndpoint ? moonshotDefaultModelId : "gpt-4o" + const handleAddCustomHeader = useCallback(() => { // Only update the local state to show the new row in the UI. setCustomHeaders((prev) => [...prev, ["", ""]]) @@ -138,10 +156,11 @@ export const OpenAICompatible = ({