diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1f69ad4b..ce49d3dfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **i18n**: Thai (th) + Persian (fa) translations / README ## Fixes +- **MiniMax**: add the required empty signature field to unsigned Anthropic thinking block starts - **Providers**: bulk-add API keys no longer overwrite existing keys (gap-fill `Key N`) - **Anthropic**: lowercase `anthropic-version` header to prevent duplication on `/v1/messages` - **Alicode-intl**: use DashScope compatible-mode endpoint so standard keys work diff --git a/open-sse/providers/registry/minimax-cn.js b/open-sse/providers/registry/minimax-cn.js index 19aa3127c1..ab3109408b 100644 --- a/open-sse/providers/registry/minimax-cn.js +++ b/open-sse/providers/registry/minimax-cn.js @@ -22,6 +22,7 @@ export default { headers: { ...CLAUDE_API_HEADERS }, quirks: { dropOutputConfig: true, + ensureThinkingSignature: true, }, reasoningInject: { scope: "all", diff --git a/open-sse/providers/registry/minimax.js b/open-sse/providers/registry/minimax.js index 47b82c896f..481e3b07e2 100644 --- a/open-sse/providers/registry/minimax.js +++ b/open-sse/providers/registry/minimax.js @@ -22,6 +22,7 @@ export default { headers: { ...CLAUDE_API_HEADERS }, quirks: { dropOutputConfig: true, + ensureThinkingSignature: true, }, reasoningInject: { scope: "all", diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 464acde467..f6020f44f2 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -1,5 +1,7 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; +import { CLAUDE_BLOCK } from "../translator/schema/index.js"; +import { PROVIDERS } from "../config/providers.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; @@ -107,10 +109,23 @@ export function createSSEStream(options = {}) { try { const parsed = JSON.parse(trimmed.slice(5).trim()); + // Some Anthropic-compatible providers omit `signature` from the + // thinking block start. Strict Messages clients deserialize that + // field before later signature_delta events arrive. + let fieldsInjected = false; + if ( + PROVIDERS[provider]?.quirks?.ensureThinkingSignature && + parsed.type === "content_block_start" && + parsed.content_block?.type === CLAUDE_BLOCK.THINKING && + parsed.content_block.signature === undefined + ) { + parsed.content_block.signature = ""; + fieldsInjected = true; + } + const idFixed = fixInvalidId(parsed); // Ensure OpenAI-required fields are present on streaming chunks (Letta compat) - let fieldsInjected = false; if (parsed.choices !== undefined) { if (!parsed.object) { parsed.object = "chat.completion.chunk"; fieldsInjected = true; } if (!parsed.created) { parsed.created = Math.floor(Date.now() / 1000); fieldsInjected = true; } diff --git a/tests/unit/minimax-thinking-signature.test.js b/tests/unit/minimax-thinking-signature.test.js new file mode 100644 index 0000000000..d04464512e --- /dev/null +++ b/tests/unit/minimax-thinking-signature.test.js @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { createPassthroughStreamWithLogger } from "../../open-sse/utils/stream.js"; + +async function runPassthrough(provider, input, chunkSize = input.length) { + const encoder = new TextEncoder(); + const inputStream = new ReadableStream({ + start(controller) { + for (let index = 0; index < input.length; index += chunkSize) { + controller.enqueue(encoder.encode(input.slice(index, index + chunkSize))); + } + controller.close(); + }, + }); + const outputStream = inputStream.pipeThrough( + createPassthroughStreamWithLogger(provider), + ); + const reader = outputStream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + return output; +} + +function firstDataEvent(output) { + const line = output.split("\n").find((item) => item.startsWith("data: {")); + return JSON.parse(line.slice(6)); +} + +describe("MiniMax Anthropic thinking stream", () => { + it.each(["minimax", "minimax-cn"])( + "enables thinking signature normalization for %s", + (provider) => { + expect(PROVIDERS[provider].quirks.ensureThinkingSignature).toBe(true); + }, + ); + + it.each(["minimax", "minimax-cn"])( + "adds a deserializable signature field for %s thinking block starts", + async (provider) => { + const event = { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }; + const output = await runPassthrough( + provider, + `event: content_block_start\ndata: ${JSON.stringify(event)}\n\n`, + ); + + expect(firstDataEvent(output).content_block.signature).toBe(""); + }, + ); + + it("preserves real signature events across fragmented chunks", async () => { + const start = { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }; + const signature = { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "minimax-signature" }, + }; + const output = await runPassthrough( + "minimax", + `event: content_block_start\ndata: ${JSON.stringify(start)}\n\n` + + `event: content_block_delta\ndata: ${JSON.stringify(signature)}\n\n`, + 7, + ); + const events = output + .split("\n") + .filter((line) => line.startsWith("data: {")) + .map((line) => JSON.parse(line.slice(6))); + + expect(events[0].content_block.signature).toBe(""); + expect(events[1].delta).toEqual({ + type: "signature_delta", + signature: "minimax-signature", + }); + }); + + it("preserves a MiniMax signature already present on the block start", async () => { + const event = { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "", signature: "minimax-signature" }, + }; + const output = await runPassthrough( + "minimax", + `event: content_block_start\ndata: ${JSON.stringify(event)}\n\n`, + ); + + expect(firstDataEvent(output).content_block.signature).toBe("minimax-signature"); + }); + + it("does not modify unsigned thinking starts from unrelated providers", async () => { + const event = { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }; + const output = await runPassthrough( + "deepseek", + `event: content_block_start\ndata: ${JSON.stringify(event)}\n\n`, + ); + + expect(firstDataEvent(output).content_block).not.toHaveProperty("signature"); + }); +});