feat(tools): add MiniMax as bundled web search provider

Add native MiniMax Search integration via their Coding Plan search API
(POST /v1/coding_plan/search). This brings MiniMax in line with Brave,
Kimi, Grok, Gemini, and other providers that already have bundled web
search support.

- Implement WebSearchProviderPlugin with caching, credential resolution,
  and trusted endpoint wrapping
- Support both global (api.minimax.io) and CN (api.minimaxi.com)
  endpoints, inferred from explicit region config, model provider base
  URL, or minimax-portal OAuth base URL
- Prefer MINIMAX_CODE_PLAN_KEY over MINIMAX_API_KEY in credential
  fallback, matching existing repo precedence
- Accept SecretRef objects for webSearch.apiKey (type: [string, object])
- Register in bundled registry, provider-id compat map, and fast-path
  plugin id list with full alignment test coverage
- Add unit tests for endpoint/region resolution and edge cases

Closes #47927
Related #11399

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jithendra
2026-03-25 18:41:35 -04:00
committed by Peter Steinberger
parent a722719720
commit d204be80af
18 changed files with 798 additions and 66 deletions

View File

@@ -129,4 +129,25 @@ describe("minimax provider hooks", () => {
expect(resolvedApiModelId).toBe("MiniMax-M2.7-highspeed");
expect(resolvedPortalModelId).toBe("MiniMax-M2.7-highspeed");
});
it("registers the bundled MiniMax web search provider", () => {
const webSearchProviders: unknown[] = [];
minimaxPlugin.register({
registerProvider() {},
registerMediaUnderstandingProvider() {},
registerImageGenerationProvider() {},
registerSpeechProvider() {},
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);
},
} as never);
expect(webSearchProviders).toHaveLength(1);
expect(webSearchProviders[0]).toMatchObject({
id: "minimax",
label: "MiniMax Search",
envVars: ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"],
});
});
});

View File

@@ -27,6 +27,7 @@ import type { MiniMaxRegion } from "./oauth.js";
import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js";
import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js";
import { buildMinimaxSpeechProvider } from "./speech-provider.js";
import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
const API_PROVIDER_ID = "minimax";
const PORTAL_PROVIDER_ID = "minimax-portal";
@@ -237,7 +238,11 @@ export default definePluginEntry({
},
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
envDirect: [ctx.env.MINIMAX_CODE_PLAN_KEY, ctx.env.MINIMAX_API_KEY],
envDirect: [
ctx.env.MINIMAX_CODE_PLAN_KEY,
ctx.env.MINIMAX_CODING_API_KEY,
ctx.env.MINIMAX_API_KEY,
],
});
return apiKey ? { token: apiKey } : null;
},
@@ -303,5 +308,6 @@ export default definePluginEntry({
api.registerImageGenerationProvider(buildMinimaxImageGenerationProvider());
api.registerImageGenerationProvider(buildMinimaxPortalImageGenerationProvider());
api.registerSpeechProvider(buildMinimaxSpeechProvider());
api.registerWebSearchProvider(createMiniMaxWebSearchProvider());
},
});

View File

