fix(minimax): keep thinking active for M3

Fix MiniMax-M3 Anthropic-compatible requests so OpenClaw no longer sends the disabled-thinking payload that makes M3 return empty content. M3 defaults now stay on MiniMax's omitted/adaptive thinking path, explicit `/think off` is still respected, and MiniMax-M2.x keeps the disabled-thinking default that prevents reasoning_content leaks.

Also wires the MiniMax thinking policy through bundled provider-policy loading so pre-runtime and configless embedded-agent paths resolve the same defaults.

Thanks @IamVNIE for the live MiniMax API repro and initial patch.
This commit is contained in:
Vinayaka Jyothi 2026-06-07 11:26:17 +05:30 committed by GitHub
parent 0880fd94c6
commit 443ac732a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 533 additions and 25 deletions

View file

@ -532,7 +532,7 @@ MiniMax is configured via `models.providers` because it uses custom endpoints:
See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets.
<Note>
On MiniMax's Anthropic-compatible streaming path, OpenClaw disables thinking by default unless you explicitly set it, and `/fast on` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
On MiniMax's Anthropic-compatible streaming path, OpenClaw disables thinking by default for the M2.x family unless you explicitly set it; MiniMax-M3 (and M3.x) stays on the provider's omitted/adaptive thinking path by default. `/fast on` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
</Note>
Plugin-owned capability split:

View file

@ -681,7 +681,7 @@ Interactive custom-provider onboarding infers image input for common vision mode
}
```
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M3 and also includes the M2.7 variants. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M3 and also includes the M2.7 variants. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax M2.x thinking by default unless you explicitly set `thinking` yourself; MiniMax-M3 (and M3.x) stays on the provider's omitted/adaptive thinking path by default. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
</Accordion>
<Accordion title="Moonshot AI (Kimi)">

View file

@ -176,7 +176,7 @@ Choose your preferred auth method and follow the setup steps.
```
<Warning>
On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. MiniMax's streaming endpoint emits `reasoning_content` in OpenAI-style delta chunks instead of native Anthropic thinking blocks, which can leak internal reasoning into visible output if left enabled implicitly.
On the Anthropic-compatible streaming path, OpenClaw disables MiniMax M2.x thinking by default unless you explicitly set `thinking` yourself. M2.x's streaming endpoint emits `reasoning_content` in OpenAI-style delta chunks instead of native Anthropic thinking blocks, which can leak internal reasoning into visible output if left enabled implicitly. MiniMax-M3 (and forward-compatible M3.x) is exempt from this default: M3 emits proper Anthropic thinking blocks and requires thinking active to produce visible content, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
</Warning>
<Note>
@ -400,9 +400,11 @@ See [MiniMax Search](/tools/minimax-search) for full web search configuration an
</Accordion>
<Accordion title="Thinking defaults">
On `api: "anthropic-messages"`, OpenClaw injects `thinking: { type: "disabled" }` unless thinking is already explicitly set in params/config.
On `api: "anthropic-messages"`, OpenClaw injects `thinking: { type: "disabled" }` for MiniMax M2.x models unless thinking is already explicitly set in params/config.
This prevents MiniMax's streaming endpoint from emitting `reasoning_content` in OpenAI-style delta chunks, which would leak internal reasoning into visible output.
This prevents M2.x's streaming endpoint from emitting `reasoning_content` in OpenAI-style delta chunks, which would leak internal reasoning into visible output.
MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns an empty `content` array with `stop_reason: "end_turn"` when thinking is disabled, so the wrapper keeps M3 on the provider's omitted/adaptive thinking path.
</Accordion>

View file

@ -33,7 +33,7 @@ title: "Thinking levels"
- Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers.<provider>.models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior.
- Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields.
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
- MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format.
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.

View file

