From 1287328b6fa293d20abb5fe948eddbc5be7ac48a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 31 Jan 2026 12:42:45 +0100 Subject: [PATCH] feat: add MiniMax OAuth plugin (#4521) (thanks @Maosghoul) --- CHANGELOG.md | 1 + README.md | 65 ++--- docs/providers/minimax.md | 20 +- extensions/minimax-portal-auth/README.md | 33 +++ extensions/minimax-portal-auth/index.ts | 152 +++++++++++ extensions/minimax-portal-auth/oauth.ts | 252 ++++++++++++++++++ .../minimax-portal-auth/openclaw.plugin.json | 11 + extensions/minimax-portal-auth/package.json | 11 + pnpm-lock.yaml | 2 + src/agents/auth-profiles/constants.ts | 1 + src/agents/auth-profiles/external-cli-sync.ts | 58 +++- src/agents/cli-credentials.ts | 68 +++++ src/agents/model-auth.ts | 4 + src/agents/models-config.providers.ts | 28 ++ src/commands/auth-choice-options.ts | 7 +- src/commands/auth-choice.apply.minimax.ts | 19 ++ .../auth-choice.preferred-provider.ts | 1 + src/commands/auth-choice.test.ts | 95 +++++++ .../local/auth-choice.ts | 3 +- src/commands/onboard-types.ts | 1 + src/config/plugin-auto-enable.ts | 1 + 21 files changed, 795 insertions(+), 38 deletions(-) create mode 100644 extensions/minimax-portal-auth/README.md create mode 100644 extensions/minimax-portal-auth/index.ts create mode 100644 extensions/minimax-portal-auth/oauth.ts create mode 100644 extensions/minimax-portal-auth/openclaw.plugin.json create mode 100644 extensions/minimax-portal-auth/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 060eed7768..de8dfabd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - CLI: add per-agent `models status` (`--agent` filter). (#4780) Thanks @jlowin. - Agents: add Kimi K2.5 to the synthetic model catalog. (#4407) Thanks @manikv12. - Auth: switch Kimi Coding to built-in provider; normalize OAuth profile email. +- Auth: add MiniMax OAuth plugin + onboarding option. (#4521) Thanks @Maosghoul. - Agents: update pi SDK/API usage and dependencies. - Web UI: refresh sessions after chat commands and improve session display names. - Build: move TypeScript builds to `tsdown` + `tsgo` (faster builds, CI typechecks), update tsconfig target, and clean up lint rules. diff --git a/README.md b/README.md index 87125ebda8..582695d073 100644 --- a/README.md +++ b/README.md @@ -480,40 +480,41 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg - rahthakor vrknetha radek-paclt vignesh07 joshp123 Tobias Bischoff czekaj mukhtharcm sebslight maxsumrall - xadenryan rodrigouroz juanpablodlc tyler6204 hsrvc magimetal zerone0x meaningfool patelhiren NicholasSpisak - jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Mariano Belinky Hyaxia dantelex SocialNerd42069 daveonkels - google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev gumadeiras shakkernerd mteam88 hirefrank joeynyc - orlyjamie dbhurley Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua - benostein elliotsecops nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat - petter-b thewilloftheshadow cpojer scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee - nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna lutr0 kiranjd danielz1z AdeboyeDN Alg0rix - papago2355 emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev osolmaz adityashaw2 CashWilliams sheeek - ryancontent artuskg Takhoffman onutc pauloportella HirokiKobayashi-R neooriginal obviyus manuelhettich minghinmatthewlam - manikv12 myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase - uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee - robbyczgw-cla conroywhitney Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 - kennyklee superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy - imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse dougvk - erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist - sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal - ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats - 24601 ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi - odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Aaron Konyer aaronveklabs andreabadesso + steipete cpojer plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot + Glucksberg rahthakor vrknetha radek-paclt vignesh07 joshp123 Tobias Bischoff czekaj mukhtharcm sebslight + maxsumrall xadenryan rodrigouroz Mariano Belinky tyler6204 juanpablodlc hsrvc magimetal zerone0x meaningfool + patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc SocialNerd42069 Hyaxia dantelex + daveonkels google-labs-jules[bot] lc0rp adam91holt mousberg hougangdev gumadeiras shakkernerd mteam88 hirefrank + joeynyc orlyjamie Eng. Juan Combetto dbhurley TSavo julianengel bradleypriest benithors rohannagpal elliotsecops + timolins benostein f-trycua nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino + Vasanth Rao Naik Sabavat petter-b thewilloftheshadow scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee + rafaelreis-r nonggialiang dominicnunez lploc94 ratulsarna lutr0 kiranjd danielz1z AdeboyeDN Alg0rix + papago2355 emanuelst evanotero KristijanJovanovski CashWilliams jlowin rdev rhuanssauro osolmaz joshrad-dev + adityashaw2 sheeek ryancontent jasonsschin obviyus artuskg Takhoffman onutc pauloportella HirokiKobayashi-R + ThanhNguyxn yuting0624 neooriginal manuelhettich minghinmatthewlam manikv12 myfunc travisirby buddyh connorshea + kyleok mcinteerj dependabot[bot] amitbiswal007 John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg + azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla YuriNachos badlogic conroywhitney + Josh Phillips pookNast Whoaa512 chriseidhof ngutman ysqander Yurii Chukhlib aj47 kennyklee superman32432432 + grp06 Hisleren antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman + jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse dougvk erikpr1994 fal3 + Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz + Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott + petradonka rubyrunsstuff siddhantjain spiceoogway suminhthanh svkozak VACInc wes-davis zats 24601 + ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi odysseus0 + oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Ubuntu Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba MarvinCui mjrussell odnxe optimikelabs p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp VAC william arzt zknicker 0oAstro abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 - anpoirier araa47 arthyn Asleep123 bguidolim bolismauro chenyuan99 Chloe-VP conhecendoia dasilva333 - David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro - Kevin Lin kira-ariaki kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik - Matt mini mertcicekci0 Miles mrdbstn MSch Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 - Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann - Seredeep sergical shiv19 shiyuanhai siraht snopoke spiceoogway techboss testingabc321 The Admiral - thesash Ubuntu Vibe Kanban voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 yazinsai - YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe - ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh - ronak-guliani William Stock + anpoirier araa47 arthyn Asleep123 Ayush Ojha Ayush10 bguidolim bolismauro championswimmer chenyuan99 + Chloe-VP Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause + foeken frankekn ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane + Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze Kiwitwitter levifig Lloyd + longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mitsuhiko mrdbstn MSch + Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps + RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht + snopoke techboss testingabc321 The Admiral thesash Vibe Kanban voidserf Vultr-Clawd Admin Wimmie wolfred + wstock YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn + Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik + pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 21d85686bc..7dd07974ed 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 +moltbot plugins enable minimax-portal-auth # skip if already loaded. +moltbot gateway restart # restart if gateway is already running +moltbot 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/moltbot/moltbot/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 0000000000..3c29ab8ac2 --- /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 diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts new file mode 100644 index 0000000000..e99509fefa --- /dev/null +++ b/extensions/minimax-portal-auth/index.ts @@ -0,0 +1,152 @@ +import { emptyPluginConfigSchema } from "clawdbot/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: "device_code", + run: createOAuthHandler("global"), + }, + { + id: "oauth-cn", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + kind: "device_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 0000000000..624ecd5cbc --- /dev/null +++ b/extensions/minimax-portal-auth/oauth.ts @@ -0,0 +1,252 @@ +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, + }), + }); + + const text = await response.text(); + let payload: + | { + status?: string; + base_resp?: { status_code?: number; status_msg?: string }; + } + | undefined; + if (text) { + try { + payload = JSON.parse(text) as typeof payload; + } catch { + payload = undefined; + } + } + + if (!response.ok) { + return { + status: "error", + message: + payload?.base_resp?.status_msg ?? + text || + "MiniMax OAuth failed to parse response.", + }; + } + + if (!payload) { + return { status: "error", message: "MiniMax OAuth failed to parse response." }; + } + + const tokenPayload = payload 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 0000000000..ded092e8e4 --- /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 0000000000..696ab26b99 --- /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 eea99e3c06..7c70626393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,6 +340,8 @@ importers: specifier: ^6.17.0 version: 6.17.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 65c2f7a54d..83946ac7ae 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 6b721d4dc8..56ca400cf1 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"; @@ -33,7 +37,7 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu 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") { @@ -42,8 +46,43 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu 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. */ @@ -79,5 +118,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 a8095e0688..19a4fd4b56 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)}`; @@ -200,6 +216,36 @@ 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 { @@ -539,3 +585,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 28f0620690..e55cdd127f 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -277,6 +277,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"); + } + if (normalized === "kimi-coding") { return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY"); } diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 5a04dad120..471c477652 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, @@ -285,6 +287,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, @@ -389,6 +409,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 94cde57497..d98273f49d 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: "moonshot", @@ -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 25a13bc233..562d855bb2 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, @@ -29,6 +30,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, + 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 e24c3dfc5d..861faf5de9 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/auth-choice.test.ts b/src/commands/auth-choice.test.ts index d36423c7ea..5013ccca1d 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -588,6 +588,101 @@ describe("applyAuthChoice", () => { refresh: "refresh", }); }); + + it("writes MiniMax credentials when selecting minimax-portal", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + + resolvePluginProviders.mockReturnValue([ + { + id: "minimax-portal", + label: "MiniMax", + auth: [ + { + id: "oauth", + label: "MiniMax OAuth (Global)", + kind: "device_code", + run: vi.fn(async () => ({ + profiles: [ + { + profileId: "minimax-portal:default", + credential: { + type: "oauth", + provider: "minimax-portal", + access: "access", + refresh: "refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + ], + configPatch: { + models: { + providers: { + "minimax-portal": { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "minimax-oauth", + api: "anthropic-messages", + models: [], + }, + }, + }, + }, + defaultModel: "minimax-portal/MiniMax-M2.1", + })), + }, + ], + }, + ]); + + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "oauth" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "minimax-portal", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(result.config.auth?.profiles?.["minimax-portal:default"]).toMatchObject({ + provider: "minimax-portal", + mode: "oauth", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe("minimax-portal/MiniMax-M2.1"); + expect(result.config.models?.providers?.["minimax-portal"]).toMatchObject({ + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "minimax-oauth", + }); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["minimax-portal:default"]).toMatchObject({ + provider: "minimax-portal", + access: "access", + refresh: "refresh", + }); + }); }); describe("resolvePreferredProviderForAuthChoice", () => { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index f930a7c3d3..dfc36a5cc5 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -432,7 +432,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 f4154bc6d9..f3d72051d4 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 19b422bde0..a7b8184f34 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 {