@@ -63,11 +63,38 @@
"contracts": {
"speechProviders": ["minimax"],
"mediaUnderstandingProviders": ["minimax", "minimax-portal"],
"imageGenerationProviders": ["minimax", "minimax-portal"]
"imageGenerationProviders": ["minimax", "minimax-portal"],
"webSearchProviders": ["minimax"]
},
"uiHints": {
"webSearch.apiKey": {
"label": "MiniMax Coding Plan key",
"help": "MiniMax Coding Plan key (fallback: MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY if it already points at a coding-plan token).",
"sensitive": true,
"placeholder": "sk-cp-..."
},
"webSearch.region": {
"label": "MiniMax Search Region",
"help": "Search endpoint region override. Leave unset to reuse your configured MiniMax host or MINIMAX_API_HOST."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"region": {
"type": "string",
"enum": ["global", "cn"]
}
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __testing } from "./minimax-web-search-provider.js";
const {
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
MINIMAX_SEARCH_ENDPOINT_CN,
resolveMiniMaxApiKey,
resolveMiniMaxEndpoint,
resolveMiniMaxRegion,
} = __testing;
describe("minimax web search provider", () => {
const originalApiHost = process.env.MINIMAX_API_HOST;
const originalCodePlanKey = process.env.MINIMAX_CODE_PLAN_KEY;
const originalCodingApiKey = process.env.MINIMAX_CODING_API_KEY;
const originalApiKey = process.env.MINIMAX_API_KEY;
beforeEach(() => {
delete process.env.MINIMAX_API_HOST;
delete process.env.MINIMAX_CODE_PLAN_KEY;
delete process.env.MINIMAX_CODING_API_KEY;
delete process.env.MINIMAX_API_KEY;
});
afterEach(() => {
process.env.MINIMAX_API_HOST = originalApiHost;
process.env.MINIMAX_CODE_PLAN_KEY = originalCodePlanKey;
process.env.MINIMAX_CODING_API_KEY = originalCodingApiKey;
process.env.MINIMAX_API_KEY = originalApiKey;
});
describe("resolveMiniMaxRegion", () => {
it("returns global by default", () => {
expect(resolveMiniMaxRegion()).toBe("global");
expect(resolveMiniMaxRegion({})).toBe("global");
});
it("returns cn when explicit region is cn", () => {
expect(resolveMiniMaxRegion({ minimax: { region: "cn" } })).toBe("cn");
});
it("returns global when explicit region is not cn", () => {
expect(resolveMiniMaxRegion({ minimax: { region: "global" } })).toBe("global");
expect(resolveMiniMaxRegion({ minimax: { region: "us" } })).toBe("global");
});
it("infers cn from MINIMAX_API_HOST", () => {
process.env.MINIMAX_API_HOST = "https://api.minimaxi.com/anthropic";
expect(resolveMiniMaxRegion()).toBe("cn");
});
it("infers cn from model provider base URL", () => {
const cnConfig = {
models: {
providers: {
minimax: { baseUrl: "https://api.minimaxi.com/anthropic" },
},
},
};
expect(resolveMiniMaxRegion({}, cnConfig)).toBe("cn");
});
it("infers cn from minimax-portal base URL (OAuth CN path)", () => {
const cnPortalConfig = {
models: {
providers: {
"minimax-portal": { baseUrl: "https://api.minimaxi.com/anthropic" },
},
},
};
expect(resolveMiniMaxRegion({}, cnPortalConfig)).toBe("cn");
});
it("returns global when model provider base URL is global", () => {
const globalConfig = {
models: {
providers: {
minimax: { baseUrl: "https://api.minimax.io/anthropic" },
},
},
};
expect(resolveMiniMaxRegion({}, globalConfig)).toBe("global");
});
it("explicit search config region takes priority over base URL", () => {
const cnConfig = {
models: {
providers: {
minimax: { baseUrl: "https://api.minimaxi.com/anthropic" },
},
},
};
// Explicit global region overrides CN base URL
expect(resolveMiniMaxRegion({ minimax: { region: "global" } }, cnConfig)).toBe("global");
});
it("handles non-object minimax search config gracefully", () => {
expect(resolveMiniMaxRegion({ minimax: "invalid" })).toBe("global");
expect(resolveMiniMaxRegion({ minimax: null })).toBe("global");
expect(resolveMiniMaxRegion({ minimax: [1, 2] })).toBe("global");
});
});
describe("resolveMiniMaxEndpoint", () => {
it("returns global endpoint by default", () => {
expect(resolveMiniMaxEndpoint()).toBe(MINIMAX_SEARCH_ENDPOINT_GLOBAL);
});
it("returns CN endpoint when region is cn", () => {
expect(resolveMiniMaxEndpoint({ minimax: { region: "cn" } })).toBe(
MINIMAX_SEARCH_ENDPOINT_CN,
);
});
it("returns CN endpoint when inferred from model provider base URL", () => {
const cnConfig = {
models: {
providers: {
minimax: { baseUrl: "https://api.minimaxi.com/anthropic" },
},
},
};
expect(resolveMiniMaxEndpoint({}, cnConfig)).toBe(MINIMAX_SEARCH_ENDPOINT_CN);
});
});
describe("resolveMiniMaxApiKey", () => {
it("prefers configured apiKey over env vars", () => {
process.env.MINIMAX_CODE_PLAN_KEY = "env-key";
expect(resolveMiniMaxApiKey({ apiKey: "configured-key" })).toBe("configured-key");
});
it("accepts MINIMAX_CODING_API_KEY as a coding-plan alias", () => {
process.env.MINIMAX_CODING_API_KEY = "coding-key";
expect(resolveMiniMaxApiKey()).toBe("coding-key");
});
it("falls back to MINIMAX_API_KEY last", () => {
process.env.MINIMAX_API_KEY = "plain-key";
expect(resolveMiniMaxApiKey()).toBe("plain-key");
});
});
describe("endpoint constants", () => {
it("uses correct global endpoint", () => {
expect(MINIMAX_SEARCH_ENDPOINT_GLOBAL).toBe("https://api.minimax.io/v1/coding_plan/search");
});
it("uses correct CN endpoint", () => {
expect(MINIMAX_SEARCH_ENDPOINT_CN).toBe("https://api.minimaxi.com/v1/coding_plan/search");
});
});
});

View File

@@ -0,0 +1,305 @@
import { Type } from "@sinclair/typebox";
import {
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
buildSearchCacheKey,
formatCliCommand,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
setProviderWebSearchPluginConfigValue,
setTopLevelCredentialValue,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search";
const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search";
const MINIMAX_CODING_PLAN_ENV_VARS = [
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
] as const;
type MiniMaxSearchResult = {
title?: string;
link?: string;
snippet?: string;
date?: string;
};
type MiniMaxRelatedSearch = {
query?: string;
};
type MiniMaxSearchResponse = {
organic?: MiniMaxSearchResult[];
related_searches?: MiniMaxRelatedSearch[];
base_resp?: {
status_code?: number;
status_msg?: string;
};
};
function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"])
);
}
function isMiniMaxCnHost(value: string | undefined): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return false;
}
try {
return new URL(trimmed).hostname.endsWith("minimaxi.com");
} catch {
return trimmed.includes("minimaxi.com");
}
}
function resolveMiniMaxRegion(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): "cn" | "global" {
// 1. Explicit region in search config takes priority
const minimax =
typeof searchConfig?.minimax === "object" &&
searchConfig.minimax !== null &&
!Array.isArray(searchConfig.minimax)
? (searchConfig.minimax as Record<string, unknown>)
: undefined;
if (typeof minimax?.region === "string" && minimax.region.trim()) {
return minimax.region === "cn" ? "cn" : "global";
}
// 2. Infer from the shared MiniMax host override.
if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) {
return "cn";
}
// 3. Infer from model provider base URL (set by CN onboarding)
const models = config?.models as Record<string, unknown> | undefined;
const providers = models?.providers as Record<string, unknown> | undefined;
const minimaxProvider = providers?.minimax as Record<string, unknown> | undefined;
const portalProvider = providers?.["minimax-portal"] as Record<string, unknown> | undefined;
const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : "";
const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : "";
if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) {
return "cn";
}
return "global";
}
function resolveMiniMaxEndpoint(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): string {
return resolveMiniMaxRegion(searchConfig, config) === "cn"
? MINIMAX_SEARCH_ENDPOINT_CN
: MINIMAX_SEARCH_ENDPOINT_GLOBAL;
}
async function runMiniMaxSearch(params: {
query: string;
count: number;
apiKey: string;
endpoint: string;
timeoutSeconds: number;
}): Promise<{
results: Array<Record<string, unknown>>;
relatedSearches?: string[];
}> {
return withTrustedWebSearchEndpoint(
{
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ q: params.query }),
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as MiniMaxSearchResponse;
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
throw new Error(
`MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`,
);
}
const organic = Array.isArray(data.organic) ? data.organic : [];
const results = organic.slice(0, params.count).map((entry) => {
const title = entry.title ?? "";
const url = entry.link ?? "";
const snippet = entry.snippet ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: snippet ? wrapWebContent(snippet, "web_search") : "",
published: entry.date || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
const relatedSearches = Array.isArray(data.related_searches)
? data.related_searches
.map((r) => r.query)
.filter((q): q is string => typeof q === "string" && q.length > 0)
.map((q) => wrapWebContent(q, "web_search"))
: undefined;
return { results, relatedSearches };
},
);
}
const MiniMaxSearchSchema = Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
});
function missingMiniMaxKeyPayload() {
return {
error: "missing_minimax_api_key",
message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function createMiniMaxToolDefinition(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): WebSearchProviderToolDefinition {
return {
description:
"Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.",
parameters: MiniMaxSearchSchema,
execute: async (args) => {
const apiKey = resolveMiniMaxApiKey(searchConfig);
if (!apiKey) {
return missingMiniMaxKeyPayload();
}
const params = args as Record<string, unknown>;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT);
const endpoint = resolveMiniMaxEndpoint(searchConfig, config);
const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
const { results, relatedSearches } = await runMiniMaxSearch({
query,
count: resolvedCount,
apiKey,
endpoint,
timeoutSeconds,
});
const payload: Record<string, unknown> = {
query,
provider: "minimax",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "minimax",
wrapped: true,
},
results,
};
if (relatedSearches && relatedSearches.length > 0) {
payload.relatedSearches = relatedSearches;
}
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
},
};
}
export const __testing = {
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
MINIMAX_SEARCH_ENDPOINT_CN,
resolveMiniMaxApiKey,
resolveMiniMaxEndpoint,
resolveMiniMaxRegion,
} as const;
export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "minimax",
label: "MiniMax Search",
hint: "Structured results via MiniMax Coding Plan search API",
credentialLabel: "MiniMax Coding Plan key",
envVars: [...MINIMAX_CODING_PLAN_ENV_VARS],
placeholder: "sk-cp-...",
signupUrl: "https://platform.minimax.io/user-center/basic-information/interface-key",
docsUrl: "https://docs.openclaw.ai/tools/minimax-search",
autoDetectOrder: 15,
credentialPath: "plugins.entries.minimax.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.minimax.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "minimax")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "minimax", "apiKey", value);
},
createTool: (ctx) =>
createMiniMaxToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"minimax",
resolveProviderWebSearchPluginConfig(ctx.config, "minimax"),
{ mirrorApiKeyToTopLevel: true },
) as SearchConfigRecord | undefined,
ctx.config as Record<string, unknown> | undefined,
),
};
}