From d204be80af6000f606aef133852359e59c4d7fe5 Mon Sep 17 00:00:00 2001 From: Jithendra Date: Wed, 25 Mar 2026 18:41:35 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/providers/minimax.md | 25 +- .../reference/secretref-credential-surface.md | 1 + ...tref-user-supplied-credentials-matrix.json | 7 + docs/tools/minimax-search.md | 95 ++++++ docs/tools/web.md | 25 +- extensions/minimax/index.test.ts | 21 ++ extensions/minimax/index.ts | 8 +- extensions/minimax/openclaw.plugin.json | 31 +- .../src/minimax-web-search-provider.test.ts | 153 +++++++++ .../src/minimax-web-search-provider.ts | 305 ++++++++++++++++++ src/config/config.web-search-provider.test.ts | 155 ++++++--- src/plugins/bundled-web-search.test.ts | 10 + ...undled-web-search.minimax.contract.test.ts | 3 + src/secrets/provider-env-vars.test.ts | 2 +- src/secrets/provider-env-vars.ts | 5 +- src/secrets/runtime.coverage.test.ts | 6 +- src/secrets/target-registry-data.ts | 11 + 18 files changed, 798 insertions(+), 66 deletions(-) create mode 100644 docs/tools/minimax-search.md create mode 100644 extensions/minimax/src/minimax-web-search-provider.test.ts create mode 100644 extensions/minimax/src/minimax-web-search-provider.ts create mode 100644 src/plugins/contracts/bundled-web-search.minimax.contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5d3b02dc6..7e2357e8015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras. - Providers/Anthropic: remove setup-token from new onboarding and auth-command setup paths, keep existing configured legacy token profiles runnable, and steer new Anthropic setup to Claude CLI or API keys. - Providers/OpenAI Codex: add forward-compat `openai-codex/gpt-5.4-mini` synthesis across provider runtime, model catalog, and model listing so Codex mini works before bundled Pi catalog updates land. +- Tools/web_search: add a bundled MiniMax Search provider backed by the Coding Plan search API, with region reuse from `MINIMAX_API_HOST` and plugin-owned credential config. (#54648) Thanks @fengmk2. - Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so quoted, threaded, and fetched history context can be filtered by sender allowlists instead of always passing through as received. - Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200) - Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras. diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index ce0addfda5c..7f03634059a 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -10,6 +10,11 @@ title: "MiniMax" OpenClaw's MiniMax provider defaults to **MiniMax M2.7**. +MiniMax also provides: + +- bundled speech synthesis via T2A v2 +- bundled `web_search` through the MiniMax Coding Plan search API + ## Model lineup - `MiniMax-M2.7`: default hosted reasoning model. @@ -44,8 +49,24 @@ When onboarding or API-key setup writes explicit `models.providers.minimax` entries, OpenClaw materializes `MiniMax-M2.7` and `MiniMax-M2.7-highspeed` with `input: ["text", "image"]`. -The bundled MiniMax provider catalog itself currently advertises those chat -refs as text-only metadata until explicit provider config is materialized. +The bundled MiniMax provider catalog also advertises image input on those M2.7 +chat refs, so image-capable routing can use MiniMax without requiring explicit +provider config first. + +## Web search + +The MiniMax plugin also registers `web_search` through the MiniMax Coding Plan +search API. + +- Provider id: `minimax` +- Structured results: titles, URLs, snippets, related queries +- Preferred env var: `MINIMAX_CODE_PLAN_KEY` +- Accepted env alias: `MINIMAX_CODING_API_KEY` +- Compatibility fallback: `MINIMAX_API_KEY` when it already points at a coding-plan token +- Region reuse: `plugins.entries.minimax.config.webSearch.region`, then `MINIMAX_API_HOST`, then MiniMax provider base URLs + +Config lives under `plugins.entries.minimax.config.webSearch.*`. +See [MiniMax Search](/tools/minimax-search). ## Choose a setup diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 108e52402a9..6696623a748 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -47,6 +47,7 @@ Scope intent: - `plugins.entries.moonshot.config.webSearch.apiKey` - `plugins.entries.perplexity.config.webSearch.apiKey` - `plugins.entries.firecrawl.config.webSearch.apiKey` +- `plugins.entries.minimax.config.webSearch.apiKey` - `plugins.entries.tavily.config.webSearch.apiKey` - `tools.web.search.apiKey` - `gateway.auth.password` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index d8549d644ca..f8b6d27115e 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -540,6 +540,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.minimax.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.minimax.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "plugins.entries.moonshot.config.webSearch.apiKey", "configFile": "openclaw.json", diff --git a/docs/tools/minimax-search.md b/docs/tools/minimax-search.md new file mode 100644 index 00000000000..e16dd56e5fb --- /dev/null +++ b/docs/tools/minimax-search.md @@ -0,0 +1,95 @@ +--- +summary: "MiniMax Search via the Coding Plan search API" +read_when: + - You want to use MiniMax for web_search + - You need a MiniMax Coding Plan key + - You want MiniMax CN/global search host guidance +title: "MiniMax Search" +--- + +# MiniMax Search + +OpenClaw supports MiniMax as a `web_search` provider through the MiniMax +Coding Plan search API. It returns structured search results with titles, URLs, +snippets, and related queries. + +## Get a Coding Plan key + + + + Create or copy a MiniMax Coding Plan key from + [MiniMax Platform](https://platform.minimax.io/user-center/basic-information/interface-key). + + + Set `MINIMAX_CODE_PLAN_KEY` in the Gateway environment, or configure via: + + ```bash + openclaw configure --section web + ``` + + + + +OpenClaw also accepts `MINIMAX_CODING_API_KEY` as an env alias. `MINIMAX_API_KEY` +is still read as a compatibility fallback when it already points at a coding-plan token. + +## Config + +```json5 +{ + plugins: { + entries: { + minimax: { + config: { + webSearch: { + apiKey: "sk-cp-...", // optional if MINIMAX_CODE_PLAN_KEY is set + region: "global", // or "cn" + }, + }, + }, + }, + }, + tools: { + web: { + search: { + provider: "minimax", + }, + }, + }, +} +``` + +**Environment alternative:** set `MINIMAX_CODE_PLAN_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +## Region selection + +MiniMax Search uses these endpoints: + +- Global: `https://api.minimax.io/v1/coding_plan/search` +- CN: `https://api.minimaxi.com/v1/coding_plan/search` + +If `plugins.entries.minimax.config.webSearch.region` is unset, OpenClaw resolves +the region in this order: + +1. `tools.web.search.minimax.region` / plugin-owned `webSearch.region` +2. `MINIMAX_API_HOST` +3. `models.providers.minimax.baseUrl` +4. `models.providers.minimax-portal.baseUrl` + +That means CN onboarding or `MINIMAX_API_HOST=https://api.minimaxi.com/...` +automatically keeps MiniMax Search on the CN host too. + +## Supported parameters + +MiniMax Search supports: + +- `query` +- `count` (OpenClaw trims the returned result list to the requested count) + +Provider-specific filters are not currently supported. + +## Related + +- [Web Search overview](/tools/web) -- all providers and auto-detection +- [MiniMax](/providers/minimax) -- model, image, speech, and auth setup diff --git a/docs/tools/web.md b/docs/tools/web.md index 3bef2eb2d3f..b1bc2a6c6b9 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -80,6 +80,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. AI-synthesized answers with citations via Moonshot web search. + + Structured results via the MiniMax Coding Plan search API. + Key-free search via your configured Ollama host. Requires `ollama signin`. @@ -105,6 +108,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. | [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` | | [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` | | [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| [MiniMax Search](/tools/minimax-search) | Structured snippets | Region (`global` / `cn`) | `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` | | [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None by default; `ollama signin` required, can reuse Ollama provider bearer auth | | [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | | [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) | @@ -158,19 +162,20 @@ first one that is ready: API-backed providers first: 1. **Brave** -- `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey` (order 10) -2. **Gemini** -- `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey` (order 20) -3. **Grok** -- `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` (order 30) -4. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey` (order 40) -5. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey` (order 50) -6. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` (order 60) -7. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey` (order 65) -8. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70) +2. **MiniMax Search** -- `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` or `plugins.entries.minimax.config.webSearch.apiKey` (order 15) +3. **Gemini** -- `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey` (order 20) +4. **Grok** -- `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` (order 30) +5. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey` (order 40) +6. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey` (order 50) +7. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` (order 60) +8. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey` (order 65) +9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70) Key-free fallbacks after that: -9. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100) -10. **Ollama Web Search** -- key-free fallback via your configured Ollama host; requires Ollama to be reachable and signed in with `ollama signin` and can reuse Ollama provider bearer auth if the host needs it (order 110) -11. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200) +10. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100) +11. **Ollama Web Search** -- key-free fallback via your configured Ollama host; requires Ollama to be reachable and signed in with `ollama signin` and can reuse Ollama provider bearer auth if the host needs it (order 110) +12. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200) If no provider is detected, it falls back to Brave (you will get a missing-key error prompting you to configure one). diff --git a/extensions/minimax/index.test.ts b/extensions/minimax/index.test.ts index 3d4a8881a79..e53ea4fa5ac 100644 --- a/extensions/minimax/index.test.ts +++ b/extensions/minimax/index.test.ts @@ -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"], + }); + }); }); diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 49d53afa7e5..23dd7c3ebba 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -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()); }, }); diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 3d6e517b6ce..27570ed2f20 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -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"] + } + } + } + } } } diff --git a/extensions/minimax/src/minimax-web-search-provider.test.ts b/extensions/minimax/src/minimax-web-search-provider.test.ts new file mode 100644 index 00000000000..3e45303822c --- /dev/null +++ b/extensions/minimax/src/minimax-web-search-provider.test.ts @@ -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"); + }); + }); +}); diff --git a/extensions/minimax/src/minimax-web-search-provider.ts b/extensions/minimax/src/minimax-web-search-provider.ts new file mode 100644 index 00000000000..e954c245034 --- /dev/null +++ b/extensions/minimax/src/minimax-web-search-provider.ts @@ -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, +): "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) + : 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 | undefined; + const providers = models?.providers as Record | undefined; + const minimaxProvider = providers?.minimax as Record | undefined; + const portalProvider = providers?.["minimax-portal"] as Record | 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 { + 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>; + 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, +): 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; + 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 = { + 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 | undefined, + ), + }; +} diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 06f4fbd6b06..99352f9fdfc 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -5,6 +5,13 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: { log: vi.fn(), error: vi.fn() }, })); +vi.mock("../plugin-sdk/telegram-command-config.js", () => ({ + TELEGRAM_COMMAND_NAME_PATTERN: /^[a-z0-9_]+$/, + normalizeTelegramCommandName: (value: string) => value.trim().toLowerCase(), + normalizeTelegramCommandDescription: (value: string) => value.trim(), + resolveTelegramCustomCommands: () => ({ commands: [], issues: [] }), +})); + const getScopedWebSearchCredential = (key: string) => (search?: Record) => (search?.[key] as { apiKey?: unknown } | undefined)?.apiKey; const getConfiguredPluginWebSearchConfig = @@ -59,6 +66,13 @@ const mockWebSearchProviders = [ getCredentialValue: getScopedWebSearchCredential("kimi"), getConfiguredCredentialValue: getConfiguredPluginWebSearchCredential("moonshot"), }, + { + id: "minimax", + envVars: ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"], + credentialPath: "plugins.entries.minimax.config.webSearch.apiKey", + getCredentialValue: getScopedWebSearchCredential("minimax"), + getConfiguredCredentialValue: getConfiguredPluginWebSearchCredential("minimax"), + }, { id: "perplexity", envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], @@ -91,24 +105,8 @@ vi.mock("../plugins/web-search-providers.js", () => { }; }); -const secretInputSchema = { - oneOf: [ - { type: "string" }, - { - type: "object", - additionalProperties: false, - properties: { - source: { type: "string" }, - provider: { type: "string" }, - id: { type: "string" }, - }, - required: ["source", "provider", "id"], - }, - ], -}; - -function buildWebSearchPluginSchema() { - return { +vi.mock("../plugins/manifest-registry.js", () => { + const buildSchema = () => ({ type: "object", additionalProperties: false, properties: { @@ -116,34 +114,69 @@ function buildWebSearchPluginSchema() { type: "object", additionalProperties: false, properties: { - apiKey: secretInputSchema, - baseUrl: secretInputSchema, + apiKey: { + oneOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: false, + properties: { + source: { type: "string" }, + provider: { type: "string" }, + id: { type: "string" }, + }, + required: ["source", "provider", "id"], + }, + ], + }, + baseUrl: { + oneOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: false, + properties: { + source: { type: "string" }, + provider: { type: "string" }, + id: { type: "string" }, + }, + required: ["source", "provider", "id"], + }, + ], + }, model: { type: "string" }, }, }, }, - }; -} + }); -vi.mock("../plugins/manifest-registry.js", () => ({ - loadPluginManifestRegistry: () => ({ - plugins: [ - { - id: "brave", - origin: "bundled", - channels: [], - providers: [], - cliBackends: [], - skills: [], - hooks: [], - rootDir: "/tmp/plugins/brave", - source: "test", - manifestPath: "/tmp/plugins/brave/openclaw.plugin.json", - schemaCacheKey: "test:brave", - configSchema: buildWebSearchPluginSchema(), - }, - ...["firecrawl", "google", "moonshot", "perplexity", "searxng", "tavily", "xai"].map( - (id) => ({ + return { + loadPluginManifestRegistry: () => ({ + plugins: [ + { + id: "brave", + origin: "bundled", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: "/tmp/plugins/brave", + source: "test", + manifestPath: "/tmp/plugins/brave/openclaw.plugin.json", + schemaCacheKey: "test:brave", + configSchema: buildSchema(), + }, + ...[ + "firecrawl", + "google", + "minimax", + "moonshot", + "perplexity", + "searxng", + "tavily", + "xai", + ].map((id) => ({ id, origin: "bundled", channels: [], @@ -155,13 +188,13 @@ vi.mock("../plugins/manifest-registry.js", () => ({ source: "test", manifestPath: `/tmp/plugins/${id}/openclaw.plugin.json`, schemaCacheKey: `test:${id}`, - configSchema: buildWebSearchPluginSchema(), - }), - ), - ], - diagnostics: [], - }), -})); + configSchema: buildSchema(), + })), + ], + diagnostics: [], + }), + }; +}); let validateConfigObjectWithPlugins: typeof import("./config.js").validateConfigObjectWithPlugins; let resolveSearchProvider: typeof import("../agents/tools/web-search.js").__testing.resolveSearchProvider; @@ -278,6 +311,24 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("accepts minimax provider config on the plugin-owned path", () => { + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + enabled: true, + provider: "minimax", + providerConfig: { + apiKey: { + source: "env", + provider: "default", + id: "MINIMAX_CODE_PLAN_KEY", + }, + }, + }), + ); + + expect(res.ok).toBe(true); + }); + it("accepts searxng provider config on the plugin-owned path", () => { const res = validateConfigObjectWithPlugins( buildWebSearchProviderConfig({ @@ -363,6 +414,9 @@ describe("web search provider auto-detection", () => { delete process.env.FIRECRAWL_API_KEY; delete process.env.GEMINI_API_KEY; delete process.env.KIMI_API_KEY; + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_CODE_PLAN_KEY; + delete process.env.MINIMAX_CODING_API_KEY; delete process.env.MOONSHOT_API_KEY; delete process.env.PERPLEXITY_API_KEY; delete process.env.OPENROUTER_API_KEY; @@ -412,6 +466,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("kimi"); }); + it("auto-detects minimax when only MINIMAX_CODE_PLAN_KEY is set", () => { + process.env.MINIMAX_CODE_PLAN_KEY = "sk-cp-test"; + expect(resolveSearchProvider({})).toBe("minimax"); + }); + it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => { process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("perplexity"); diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index d37f2333594..cba6cf26491 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -67,6 +67,13 @@ describe("bundled web search helpers", () => { }); vi.mocked(loadBundledCapabilityRuntimeRegistry).mockReturnValue({ webSearchProviders: [ + { + pluginId: "minimax", + provider: createMockedBundledWebSearchProvider({ + pluginId: "minimax", + providerId: "minimax", + }), + }, { pluginId: "xai", provider: createMockedBundledWebSearchProvider({ @@ -116,15 +123,18 @@ describe("bundled web search helpers", () => { it("maps bundled provider ids back to their owning plugins", () => { expect(resolveBundledWebSearchPluginId(" gemini ")).toBe("google"); + expect(resolveBundledWebSearchPluginId(" minimax ")).toBe("minimax"); expect(resolveBundledWebSearchPluginId("missing")).toBeUndefined(); }); it("loads bundled provider entries through the capability runtime registry once", () => { expect(listBundledWebSearchProviders()).toEqual([ + expect.objectContaining({ pluginId: "minimax", id: "minimax" }), expect.objectContaining({ pluginId: "xai", id: "grok" }), expect.objectContaining({ pluginId: "google", id: "gemini" }), ]); expect(listBundledWebSearchProviders()).toEqual([ + expect.objectContaining({ pluginId: "minimax", id: "minimax" }), expect.objectContaining({ pluginId: "xai", id: "grok" }), expect.objectContaining({ pluginId: "google", id: "gemini" }), ]); diff --git a/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts b/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts new file mode 100644 index 00000000000..f4b5fcd81a2 --- /dev/null +++ b/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("minimax"); diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts index 6180a014986..45f0b11ae08 100644 --- a/src/secrets/provider-env-vars.test.ts +++ b/src/secrets/provider-env-vars.test.ts @@ -37,7 +37,7 @@ describe("provider env vars", () => { ]), ); expect(listKnownProviderAuthEnvVarNames()).toEqual( - expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]), + expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"]), ); expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY"); }); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 936ff8a4ddc..58aade35ce1 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -44,7 +44,10 @@ export function getProviderEnvVars(providerId: string): string[] { return Array.isArray(envVars) ? [...envVars] : []; } -const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY"] as const; +const EXTRA_PROVIDER_AUTH_ENV_VARS = [ + "MINIMAX_CODE_PLAN_KEY", + "MINIMAX_CODING_API_KEY", +] as const; const KNOWN_SECRET_ENV_VARS = [ ...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)), diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 0ec523b4ac0..67e63aca2c1 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -42,7 +42,7 @@ vi.mock("../plugins/web-fetch-providers.runtime.js", () => ({ })); function createTestProvider(params: { - id: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl" | "tavily"; + id: "brave" | "gemini" | "grok" | "kimi" | "minimax" | "perplexity" | "firecrawl" | "tavily"; pluginId: string; order: number; }): PluginWebSearchProviderEntry { @@ -100,6 +100,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), createTestProvider({ id: "grok", pluginId: "xai", order: 30 }), createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ id: "minimax", pluginId: "minimax", order: 15 }), createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), createTestProvider({ id: "tavily", pluginId: "tavily", order: 70 }), @@ -260,6 +261,9 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string) if (entry.id === "plugins.entries.firecrawl.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "firecrawl"); } + if (entry.id === "plugins.entries.minimax.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "minimax"); + } if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily"); } diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 7086499d6c1..0301a75268b 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -422,6 +422,17 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "plugins.entries.minimax.config.webSearch.apiKey", + targetType: "plugins.entries.minimax.config.webSearch.apiKey", + configFile: "openclaw.json", + pathPattern: "plugins.entries.minimax.config.webSearch.apiKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, ]; const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [