From 0828db93e92e900420b9249ff22fe46837f5a2fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 17:36:17 +0100 Subject: [PATCH] test: speed up provider entry tests --- extensions/google/index.test.ts | 16 +- extensions/google/index.ts | 61 +--- extensions/google/provider-registration.ts | 62 ++++ extensions/minimax/index.test.ts | 20 +- extensions/minimax/index.ts | 301 +------------------- extensions/minimax/provider-registration.ts | 296 +++++++++++++++++++ extensions/xai/web-search.test.ts | 70 ++--- 7 files changed, 414 insertions(+), 412 deletions(-) create mode 100644 extensions/google/provider-registration.ts create mode 100644 extensions/minimax/provider-registration.ts diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 4774c9fd537..df967852a4f 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -9,12 +9,20 @@ import { registerProviderPlugin, requireRegisteredProvider, } from "../../test/helpers/plugins/provider-registration.js"; -import googlePlugin from "./index.js"; +import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +import { registerGoogleProvider } from "./provider-registration.js"; + +const googleProviderPlugin = { + register(api: Parameters[0]) { + registerGoogleProvider(api); + registerGoogleGeminiCliProvider(api); + }, +}; describe("google provider plugin hooks", () => { it("owns replay policy and reasoning mode for the direct Gemini provider", async () => { const { providers } = await registerProviderPlugin({ - plugin: googlePlugin, + plugin: googleProviderPlugin, id: "google", name: "Google Provider", }); @@ -85,7 +93,7 @@ describe("google provider plugin hooks", () => { it("owns Gemini CLI tool schema normalization", async () => { const { providers } = await registerProviderPlugin({ - plugin: googlePlugin, + plugin: googleProviderPlugin, id: "google", name: "Google Provider", }); @@ -132,7 +140,7 @@ describe("google provider plugin hooks", () => { it("wires google-thinking stream hooks for direct and Gemini CLI providers", async () => { const { providers } = await registerProviderPlugin({ - plugin: googlePlugin, + plugin: googleProviderPlugin, id: "google", name: "Google Provider", }); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 6d66e346720..697abbe8cb5 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -1,20 +1,10 @@ import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; -import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream-family"; -import { - GOOGLE_GEMINI_DEFAULT_MODEL, - applyGoogleGeminiModelDefault, - normalizeGoogleProviderConfig, - normalizeGoogleModelId, - resolveGoogleGenerativeAiTransport, -} from "./api.js"; import { buildGoogleGeminiCliBackend } from "./cli-backend.js"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js"; -import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; +import { registerGoogleProvider } from "./provider-registration.js"; import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; import { buildGoogleVideoGenerationProvider } from "./video-generation-provider.js"; @@ -28,13 +18,6 @@ type GoogleMediaUnderstandingProvider = MediaUnderstandingProvider & { describeVideo: NonNullable; }; -const GOOGLE_GEMINI_PROVIDER_HOOKS = { - ...buildProviderReplayFamilyHooks({ - family: "google-gemini", - }), - ...buildProviderStreamFamilyHooks("google-thinking"), -}; - async function loadGoogleImageGenerationProvider(): Promise { if (!googleImageGenerationProviderPromise) { googleImageGenerationProviderPromise = import("./image-generation-provider.js").then((mod) => @@ -126,47 +109,7 @@ export default definePluginEntry({ register(api) { api.registerCliBackend(buildGoogleGeminiCliBackend()); registerGoogleGeminiCliProvider(api); - api.registerProvider({ - id: "google", - label: "Google AI Studio", - docsPath: "/providers/models", - hookAliases: ["google-antigravity", "google-vertex"], - envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], - auth: [ - createProviderApiKeyAuthMethod({ - providerId: "google", - methodId: "api-key", - label: "Google Gemini API key", - hint: "AI Studio / Gemini API key", - optionKey: "geminiApiKey", - flagName: "--gemini-api-key", - envVar: "GEMINI_API_KEY", - promptMessage: "Enter Gemini API key", - defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL, - expectedProviders: ["google"], - applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next, - wizard: { - choiceId: "gemini-api-key", - choiceLabel: "Google Gemini API key", - groupId: "google", - groupLabel: "Google", - groupHint: "Gemini API key + OAuth", - }, - }), - ], - normalizeTransport: ({ api, baseUrl }) => - resolveGoogleGenerativeAiTransport({ api, baseUrl }), - normalizeConfig: ({ provider, providerConfig }) => - normalizeGoogleProviderConfig(provider, providerConfig), - normalizeModelId: ({ modelId }) => normalizeGoogleModelId(modelId), - resolveDynamicModel: (ctx) => - resolveGoogleGeminiForwardCompatModel({ - providerId: ctx.provider, - ctx, - }), - ...GOOGLE_GEMINI_PROVIDER_HOOKS, - isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), - }); + registerGoogleProvider(api); api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider()); api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider()); api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider()); diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts new file mode 100644 index 00000000000..13b8ad37032 --- /dev/null +++ b/extensions/google/provider-registration.ts @@ -0,0 +1,62 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; +import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream-family"; +import { + GOOGLE_GEMINI_DEFAULT_MODEL, + applyGoogleGeminiModelDefault, + normalizeGoogleProviderConfig, + normalizeGoogleModelId, + resolveGoogleGenerativeAiTransport, +} from "./api.js"; +import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; + +const GOOGLE_GEMINI_PROVIDER_HOOKS = { + ...buildProviderReplayFamilyHooks({ + family: "google-gemini", + }), + ...buildProviderStreamFamilyHooks("google-thinking"), +}; + +export function registerGoogleProvider(api: OpenClawPluginApi) { + api.registerProvider({ + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + hookAliases: ["google-antigravity", "google-vertex"], + envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: "google", + methodId: "api-key", + label: "Google Gemini API key", + hint: "AI Studio / Gemini API key", + optionKey: "geminiApiKey", + flagName: "--gemini-api-key", + envVar: "GEMINI_API_KEY", + promptMessage: "Enter Gemini API key", + defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL, + expectedProviders: ["google"], + applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next, + wizard: { + choiceId: "gemini-api-key", + choiceLabel: "Google Gemini API key", + groupId: "google", + groupLabel: "Google", + groupHint: "Gemini API key + OAuth", + }, + }), + ], + normalizeTransport: ({ api, baseUrl }) => resolveGoogleGenerativeAiTransport({ api, baseUrl }), + normalizeConfig: ({ provider, providerConfig }) => + normalizeGoogleProviderConfig(provider, providerConfig), + normalizeModelId: ({ modelId }) => normalizeGoogleModelId(modelId), + resolveDynamicModel: (ctx) => + resolveGoogleGeminiForwardCompatModel({ + providerId: ctx.provider, + ctx, + }), + ...GOOGLE_GEMINI_PROVIDER_HOOKS, + isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), + }); +} diff --git a/extensions/minimax/index.test.ts b/extensions/minimax/index.test.ts index 3410fc64d01..786e2b8323c 100644 --- a/extensions/minimax/index.test.ts +++ b/extensions/minimax/index.test.ts @@ -5,12 +5,20 @@ import { registerProviderPlugin, requireRegisteredProvider, } from "../../test/helpers/plugins/provider-registration.js"; -import minimaxPlugin from "./index.js"; +import { registerMinimaxProviders } from "./provider-registration.js"; +import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js"; + +const minimaxProviderPlugin = { + register(api: Parameters[0]) { + registerMinimaxProviders(api); + api.registerWebSearchProvider(createMiniMaxWebSearchProvider()); + }, +}; describe("minimax provider hooks", () => { it("keeps native reasoning mode for MiniMax transports", async () => { const { providers } = await registerProviderPlugin({ - plugin: minimaxPlugin, + plugin: minimaxProviderPlugin, id: "minimax", name: "MiniMax Provider", }); @@ -38,7 +46,7 @@ describe("minimax provider hooks", () => { it("owns replay policy for Anthropic and OpenAI-compatible MiniMax transports", async () => { const { providers } = await registerProviderPlugin({ - plugin: minimaxPlugin, + plugin: minimaxProviderPlugin, id: "minimax", name: "MiniMax Provider", }); @@ -75,7 +83,7 @@ describe("minimax provider hooks", () => { it("owns fast-mode stream wrapping for MiniMax transports", async () => { const { providers } = await registerProviderPlugin({ - plugin: minimaxPlugin, + plugin: minimaxProviderPlugin, id: "minimax", name: "MiniMax Provider", }); @@ -133,7 +141,7 @@ describe("minimax provider hooks", () => { it("registers the bundled MiniMax web search provider", () => { const webSearchProviders: unknown[] = []; - minimaxPlugin.register({ + minimaxProviderPlugin.register({ registerProvider() {}, registerMediaUnderstandingProvider() {}, registerImageGenerationProvider() {}, @@ -155,7 +163,7 @@ describe("minimax provider hooks", () => { it("prefers minimax-portal oauth when resolving MiniMax usage auth", async () => { const { providers } = await registerProviderPlugin({ - plugin: minimaxPlugin, + plugin: minimaxProviderPlugin, id: "minimax", name: "MiniMax Provider", }); diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 2adf627c879..0087553af1e 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,21 +1,4 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - definePluginEntry, - type ProviderAuthContext, - type ProviderAuthResult, - type ProviderCatalogContext, -} from "openclaw/plugin-sdk/plugin-entry"; -import { - MINIMAX_OAUTH_MARKER, - ensureAuthProfileStore, - listProfilesForProvider, -} from "openclaw/plugin-sdk/provider-auth"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; -import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream-family"; -import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; -import { isMiniMaxModernModelId, MINIMAX_DEFAULT_MODEL_ID } from "./api.js"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { buildMinimaxImageGenerationProvider, buildMinimaxPortalImageGenerationProvider, @@ -25,295 +8,19 @@ import { minimaxPortalMediaUnderstandingProvider, } from "./media-understanding-provider.js"; import { buildMinimaxMusicGenerationProvider } from "./music-generation-provider.js"; -import type { MiniMaxRegion } from "./oauth.js"; -import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; -import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; +import { registerMinimaxProviders } from "./provider-registration.js"; import { buildMinimaxSpeechProvider } from "./speech-provider.js"; import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js"; import { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js"; -const API_PROVIDER_ID = "minimax"; -const PORTAL_PROVIDER_ID = "minimax-portal"; -const PROVIDER_LABEL = "MiniMax"; -const DEFAULT_MODEL = MINIMAX_DEFAULT_MODEL_ID; -const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; -const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; -const MINIMAX_USAGE_ENV_VAR_KEYS = [ - "MINIMAX_OAUTH_TOKEN", - "MINIMAX_CODE_PLAN_KEY", - "MINIMAX_CODING_API_KEY", - "MINIMAX_API_KEY", -] as const; -const HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS = buildProviderReplayFamilyHooks({ - family: "hybrid-anthropic-openai", - anthropicModelDropThinkingBlocks: true, -}); -const MINIMAX_FAST_MODE_STREAM_HOOKS = buildProviderStreamFamilyHooks("minimax-fast-mode"); - -function resolveMinimaxReasoningOutputMode(): "native" { - // Keep MiniMax on native reasoning mode. Tagged enforcement previously - // suppressed normal assistant replies on this Anthropic-compatible surface. - return "native"; -} - -function getDefaultBaseUrl(region: MiniMaxRegion): string { - return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; -} - -function apiModelRef(modelId: string): string { - return `${API_PROVIDER_ID}/${modelId}`; -} - -function portalModelRef(modelId: string): string { - return `${PORTAL_PROVIDER_ID}/${modelId}`; -} - -function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { - return { - ...buildMinimaxPortalProvider(), - baseUrl: params.baseUrl, - apiKey: params.apiKey, - }; -} - -function resolveApiCatalog(ctx: ProviderCatalogContext) { - const apiKey = ctx.resolveProviderApiKey(API_PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildMinimaxProvider(ctx.env), - apiKey, - }, - }; -} - -function resolvePortalCatalog(ctx: ProviderCatalogContext) { - const explicitProvider = ctx.config.models?.providers?.[PORTAL_PROVIDER_ID]; - const envApiKey = ctx.resolveProviderApiKey(PORTAL_PROVIDER_ID).apiKey; - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const hasProfiles = listProfilesForProvider(authStore, PORTAL_PROVIDER_ID).length > 0; - const explicitApiKey = - typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; - const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); - if (!apiKey) { - return null; - } - - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; - - return { - provider: buildPortalProviderCatalog({ - baseUrl: explicitBaseUrl || buildMinimaxPortalProvider(ctx.env).baseUrl, - apiKey, - }), - }; -} - -function createOAuthHandler(region: MiniMaxRegion) { - const defaultBaseUrl = getDefaultBaseUrl(region); - const regionLabel = region === "cn" ? "CN" : "Global"; - - return async (ctx: ProviderAuthContext): Promise => { - const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); - try { - const { loginMiniMaxPortalOAuth } = await import("./oauth.runtime.js"); - 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 baseUrl = result.resourceUrl || defaultBaseUrl; - - return buildOauthProviderAuthResult({ - providerId: PORTAL_PROVIDER_ID, - defaultModel: portalModelRef(DEFAULT_MODEL), - access: result.access, - refresh: result.refresh, - expires: result.expires, - configPatch: { - models: { - providers: { - [PORTAL_PROVIDER_ID]: { - baseUrl, - models: [], - }, - }, - }, - agents: { - defaults: { - models: { - [portalModelRef("MiniMax-M2.7")]: { alias: "minimax-m2.7" }, - [portalModelRef("MiniMax-M2.7-highspeed")]: { - alias: "minimax-m2.7-highspeed", - }, - }, - }, - }, - }, - notes: [ - "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", - `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PORTAL_PROVIDER_ID}.baseUrl if needed.`, - ...(result.notification_message ? [result.notification_message] : []), - ], - }); - } catch (err) { - const errorMsg = formatErrorMessage(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; - } - }; -} - export default definePluginEntry({ - id: API_PROVIDER_ID, + id: "minimax", name: "MiniMax", description: "Bundled MiniMax API-key and OAuth provider plugin", register(api) { - api.registerProvider({ - id: API_PROVIDER_ID, - label: PROVIDER_LABEL, - hookAliases: ["minimax-cn"], - docsPath: "/providers/minimax", - envVars: ["MINIMAX_API_KEY"], - auth: [ - createProviderApiKeyAuthMethod({ - providerId: API_PROVIDER_ID, - methodId: "api-global", - label: "MiniMax API key (Global)", - hint: "Global endpoint - api.minimax.io", - optionKey: "minimaxApiKey", - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - promptMessage: - "Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key", - profileId: "minimax:global", - allowProfile: false, - defaultModel: apiModelRef(DEFAULT_MODEL), - expectedProviders: ["minimax"], - applyConfig: (cfg) => applyMinimaxApiConfig(cfg), - wizard: { - choiceId: "minimax-global-api", - choiceLabel: "MiniMax API key (Global)", - choiceHint: "Global endpoint - api.minimax.io", - groupId: "minimax", - groupLabel: "MiniMax", - groupHint: "M2.7 (recommended)", - }, - }), - createProviderApiKeyAuthMethod({ - providerId: API_PROVIDER_ID, - methodId: "api-cn", - label: "MiniMax API key (CN)", - hint: "CN endpoint - api.minimaxi.com", - optionKey: "minimaxApiKey", - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - promptMessage: - "Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key", - profileId: "minimax:cn", - allowProfile: false, - defaultModel: apiModelRef(DEFAULT_MODEL), - expectedProviders: ["minimax", "minimax-cn"], - applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg), - wizard: { - choiceId: "minimax-cn-api", - choiceLabel: "MiniMax API key (CN)", - choiceHint: "CN endpoint - api.minimaxi.com", - groupId: "minimax", - groupLabel: "MiniMax", - groupHint: "M2.7 (recommended)", - }, - }), - ], - catalog: { - order: "simple", - run: async (ctx) => resolveApiCatalog(ctx), - }, - resolveUsageAuth: async (ctx) => { - const portalOauth = await ctx.resolveOAuthToken({ provider: PORTAL_PROVIDER_ID }); - if (portalOauth) { - return portalOauth; - } - const apiKey = ctx.resolveApiKeyFromConfigAndStore({ - providerIds: [API_PROVIDER_ID, PORTAL_PROVIDER_ID], - envDirect: MINIMAX_USAGE_ENV_VAR_KEYS.map((name) => ctx.env[name]), - }); - return apiKey ? { token: apiKey } : null; - }, - ...HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS, - ...MINIMAX_FAST_MODE_STREAM_HOOKS, - resolveReasoningOutputMode: () => resolveMinimaxReasoningOutputMode(), - isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId), - fetchUsageSnapshot: async (ctx) => - await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), - }); - + registerMinimaxProviders(api); api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider); api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider); - - api.registerProvider({ - id: PORTAL_PROVIDER_ID, - label: PROVIDER_LABEL, - hookAliases: ["minimax-portal-cn"], - docsPath: "/providers/minimax", - envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], - catalog: { - run: async (ctx) => resolvePortalCatalog(ctx), - }, - auth: [ - { - id: "oauth", - label: "MiniMax OAuth (Global)", - hint: "Global endpoint - api.minimax.io", - kind: "device_code", - wizard: { - choiceId: "minimax-global-oauth", - choiceLabel: "MiniMax OAuth (Global)", - choiceHint: "Global endpoint - api.minimax.io", - groupId: "minimax", - groupLabel: "MiniMax", - groupHint: "M2.7 (recommended)", - }, - run: createOAuthHandler("global"), - }, - { - id: "oauth-cn", - label: "MiniMax OAuth (CN)", - hint: "CN endpoint - api.minimaxi.com", - kind: "device_code", - wizard: { - choiceId: "minimax-cn-oauth", - choiceLabel: "MiniMax OAuth (CN)", - choiceHint: "CN endpoint - api.minimaxi.com", - groupId: "minimax", - groupLabel: "MiniMax", - groupHint: "M2.7 (recommended)", - }, - run: createOAuthHandler("cn"), - }, - ], - ...HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS, - ...MINIMAX_FAST_MODE_STREAM_HOOKS, - resolveReasoningOutputMode: () => resolveMinimaxReasoningOutputMode(), - isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId), - }); api.registerImageGenerationProvider(buildMinimaxImageGenerationProvider()); api.registerImageGenerationProvider(buildMinimaxPortalImageGenerationProvider()); api.registerMusicGenerationProvider(buildMinimaxMusicGenerationProvider()); diff --git a/extensions/minimax/provider-registration.ts b/extensions/minimax/provider-registration.ts new file mode 100644 index 00000000000..4a263b3b09e --- /dev/null +++ b/extensions/minimax/provider-registration.ts @@ -0,0 +1,296 @@ +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthResult, + ProviderCatalogContext, +} from "openclaw/plugin-sdk/plugin-entry"; +import { + MINIMAX_OAUTH_MARKER, + ensureAuthProfileStore, + listProfilesForProvider, +} from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; +import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream-family"; +import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; +import { isMiniMaxModernModelId, MINIMAX_DEFAULT_MODEL_ID } from "./api.js"; +import type { MiniMaxRegion } from "./oauth.js"; +import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; +import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; + +const API_PROVIDER_ID = "minimax"; +const PORTAL_PROVIDER_ID = "minimax-portal"; +const PROVIDER_LABEL = "MiniMax"; +const DEFAULT_MODEL = MINIMAX_DEFAULT_MODEL_ID; +const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; +const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; +const MINIMAX_USAGE_ENV_VAR_KEYS = [ + "MINIMAX_OAUTH_TOKEN", + "MINIMAX_CODE_PLAN_KEY", + "MINIMAX_CODING_API_KEY", + "MINIMAX_API_KEY", +] as const; +const HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS = buildProviderReplayFamilyHooks({ + family: "hybrid-anthropic-openai", + anthropicModelDropThinkingBlocks: true, +}); +const MINIMAX_FAST_MODE_STREAM_HOOKS = buildProviderStreamFamilyHooks("minimax-fast-mode"); + +function resolveMinimaxReasoningOutputMode(): "native" { + return "native"; +} + +function getDefaultBaseUrl(region: MiniMaxRegion): string { + return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; +} + +function apiModelRef(modelId: string): string { + return `${API_PROVIDER_ID}/${modelId}`; +} + +function portalModelRef(modelId: string): string { + return `${PORTAL_PROVIDER_ID}/${modelId}`; +} + +function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { + return { + ...buildMinimaxPortalProvider(), + baseUrl: params.baseUrl, + apiKey: params.apiKey, + }; +} + +function resolveApiCatalog(ctx: ProviderCatalogContext) { + const apiKey = ctx.resolveProviderApiKey(API_PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildMinimaxProvider(ctx.env), + apiKey, + }, + }; +} + +function resolvePortalCatalog(ctx: ProviderCatalogContext) { + const explicitProvider = ctx.config.models?.providers?.[PORTAL_PROVIDER_ID]; + const envApiKey = ctx.resolveProviderApiKey(PORTAL_PROVIDER_ID).apiKey; + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const hasProfiles = listProfilesForProvider(authStore, PORTAL_PROVIDER_ID).length > 0; + const explicitApiKey = + typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; + const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); + if (!apiKey) { + return null; + } + + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; + + return { + provider: buildPortalProviderCatalog({ + baseUrl: explicitBaseUrl || buildMinimaxPortalProvider(ctx.env).baseUrl, + apiKey, + }), + }; +} + +function createOAuthHandler(region: MiniMaxRegion) { + const defaultBaseUrl = getDefaultBaseUrl(region); + const regionLabel = region === "cn" ? "CN" : "Global"; + + return async (ctx: ProviderAuthContext): Promise => { + const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); + try { + const { loginMiniMaxPortalOAuth } = await import("./oauth.runtime.js"); + 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 baseUrl = result.resourceUrl || defaultBaseUrl; + + return buildOauthProviderAuthResult({ + providerId: PORTAL_PROVIDER_ID, + defaultModel: portalModelRef(DEFAULT_MODEL), + access: result.access, + refresh: result.refresh, + expires: result.expires, + configPatch: { + models: { + providers: { + [PORTAL_PROVIDER_ID]: { + baseUrl, + models: [], + }, + }, + }, + agents: { + defaults: { + models: { + [portalModelRef("MiniMax-M2.7")]: { alias: "minimax-m2.7" }, + [portalModelRef("MiniMax-M2.7-highspeed")]: { + alias: "minimax-m2.7-highspeed", + }, + }, + }, + }, + }, + notes: [ + "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PORTAL_PROVIDER_ID}.baseUrl if needed.`, + ...(result.notification_message ? [result.notification_message] : []), + ], + }); + } catch (err) { + const errorMsg = formatErrorMessage(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; + } + }; +} + +export function registerMinimaxProviders(api: OpenClawPluginApi) { + api.registerProvider({ + id: API_PROVIDER_ID, + label: PROVIDER_LABEL, + hookAliases: ["minimax-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_API_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: API_PROVIDER_ID, + methodId: "api-global", + label: "MiniMax API key (Global)", + hint: "Global endpoint - api.minimax.io", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: + "Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key", + profileId: "minimax:global", + allowProfile: false, + defaultModel: apiModelRef(DEFAULT_MODEL), + expectedProviders: ["minimax"], + applyConfig: (cfg) => applyMinimaxApiConfig(cfg), + wizard: { + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + choiceHint: "Global endpoint - api.minimax.io", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", + }, + }), + createProviderApiKeyAuthMethod({ + providerId: API_PROVIDER_ID, + methodId: "api-cn", + label: "MiniMax API key (CN)", + hint: "CN endpoint - api.minimaxi.com", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: + "Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key", + profileId: "minimax:cn", + allowProfile: false, + defaultModel: apiModelRef(DEFAULT_MODEL), + expectedProviders: ["minimax", "minimax-cn"], + applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg), + wizard: { + choiceId: "minimax-cn-api", + choiceLabel: "MiniMax API key (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", + }, + }), + ], + catalog: { + order: "simple", + run: async (ctx) => resolveApiCatalog(ctx), + }, + resolveUsageAuth: async (ctx) => { + const portalOauth = await ctx.resolveOAuthToken({ provider: PORTAL_PROVIDER_ID }); + if (portalOauth) { + return portalOauth; + } + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + providerIds: [API_PROVIDER_ID, PORTAL_PROVIDER_ID], + envDirect: MINIMAX_USAGE_ENV_VAR_KEYS.map((name) => ctx.env[name]), + }); + return apiKey ? { token: apiKey } : null; + }, + ...HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS, + ...MINIMAX_FAST_MODE_STREAM_HOOKS, + resolveReasoningOutputMode: () => resolveMinimaxReasoningOutputMode(), + isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId), + fetchUsageSnapshot: async (ctx) => + await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), + }); + + api.registerProvider({ + id: PORTAL_PROVIDER_ID, + label: PROVIDER_LABEL, + hookAliases: ["minimax-portal-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + catalog: { + run: async (ctx) => resolvePortalCatalog(ctx), + }, + auth: [ + { + id: "oauth", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + kind: "device_code", + wizard: { + choiceId: "minimax-global-oauth", + choiceLabel: "MiniMax OAuth (Global)", + choiceHint: "Global endpoint - api.minimax.io", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", + }, + run: createOAuthHandler("global"), + }, + { + id: "oauth-cn", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + kind: "device_code", + wizard: { + choiceId: "minimax-cn-oauth", + choiceLabel: "MiniMax OAuth (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", + }, + run: createOAuthHandler("cn"), + }, + ], + ...HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS, + ...MINIMAX_FAST_MODE_STREAM_HOOKS, + resolveReasoningOutputMode: () => resolveMinimaxReasoningOutputMode(), + isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId), + }); +} diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 9681a6fdecf..b5f732236fa 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -2,11 +2,10 @@ import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runt import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env"; import { withEnv } from "openclaw/plugin-sdk/testing"; import { describe, expect, it, vi } from "vitest"; -import { capturePluginRegistration } from "../../src/plugins/captured-registration.js"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; -import xaiPlugin from "./index.js"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; +import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; import { __testing, createXaiWebSearchProvider } from "./web-search.js"; const { @@ -188,84 +187,63 @@ describe("xai web search config resolution", () => { }); it("reuses the plugin web search api key for provider auth fallback", () => { - const captured = capturePluginRegistration(xaiPlugin); - const provider = captured.providers[0]; expect( - provider?.resolveSyntheticAuth?.({ - config: { - plugins: { - entries: { - xai: { - config: { - webSearch: { - apiKey: "xai-provider-fallback", // pragma: allowlist secret - }, + resolveFallbackXaiAuth({ + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-provider-fallback", // pragma: allowlist secret }, }, }, }, }, - provider: "xai", - providerConfig: undefined, - }), + } as never), ).toEqual({ apiKey: "xai-provider-fallback", source: "plugins.entries.xai.config.webSearch.apiKey", - mode: "api-key", }); }); it("reuses the legacy grok web search api key for provider auth fallback", () => { - const captured = capturePluginRegistration(xaiPlugin); - const provider = captured.providers[0]; expect( - provider?.resolveSyntheticAuth?.({ - config: { - tools: { - web: { - search: { - grok: { - apiKey: "xai-legacy-fallback", // pragma: allowlist secret - }, + resolveFallbackXaiAuth({ + tools: { + web: { + search: { + grok: { + apiKey: "xai-legacy-fallback", // pragma: allowlist secret }, }, }, }, - provider: "xai", - providerConfig: undefined, - }), + } as never), ).toEqual({ apiKey: "xai-legacy-fallback", source: "tools.web.search.grok.apiKey", - mode: "api-key", }); }); it("returns a managed marker for SecretRef-backed plugin auth fallback", () => { - const captured = capturePluginRegistration(xaiPlugin); - const provider = captured.providers[0]; expect( - provider?.resolveSyntheticAuth?.({ - config: { - plugins: { - entries: { - xai: { - config: { - webSearch: { - apiKey: { source: "file", provider: "vault", id: "/xai/api-key" }, - }, + resolveFallbackXaiAuth({ + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: { source: "file", provider: "vault", id: "/xai/api-key" }, }, }, }, }, }, - provider: "xai", - providerConfig: undefined, - }), + } as never), ).toEqual({ apiKey: NON_ENV_SECRETREF_MARKER, source: "plugins.entries.xai.config.webSearch.apiKey", - mode: "api-key", }); });