fix(ai): use reasoning_effort for Mistral Small 4 closes #3338
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1781,13 +1781,14 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@mistralai/mistralai": {
|
"node_modules/@mistralai/mistralai": {
|
||||||
"version": "1.14.1",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.0.tgz",
|
||||||
"integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==",
|
"integrity": "sha512-JQUGIXjFWnw/J9LpTSf/ZXwVW3Sh8FBAcfTo5QvAHqkl4CfSiIwnjRJhMoAFcP6ncCe84YPU1ncDGX+p3OXnfg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.25.0 || ^4.0.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": {
|
"node_modules/@napi-rs/canvas": {
|
||||||
@@ -8576,7 +8577,7 @@
|
|||||||
"@anthropic-ai/sdk": "^0.90.0",
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||||
"@google/genai": "^1.40.0",
|
"@google/genai": "^1.40.0",
|
||||||
"@mistralai/mistralai": "1.14.1",
|
"@mistralai/mistralai": "^2.2.0",
|
||||||
"@sinclair/typebox": "^0.34.41",
|
"@sinclair/typebox": "^0.34.41",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Anthropic and Bedrock adaptive-thinking payload tests to expect the default `display: "summarized"` field when reasoning is enabled.
|
- 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))
|
- 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
|
## [0.67.6] - 2026-04-16
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
"@anthropic-ai/sdk": "^0.90.0",
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||||
"@google/genai": "^1.40.0",
|
"@google/genai": "^1.40.0",
|
||||||
"@mistralai/mistralai": "1.14.1",
|
"@mistralai/mistralai": "^2.2.0",
|
||||||
"@sinclair/typebox": "^0.34.41",
|
"@sinclair/typebox": "^0.34.41",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Mistral } from "@mistralai/mistralai";
|
import { Mistral } from "@mistralai/mistralai";
|
||||||
import type { RequestOptions } from "@mistralai/mistralai/lib/sdks.js";
|
|
||||||
import type {
|
import type {
|
||||||
ChatCompletionStreamRequest,
|
ChatCompletionStreamRequest,
|
||||||
ChatCompletionStreamRequestMessages,
|
ChatCompletionStreamRequestMessage,
|
||||||
CompletionEvent,
|
CompletionEvent,
|
||||||
ContentChunk,
|
ContentChunk,
|
||||||
FunctionTool,
|
FunctionTool,
|
||||||
} from "@mistralai/mistralai/models/components/index.js";
|
} from "@mistralai/mistralai/models/components";
|
||||||
import { getEnvApiKey } from "../env-api-keys.js";
|
import { getEnvApiKey } from "../env-api-keys.js";
|
||||||
import { calculateCost } from "../models.js";
|
import { calculateCost } from "../models.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -36,9 +35,12 @@ const MAX_MISTRAL_ERROR_BODY_CHARS = 4000;
|
|||||||
/**
|
/**
|
||||||
* Provider-specific options for the Mistral API.
|
* Provider-specific options for the Mistral API.
|
||||||
*/
|
*/
|
||||||
|
type MistralReasoningEffort = "none" | "high";
|
||||||
|
|
||||||
export interface MistralOptions extends StreamOptions {
|
export interface MistralOptions extends StreamOptions {
|
||||||
toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } };
|
toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } };
|
||||||
promptMode?: "reasoning";
|
promptMode?: "reasoning";
|
||||||
|
reasoningEffort?: MistralReasoningEffort;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,10 +120,12 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple
|
|||||||
|
|
||||||
const base = buildBaseOptions(model, options, apiKey);
|
const base = buildBaseOptions(model, options, apiKey);
|
||||||
const reasoning = clampReasoning(options?.reasoning);
|
const reasoning = clampReasoning(options?.reasoning);
|
||||||
|
const shouldUseReasoning = model.reasoning && reasoning !== undefined;
|
||||||
|
|
||||||
return streamMistral(model, context, {
|
return streamMistral(model, context, {
|
||||||
...base,
|
...base,
|
||||||
promptMode: model.reasoning && reasoning ? "reasoning" : undefined,
|
promptMode: shouldUseReasoning && usesPromptModeReasoning(model) ? "reasoning" : undefined,
|
||||||
|
reasoningEffort: shouldUseReasoning && usesReasoningEffort(model) ? mapReasoningEffort(reasoning) : undefined,
|
||||||
} satisfies MistralOptions);
|
} satisfies MistralOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,10 +209,15 @@ function safeJsonStringify(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions): RequestOptions {
|
function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) {
|
||||||
const requestOptions: RequestOptions = {};
|
const requestOptions: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
retries: { strategy: "none" };
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {
|
||||||
|
retries: { strategy: "none" },
|
||||||
|
};
|
||||||
if (options?.signal) requestOptions.signal = options.signal;
|
if (options?.signal) requestOptions.signal = options.signal;
|
||||||
requestOptions.retries = { strategy: "none" };
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (model.headers) Object.assign(headers, model.headers);
|
if (model.headers) Object.assign(headers, model.headers);
|
||||||
@@ -243,7 +252,8 @@ function buildChatPayload(
|
|||||||
if (options?.temperature !== undefined) payload.temperature = options.temperature;
|
if (options?.temperature !== undefined) payload.temperature = options.temperature;
|
||||||
if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens;
|
if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens;
|
||||||
if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice);
|
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) {
|
if (context.systemPrompt) {
|
||||||
payload.messages.unshift({
|
payload.messages.unshift({
|
||||||
@@ -452,8 +462,8 @@ function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function"
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessages[] {
|
function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessage[] {
|
||||||
const result: ChatCompletionStreamRequestMessages[] = [];
|
const result: ChatCompletionStreamRequestMessage[] = [];
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.role === "user") {
|
if (msg.role === "user") {
|
||||||
@@ -505,7 +515,7 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessage: ChatCompletionStreamRequestMessages = { role: "assistant" };
|
const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" };
|
||||||
if (contentParts.length > 0) assistantMessage.content = contentParts;
|
if (contentParts.length > 0) assistantMessage.content = contentParts;
|
||||||
if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
|
if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
|
||||||
if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage);
|
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)";
|
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<SimpleStreamOptions["reasoning"], undefined>): MistralReasoningEffort {
|
||||||
|
return "high";
|
||||||
|
}
|
||||||
|
|
||||||
function mapToolChoice(
|
function mapToolChoice(
|
||||||
choice: MistralOptions["toolChoice"],
|
choice: MistralOptions["toolChoice"],
|
||||||
): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined {
|
): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined {
|
||||||
|
|||||||
66
packages/ai/test/mistral-reasoning-mode.test.ts
Normal file
66
packages/ai/test/mistral-reasoning-mode.test.ts
Normal file
@@ -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<MistralPayload> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
### Fixed
|
### 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 `/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 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 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))
|
- 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user