From 443ac732a11da91feefeda7dd79898be5c74a01c Mon Sep 17 00:00:00 2001 From: Vinayaka Jyothi Date: Sun, 7 Jun 2026 11:26:17 +0530 Subject: [PATCH] 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. --- docs/concepts/model-providers.md | 2 +- docs/gateway/config-tools.md | 2 +- docs/providers/minimax.md | 8 +- docs/tools/thinking.md | 2 +- extensions/minimax/index.test.ts | 24 +++ extensions/minimax/provider-contract-api.ts | 3 + extensions/minimax/provider-policy-api.ts | 7 + extensions/minimax/provider-registration.ts | 3 + extensions/minimax/thinking.ts | 23 +++ .../embedded-agent-runner-extraparams.test.ts | 147 ++++++++++++++++ .../embedded-agent-runner/extra-params.ts | 2 +- .../run.overflow-compaction.loop.test.ts | 29 ++++ src/agents/embedded-agent-runner/run.ts | 40 ++++- .../providers/stream-wrappers/minimax.test.ts | 163 +++++++++++++++++- src/llm/providers/stream-wrappers/minimax.ts | 90 ++++++++-- src/plugins/provider-public-artifacts.test.ts | 13 ++ 16 files changed, 533 insertions(+), 25 deletions(-) create mode 100644 extensions/minimax/provider-policy-api.ts create mode 100644 extensions/minimax/thinking.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index e788ee699ae1..8bcbeb3f9d6e 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -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. -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`. Plugin-owned capability split: diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 37c4b426951e..c6dd3de72fcd 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -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`. diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index ca871408dd3c..c04a99b5031f 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -176,7 +176,7 @@ Choose your preferred auth method and follow the setup steps. ``` - 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. @@ -400,9 +400,11 @@ See [MiniMax Search](/tools/minimax-search) for full web search configuration an - 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. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 784bd21e2dbd..8dde80d2bf38 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -33,7 +33,7 @@ title: "Thinking levels" - Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers..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`. diff --git a/extensions/minimax/index.test.ts b/extensions/minimax/index.test.ts index eaad2957437b..fc1c1e44f4f2 100644 --- a/extensions/minimax/index.test.ts +++ b/extensions/minimax/index.test.ts @@ -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, diff --git a/extensions/minimax/provider-contract-api.ts b/extensions/minimax/provider-contract-api.ts index ee4d82bf3ea6..99f066eedcaa 100644 --- a/extensions/minimax/provider-contract-api.ts +++ b/extensions/minimax/provider-contract-api.ts @@ -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", diff --git a/extensions/minimax/provider-policy-api.ts b/extensions/minimax/provider-policy-api.ts new file mode 100644 index 000000000000..87dfc9666022 --- /dev/null +++ b/extensions/minimax/provider-policy-api.ts @@ -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); +} diff --git a/extensions/minimax/provider-registration.ts b/extensions/minimax/provider-registration.ts index 034561a7cd74..719e7cd71149 100644 --- a/extensions/minimax/provider-registration.ts +++ b/extensions/minimax/provider-registration.ts @@ -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 { diff --git a/extensions/minimax/thinking.ts b/extensions/minimax/thinking.ts new file mode 100644 index 000000000000..23e35e0cdbc9 --- /dev/null +++ b/extensions/minimax/thinking.ts @@ -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; +} diff --git a/src/agents/embedded-agent-runner-extraparams.test.ts b/src/agents/embedded-agent-runner-extraparams.test.ts index 72b9830fc479..a603a27a8920 100644 --- a/src/agents/embedded-agent-runner-extraparams.test.ts +++ b/src/agents/embedded-agent-runner-extraparams.test.ts @@ -719,6 +719,153 @@ describe("applyExtraParamsToAgent", () => { ]); }); + it("removes implicit disabled thinking for MiniMax-M3 anthropic-messages payloads", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + thinking: { type: "disabled" }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + 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[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + thinking: { type: "disabled" }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + 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[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + thinking: { type: "enabled", budget_tokens: 1024 }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + 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[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + max_tokens: 8692, + thinking: { type: "enabled", budget_tokens: 8192 }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + 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[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + thinking: { type: "disabled" }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + 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).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", diff --git a/src/agents/embedded-agent-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts index fb3539e4317a..3333a7c0138b 100644 --- a/src/agents/embedded-agent-runner/extra-params.ts +++ b/src/agents/embedded-agent-runner/extra-params.ts @@ -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], diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts index 600ec7869cbf..f1cc55dc05cf 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts @@ -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(); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index bb1e1e542a33..163a636bfd62 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -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(); 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 = ( + meta, + ) => { attempt.setTerminalLifecycleMeta?.({ ...meta, aborted }); }; const timedOutDuringToolExecution = attempt.timedOutDuringToolExecution ?? false; diff --git a/src/llm/providers/stream-wrappers/minimax.test.ts b/src/llm/providers/stream-wrappers/minimax.test.ts index cc1613f110f7..5b37d5011d70 100644 --- a/src/llm/providers/stream-wrappers/minimax.test.ts +++ b/src/llm/providers/stream-wrappers/minimax.test.ts @@ -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; }; - 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 = { + thinking: { type: "disabled" }, + }; + options?.onPayload?.(payload, model); + capturedThinking = payload.thinking; + return {} as ReturnType; + }; + + 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 = { + thinking: { type: "disabled" }, + }; + options?.onPayload?.(payload, model); + capturedThinking = payload.thinking; + return {} as ReturnType; + }; + + 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 = { + thinking: { type: "enabled", budget_tokens: 1024 }, + }; + options?.onPayload?.(payload, model); + capturedThinking = payload.thinking; + return {} as ReturnType; + }; + + 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 | undefined; + const baseStreamFn: StreamFn = (model, context, options) => { + const payload: Record = { + max_tokens: 8692, + thinking: { type: "enabled", budget_tokens: 8192 }, + }; + options?.onPayload?.(payload, model); + capturedPayload = payload; + return {} as ReturnType; + }; + + 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 = { + thinking: { type: "disabled" }, + }; + options?.onPayload?.(payload, model); + capturedThinking = payload.thinking; + return {} as ReturnType; + }; + + 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).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) => { diff --git a/src/llm/providers/stream-wrappers/minimax.ts b/src/llm/providers/stream-wrappers/minimax.ts index 678df71a7910..6a1fcdf5dad5 100644 --- a/src/llm/providers/stream-wrappers/minimax.ts +++ b/src/llm/providers/stream-wrappers/minimax.ts @@ -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([ @@ -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).type === "disabled" + ); +} + +function isEnabledThinkingPayload(value: unknown): boolean { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + (value as Record).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; - // 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" }; } } diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index 168709f4a1e8..f92b862cf978 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -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");