Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion open-sse/executors/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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 });
}

Expand Down
12 changes: 8 additions & 4 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 16 additions & 5 deletions open-sse/services/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/minimax-transport-protocol.test.js
Original file line number Diff line number Diff line change
@@ -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(" ");
});
});