Skip to content
Closed
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
21 changes: 11 additions & 10 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
messages,
stream: parsed.stream,
};
if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
const maxTokens = resolveMaxTokens(provider, parsed);
const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
Expand All @@ -545,9 +546,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
if (budget !== undefined) body.thinking_budget = budget;
} else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
// Vendor thinking-toggle wire (MiMo v2.x, GLM 5/5.1): the mapped value is the toggle
// state, sent as `thinking: {type}` — these models ignore/reject reasoning_effort.
if (reasoningEffort === "enabled" || reasoningEffort === "disabled") {
// Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
// these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
// uses adaptive/disabled.
if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
body.thinking = { type: reasoningEffort };
}
} else {
Expand Down Expand Up @@ -679,13 +681,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
}
const delta = choices[0].delta;
if (delta) {
if (typeof delta.content === "string" && delta.content.length > 0) {
yield { type: "text_delta", text: delta.content };
}

if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
}
if (typeof delta.content === "string" && delta.content.length > 0) {
yield { type: "text_delta", text: delta.content };
}

const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
if (toolCalls) {
Expand Down Expand Up @@ -780,12 +781,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
}

const msg = choices[0].message;
if (typeof msg.content === "string") {
events.push({ type: "text_delta", text: msg.content });
}
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
}
if (typeof msg.content === "string") {
events.push({ type: "text_delta", text: msg.content });
}
const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
if (toolCalls) {
for (const tc of toolCalls) {
Expand Down
1 change: 1 addition & 0 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
"noPenaltyModels",
"autoToolChoiceOnlyModels",
"preserveReasoningContentModels",
"reasoningSplitModels",
];

export function reconcileOAuthProviders(config: OcxConfig): boolean {
Expand Down
1 change: 1 addition & 0 deletions src/oauth/login-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s
...(def.noPenaltyModels ? { noPenaltyModels: [...def.noPenaltyModels] } : {}),
...(def.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...def.autoToolChoiceOnlyModels] } : {}),
...(def.preserveReasoningContentModels ? { preserveReasoningContentModels: [...def.preserveReasoningContentModels] } : {}),
...(def.reasoningSplitModels ? { reasoningSplitModels: [...def.reasoningSplitModels] } : {}),
...(def.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: def.escapeBuiltinToolNames } : {}),
};
}
Expand Down
4 changes: 4 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface DerivedKeyLoginProvider {
noPenaltyModels?: string[];
autoToolChoiceOnlyModels?: string[];
preserveReasoningContentModels?: string[];
reasoningSplitModels?: string[];
thinkingToggleModels?: string[];
thinkingBudgetModels?: string[];
escapeBuiltinToolNames?: boolean;
Expand Down Expand Up @@ -113,6 +114,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
Expand Down Expand Up @@ -153,6 +155,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
Expand Down Expand Up @@ -224,6 +227,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels];
if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
Expand Down
25 changes: 24 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface ProviderRegistryEntry {
promptCacheKey?: boolean;
autoToolChoiceOnlyModels?: string[];
preserveReasoningContentModels?: string[];
reasoningSplitModels?: string[];
thinkingToggleModels?: string[];
thinkingBudgetModels?: string[];
escapeBuiltinToolNames?: boolean;
Expand All @@ -92,7 +93,7 @@ export type ProviderConfigSeed = Pick<
| "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
| "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
| "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
| "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
| "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
| "googleMode" | "project" | "location" | "headers"
>;

Expand All @@ -117,6 +118,16 @@ const MINIMAX_MODELS = [
const MINIMAX_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" ? 1_000_000 : 204_800]),
);
const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
const MINIMAX_M3_REASONING_EFFORT_MAP: Record<string, string> = {
none: "disabled",
minimal: "disabled",
low: "disabled",
medium: "adaptive",
high: "adaptive",
xhigh: "adaptive",
max: "adaptive",
};
const OPENAI_GPT56_MODELS = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"];
const OPENAI_GPT56_PRO_MODELS = ["gpt-5.6-sol-pro", "gpt-5.6-terra-pro", "gpt-5.6-luna-pro"];
const OPENAI_API_GPT56_CONTEXT_WINDOW = 1_050_000;
Expand Down Expand Up @@ -920,12 +931,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key",
dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS,
modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS,
modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS },
modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" },
modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP },
preserveReasoningContentModels: MINIMAX_MODELS,
reasoningSplitModels: MINIMAX_MODELS,
thinkingToggleModels: ["MiniMax-M3"],
jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key",
},
{
id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key",
dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS,
modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS,
modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS },
modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" },
modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP },
preserveReasoningContentModels: MINIMAX_MODELS,
reasoningSplitModels: MINIMAX_MODELS,
thinkingToggleModels: ["MiniMax-M3"],
jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key",
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
Expand Down Expand Up @@ -203,6 +204,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
...(noPenaltyModels ? { noPenaltyModels } : {}),
...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
...(reasoningSplitModels ? { reasoningSplitModels } : {}),
...(thinkingToggleModels ? { thinkingToggleModels } : {}),
...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
};
Expand Down
1 change: 1 addition & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
"noPenaltyModels",
"autoToolChoiceOnlyModels",
"preserveReasoningContentModels",
"reasoningSplitModels",
"escapeBuiltinToolNames",
] as const) {
copyIfDefined(dto, provider, key);
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,12 @@ export interface OcxProviderConfig {
/** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */
preserveReasoningContentModels?: string[];
/**
* Model ids whose reasoning is a vendor `thinking: {type: enabled|disabled}` toggle on the
* Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns
* thinking separately in `reasoning_content` / `reasoning_details` instead of visible content.
*/
reasoningSplitModels?: string[];
/**
* Model ids whose reasoning is a vendor `thinking: {type}` toggle on the
* chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder.
* The openai-chat adapter translates the mapped effort into the thinking toggle for these.
*/
Expand Down
23 changes: 11 additions & 12 deletions tests/adapter-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,29 @@ describe("adapter reasoning and usage details", () => {
},
})));

