diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index 5a3f8a629..a30a20e5a 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -2,6 +2,7 @@ import { BailianError, ExitCode, getApiKeyLoginKind, + getMiniMaxProfilePreset, normalizeModelBaseUrl, requestJson, type ApiKeyLoginKind, @@ -33,7 +34,7 @@ interface ApiKeyLoginProfile { persistPatch?: AuthPersistPatch; } -type ApiKeyEndpointKind = Exclude; +type ApiKeyEndpointKind = Exclude | "minimax"; interface ApiKeyValidationCandidate { baseUrl: string; @@ -63,7 +64,7 @@ const ORDINARY_WORKSPACE_REGIONS = [ "us-east-1", ] as const; -const KNOWN_BASE_URLS: Readonly> = { +const KNOWN_BASE_URLS: Readonly, readonly string[]>> = { ordinary: [ "https://dashscope.aliyuncs.com", "https://dashscope-intl.aliyuncs.com", @@ -77,6 +78,7 @@ const KNOWN_BASE_URLS: Readonly> = }; 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}/`, @@ -88,7 +90,7 @@ function modelsUrl(baseUrl: string, kind: ApiKeyEndpointKind): string { return url.toString(); } -function validationKinds(apiKeyKind: ApiKeyLoginKind): ApiKeyEndpointKind[] { +function validationKinds(apiKeyKind: ApiKeyLoginKind): Exclude[] { if (apiKeyKind === "unknown") return ["ordinary", "token-plan"]; return [apiKeyKind]; } @@ -119,6 +121,12 @@ export function apiKeyValidationCandidates( const candidates: ApiKeyValidationCandidate[] = []; const seenUrls = new Set(); + 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; diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 805a5deb7..c97a0bb6b 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -2,6 +2,7 @@ import { defineCommand, generateCLIAccessToken, getModelProfilePreset, + getMiniMaxProfilePreset, normalizeModelBaseUrl, } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; @@ -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 ", + "--config minimax-cn --api-key ", "--api-key sk-xxxxx", "--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx", ], @@ -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, diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 22f139bba..2c9b24910 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -2,6 +2,8 @@ import { defineCommand, chatPath, responsesPath, + getMiniMaxProfilePreset, + UsageError, parseSSE, detectOutputFormat, readTextFromPathOrStdin, @@ -35,8 +37,9 @@ const CHAT_FLAGS = { type: "string", valueHint: "", 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: { @@ -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: { @@ -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.", @@ -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); @@ -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) => { diff --git a/packages/commands/tests/minimax.test.ts b/packages/commands/tests/minimax.test.ts new file mode 100644 index 000000000..00433e196 --- /dev/null +++ b/packages/commands/tests/minimax.test.ts @@ -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(); +}); diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 77ba42f70..355142bf5 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -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; diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 57ca231a1..b0e2ef817 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -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. @@ -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) ---- diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index fce85ac6f..efc2fda2a 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -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"; diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts index 2f6f05c5e..3666ea00f 100644 --- a/packages/core/src/config/profile-presets.ts +++ b/packages/core/src/config/profile-presets.ts @@ -1,18 +1,28 @@ interface ModelProfilePreset { baseUrl: string; defaultTextModel: string; - defaultVideoModel: string; - defaultImageToVideoModel: string; - defaultReferenceToVideoModel: string; - defaultImageModel: string; - defaultSpeechModel: string; - defaultSpeechRecognitionModel: string; + defaultVideoModel?: string; + defaultImageToVideoModel?: string; + defaultReferenceToVideoModel?: string; + defaultImageModel?: string; + defaultSpeechModel?: string; + defaultSpeechRecognitionModel?: string; apiKeyCapabilities: readonly string[]; } export type ApiKeyLoginKind = "token-plan" | "ordinary" | "unknown"; const MODEL_PROFILE_PRESETS: Readonly> = { + minimax: { + baseUrl: "https://api.minimax.io", + defaultTextModel: "MiniMax-M3.1-Flash", + apiKeyCapabilities: ["text.chat"], + }, + "minimax-cn": { + baseUrl: "https://api.minimax.cn", + defaultTextModel: "MiniMax-M3.1-Flash", + apiKeyCapabilities: ["text.chat"], + }, "token-plan": { baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", defaultTextModel: "qwen3.8-max", @@ -42,7 +52,20 @@ export function getModelProfilePreset(configName?: string): ModelProfilePreset | return configName ? MODEL_PROFILE_PRESETS[configName] : undefined; } -/** Classify only the key formats that affect automatic Profile routing. */ +/** Recognize only official HTTPS origins; custom gateways keep their existing behavior. */ +export function getMiniMaxProfilePreset(baseUrl?: string): ModelProfilePreset | undefined { + if (!baseUrl) return undefined; + let origin: string; + try { + origin = new URL(baseUrl).origin; + } catch { + return undefined; + } + return [MODEL_PROFILE_PRESETS.minimax, MODEL_PROFILE_PRESETS["minimax-cn"]].find( + (preset) => preset.baseUrl === origin, + ); +} + export function getApiKeyLoginKind(apiKey: string): ApiKeyLoginKind { const normalizedApiKey = apiKey.trim(); if (normalizedApiKey.startsWith("sk-sp-")) return "token-plan"; @@ -55,8 +78,9 @@ export function resolveApiKeyLoginConfigName( apiKey: string, selectedConfigName: string | undefined, configExplicit: boolean, + baseUrl?: string, ): string | undefined { - if (configExplicit) return selectedConfigName; + if (configExplicit || getMiniMaxProfilePreset(baseUrl)) return selectedConfigName; const apiKeyKind = getApiKeyLoginKind(apiKey); if (apiKeyKind === "token-plan") return "token-plan"; if (apiKeyKind === "ordinary" && selectedConfigName === "token-plan") return undefined; diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 10eca270c..25bf60fc7 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -42,6 +42,7 @@ export interface ChatRequest { tool_choice?: "auto" | "none" | { type: "function"; function: { name: string } }; enable_thinking?: boolean; thinking_budget?: number; + thinking?: { type: "adaptive" }; modalities?: string[]; audio?: { voice: string; format?: string }; stream_options?: { include_usage?: boolean }; @@ -119,6 +120,7 @@ export interface ResponsesRequest { stream?: boolean; tools?: Array>; enable_thinking?: boolean; + reasoning?: { effort: "max" }; } export interface ResponsesOutputContent {