diff --git a/package-lock.json b/package-lock.json index d6ef41ff0..ffb4bf595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1781,13 +1781,14 @@ "link": true }, "node_modules/@mistralai/mistralai": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", - "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.0.tgz", + "integrity": "sha512-JQUGIXjFWnw/J9LpTSf/ZXwVW3Sh8FBAcfTo5QvAHqkl4CfSiIwnjRJhMoAFcP6ncCe84YPU1ncDGX+p3OXnfg==", + "license": "Apache-2.0", "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.24.1" + "zod-to-json-schema": "^3.25.0" } }, "node_modules/@napi-rs/canvas": { @@ -8576,7 +8577,7 @@ "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", - "@mistralai/mistralai": "1.14.1", + "@mistralai/mistralai": "^2.2.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 77cbb3cad..9afd9b42b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed Anthropic and Bedrock adaptive-thinking payload tests to expect the default `display: "summarized"` field when reasoning is enabled. +- Fixed Mistral Small 4 reasoning requests to use `reasoning_effort` instead of `prompt_mode`, restoring default thinking support for `mistral-small-2603` and `mistral-small-latest` ([#3338](https://github.com/badlogic/pi-mono/issues/3338)) - Fixed `qwen-chat-template` OpenAI-compatible requests to set `chat_template_kwargs.preserve_thinking: true`, preserving prior Qwen thinking across turns so multi-turn tool calls keep their arguments instead of degrading to empty `{}` payloads ([#3325](https://github.com/badlogic/pi-mono/issues/3325)) ## [0.67.6] - 2026-04-16 diff --git a/packages/ai/package.json b/packages/ai/package.json index 7fddf4c32..881ada704 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -75,7 +75,7 @@ "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", - "@mistralai/mistralai": "1.14.1", + "@mistralai/mistralai": "^2.2.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index 9685a296c..33a74885f 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -1,12 +1,11 @@ import { Mistral } from "@mistralai/mistralai"; -import type { RequestOptions } from "@mistralai/mistralai/lib/sdks.js"; import type { ChatCompletionStreamRequest, - ChatCompletionStreamRequestMessages, + ChatCompletionStreamRequestMessage, CompletionEvent, ContentChunk, FunctionTool, -} from "@mistralai/mistralai/models/components/index.js"; +} from "@mistralai/mistralai/models/components"; import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost } from "../models.js"; import type { @@ -36,9 +35,12 @@ const MAX_MISTRAL_ERROR_BODY_CHARS = 4000; /** * Provider-specific options for the Mistral API. */ +type MistralReasoningEffort = "none" | "high"; + export interface MistralOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } }; promptMode?: "reasoning"; + reasoningEffort?: MistralReasoningEffort; } /** @@ -118,10 +120,12 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple const base = buildBaseOptions(model, options, apiKey); const reasoning = clampReasoning(options?.reasoning); + const shouldUseReasoning = model.reasoning && reasoning !== undefined; return streamMistral(model, context, { ...base, - promptMode: model.reasoning && reasoning ? "reasoning" : undefined, + promptMode: shouldUseReasoning && usesPromptModeReasoning(model) ? "reasoning" : undefined, + reasoningEffort: shouldUseReasoning && usesReasoningEffort(model) ? mapReasoningEffort(reasoning) : undefined, } satisfies MistralOptions); }; @@ -205,10 +209,15 @@ function safeJsonStringify(value: unknown): string { } } -function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions): RequestOptions { - const requestOptions: RequestOptions = {}; +function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) { + const requestOptions: { + signal?: AbortSignal; + retries: { strategy: "none" }; + headers?: Record; + } = { + retries: { strategy: "none" }, + }; if (options?.signal) requestOptions.signal = options.signal; - requestOptions.retries = { strategy: "none" }; const headers: Record = {}; if (model.headers) Object.assign(headers, model.headers); @@ -243,7 +252,8 @@ function buildChatPayload( if (options?.temperature !== undefined) payload.temperature = options.temperature; if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens; if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice); - if (options?.promptMode) payload.promptMode = options.promptMode as any; + if (options?.promptMode) payload.promptMode = options.promptMode; + if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort; if (context.systemPrompt) { payload.messages.unshift({ @@ -452,8 +462,8 @@ function toFunctionTools(tools: Tool[]): Array 0) assistantMessage.content = contentParts; if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls; if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage); @@ -560,6 +570,18 @@ function buildToolResultText(text: string, hasImages: boolean, supportsImages: b return isError ? "[tool error] (no tool output)" : "(no tool output)"; } +function usesReasoningEffort(model: Model<"mistral-conversations">): boolean { + return model.id === "mistral-small-2603" || model.id === "mistral-small-latest"; +} + +function usesPromptModeReasoning(model: Model<"mistral-conversations">): boolean { + return model.reasoning && !usesReasoningEffort(model); +} + +function mapReasoningEffort(_level: Exclude): MistralReasoningEffort { + return "high"; +} + function mapToolChoice( choice: MistralOptions["toolChoice"], ): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined { diff --git a/packages/ai/test/mistral-reasoning-mode.test.ts b/packages/ai/test/mistral-reasoning-mode.test.ts new file mode 100644 index 000000000..2f1231d75 --- /dev/null +++ b/packages/ai/test/mistral-reasoning-mode.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { streamSimple } from "../src/stream.js"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.js"; + +interface MistralPayload { + promptMode?: "reasoning"; + reasoningEffort?: "none" | "high"; +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +async function capturePayload( + model: Model<"mistral-conversations">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: MistralPayload | undefined; + const payloadCaptureModel: Model<"mistral-conversations"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const stream = streamSimple(payloadCaptureModel, makeContext(), { + ...options, + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as MistralPayload; + return payload; + }, + }); + + await stream.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +describe("Mistral reasoning mode selection", () => { + it("uses reasoning_effort for Mistral Small 4", async () => { + const payload = await capturePayload(getModel("mistral", "mistral-small-2603"), { reasoning: "medium" }); + + expect(payload.reasoningEffort).toBe("high"); + expect(payload.promptMode).toBeUndefined(); + }); + + it("omits reasoning controls for Mistral Small 4 when thinking is off", async () => { + const payload = await capturePayload(getModel("mistral", "mistral-small-2603")); + + expect(payload.reasoningEffort).toBeUndefined(); + expect(payload.promptMode).toBeUndefined(); + }); + + it("uses prompt_mode for Magistral reasoning models", async () => { + const payload = await capturePayload(getModel("mistral", "magistral-medium-latest"), { reasoning: "medium" }); + + expect(payload.promptMode).toBe("reasoning"); + expect(payload.reasoningEffort).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 71aadd1e4..07c68ecdd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed `/scoped-models` Alt+Up/Down to stay a no-op in the implicit `all enabled` state instead of materializing a full explicit enabled-model list and marking the selector dirty ([#3331](https://github.com/badlogic/pi-mono/issues/3331)) +- Fixed Mistral Small 4 default thinking requests to use the model's supported reasoning control, avoiding `400` errors when starting sessions on `mistral-small-2603` and `mistral-small-latest` ([#3338](https://github.com/badlogic/pi-mono/issues/3338)) - Fixed flaky git package update notifications by waiting for captured git command stdio to fully drain before comparing local and remote commit SHAs ([#3027](https://github.com/badlogic/pi-mono/issues/3027)) - Fixed auto-retry transient error detection to treat `Network connection lost.` as retryable, so dropped provider connections retry instead of terminating the agent ([#3317](https://github.com/badlogic/pi-mono/issues/3317)) - Fixed compact interactive extension startup summaries to disambiguate package extensions and repeated local `index.ts` entries by using package-aware labels and the minimal parent path needed to make local entries unique ([#3308](https://github.com/badlogic/pi-mono/issues/3308))