expect(events).toContainEqual({ type: "reasoning_raw_delta", text: "raw thoughts" });
expect(events).toContainEqual({ type: "text_delta", text: "answer" });
expect(events?.at(-1)).toEqual({
type: "done",
usage: { inputTokens: 11, outputTokens: 7, cachedInputTokens: 5, reasoningOutputTokens: 3 },
});
expect(events).toEqual([
{ type: "reasoning_raw_delta", text: "raw thoughts" },
{ type: "text_delta", text: "answer" },
{ type: "done", usage: { inputTokens: 11, outputTokens: 7, cachedInputTokens: 5, reasoningOutputTokens: 3 } },
]);
});

test("OpenAI-compatible streaming maps reasoning_content and usage details", async () => {
const adapter = createOpenAIChatAdapter(provider);
const response = new Response([
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"raw stream\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"raw stream\",\"content\":\"answer\"}}]}\n\n",
"data: {\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4,\"prompt_tokens_details\":{\"cached_tokens\":2},\"completion_tokens_details\":{\"reasoning_tokens\":1}}}\n\n",
"data: [DONE]\n\n",
].join(""));

const events = [];
for await (const event of adapter.parseStream(response)) events.push(event);

expect(events).toContainEqual({ type: "reasoning_raw_delta", text: "raw stream" });
expect(events.at(-1)).toEqual({
type: "done",
usage: { inputTokens: 9, outputTokens: 4, cachedInputTokens: 2, reasoningOutputTokens: 1 },
});
expect(events).toEqual([
{ type: "reasoning_raw_delta", text: "raw stream" },
{ type: "text_delta", text: "answer" },
{ type: "done", usage: { inputTokens: 9, outputTokens: 4, cachedInputTokens: 2, reasoningOutputTokens: 1 } },
]);
});

test("OpenAI-compatible non-OpenAI providers receive the tool catalog nudge", async () => {
Expand Down
125 changes: 125 additions & 0 deletions tests/minimax-reasoning-split.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
import { enrichProviderFromRegistry } from "../src/providers/derive";
import { routeModel } from "../src/router";
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types";

type ReasoningEffort = OcxParsedRequest["options"]["reasoning"];

function parsed(modelId: string, reasoning?: ReasoningEffort): OcxParsedRequest {
return {
modelId,
context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] },
stream: false,
options: reasoning ? { reasoning } : {},
};
}

