mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(minimax): normalize unsigned thinking block starts (#9256)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
This commit is contained in:
committed by
GitHub
parent
3a1e42d985
commit
aff021e78f
1
changelog.d/fixes/9256-minimax-thinking-signature.md
Normal file
1
changelog.d/fixes/9256-minimax-thinking-signature.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(minimax):** add the required empty signature placeholder to unsigned thinking block starts. (thanks @rixzkiye)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
108
tests/unit/minimax-thinking-signature-2706.test.ts
Normal file
108
tests/unit/minimax-thinking-signature-2706.test.ts
Normal file
@@ -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<Uint8Array>({
|
||||
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<string, unknown>[] {
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>), false);
|
||||
});
|
||||
Reference in New Issue
Block a user