diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 96a230b347..5ce8709f83 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -81,7 +81,7 @@ export class DefaultExecutor extends BaseExecutor { super(provider, PROVIDERS[provider] || PROVIDERS.openai); } - transformRequest(model, body) { + transformRequest(model, body, _stream, credentials) { const transformed = this.applyJsonSchemaFallback(body); if (transformed && typeof transformed === "object") { @@ -92,6 +92,9 @@ export class DefaultExecutor extends BaseExecutor { stripUnsupportedParams(this.provider, model, transformed); } + // reasoning_content is an OpenAI compatibility field. Anthropic Messages + // transports preserve thinking blocks directly and may reject this extra key. + if (credentials?.runtimeTransport?.format === "claude") return transformed; return injectReasoningContent({ provider: this.provider, model, body: transformed }); } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 47190acfaa..1356163275 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,4 +1,4 @@ -import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; +import { detectFormat, resolveRequestTransport } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; import { stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; import { FORMATS } from "../translator/formats.js"; @@ -59,9 +59,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, model); - // Multi-endpoint providers: pick transport matching sourceFormat → zero translation - const runtimeTransport = resolveTransport(provider, sourceFormat); - const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider); + // Multi-endpoint providers: select transport from the effective target format + // so URL and translated request body always use the same protocol. + const { runtimeTransport, targetFormat } = resolveRequestTransport( + provider, + sourceFormat, + modelTargetFormat, + ); if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport; const stripList = getModelStrip(alias, model); const upstreamModel = getModelUpstreamId(alias, model); diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 1b02cd83e4..c6d8ef6ac3 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -136,14 +136,25 @@ export function getTargetFormat(provider) { return config.format || "openai"; } -// Resolve which transport to use for a provider given the client sourceFormat. -// Multi-endpoint providers (transport.transports[]) pick the entry matching sourceFormat -// to avoid lossy translation; falls back to the default transport when no match. -export function resolveTransport(provider, sourceFormat) { +// Resolve one transport by wire format. Callers decide whether client source format +// or a model's preferred target format is authoritative for the request. +export function resolveTransport(provider, format) { const config = PROVIDERS[provider]; const transports = config?.transports; if (!Array.isArray(transports) || !transports.length) return null; - return transports.find(t => t.format === sourceFormat) || null; + return transports.find(t => t.format === format) || null; +} + +// Keep selected URL and translated request body on the same protocol. A model-level +// targetFormat is authoritative (for example MiniMax-M3 prefers Claude); otherwise +// use an exact client-format transport to avoid unnecessary translation. +export function resolveRequestTransport(provider, sourceFormat, modelTargetFormat = null) { + const requestedFormat = modelTargetFormat || sourceFormat; + const runtimeTransport = resolveTransport(provider, requestedFormat); + return { + runtimeTransport, + targetFormat: runtimeTransport?.format || modelTargetFormat || getTargetFormat(provider), + }; } // Check if last message is from user diff --git a/tests/unit/minimax-transport-protocol.test.js b/tests/unit/minimax-transport-protocol.test.js new file mode 100644 index 0000000000..99a9ba0e89 --- /dev/null +++ b/tests/unit/minimax-transport-protocol.test.js @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { getModelTargetFormat } from "../../open-sse/config/providerModels.js"; +import { DefaultExecutor } from "../../open-sse/executors/default.js"; +import { resolveRequestTransport } from "../../open-sse/services/provider.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +describe("MiniMax transport selection", () => { + it("pairs MiniMax-M3's Claude body with the Anthropic transport", () => { + const modelTarget = getModelTargetFormat("minimax", "MiniMax-M3"); + expect(modelTarget).toBe(FORMATS.CLAUDE); + + const { runtimeTransport, targetFormat } = resolveRequestTransport( + "minimax", + FORMATS.OPENAI, + modelTarget, + ); + + expect(targetFormat).toBe(FORMATS.CLAUDE); + expect(runtimeTransport).toMatchObject({ + format: FORMATS.CLAUDE, + baseUrl: "https://api.minimax.io/anthropic/v1/messages", + }); + }); + + it("keeps MiniMax-M2.7 on the OpenAI transport for Chat Completions clients", () => { + const { runtimeTransport, targetFormat } = resolveRequestTransport( + "minimax", + FORMATS.OPENAI, + getModelTargetFormat("minimax", "MiniMax-M2.7"), + ); + + expect(targetFormat).toBe(FORMATS.OPENAI); + expect(runtimeTransport?.format).toBe(FORMATS.OPENAI); + }); + + it("keeps Claude-native clients on the Anthropic transport", () => { + const { runtimeTransport, targetFormat } = resolveRequestTransport( + "minimax", + FORMATS.CLAUDE, + getModelTargetFormat("minimax", "MiniMax-M3"), + ); + + expect(targetFormat).toBe(FORMATS.CLAUDE); + expect(runtimeTransport).toMatchObject({ + format: FORMATS.CLAUDE, + baseUrl: "https://api.minimax.io/anthropic/v1/messages", + urlSuffix: "?beta=true", + auth: { header: "x-api-key", scheme: "raw" }, + }); + }); + + it("uses the China Anthropic host for minimax-cn", () => { + const { runtimeTransport, targetFormat } = resolveRequestTransport( + "minimax-cn", + FORMATS.CLAUDE, + getModelTargetFormat("minimax-cn", "MiniMax-M3"), + ); + + expect(targetFormat).toBe(FORMATS.CLAUDE); + expect(runtimeTransport).toMatchObject({ + baseUrl: "https://api.minimaxi.com/anthropic/v1/messages", + auth: { header: "x-api-key", scheme: "raw" }, + }); + }); +}); + +describe("MiniMax Anthropic multi-turn normalization", () => { + it("does not inject OpenAI reasoning_content into the Anthropic transport", () => { + const executor = new DefaultExecutor("minimax"); + const body = { + model: "MiniMax-M3", + messages: [ + { role: "user", content: [{ type: "text", text: "Inspect files" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_probe", name: "list_dir", input: {} }], + }, + ], + }; + + const result = executor.transformRequest("MiniMax-M3", structuredClone(body), true, { + runtimeTransport: { format: FORMATS.CLAUDE }, + }); + expect(result.messages[1].reasoning_content).toBeUndefined(); + }); + + it("keeps reasoning_content injection on MiniMax's OpenAI transport", () => { + const executor = new DefaultExecutor("minimax"); + const body = { + model: "MiniMax-M2.7", + messages: [ + { role: "user", content: "Inspect files" }, + { role: "assistant", content: "", tool_calls: [{ id: "call_probe", type: "function", function: { name: "list_dir", arguments: "{}" } }] }, + ], + }; + + const result = executor.transformRequest("MiniMax-M2.7", structuredClone(body), true, { + runtimeTransport: { format: FORMATS.OPENAI }, + }); + expect(result.messages[1].reasoning_content).toBe(" "); + }); +});