function body(provider: OcxProviderConfig, modelId: string, reasoning?: ReasoningEffort): Record<string, unknown> {
const request = createOpenAIChatAdapter(provider).buildRequest(parsed(modelId, reasoning));
return JSON.parse(request.body as string) as Record<string, unknown>;
}

function minimaxRoute(modelId = "MiniMax-M3", provider: Partial<OcxProviderConfig> = {}) {
const config: OcxConfig = {
port: 10100,
defaultProvider: "minimax",
providers: {
minimax: {
adapter: "openai-chat",
baseUrl: "https://api.minimax.io/v1",
apiKey: "test-key",
...provider,
},
},
};
return routeModel(config, `minimax/${modelId}`);
}

describe("MiniMax split reasoning", () => {
test("M3 maps Codex effort to MiniMax adaptive/disabled and separates reasoning", () => {
const route = minimaxRoute();

expect(body(route.provider, route.modelId, "low")).toMatchObject({
model: "MiniMax-M3",
reasoning_split: true,
thinking: { type: "disabled" },
});
expect(body(route.provider, route.modelId, "medium")).toMatchObject({
model: "MiniMax-M3",
reasoning_split: true,
thinking: { type: "adaptive" },
});
expect(body(route.provider, route.modelId, "high")).toMatchObject({
reasoning_split: true,
thinking: { type: "adaptive" },
});
expect(body(route.provider, route.modelId, "high")).not.toHaveProperty("reasoning_effort");
});

test("all MiniMax M-series models request split reasoning and preserve it in history", () => {
const route = minimaxRoute("MiniMax-M2.7");
const request = createOpenAIChatAdapter(route.provider).buildRequest({
modelId: route.modelId,
context: {
messages: [
{ role: "user", content: "first", timestamp: 0 },
{
role: "assistant",
timestamp: 1,
content: [
{ type: "thinking", thinking: "prior reasoning" },
{ type: "text", text: "prior answer" },
],
},
{ role: "user", content: "continue", timestamp: 2 },
],
},
stream: false,
options: {},
});
const requestBody = JSON.parse(request.body as string) as {
reasoning_split?: boolean;
messages: Array<Record<string, unknown>>;
};

expect(requestBody.reasoning_split).toBe(true);
expect(requestBody.messages[1]?.reasoning_content).toBe("prior reasoning");
});

test("routing merges registry capabilities while explicit user effort mappings win", () => {
const route = minimaxRoute("MiniMax-M3", {
reasoningSplitModels: ["user-split-model"],
modelReasoningEffortMap: { "MiniMax-M3": { medium: "disabled" } },
});

expect(route.provider.reasoningSplitModels).toEqual(expect.arrayContaining(["MiniMax-M3", "user-split-model"]));
expect(route.provider.modelReasoningEffortMap?.["MiniMax-M3"]).toMatchObject({
medium: "disabled",
high: "adaptive",
});
});

test("registry enrichment never replaces explicit split-reasoning fields", () => {
const provider: OcxProviderConfig = {
adapter: "openai-chat",
baseUrl: "https://api.minimax.io/v1",
reasoningSplitModels: ["only-user-model"],
modelReasoningEffortMap: { "MiniMax-M3": { medium: "disabled" } },
};

enrichProviderFromRegistry("minimax", provider);

expect(provider.reasoningSplitModels).toEqual(["only-user-model"]);
expect(provider.modelReasoningEffortMap).toEqual({ "MiniMax-M3": { medium: "disabled" } });
});

test("unconfigured OpenAI-compatible providers remain unchanged", () => {
const provider: OcxProviderConfig = {
adapter: "openai-chat",
baseUrl: "https://example.test/v1",
};

expect(body(provider, "example-model", "high")).not.toHaveProperty("reasoning_split");
});
});
Loading
Loading