@ -71,6 +71,30 @@ describe("minimax provider hooks", () => {
).toBe("native");
});
it("defaults M3 thinking on while keeping M2.x thinking off by default", async () => {
const { providers } = await registerProviderPlugin({
plugin: minimaxProviderPlugin,
id: "minimax",
name: "MiniMax Provider",
});
const apiProvider = requireRegisteredProvider(providers, "minimax");
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
expect(apiProvider.resolveThinkingProfile?.({ modelId: "MiniMax-M3" } as never)).toMatchObject({
defaultLevel: "adaptive",
});
expect(
apiProvider.resolveThinkingProfile?.({ modelId: "MiniMax-M2.7" } as never),
).toMatchObject({
defaultLevel: "off",
});
expect(
portalProvider.resolveThinkingProfile?.({ modelId: "MiniMax-M3" } as never),
).toMatchObject({
defaultLevel: "adaptive",
});
});
it("keeps MiniMax auth setup metadata aligned across regions", async () => {
const { providers } = await registerProviderPlugin({
plugin: minimaxProviderPlugin,

View file

@ -1,5 +1,6 @@
// Minimax API module exposes the plugin public contract.
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { resolveMinimaxThinkingProfile } from "./thinking.js";
const noopAuth = async () => ({ profiles: [] });
const wizardGroup = {
@ -15,6 +16,7 @@ export function createMinimaxProvider(): ProviderPlugin {
hookAliases: ["minimax-cn"],
docsPath: "/providers/minimax",
envVars: ["MINIMAX_API_KEY"],
resolveThinkingProfile: ({ modelId }) => resolveMinimaxThinkingProfile(modelId),
auth: [
{
id: "api-global",
@ -53,6 +55,7 @@ export function createMinimaxPortalProvider(): ProviderPlugin {
hookAliases: ["minimax-portal-cn"],
docsPath: "/providers/minimax",
envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
resolveThinkingProfile: ({ modelId }) => resolveMinimaxThinkingProfile(modelId),
auth: [
{
id: "oauth",

View file

@ -0,0 +1,7 @@
// MiniMax policy module exposes static provider policy before runtime registration.
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
import { resolveMinimaxThinkingProfile } from "./thinking.js";
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
return resolveMinimaxThinkingProfile(context.modelId);
}

View file

@ -38,6 +38,7 @@ import {
buildMinimaxProvider,
resolveMinimaxCatalogBaseUrl,
} from "./provider-catalog.js";
import { resolveMinimaxThinkingProfile } from "./thinking.js";
const API_PROVIDER_ID = "minimax";
const PORTAL_PROVIDER_ID = "minimax-portal";
@ -64,6 +65,8 @@ const MINIMAX_PROVIDER_HOOKS = {
...HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS,
...MINIMAX_FAST_MODE_STREAM_HOOKS,
resolveReasoningOutputMode: () => "native" as const,
resolveThinkingProfile: ({ modelId }: { modelId: string }) =>
resolveMinimaxThinkingProfile(modelId),
};
function getDefaultBaseUrl(region: MiniMaxRegion): string {

View file

@ -0,0 +1,23 @@
// MiniMax thinking policy keeps M3 active by default while preserving M2.x leak prevention.
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
const BUDGET_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
const ADAPTIVE_THINKING_LEVELS = ["off", "adaptive"] as const;
export function resolveMinimaxThinkingProfile(
modelId: string,
): ProviderThinkingProfile | undefined {
if (/^MiniMax-M3(\b|[-.])/i.test(modelId)) {
return {
levels: ADAPTIVE_THINKING_LEVELS.map((id) => ({ id })),
defaultLevel: "adaptive",
};
}
if (/^MiniMax-M2(?:\b|[-.])/i.test(modelId)) {
return {
levels: BUDGET_THINKING_LEVELS.map((id) => ({ id })),
defaultLevel: "off",
};
}
return undefined;
}

View file

@ -719,6 +719,153 @@ describe("applyExtraParamsToAgent", () => {
]);
});
it("removes implicit disabled thinking for MiniMax-M3 anthropic-messages payloads", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "disabled" },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "minimax", "MiniMax-M3");
const model = {
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toStrictEqual([{}]);
});
it("preserves explicit off thinking for MiniMax-M3 anthropic-messages payloads", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "disabled" },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "minimax", "MiniMax-M3", undefined, "off");
const model = {
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toStrictEqual([
{
thinking: { type: "disabled" },
},
]);
});
it("rewrites MiniMax-M3 default budget thinking to adaptive", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "enabled", budget_tokens: 1024 },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "minimax", "MiniMax-M3", undefined, "adaptive");
const model = {
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toStrictEqual([
{
thinking: { type: "adaptive" },
},
]);
});
it("restores explicit MiniMax-M3 maxTokens when rewriting budget thinking", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
max_tokens: 8692,
thinking: { type: "enabled", budget_tokens: 8192 },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "minimax", "MiniMax-M3", undefined, "adaptive");
const model = {
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, { maxTokens: 500 });
expect(payloads).toStrictEqual([
{
max_tokens: 500,
thinking: { type: "adaptive" },
},
]);
});
it("preserves downstream explicit MiniMax-M3 thinking overrides", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "disabled" },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "minimax", "MiniMax-M3");
const model = {
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {
onPayload: (payload) => {
(payload as Record<string, unknown>).thinking = { type: "disabled" };
},
});
expect(payloads).toStrictEqual([
{
thinking: { type: "disabled" },
},
]);
});
it("fills DeepSeek V4 reasoning_content for unowned OpenAI-compatible proxy models", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "opencode",

View file

@ -866,7 +866,7 @@ function applyPostPluginStreamWrappers(
// MiniMax's Anthropic-compatible stream can leak reasoning_content into the
// visible reply path because it does not emit native Anthropic thinking
// blocks. Disable thinking unless an earlier wrapper already set it.
ctx.agent.streamFn = createMinimaxThinkingDisabledWrapper(ctx.agent.streamFn);
ctx.agent.streamFn = createMinimaxThinkingDisabledWrapper(ctx.agent.streamFn, ctx.thinkingLevel);
const rawChatTemplateKwargs = resolveAliasedParamValue(
[ctx.effectiveExtraParams, ctx.override],

View file

@ -14,6 +14,7 @@ import {
mockedIsCompactionFailureError,
mockedIsLikelyContextOverflowError,
mockedLog,
mockedResolveModelAsync,
mockedRunEmbeddedAttempt,
mockedSessionLikelyHasOversizedToolResults,
mockedTruncateOversizedToolResultsInSession,
@ -111,6 +112,34 @@ describe("overflow compaction in run loop", () => {
expect(result.meta.error).toBeUndefined();
});
it("uses provider thinking policy for configless embedded MiniMax-M3 runs", async () => {
mockedResolveModelAsync.mockResolvedValueOnce({
model: {
id: "MiniMax-M3",
provider: "minimax",
contextWindow: 1_000_000,
api: "anthropic-messages",
reasoning: true,
},
error: null,
authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
});
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
await runEmbeddedAgent({
...baseParams,
config: undefined,
provider: "minimax",
model: "MiniMax-M3",
runId: "run-configless-minimax-m3-thinking-default",
});
expect(requireMockCallArg(mockedRunEmbeddedAttempt, 0).thinkLevel).toBe("adaptive");
});
it("continues from transcript after compaction when the current inbound message was persisted", async () => {
const overflowError = makeOverflowError();

View file

@ -88,6 +88,7 @@ import {
resolveAuthProfileOrder,
shouldPreferExplicitConfigApiKeyAuth,
} from "../model-auth.js";
import { resolveThinkingDefault } from "../model-thinking-default.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import {
OPENAI_PROVIDER_ID,
@ -229,6 +230,31 @@ function isNoRealConversationCompactionNoop(params: {
);
}
function resolveInitialThinkLevel(params: {
requested?: ThinkLevel;
config?: RunEmbeddedAgentParams["config"];
provider: string;
modelId: string;
model: { reasoning?: boolean };
}): ThinkLevel {
if (params.requested) {
return params.requested;
}
return resolveThinkingDefault({
cfg: params.config ?? {},
provider: params.provider,
model: params.modelId,
catalog: [
{
provider: params.provider,
id: params.modelId,
name: params.modelId,
reasoning: params.model.reasoning,
},
],
});
}
async function resetNoRealConversationTokenSnapshot(params: {
config?: RunEmbeddedAgentParams["config"];
sessionKey?: string;
@ -1030,7 +1056,13 @@ export async function runEmbeddedAgent(
let profileIndex = 0;
const traceAttempts: TraceAttempt[] = [];
const initialThinkLevel = params.thinkLevel ?? "off";
const initialThinkLevel = resolveInitialThinkLevel({
requested: params.thinkLevel,
config: params.config,
provider,
modelId,
model: effectiveModel,
});
let thinkLevel = initialThinkLevel;
const attemptedThinking = new Set<ThinkLevel>();
let apiKeyInfo: ApiKeyInfo | null = null;
@ -1724,9 +1756,9 @@ export async function runEmbeddedAgent(
lastAssistant: sessionLastAssistant,
currentAttemptAssistant,
} = attempt;
const setTerminalLifecycleMeta: NonNullable<
typeof attempt.setTerminalLifecycleMeta
> = (meta) => {
const setTerminalLifecycleMeta: NonNullable<typeof attempt.setTerminalLifecycleMeta> = (
meta,
) => {
attempt.setTerminalLifecycleMeta?.({ ...meta, aborted });
};
const timedOutDuringToolExecution = attempt.timedOutDuringToolExecution ?? false;

View file

@ -1,13 +1,14 @@
// MiniMax stream wrapper tests cover streamed text and reasoning conversion.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import { createMinimaxFastModeWrapper, createMinimaxThinkingDisabledWrapper } from "./minimax.js";
function captureThinkingPayload(params: {
provider: string;
api: string;
modelId: string;
thinkingLevel?: ThinkLevel;
}): unknown {
let capturedThinking: unknown = undefined;
const baseStreamFn: StreamFn = (model, context, options) => {
@ -17,7 +18,7 @@ function captureThinkingPayload(params: {
return {} as ReturnType<StreamFn>;
};
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn);
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn, params.thinkingLevel);
void wrapped(
{
api: params.api,
@ -72,6 +73,164 @@ describe("createMinimaxThinkingDisabledWrapper", () => {
).toBeUndefined();
});
it("does NOT disable thinking for MiniMax-M3 on anthropic-messages", () => {
// M3 emits Anthropic-shape thinking blocks and returns empty content
// when thinking is disabled; see isMinimaxModelRequiringThinking.
expect(
captureThinkingPayload({
provider: "minimax",
api: "anthropic-messages",
modelId: "MiniMax-M3",
}),
).toBeUndefined();
});
it("does NOT disable thinking for MiniMax-M3 on minimax-portal", () => {
expect(
captureThinkingPayload({
provider: "minimax-portal",
api: "anthropic-messages",
modelId: "MiniMax-M3",
}),
).toBeUndefined();
});
it("removes implicit disabled thinking for MiniMax-M3", () => {
let capturedThinking: unknown = undefined;
const baseStreamFn: StreamFn = (model, context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "disabled" },
};
options?.onPayload?.(payload, model);
capturedThinking = payload.thinking;
return {} as ReturnType<StreamFn>;
};
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn);
void wrapped(
{
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">,
{ messages: [] } as Context,
{},
);
expect(capturedThinking).toBeUndefined();
});
it("preserves explicit off thinking for MiniMax-M3", () => {
let capturedThinking: unknown = undefined;
const baseStreamFn: StreamFn = (model, context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "disabled" },
};
options?.onPayload?.(payload, model);
capturedThinking = payload.thinking;
return {} as ReturnType<StreamFn>;
};
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn, "off");
void wrapped(
{
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">,
{ messages: [] } as Context,
{},
);
expect(capturedThinking).toEqual({ type: "disabled" });
});
it("rewrites MiniMax-M3 default budget thinking to adaptive", () => {
let capturedThinking: unknown = undefined;
const baseStreamFn: StreamFn = (model, context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "enabled", budget_tokens: 1024 },
};
options?.onPayload?.(payload, model);
capturedThinking = payload.thinking;
return {} as ReturnType<StreamFn>;
};
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn, "adaptive");
void wrapped(
{
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">,
{ messages: [] } as Context,
{},
);
expect(capturedThinking).toEqual({ type: "adaptive" });
});
it("restores explicit MiniMax-M3 maxTokens when rewriting budget thinking", () => {
let capturedPayload: Record<string, unknown> | undefined;
const baseStreamFn: StreamFn = (model, context, options) => {
const payload: Record<string, unknown> = {
max_tokens: 8692,
thinking: { type: "enabled", budget_tokens: 8192 },
};
options?.onPayload?.(payload, model);
capturedPayload = payload;
return {} as ReturnType<StreamFn>;
};
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn, "adaptive");
void wrapped(
{
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">,
{ messages: [] } as Context,
{ maxTokens: 500 },
);
expect(capturedPayload).toMatchObject({
max_tokens: 500,
thinking: { type: "adaptive" },
});
});
it("preserves explicit enabled thinking for MiniMax-M3", () => {
let capturedThinking: unknown = undefined;
const baseStreamFn: StreamFn = (model, context, options) => {
const payload: Record<string, unknown> = {
thinking: { type: "disabled" },
};
options?.onPayload?.(payload, model);
capturedThinking = payload.thinking;
return {} as ReturnType<StreamFn>;
};
const wrapped = createMinimaxThinkingDisabledWrapper(baseStreamFn);
void wrapped(
{
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M3",
} as Model<"anthropic-messages">,
{ messages: [] } as Context,
{
onPayload: (payload) => {
(payload as Record<string, unknown>).thinking = {
type: "enabled",
budget_tokens: 1024,
};
},
},
);
expect(capturedThinking).toEqual({ type: "enabled", budget_tokens: 1024 });
});
it("preserves an already-set thinking value", () => {
let capturedThinking: unknown = undefined;
const baseStreamFn: StreamFn = (model, context, options) => {

View file

@ -1,5 +1,5 @@
// MiniMax stream wrapper normalizes MiniMax streamed text and reasoning output.
import type { StreamFn } from "../../../agents/runtime/index.js";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import { streamSimple } from "../../stream.js";
const MINIMAX_FAST_MODEL_IDS = new Map<string, string>([
@ -20,6 +20,47 @@ function isMinimaxAnthropicMessagesModel(model: { api?: unknown; provider?: unkn
);
}
/**
* MiniMax-M3 (and any forward-compatible MiniMax-M3.x successor) emits proper
* Anthropic-shape thinking blocks (`content_block_start` with `type:"thinking"`
* + `thinking_delta`) and **requires** thinking to be active to produce any
* visible text. Pinning `thinking: { type: "disabled" }` on M3 makes the model
* return an empty content array with `stop_reason: "end_turn"` and 1 output
* token, observed against `https://api.minimax.io/anthropic/v1/messages`.
*
* The legacy MiniMax-M2.x family still needs the disable-thinking shim
* because their Anthropic-compat streams leak `reasoning_content` in
* OpenAI-style deltas (see {@link createMinimaxThinkingDisabledWrapper}).
*/
function isMinimaxModelRequiringThinking(model: { id?: unknown }): boolean {
const modelId = typeof model.id === "string" ? model.id.trim() : "";
return /^MiniMax-M3(\b|[-.])/i.test(modelId);
}
function isDisabledThinkingPayload(value: unknown): boolean {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as Record<string, unknown>).type === "disabled"
);
}
function isEnabledThinkingPayload(value: unknown): boolean {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as Record<string, unknown>).type === "enabled"
);
}
function resolvePositiveMaxTokens(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
/** @deprecated MiniMax provider-owned stream helper; do not use from third-party plugins. */
export function createMinimaxFastModeWrapper(
baseStreamFn: StreamFn | undefined,
@ -45,20 +86,30 @@ export function createMinimaxFastModeWrapper(
}
/**
* MiniMax's Anthropic-compatible streaming endpoint returns reasoning_content
* in OpenAI-style delta chunks ({delta: {content: "", reasoning_content: "..."}})
* rather than the native Anthropic thinking block format. The shared Anthropic
* provider cannot process this format and leaks the reasoning text as visible
* content. Disable thinking in the outgoing payload so MiniMax does not produce
* reasoning_content deltas during streaming.
* Legacy MiniMax (M2.x) Anthropic-compatible streaming endpoint returns
* reasoning_content in OpenAI-style delta chunks ({delta: {content: "",
* reasoning_content: "..."}}) rather than the native Anthropic thinking
* block format. The shared Anthropic provider cannot process this format
* and leaks the reasoning text as visible content. Disable thinking in the
* outgoing payload so MiniMax does not produce reasoning_content deltas
* during streaming.
*
* Skipped for MiniMax-M3 and M3.x, which emit proper Anthropic-shape thinking
* blocks and require thinking enabled to produce any visible content.
* The Anthropic transport builds `thinking: { type: "disabled" }` when no
* resolved thinking level exists, so M3 removes that implicit disabled payload.
* See {@link isMinimaxModelRequiringThinking}.
*/
/** @deprecated MiniMax provider-owned stream helper; do not use from third-party plugins. */
export function createMinimaxThinkingDisabledWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
export function createMinimaxThinkingDisabledWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (!isMinimaxAnthropicMessagesModel(model)) {
return underlying(model, context, options);
}
const requiresThinking = isMinimaxModelRequiringThinking(model);
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
@ -66,9 +117,24 @@ export function createMinimaxThinkingDisabledWrapper(baseStreamFn: StreamFn | un
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
// Only inject if thinking is not already explicitly set.
// This preserves unknown intentional override from other wrappers.
if (payloadObj.thinking === undefined) {
if (requiresThinking) {
if (thinkingLevel === undefined && isDisabledThinkingPayload(payloadObj.thinking)) {
delete payloadObj.thinking;
} else if (
thinkingLevel !== "off" &&
(isEnabledThinkingPayload(payloadObj.thinking) ||
isDisabledThinkingPayload(payloadObj.thinking))
) {
payloadObj.thinking = { type: "adaptive" };
const maxTokens = resolvePositiveMaxTokens(options?.maxTokens);
if (maxTokens !== undefined) {
payloadObj.max_tokens = maxTokens;
}
}
}
// M2.x only needs the shim when no earlier wrapper set thinking.
// Downstream payload hooks still run after this wrapper.
if (!requiresThinking && payloadObj.thinking === undefined) {
payloadObj.thinking = { type: "disabled" };
}
}

View file

@ -45,6 +45,19 @@ describe("provider public artifacts", () => {
).toBe(providerConfig);
});
it("loads MiniMax thinking policy before runtime registration", () => {
const surface = resolveBundledProviderPolicySurface("minimax");
expect(
surface?.resolveThinkingProfile?.({ provider: "minimax", modelId: "MiniMax-M2.7" })
?.defaultLevel,
).toBe("off");
expect(
surface?.resolveThinkingProfile?.({ provider: "minimax", modelId: "MiniMax-M3" })
?.defaultLevel,
).toBe("adaptive");
});
it("resolves multi-provider policy artifacts by manifest-owned provider id", async () => {
const bundledPluginsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-policy-"));
const pluginDir = path.join(bundledPluginsDir, "openai");