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
10 changes: 10 additions & 0 deletions .changeset/fix-case-insensitive-model-search.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/fix-kimi-model-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix Kimi model search and add Kimi models as fallback for OpenAI Compatible provider
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
123 changes: 48 additions & 75 deletions src/api/providers/__tests__/mistral-fim.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand All @@ -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 () => {
Expand All @@ -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",
}),
)
})
Expand All @@ -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),
)
})
})
Expand Down
69 changes: 30 additions & 39 deletions src/api/providers/mistral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -258,56 +255,50 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
): AsyncGenerator<string> {
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<string, string> = {
...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}`)
}
Comment on lines +271 to 279
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Mistral fim tests stale 🐞 Bug ⛯ Reliability

mistral-fim.spec.ts still mocks/ asserts the old fetch+SSE behavior, but streamFim now uses the
Mistral SDK (client.fim.stream). This mismatch will keep CI failing until tests are updated to
mock the SDK layer.
Agent Prompt
### Issue description
Unit tests in `mistral-fim.spec.ts` are written for the previous implementation (manual `fetch` + `streamSse`). The provider now uses the Mistral SDK’s `client.fim.stream`, so these tests will fail.

### Issue Context
`mistral.spec.ts` already demonstrates the pattern used in this repo for mocking the Mistral SDK (`@mistralai/mistralai`) by returning an async-iterable stream.

### Fix Focus Areas
- src/api/providers/__tests__/mistral-fim.spec.ts[7-191]
- src/api/providers/__tests__/mistral.spec.ts[11-48]
- src/api/providers/mistral.ts[250-305]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


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") {
Comment on lines +282 to +285
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Unsafe data.choices[0] access 📘 Rule violation ⛯ Reliability

streamFim now indexes data.choices[0] without guarding when choices is missing, which can
throw at runtime and break streaming. This violates the requirement to explicitly handle null/empty
edge cases.
Agent Prompt
## Issue description
`streamFim` can throw when `ev.data.choices` is missing because it uses `data.choices[0]` without optional chaining on `choices`.

## Issue Context
The previous implementation used safe optional chaining (`data.choices?.[0]?.delta?.content`). The new SDK event payload may omit fields in some chunks or error cases.

## Fix Focus Areas
- src/api/providers/mistral.ts[282-285]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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,
})
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
])
}
})
})
})

Expand Down
7 changes: 5 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3781,8 +3781,11 @@ export class Task extends EventEmitter<TaskEvents> 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"),
Expand Down
6 changes: 6 additions & 0 deletions src/shared/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ export interface ToolUse<TName extends ToolName = ToolName> {
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<string, unknown>
}

/**
Expand Down
Loading
Loading