From aff021e78fe383044d5ab4a854536ceeaf4d153b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 22:41:13 -0300 Subject: [PATCH] fix(minimax): normalize unsigned thinking block starts (#9256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake). --- .../fixes/9256-minimax-thinking-signature.md | 1 + .../providers/registry/minimax/cn/index.ts | 1 + .../providers/registry/minimax/index.ts | 1 + open-sse/config/providers/shared.ts | 6 + open-sse/utils/stream.ts | 8 +- open-sse/utils/streamHelpers.ts | 22 ++++ .../minimax-thinking-signature-2706.test.ts | 108 ++++++++++++++++++ 7 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/9256-minimax-thinking-signature.md create mode 100644 tests/unit/minimax-thinking-signature-2706.test.ts diff --git a/changelog.d/fixes/9256-minimax-thinking-signature.md b/changelog.d/fixes/9256-minimax-thinking-signature.md new file mode 100644 index 0000000000..d25edee6f0 --- /dev/null +++ b/changelog.d/fixes/9256-minimax-thinking-signature.md @@ -0,0 +1 @@ +- **fix(minimax):** add the required empty signature placeholder to unsigned thinking block starts. (thanks @rixzkiye) diff --git a/open-sse/config/providers/registry/minimax/cn/index.ts b/open-sse/config/providers/registry/minimax/cn/index.ts index 2274b01c39..8046b9e9d6 100644 --- a/open-sse/config/providers/registry/minimax/cn/index.ts +++ b/open-sse/config/providers/registry/minimax/cn/index.ts @@ -12,6 +12,7 @@ export const minimax_cnProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", headers: getAnthropicCompatHeaders(), + ensureThinkingSignature: true, models: [ // Keep parity with minimax to ensure model discovery works for minimax-cn connections. // #3110: MiniMax M3 — frontier coding model with 1M context diff --git a/open-sse/config/providers/registry/minimax/index.ts b/open-sse/config/providers/registry/minimax/index.ts index 3033fccb46..6f1f80f51f 100644 --- a/open-sse/config/providers/registry/minimax/index.ts +++ b/open-sse/config/providers/registry/minimax/index.ts @@ -12,6 +12,7 @@ export const minimaxProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", headers: getAnthropicCompatHeaders(), + ensureThinkingSignature: true, models: [ // T12/T28: MiniMax default upgraded from M2.5 to M2.7 // #3110: MiniMax M3 — frontier coding model with 1M context diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 08cdea6f6d..e8e2e96d75 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -179,6 +179,12 @@ export interface RegistryEntry { * standard OpenAI array-shaped content untouched (see openai-responses.ts). */ requiresPlainStringContent?: boolean; + /** + * Anthropic-compatible providers that omit the required `signature` field + * from streamed thinking block starts. The passthrough stream adds only an + * empty placeholder; later provider `signature_delta` events remain intact. + */ + ensureThinkingSignature?: boolean; /** * Protocolos alternativos que este provedor aceita (ex.: um endpoint * Anthropic-compatible alem do OpenAI-compatible padrao). A conexao escolhe diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 85d2524dfc..27182d6887 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -21,6 +21,7 @@ import { appendBoundedText, buildSyntheticChatChunk, hasActiveDeltaValue, + injectThinkingSignature, } from "./streamHelpers.ts"; import { calculateCost } from "@/lib/usage/costCalculator"; import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta"; @@ -711,11 +712,9 @@ export function createSSEStream(options: StreamOptions = {}) { } // Drop internal commentary-phase Responses output before forwarding (#6199). - // Explicit option wins; otherwise read the feature flag (default on). Resolved - // once per stream — never on the hot per-chunk path. + // Explicit option wins; otherwise read the feature flag (default on) — resolved once per stream. const shouldDropResponsesCommentary = dropResponsesCommentary ?? isFeatureFlagEnabled("RESPONSES_PASSTHROUGH_DROP_COMMENTARY"); - const clientExpectsResponsesStream = (mode === STREAM_MODE.PASSTHROUGH ? clientResponseFormat === FORMATS.OPENAI_RESPONSES @@ -1608,6 +1607,7 @@ export function createSSEStream(options: StreamOptions = {}) { } } else if (isClaudeSSE) { // Claude SSE: extract usage, track content, forward as-is + const thinkingSignatureInjected = injectThinkingSignature(parsed, provider); const extracted = extractUsage(parsed); if (extracted) { // Non-destructive merge: never overwrite a positive value with 0 @@ -1649,7 +1649,7 @@ export function createSSEStream(options: StreamOptions = {}) { parsed.delta.thinking ); } - if (restoredToolName) { + if (restoredToolName || thinkingSignatureInjected) { output = `data: ${JSON.stringify(parsed)}\n\n`; injectedUsage = true; } diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index 5e5000094e..d9fb78415b 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -13,6 +13,7 @@ import { FORMATS } from "../translator/formats.ts"; import { hasAnyReasoningSignal } from "./reasoningFields.ts"; +import { getRegistryEntry } from "../config/providerRegistry.ts"; type SSEPayloadOptions = { eventType?: string; @@ -523,3 +524,24 @@ export function hasActiveDeltaValue(value: unknown): boolean { } return value !== null && value !== undefined; } + +// Claude SSE content_block_start normalization for providers (e.g. MiniMax) whose thinking +// blocks omit `signature` on the opening event. Strict Anthropic Messages clients deserialize +// this field before a later signature_delta arrives — inject only the empty envelope +// placeholder, never synthesize/replace a provider-supplied signature. +export function injectThinkingSignature( + parsed: { type?: string; content_block?: { type?: string; signature?: string } }, + provider: string | null +): boolean { + if ( + provider !== null && + getRegistryEntry(provider)?.ensureThinkingSignature === true && + parsed.type === "content_block_start" && + parsed.content_block?.type === "thinking" && + parsed.content_block.signature === undefined + ) { + parsed.content_block.signature = ""; + return true; + } + return false; +} diff --git a/tests/unit/minimax-thinking-signature-2706.test.ts b/tests/unit/minimax-thinking-signature-2706.test.ts new file mode 100644 index 0000000000..dc0fb2ef0e --- /dev/null +++ b/tests/unit/minimax-thinking-signature-2706.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts"; +import { createPassthroughStreamWithLogger } from "../../open-sse/utils/stream.ts"; + +const encoder = new TextEncoder(); + +async function runPassthrough(provider: string, input: string, chunkSize = input.length) { + const source = new ReadableStream({ + start(controller) { + for (let index = 0; index < input.length; index += chunkSize) { + controller.enqueue(encoder.encode(input.slice(index, index + chunkSize))); + } + controller.close(); + }, + }); + + return new Response( + source.pipeThrough( + createPassthroughStreamWithLogger( + provider, + null, + null, + "MiniMax-M2.7", + "minimax-thinking-signature-2706", + { messages: [] }, + null, + null, + null, + FORMATS.CLAUDE + ) + ) + ).text(); +} + +function parseDataEvents(raw: string): Record[] { + return raw + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .filter((payload) => payload && payload !== "[DONE]") + .map((payload) => JSON.parse(payload) as Record); +} + +function thinkingStart(signature?: string) { + return { + type: "content_block_start", + index: 0, + content_block: { + type: "thinking", + thinking: "", + ...(signature === undefined ? {} : { signature }), + }, + }; +} + +test("MiniMax registries opt into thinking signature normalization", () => { + assert.equal(getRegistryEntry("minimax")?.ensureThinkingSignature, true); + assert.equal(getRegistryEntry("minimax-cn")?.ensureThinkingSignature, true); +}); + +test("unsigned MiniMax thinking block starts receive an empty signature", async () => { + const event = thinkingStart(); + const output = await runPassthrough( + "minimax", + `event: content_block_start\ndata: ${JSON.stringify(event)}\n\n` + ); + const firstEvent = parseDataEvents(output)[0]; + + assert.equal((firstEvent.content_block as Record).signature, ""); +}); + +test("fragmented MiniMax streams preserve real signatures and later signature deltas", async () => { + const start = thinkingStart(); + const signatureDelta = { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "minimax-signature" }, + }; + const input = + `event: content_block_start\ndata: ${JSON.stringify(start)}\n\n` + + `event: content_block_delta\ndata: ${JSON.stringify(signatureDelta)}\n\n`; + const events = parseDataEvents(await runPassthrough("minimax-cn", input, 7)); + + assert.equal((events[0].content_block as Record).signature, ""); + assert.deepEqual(events[1].delta, signatureDelta.delta); + + const existing = parseDataEvents( + await runPassthrough( + "minimax", + `event: content_block_start\ndata: ${JSON.stringify(thinkingStart("real-signature"))}\n\n` + ) + )[0]; + assert.equal((existing.content_block as Record).signature, "real-signature"); +}); + +test("unrelated providers do not receive the MiniMax signature placeholder", async () => { + const events = parseDataEvents( + await runPassthrough( + "deepseek", + `event: content_block_start\ndata: ${JSON.stringify(thinkingStart())}\n\n` + ) + ); + + assert.equal("signature" in (events[0].content_block as Record), false); +});