From 68bfc6fcf55adf68aaf40e8e41a201fc26d4d85e Mon Sep 17 00:00:00 2001 From: Neerav Makwana <261249544+neeravmakwana@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:04:53 -0400 Subject: [PATCH] Mistral: enable reasoning_effort for mistral-small-latest Made-with: Cursor --- CHANGELOG.md | 1 + docs/providers/mistral.md | 29 +++++-- extensions/mistral/api.test.ts | 86 ++++++++++++++++---- extensions/mistral/api.ts | 83 +++++++++++++++++-- extensions/mistral/index.ts | 4 +- extensions/mistral/model-definitions.test.ts | 7 ++ extensions/mistral/model-definitions.ts | 2 +- 7 files changed, 177 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c6fca378d..a69265323d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras. - Cron: load `jobId` into `id` when the on-disk store omits `id`, matching doctor migration and fixing `unknown cron job id` for hand-edited `jobs.json`. (#62246) Thanks @neeravmakwana. - Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana. +- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistral’s Chat Completions API. (#62162) Thanks @neeravmakwana. ## 2026.4.5 diff --git a/docs/providers/mistral.md b/docs/providers/mistral.md index 725d84fc14f..f1a45ee1933 100644 --- a/docs/providers/mistral.md +++ b/docs/providers/mistral.md @@ -33,15 +33,15 @@ openclaw onboard --mistral-api-key "$MISTRAL_API_KEY" OpenClaw currently ships this bundled Mistral catalog: -| Model ref | Input | Context | Max output | Notes | -| -------------------------------- | ----------- | ------- | ---------- | ------------------------ | -| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model | -| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 | -| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Smaller multimodal model | -| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral | -| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding | -| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 | -| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled | +| Model ref | Input | Context | Max output | Notes | +| -------------------------------- | ----------- | ------- | ---------- | ---------------------------------------------------------------- | +| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model | +| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 | +| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Mistral Small 4; adjustable reasoning via API `reasoning_effort` | +| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral | +| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding | +| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 | +| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled | ## Config snippet (audio transcription with Voxtral) @@ -58,6 +58,17 @@ OpenClaw currently ships this bundled Mistral catalog: } ``` +## Adjustable reasoning (`mistral-small-latest`) + +`mistral/mistral-small-latest` maps to Mistral Small 4 and supports [adjustable reasoning](https://docs.mistral.ai/capabilities/reasoning/adjustable) on the Chat Completions API via `reasoning_effort` (`none` minimizes extra thinking in the output; `high` surfaces full thinking traces before the final answer). + +OpenClaw maps the session **thinking** level to Mistral’s API: + +- **off** / **minimal** → `none` +- **low** / **medium** / **high** / **xhigh** / **adaptive** → `high` + +Other bundled Mistral catalog models do not use this parameter; keep using `magistral-*` models (for example `magistral-small`) when you want Mistral’s native reasoning-first behavior. + ## Notes - Mistral auth uses `MISTRAL_API_KEY`. diff --git a/extensions/mistral/api.test.ts b/extensions/mistral/api.test.ts index ce8c645f8b0..56178ec2e63 100644 --- a/extensions/mistral/api.test.ts +++ b/extensions/mistral/api.test.ts @@ -1,29 +1,65 @@ import { describe, expect, it } from "vitest"; -import { applyMistralModelCompat } from "./api.js"; +import { + applyMistralModelCompat, + MISTRAL_MODEL_TRANSPORT_PATCH, + MISTRAL_SMALL_LATEST_ID, + resolveMistralCompatPatch, +} from "./api.js"; import { default as mistralPlugin } from "./index.js"; -function supportsStore(model: { compat?: unknown }): boolean | undefined { - return (model.compat as { supportsStore?: boolean } | undefined)?.supportsStore; +function readCompat(model: unknown): T | undefined { + return (model as { compat?: T }).compat; } -function supportsReasoningEffort(model: { compat?: unknown }): boolean | undefined { - return (model.compat as { supportsReasoningEffort?: boolean } | undefined) - ?.supportsReasoningEffort; +function supportsStore(model: unknown): boolean | undefined { + return readCompat<{ supportsStore?: boolean }>(model)?.supportsStore; } -function maxTokensField(model: { - compat?: unknown; -}): "max_completion_tokens" | "max_tokens" | undefined { - return (model.compat as { maxTokensField?: "max_completion_tokens" | "max_tokens" } | undefined) +function supportsReasoningEffort(model: unknown): boolean | undefined { + return readCompat<{ supportsReasoningEffort?: boolean }>(model)?.supportsReasoningEffort; +} + +function maxTokensField(model: unknown): "max_completion_tokens" | "max_tokens" | undefined { + return readCompat<{ maxTokensField?: "max_completion_tokens" | "max_tokens" }>(model) ?.maxTokensField; } +function reasoningEffortMap(model: unknown): Record | undefined { + return readCompat<{ reasoningEffortMap?: Record }>(model)?.reasoningEffortMap; +} + +describe("resolveMistralCompatPatch", () => { + it("enables reasoning_effort mapping for mistral-small-latest", () => { + expect(resolveMistralCompatPatch({ id: MISTRAL_SMALL_LATEST_ID })).toMatchObject({ + supportsStore: false, + supportsReasoningEffort: true, + maxTokensField: "max_tokens", + reasoningEffortMap: expect.objectContaining({ high: "high", off: "none" }), + }); + }); + + it("disables reasoning_effort for other Mistral model ids", () => { + expect(resolveMistralCompatPatch({ id: "mistral-large-latest" })).toEqual({ + ...MISTRAL_MODEL_TRANSPORT_PATCH, + supportsReasoningEffort: false, + }); + }); +}); + describe("applyMistralModelCompat", () => { it("applies the Mistral request-shape compat flags", () => { const normalized = applyMistralModelCompat({}); expect(supportsStore(normalized)).toBe(false); expect(supportsReasoningEffort(normalized)).toBe(false); expect(maxTokensField(normalized)).toBe("max_tokens"); + expect(reasoningEffortMap(normalized)).toBeUndefined(); + }); + + it("applies reasoning compat for mistral-small-latest", () => { + const normalized = applyMistralModelCompat({ id: MISTRAL_SMALL_LATEST_ID }); + expect(supportsReasoningEffort(normalized)).toBe(true); + expect(reasoningEffortMap(normalized)?.high).toBe("high"); + expect(reasoningEffortMap(normalized)?.off).toBe("none"); }); it("overrides explicit compat values that would trigger 422s", () => { @@ -39,6 +75,20 @@ describe("applyMistralModelCompat", () => { expect(maxTokensField(normalized)).toBe("max_tokens"); }); + it("overrides explicit compat on mistral-small-latest except reasoning enablement", () => { + const normalized = applyMistralModelCompat({ + id: MISTRAL_SMALL_LATEST_ID, + compat: { + supportsStore: true, + supportsReasoningEffort: false, + maxTokensField: "max_completion_tokens" as const, + }, + }); + expect(supportsStore(normalized)).toBe(false); + expect(supportsReasoningEffort(normalized)).toBe(true); + expect(maxTokensField(normalized)).toBe("max_tokens"); + }); + it("returns the same object when the compat patch is already present", () => { const model = { compat: { @@ -50,7 +100,15 @@ describe("applyMistralModelCompat", () => { expect(applyMistralModelCompat(model)).toBe(model); }); - it("contributes Mistral compat for native, provider-family, and hinted custom routes", () => { + it("returns the same object when mistral-small-latest compat is fully normalized", () => { + const model = { + id: MISTRAL_SMALL_LATEST_ID, + compat: resolveMistralCompatPatch({ id: MISTRAL_SMALL_LATEST_ID }), + }; + expect(applyMistralModelCompat(model)).toBe(model); + }); + + it("contributes Mistral transport compat for native, provider-family, and hinted custom routes", () => { const registerProvider = (mistralPlugin as { register?: (api: unknown) => void }).register; let contributeResolvedModelCompat: | ((params: { modelId: string; model: Record }) => unknown) @@ -74,7 +132,7 @@ describe("applyMistralModelCompat", () => { baseUrl: "https://proxy.example/v1", }, }), - ).toBeDefined(); + ).toEqual(MISTRAL_MODEL_TRANSPORT_PATCH); expect( contributeResolvedModelCompat?.({ @@ -85,7 +143,7 @@ describe("applyMistralModelCompat", () => { baseUrl: "https://api.mistral.ai/v1", }, }), - ).toBeDefined(); + ).toEqual(MISTRAL_MODEL_TRANSPORT_PATCH); expect( contributeResolvedModelCompat?.({ @@ -96,6 +154,6 @@ describe("applyMistralModelCompat", () => { baseUrl: "https://openrouter.ai/api/v1", }, }), - ).toBeDefined(); + ).toEqual(MISTRAL_MODEL_TRANSPORT_PATCH); }); }); diff --git a/extensions/mistral/api.ts b/extensions/mistral/api.ts index aab786b6eed..c3afd68255a 100644 --- a/extensions/mistral/api.ts +++ b/extensions/mistral/api.ts @@ -12,32 +12,97 @@ export { const MISTRAL_MAX_TOKENS_FIELD = "max_tokens"; -export const MISTRAL_MODEL_COMPAT_PATCH = { +/** Transport-only flags merged for hinted Mistral routes; omits reasoning so `mistral-small-latest` is not clobbered after normalization. */ +export const MISTRAL_MODEL_TRANSPORT_PATCH = { supportsStore: false, - supportsReasoningEffort: false, maxTokensField: MISTRAL_MAX_TOKENS_FIELD, } as const satisfies { supportsStore: boolean; - supportsReasoningEffort: boolean; maxTokensField: "max_tokens"; }; -export function applyMistralModelCompat(model: T): T { +/** Resolves to Mistral Chat Completions `reasoning_effort` (`none` | `high`). */ +export const MISTRAL_SMALL_LATEST_REASONING_EFFORT_MAP: Record = { + off: "none", + minimal: "none", + low: "high", + medium: "high", + high: "high", + xhigh: "high", + adaptive: "high", +}; + +export const MISTRAL_SMALL_LATEST_ID = "mistral-small-latest"; + +function mistralReasoningCompatForModelId(modelId: string | undefined): { + supportsReasoningEffort: boolean; + reasoningEffortMap?: Record; +} { + if (modelId === MISTRAL_SMALL_LATEST_ID) { + return { + supportsReasoningEffort: true, + reasoningEffortMap: MISTRAL_SMALL_LATEST_REASONING_EFFORT_MAP, + }; + } + return { supportsReasoningEffort: false }; +} + +export function resolveMistralCompatPatch(model: { id?: string }): { + supportsStore: boolean; + supportsReasoningEffort: boolean; + maxTokensField: "max_tokens"; + reasoningEffortMap?: Record; +} { + return { + ...MISTRAL_MODEL_TRANSPORT_PATCH, + ...mistralReasoningCompatForModelId(model.id), + }; +} + +function compatMatchesResolved( + compat: Record | undefined, + modelId: string | undefined, +): boolean { + const expected = resolveMistralCompatPatch({ id: modelId }); + for (const [key, value] of Object.entries(expected)) { + if (key === "reasoningEffortMap") { + const a = compat?.[key]; + const b = value; + if (a === b) { + continue; + } + if ( + a && + b && + typeof a === "object" && + typeof b === "object" && + JSON.stringify(a) === JSON.stringify(b) + ) { + continue; + } + return false; + } + if (compat?.[key] !== value) { + return false; + } + } + return true; +} + +export function applyMistralModelCompat(model: T): T { const compat = model.compat && typeof model.compat === "object" ? (model.compat as Record) : undefined; - if ( - compat && - Object.entries(MISTRAL_MODEL_COMPAT_PATCH).every(([key, value]) => compat[key] === value) - ) { + if (compatMatchesResolved(compat, model.id)) { return model; } + const patch = resolveMistralCompatPatch(model); return { ...model, compat: { ...compat, - ...MISTRAL_MODEL_COMPAT_PATCH, + ...patch, } as T extends { compat?: infer TCompat } ? TCompat : never, } as T; } diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index db0265c07e0..fb2118d086e 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,7 +1,7 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http"; import { readStringValue } from "openclaw/plugin-sdk/text-runtime"; -import { applyMistralModelCompat, MISTRAL_MODEL_COMPAT_PATCH } from "./api.js"; +import { applyMistralModelCompat, MISTRAL_MODEL_TRANSPORT_PATCH } from "./api.js"; import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildMistralProvider } from "./provider-catalog.js"; @@ -94,7 +94,7 @@ export default defineSingleProviderPluginEntry({ /\bmistral\b.*(?:input.*too long|token limit.*exceeded)/i.test(errorMessage), normalizeResolvedModel: ({ model }) => applyMistralModelCompat(model), contributeResolvedModelCompat: ({ modelId, model }) => - shouldContributeMistralCompat({ modelId, model }) ? MISTRAL_MODEL_COMPAT_PATCH : undefined, + shouldContributeMistralCompat({ modelId, model }) ? MISTRAL_MODEL_TRANSPORT_PATCH : undefined, buildReplayPolicy: () => buildMistralReplayPolicy(), }, register(api) { diff --git a/extensions/mistral/model-definitions.test.ts b/extensions/mistral/model-definitions.test.ts index c261e4cbd0d..a2619516318 100644 --- a/extensions/mistral/model-definitions.test.ts +++ b/extensions/mistral/model-definitions.test.ts @@ -41,6 +41,13 @@ describe("mistral model definitions", () => { contextWindow: 128000, maxTokens: 40000, }), + expect.objectContaining({ + id: "mistral-small-latest", + reasoning: true, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 16384, + }), expect.objectContaining({ id: "pixtral-large-latest", input: ["text", "image"], diff --git a/extensions/mistral/model-definitions.ts b/extensions/mistral/model-definitions.ts index c0f7eae3471..6ee5483d86b 100644 --- a/extensions/mistral/model-definitions.ts +++ b/extensions/mistral/model-definitions.ts @@ -61,7 +61,7 @@ const MISTRAL_MODEL_CATALOG = [ { id: "mistral-small-latest", name: "Mistral Small (latest)", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000,