mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
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:
parent
0880fd94c6
commit
443ac732a1
16 changed files with 533 additions and 25 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
7
extensions/minimax/provider-policy-api.ts
Normal file
7
extensions/minimax/provider-policy-api.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
23
extensions/minimax/thinking.ts
Normal file
23
extensions/minimax/thinking.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue