diff --git a/package-lock.json b/package-lock.json index 5621c32950..b1de717b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "claude-dev", - "version": "3.1.6", + "version": "3.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-dev", - "version": "3.1.6", + "version": "3.1.8", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/vertex-sdk": "^0.4.1", "@google/generative-ai": "^0.18.0", + "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", "@types/get-folder-size": "^3.0.4", @@ -2795,6 +2796,14 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, + "node_modules/@mistralai/mistralai": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.3.6.tgz", + "integrity": "sha512-2y7U5riZq+cIjKpxGO9y417XuZv9CpBXEAvbjRMzWPGhXY7U1ZXj4VO4H9riS2kFZqTR2yLEKSE6/pGWVVIqgQ==", + "peerDependencies": { + "zod": ">= 3" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", diff --git a/package.json b/package.json index b6c38ac0a3..488dd6e54b 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/vertex-sdk": "^0.4.1", "@google/generative-ai": "^0.18.0", + "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", "@types/get-folder-size": "^3.0.4", diff --git a/src/api/index.ts b/src/api/index.ts index 287f843642..d3308df5c6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -11,6 +11,7 @@ import { GeminiHandler } from "./providers/gemini" import { OpenAiNativeHandler } from "./providers/openai-native" import { ApiStream } from "./transform/stream" import { DeepSeekHandler } from "./providers/deepseek" +import { MistralHandler } from "./providers/mistral" export interface ApiHandler { createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream @@ -40,6 +41,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new OpenAiNativeHandler(options) case "deepseek": return new DeepSeekHandler(options) + case "mistral": + return new MistralHandler(options) default: return new AnthropicHandler(options) } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts new file mode 100644 index 0000000000..c4377f0003 --- /dev/null +++ b/src/api/providers/mistral.ts @@ -0,0 +1,74 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { Mistral } from "@mistralai/mistralai" +import { ApiHandler } from "../" +import { + ApiHandlerOptions, + mistralDefaultModelId, + MistralModelId, + mistralModels, + ModelInfo, + openAiNativeDefaultModelId, + OpenAiNativeModelId, + openAiNativeModels, +} from "../../shared/api" +import { convertToMistralMessages } from "../transform/mistral-format" +import { ApiStream } from "../transform/stream" + +export class MistralHandler implements ApiHandler { + private options: ApiHandlerOptions + private client: Mistral + + constructor(options: ApiHandlerOptions) { + this.options = options + this.client = new Mistral({ + serverURL: "https://codestral.mistral.ai", + apiKey: this.options.mistralApiKey, + }) + } + + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const stream = await this.client.chat.stream({ + model: this.getModel().id, + // max_completion_tokens: this.getModel().info.maxTokens, + temperature: 0, + messages: [{ role: "system", content: systemPrompt }, ...convertToMistralMessages(messages)], + stream: true, + }) + + for await (const chunk of stream) { + const delta = chunk.data.choices[0]?.delta + if (delta?.content) { + let content: string = "" + if (typeof delta.content === "string") { + content = delta.content + } else if (Array.isArray(delta.content)) { + content = delta.content.map((c) => (c.type === "text" ? c.text : "")).join("") + } + yield { + type: "text", + text: content, + } + } + + if (chunk.data.usage) { + yield { + type: "usage", + inputTokens: chunk.data.usage.promptTokens || 0, + outputTokens: chunk.data.usage.completionTokens || 0, + } + } + } + } + + getModel(): { id: MistralModelId; info: ModelInfo } { + const modelId = this.options.apiModelId + if (modelId && modelId in mistralModels) { + const id = modelId as MistralModelId + return { id, info: mistralModels[id] } + } + return { + id: mistralDefaultModelId, + info: mistralModels[mistralDefaultModelId], + } + } +} diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts new file mode 100644 index 0000000000..16c6aaf238 --- /dev/null +++ b/src/api/transform/mistral-format.ts @@ -0,0 +1,92 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { Mistral } from "@mistralai/mistralai" +import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage" +import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage" +import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage" +import { UserMessage } from "@mistralai/mistralai/models/components/usermessage" + +export type MistralMessage = + | (SystemMessage & { role: "system" }) + | (UserMessage & { role: "user" }) + | (AssistantMessage & { role: "assistant" }) + | (ToolMessage & { role: "tool" }) + +export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] { + const mistralMessages: MistralMessage[] = [] + for (const anthropicMessage of anthropicMessages) { + if (typeof anthropicMessage.content === "string") { + mistralMessages.push({ + role: anthropicMessage.role, + content: anthropicMessage.content, + }) + } else { + if (anthropicMessage.role === "user") { + const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolResultBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_result") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } // user cannot send tool_use messages + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + if (nonToolMessages.length > 0) { + mistralMessages.push({ + role: "user", + content: nonToolMessages.map((part) => { + if (part.type === "image") { + return { + type: "image_url", + imageUrl: { + url: `data:${part.source.media_type};base64,${part.source.data}`, + }, + } + } + return { type: "text", text: part.text } + }), + }) + } + } else if (anthropicMessage.role === "assistant") { + const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ + nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] + toolMessages: Anthropic.ToolUseBlockParam[] + }>( + (acc, part) => { + if (part.type === "tool_use") { + acc.toolMessages.push(part) + } else if (part.type === "text" || part.type === "image") { + acc.nonToolMessages.push(part) + } // assistant cannot send tool_result messages + return acc + }, + { nonToolMessages: [], toolMessages: [] }, + ) + + let content: string | undefined + if (nonToolMessages.length > 0) { + content = nonToolMessages + .map((part) => { + if (part.type === "image") { + return "" // impossible as the assistant cannot send images + } + return part.text + }) + .join("\n") + } + + mistralMessages.push({ + role: "assistant", + content, + }) + } + } + } + + return mistralMessages +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e2a1d7f453..54e47055f2 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -41,6 +41,7 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" + | "mistralApiKey" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -392,6 +393,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { geminiApiKey, openAiNativeApiKey, deepSeekApiKey, + mistralApiKey, azureApiVersion, openRouterModelId, openRouterModelInfo, @@ -418,6 +420,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("geminiApiKey", geminiApiKey) await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) await this.storeSecret("deepSeekApiKey", deepSeekApiKey) + await this.storeSecret("mistralApiKey", mistralApiKey) await this.updateGlobalState("azureApiVersion", azureApiVersion) await this.updateGlobalState("openRouterModelId", openRouterModelId) await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) @@ -1023,6 +1026,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { geminiApiKey, openAiNativeApiKey, deepSeekApiKey, + mistralApiKey, azureApiVersion, openRouterModelId, openRouterModelInfo, @@ -1054,6 +1058,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getSecret("geminiApiKey") as Promise, this.getSecret("openAiNativeApiKey") as Promise, this.getSecret("deepSeekApiKey") as Promise, + this.getSecret("mistralApiKey") as Promise, this.getGlobalState("azureApiVersion") as Promise, this.getGlobalState("openRouterModelId") as Promise, this.getGlobalState("openRouterModelInfo") as Promise, @@ -1102,6 +1107,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { geminiApiKey, openAiNativeApiKey, deepSeekApiKey, + mistralApiKey, azureApiVersion, openRouterModelId, openRouterModelInfo, @@ -1187,6 +1193,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", + "mistralApiKey", ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/shared/api.ts b/src/shared/api.ts index 8229d02790..f5ff3017fe 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -9,6 +9,7 @@ export type ApiProvider = | "gemini" | "openai-native" | "deepseek" + | "mistral" export interface ApiHandlerOptions { apiModelId?: string @@ -34,6 +35,7 @@ export interface ApiHandlerOptions { geminiApiKey?: string openAiNativeApiKey?: string deepSeekApiKey?: string + mistralApiKey?: string azureApiVersion?: string } @@ -374,3 +376,18 @@ export const deepSeekModels = { cacheReadsPrice: 0.014, }, } as const satisfies Record + +// Mistral +// https://docs.mistral.ai/getting-started/models/models_overview/ +export type MistralModelId = keyof typeof mistralModels +export const mistralDefaultModelId: MistralModelId = "codestral-latest" +export const mistralModels = { + "codestral-latest": { + maxTokens: 32_768, + contextWindow: 256_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.3, + outputPrice: 0.9, + }, +} as const satisfies Record diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index fa00598a8e..28cbb6fd7c 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -21,6 +21,8 @@ import { deepSeekModels, geminiDefaultModelId, geminiModels, + mistralDefaultModelId, + mistralModels, openAiModelInfoSaneDefaults, openAiNativeDefaultModelId, openAiNativeModels, @@ -142,6 +144,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: Anthropic Google Gemini DeepSeek + Mistral GCP Vertex AI AWS Bedrock OpenAI @@ -270,6 +273,37 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: )} + {selectedProvider === "mistral" && ( +
+ + Mistral API Key + +

+ This key is stored locally and only used to make API requests from this extension. + {!apiConfiguration?.mistralApiKey && ( + + You can get a Mistral API key by signing up here. + + )} +

+
+ )} + {selectedProvider === "openrouter" && (
key !== undefined) : false setShowWelcome(!hasKey) diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 91cf4f9136..7dce99bebd 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -38,6 +38,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s return "You must provide a valid API key or choose a different provider." } break + case "mistral": + if (!apiConfiguration.mistralApiKey) { + return "You must provide a valid API key or choose a different provider." + } + break case "openai": if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) { return "You must provide a valid base URL, API key, and model ID."