From ec2306371f624400bbb996a0483401405f1d7f64 Mon Sep 17 00:00:00 2001 From: xiaose Date: Tue, 27 Jan 2026 15:33:41 +0800 Subject: [PATCH] feat: minimax oauth --- extensions/minimax-portal-auth/README.md | 24 +++ .../minimax-portal-auth/clawdbot.plugin.json | 11 + extensions/minimax-portal-auth/index.ts | 126 ++++++++++++ extensions/minimax-portal-auth/oauth.ts | 190 ++++++++++++++++++ src/agents/model-auth.ts | 4 + src/commands/auth-choice-options.ts | 3 +- src/commands/auth-choice.apply.minimax.ts | 10 + src/commands/onboard-types.ts | 1 + 8 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 extensions/minimax-portal-auth/README.md create mode 100644 extensions/minimax-portal-auth/clawdbot.plugin.json create mode 100644 extensions/minimax-portal-auth/index.ts create mode 100644 extensions/minimax-portal-auth/oauth.ts diff --git a/extensions/minimax-portal-auth/README.md b/extensions/minimax-portal-auth/README.md new file mode 100644 index 000000000..d0346f8f5 --- /dev/null +++ b/extensions/minimax-portal-auth/README.md @@ -0,0 +1,24 @@ +# MiniMax OAuth (Clawdbot plugin) + +OAuth provider plugin for **MiniMax** (free-tier OAuth). + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +clawdbot plugins enable minimax-portal-auth +``` + +Restart the Gateway after enabling. + +## Authenticate + +```bash +clawdbot models auth login --provider minimax-portal --set-default +``` + +## Notes + +- MiniMax OAuth uses a device-code login flow. +- Tokens auto-refresh; re-run login if refresh fails or access is revoked. diff --git a/extensions/minimax-portal-auth/clawdbot.plugin.json b/extensions/minimax-portal-auth/clawdbot.plugin.json new file mode 100644 index 000000000..ded092e8e --- /dev/null +++ b/extensions/minimax-portal-auth/clawdbot.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/index.ts b/extensions/minimax-portal-auth/index.ts new file mode 100644 index 000000000..b8844bde2 --- /dev/null +++ b/extensions/minimax-portal-auth/index.ts @@ -0,0 +1,126 @@ +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { loginMiniMaxPortalOAuth } from "./oauth.js"; + +const PROVIDER_ID = "minimax-portal"; +const PROVIDER_LABEL = "MiniMax"; +const DEFAULT_MODEL = "MiniMax-M2.1"; +const DEFAULT_BASE_URL = "https://api.minimax.io/v1"; +const DEFAULT_CONTEXT_WINDOW = 200000; +const DEFAULT_MAX_TOKENS = 8192; +const OAUTH_PLACEHOLDER = "minimax-portal-oauth"; + +function normalizeBaseUrl(value: string | undefined): string { + const raw = value?.trim() || DEFAULT_BASE_URL; + const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`; + return withProtocol.endsWith("/v1") ? withProtocol : `${withProtocol.replace(/\/+$/, "")}/v1`; +} + +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, + }; +} + +const minimaxPortalPlugin = { + id: "minimax-portal-auth", + name: "MiniMax OAuth", + description: "OAuth flow for MiniMax (free-tier) models", + configSchema: emptyPluginConfigSchema(), + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/minimax", + aliases: ["minimax"], + auth: [ + { + id: "device", + label: "MiniMax OAuth", + hint: "Device code login", + kind: "device_code", + run: async (ctx) => { + const progress = ctx.prompter.progress("Starting MiniMax OAuth…"); + try { + const result = await loginMiniMaxPortalOAuth({ + openUrl: ctx.openUrl, + note: ctx.prompter.note, + progress, + }); + + progress.stop("MiniMax OAuth complete"); + + const profileId = `${PROVIDER_ID}:default`; + const baseUrl = normalizeBaseUrl(result.resourceUrl); + + return { + profiles: [ + { + profileId, + credential: { + type: "oauth", + 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: { + "MiniMax-M2.1": { alias: "minimax" }, + }, + }, + }, + }, + defaultModel: DEFAULT_MODEL, + notes: [ + "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, + ], + }; + } catch (err) { + progress.stop("MiniMax OAuth failed"); + await ctx.prompter.note( + "If OAuth fails, verify your MiniMax account has portal access and try again.", + "MiniMax OAuth", + ); + throw err; + } + }, + }, + ], + }); + }, +}; + +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..c4646d8f4 --- /dev/null +++ b/extensions/minimax-portal-auth/oauth.ts @@ -0,0 +1,190 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +const MINIMAX_OAUTH_BASE_URL = "https://api.minimax.io"; +const MINIMAX_OAUTH_DEVICE_CODE_ENDPOINT = `${MINIMAX_OAUTH_BASE_URL}/oauth/code`; +const MINIMAX_OAUTH_TOKEN_ENDPOINT = `${MINIMAX_OAUTH_BASE_URL}/oauth/token`; +const MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"; +const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"; +const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"; + +export type MiniMaxDeviceAuthorization = { + user_code: string; + verification_uri: string; + expires_in: number; + interval?: number; + has_benefit: boolean; + benefit_message: string; +}; + +export type MiniMaxOAuthToken = { + access: string; + refresh: string; + expires: number; + resourceUrl?: string; +}; + +type TokenPending = { status: "pending"; slowDown?: boolean }; + +type DeviceTokenResult = + | { 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 } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +async function requestDeviceCode(params: { challenge: string }): Promise { + const response = await fetch(MINIMAX_OAUTH_DEVICE_CODE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "x-request-id": randomUUID(), + }, + body: toFormUrlEncoded({ + client_id: MINIMAX_OAUTH_CLIENT_ID, + scope: MINIMAX_OAUTH_SCOPE, + code_challenge: params.challenge, + code_challenge_method: "S256", + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`MiniMax device authorization failed: ${text || response.statusText}`); + } + + const payload = (await response.json()) as MiniMaxDeviceAuthorization & { error?: string }; + if (!payload.user_code || !payload.verification_uri) { + throw new Error( + payload.error ?? + "MiniMax device authorization returned an incomplete payload (missing user_code or verification_uri).", + ); + } + return payload; +} + +async function pollDeviceToken(params: { + userCode: string; + verifier: string; +}): Promise { + const response = await fetch(MINIMAX_OAUTH_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: toFormUrlEncoded({ + grant_type: MINIMAX_OAUTH_GRANT_TYPE, + client_id: MINIMAX_OAUTH_CLIENT_ID, + user_code: params.userCode, + code_verifier: params.verifier, + }), + }); + + if (!response.ok) { + let payload: { error?: string; error_description?: string } | undefined; + try { + payload = (await response.json()) as { error?: string; error_description?: string }; + } catch { + const text = await response.text(); + return { status: "error", message: text || response.statusText }; + } + + if (payload?.error === "authorization_pending") { + return { status: "pending" }; + } + + if (payload?.error === "slow_down") { + return { status: "pending", slowDown: true }; + } + + return { + status: "error", + message: payload?.error_description || payload?.error || response.statusText, + }; + } + + const tokenPayload = (await response.json()) as { + access_token?: string | null; + refresh_token?: string | null; + expires_in?: number | null; + token_type?: string; + resource_url?: string; + }; + + if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expires_in) { + return { status: "error", message: "MiniMax OAuth returned incomplete token payload." }; + } + + return { + status: "success", + token: { + access: tokenPayload.access_token, + refresh: tokenPayload.refresh_token, + expires: Date.now() + tokenPayload.expires_in * 1000, + resourceUrl: tokenPayload.resource_url, + }, + }; +} + +export async function loginMiniMaxPortalOAuth(params: { + openUrl: (url: string) => Promise; + note: (message: string, title?: string) => Promise; + progress: { update: (message: string) => void; stop: (message?: string) => void }; +}): Promise { + const { verifier, challenge } = generatePkce(); + const device = await requestDeviceCode({ challenge }); + const verificationUrl = device.verification_uri; + + await params.note( + [ + `Open ${verificationUrl} to approve access.`, + `If prompted, enter the code ${device.user_code}.`, + ].join("\n"), + "MiniMax OAuth", + ); + + try { + await params.openUrl(verificationUrl); + } catch { + // Fall back to manual copy/paste if browser open fails. + } + + const start = Date.now(); + let pollIntervalMs = device.interval ? device.interval * 1000 : 2000; + const timeoutMs = device.expires_in * 1000; + + while (Date.now() - start < timeoutMs) { + params.progress.update("Waiting for MiniMax OAuth approval…"); + const result = await pollDeviceToken({ + userCode: device.user_code, + verifier, + }); + + if (result.status === "success") { + return result.token; + } + + if (result.status === "error") { + throw new Error(`MiniMax OAuth failed: ${result.message}`); + } + + if (result.status === "pending" && result.slowDown) { + 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/src/agents/model-auth.ts b/src/agents/model-auth.ts index 680d0f53c..905745008 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/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index f13eef365..53bf36819 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -53,7 +53,7 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "minimax", label: "MiniMax", hint: "M2.1 (recommended)", - choices: ["minimax-api", "minimax-api-lightning"], + choices: ["minimax-api", "minimax-api-lightning","minimax-portal"], }, { value: "qwen", @@ -219,6 +219,7 @@ export function buildAuthChoiceOptions(params: { hint: "Uses the bundled Gemini CLI auth plugin", }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); + options.push({ value: "minimax-portal", label: "MiniMax OAuth" }); 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..eab5d9423 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,15 @@ export async function applyAuthChoiceMiniMax( "Model configured", ); }; + if (params.authChoice === "minimax-portal") { + return await applyAuthChoicePluginProvider(params, { + authChoice: "minimax-portal", + pluginId: "minimax-portal-auth", + providerId: "minimax-portal", + methodId: "device", + label: "MiniMax", + }); + } if ( params.authChoice === "minimax-cloud" || diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 84c15afc4..5d28bd073 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -27,6 +27,7 @@ export type AuthChoice = | "minimax" | "minimax-api" | "minimax-api-lightning" + | "minimax-portal" | "opencode-zen" | "github-copilot" | "copilot-proxy"