fix(ai): parsed minimax think tags across providers

Promoted MiniMax model/provider detection into stream markup healing so OpenCode Zen MiniMax streams use the existing thinking-tag parser instead of exposing raw tags.

Added a regression test for OpenCode Zen minimax-m3 chunks split across <think> boundaries.

Fixes #2049
This commit is contained in:
roboomp
2026-06-07 11:37:13 +00:00
parent b6d422ff65
commit 10fd51425b
3 changed files with 41 additions and 5 deletions
@@ -537,7 +537,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
}
stream.push({ type: "start", partial: output });
const parseMiniMaxThinkTags = model.provider === "minimax-code" || model.provider === "minimax-code-cn";
// Some OpenAI-compatible DeepSeek hosts (including NVIDIA NIM and DeepSeek's
// native API) leak chat-template tool-call markers in `delta.content` even
// though tool calls are also surfaced structurally. Strip the leaked markers
@@ -678,9 +677,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
}
};
const streamMarkupHealingPattern = getStreamMarkupHealingPattern(model.provider, model.id, {
parseThinkingTags: parseMiniMaxThinkTags,
});
const streamMarkupHealingPattern = getStreamMarkupHealingPattern(model.provider, model.id);
const streamMarkupHealing = streamMarkupHealingPattern
? new StreamMarkupHealing({ pattern: streamMarkupHealingPattern })
: undefined;
@@ -600,12 +600,17 @@ export function modelMayLeakDsmlToolCalls(provider: string, modelId: string): bo
);
}
/** Cheap model/provider gate for MiniMax plain thinking tag leaks. */
export function modelMayLeakThinkingTags(provider: string, modelId: string): boolean {
return /minimax/i.test(provider) || /minimax/i.test(modelId);
}
export function getStreamMarkupHealingPattern(
provider: string,
modelId: string,
options?: { readonly parseThinkingTags?: boolean },
): StreamMarkupHealingPattern | undefined {
if (options?.parseThinkingTags) return "thinking";
if (options?.parseThinkingTags || modelMayLeakThinkingTags(provider, modelId)) return "thinking";
if (modelMayLeakKimiToolCalls(provider, modelId)) return "kimi";
if (modelMayLeakDsmlToolCalls(provider, modelId)) return "dsml";
return undefined;
@@ -148,6 +148,7 @@ describe("StreamMarkupHealing pattern selection", () => {
expect(getStreamMarkupHealingPattern("minimax-code", "MiniMax-M2.5", { parseThinkingTags: true })).toBe(
"thinking",
);
expect(getStreamMarkupHealingPattern("opencode-zen", "minimax-m3")).toBe("thinking");
expect(getStreamMarkupHealingPattern("nanogpt", "deepseek/deepseek-v4-pro")).toBe("dsml");
expect(getStreamMarkupHealingPattern("ollama-cloud", "gpt-oss:120b")).toBeUndefined();
expect(getStreamMarkupHealingPattern("openai", "deepseek-v4-pro")).toBeUndefined();
@@ -582,6 +583,39 @@ describe("Ollama provider DSML envelope healing", () => {
});
});
describe("OpenAI completions MiniMax thinking healing", () => {
it("parses OpenCode Zen MiniMax think tags into a thinking block", async () => {
const model: Model<"openai-completions"> = {
id: "minimax-m3",
name: "MiniMax M3",
api: "openai-completions",
provider: "opencode-zen",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
global.fetch = mockFetch([
chunk(model.id, { content: "visible <thin" }),
chunk(model.id, { content: "k>hidden reasoning</think" }),
chunk(model.id, { content: ">" }),
chunk(model.id, { content: " answer" }),
chunk(model.id, {}, "stop"),
"[DONE]",
]);
const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" }).result();
expect(result.content).toEqual([
{ type: "text", text: "visible " },
{ type: "thinking", thinking: "hidden reasoning", thinkingSignature: undefined },
{ type: "text", text: " answer" },
]);
});
});
describe("OpenAI completions provider DSML envelope healing", () => {
it("heals the envelope into a structured tool call and suppresses leaked text", async () => {
const model: Model<"openai-completions"> = {