From 41ee813a458d7850ec517a970fdc41448d445952 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 18:08:00 -0400 Subject: [PATCH] test: lazy-load minimax web search runtime Keep the Minimax web-search provider artifact metadata-only and move execution, cache, endpoint, and test helpers behind a lazy runtime import. This keeps contract metadata tests from importing the full runtime path. --- .../minimax-web-search-provider.runtime.ts | 253 ++++++++++++++ .../src/minimax-web-search-provider.test.ts | 4 +- .../src/minimax-web-search-provider.ts | 311 ++---------------- extensions/minimax/test-api.ts | 1 + 4 files changed, 284 insertions(+), 285 deletions(-) create mode 100644 extensions/minimax/src/minimax-web-search-provider.runtime.ts diff --git a/extensions/minimax/src/minimax-web-search-provider.runtime.ts b/extensions/minimax/src/minimax-web-search-provider.runtime.ts new file mode 100644 index 00000000000..10e3a0bd742 --- /dev/null +++ b/extensions/minimax/src/minimax-web-search-provider.runtime.ts @@ -0,0 +1,253 @@ +import { + DEFAULT_SEARCH_COUNT, + buildSearchCacheKey, + formatCliCommand, + mergeScopedSearchConfig, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, + type SearchConfigRecord, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; + +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 = normalizeOptionalString(value); + 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; + const configuredRegion = + typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined; + if (configuredRegion) { + return configuredRegion === "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 }; + }, + ); +} + +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", + }; +} + +export async function executeMiniMaxWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "minimax", + resolveProviderWebSearchPluginConfig(ctx.config, "minimax"), + { mirrorApiKeyToTopLevel: true }, + ) as SearchConfigRecord | undefined; + const config = ctx.config; + const apiKey = resolveMiniMaxApiKey(searchConfig); + if (!apiKey) { + return missingMiniMaxKeyPayload(); + } + + const params = args; + 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; diff --git a/extensions/minimax/src/minimax-web-search-provider.test.ts b/extensions/minimax/src/minimax-web-search-provider.test.ts index 3e45303822c..03d7e0fa48a 100644 --- a/extensions/minimax/src/minimax-web-search-provider.test.ts +++ b/extensions/minimax/src/minimax-web-search-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { __testing } from "./minimax-web-search-provider.js"; +import { minimaxWebSearchTesting } from "../test-api.js"; const { MINIMAX_SEARCH_ENDPOINT_GLOBAL, @@ -7,7 +7,7 @@ const { resolveMiniMaxApiKey, resolveMiniMaxEndpoint, resolveMiniMaxRegion, -} = __testing; +} = minimaxWebSearchTesting; describe("minimax web search provider", () => { const originalApiHost = process.env.MINIMAX_API_HOST; diff --git a/extensions/minimax/src/minimax-web-search-provider.ts b/extensions/minimax/src/minimax-web-search-provider.ts index 5ff2c9d43bc..367ef7f1948 100644 --- a/extensions/minimax/src/minimax-web-search-provider.ts +++ b/extensions/minimax/src/minimax-web-search-provider.ts @@ -1,275 +1,23 @@ -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, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -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_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey"; 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 = normalizeOptionalString(value); - 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; - const configuredRegion = - typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined; - if (configuredRegion) { - return configuredRegion === "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({ +const MiniMaxSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + 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; - 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; + maximum: 10, }, - }; -} - -export const __testing = { - MINIMAX_SEARCH_ENDPOINT_GLOBAL, - MINIMAX_SEARCH_ENDPOINT_CN, - resolveMiniMaxApiKey, - resolveMiniMaxEndpoint, - resolveMiniMaxRegion, -} as const; + }, +} satisfies Record; export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { return { @@ -282,24 +30,21 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { 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, - ), + credentialPath: MINIMAX_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: MINIMAX_CREDENTIAL_PATH, + searchCredential: { type: "top-level" }, + configuredCredential: { pluginId: "minimax" }, + }), + createTool: (ctx) => ({ + description: + "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", + parameters: MiniMaxSearchSchema, + execute: async (args) => { + const { executeMiniMaxWebSearchProviderTool } = + await import("./minimax-web-search-provider.runtime.js"); + return await executeMiniMaxWebSearchProviderTool(ctx, args); + }, + }), }; } diff --git a/extensions/minimax/test-api.ts b/extensions/minimax/test-api.ts index 79181773d9d..1a47d4092b3 100644 --- a/extensions/minimax/test-api.ts +++ b/extensions/minimax/test-api.ts @@ -7,4 +7,5 @@ export { minimaxMediaUnderstandingProvider, minimaxPortalMediaUnderstandingProvider, } from "./media-understanding-provider.js"; +export { __testing as minimaxWebSearchTesting } from "./src/minimax-web-search-provider.runtime.js"; export { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js";