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
14 changes: 11 additions & 3 deletions packages/commands/src/commands/auth/login-api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BailianError,
ExitCode,
getApiKeyLoginKind,
getMiniMaxProfilePreset,
normalizeModelBaseUrl,
requestJson,
type ApiKeyLoginKind,
Expand Down Expand Up @@ -33,7 +34,7 @@ interface ApiKeyLoginProfile {
persistPatch?: AuthPersistPatch;
}

type ApiKeyEndpointKind = Exclude<ApiKeyLoginKind, "unknown">;
type ApiKeyEndpointKind = Exclude<ApiKeyLoginKind, "unknown"> | "minimax";

interface ApiKeyValidationCandidate {
baseUrl: string;
Expand Down Expand Up @@ -63,7 +64,7 @@ const ORDINARY_WORKSPACE_REGIONS = [
"us-east-1",
] as const;

const KNOWN_BASE_URLS: Readonly<Record<ApiKeyEndpointKind, readonly string[]>> = {
const KNOWN_BASE_URLS: Readonly<Record<Exclude<ApiKeyLoginKind, "unknown">, readonly string[]>> = {
ordinary: [
"https://dashscope.aliyuncs.com",
"https://dashscope-intl.aliyuncs.com",
Expand All @@ -77,6 +78,7 @@ const KNOWN_BASE_URLS: Readonly<Record<ApiKeyEndpointKind, readonly string[]>> =
};

function modelsUrl(baseUrl: string, kind: ApiKeyEndpointKind): string {
if (kind === "minimax") return new URL("/v1/models", baseUrl).toString();
const url = new URL(
kind === "token-plan" ? "/compatible-mode/v1/models" : "/api/v1/models",
`${baseUrl}/`,
Expand All @@ -88,7 +90,7 @@ function modelsUrl(baseUrl: string, kind: ApiKeyEndpointKind): string {
return url.toString();
}

function validationKinds(apiKeyKind: ApiKeyLoginKind): ApiKeyEndpointKind[] {
function validationKinds(apiKeyKind: ApiKeyLoginKind): Exclude<ApiKeyLoginKind, "unknown">[] {
if (apiKeyKind === "unknown") return ["ordinary", "token-plan"];
return [apiKeyKind];
}
Expand Down Expand Up @@ -119,6 +121,12 @@ export function apiKeyValidationCandidates(
const candidates: ApiKeyValidationCandidate[] = [];
const seenUrls = new Set<string>();

const selectedBaseUrl = options.explicitBaseUrl ?? options.storedBaseUrl;
if (selectedBaseUrl && getMiniMaxProfilePreset(selectedBaseUrl)) {
addCandidate(candidates, seenUrls, selectedBaseUrl, "minimax");
return candidates;
}

if (options.explicitBaseUrl) {
for (const kind of kinds) addCandidate(candidates, seenUrls, options.explicitBaseUrl, kind);
return candidates;
Expand Down
27 changes: 22 additions & 5 deletions packages/commands/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
defineCommand,
generateCLIAccessToken,
getModelProfilePreset,
getMiniMaxProfilePreset,
normalizeModelBaseUrl,
} from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
Expand Down Expand Up @@ -81,8 +82,18 @@ export default defineCommand({
},
},
},
notes: [
{
"en-US":
"Use --config minimax or --config minimax-cn with --api-key for MiniMax global or China text profiles. New profiles default to MiniMax-M3.1-Flash and only claim text.chat; saved models and custom --base-url values are preserved.",
"zh-CN":
"使用 --config minimax 或 --config minimax-cn 配合 --api-key 登录 MiniMax 国际站或中国站文本配置。新配置默认使用 MiniMax-M3.1-Flash,仅声明 text.chat;保留已保存的模型和自定义 --base-url。",
},
],
exampleArgs: [
"--console",
"--config minimax --api-key <key>",
"--config minimax-cn --api-key <key>",
"--api-key sk-xxxxx",
"--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx",
],
Expand Down Expand Up @@ -171,17 +182,23 @@ export default defineCommand({
return;
}
const stored = store.stored();
const namedPreset = getMiniMaxProfilePreset(
getModelProfilePreset(settings.configName)?.baseUrl,
);
const validation = await validateApiKey(deps, key, {
explicitBaseUrl: baseUrl,
explicitBaseUrl: baseUrl ?? (!stored.baseUrl ? namedPreset?.baseUrl : undefined),
storedBaseUrl: stored.baseUrl,
workspaceId: settings.workspaceId,
});
const profilePreset = getModelProfilePreset(
validation.kind === "token-plan" ? "token-plan" : undefined,
);
const profilePreset =
getMiniMaxProfilePreset(validation.baseUrl) ??
getModelProfilePreset(validation.kind === "token-plan" ? "token-plan" : undefined);
await persistApiKey(deps, key, {
persistBaseUrl: validation.baseUrl,
defaultTextModel: profilePreset?.defaultTextModel,
defaultTextModel:
validation.kind === "minimax"
? (settings.defaultTextModel ?? profilePreset?.defaultTextModel)
: profilePreset?.defaultTextModel,
defaultVideoModel: profilePreset?.defaultVideoModel,
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
Expand Down
39 changes: 29 additions & 10 deletions packages/commands/src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
defineCommand,
chatPath,
responsesPath,
getMiniMaxProfilePreset,
UsageError,
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
Expand Down Expand Up @@ -35,8 +37,9 @@ const CHAT_FLAGS = {
type: "string",
valueHint: "<model>",
description: {
"en-US": "Model ID (default: qwen3.8-max)",
"zh-CN": "模型 ID(默认:qwen3.8-max)",
"en-US":
"Model ID (default: profile model, MiniMax-M3.1-Flash for MiniMax, otherwise qwen3.8-max)",
"zh-CN": "模型 ID(默认:配置模型,MiniMax 使用 MiniMax-M3.1-Flash,其他使用 qwen3.8-max)",
},
},
message: {
Expand Down Expand Up @@ -96,8 +99,8 @@ const CHAT_FLAGS = {
enableThinking: {
type: "switch",
description: {
"en-US": "Enable thinking/reasoning mode (for qwen3/qwq models)",
"zh-CN": "启用思考/推理模式(适用于 qwen3/qwq 模型)",
"en-US": "Enable thinking/reasoning mode (Qwen or MiniMax; Flash always reasons)",
"zh-CN": "启用思考/推理模式(Qwen 或 MiniMax;Flash 始终开启推理)",
},
},
thinkingBudget: {
Expand Down Expand Up @@ -195,6 +198,12 @@ export default defineCommand({
},
],
notes: [
{
"en-US":
"MiniMax profiles support --api chat and --api responses. Use --model MiniMax-M3, MiniMax-M2.7, or a custom model to override Flash. Flash reasoning is always on; --thinking-budget is unsupported.",
"zh-CN":
"MiniMax 配置支持 --api chat 和 --api responses。使用 --model MiniMax-M3、MiniMax-M2.7 或自定义模型可覆盖 Flash 默认值。Flash 始终开启推理,不支持 --thinking-budget。",
},
{
"en-US":
"Prime mode requires an explicit model and currently supports only the Chat Completions API.",
Expand Down Expand Up @@ -226,7 +235,12 @@ export default defineCommand({
const { system, messages } = parseMessages(flags);

const api = flags.api ?? "chat";
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
const miniMaxPreset = flags.prime ? undefined : getMiniMaxProfilePreset(ctx.client.baseUrl);
const model =
flags.model || settings.defaultTextModel || miniMaxPreset?.defaultTextModel || "qwen3.8-max";
if (miniMaxPreset && flags.thinkingBudget !== undefined) {
throw new UsageError("MiniMax does not support --thinking-budget.");
}
const shouldStream = flags.stream || process.stdout.isTTY;
const format = detectOutputFormat(settings.output);

Expand Down Expand Up @@ -258,17 +272,22 @@ export default defineCommand({
if (flags.topP !== undefined) body.top_p = flags.topP;

if (flags.enableThinking) {
body.enable_thinking = true;
if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
if (miniMaxPreset) {
if ("messages" in body) body.thinking = { type: "adaptive" };
else body.reasoning = { effort: "max" };
} else {
body.enable_thinking = true;
if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
}
}

const requestPath = flags.prime
? resolvePrimeEndpoint(ctx, chatPath())
: api === "responses"
? responsesPath()
: chatPath();
? responsesPath(ctx.client.baseUrl)
: chatPath(ctx.client.baseUrl);

if (flags.tool) {
const tools = flags.tool.map((toolValue) => {
Expand Down
137 changes: 137 additions & 0 deletions packages/commands/tests/minimax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { afterEach, expect, it, vi } from "vite-plus/test";
import { Client, chatPath, responsesPath, getModelProfilePreset } from "bailian-cli-core";
import { resolveApiKeyLoginConfigName } from "../../core/src/config/profile-presets.ts";
import { apiKeyValidationCandidates } from "../src/commands/auth/login-api-key.ts";
import loginCommand from "../src/commands/auth/login.ts";
import chatCommand from "../src/commands/text/chat.ts";

afterEach(() => vi.restoreAllMocks());

for (const [profile, origin] of [
["minimax", "https://api.minimax.io"],
["minimax-cn", "https://api.minimax.cn"],
]) {
it(`${profile} authenticates only on its selected origin and preserves saved models`, async () => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(JSON.stringify({ object: "list", data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const persist = vi.fn();
for (const saved of [undefined, "MiniMax-M3", "MiniMax-M2.7", "custom-model"]) {
const stored = { baseUrl: undefined, apiKeyCapabilities: undefined };
await loginCommand.run!({
flags: { apiKey: "sk-sp-test-minimax" },
settings: { configName: profile, defaultTextModel: saved, timeout: 5, language: "en-US" },
identity: { productName: "bailian", version: "0.0.0", userAgent: "test" },
authStore: { stored: () => stored, login: persist, path: "test-config" },
} as never);
expect(persist).toHaveBeenLastCalledWith(
expect.objectContaining({
base_url: origin,
default_text_model: saved ?? "MiniMax-M3.1-Flash",
api_key_capabilities: ["text.chat"],
}),
);
expect(persist.mock.lastCall![0].default_speech_model).toBeUndefined();
}
expect(fetchMock.mock.calls.every(([url]) => url === `${origin}/v1/models`)).toBe(true);
expect(apiKeyValidationCandidates("sk-sp-test", { storedBaseUrl: origin })).toEqual([
{ baseUrl: origin, kind: "minimax", modelsUrl: `${origin}/v1/models` },
]);
expect(resolveApiKeyLoginConfigName("sk-sp-test", profile, false, origin)).toBe(profile);
expect(getModelProfilePreset(profile)?.apiKeyCapabilities).toEqual(["text.chat"]);
});

for (const api of ["chat", "responses"]) {
for (const stream of [false, true]) {
it(`${profile} ${api} stream=${stream} sends a real client request with Flash`, async () => {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const response =
api === "chat"
? { choices: [{ message: { content: "hello" } }] }
: {
status: "completed",
output: [{ type: "message", content: [{ type: "output_text", text: "hello" }] }],
};
const sse =
api === "chat"
? 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\ndata: [DONE]\n\n'
: 'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n';
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(stream ? sse : JSON.stringify(response), {
status: 200,
headers: { "Content-Type": stream ? "text/event-stream" : "application/json" },
}),
);
const settings = { output: "json", timeout: 5, language: "en-US" };
const identity = { productName: "bailian", version: "0.0.0", userAgent: "test" };
const client = new Client({
identity,
settings,
baseUrl: origin,
apiCred: { token: "test-minimax-key", baseUrl: origin },
} as never);
await chatCommand.run!({
client,
settings,
flags: { api, stream, message: ["hello"], enableThinking: true },
} as never);
const [url, request] = fetchMock.mock.calls[0];
expect(url).toBe(`${origin}/v1/${api === "chat" ? "chat/completions" : "responses"}`);
const body = JSON.parse(request!.body as string);
expect(body.model).toBe("MiniMax-M3.1-Flash");
expect(body.enable_thinking).toBeUndefined();
expect(api === "chat" ? body.thinking : body.reasoning).toEqual(
api === "chat" ? { type: "adaptive" } : { effort: "max" },
);
expect(new Headers(request!.headers).get("Authorization")).toBe("Bearer test-minimax-key");
});
}
}
}

it("keeps legacy/custom selections and non-MiniMax routing", async () => {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
for (const [origin, saved, explicit, expected] of [
["https://api.minimax.io", "MiniMax-M2.7", undefined, "MiniMax-M2.7"],
["https://api.minimax.cn", "MiniMax-M3", "custom-model", "custom-model"],
["https://dashscope.aliyuncs.com", undefined, undefined, "qwen3.8-max"],
["https://gateway.example", "custom-model", undefined, "custom-model"],
]) {
const requestJson = vi.fn().mockResolvedValue({ choices: [{ message: { content: "hello" } }] });
await chatCommand.run!({
client: { baseUrl: origin, requestJson },
settings: { output: "json", defaultTextModel: saved },
flags: { message: ["hello"], model: explicit },
} as never);
expect(requestJson.mock.calls[0][0].body.model).toBe(expected);
expect(requestJson.mock.calls[0][0].path).toBe(chatPath(origin));
}
for (const origin of [
undefined,
"https://gateway.example",
"https://api.minimax.io.example",
"http://api.minimax.io",
]) {
expect(chatPath(origin)).toBe("/compatible-mode/v1/chat/completions");
expect(responsesPath(origin)).toBe("/compatible-mode/v1/responses");
}
expect(getModelProfilePreset("token-plan")?.defaultTextModel).toBe("qwen3.8-max");
expect(getModelProfilePreset("token-plan")?.defaultSpeechModel).toBe("qwen-audio-3.0-tts-plus");
});

it("rejects a MiniMax thinking budget before sending a request", async () => {
const requestJson = vi.fn();
await expect(
chatCommand.run!({
client: { baseUrl: "https://api.minimax.io", requestJson },
settings: { output: "json" },
flags: { message: ["hello"], thinkingBudget: 128 },
} as never),
).rejects.toThrow("MiniMax does not support --thinking-budget");
expect(requestJson).not.toHaveBeenCalled();
});
7 changes: 6 additions & 1 deletion packages/core/src/auth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore {
resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback),
async login(patch) {
const targetConfigName = patch.api_key
? resolveApiKeyLoginConfigName(patch.api_key, configName, activateAfterLogin)
? resolveApiKeyLoginConfigName(
patch.api_key,
configName,
activateAfterLogin,
patch.base_url,
)
: configName;
const shouldActivate = activateAfterLogin || targetConfigName !== configName;
const existing = readConfigFile(targetConfigName) as Record<string, unknown>;
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/client/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { UsageError } from "../errors/base.ts";
import { getMiniMaxProfilePreset } from "../config/profile-presets.ts";

// API path builders — return the path only; the Client prepends the
// credential's baseUrl. Commands never see baseUrl.
Expand All @@ -22,13 +23,15 @@ function workspaceEndpoint(workspaceId: string, path: string): string {
}

// ---- Chat (OpenAI Compatible) ----
export function chatPath(): string {
return "/compatible-mode/v1/chat/completions";
export function chatPath(baseUrl?: string): string {
return getMiniMaxProfilePreset(baseUrl)
? "/v1/chat/completions"
: "/compatible-mode/v1/chat/completions";
}

// ---- Responses (OpenAI Compatible) ----
export function responsesPath(): string {
return "/compatible-mode/v1/responses";
export function responsesPath(baseUrl?: string): string {
return getMiniMaxProfilePreset(baseUrl) ? "/v1/responses" : "/compatible-mode/v1/responses";
}

// ---- Decision model (TypeSafe System One, workspace-based host) ----
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from
export {
getApiKeyLoginKind,
getModelProfilePreset,
getMiniMaxProfilePreset,
type ApiKeyLoginKind,
} from "./profile-presets.ts";
export { normalizeModelBaseUrl } from "./model-base-url.ts";
Expand Down
Loading