diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 21d85686b..07f679b86 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -35,7 +35,24 @@ MiniMax highlights these improvements in M2.1: ## Choose a setup -### MiniMax M2.1 — recommended +### MiniMax OAuth (Coding Plan) — recommended + +**Best for:** quick setup with MiniMax Coding Plan via OAuth, no API key required. + +Enable the bundled OAuth plugin and authenticate: + +```bash +openclaw plugins enable minimax-portal-auth # skip if already loaded. +openclaw gateway restart # restart if gateway is already running +openclaw onboard --auth-choice minimax-portal +``` +You will be prompted to select an endpoint: +- **Global** - International users (`api.minimax.io`) +- **CN** - Users in China (`api.minimaxi.com`) + +See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details. + +### MiniMax M2.1 (API key) **Best for:** hosted MiniMax with Anthropic-compatible API. @@ -143,6 +160,7 @@ Use the interactive config wizard to set MiniMax without editing JSON: 3) Choose **MiniMax M2.1**. 4) Pick your default model when prompted. + ## Configuration options - `models.providers.minimax.baseUrl`: prefer `https://api.minimax.io/anthropic` (Anthropic-compatible); `https://api.minimax.io/v1` is optional for OpenAI-compatible payloads. diff --git a/extensions/minimax-portal-auth/README.md b/extensions/minimax-portal-auth/README.md new file mode 100644 index 000000000..5e853b0df --- /dev/null +++ b/extensions/minimax-portal-auth/README.md @@ -0,0 +1,33 @@ +# MiniMax OAuth (OpenClaw plugin) + +OAuth provider plugin for **MiniMax** (OAuth). + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +openclaw plugins enable minimax-portal-auth +``` + +Restart the Gateway after enabling. + +```bash +openclaw gateway restart +``` + +## Authenticate + +```bash +openclaw models auth login --provider minimax-portal --set-default +``` + +You will be prompted to select an endpoint: + +- **Global** - International users, optimized for overseas access (`api.minimax.io`) +- **China** - Optimized for users in China (`api.minimaxi.com`) + +## Notes + +- MiniMax OAuth uses a user-code login flow. +- Currently, OAuth login is supported only for the Coding plan \ No newline at end of file diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts new file mode 100644 index 000000000..e36ba244f --- /dev/null +++ b/extensions/minimax-portal-auth/index.ts @@ -0,0 +1,152 @@ +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; + +import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; + +const PROVIDER_ID = "minimax-portal"; +const PROVIDER_LABEL = "MiniMax"; +const DEFAULT_MODEL = "MiniMax-M2.1"; +const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; +const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; +const DEFAULT_CONTEXT_WINDOW = 200000; +const DEFAULT_MAX_TOKENS = 8192; +const OAUTH_PLACEHOLDER = "minimax-oauth"; + +function getDefaultBaseUrl(region: MiniMaxRegion): string { + return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; +} + +function modelRef(modelId: string): string { + return `${PROVIDER_ID}/${modelId}`; +} + +function buildModelDefinition(params: { id: string; name: string; input: Array<"text" | "image"> }) { + return { + id: params.id, + name: params.name, + reasoning: false, + input: params.input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +function createOAuthHandler(region: MiniMaxRegion) { + const defaultBaseUrl = getDefaultBaseUrl(region); + const regionLabel = region === "cn" ? "CN" : "Global"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return async (ctx: any) => { + const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); + try { + const result = await loginMiniMaxPortalOAuth({ + openUrl: ctx.openUrl, + note: ctx.prompter.note, + progress, + region, + }); + + progress.stop("MiniMax OAuth complete"); + + if (result.notification_message) { + await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); + } + + const profileId = `${PROVIDER_ID}:default`; + const baseUrl = result.resourceUrl || defaultBaseUrl; + + return { + profiles: [ + { + profileId, + credential: { + type: "oauth" as const, + provider: PROVIDER_ID, + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: { + baseUrl, + apiKey: OAUTH_PLACEHOLDER, + api: "anthropic-messages", + models: [ + buildModelDefinition({ + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + input: ["text"], + }), + buildModelDefinition({ + id: "MiniMax-M2.1-lightning", + name: "MiniMax M2.1 Lightning", + input: ["text"], + }), + ], + }, + }, + }, + agents: { + defaults: { + models: { + [modelRef("MiniMax-M2.1")]: { alias: "minimax-m2.1" }, + [modelRef("MiniMax-M2.1-lightning")]: { alias: "minimax-m2.1-lightning" }, + }, + }, + }, + }, + defaultModel: modelRef(DEFAULT_MODEL), + notes: [ + "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, + ...(result.notification_message ? [result.notification_message] : []), + ], + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.stop(`MiniMax OAuth failed: ${errorMsg}`); + await ctx.prompter.note( + "If OAuth fails, verify your MiniMax account has portal access and try again.", + "MiniMax OAuth", + ); + throw err; + } + }; +} + +const minimaxPortalPlugin = { + id: "minimax-portal-auth", + name: "MiniMax OAuth", + description: "OAuth flow for MiniMax models", + configSchema: emptyPluginConfigSchema(), + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/minimax", + aliases: ["minimax"], + auth: [ + { + id: "oauth", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + kind: "user_code", + run: createOAuthHandler("global"), + }, + { + id: "oauth-cn", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + kind: "user_code", + run: createOAuthHandler("cn"), + }, + ], + }); + }, +}; + +export default minimaxPortalPlugin; diff --git a/extensions/minimax-portal-auth/oauth.ts b/extensions/minimax-portal-auth/oauth.ts new file mode 100644 index 000000000..9945ac551 --- /dev/null +++ b/extensions/minimax-portal-auth/oauth.ts @@ -0,0 +1,240 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +export type MiniMaxRegion = "cn" | "global"; + +const MINIMAX_OAUTH_CONFIG = { + cn: { + baseUrl: "https://api.minimaxi.com", + clientId: "78257093-7e40-4613-99e0-527b14b39113", + }, + global: { + baseUrl: "https://api.minimax.io", + clientId: "78257093-7e40-4613-99e0-527b14b39113", + }, +} as const; + +const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"; +const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"; + +function getOAuthEndpoints(region: MiniMaxRegion) { + const config = MINIMAX_OAUTH_CONFIG[region]; + return { + codeEndpoint: `${config.baseUrl}/oauth/code`, + tokenEndpoint: `${config.baseUrl}/oauth/token`, + clientId: config.clientId, + baseUrl: config.baseUrl, + }; +} + +export type MiniMaxOAuthAuthorization = { + user_code: string; + verification_uri: string; + expired_in: number; + interval?: number; + state: string; +}; + +export type MiniMaxOAuthToken = { + access: string; + refresh: string; + expires: number; + resourceUrl?: string; + notification_message?: string; +}; + +type TokenPending = { status: "pending"; message?: string }; + +type TokenResult = + | { status: "success"; token: MiniMaxOAuthToken } + | TokenPending + | { status: "error"; message: string }; + +function toFormUrlEncoded(data: Record): string { + return Object.entries(data) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); +} + +function generatePkce(): { verifier: string; challenge: string; state: string } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomBytes(16).toString("base64url"); + return { verifier, challenge, state }; +} + +async function requestOAuthCode(params: { + challenge: string; + state: string; + region: MiniMaxRegion; +}): Promise { + const endpoints = getOAuthEndpoints(params.region); + const response = await fetch(endpoints.codeEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "x-request-id": randomUUID(), + }, + body: toFormUrlEncoded({ + response_type: "code", + client_id: endpoints.clientId, + scope: MINIMAX_OAUTH_SCOPE, + code_challenge: params.challenge, + code_challenge_method: "S256", + state: params.state, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`); + } + + const payload = (await response.json()) as MiniMaxOAuthAuthorization & { error?: string }; + if (!payload.user_code || !payload.verification_uri) { + throw new Error( + payload.error ?? + "MiniMax OAuth authorization returned an incomplete payload (missing user_code or verification_uri).", + ); + } + if (payload.state !== params.state) { + throw new Error( + "MiniMax OAuth state mismatch: possible CSRF attack or session corruption.", + ); + } + return payload; +} + +async function pollOAuthToken(params: { + userCode: string; + verifier: string; + region: MiniMaxRegion; +}): Promise { + const endpoints = getOAuthEndpoints(params.region); + const response = await fetch(endpoints.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: toFormUrlEncoded({ + grant_type: MINIMAX_OAUTH_GRANT_TYPE, + client_id: endpoints.clientId, + user_code: params.userCode, + code_verifier: params.verifier, + }), + }); + + if (!response.ok) { + const text = await response.text(); + let payload: { + status?: string; + base_resp?: { status_code?: number; status_msg?: string }; + } | undefined; + try { + payload = (await response.json()) as typeof payload; + } catch { + return { status: "error", message: text || "MiniMax OAuth failed to parse response.",}; + } + return { + status: "error", + message: text || "MiniMax OAuth failed to parse response.", + }; + } + + const tokenPayload = (await response.json()) as { + status: string; + access_token?: string | null; + refresh_token?: string | null; + expired_in?: number | null; + token_type?: string; + resource_url?: string; + notification_message?: string; + }; + + if (tokenPayload.status === "error") { + return { status: "error", message: "An error occurred. Please try again later"}; + } + + if (tokenPayload.status != "success") { + return { status: "pending", message: "current user code is not authorized" }; + } + + if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expired_in) { + return { status: "error", message: "MiniMax OAuth returned incomplete token payload." }; + } + + return { + status: "success", + token: { + access: tokenPayload.access_token, + refresh: tokenPayload.refresh_token, + expires: tokenPayload.expired_in, + resourceUrl: tokenPayload.resource_url, + notification_message: tokenPayload.notification_message, + }, + }; +} + +export async function loginMiniMaxPortalOAuth(params: { + openUrl: (url: string) => Promise; + note: (message: string, title?: string) => Promise; + progress: { update: (message: string) => void; stop: (message?: string) => void }; + region?: MiniMaxRegion; +}): Promise { + const region = params.region ?? "global"; + const { verifier, challenge, state } = generatePkce(); + const oauth = await requestOAuthCode({ challenge, state, region }); + const verificationUrl = oauth.verification_uri; + + const noteLines = [ + `Open ${verificationUrl} to approve access.`, + `If prompted, enter the code ${oauth.user_code}.`, + `Interval: ${oauth.interval ?? "default (2000ms)"}, Expires in: ${oauth.expired_in}ms`, + ]; + await params.note(noteLines.join("\n"), "MiniMax OAuth"); + + try { + await params.openUrl(verificationUrl); + } catch { + // Fall back to manual copy/paste if browser open fails. + } + + let pollIntervalMs = oauth.interval ? oauth.interval : 2000; + const expireTimeMs = oauth.expired_in; + + + while (Date.now() < expireTimeMs) { + params.progress.update("Waiting for MiniMax OAuth approval…"); + const result = await pollOAuthToken({ + userCode: oauth.user_code, + verifier, + region, + }); + + // // Debug: print poll result + // await params.note( + // `status: ${result.status}` + + // (result.status === "success" ? `\ntoken: ${JSON.stringify(result.token, null, 2)}` : "") + + // (result.status === "error" ? `\nmessage: ${result.message}` : "") + + // (result.status === "pending" && result.message ? `\nmessage: ${result.message}` : ""), + // "MiniMax OAuth Poll Result", + // ); + + if (result.status === "success") { + return result.token; + } + + if (result.status === "error") { + throw new Error(`MiniMax OAuth failed: ${result.message}`); + } + + if (result.status === "pending") { + pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000); + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error("MiniMax OAuth timed out waiting for authorization."); +} diff --git a/extensions/minimax-portal-auth/openclaw.plugin.json b/extensions/minimax-portal-auth/openclaw.plugin.json new file mode 100644 index 000000000..ded092e8e --- /dev/null +++ b/extensions/minimax-portal-auth/openclaw.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "minimax-portal-auth", + "providers": [ + "minimax-portal" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json new file mode 100644 index 000000000..696ab26b9 --- /dev/null +++ b/extensions/minimax-portal-auth/package.json @@ -0,0 +1,11 @@ +{ + "name": "@openclaw/minimax-portal-auth", + "version": "2026.1.30", + "type": "module", + "description": "OpenClaw MiniMax Portal OAuth provider plugin", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95b940c97..8f3b79644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -372,6 +372,8 @@ importers: specifier: ^6.16.0 version: 6.16.0(ws@8.19.0)(zod@4.3.6) + extensions/minimax-portal-auth: {} + extensions/msteams: dependencies: '@microsoft/agents-hosting': diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index 65c2f7a54..83946ac7a 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -7,6 +7,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json"; export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli"; export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli"; export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli"; +export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli"; export const AUTH_STORE_LOCK_OPTIONS = { retries: { diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index d1fa31f23..361ed67e9 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,8 +1,12 @@ -import { readQwenCliCredentialsCached } from "../cli-credentials.js"; +import { + readQwenCliCredentialsCached, + readMiniMaxCliCredentialsCached, +} from "../cli-credentials.js"; import { EXTERNAL_CLI_NEAR_EXPIRY_MS, EXTERNAL_CLI_SYNC_TTL_MS, QWEN_CLI_PROFILE_ID, + MINIMAX_CLI_PROFILE_ID, log, } from "./constants.js"; import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js"; @@ -25,15 +29,48 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean { if (!cred) return false; if (cred.type !== "oauth" && cred.type !== "token") return false; - if (cred.provider !== "qwen-portal") { + if (cred.provider !== "qwen-portal" && cred.provider !== "minimax-portal") { return false; } if (typeof cred.expires !== "number") return true; return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; } +/** Sync external CLI credentials into the store for a given provider. */ +function syncExternalCliCredentialsForProvider( + store: AuthProfileStore, + profileId: string, + provider: string, + readCredentials: () => OAuthCredential | null, + now: number, +): boolean { + const existing = store.profiles[profileId]; + const shouldSync = + !existing || existing.provider !== provider || !isExternalProfileFresh(existing, now); + const creds = shouldSync ? readCredentials() : null; + if (!creds) return false; + + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + const shouldUpdate = + !existingOAuth || + existingOAuth.provider !== provider || + existingOAuth.expires <= now || + creds.expires > existingOAuth.expires; + + if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) { + store.profiles[profileId] = creds; + log.info(`synced ${provider} credentials from external cli`, { + profileId, + expires: new Date(creds.expires).toISOString(), + }); + return true; + } + + return false; +} + /** - * Sync OAuth credentials from external CLI tools (Qwen Code CLI) into the store. + * Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI) into the store. * * Returns true if any credentials were updated. */ @@ -69,5 +106,18 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean { } } + // Sync from MiniMax Portal CLI + if ( + syncExternalCliCredentialsForProvider( + store, + MINIMAX_CLI_PROFILE_ID, + "minimax-portal", + () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + now, + ) + ) { + mutated = true; + } + return mutated; } diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 5ca5629ff..43ce91b8c 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -14,6 +14,7 @@ const log = createSubsystemLogger("agents/auth-profiles"); const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json"; const CODEX_CLI_AUTH_FILENAME = "auth.json"; const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json"; +const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; @@ -27,11 +28,13 @@ type CachedValue = { let claudeCliCache: CachedValue | null = null; let codexCliCache: CachedValue | null = null; let qwenCliCache: CachedValue | null = null; +let minimaxCliCache: CachedValue | null = null; export function resetCliCredentialCachesForTest(): void { claudeCliCache = null; codexCliCache = null; qwenCliCache = null; + minimaxCliCache = null; } export type ClaudeCliCredential = @@ -66,6 +69,14 @@ export type QwenCliCredential = { expires: number; }; +export type MiniMaxCliCredential = { + type: "oauth"; + provider: "minimax-portal"; + access: string; + refresh: string; + expires: number; +}; + type ClaudeCliFileOptions = { homeDir?: string; }; @@ -102,6 +113,11 @@ function resolveQwenCliCredentialsPath(homeDir?: string) { return path.join(baseDir, QWEN_CLI_CREDENTIALS_RELATIVE_PATH); } +function resolveMiniMaxCliCredentialsPath(homeDir?: string) { + const baseDir = homeDir ?? resolveUserPath("~"); + return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH); +} + function computeCodexKeychainAccount(codexHome: string) { const hash = createHash("sha256").update(codexHome).digest("hex"); return `cli|${hash.slice(0, 16)}`; @@ -186,6 +202,28 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti }; } +function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCredential | null { + const credPath = resolveMiniMaxCliCredentialsPath(options?.homeDir); + const raw = loadJsonFile(credPath); + if (!raw || typeof raw !== "object") return null; + const data = raw as Record; + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + const expiresAt = data.expiry_date; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null; + + return { + type: "oauth", + provider: "minimax-portal", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; +} + function readClaudeCliKeychainCredentials( execSyncImpl: ExecSyncFn = execSync, ): ClaudeCliCredential | null { @@ -497,3 +535,25 @@ export function readQwenCliCredentialsCached(options?: { } return value; } + +export function readMiniMaxCliCredentialsCached(options?: { + ttlMs?: number; + homeDir?: string; +}): MiniMaxCliCredential | null { + const ttlMs = options?.ttlMs ?? 0; + const now = Date.now(); + const cacheKey = resolveMiniMaxCliCredentialsPath(options?.homeDir); + if ( + ttlMs > 0 && + minimaxCliCache && + minimaxCliCache.cacheKey === cacheKey && + now - minimaxCliCache.readAt < ttlMs + ) { + return minimaxCliCache.value; + } + const value = readMiniMaxCliCredentials({ homeDir: options?.homeDir }); + if (ttlMs > 0) { + minimaxCliCache = { value, readAt: now, cacheKey }; + } + return value; +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1445b53f7..2b7727fda 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -269,6 +269,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY"); } + if (normalized === "minimax-portal") { + return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY"); + } + const envMap: Record = { openai: "OPENAI_API_KEY", google: "GEMINI_API_KEY", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0cd034c82..45703cc0f 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -18,10 +18,12 @@ type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1"; +const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; +const MINIMAX_OAUTH_PLACEHOLDER = "minimax-oauth"; // Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. const MINIMAX_API_COST = { input: 15, @@ -279,6 +281,24 @@ function buildMinimaxProvider(): ProviderConfig { }; } +function buildMinimaxPortalProvider(): ProviderConfig { + return { + baseUrl: MINIMAX_PORTAL_BASE_URL, + api: "anthropic-messages", + models: [ + { + id: MINIMAX_DEFAULT_MODEL_ID, + name: "MiniMax M2.1", + reasoning: false, + input: ["text"], + cost: MINIMAX_API_COST, + contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, + maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + function buildMoonshotProvider(): ProviderConfig { return { baseUrl: MOONSHOT_BASE_URL, @@ -403,6 +423,14 @@ export async function resolveImplicitProviders(params: { providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; } + const minimaxOauthProfile = listProfilesForProvider(authStore, "minimax-portal"); + if (minimaxOauthProfile.length > 0) { + providers["minimax-portal"] = { + ...buildMinimaxPortalProvider(), + apiKey: MINIMAX_OAUTH_PLACEHOLDER, + }; + } + const moonshotKey = resolveEnvApiKeyVarName("moonshot") ?? resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 5acddf4e3..6996ae595 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -52,7 +52,7 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "minimax", label: "MiniMax", hint: "M2.1 (recommended)", - choices: ["minimax-api", "minimax-api-lightning"], + choices: ["minimax-portal", "minimax-api", "minimax-api-lightning"], }, { value: "qwen", @@ -175,6 +175,11 @@ export function buildAuthChoiceOptions(params: { value: "xiaomi-api-key", label: "Xiaomi API key", }); + options.push({ + value: "minimax-portal", + label: "MiniMax OAuth", + hint: "OAuth new users enjoy a 3-day free trial of the MiniMax Coding Plan!", + }); options.push({ value: "qwen-portal", label: "Qwen OAuth" }); options.push({ value: "copilot-proxy", diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index bf69179ca..57cd67749 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -6,6 +6,7 @@ import { } from "./auth-choice.api-key.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; +import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; import { applyAuthProfileConfig, applyMinimaxApiConfig, @@ -27,6 +28,24 @@ export async function applyAuthChoiceMiniMax( "Model configured", ); }; + if (params.authChoice === "minimax-portal") { + // Let user choose between Global/CN endpoints + const endpoint = await params.prompter.select({ + message: "Select MiniMax endpoint", + options: [ + { value: "oauth", label: "Global", hint: "OAuth for international users" }, + { value: "oauth-cn", label: "CN", hint: "OAuth for users in China" }, + ], + }); + + return await applyAuthChoicePluginProvider(params, { + authChoice: "minimax-portal", + pluginId: "minimax-portal-auth", + providerId: "minimax-portal", + methodId: endpoint as string, + label: "MiniMax", + }); + } if ( params.authChoice === "minimax-cloud" || diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index a4d831c92..e9047cae0 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -29,6 +29,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { minimax: "lmstudio", "opencode-zen": "opencode", "qwen-portal": "qwen-portal", + "minimax-portal": "minimax-portal", }; export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 8719a1f1a..109b19875 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -380,7 +380,8 @@ export async function applyNonInteractiveAuthChoice(params: { authChoice === "oauth" || authChoice === "chutes" || authChoice === "openai-codex" || - authChoice === "qwen-portal" + authChoice === "qwen-portal" || + authChoice === "minimax-portal" ) { runtime.error("OAuth requires interactive mode."); runtime.exit(1); diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index f4154bc6d..f3d72051d 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -28,6 +28,7 @@ export type AuthChoice = | "minimax" | "minimax-api" | "minimax-api-lightning" + | "minimax-portal" | "opencode-zen" | "github-copilot" | "copilot-proxy" diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index c9c106597..edeef7ae7 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -33,6 +33,7 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, + { pluginId: "minimax-portal-auth", providerId: "minimax-portal" }, ]; function isRecord(value: unknown): value is Record {