Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
37
extensions/minimax/README.md
Normal file
37
extensions/minimax/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# MiniMax (OpenClaw plugin)
|
||||
|
||||
Bundled MiniMax plugin for both:
|
||||
|
||||
- API-key provider setup (`minimax`)
|
||||
- Token Plan OAuth setup (`minimax-portal`)
|
||||
|
||||
## Enable
|
||||
|
||||
```bash
|
||||
openclaw plugins enable minimax
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Authenticate
|
||||
|
||||
OAuth:
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider minimax-portal --set-default
|
||||
```
|
||||
|
||||
API key:
|
||||
|
||||
```bash
|
||||
openclaw setup --wizard --auth-choice minimax-global-api
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- MiniMax OAuth uses a user-code login flow.
|
||||
- OAuth currently targets the Token Plan path.
|
||||
28
extensions/minimax/api.ts
Normal file
28
extensions/minimax/api.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Minimax API module exposes the plugin public contract.
|
||||
export { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js";
|
||||
export {
|
||||
buildMinimaxApiModelDefinition,
|
||||
buildMinimaxModelDefinition,
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
MINIMAX_API_BASE_URL,
|
||||
MINIMAX_API_COST,
|
||||
MINIMAX_CN_API_BASE_URL,
|
||||
MINIMAX_HOSTED_COST,
|
||||
MINIMAX_HOSTED_MODEL_ID,
|
||||
MINIMAX_HOSTED_MODEL_REF,
|
||||
MINIMAX_LM_STUDIO_COST,
|
||||
} from "./model-definitions.js";
|
||||
export {
|
||||
isMiniMaxModernModelId,
|
||||
MINIMAX_DEFAULT_MODEL_ID,
|
||||
MINIMAX_DEFAULT_MODEL_REF,
|
||||
MINIMAX_TEXT_MODEL_CATALOG,
|
||||
MINIMAX_TEXT_MODEL_ORDER,
|
||||
MINIMAX_TEXT_MODEL_REFS,
|
||||
} from "./provider-models.js";
|
||||
export {
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiConfigCn,
|
||||
applyMinimaxApiProviderConfig,
|
||||
applyMinimaxApiProviderConfigCn,
|
||||
} from "./onboard.js";
|
||||
314
extensions/minimax/image-generation-provider.test.ts
Normal file
314
extensions/minimax/image-generation-provider.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
// Minimax tests cover image generation provider plugin behavior.
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import * as providerHttp from "openclaw/plugin-sdk/provider-http";
|
||||
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMinimaxImageGenerationProvider,
|
||||
buildMinimaxPortalImageGenerationProvider,
|
||||
} from "./image-generation-provider.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
describe("minimax image-generation provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("MINIMAX_API_HOST", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function mockMinimaxApiKey() {
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "minimax-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulMinimaxImageResponse() {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
image_base64: [Buffer.from("png-data").toString("base64")],
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function expectImageGenerationUrl(fetchMock: ReturnType<typeof vi.fn>, url: string) {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
const [actualUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit | undefined];
|
||||
expect(actualUrl).toBe(url);
|
||||
expect(init?.method).toBe("POST");
|
||||
}
|
||||
|
||||
function requireFirstPostJsonRequest(mock: ReturnType<typeof vi.fn>): {
|
||||
body?: unknown;
|
||||
ssrfPolicy?: unknown;
|
||||
url?: string;
|
||||
} {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected MiniMax image request");
|
||||
}
|
||||
const [request] = call;
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
||||
throw new Error("expected MiniMax image request");
|
||||
}
|
||||
return request as { body?: unknown; url?: string };
|
||||
}
|
||||
|
||||
it("generates PNG buffers through the shared provider HTTP path", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://api.minimax.io/v1/image_generation");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe(
|
||||
JSON.stringify({
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
response_format: "base64",
|
||||
n: 1,
|
||||
}),
|
||||
);
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer minimax-test-key");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("png-data"),
|
||||
mimeType: "image/png",
|
||||
fileName: "image-1.png",
|
||||
},
|
||||
],
|
||||
model: "image-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed base64 image payloads", async () => {
|
||||
mockMinimaxApiKey();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
image_base64: ["not-base64!"],
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("MiniMax image generation returned malformed image base64");
|
||||
});
|
||||
|
||||
it("passes request SSRF policy to the provider HTTP helper", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
data: { image_base64: [Buffer.from("png-data").toString("base64")] },
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
finalUrl: "https://api.minimax.io/v1/image_generation",
|
||||
release: async () => {},
|
||||
});
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
|
||||
});
|
||||
|
||||
expect(postJsonRequest).toHaveBeenCalledOnce();
|
||||
const request = requireFirstPostJsonRequest(postJsonRequest);
|
||||
expect(request.url).toBe("https://api.minimax.io/v1/image_generation");
|
||||
expect(request.body).toEqual({
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
response_format: "base64",
|
||||
n: 1,
|
||||
});
|
||||
expect(request.ssrfPolicy).toEqual({ allowRfc2544BenchmarkRange: true });
|
||||
});
|
||||
|
||||
it("keeps the dedicated global image endpoint when text config uses the global API host", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation");
|
||||
});
|
||||
|
||||
it("does not inherit unrelated MiniMax text endpoint hosts for image generation", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.chat/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation");
|
||||
});
|
||||
|
||||
it("uses the dedicated CN image endpoint when CN API host is configured", async () => {
|
||||
vi.stubEnv("MINIMAX_API_HOST", "https://api.minimaxi.com/anthropic");
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expectImageGenerationUrl(fetchMock, "https://api.minimaxi.com/v1/image_generation");
|
||||
});
|
||||
|
||||
it("infers the dedicated CN image endpoint from MiniMax provider config", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimaxi.com/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectImageGenerationUrl(fetchMock, "https://api.minimaxi.com/v1/image_generation");
|
||||
});
|
||||
|
||||
it("infers the dedicated CN image endpoint from MiniMax Portal provider config", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxPortalImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax-portal",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
"minimax-portal": {
|
||||
baseUrl: "api.minimaxi.com/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectImageGenerationUrl(fetchMock, "https://api.minimaxi.com/v1/image_generation");
|
||||
});
|
||||
|
||||
it("ignores private custom text endpoints for image generation", async () => {
|
||||
mockMinimaxApiKey();
|
||||
const fetchMock = mockSuccessfulMinimaxImageResponse();
|
||||
|
||||
const provider = buildMinimaxImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "minimax",
|
||||
model: "image-01",
|
||||
prompt: "draw a cat",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "http://127.0.0.1:8080/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation");
|
||||
});
|
||||
});
|
||||
242
extensions/minimax/image-generation-provider.ts
Normal file
242
extensions/minimax/image-generation-provider.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import {
|
||||
resolveInlineImageJsonResponseMaxBytes,
|
||||
type ImageGenerationProvider,
|
||||
} from "openclaw/plugin-sdk/image-generation";
|
||||
import { canonicalizeBase64, MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
|
||||
const DEFAULT_MINIMAX_IMAGE_BASE_URL = "https://api.minimax.io";
|
||||
const CN_MINIMAX_IMAGE_BASE_URL = "https://api.minimaxi.com";
|
||||
const DEFAULT_MODEL = "image-01";
|
||||
const DEFAULT_OUTPUT_MIME = "image/png";
|
||||
const MINIMAX_MAX_IMAGE_RESULTS = 9;
|
||||
const MB = 1024 * 1024;
|
||||
const MINIMAX_SUPPORTED_ASPECT_RATIOS = [
|
||||
"1:1",
|
||||
"16:9",
|
||||
"4:3",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"3:4",
|
||||
"9:16",
|
||||
"21:9",
|
||||
] as const;
|
||||
|
||||
type MinimaxImageApiResponse = {
|
||||
data?: {
|
||||
image_base64?: string[];
|
||||
};
|
||||
metadata?: {
|
||||
success_count?: number;
|
||||
failed_count?: number;
|
||||
};
|
||||
id?: string;
|
||||
base_resp?: {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function isMinimaxCnHost(value: string | undefined): boolean {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const candidate = /^[a-z][a-z\d+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
try {
|
||||
const hostname = new URL(candidate).hostname.toLowerCase();
|
||||
return hostname === "minimaxi.com" || hostname.endsWith(".minimaxi.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMinimaxImageBaseUrl(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
providerId: string,
|
||||
): string {
|
||||
// MiniMax image generation uses dedicated endpoints that are separate from
|
||||
// the text/chat API endpoints. First check MINIMAX_API_HOST env var,
|
||||
// then fall back to the provider's configured baseUrl to determine region.
|
||||
const apiHost = process.env.MINIMAX_API_HOST;
|
||||
if (isMinimaxCnHost(apiHost)) {
|
||||
return CN_MINIMAX_IMAGE_BASE_URL;
|
||||
}
|
||||
// CN onboarding stores region in provider config without requiring env var
|
||||
const providerBaseUrl = cfg?.models?.providers?.[providerId]?.baseUrl;
|
||||
if (isMinimaxCnHost(providerBaseUrl)) {
|
||||
return CN_MINIMAX_IMAGE_BASE_URL;
|
||||
}
|
||||
return DEFAULT_MINIMAX_IMAGE_BASE_URL;
|
||||
}
|
||||
|
||||
function resolveGeneratedImageMaxBytes(req: {
|
||||
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
|
||||
}): number {
|
||||
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return Math.floor(configured * MB);
|
||||
}
|
||||
return MAX_IMAGE_BYTES;
|
||||
}
|
||||
|
||||
function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider {
|
||||
return {
|
||||
id: providerId,
|
||||
label: "MiniMax",
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
models: [DEFAULT_MODEL],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: providerId,
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxCount: MINIMAX_MAX_IMAGE_RESULTS,
|
||||
supportsSize: false,
|
||||
supportsAspectRatio: true,
|
||||
supportsResolution: false,
|
||||
},
|
||||
edit: {
|
||||
enabled: true,
|
||||
maxCount: MINIMAX_MAX_IMAGE_RESULTS,
|
||||
maxInputImages: 1,
|
||||
supportsSize: false,
|
||||
supportsAspectRatio: true,
|
||||
supportsResolution: false,
|
||||
},
|
||||
geometry: {
|
||||
aspectRatios: [...MINIMAX_SUPPORTED_ASPECT_RATIOS],
|
||||
},
|
||||
},
|
||||
async generateImage(req) {
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: providerId,
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("MiniMax API key missing");
|
||||
}
|
||||
|
||||
const baseUrl = resolveMinimaxImageBaseUrl(req.cfg, providerId);
|
||||
const {
|
||||
baseUrl: resolvedBaseUrl,
|
||||
allowPrivateNetwork,
|
||||
headers,
|
||||
dispatcherPolicy,
|
||||
} = resolveProviderHttpRequestConfig({
|
||||
baseUrl,
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_IMAGE_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: providerId,
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: req.model || DEFAULT_MODEL,
|
||||
prompt: req.prompt,
|
||||
response_format: "base64",
|
||||
n: req.count ?? 1,
|
||||
};
|
||||
|
||||
if (req.aspectRatio?.trim()) {
|
||||
body.aspect_ratio = req.aspectRatio.trim();
|
||||
}
|
||||
|
||||
// Map input images to subject_reference for image-to-image generation
|
||||
if (req.inputImages && req.inputImages.length > 0) {
|
||||
const ref = req.inputImages[0];
|
||||
const mime = ref.mimeType || "image/jpeg";
|
||||
const dataUrl = `data:${mime};base64,${ref.buffer.toString("base64")}`;
|
||||
body.subject_reference = [{ type: "character", image_file: dataUrl }];
|
||||
}
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: `${resolvedBaseUrl}/v1/image_generation`,
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "MiniMax image generation failed");
|
||||
|
||||
const data = await readProviderJsonResponse<MinimaxImageApiResponse>(
|
||||
response,
|
||||
"minimax.image-generation",
|
||||
{
|
||||
maxBytes: resolveInlineImageJsonResponseMaxBytes(
|
||||
MINIMAX_MAX_IMAGE_RESULTS,
|
||||
resolveGeneratedImageMaxBytes(req),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const baseResp = data.base_resp;
|
||||
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
|
||||
const msg = baseResp.status_msg ?? "";
|
||||
throw new Error(`MiniMax image generation API error (${baseResp.status_code}): ${msg}`);
|
||||
}
|
||||
|
||||
const base64Images = data.data?.image_base64 ?? [];
|
||||
const failedCount = data.metadata?.failed_count ?? 0;
|
||||
|
||||
if (base64Images.length === 0) {
|
||||
const reason =
|
||||
failedCount > 0 ? `${failedCount} image(s) failed to generate` : "no images returned";
|
||||
throw new Error(`MiniMax image generation returned no images: ${reason}`);
|
||||
}
|
||||
|
||||
const images = base64Images
|
||||
.map((b64, index) => {
|
||||
if (!b64) {
|
||||
return null;
|
||||
}
|
||||
const canonicalBase64 = canonicalizeBase64(b64);
|
||||
if (!canonicalBase64) {
|
||||
throw new Error("MiniMax image generation returned malformed image base64");
|
||||
}
|
||||
return {
|
||||
buffer: Buffer.from(canonicalBase64, "base64"),
|
||||
mimeType: DEFAULT_OUTPUT_MIME,
|
||||
fileName: `image-${index + 1}.png`,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
|
||||
return {
|
||||
images,
|
||||
model: req.model || DEFAULT_MODEL,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxImageGenerationProvider(): ImageGenerationProvider {
|
||||
return buildMinimaxImageProvider("minimax");
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalImageGenerationProvider(): ImageGenerationProvider {
|
||||
return buildMinimaxImageProvider("minimax-portal");
|
||||
}
|
||||
479
extensions/minimax/index.test.ts
Normal file
479
extensions/minimax/index.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
// Minimax tests cover index plugin behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerMinimaxProviders } from "./provider-registration.js";
|
||||
import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
|
||||
|
||||
vi.mock("./oauth.runtime.js", () => ({
|
||||
loginMiniMaxPortalOAuth: vi.fn(async () => ({
|
||||
access: "minimax-oauth-access-token",
|
||||
refresh: "minimax-oauth-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
resourceUrl: "https://api.minimax.io/anthropic",
|
||||
})),
|
||||
}));
|
||||
|
||||
const minimaxProviderPlugin = {
|
||||
register(api: Parameters<typeof registerMinimaxProviders>[0]) {
|
||||
registerMinimaxProviders(api);
|
||||
api.registerWebSearchProvider(createMiniMaxWebSearchProvider());
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("minimax provider hooks", () => {
|
||||
it("declares CN provider auth aliases in the manifest", () => {
|
||||
const pluginJson = JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname, "openclaw.plugin.json"), "utf-8"),
|
||||
);
|
||||
|
||||
expect(pluginJson.providerAuthAliases).toEqual({
|
||||
"minimax-cn": "minimax",
|
||||
"minimax-portal-cn": "minimax-portal",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps native reasoning mode for MiniMax transports", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
|
||||
expect(apiProvider.hookAliases).toContain("minimax-cn");
|
||||
expect(
|
||||
apiProvider.resolveReasoningOutputMode?.({
|
||||
provider: "minimax",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "MiniMax-M2.7",
|
||||
} as never),
|
||||
).toBe("native");
|
||||
|
||||
expect(portalProvider.hookAliases).toContain("minimax-portal-cn");
|
||||
expect(
|
||||
portalProvider.resolveReasoningOutputMode?.({
|
||||
provider: "minimax-portal",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "MiniMax-M2.7",
|
||||
} as never),
|
||||
).toBe("native");
|
||||
});
|
||||
|
||||
it("defaults M3 thinking on while keeping M2.x thinking off by default", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
|
||||
expect(apiProvider.resolveThinkingProfile?.({ modelId: "MiniMax-M3" } as never)).toMatchObject({
|
||||
defaultLevel: "adaptive",
|
||||
});
|
||||
expect(
|
||||
apiProvider.resolveThinkingProfile?.({ modelId: "MiniMax-M2.7" } as never),
|
||||
).toMatchObject({
|
||||
defaultLevel: "off",
|
||||
});
|
||||
expect(
|
||||
portalProvider.resolveThinkingProfile?.({ modelId: "MiniMax-M3" } as never),
|
||||
).toMatchObject({
|
||||
defaultLevel: "adaptive",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MiniMax auth setup metadata aligned across regions", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
|
||||
expect(
|
||||
apiProvider.auth.map((method) => ({
|
||||
id: method.id,
|
||||
label: method.label,
|
||||
hint: method.hint,
|
||||
choiceId: method.wizard?.choiceId,
|
||||
groupId: method.wizard?.groupId,
|
||||
groupHint: method.wizard?.groupHint,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "api-global",
|
||||
label: "MiniMax API key (Global)",
|
||||
hint: "Global endpoint - api.minimax.io",
|
||||
choiceId: "minimax-global-api",
|
||||
groupId: "minimax",
|
||||
groupHint: "M3 (recommended)",
|
||||
},
|
||||
{
|
||||
id: "api-cn",
|
||||
label: "MiniMax API key (CN)",
|
||||
hint: "CN endpoint - api.minimaxi.com",
|
||||
choiceId: "minimax-cn-api",
|
||||
groupId: "minimax",
|
||||
groupHint: "M3 (recommended)",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
portalProvider.auth.map((method) => ({
|
||||
id: method.id,
|
||||
label: method.label,
|
||||
hint: method.hint,
|
||||
choiceId: method.wizard?.choiceId,
|
||||
groupId: method.wizard?.groupId,
|
||||
groupHint: method.wizard?.groupHint,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "oauth",
|
||||
label: "MiniMax OAuth (Global)",
|
||||
hint: "Global endpoint - api.minimax.io",
|
||||
choiceId: "minimax-global-oauth",
|
||||
groupId: "minimax",
|
||||
groupHint: "M3 (recommended)",
|
||||
},
|
||||
{
|
||||
id: "oauth-cn",
|
||||
label: "MiniMax OAuth (CN)",
|
||||
hint: "CN endpoint - api.minimaxi.com",
|
||||
choiceId: "minimax-cn-oauth",
|
||||
groupId: "minimax",
|
||||
groupHint: "M3 (recommended)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("owns replay policy for Anthropic and OpenAI-compatible MiniMax transports", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
|
||||
expect(
|
||||
apiProvider.buildReplayPolicy?.({
|
||||
provider: "minimax",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "MiniMax-M2.7",
|
||||
} as never),
|
||||
).toEqual({
|
||||
sanitizeMode: "full",
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
preserveSignatures: true,
|
||||
repairToolUseResultPairing: true,
|
||||
validateAnthropicTurns: true,
|
||||
allowSyntheticToolResults: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
portalProvider.buildReplayPolicy?.({
|
||||
provider: "minimax-portal",
|
||||
modelApi: "openai-completions",
|
||||
modelId: "MiniMax-M2.7",
|
||||
} as never),
|
||||
).toEqual({
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
applyAssistantFirstOrderingFix: true,
|
||||
validateGeminiTurns: true,
|
||||
validateAnthropicTurns: true,
|
||||
dropReasoningFromHistory: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists M3 on the Anthropic Messages route used by the empty-history guard", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
|
||||
const catalog = await apiProvider.catalog?.run({
|
||||
env: {},
|
||||
config: {},
|
||||
resolveProviderApiKey: (providerId?: string) => ({
|
||||
apiKey: providerId === "minimax" ? "sk-minimax-test" : undefined,
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const provider = catalog && "provider" in catalog ? catalog.provider : undefined;
|
||||
expect(provider?.api).toBe("anthropic-messages");
|
||||
expect(provider?.authHeader).toBe(true);
|
||||
expect(provider?.baseUrl).toBe("https://api.minimax.io/anthropic");
|
||||
const model = provider?.models.find((entry: { id?: string }) => entry.id === "MiniMax-M3");
|
||||
expect(model?.id).toBe("MiniMax-M3");
|
||||
expect(model?.input).toEqual(["text", "image"]);
|
||||
expect(model?.name).toBe("MiniMax M3");
|
||||
expect(model?.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves M3 through the dynamic model hook before agent discovery", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
|
||||
const model = apiProvider.resolveDynamicModel?.({
|
||||
provider: "minimax",
|
||||
modelId: "MiniMax-M3",
|
||||
providerConfig: {},
|
||||
} as never);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
provider: "minimax",
|
||||
id: "MiniMax-M3",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MINIMAX_API_HOST endpoint overrides on dynamic M3 resolution", async () => {
|
||||
vi.stubEnv("MINIMAX_API_HOST", "https://api.minimaxi.com");
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
|
||||
const model = apiProvider.resolveDynamicModel?.({
|
||||
provider: "minimax",
|
||||
modelId: "MiniMax-M3",
|
||||
providerConfig: {},
|
||||
} as never);
|
||||
|
||||
expect(model?.baseUrl).toBe("https://api.minimaxi.com/anthropic");
|
||||
});
|
||||
|
||||
it("owns fast-mode stream wrapping for MiniMax transports", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
|
||||
let resolvedApiModelId = "";
|
||||
const captureApiModel: StreamFn = (model) => {
|
||||
resolvedApiModelId = model.id ?? "";
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
const wrappedApiStream = apiProvider.wrapStreamFn?.({
|
||||
provider: "minimax",
|
||||
modelId: "MiniMax-M2.7",
|
||||
extraParams: { fastMode: true },
|
||||
streamFn: captureApiModel,
|
||||
} as never);
|
||||
|
||||
void wrappedApiStream?.(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "minimax",
|
||||
id: "MiniMax-M2.7",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
let resolvedPortalModelId = "";
|
||||
const capturePortalModel: StreamFn = (model) => {
|
||||
resolvedPortalModelId = model.id ?? "";
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
const wrappedPortalStream = portalProvider.wrapStreamFn?.({
|
||||
provider: "minimax-portal",
|
||||
modelId: "MiniMax-M2.7",
|
||||
extraParams: { fastMode: true },
|
||||
streamFn: capturePortalModel,
|
||||
} as never);
|
||||
|
||||
void wrappedPortalStream?.(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "minimax-portal",
|
||||
id: "MiniMax-M2.7",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(resolvedApiModelId).toBe("MiniMax-M2.7-highspeed");
|
||||
expect(resolvedPortalModelId).toBe("MiniMax-M2.7-highspeed");
|
||||
});
|
||||
|
||||
it("shares the provider hook bundle across MiniMax variants", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
|
||||
expect(apiProvider.buildReplayPolicy).toBe(portalProvider.buildReplayPolicy);
|
||||
expect(apiProvider.wrapStreamFn).toBe(portalProvider.wrapStreamFn);
|
||||
expect(apiProvider.resolveReasoningOutputMode).toBe(portalProvider.resolveReasoningOutputMode);
|
||||
});
|
||||
|
||||
it("registers the bundled MiniMax web search provider", () => {
|
||||
const webSearchProviders: unknown[] = [];
|
||||
|
||||
minimaxProviderPlugin.register({
|
||||
registerProvider() {},
|
||||
registerMediaUnderstandingProvider() {},
|
||||
registerImageGenerationProvider() {},
|
||||
registerMusicGenerationProvider() {},
|
||||
registerVideoGenerationProvider() {},
|
||||
registerSpeechProvider() {},
|
||||
registerWebSearchProvider(provider: unknown) {
|
||||
webSearchProviders.push(provider);
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(webSearchProviders).toHaveLength(1);
|
||||
const provider = webSearchProviders[0] as
|
||||
| {
|
||||
id?: unknown;
|
||||
label?: unknown;
|
||||
onboardingScopes?: unknown;
|
||||
envVars?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
expect(provider?.id).toBe("minimax");
|
||||
expect(provider?.label).toBe("MiniMax Search");
|
||||
expect(provider?.onboardingScopes).toEqual(["text-inference"]);
|
||||
expect(provider?.envVars).toEqual([
|
||||
"MINIMAX_CODE_PLAN_KEY",
|
||||
"MINIMAX_CODING_API_KEY",
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
"MINIMAX_API_KEY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers minimax-portal oauth when resolving MiniMax usage auth", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const resolveOAuthToken = vi.fn(async (params?: { provider?: string }) =>
|
||||
params?.provider === "minimax-portal" ? { token: "portal-oauth-token" } : null,
|
||||
);
|
||||
const resolveApiKeyFromConfigAndStore = vi.fn(() => undefined);
|
||||
|
||||
await expect(
|
||||
apiProvider.resolveUsageAuth?.({
|
||||
provider: "minimax",
|
||||
config: {},
|
||||
env: {},
|
||||
resolveOAuthToken,
|
||||
resolveApiKeyFromConfigAndStore,
|
||||
} as never),
|
||||
).resolves.toEqual({ token: "portal-oauth-token" });
|
||||
|
||||
expect(resolveOAuthToken).toHaveBeenCalledWith({ provider: "minimax-portal" });
|
||||
expect(resolveApiKeyFromConfigAndStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the configured MiniMax base URL for usage snapshots", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const apiProvider = requireRegisteredProvider(providers, "minimax");
|
||||
const fetchFn = vi.fn(async (input: string | URL | Request) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
expect(url).toBe("https://api.minimax.io/v1/token_plan/remains");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
current_interval_total_count: 100,
|
||||
current_interval_usage_count: 98,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
const result = await apiProvider.fetchUsageSnapshot?.({
|
||||
provider: "minimax",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
token: "key",
|
||||
timeoutMs: 5000,
|
||||
fetchFn: fetchFn as typeof fetch,
|
||||
} as never);
|
||||
|
||||
expect(result?.windows).toEqual([{ label: "5h", usedPercent: 2, resetAt: undefined }]);
|
||||
});
|
||||
|
||||
it("writes api and authHeader into the MiniMax portal OAuth config patch", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: minimaxProviderPlugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
const portalProvider = requireRegisteredProvider(providers, "minimax-portal");
|
||||
const oauthMethod = portalProvider.auth.find((method) => method.id === "oauth");
|
||||
|
||||
if (!oauthMethod) {
|
||||
throw new Error("expected minimax portal oauth auth method");
|
||||
}
|
||||
|
||||
const result = await oauthMethod.run({
|
||||
prompter: {
|
||||
progress() {
|
||||
return { stop() {} };
|
||||
},
|
||||
note: vi.fn(async () => undefined),
|
||||
},
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
} as never);
|
||||
|
||||
expect(result?.configPatch?.models?.providers?.["minimax-portal"]).toEqual({
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
40
extensions/minimax/index.ts
Normal file
40
extensions/minimax/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Minimax plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
buildMinimaxImageGenerationProvider,
|
||||
buildMinimaxPortalImageGenerationProvider,
|
||||
} from "./image-generation-provider.js";
|
||||
import {
|
||||
minimaxMediaUnderstandingProvider,
|
||||
minimaxPortalMediaUnderstandingProvider,
|
||||
} from "./media-understanding-provider.js";
|
||||
import {
|
||||
buildMinimaxMusicGenerationProvider,
|
||||
buildMinimaxPortalMusicGenerationProvider,
|
||||
} from "./music-generation-provider.js";
|
||||
import { registerMinimaxProviders } from "./provider-registration.js";
|
||||
import { buildMinimaxSpeechProvider } from "./speech-provider.js";
|
||||
import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
|
||||
import {
|
||||
buildMinimaxVideoGenerationProvider,
|
||||
buildMinimaxPortalVideoGenerationProvider,
|
||||
} from "./video-generation-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
description: "Bundled MiniMax API-key and OAuth provider plugin",
|
||||
register(api) {
|
||||
registerMinimaxProviders(api);
|
||||
api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider);
|
||||
api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider);
|
||||
api.registerImageGenerationProvider(buildMinimaxImageGenerationProvider());
|
||||
api.registerImageGenerationProvider(buildMinimaxPortalImageGenerationProvider());
|
||||
api.registerMusicGenerationProvider(buildMinimaxMusicGenerationProvider());
|
||||
api.registerMusicGenerationProvider(buildMinimaxPortalMusicGenerationProvider());
|
||||
api.registerVideoGenerationProvider(buildMinimaxVideoGenerationProvider());
|
||||
api.registerVideoGenerationProvider(buildMinimaxPortalVideoGenerationProvider());
|
||||
api.registerSpeechProvider(buildMinimaxSpeechProvider());
|
||||
api.registerWebSearchProvider(createMiniMaxWebSearchProvider());
|
||||
},
|
||||
});
|
||||
26
extensions/minimax/media-understanding-provider.ts
Normal file
26
extensions/minimax/media-understanding-provider.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import {
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
type MediaUnderstandingProvider,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
|
||||
export const minimaxMediaUnderstandingProvider: MediaUnderstandingProvider = {
|
||||
id: "minimax",
|
||||
capabilities: ["image"],
|
||||
defaultModels: { image: "MiniMax-VL-01" },
|
||||
documentModels: { pdf: { textExtraction: "MiniMax-M2.7", image: false } },
|
||||
autoPriority: { image: 40 },
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
};
|
||||
|
||||
export const minimaxPortalMediaUnderstandingProvider: MediaUnderstandingProvider = {
|
||||
id: "minimax-portal",
|
||||
capabilities: ["image"],
|
||||
defaultModels: { image: "MiniMax-VL-01" },
|
||||
documentModels: { pdf: { textExtraction: "MiniMax-M2.7", image: false } },
|
||||
autoPriority: { image: 50 },
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
};
|
||||
135
extensions/minimax/minimax.live.test.ts
Normal file
135
extensions/minimax/minimax.live.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { resolveFfmpegBin } from "openclaw/plugin-sdk/media-runtime";
|
||||
// Minimax tests cover minimax plugin behavior.
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { buildMinimaxSpeechProvider } from "./speech-provider.js";
|
||||
import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
|
||||
|
||||
const MINIMAX_API_KEY = process.env.MINIMAX_API_KEY?.trim() ?? "";
|
||||
const MINIMAX_SEARCH_KEY =
|
||||
process.env.MINIMAX_CODE_PLAN_KEY?.trim() ||
|
||||
process.env.MINIMAX_CODING_API_KEY?.trim() ||
|
||||
process.env.MINIMAX_OAUTH_TOKEN?.trim() ||
|
||||
MINIMAX_API_KEY ||
|
||||
"";
|
||||
const MINIMAX_TTS_TOKEN_PLAN_KEY =
|
||||
process.env.MINIMAX_OAUTH_TOKEN?.trim() ||
|
||||
process.env.MINIMAX_CODE_PLAN_KEY?.trim() ||
|
||||
process.env.MINIMAX_CODING_API_KEY?.trim() ||
|
||||
"";
|
||||
const describeLive =
|
||||
isLiveTestEnabled() && MINIMAX_SEARCH_KEY.length > 0 ? describe : describe.skip;
|
||||
const describeTtsLive =
|
||||
isLiveTestEnabled() && MINIMAX_API_KEY.length > 0 ? describe : describe.skip;
|
||||
const describeTokenPlanTtsLive =
|
||||
isLiveTestEnabled() && MINIMAX_TTS_TOKEN_PLAN_KEY.length > 0 ? describe : describe.skip;
|
||||
|
||||
const registerMinimaxPlugin = () =>
|
||||
registerProviderPlugin({
|
||||
plugin,
|
||||
id: "minimax",
|
||||
name: "MiniMax Provider",
|
||||
});
|
||||
|
||||
function hasTrustedFfmpegForLiveVoiceNote(): boolean {
|
||||
try {
|
||||
resolveFfmpegBin();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("ffmpeg not found in trusted system directories")) {
|
||||
console.warn("[minimax:live] skip voice-note transcode: ffmpeg unavailable");
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describeLive("minimax plugin live", () => {
|
||||
it("runs MiniMax web search through the provider tool", async () => {
|
||||
const provider = createMiniMaxWebSearchProvider();
|
||||
const tool = provider.createTool?.({
|
||||
config: {},
|
||||
searchConfig: { apiKey: MINIMAX_SEARCH_KEY, cacheTtlMinutes: 0 },
|
||||
} as never);
|
||||
|
||||
const result = await tool?.execute({ query: "OpenClaw GitHub", count: 1 });
|
||||
|
||||
expect(result?.provider).toBe("minimax");
|
||||
expect(result?.count).toBeGreaterThan(0);
|
||||
expect(Array.isArray(result?.results)).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describeTtsLive("minimax tts live", () => {
|
||||
it("synthesizes TTS through the registered speech provider", async () => {
|
||||
const { speechProviders } = await registerMinimaxPlugin();
|
||||
const provider = requireRegisteredProvider(speechProviders, "minimax");
|
||||
|
||||
const audioFile = await provider.synthesize({
|
||||
text: "OpenClaw MiniMax text to speech integration test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: { apiKey: MINIMAX_API_KEY },
|
||||
target: "audio-file",
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(audioFile.outputFormat).toBe("mp3");
|
||||
expect(audioFile.fileExtension).toBe(".mp3");
|
||||
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
|
||||
}, 120_000);
|
||||
|
||||
it("synthesizes MiniMax TTS as an Opus voice note", async () => {
|
||||
if (!hasTrustedFfmpegForLiveVoiceNote()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = buildMinimaxSpeechProvider();
|
||||
|
||||
const voiceNote = await provider.synthesize({
|
||||
text: "OpenClaw MiniMax voice note test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: { apiKey: MINIMAX_API_KEY },
|
||||
target: "voice-note",
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(voiceNote.outputFormat).toBe("opus");
|
||||
expect(voiceNote.fileExtension).toBe(".opus");
|
||||
expect(voiceNote.voiceCompatible).toBe(true);
|
||||
expect(voiceNote.audioBuffer.byteLength).toBeGreaterThan(512);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describeTokenPlanTtsLive("minimax token plan tts live", () => {
|
||||
it("synthesizes TTS with Token Plan auth without MINIMAX_API_KEY", async () => {
|
||||
const savedApiKey = process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
try {
|
||||
const provider = buildMinimaxSpeechProvider();
|
||||
|
||||
const audioFile = await provider.synthesize({
|
||||
text: "OpenClaw MiniMax Token Plan text to speech integration test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(audioFile.outputFormat).toBe("mp3");
|
||||
expect(audioFile.fileExtension).toBe(".mp3");
|
||||
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
|
||||
} finally {
|
||||
if (savedApiKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = savedApiKey;
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
114
extensions/minimax/model-definitions.test.ts
Normal file
114
extensions/minimax/model-definitions.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// Minimax tests cover model definitions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMinimaxApiModelDefinition,
|
||||
buildMinimaxModelDefinition,
|
||||
DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
MINIMAX_API_COST,
|
||||
MINIMAX_API_HIGHSPEED_COST,
|
||||
MINIMAX_HOSTED_MODEL_ID,
|
||||
MINIMAX_M27_API_COST,
|
||||
MINIMAX_M25_API_COST,
|
||||
MINIMAX_M25_API_HIGHSPEED_COST,
|
||||
} from "./model-definitions.js";
|
||||
import { MINIMAX_TEXT_MODEL_CATALOG } from "./provider-models.js";
|
||||
|
||||
const MINIMAX_M3_CATALOG_CONTEXT_WINDOW = MINIMAX_TEXT_MODEL_CATALOG["MiniMax-M3"].contextWindow;
|
||||
|
||||
describe("minimax model definitions", () => {
|
||||
it("uses M3 as default hosted model", () => {
|
||||
expect(MINIMAX_HOSTED_MODEL_ID).toBe("MiniMax-M3");
|
||||
});
|
||||
|
||||
it("uses the current upstream MiniMax context, token, and pricing defaults", () => {
|
||||
expect(MINIMAX_M3_CATALOG_CONTEXT_WINDOW).toBe(1_000_000);
|
||||
expect(DEFAULT_MINIMAX_CONTEXT_WINDOW).toBe(204800);
|
||||
expect(DEFAULT_MINIMAX_MAX_TOKENS).toBe(131072);
|
||||
expect(MINIMAX_API_COST).toEqual({
|
||||
input: 0.6,
|
||||
output: 2.4,
|
||||
cacheRead: 0.12,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds catalog model with M3 metadata from the catalog", () => {
|
||||
const model = buildMinimaxModelDefinition({
|
||||
id: "MiniMax-M3",
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_M3_CATALOG_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
expect(model).toEqual({
|
||||
contextWindow: MINIMAX_M3_CATALOG_CONTEXT_WINDOW,
|
||||
cost: MINIMAX_API_COST,
|
||||
id: "MiniMax-M3",
|
||||
input: ["text", "image"],
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
name: "MiniMax M3",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds non-catalog model with generated name and default reasoning", () => {
|
||||
const model = buildMinimaxModelDefinition({
|
||||
id: "MiniMax-M2.5",
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
expect(model).toEqual({
|
||||
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
cost: MINIMAX_API_COST,
|
||||
id: "MiniMax-M2.5",
|
||||
input: ["text"],
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
name: "MiniMax MiniMax-M2.5",
|
||||
reasoning: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds API model definition with standard cost for M3", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-M3");
|
||||
expect(model.cost).toEqual(MINIMAX_API_COST);
|
||||
expect(model.contextWindow).toBe(MINIMAX_M3_CATALOG_CONTEXT_WINDOW);
|
||||
expect(model.maxTokens).toBe(DEFAULT_MINIMAX_MAX_TOKENS);
|
||||
expect(model.input).toEqual(["text", "image"]);
|
||||
});
|
||||
|
||||
it("falls back to generated name for unknown model id", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-Future");
|
||||
expect(model.name).toBe("MiniMax MiniMax-Future");
|
||||
expect(model.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps M2.7 on its existing price and text-only metadata", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-M2.7");
|
||||
expect(model.input).toEqual(["text"]);
|
||||
expect(model.cost).toEqual(MINIMAX_M27_API_COST);
|
||||
expect(model.contextWindow).toBe(DEFAULT_MINIMAX_CONTEXT_WINDOW);
|
||||
});
|
||||
|
||||
it("keeps M2.7 text-only on the Anthropic-compatible chat path", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-M2.7");
|
||||
expect(model.input).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("keeps M2.7-highspeed text-only on the Anthropic-compatible chat path", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-M2.7-highspeed");
|
||||
expect(model.input).toEqual(["text"]);
|
||||
expect(model.cost).toEqual(MINIMAX_API_HIGHSPEED_COST);
|
||||
});
|
||||
|
||||
it("M2.5 model remains text-only", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-M2.5");
|
||||
expect(model.input).toEqual(["text"]);
|
||||
expect(model.cost).toEqual(MINIMAX_M25_API_COST);
|
||||
});
|
||||
|
||||
it("M2.5-highspeed keeps the M2.5 cache-read pricing", () => {
|
||||
const model = buildMinimaxApiModelDefinition("MiniMax-M2.5-highspeed");
|
||||
expect(model.cost).toEqual(MINIMAX_M25_API_HIGHSPEED_COST);
|
||||
});
|
||||
});
|
||||
103
extensions/minimax/model-definitions.ts
Normal file
103
extensions/minimax/model-definitions.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Minimax plugin module implements model definitions behavior.
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { MINIMAX_DEFAULT_MODEL_ID, MINIMAX_TEXT_MODEL_CATALOG } from "./provider-models.js";
|
||||
|
||||
export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
|
||||
export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic";
|
||||
export const MINIMAX_HOSTED_MODEL_ID = MINIMAX_DEFAULT_MODEL_ID;
|
||||
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
|
||||
export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 204800;
|
||||
export const DEFAULT_MINIMAX_MAX_TOKENS = 131072;
|
||||
|
||||
export const MINIMAX_API_COST = {
|
||||
input: 0.6,
|
||||
output: 2.4,
|
||||
cacheRead: 0.12,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
export const MINIMAX_M27_API_COST = {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cacheRead: 0.06,
|
||||
cacheWrite: 0.375,
|
||||
};
|
||||
export const MINIMAX_API_HIGHSPEED_COST = {
|
||||
input: 0.6,
|
||||
output: 2.4,
|
||||
cacheRead: 0.06,
|
||||
cacheWrite: 0.375,
|
||||
};
|
||||
export const MINIMAX_M25_API_COST = {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.375,
|
||||
};
|
||||
export const MINIMAX_M25_API_HIGHSPEED_COST = {
|
||||
input: 0.6,
|
||||
output: 2.4,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.375,
|
||||
};
|
||||
export const MINIMAX_HOSTED_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
export const MINIMAX_LM_STUDIO_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
type MinimaxCatalogId = keyof typeof MINIMAX_TEXT_MODEL_CATALOG;
|
||||
|
||||
export function resolveMinimaxApiCost(modelId: string): ModelDefinitionConfig["cost"] {
|
||||
if (modelId === "MiniMax-M2.7") {
|
||||
return MINIMAX_M27_API_COST;
|
||||
}
|
||||
if (modelId === "MiniMax-M2.5-highspeed") {
|
||||
return MINIMAX_M25_API_HIGHSPEED_COST;
|
||||
}
|
||||
if (modelId === "MiniMax-M2.5") {
|
||||
return MINIMAX_M25_API_COST;
|
||||
}
|
||||
if (modelId === "MiniMax-M2.7-highspeed") {
|
||||
return MINIMAX_API_HIGHSPEED_COST;
|
||||
}
|
||||
return MINIMAX_API_COST;
|
||||
}
|
||||
|
||||
export function buildMinimaxModelDefinition(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
}): ModelDefinitionConfig {
|
||||
const catalog = MINIMAX_TEXT_MODEL_CATALOG[params.id as MinimaxCatalogId];
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`,
|
||||
reasoning: params.reasoning ?? catalog?.reasoning ?? false,
|
||||
input: [...(catalog?.input ?? ["text"])],
|
||||
cost: params.cost,
|
||||
contextWindow: params.contextWindow,
|
||||
maxTokens: params.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinitionConfig {
|
||||
return buildMinimaxModelDefinition({
|
||||
id: modelId,
|
||||
cost: resolveMinimaxApiCost(modelId),
|
||||
contextWindow:
|
||||
MINIMAX_TEXT_MODEL_CATALOG[modelId as MinimaxCatalogId]?.contextWindow ??
|
||||
DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
}
|
||||
441
extensions/minimax/music-generation-provider.test.ts
Normal file
441
extensions/minimax/music-generation-provider.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
// Minimax tests cover music generation provider plugin behavior.
|
||||
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getMinimaxProviderHttpMocks,
|
||||
installMinimaxProviderHttpMockCleanup,
|
||||
loadMinimaxMusicGenerationProviderModule,
|
||||
} from "./provider-http.test-helpers.js";
|
||||
|
||||
const {
|
||||
resolveApiKeyForProviderMock,
|
||||
postJsonRequestMock,
|
||||
fetchWithTimeoutMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
} = getMinimaxProviderHttpMocks();
|
||||
|
||||
let buildMinimaxMusicGenerationProvider: Awaited<
|
||||
ReturnType<typeof loadMinimaxMusicGenerationProviderModule>
|
||||
>["buildMinimaxMusicGenerationProvider"];
|
||||
let buildMinimaxPortalMusicGenerationProvider: Awaited<
|
||||
ReturnType<typeof loadMinimaxMusicGenerationProviderModule>
|
||||
>["buildMinimaxPortalMusicGenerationProvider"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildMinimaxMusicGenerationProvider, buildMinimaxPortalMusicGenerationProvider } =
|
||||
await loadMinimaxMusicGenerationProviderModule());
|
||||
});
|
||||
|
||||
installMinimaxProviderHttpMockCleanup();
|
||||
|
||||
function mockMusicGenerationResponse(json: Record<string, unknown>): void {
|
||||
const response = new Response(JSON.stringify(json), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response,
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValue({
|
||||
headers: new Headers({ "content-type": "audio/mpeg" }),
|
||||
arrayBuffer: async () => Buffer.from("mp3-bytes"),
|
||||
});
|
||||
}
|
||||
|
||||
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0): Record<string, unknown> {
|
||||
const call = mock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call ${index}`);
|
||||
}
|
||||
return call[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function streamedAudioResponse(bytes: string): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bytes));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "audio/mpeg" } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("minimax music generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitMusicGenerationCapabilities(buildMinimaxMusicGenerationProvider());
|
||||
});
|
||||
|
||||
it("streams generated music chunks from MiniMax", async () => {
|
||||
const chunkA = Buffer.from("ID3\x04\x00mp3-a");
|
||||
const chunkB = Buffer.from("mp3-b");
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
[
|
||||
`data: ${JSON.stringify({ data: { status: 1, audio: chunkA.toString("hex") }, base_resp: { status_code: 0 } })}`,
|
||||
`data: ${JSON.stringify({ data: { status: 1, audio: chunkB.toString("hex") }, base_resp: { status_code: 0 } })}`,
|
||||
`data: ${JSON.stringify({ data: { status: 2, audio: Buffer.concat([chunkA, chunkB]).toString("hex") }, base_resp: { status_code: 0 } })}`,
|
||||
"",
|
||||
].join("\n\n"),
|
||||
{
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
const result = await provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "",
|
||||
prompt: "upbeat dance-pop with female vocals",
|
||||
cfg: {},
|
||||
lyrics: "our city wakes",
|
||||
durationSeconds: 45,
|
||||
});
|
||||
|
||||
const request = mockCallArg(postJsonRequestMock);
|
||||
expect(request.url).toBe("https://api.minimax.io/v1/music_generation");
|
||||
const body = request.body as Record<string, unknown>;
|
||||
expect(body.model).toBe("music-2.6");
|
||||
expect(body.prompt).toBe("upbeat dance-pop with female vocals");
|
||||
expect(body.prompt).not.toContain("Target duration");
|
||||
expect(body).not.toHaveProperty("duration");
|
||||
expect(body.lyrics).toBe("our city wakes");
|
||||
expect(body.stream).toBe(true);
|
||||
expect(body.output_format).toBe("hex");
|
||||
expect(body.audio_setting).toEqual({
|
||||
sample_rate: 44100,
|
||||
bitrate: 256000,
|
||||
format: "mp3",
|
||||
});
|
||||
expect(request.timeoutMs).toBe(300000);
|
||||
expect(request?.headers).toBeInstanceOf(Headers);
|
||||
const headers = request?.headers as Headers | undefined;
|
||||
expect(headers?.get("content-type")).toBe("application/json");
|
||||
expect(result.tracks).toHaveLength(1);
|
||||
expect(result.tracks[0]?.buffer).toEqual(Buffer.concat([chunkA, chunkB]));
|
||||
expect(result.tracks[0]?.mimeType).toBe("audio/mpeg");
|
||||
expect(result.metadata?.requestedLyrics).toBe(true);
|
||||
expect(result.metadata).not.toHaveProperty("requestedDurationSeconds");
|
||||
});
|
||||
|
||||
it("reports streaming music task failures", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
`data: ${JSON.stringify({
|
||||
base_resp: { status_code: 0 },
|
||||
})}\n\ndata: ${JSON.stringify({
|
||||
base_resp: { status_code: 2013, status_msg: "render rejected" },
|
||||
})}`,
|
||||
{
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "upbeat dance-pop with female vocals",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("MiniMax music generation failed (2013): render rejected");
|
||||
});
|
||||
|
||||
it("keeps terminal streaming audio when no progressive chunks were sent", async () => {
|
||||
const terminalAudio = Buffer.from("terminal-mp3");
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
`data: ${JSON.stringify({
|
||||
data: { status: 2, audio: terminalAudio.toString("hex") },
|
||||
base_resp: { status_code: 0 },
|
||||
})}`,
|
||||
{
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
const result = await provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "upbeat dance-pop with female vocals",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.tracks[0]?.buffer).toEqual(terminalAudio);
|
||||
});
|
||||
|
||||
it("rejects streamed generated music that exceeds the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
`data: ${JSON.stringify({
|
||||
data: { status: 2, audio: Buffer.from("too-large").toString("hex") },
|
||||
base_resp: { status_code: 0 },
|
||||
})}`,
|
||||
{
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
await expect(
|
||||
provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "short track",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
).rejects.toThrow("MiniMax generated music download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("rejects inline generated music that exceeds the configured media cap before decoding", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
audio: Buffer.from("too-large").toString("hex"),
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
{
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
await expect(
|
||||
provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "short track",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
).rejects.toThrow("MiniMax generated music download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("downloads tracks when url output is returned in data.audio", async () => {
|
||||
mockMusicGenerationResponse({
|
||||
task_id: "task-url",
|
||||
lyrics: "our city wakes",
|
||||
data: {
|
||||
audio: "https://example.com/url-audio.mp3",
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
const result = await provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "upbeat dance-pop with female vocals",
|
||||
cfg: {},
|
||||
lyrics: "our city wakes",
|
||||
});
|
||||
|
||||
expect(fetchWithTimeoutMock).toHaveBeenCalledWith(
|
||||
"https://example.com/url-audio.mp3",
|
||||
{ method: "GET" },
|
||||
120000,
|
||||
fetch,
|
||||
);
|
||||
expect(result.tracks[0]?.buffer.byteLength).toBeGreaterThan(0);
|
||||
expect(result.lyrics).toEqual(["our city wakes"]);
|
||||
expect(result.metadata?.taskId).toBe("task-url");
|
||||
expect(result.metadata?.audioUrl).toBe("https://example.com/url-audio.mp3");
|
||||
});
|
||||
|
||||
it("rejects generated music downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
audio: "https://example.com/too-large.mp3",
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(streamedAudioResponse("too-large"));
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
await expect(
|
||||
provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "short track",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
).rejects.toThrow("MiniMax generated music download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("honors explicit long caller timeouts for request and download fallbacks", async () => {
|
||||
mockMusicGenerationResponse({
|
||||
data: {
|
||||
audio: "https://example.com/long-timeout.mp3",
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
await provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "upbeat dance-pop with female vocals",
|
||||
cfg: {},
|
||||
lyrics: "our city wakes",
|
||||
timeoutMs: 600000,
|
||||
});
|
||||
|
||||
expect(mockCallArg(postJsonRequestMock).timeoutMs).toBe(600000);
|
||||
expect(fetchWithTimeoutMock).toHaveBeenCalledWith(
|
||||
"https://example.com/long-timeout.mp3",
|
||||
{ method: "GET" },
|
||||
600000,
|
||||
fetch,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies explicit caller timeouts while reading streaming response bodies", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
setTimeout(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`data: ${JSON.stringify({
|
||||
data: { status: 2, audio: Buffer.from("late-mp3").toString("hex") },
|
||||
base_resp: { status_code: 0 },
|
||||
})}`,
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
}, 200);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(stream, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
const generation = provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "upbeat dance-pop with female vocals",
|
||||
cfg: {},
|
||||
timeoutMs: 50,
|
||||
});
|
||||
const expectation = expect(generation).rejects.toThrow(
|
||||
"MiniMax music generation timed out after 50ms",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await expectation;
|
||||
expect(cancelled).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects instrumental requests that also include lyrics", async () => {
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "driving techno",
|
||||
cfg: {},
|
||||
instrumental: true,
|
||||
lyrics: "do not sing this",
|
||||
}),
|
||||
).rejects.toThrow("cannot use lyrics when instrumental=true");
|
||||
});
|
||||
|
||||
it("uses lyrics optimizer when lyrics are omitted", async () => {
|
||||
mockMusicGenerationResponse({
|
||||
task_id: "task-456",
|
||||
audio_url: "https://example.com/out.mp3",
|
||||
base_resp: { status_code: 0 },
|
||||
});
|
||||
|
||||
const provider = buildMinimaxMusicGenerationProvider();
|
||||
await provider.generateMusic({
|
||||
provider: "minimax",
|
||||
model: "music-2.6",
|
||||
prompt: "upbeat dance-pop",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
const request = mockCallArg(postJsonRequestMock);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
expect(body.model).toBe("music-2.6");
|
||||
expect(body.lyrics_optimizer).toBe(true);
|
||||
});
|
||||
|
||||
it("routes portal music generation through minimax-portal auth and HTTP config", async () => {
|
||||
mockMusicGenerationResponse({
|
||||
task_id: "task-portal",
|
||||
audio_url: "https://example.com/portal.mp3",
|
||||
base_resp: { status_code: 0 },
|
||||
});
|
||||
|
||||
const provider = buildMinimaxPortalMusicGenerationProvider();
|
||||
await provider.generateMusic({
|
||||
provider: "minimax-portal",
|
||||
model: "",
|
||||
prompt: "cinematic synth theme",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://wrong.example/anthropic",
|
||||
models: [],
|
||||
},
|
||||
"minimax-portal": {
|
||||
baseUrl: "https://api.minimaxi.com/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockCallArg(resolveApiKeyForProviderMock).provider).toBe("minimax-portal");
|
||||
const httpConfigParams = mockCallArg(resolveProviderHttpRequestConfigMock);
|
||||
expect(httpConfigParams.baseUrl).toBe("https://api.minimaxi.com");
|
||||
expect(httpConfigParams.provider).toBe("minimax-portal");
|
||||
expect(httpConfigParams.capability).toBe("audio");
|
||||
expect(httpConfigParams.transport).toBe("http");
|
||||
expect(mockCallArg(postJsonRequestMock).url).toBe(
|
||||
"https://api.minimaxi.com/v1/music_generation",
|
||||
);
|
||||
});
|
||||
});
|
||||
480
extensions/minimax/music-generation-provider.ts
Normal file
480
extensions/minimax/music-generation-provider.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import type {
|
||||
GeneratedMusicAsset,
|
||||
MusicGenerationProvider,
|
||||
MusicGenerationRequest,
|
||||
} from "openclaw/plugin-sdk/music-generation";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
createProviderOperationDeadline,
|
||||
fetchProviderDownloadResponse,
|
||||
postJsonRequest,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
resolveProviderHttpRequestConfig,
|
||||
type ProviderOperationDeadline,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const DEFAULT_MINIMAX_MUSIC_BASE_URL = "https://api.minimax.io";
|
||||
const DEFAULT_MINIMAX_MUSIC_MODEL = "music-2.6";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 300_000;
|
||||
const DEFAULT_GENERATED_MUSIC_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const STREAM_ENVELOPE_MAX_BYTES_MULTIPLIER = 5;
|
||||
const STREAM_ENVELOPE_OVERHEAD_BYTES = 64 * 1024;
|
||||
|
||||
type MinimaxBaseResp = {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
|
||||
type MinimaxMusicCreateResponse = {
|
||||
task_id?: string;
|
||||
audio?: string;
|
||||
audio_url?: string;
|
||||
lyrics?: string;
|
||||
data?: {
|
||||
audio?: string;
|
||||
audio_url?: string;
|
||||
lyrics?: string;
|
||||
};
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
type MinimaxMusicStreamFrame = {
|
||||
data?: {
|
||||
audio?: string;
|
||||
status?: number | string;
|
||||
};
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
function resolveMinimaxMusicBaseUrl(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
providerId: string,
|
||||
): string {
|
||||
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
|
||||
if (!direct) {
|
||||
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
|
||||
}
|
||||
try {
|
||||
return new URL(direct).origin;
|
||||
} catch {
|
||||
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: string): void {
|
||||
if (!baseResp || typeof baseResp.status_code !== "number" || baseResp.status_code === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function decodePossibleBinaryWithLimit(data: string, maxBytes: number): Buffer {
|
||||
const trimmed = data.trim();
|
||||
if (/^[0-9a-f]+$/iu.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
if (trimmed.length / 2 > maxBytes) {
|
||||
throw createGeneratedMusicTooLargeError(maxBytes);
|
||||
}
|
||||
return Buffer.from(trimmed, "hex");
|
||||
}
|
||||
if (Buffer.byteLength(trimmed, "base64") > maxBytes) {
|
||||
throw createGeneratedMusicTooLargeError(maxBytes);
|
||||
}
|
||||
return Buffer.from(trimmed, "base64");
|
||||
}
|
||||
|
||||
function decodePossibleText(data: string): string {
|
||||
const trimmed = data.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (/^[0-9a-f]+$/iu.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
return Buffer.from(trimmed, "hex").toString("utf8").trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isLikelyRemoteUrl(value: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
return Boolean(trimmed && /^https?:\/\//iu.test(trimmed));
|
||||
}
|
||||
|
||||
function resolveGeneratedMusicMaxBytes(req: MusicGenerationRequest): number {
|
||||
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return Math.floor(configured * 1024 * 1024);
|
||||
}
|
||||
return DEFAULT_GENERATED_MUSIC_MAX_BYTES;
|
||||
}
|
||||
|
||||
async function downloadTrackFromUrl(params: {
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
fetchFn: typeof fetch;
|
||||
maxBytes: number;
|
||||
}): Promise<GeneratedMusicAsset> {
|
||||
const response = await fetchProviderDownloadResponse({
|
||||
url: params.url,
|
||||
init: { method: "GET" },
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: params.fetchFn,
|
||||
provider: "minimax",
|
||||
requestFailedMessage: "MiniMax generated music download failed",
|
||||
});
|
||||
const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "audio/mpeg";
|
||||
const ext = extensionForMime(mimeType)?.replace(/^\./u, "") || "mp3";
|
||||
return {
|
||||
buffer: await readResponseWithLimit(response, params.maxBytes, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`MiniMax generated music download exceeds ${maxBytes} bytes`),
|
||||
}),
|
||||
mimeType,
|
||||
fileName: `track-1.${ext}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createMinimaxMusicTimeoutError(deadline: ProviderOperationDeadline): Error {
|
||||
const timeoutLabel =
|
||||
typeof deadline.timeoutMs === "number" ? ` after ${deadline.timeoutMs}ms` : "";
|
||||
return new Error(`${deadline.label} timed out${timeoutLabel}`);
|
||||
}
|
||||
|
||||
function resolveBodyReadTimeoutMs(deadline: ProviderOperationDeadline): number {
|
||||
return resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: deadline.timeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function createGeneratedMusicTooLargeError(maxBytes: number): Error {
|
||||
return new Error(`MiniMax generated music download exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
function resolveStreamEnvelopeMaxBytes(maxBytes: number): number {
|
||||
return Math.max(
|
||||
STREAM_ENVELOPE_OVERHEAD_BYTES,
|
||||
maxBytes * STREAM_ENVELOPE_MAX_BYTES_MULTIPLIER + STREAM_ENVELOPE_OVERHEAD_BYTES,
|
||||
);
|
||||
}
|
||||
|
||||
async function readResponseBufferWithDeadline(
|
||||
response: Response,
|
||||
deadline: ProviderOperationDeadline,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const timeoutMs = resolveBodyReadTimeoutMs(deadline);
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(createMinimaxMusicTimeoutError(deadline)), timeoutMs);
|
||||
});
|
||||
const result = await Promise.race([reader.read(), timeoutPromise]);
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
if (!result.value || result.value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const nextTotalBytes = totalBytes + result.value.byteLength;
|
||||
if (nextTotalBytes > maxBytes) {
|
||||
const error = createGeneratedMusicTooLargeError(maxBytes);
|
||||
try {
|
||||
await reader.cancel(error);
|
||||
} catch {
|
||||
// Preserve the size-limit failure that caused cancellation.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
chunks.push(result.value);
|
||||
totalBytes = nextTotalBytes;
|
||||
} catch (error) {
|
||||
try {
|
||||
await reader.cancel(error);
|
||||
} catch {
|
||||
// Preserve the timeout or stream read failure that caused cancellation.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const buffer = Buffer.allocUnsafe(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function readStreamingTrack(
|
||||
response: Response,
|
||||
deadline: ProviderOperationDeadline,
|
||||
maxBytes: number,
|
||||
): Promise<GeneratedMusicAsset> {
|
||||
const contentType = normalizeOptionalString(response.headers.get("content-type")) ?? "";
|
||||
if (contentType.toLowerCase().startsWith("audio/")) {
|
||||
const ext = extensionForMime(contentType)?.replace(/^\./u, "") || "mp3";
|
||||
return {
|
||||
buffer: await readResponseBufferWithDeadline(response, deadline, maxBytes),
|
||||
mimeType: contentType,
|
||||
fileName: `track-1.${ext}`,
|
||||
};
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let decodedBytes = 0;
|
||||
const text = new TextDecoder().decode(
|
||||
await readResponseBufferWithDeadline(
|
||||
response,
|
||||
deadline,
|
||||
resolveStreamEnvelopeMaxBytes(maxBytes),
|
||||
),
|
||||
);
|
||||
for (const rawLine of text.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("data:")) {
|
||||
continue;
|
||||
}
|
||||
const json = line.slice("data:".length).trim();
|
||||
if (!json || json === "[DONE]") {
|
||||
continue;
|
||||
}
|
||||
const frame = JSON.parse(json) as MinimaxMusicStreamFrame;
|
||||
assertMinimaxBaseResp(frame.base_resp, "MiniMax music generation failed");
|
||||
const audio = normalizeOptionalString(frame.data?.audio);
|
||||
if (audio) {
|
||||
if (String(frame.data?.status ?? "") === "2" && chunks.length > 0) {
|
||||
continue;
|
||||
}
|
||||
const chunk = decodePossibleBinaryWithLimit(audio, maxBytes - decodedBytes);
|
||||
const nextDecodedBytes = decodedBytes + chunk.byteLength;
|
||||
if (nextDecodedBytes > maxBytes) {
|
||||
throw createGeneratedMusicTooLargeError(maxBytes);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
decodedBytes = nextDecodedBytes;
|
||||
}
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error("MiniMax music generation response missing audio output");
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
mimeType: "audio/mpeg",
|
||||
fileName: "track-1.mp3",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMinimaxMusicModel(model: string | undefined): string {
|
||||
const trimmed = normalizeOptionalString(model);
|
||||
if (!trimmed) {
|
||||
return DEFAULT_MINIMAX_MUSIC_MODEL;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider {
|
||||
return {
|
||||
id: providerId,
|
||||
label: "MiniMax",
|
||||
defaultModel: DEFAULT_MINIMAX_MUSIC_MODEL,
|
||||
models: [DEFAULT_MINIMAX_MUSIC_MODEL, "music-2.6-free", "music-cover", "music-cover-free"],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: providerId,
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxTracks: 1,
|
||||
supportsLyrics: true,
|
||||
supportsInstrumental: true,
|
||||
supportsFormat: true,
|
||||
supportedFormats: ["mp3"],
|
||||
},
|
||||
edit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
async generateMusic(req) {
|
||||
if ((req.inputImages?.length ?? 0) > 0) {
|
||||
throw new Error("MiniMax music generation does not support image reference inputs.");
|
||||
}
|
||||
if (req.instrumental === true && normalizeOptionalString(req.lyrics)) {
|
||||
throw new Error("MiniMax music generation cannot use lyrics when instrumental=true.");
|
||||
}
|
||||
if (req.format && req.format !== "mp3") {
|
||||
throw new Error("MiniMax music generation currently supports mp3 output only.");
|
||||
}
|
||||
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: providerId,
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("MiniMax API key missing");
|
||||
}
|
||||
|
||||
const fetchFn = fetch;
|
||||
const operationTimeoutMs = req.timeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: operationTimeoutMs,
|
||||
label: "MiniMax music generation",
|
||||
});
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolveMinimaxMusicBaseUrl(req.cfg, providerId),
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_MUSIC_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
},
|
||||
provider: providerId,
|
||||
capability: "audio",
|
||||
transport: "http",
|
||||
});
|
||||
const jsonHeaders = new Headers(headers);
|
||||
jsonHeaders.set("Content-Type", "application/json");
|
||||
|
||||
const model = resolveMinimaxMusicModel(req.model);
|
||||
const requestedLyrics = normalizeOptionalString(req.lyrics);
|
||||
const body = {
|
||||
model,
|
||||
prompt: req.prompt.trim(),
|
||||
...(req.instrumental === true ? { is_instrumental: true } : {}),
|
||||
...(requestedLyrics
|
||||
? { lyrics: requestedLyrics }
|
||||
: req.instrumental === true
|
||||
? {}
|
||||
: { lyrics_optimizer: true }),
|
||||
stream: true,
|
||||
output_format: "hex",
|
||||
audio_setting: {
|
||||
sample_rate: 44_100,
|
||||
bitrate: 256_000,
|
||||
format: "mp3",
|
||||
},
|
||||
};
|
||||
|
||||
const { response: res, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/v1/music_generation`,
|
||||
headers: jsonHeaders,
|
||||
body,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: operationTimeoutMs,
|
||||
}),
|
||||
fetchFn,
|
||||
pinDns: false,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(res, "MiniMax music generation failed");
|
||||
const contentType = normalizeOptionalString(res.headers.get("content-type")) ?? "";
|
||||
const lowerContentType = contentType.toLowerCase();
|
||||
const maxGeneratedMusicBytes = resolveGeneratedMusicMaxBytes(req);
|
||||
const payload =
|
||||
lowerContentType.includes("text/event-stream") || lowerContentType.startsWith("audio/")
|
||||
? null
|
||||
: (JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
await readResponseBufferWithDeadline(
|
||||
res.clone(),
|
||||
deadline,
|
||||
resolveStreamEnvelopeMaxBytes(maxGeneratedMusicBytes),
|
||||
),
|
||||
),
|
||||
) as MinimaxMusicCreateResponse);
|
||||
if (payload) {
|
||||
assertMinimaxBaseResp(payload.base_resp, "MiniMax music generation failed");
|
||||
}
|
||||
|
||||
const audioCandidate =
|
||||
normalizeOptionalString(payload?.audio) ?? normalizeOptionalString(payload?.data?.audio);
|
||||
const audioUrl =
|
||||
normalizeOptionalString(payload?.audio_url) ||
|
||||
normalizeOptionalString(payload?.data?.audio_url) ||
|
||||
(isLikelyRemoteUrl(audioCandidate) ? audioCandidate : undefined);
|
||||
const inlineAudio = isLikelyRemoteUrl(audioCandidate) ? undefined : audioCandidate;
|
||||
const responseLyrics = decodePossibleText(payload?.lyrics ?? payload?.data?.lyrics ?? "");
|
||||
|
||||
const track = audioUrl
|
||||
? await downloadTrackFromUrl({
|
||||
url: audioUrl,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
maxBytes: resolveGeneratedMusicMaxBytes(req),
|
||||
})
|
||||
: inlineAudio
|
||||
? (() => {
|
||||
const buffer = decodePossibleBinaryWithLimit(inlineAudio, maxGeneratedMusicBytes);
|
||||
return {
|
||||
buffer,
|
||||
mimeType: "audio/mpeg",
|
||||
fileName: "track-1.mp3",
|
||||
};
|
||||
})()
|
||||
: await readStreamingTrack(res, deadline, maxGeneratedMusicBytes);
|
||||
if (!track) {
|
||||
throw new Error("MiniMax music generation response missing audio output");
|
||||
}
|
||||
|
||||
return {
|
||||
tracks: [track],
|
||||
...(responseLyrics ? { lyrics: [responseLyrics] } : {}),
|
||||
model,
|
||||
metadata: {
|
||||
...(normalizeOptionalString(payload?.task_id)
|
||||
? { taskId: normalizeOptionalString(payload?.task_id) }
|
||||
: {}),
|
||||
...(audioUrl ? { audioUrl } : {}),
|
||||
instrumental: req.instrumental === true,
|
||||
...(requestedLyrics ? { requestedLyrics: true } : {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider {
|
||||
return buildMinimaxMusicProvider("minimax");
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalMusicGenerationProvider(): MusicGenerationProvider {
|
||||
return buildMinimaxMusicProvider("minimax-portal");
|
||||
}
|
||||
2
extensions/minimax/oauth.runtime.ts
Normal file
2
extensions/minimax/oauth.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Minimax plugin module implements oauth behavior.
|
||||
export { loginMiniMaxPortalOAuth } from "./oauth.js";
|
||||
402
extensions/minimax/oauth.test.ts
Normal file
402
extensions/minimax/oauth.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
// Minimax tests cover oauth plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.js";
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("normalizeOAuthExpires", () => {
|
||||
it("converts relative expiry seconds into an absolute millisecond timestamp", () => {
|
||||
expect(normalizeOAuthExpires(86_400, 1_700_000_000_000)).toBe(1_700_086_400_000);
|
||||
});
|
||||
|
||||
it("converts Unix second timestamps into milliseconds", () => {
|
||||
expect(normalizeOAuthExpires(1_700_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("preserves absolute millisecond timestamps", () => {
|
||||
expect(normalizeOAuthExpires(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("rejects unsafe and malformed expiry values", () => {
|
||||
expect(normalizeOAuthExpires(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(normalizeOAuthExpires(Number.MAX_SAFE_INTEGER + 1)).toBeUndefined();
|
||||
expect(normalizeOAuthExpires("3600s")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginMiniMaxPortalOAuth", () => {
|
||||
it("bounds authorization error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(
|
||||
`${"minimax authorization unavailable ".repeat(1024)}tail`,
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
},
|
||||
);
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => tracked.response),
|
||||
);
|
||||
|
||||
const error = await loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(
|
||||
/MiniMax OAuth authorization failed: minimax authorization unavailable/,
|
||||
);
|
||||
expect((error as Error).message).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds token error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"minimax token unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
let callCount = 0;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
callCount += 1;
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: Date.now() + 10_000,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return tracked.response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const error = await loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain("minimax token unavailable");
|
||||
expect((error as Error).message).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds HTTP 200 token bodies before app-level parsing", async () => {
|
||||
const tracked = cancelTrackedResponse(`${'{"status":"error","detail":"'.repeat(512)}tail`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
let callCount = 0;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
callCount += 1;
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: Date.now() + 10_000,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return tracked.response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const error = await loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("MiniMax OAuth failed to parse response.");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses MiniMax account OAuth endpoints directly for global and CN login", async () => {
|
||||
for (const [region, expectedHosts] of [
|
||||
[
|
||||
"global",
|
||||
[
|
||||
"https://account.minimax.io/oauth2/device/code",
|
||||
"https://account.minimax.io/oauth2/token",
|
||||
],
|
||||
],
|
||||
[
|
||||
"cn",
|
||||
[
|
||||
"https://account.minimaxi.com/oauth2/device/code",
|
||||
"https://account.minimaxi.com/oauth2/token",
|
||||
],
|
||||
],
|
||||
] as const) {
|
||||
const requestedUrls: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requestedUrls.push(input instanceof Request ? input.url : String(input));
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
if (requestedUrls.length === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: Date.now() + 10_000,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: "success",
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
expired_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
loginMiniMaxPortalOAuth({
|
||||
region,
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
}),
|
||||
).resolves.toMatchObject({ access: "access", refresh: "refresh" });
|
||||
expect(requestedUrls).toEqual(expectedHosts);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Date-invalid authorization expiries before formatting instructions", async () => {
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: 8_700_000_000_000_000,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const note = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note,
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
}),
|
||||
).rejects.toThrow("invalid expired_in");
|
||||
expect(note).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps oversized authorization poll intervals before scheduling", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let callCount = 0;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
callCount += 1;
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: Date.now() + MAX_TIMER_TIMEOUT_MS + 10_000,
|
||||
interval: Number.MAX_SAFE_INTEGER,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
callCount === 2
|
||||
? { status: "pending" }
|
||||
: {
|
||||
status: "success",
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
expired_in: 3600,
|
||||
},
|
||||
),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MAX_TIMER_TIMEOUT_MS);
|
||||
await expect(result).resolves.toMatchObject({ access: "access", refresh: "refresh" });
|
||||
});
|
||||
|
||||
it("does not sleep past the authorization expiry deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let callCount = 0;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
callCount += 1;
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: Date.now() + 10_000,
|
||||
interval: Number.MAX_SAFE_INTEGER,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "pending" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10_000);
|
||||
});
|
||||
|
||||
const rejection = expect(result).rejects.toThrow("timed out");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("keeps the default poll delay for zero authorization intervals", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let callCount = 0;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
callCount += 1;
|
||||
const body =
|
||||
init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user_code: "CODE",
|
||||
verification_uri: "https://example.com/device",
|
||||
expired_in: Date.now() + 10_000,
|
||||
interval: 0,
|
||||
state: body.get("state"),
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
callCount === 2
|
||||
? { status: "pending" }
|
||||
: {
|
||||
status: "success",
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
expired_in: 3600,
|
||||
},
|
||||
),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = loginMiniMaxPortalOAuth({
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
progress: { update: vi.fn(), stop: vi.fn() },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_000);
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await expect(result).resolves.toMatchObject({ access: "access", refresh: "refresh" });
|
||||
});
|
||||
});
|
||||
305
extensions/minimax/oauth.ts
Normal file
305
extensions/minimax/oauth.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
// Minimax plugin module implements oauth behavior.
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import {
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
asSafeIntegerInRange,
|
||||
resolveExpiresAtMsFromDurationOrEpoch,
|
||||
resolvePositiveTimerTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
export type MiniMaxRegion = "cn" | "global";
|
||||
|
||||
const MINIMAX_OAUTH_CONFIG = {
|
||||
cn: {
|
||||
baseUrl: "https://api.minimaxi.com",
|
||||
oauthBaseUrl: "https://account.minimaxi.com",
|
||||
clientId: "78257093-7e40-4613-99e0-527b14b39113",
|
||||
},
|
||||
global: {
|
||||
baseUrl: "https://api.minimax.io",
|
||||
oauthBaseUrl: "https://account.minimax.io",
|
||||
clientId: "78257093-7e40-4613-99e0-527b14b39113",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion";
|
||||
const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code";
|
||||
const MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD = 1_000_000_000;
|
||||
const MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD = 1_000_000_000_000;
|
||||
const MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
function getOAuthEndpoints(region: MiniMaxRegion) {
|
||||
const config = MINIMAX_OAUTH_CONFIG[region];
|
||||
return {
|
||||
codeEndpoint: `${config.oauthBaseUrl}/oauth2/device/code`,
|
||||
tokenEndpoint: `${config.oauthBaseUrl}/oauth2/token`,
|
||||
clientId: config.clientId,
|
||||
baseUrl: config.baseUrl,
|
||||
hostname: new URL(config.oauthBaseUrl).hostname,
|
||||
};
|
||||
}
|
||||
|
||||
type MiniMaxOAuthAuthorization = {
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expired_in: number;
|
||||
interval?: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
type MiniMaxOAuthToken = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
resourceUrl?: string;
|
||||
notification_message?: string;
|
||||
};
|
||||
|
||||
type TokenPending = { status: "pending"; message?: string };
|
||||
|
||||
type TokenResult =
|
||||
| { status: "success"; token: MiniMaxOAuthToken }
|
||||
| TokenPending
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Normalize MiniMax token endpoint `expired_in` values to the auth-profile
|
||||
* contract: absolute Unix milliseconds.
|
||||
*/
|
||||
export function normalizeOAuthExpires(expiredIn: unknown, now = Date.now()): number | undefined {
|
||||
return resolveExpiresAtMsFromDurationOrEpoch(expiredIn, {
|
||||
nowMs: now,
|
||||
relativeSecondsThreshold: MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD,
|
||||
absoluteMillisecondsThreshold: MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOAuthAuthorizationExpires(expiredIn: unknown): number | undefined {
|
||||
return asSafeIntegerInRange(expiredIn, { min: 1, max: MAX_DATE_TIMESTAMP_MS });
|
||||
}
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string; state: string } {
|
||||
const { verifier, challenge } = generatePkceVerifierChallenge();
|
||||
const state = randomBytes(16).toString("base64url");
|
||||
return { verifier, challenge, state };
|
||||
}
|
||||
|
||||
async function requestOAuthCode(params: {
|
||||
challenge: string;
|
||||
state: string;
|
||||
region: MiniMaxRegion;
|
||||
}): Promise<MiniMaxOAuthAuthorization> {
|
||||
const endpoints = getOAuthEndpoints(params.region);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: endpoints.codeEndpoint,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"x-request-id": randomUUID(),
|
||||
},
|
||||
body: toFormUrlEncoded({
|
||||
response_type: "code",
|
||||
client_id: endpoints.clientId,
|
||||
scope: MINIMAX_OAUTH_SCOPE,
|
||||
code_challenge: params.challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: params.state,
|
||||
}),
|
||||
},
|
||||
policy: { allowedHostnames: [endpoints.hostname] },
|
||||
auditContext: "minimax.oauth.code",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
|
||||
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await readProviderJsonResponse(
|
||||
response,
|
||||
"minimax.oauth-code",
|
||||
)) as MiniMaxOAuthAuthorization & { error?: string };
|
||||
if (!payload.user_code || !payload.verification_uri) {
|
||||
throw new Error(
|
||||
payload.error ??
|
||||
"MiniMax OAuth authorization returned an incomplete payload (missing user_code or verification_uri).",
|
||||
);
|
||||
}
|
||||
if (payload.state !== params.state) {
|
||||
throw new Error("MiniMax OAuth state mismatch: possible CSRF attack or session corruption.");
|
||||
}
|
||||
const expiredIn = normalizeOAuthAuthorizationExpires(payload.expired_in);
|
||||
if (expiredIn === undefined) {
|
||||
throw new Error("MiniMax OAuth authorization returned invalid expired_in.");
|
||||
}
|
||||
return { ...payload, expired_in: expiredIn };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOAuthToken(params: {
|
||||
userCode: string;
|
||||
verifier: string;
|
||||
region: MiniMaxRegion;
|
||||
}): Promise<TokenResult> {
|
||||
const endpoints = getOAuthEndpoints(params.region);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: endpoints.tokenEndpoint,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: toFormUrlEncoded({
|
||||
grant_type: MINIMAX_OAUTH_GRANT_TYPE,
|
||||
client_id: endpoints.clientId,
|
||||
user_code: params.userCode,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
},
|
||||
policy: { allowedHostnames: [endpoints.hostname] },
|
||||
auditContext: "minimax.oauth.token",
|
||||
});
|
||||
try {
|
||||
return await parseMiniMaxOAuthTokenResponse(response);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function parseMiniMaxOAuthTokenResponse(response: Response): Promise<TokenResult> {
|
||||
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
|
||||
let payload:
|
||||
| {
|
||||
status?: string;
|
||||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
}
|
||||
| undefined;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text) as typeof payload;
|
||||
} catch {
|
||||
payload = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: "error",
|
||||
message:
|
||||
(payload?.base_resp?.status_msg ?? text) || "MiniMax OAuth failed to parse response.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return { status: "error", message: "MiniMax OAuth failed to parse response." };
|
||||
}
|
||||
|
||||
const tokenPayload = payload as {
|
||||
status: string;
|
||||
access_token?: string | null;
|
||||
refresh_token?: string | null;
|
||||
expired_in?: unknown;
|
||||
token_type?: string;
|
||||
resource_url?: string;
|
||||
notification_message?: string;
|
||||
};
|
||||
|
||||
if (tokenPayload.status === "error") {
|
||||
return { status: "error", message: "An error occurred. Please try again later" };
|
||||
}
|
||||
|
||||
if (tokenPayload.status !== "success") {
|
||||
return { status: "pending", message: "current user code is not authorized" };
|
||||
}
|
||||
|
||||
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expired_in) {
|
||||
return { status: "error", message: "MiniMax OAuth returned incomplete token payload." };
|
||||
}
|
||||
const expires = normalizeOAuthExpires(tokenPayload.expired_in);
|
||||
if (expires === undefined) {
|
||||
return { status: "error", message: "MiniMax OAuth returned invalid token expiry." };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
token: {
|
||||
access: tokenPayload.access_token,
|
||||
refresh: tokenPayload.refresh_token,
|
||||
expires,
|
||||
resourceUrl: tokenPayload.resource_url,
|
||||
notification_message: tokenPayload.notification_message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginMiniMaxPortalOAuth(params: {
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
progress: { update: (message: string) => void; stop: (message?: string) => void };
|
||||
region?: MiniMaxRegion;
|
||||
}): Promise<MiniMaxOAuthToken> {
|
||||
// Ensure env-based proxy dispatcher is active before any outbound fetch calls.
|
||||
// Without this, HTTP_PROXY/HTTPS_PROXY env vars are silently ignored (#51619).
|
||||
ensureGlobalUndiciEnvProxyDispatcher();
|
||||
const region = params.region ?? "global";
|
||||
const { verifier, challenge, state } = generatePkce();
|
||||
const oauth = await requestOAuthCode({ challenge, state, region });
|
||||
const verificationUrl = oauth.verification_uri;
|
||||
|
||||
const noteLines = [
|
||||
`Open ${verificationUrl} to approve access.`,
|
||||
`If prompted, enter the code ${oauth.user_code}.`,
|
||||
`Interval: ${oauth.interval ?? "default (2000ms)"}, Expires at: ${new Date(oauth.expired_in).toISOString()}`,
|
||||
];
|
||||
await params.note(noteLines.join("\n"), "MiniMax OAuth");
|
||||
|
||||
try {
|
||||
await params.openUrl(verificationUrl);
|
||||
} catch {
|
||||
// Fall back to manual copy/paste if browser open fails.
|
||||
}
|
||||
|
||||
let pollIntervalMs = resolvePositiveTimerTimeoutMs(oauth.interval, 2000);
|
||||
// The authorization endpoint returns an absolute millisecond deadline.
|
||||
const expireTimeMs = oauth.expired_in;
|
||||
|
||||
while (Date.now() < expireTimeMs) {
|
||||
params.progress.update("Waiting for MiniMax OAuth approval…");
|
||||
const result = await pollOAuthToken({
|
||||
userCode: oauth.user_code,
|
||||
verifier,
|
||||
region,
|
||||
});
|
||||
|
||||
if (result.status === "success") {
|
||||
return result.token;
|
||||
}
|
||||
|
||||
if (result.status === "error") {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(0, expireTimeMs - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.min(pollIntervalMs, remainingMs));
|
||||
});
|
||||
pollIntervalMs = Math.max(pollIntervalMs, 2000);
|
||||
}
|
||||
|
||||
throw new Error("MiniMax OAuth timed out before authorization completed.");
|
||||
}
|
||||
127
extensions/minimax/onboard.test.ts
Normal file
127
extensions/minimax/onboard.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Minimax tests cover onboard plugin behavior.
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
expectProviderOnboardMergedLegacyConfig,
|
||||
expectProviderOnboardPreservesPrimary,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMinimaxApiModelDefinition } from "./model-definitions.js";
|
||||
import { applyMinimaxApiConfig, applyMinimaxApiProviderConfig } from "./onboard.js";
|
||||
|
||||
describe("minimax onboard", () => {
|
||||
it("adds minimax provider with correct settings", () => {
|
||||
const cfg = applyMinimaxApiConfig({});
|
||||
expect(cfg.models?.providers?.minimax).toEqual({
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [buildMinimaxApiModelDefinition("MiniMax-M3")],
|
||||
});
|
||||
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M3"]).toEqual({
|
||||
alias: "Minimax",
|
||||
});
|
||||
expect(cfg.agents?.defaults?.model).toEqual({ primary: "minimax/MiniMax-M3" });
|
||||
});
|
||||
|
||||
it("keeps reasoning enabled for MiniMax-M3", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M3");
|
||||
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps MiniMax chat models text-only so image tools use MiniMax-VL-01", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed");
|
||||
expect(cfg.models?.providers?.minimax?.models).toEqual([
|
||||
{
|
||||
id: "MiniMax-M2.7-highspeed",
|
||||
name: "MiniMax M2.7 Highspeed",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.6, output: 2.4, cacheRead: 0.06, cacheWrite: 0.375 },
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves existing model params when adding alias", () => {
|
||||
const cfg = applyMinimaxApiConfig(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"minimax/MiniMax-M2.7": {
|
||||
alias: "MiniMax",
|
||||
params: { custom: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"MiniMax-M2.7",
|
||||
);
|
||||
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.7"]).toEqual({
|
||||
alias: "Minimax",
|
||||
params: { custom: "value" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges existing minimax provider models", () => {
|
||||
const provider = expectProviderOnboardMergedLegacyConfig({
|
||||
applyProviderConfig: applyMinimaxApiConfig,
|
||||
providerId: "minimax",
|
||||
providerApi: "anthropic-messages",
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
legacyApi: "openai-completions",
|
||||
});
|
||||
expect(provider?.authHeader).toBe(true);
|
||||
expect(provider?.models.map((m) => m.id)).toEqual(["old-model", "MiniMax-M3"]);
|
||||
});
|
||||
|
||||
it("preserves other providers when adding minimax", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic-key",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.models?.providers).toHaveProperty("anthropic");
|
||||
expect(cfg.models?.providers).toHaveProperty("minimax");
|
||||
});
|
||||
|
||||
it("preserves existing models mode", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: { mode: "replace", providers: {} },
|
||||
});
|
||||
expect(cfg.models?.mode).toBe("replace");
|
||||
});
|
||||
|
||||
it("does not overwrite existing primary model in provider-only mode", () => {
|
||||
expectProviderOnboardPreservesPrimary({
|
||||
applyProviderConfig: applyMinimaxApiProviderConfig,
|
||||
primaryModelRef: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets the chosen model as primary in config mode", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed");
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
|
||||
"minimax/MiniMax-M2.7-highspeed",
|
||||
);
|
||||
});
|
||||
});
|
||||
106
extensions/minimax/onboard.ts
Normal file
106
extensions/minimax/onboard.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Minimax setup module handles plugin onboarding behavior.
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
applyOnboardAuthAgentModelsAndProviders,
|
||||
type ModelProviderConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
buildMinimaxApiModelDefinition,
|
||||
MINIMAX_API_BASE_URL,
|
||||
MINIMAX_CN_API_BASE_URL,
|
||||
} from "./model-definitions.js";
|
||||
import { MINIMAX_DEFAULT_MODEL_ID } from "./provider-models.js";
|
||||
|
||||
type MinimaxApiProviderConfigParams = {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
function applyMinimaxApiProviderConfigWithBaseUrl(
|
||||
cfg: OpenClawConfig,
|
||||
params: MinimaxApiProviderConfigParams,
|
||||
): OpenClawConfig {
|
||||
const providers = { ...cfg.models?.providers } as Record<string, ModelProviderConfig>;
|
||||
const existingProvider = providers[params.providerId];
|
||||
const existingModels = existingProvider?.models ?? [];
|
||||
const apiModel = buildMinimaxApiModelDefinition(params.modelId);
|
||||
const hasApiModel = existingModels.some((model) => model.id === params.modelId);
|
||||
const mergedModels = hasApiModel ? existingModels : [...existingModels, apiModel];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {
|
||||
baseUrl: params.baseUrl,
|
||||
models: [],
|
||||
};
|
||||
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey;
|
||||
providers[params.providerId] = {
|
||||
...existingProviderRest,
|
||||
baseUrl: params.baseUrl,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : [apiModel],
|
||||
};
|
||||
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
const modelRef = `${params.providerId}/${params.modelId}`;
|
||||
models[modelRef] = {
|
||||
...models[modelRef],
|
||||
alias: "Minimax",
|
||||
};
|
||||
|
||||
return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers });
|
||||
}
|
||||
|
||||
function applyMinimaxApiConfigWithBaseUrl(
|
||||
cfg: OpenClawConfig,
|
||||
params: MinimaxApiProviderConfigParams,
|
||||
): OpenClawConfig {
|
||||
const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params);
|
||||
return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`);
|
||||
}
|
||||
|
||||
export function applyMinimaxApiProviderConfig(
|
||||
cfg: OpenClawConfig,
|
||||
modelId = MINIMAX_DEFAULT_MODEL_ID,
|
||||
): OpenClawConfig {
|
||||
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, {
|
||||
providerId: "minimax",
|
||||
modelId,
|
||||
baseUrl: MINIMAX_API_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyMinimaxApiConfig(
|
||||
cfg: OpenClawConfig,
|
||||
modelId = MINIMAX_DEFAULT_MODEL_ID,
|
||||
): OpenClawConfig {
|
||||
return applyMinimaxApiConfigWithBaseUrl(cfg, {
|
||||
providerId: "minimax",
|
||||
modelId,
|
||||
baseUrl: MINIMAX_API_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyMinimaxApiProviderConfigCn(
|
||||
cfg: OpenClawConfig,
|
||||
modelId = MINIMAX_DEFAULT_MODEL_ID,
|
||||
): OpenClawConfig {
|
||||
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, {
|
||||
providerId: "minimax",
|
||||
modelId,
|
||||
baseUrl: MINIMAX_CN_API_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyMinimaxApiConfigCn(
|
||||
cfg: OpenClawConfig,
|
||||
modelId = MINIMAX_DEFAULT_MODEL_ID,
|
||||
): OpenClawConfig {
|
||||
return applyMinimaxApiConfigWithBaseUrl(cfg, {
|
||||
providerId: "minimax",
|
||||
modelId,
|
||||
baseUrl: MINIMAX_CN_API_BASE_URL,
|
||||
});
|
||||
}
|
||||
155
extensions/minimax/openclaw.plugin.json
Normal file
155
extensions/minimax/openclaw.plugin.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"id": "minimax",
|
||||
"icon": "https://cdn.simpleicons.org/minimax",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"legacyPluginIds": ["minimax-portal-auth"],
|
||||
"providers": ["minimax", "minimax-portal"],
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"autoEnableWhenConfiguredProviders": ["minimax", "minimax-portal"],
|
||||
"nonSecretAuthMarkers": ["minimax-oauth"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "minimax",
|
||||
"envVars": ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "minimax-portal",
|
||||
"envVars": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthAliases": {
|
||||
"minimax-cn": "minimax",
|
||||
"minimax-portal-cn": "minimax-portal"
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "minimax-portal",
|
||||
"method": "oauth",
|
||||
"choiceId": "minimax-global-oauth",
|
||||
"choiceLabel": "MiniMax OAuth (Global)",
|
||||
"choiceHint": "Global endpoint - api.minimax.io",
|
||||
"groupId": "minimax",
|
||||
"groupLabel": "MiniMax",
|
||||
"groupHint": "M3 (recommended)"
|
||||
},
|
||||
{
|
||||
"provider": "minimax",
|
||||
"method": "api-global",
|
||||
"choiceId": "minimax-global-api",
|
||||
"deprecatedChoiceIds": ["minimax", "minimax-api", "minimax-cloud", "minimax-api-lightning"],
|
||||
"choiceLabel": "MiniMax API key (Global)",
|
||||
"choiceHint": "Global endpoint - api.minimax.io",
|
||||
"groupId": "minimax",
|
||||
"groupLabel": "MiniMax",
|
||||
"groupHint": "M3 (recommended)",
|
||||
"optionKey": "minimaxApiKey",
|
||||
"cliFlag": "--minimax-api-key",
|
||||
"cliOption": "--minimax-api-key <key>",
|
||||
"cliDescription": "MiniMax API key"
|
||||
},
|
||||
{
|
||||
"provider": "minimax-portal",
|
||||
"method": "oauth-cn",
|
||||
"choiceId": "minimax-cn-oauth",
|
||||
"choiceLabel": "MiniMax OAuth (CN)",
|
||||
"choiceHint": "CN endpoint - api.minimaxi.com",
|
||||
"groupId": "minimax",
|
||||
"groupLabel": "MiniMax",
|
||||
"groupHint": "M3 (recommended)"
|
||||
},
|
||||
{
|
||||
"provider": "minimax",
|
||||
"method": "api-cn",
|
||||
"choiceId": "minimax-cn-api",
|
||||
"deprecatedChoiceIds": ["minimax-api-key-cn"],
|
||||
"choiceLabel": "MiniMax API key (CN)",
|
||||
"choiceHint": "CN endpoint - api.minimaxi.com",
|
||||
"groupId": "minimax",
|
||||
"groupLabel": "MiniMax",
|
||||
"groupHint": "M3 (recommended)",
|
||||
"optionKey": "minimaxApiKey",
|
||||
"cliFlag": "--minimax-api-key",
|
||||
"cliOption": "--minimax-api-key <key>",
|
||||
"cliDescription": "MiniMax API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"speechProviders": ["minimax"],
|
||||
"mediaUnderstandingProviders": ["minimax", "minimax-portal"],
|
||||
"imageGenerationProviders": ["minimax", "minimax-portal"],
|
||||
"musicGenerationProviders": ["minimax", "minimax-portal"],
|
||||
"videoGenerationProviders": ["minimax", "minimax-portal"],
|
||||
"webSearchProviders": ["minimax"]
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"minimax": {
|
||||
"capabilities": ["image"],
|
||||
"defaultModels": {
|
||||
"image": "MiniMax-VL-01"
|
||||
},
|
||||
"documentModels": {
|
||||
"pdf": {
|
||||
"textExtraction": "MiniMax-M2.7",
|
||||
"image": false
|
||||
}
|
||||
},
|
||||
"autoPriority": {
|
||||
"image": 40
|
||||
}
|
||||
},
|
||||
"minimax-portal": {
|
||||
"capabilities": ["image"],
|
||||
"defaultModels": {
|
||||
"image": "MiniMax-VL-01"
|
||||
},
|
||||
"documentModels": {
|
||||
"pdf": {
|
||||
"textExtraction": "MiniMax-M2.7",
|
||||
"image": false
|
||||
}
|
||||
},
|
||||
"autoPriority": {
|
||||
"image": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "MiniMax Token Plan key",
|
||||
"help": "MiniMax Token Plan key or OAuth token (fallback: MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, MINIMAX_OAUTH_TOKEN, or MINIMAX_API_KEY if it already points at a token-plan credential).",
|
||||
"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": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"enum": ["global", "cn"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
extensions/minimax/package.json
Normal file
15
extensions/minimax/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-provider",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax provider and OAuth plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
91
extensions/minimax/provider-catalog.ts
Normal file
91
extensions/minimax/provider-catalog.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
MINIMAX_API_BASE_URL,
|
||||
resolveMinimaxApiCost,
|
||||
} from "./model-definitions.js";
|
||||
import { MINIMAX_TEXT_MODEL_CATALOG, MINIMAX_TEXT_MODEL_ORDER } from "./provider-models.js";
|
||||
|
||||
export function resolveMinimaxCatalogBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const rawHost = env.MINIMAX_API_HOST?.trim();
|
||||
if (!rawHost) {
|
||||
return MINIMAX_API_BASE_URL;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(rawHost);
|
||||
const basePath = url.pathname.replace(/\/+$/, "");
|
||||
if (basePath.endsWith("/anthropic")) {
|
||||
return `${url.origin}${basePath}`;
|
||||
}
|
||||
return `${url.origin}/anthropic`;
|
||||
} catch {
|
||||
return MINIMAX_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMinimaxModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ModelDefinitionConfig["input"];
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
contextWindow: number;
|
||||
}): ModelDefinitionConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: params.cost,
|
||||
contextWindow: params.contextWindow,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxTextModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ModelDefinitionConfig["input"];
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
contextWindow: number;
|
||||
}): ModelDefinitionConfig {
|
||||
return buildMinimaxModel(params);
|
||||
}
|
||||
|
||||
function buildMinimaxCatalog(): ModelDefinitionConfig[] {
|
||||
return MINIMAX_TEXT_MODEL_ORDER.map((id) => {
|
||||
const model = MINIMAX_TEXT_MODEL_CATALOG[id];
|
||||
return buildMinimaxTextModel({
|
||||
id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
input: [...model.input],
|
||||
cost: resolveMinimaxApiCost(id),
|
||||
contextWindow: model.contextWindow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMinimaxProvider(env?: NodeJS.ProcessEnv): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: resolveMinimaxCatalogBaseUrl(env),
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: buildMinimaxCatalog(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalProvider(env?: NodeJS.ProcessEnv): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: resolveMinimaxCatalogBaseUrl(env),
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: buildMinimaxCatalog(),
|
||||
};
|
||||
}
|
||||
88
extensions/minimax/provider-contract-api.ts
Normal file
88
extensions/minimax/provider-contract-api.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Minimax API module exposes the plugin public contract.
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { resolveMinimaxThinkingProfile } from "./thinking.js";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
const wizardGroup = {
|
||||
groupId: "minimax",
|
||||
groupLabel: "MiniMax",
|
||||
groupHint: "M3 (recommended)",
|
||||
} as const;
|
||||
|
||||
export function createMinimaxProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
hookAliases: ["minimax-cn"],
|
||||
docsPath: "/providers/minimax",
|
||||
envVars: ["MINIMAX_API_KEY"],
|
||||
resolveThinkingProfile: ({ modelId }) => resolveMinimaxThinkingProfile(modelId),
|
||||
auth: [
|
||||
{
|
||||
id: "api-global",
|
||||
kind: "api_key",
|
||||
label: "MiniMax API key (Global)",
|
||||
hint: "Global endpoint - api.minimax.io",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-global-api",
|
||||
choiceLabel: "MiniMax API key (Global)",
|
||||
choiceHint: "Global endpoint - api.minimax.io",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-cn",
|
||||
kind: "api_key",
|
||||
label: "MiniMax API key (CN)",
|
||||
hint: "CN endpoint - api.minimaxi.com",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-cn-api",
|
||||
choiceLabel: "MiniMax API key (CN)",
|
||||
choiceHint: "CN endpoint - api.minimaxi.com",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createMinimaxPortalProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "minimax-portal",
|
||||
label: "MiniMax",
|
||||
hookAliases: ["minimax-portal-cn"],
|
||||
docsPath: "/providers/minimax",
|
||||
envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
|
||||
resolveThinkingProfile: ({ modelId }) => resolveMinimaxThinkingProfile(modelId),
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
kind: "device_code",
|
||||
label: "MiniMax OAuth (Global)",
|
||||
hint: "Global endpoint - api.minimax.io",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-global-oauth",
|
||||
choiceLabel: "MiniMax OAuth (Global)",
|
||||
choiceHint: "Global endpoint - api.minimax.io",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oauth-cn",
|
||||
kind: "device_code",
|
||||
label: "MiniMax OAuth (CN)",
|
||||
hint: "CN endpoint - api.minimaxi.com",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-cn-oauth",
|
||||
choiceLabel: "MiniMax OAuth (CN)",
|
||||
choiceHint: "CN endpoint - api.minimaxi.com",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
4
extensions/minimax/provider-discovery.contract.test.ts
Normal file
4
extensions/minimax/provider-discovery.contract.test.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Minimax tests cover provider discovery.contract plugin behavior.
|
||||
import { describeMinimaxProviderDiscoveryContract } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
|
||||
describeMinimaxProviderDiscoveryContract(() => import("./index.js"));
|
||||
30
extensions/minimax/provider-discovery.ts
Normal file
30
extensions/minimax/provider-discovery.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js";
|
||||
|
||||
const minimaxProviderDiscovery: ProviderPlugin[] = [
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
docsPath: "/providers/minimax",
|
||||
auth: [],
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => ({ providers: { minimax: buildMinimaxProvider(ctx.env) } }),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "minimax-portal",
|
||||
label: "MiniMax",
|
||||
docsPath: "/providers/minimax",
|
||||
auth: [],
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => ({
|
||||
providers: { "minimax-portal": buildMinimaxPortalProvider(ctx.env) },
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default minimaxProviderDiscovery;
|
||||
147
extensions/minimax/provider-http.test-helpers.ts
Normal file
147
extensions/minimax/provider-http.test-helpers.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import type {
|
||||
fetchProviderDownloadResponse,
|
||||
fetchProviderOperationResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { afterEach, vi, type Mock } from "vitest";
|
||||
|
||||
type ResolveProviderHttpRequestConfigParams = Parameters<
|
||||
typeof resolveProviderHttpRequestConfig
|
||||
>[0];
|
||||
type FetchProviderOperationResponseParams = Parameters<typeof fetchProviderOperationResponse>[0];
|
||||
type FetchProviderDownloadResponseParams = Parameters<typeof fetchProviderDownloadResponse>[0];
|
||||
|
||||
type ResolveProviderHttpRequestConfigResult = {
|
||||
baseUrl: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
headers: Headers;
|
||||
dispatcherPolicy: undefined;
|
||||
};
|
||||
|
||||
type AnyMock = Mock<(...args: any[]) => any>;
|
||||
|
||||
interface MinimaxProviderHttpMocks {
|
||||
resolveApiKeyForProviderMock: Mock<() => Promise<{ apiKey: string }>>;
|
||||
postJsonRequestMock: AnyMock;
|
||||
fetchWithTimeoutMock: AnyMock;
|
||||
fetchProviderOperationResponseMock: AnyMock;
|
||||
fetchProviderDownloadResponseMock: AnyMock;
|
||||
assertOkOrThrowHttpErrorMock: Mock<(response: Response, label: string) => Promise<void>>;
|
||||
resolveProviderHttpRequestConfigMock: Mock<
|
||||
(params: ResolveProviderHttpRequestConfigParams) => ResolveProviderHttpRequestConfigResult
|
||||
>;
|
||||
}
|
||||
|
||||
const minimaxProviderHttpMocks = vi.hoisted(() => ({
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
fetchWithTimeoutMock: vi.fn(),
|
||||
fetchProviderOperationResponseMock: vi.fn(),
|
||||
fetchProviderDownloadResponseMock: vi.fn(),
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async (_response: Response, _label: string) => {}),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: ResolveProviderHttpRequestConfigParams) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
function resolveMockProviderTimeoutMs(
|
||||
timeoutMs: FetchProviderOperationResponseParams["timeoutMs"],
|
||||
) {
|
||||
return typeof timeoutMs === "function" ? timeoutMs() : (timeoutMs ?? 60_000);
|
||||
}
|
||||
|
||||
minimaxProviderHttpMocks.fetchProviderOperationResponseMock.mockImplementation(
|
||||
async (params: FetchProviderOperationResponseParams) => {
|
||||
const response = await minimaxProviderHttpMocks.fetchWithTimeoutMock(
|
||||
params.url,
|
||||
params.init ?? {},
|
||||
resolveMockProviderTimeoutMs(params.timeoutMs),
|
||||
params.fetchFn,
|
||||
);
|
||||
if (params.requestFailedMessage) {
|
||||
await minimaxProviderHttpMocks.assertOkOrThrowHttpErrorMock(
|
||||
response,
|
||||
params.requestFailedMessage,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
minimaxProviderHttpMocks.fetchProviderDownloadResponseMock.mockImplementation(
|
||||
async (params: FetchProviderDownloadResponseParams) => {
|
||||
const response = await minimaxProviderHttpMocks.fetchWithTimeoutMock(
|
||||
params.url,
|
||||
params.init ?? {},
|
||||
resolveMockProviderTimeoutMs(params.timeoutMs),
|
||||
params.fetchFn,
|
||||
);
|
||||
await minimaxProviderHttpMocks.assertOkOrThrowHttpErrorMock(
|
||||
response,
|
||||
params.requestFailedMessage,
|
||||
);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: minimaxProviderHttpMocks.resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => {
|
||||
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-http")>();
|
||||
return {
|
||||
assertOkOrThrowHttpError: minimaxProviderHttpMocks.assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadline: ({
|
||||
label,
|
||||
timeoutMs,
|
||||
}: {
|
||||
label: string;
|
||||
timeoutMs?: number;
|
||||
}) => ({
|
||||
label,
|
||||
timeoutMs,
|
||||
}),
|
||||
createProviderOperationTimeoutResolver:
|
||||
({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
|
||||
() =>
|
||||
defaultTimeoutMs,
|
||||
fetchProviderDownloadResponse: minimaxProviderHttpMocks.fetchProviderDownloadResponseMock,
|
||||
fetchProviderOperationResponse: minimaxProviderHttpMocks.fetchProviderOperationResponseMock,
|
||||
fetchWithTimeout: minimaxProviderHttpMocks.fetchWithTimeoutMock,
|
||||
postJsonRequest: minimaxProviderHttpMocks.postJsonRequestMock,
|
||||
readProviderJsonResponse: actual.readProviderJsonResponse,
|
||||
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
|
||||
defaultTimeoutMs,
|
||||
resolveProviderHttpRequestConfig: minimaxProviderHttpMocks.resolveProviderHttpRequestConfigMock,
|
||||
waitProviderOperationPollInterval: async () => {},
|
||||
};
|
||||
});
|
||||
|
||||
export function getMinimaxProviderHttpMocks(): MinimaxProviderHttpMocks {
|
||||
return minimaxProviderHttpMocks;
|
||||
}
|
||||
|
||||
export function installMinimaxProviderHttpMockCleanup(): void {
|
||||
afterEach(() => {
|
||||
minimaxProviderHttpMocks.resolveApiKeyForProviderMock.mockClear();
|
||||
minimaxProviderHttpMocks.postJsonRequestMock.mockReset();
|
||||
minimaxProviderHttpMocks.fetchWithTimeoutMock.mockReset();
|
||||
minimaxProviderHttpMocks.fetchProviderOperationResponseMock.mockClear();
|
||||
minimaxProviderHttpMocks.fetchProviderDownloadResponseMock.mockClear();
|
||||
minimaxProviderHttpMocks.assertOkOrThrowHttpErrorMock.mockClear();
|
||||
minimaxProviderHttpMocks.resolveProviderHttpRequestConfigMock.mockClear();
|
||||
});
|
||||
}
|
||||
|
||||
export function loadMinimaxMusicGenerationProviderModule() {
|
||||
return import("./music-generation-provider.js");
|
||||
}
|
||||
|
||||
export function loadMinimaxVideoGenerationProviderModule() {
|
||||
return import("./video-generation-provider.js");
|
||||
}
|
||||
42
extensions/minimax/provider-models.ts
Normal file
42
extensions/minimax/provider-models.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import { matchesExactOrPrefix } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M3";
|
||||
export const MINIMAX_DEFAULT_MODEL_REF = `minimax/${MINIMAX_DEFAULT_MODEL_ID}`;
|
||||
|
||||
export const MINIMAX_TEXT_MODEL_ORDER = [
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
] as const;
|
||||
|
||||
export const MINIMAX_TEXT_MODEL_CATALOG = {
|
||||
"MiniMax-M3": {
|
||||
name: "MiniMax M3",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
},
|
||||
"MiniMax-M2.7": {
|
||||
name: "MiniMax M2.7",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 204800,
|
||||
},
|
||||
"MiniMax-M2.7-highspeed": {
|
||||
name: "MiniMax M2.7 Highspeed",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 204800,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const MINIMAX_TEXT_MODEL_REFS = MINIMAX_TEXT_MODEL_ORDER.map(
|
||||
(modelId) => `minimax/${modelId}`,
|
||||
);
|
||||
|
||||
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m3", "minimax-m2.7"] as const;
|
||||
|
||||
export function isMiniMaxModernModelId(modelId: string): boolean {
|
||||
return matchesExactOrPrefix(modelId, MINIMAX_MODERN_MODEL_MATCHERS);
|
||||
}
|
||||
7
extensions/minimax/provider-policy-api.ts
Normal file
7
extensions/minimax/provider-policy-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// MiniMax policy module exposes static provider policy before runtime registration.
|
||||
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
|
||||
import { resolveMinimaxThinkingProfile } from "./thinking.js";
|
||||
|
||||
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
|
||||
return resolveMinimaxThinkingProfile(context.modelId);
|
||||
}
|
||||
354
extensions/minimax/provider-registration.ts
Normal file
354
extensions/minimax/provider-registration.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawConfig,
|
||||
ProviderAuthContext,
|
||||
ProviderAuthResult,
|
||||
ProviderCatalogContext,
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} 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 type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
normalizeModelCompat,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { MINIMAX_FAST_MODE_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
|
||||
import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
isMiniMaxModernModelId,
|
||||
MINIMAX_DEFAULT_MODEL_ID,
|
||||
MINIMAX_TEXT_MODEL_CATALOG,
|
||||
MINIMAX_TEXT_MODEL_ORDER,
|
||||
} from "./api.js";
|
||||
import { DEFAULT_MINIMAX_MAX_TOKENS, resolveMinimaxApiCost } from "./model-definitions.js";
|
||||
import type { MiniMaxRegion } from "./oauth.js";
|
||||
import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js";
|
||||
import {
|
||||
buildMinimaxPortalProvider,
|
||||
buildMinimaxProvider,
|
||||
resolveMinimaxCatalogBaseUrl,
|
||||
} from "./provider-catalog.js";
|
||||
import { resolveMinimaxThinkingProfile } from "./thinking.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 MINIMAX_WIZARD_GROUP = {
|
||||
groupId: "minimax",
|
||||
groupLabel: "MiniMax",
|
||||
groupHint: "M3 (recommended)",
|
||||
} as const;
|
||||
const HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
||||
family: "hybrid-anthropic-openai",
|
||||
anthropicModelDropThinkingBlocks: true,
|
||||
});
|
||||
const MINIMAX_PROVIDER_HOOKS = {
|
||||
...HYBRID_ANTHROPIC_OPENAI_REPLAY_HOOKS,
|
||||
...MINIMAX_FAST_MODE_STREAM_HOOKS,
|
||||
resolveReasoningOutputMode: () => "native" as const,
|
||||
resolveThinkingProfile: ({ modelId }: { modelId: string }) =>
|
||||
resolveMinimaxThinkingProfile(modelId),
|
||||
};
|
||||
|
||||
function getDefaultBaseUrl(region: MiniMaxRegion): string {
|
||||
return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL;
|
||||
}
|
||||
|
||||
function resolveMinimaxRegionLabel(region: MiniMaxRegion): string {
|
||||
return region === "cn" ? "CN" : "Global";
|
||||
}
|
||||
|
||||
function resolveMinimaxEndpointHint(region: MiniMaxRegion): string {
|
||||
return region === "cn" ? "CN endpoint - api.minimaxi.com" : "Global endpoint - api.minimax.io";
|
||||
}
|
||||
|
||||
function apiModelRef(modelId: string): string {
|
||||
return `${API_PROVIDER_ID}/${modelId}`;
|
||||
}
|
||||
|
||||
function portalModelRef(modelId: string): string {
|
||||
return `${PORTAL_PROVIDER_ID}/${modelId}`;
|
||||
}
|
||||
|
||||
function getProviderBaseUrl(cfg: OpenClawConfig, providerId: string): string | undefined {
|
||||
return normalizeOptionalString(cfg.models?.providers?.[providerId]?.baseUrl);
|
||||
}
|
||||
|
||||
function resolveMinimaxUsageBaseUrl(cfg: OpenClawConfig): string | undefined {
|
||||
return getProviderBaseUrl(cfg, PORTAL_PROVIDER_ID) ?? getProviderBaseUrl(cfg, API_PROVIDER_ID);
|
||||
}
|
||||
|
||||
function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) {
|
||||
return {
|
||||
...buildMinimaxPortalProvider(),
|
||||
baseUrl: params.baseUrl,
|
||||
apiKey: params.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
function findMinimaxCatalogModel(modelId: string) {
|
||||
const normalizedModelId = modelId.trim().toLowerCase();
|
||||
const catalogId = MINIMAX_TEXT_MODEL_ORDER.find((id) => id.toLowerCase() === normalizedModelId);
|
||||
return catalogId ? { id: catalogId, model: MINIMAX_TEXT_MODEL_CATALOG[catalogId] } : undefined;
|
||||
}
|
||||
|
||||
function resolveMinimaxDynamicModel(params: {
|
||||
providerId: string;
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const catalogModel = findMinimaxCatalogModel(params.ctx.modelId);
|
||||
if (!catalogModel) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeModelCompat({
|
||||
id: catalogModel.id,
|
||||
name: catalogModel.model.name,
|
||||
provider: params.providerId,
|
||||
api: "anthropic-messages",
|
||||
baseUrl:
|
||||
normalizeOptionalString(params.ctx.providerConfig?.baseUrl) ?? resolveMinimaxCatalogBaseUrl(),
|
||||
reasoning: catalogModel.model.reasoning,
|
||||
input: [...catalogModel.model.input],
|
||||
cost: resolveMinimaxApiCost(catalogModel.id),
|
||||
contextWindow: catalogModel.model.contextWindow,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
}
|
||||
|
||||
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 = normalizeOptionalString(explicitProvider?.apiKey);
|
||||
const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined);
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl);
|
||||
|
||||
return {
|
||||
provider: buildPortalProviderCatalog({
|
||||
baseUrl: explicitBaseUrl || buildMinimaxPortalProvider(ctx.env).baseUrl,
|
||||
apiKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createOAuthHandler(region: MiniMaxRegion) {
|
||||
const defaultBaseUrl = getDefaultBaseUrl(region);
|
||||
const regionLabel = resolveMinimaxRegionLabel(region);
|
||||
|
||||
return async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||
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,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[portalModelRef("MiniMax-M3")]: { alias: "minimax-m3" },
|
||||
[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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createMinimaxApiKeyMethod(region: MiniMaxRegion) {
|
||||
const regionLabel = resolveMinimaxRegionLabel(region);
|
||||
const endpointHint = resolveMinimaxEndpointHint(region);
|
||||
const isCn = region === "cn";
|
||||
return createProviderApiKeyAuthMethod({
|
||||
providerId: API_PROVIDER_ID,
|
||||
methodId: isCn ? "api-cn" : "api-global",
|
||||
label: `MiniMax API key (${regionLabel})`,
|
||||
hint: endpointHint,
|
||||
optionKey: "minimaxApiKey",
|
||||
flagName: "--minimax-api-key",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
promptMessage: isCn
|
||||
? "Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key"
|
||||
: "Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key",
|
||||
profileId: isCn ? "minimax:cn" : "minimax:global",
|
||||
allowProfile: false,
|
||||
defaultModel: apiModelRef(DEFAULT_MODEL),
|
||||
expectedProviders: isCn ? ["minimax", "minimax-cn"] : ["minimax"],
|
||||
applyConfig: (cfg) => (isCn ? applyMinimaxApiConfigCn(cfg) : applyMinimaxApiConfig(cfg)),
|
||||
wizard: {
|
||||
choiceId: isCn ? "minimax-cn-api" : "minimax-global-api",
|
||||
choiceLabel: `MiniMax API key (${regionLabel})`,
|
||||
choiceHint: endpointHint,
|
||||
...MINIMAX_WIZARD_GROUP,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMinimaxOAuthMethod(region: MiniMaxRegion) {
|
||||
const regionLabel = resolveMinimaxRegionLabel(region);
|
||||
const endpointHint = resolveMinimaxEndpointHint(region);
|
||||
const isCn = region === "cn";
|
||||
return {
|
||||
id: isCn ? "oauth-cn" : "oauth",
|
||||
label: `MiniMax OAuth (${regionLabel})`,
|
||||
hint: endpointHint,
|
||||
kind: "device_code" as const,
|
||||
wizard: {
|
||||
choiceId: isCn ? "minimax-cn-oauth" : "minimax-global-oauth",
|
||||
choiceLabel: `MiniMax OAuth (${regionLabel})`,
|
||||
choiceHint: endpointHint,
|
||||
...MINIMAX_WIZARD_GROUP,
|
||||
},
|
||||
run: createOAuthHandler(region),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxApiProviderPlugin(): ProviderPlugin {
|
||||
return {
|
||||
id: API_PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
hookAliases: ["minimax-cn"],
|
||||
docsPath: "/providers/minimax",
|
||||
envVars: ["MINIMAX_API_KEY"],
|
||||
auth: [createMinimaxApiKeyMethod("global"), createMinimaxApiKeyMethod("cn")],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => resolveApiCatalog(ctx),
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => ({ providers: { [API_PROVIDER_ID]: buildMinimaxProvider(ctx.env) } }),
|
||||
},
|
||||
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;
|
||||
},
|
||||
...MINIMAX_PROVIDER_HOOKS,
|
||||
resolveDynamicModel: (ctx) => resolveMinimaxDynamicModel({ providerId: API_PROVIDER_ID, ctx }),
|
||||
isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId),
|
||||
fetchUsageSnapshot: async (ctx) =>
|
||||
await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, {
|
||||
baseUrl: resolveMinimaxUsageBaseUrl(ctx.config),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalProviderPlugin(): ProviderPlugin {
|
||||
return {
|
||||
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),
|
||||
},
|
||||
staticCatalog: {
|
||||
run: async (ctx) => ({
|
||||
providers: { [PORTAL_PROVIDER_ID]: buildMinimaxPortalProvider(ctx.env) },
|
||||
}),
|
||||
},
|
||||
auth: [createMinimaxOAuthMethod("global"), createMinimaxOAuthMethod("cn")],
|
||||
...MINIMAX_PROVIDER_HOOKS,
|
||||
resolveDynamicModel: (ctx) =>
|
||||
resolveMinimaxDynamicModel({ providerId: PORTAL_PROVIDER_ID, ctx }),
|
||||
isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMinimaxProviders(api: OpenClawPluginApi) {
|
||||
api.registerProvider(buildMinimaxApiProviderPlugin());
|
||||
api.registerProvider(buildMinimaxPortalProviderPlugin());
|
||||
}
|
||||
648
extensions/minimax/speech-provider.test.ts
Normal file
648
extensions/minimax/speech-provider.test.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
// Minimax tests cover speech provider plugin behavior.
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
saveAuthProfileStore,
|
||||
type AuthProfileStore,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcodeAudioBufferToOpusMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
transcodeAudioBufferToOpus: transcodeAudioBufferToOpusMock,
|
||||
}));
|
||||
|
||||
import { buildMinimaxSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
function clearMinimaxAuthEnv() {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
delete process.env.MINIMAX_CODE_PLAN_KEY;
|
||||
delete process.env.MINIMAX_CODING_API_KEY;
|
||||
}
|
||||
|
||||
function minimaxPortalStore(): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax-portal:test": {
|
||||
type: "token",
|
||||
provider: "minimax-portal",
|
||||
token: "portal-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedMinimaxPortalProfile(agentDir: string) {
|
||||
saveAuthProfileStore(minimaxPortalStore(), agentDir, {
|
||||
filterExternalAuthProfiles: false,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildMinimaxSpeechProvider", () => {
|
||||
const provider = buildMinimaxSpeechProvider();
|
||||
|
||||
function resolveProviderConfig(
|
||||
params: Parameters<NonNullable<typeof provider.resolveConfig>>[0],
|
||||
): ReturnType<NonNullable<typeof provider.resolveConfig>> {
|
||||
const resolveConfig = provider.resolveConfig;
|
||||
if (!resolveConfig) {
|
||||
throw new Error("MiniMax speech provider did not expose config resolution");
|
||||
}
|
||||
return resolveConfig(params);
|
||||
}
|
||||
|
||||
function parseDirectiveToken(
|
||||
params: Parameters<NonNullable<typeof provider.parseDirectiveToken>>[0],
|
||||
): ReturnType<NonNullable<typeof provider.parseDirectiveToken>> {
|
||||
const parseToken = provider.parseDirectiveToken;
|
||||
if (!parseToken) {
|
||||
throw new Error("MiniMax speech provider did not expose directive parsing");
|
||||
}
|
||||
return parseToken(params);
|
||||
}
|
||||
|
||||
describe("metadata", () => {
|
||||
it("has correct id and label", () => {
|
||||
expect(provider.id).toBe("minimax");
|
||||
expect(provider.label).toBe("MiniMax");
|
||||
});
|
||||
|
||||
it("has autoSelectOrder 40", () => {
|
||||
expect(provider.autoSelectOrder).toBe(40);
|
||||
});
|
||||
|
||||
it("exposes models and voices", () => {
|
||||
expect(provider.models).toEqual([
|
||||
"speech-2.8-hd",
|
||||
"speech-2.8-turbo",
|
||||
"speech-2.6-hd",
|
||||
"speech-2.6-turbo",
|
||||
"speech-02-hd",
|
||||
"speech-02-turbo",
|
||||
"speech-01-hd",
|
||||
"speech-01-turbo",
|
||||
"speech-01-240228",
|
||||
]);
|
||||
expect(provider.voices).toContain("English_expressive_narrator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConfigured", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
let tempStateDir: string;
|
||||
let tempAgentDir: string;
|
||||
let tokenPlanEnvConfigured = false;
|
||||
|
||||
beforeAll(() => {
|
||||
const previous = process.env.MINIMAX_CODING_API_KEY;
|
||||
try {
|
||||
process.env.MINIMAX_CODING_API_KEY = "sk-cp-env";
|
||||
tokenPlanEnvConfigured = provider.isConfigured({
|
||||
providerConfig: {},
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.MINIMAX_CODING_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_CODING_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tempStateDir = await mkdtemp(path.join(tmpdir(), "openclaw-minimax-tts-auth-"));
|
||||
tempAgentDir = path.join(tempStateDir, "agents", "main", "agent");
|
||||
await mkdir(tempAgentDir, { recursive: true });
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = tempAgentDir;
|
||||
clearMinimaxAuthEnv();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
process.env = { ...savedEnv };
|
||||
await rm(tempStateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns true when apiKey is in provider config", () => {
|
||||
expect(
|
||||
provider.isConfigured({ providerConfig: { apiKey: "sk-test" }, timeoutMs: 30000 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no apiKey anywhere", () => {
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when MINIMAX_API_KEY env var is set", () => {
|
||||
process.env.MINIMAX_API_KEY = "sk-env";
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a MiniMax Token Plan env var is set", () => {
|
||||
expect(tokenPlanEnvConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a MiniMax portal auth profile is available", async () => {
|
||||
seedMinimaxPortalProfile(tempAgentDir);
|
||||
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfig", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
it("returns defaults when rawConfig is empty", () => {
|
||||
delete process.env.MINIMAX_API_HOST;
|
||||
delete process.env.MINIMAX_TTS_MODEL;
|
||||
delete process.env.MINIMAX_TTS_VOICE_ID;
|
||||
const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 });
|
||||
expect(config.baseUrl).toBe("https://api.minimax.io");
|
||||
expect(config.model).toBe("speech-2.8-hd");
|
||||
expect(config.voiceId).toBe("English_expressive_narrator");
|
||||
});
|
||||
|
||||
it("reads from providers.minimax in rawConfig", () => {
|
||||
const config = resolveProviderConfig({
|
||||
rawConfig: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://custom.api.com",
|
||||
model: "speech-01-240228",
|
||||
voiceId: "Chinese (Mandarin)_Warm_Girl",
|
||||
speed: 1.5,
|
||||
vol: 2,
|
||||
pitch: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: {} as never,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
expect(config.baseUrl).toBe("https://custom.api.com");
|
||||
expect(config.model).toBe("speech-01-240228");
|
||||
expect(config.voiceId).toBe("Chinese (Mandarin)_Warm_Girl");
|
||||
expect(config.speed).toBe(1.5);
|
||||
expect(config.vol).toBe(2);
|
||||
expect(config.pitch).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps trusted MINIMAX_API_HOST fallback for TTS baseUrl", () => {
|
||||
process.env.MINIMAX_API_HOST = "https://api.minimax.io/anthropic";
|
||||
process.env.MINIMAX_TTS_MODEL = "speech-01-240228";
|
||||
process.env.MINIMAX_TTS_VOICE_ID = "Chinese (Mandarin)_Gentle_Boy";
|
||||
const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 });
|
||||
expect(config.baseUrl).toBe("https://api.minimax.io");
|
||||
expect(config.model).toBe("speech-01-240228");
|
||||
expect(config.voiceId).toBe("Chinese (Mandarin)_Gentle_Boy");
|
||||
});
|
||||
|
||||
it("derives the TTS host from minimax-portal OAuth config", () => {
|
||||
delete process.env.MINIMAX_API_HOST;
|
||||
const config = resolveProviderConfig({
|
||||
rawConfig: {},
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
"minimax-portal": { baseUrl: "https://api.minimaxi.com/anthropic" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
expect(config.baseUrl).toBe("https://api.minimaxi.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDirectiveToken", () => {
|
||||
const policy = {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
};
|
||||
|
||||
it("handles voice key", () => {
|
||||
const result = parseDirectiveToken({
|
||||
key: "voice",
|
||||
value: "Chinese (Mandarin)_Warm_Girl",
|
||||
policy,
|
||||
});
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.voiceId).toBe("Chinese (Mandarin)_Warm_Girl");
|
||||
});
|
||||
|
||||
it("handles voiceid key", () => {
|
||||
const result = parseDirectiveToken({ key: "voiceid", value: "test_voice", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.voiceId).toBe("test_voice");
|
||||
});
|
||||
|
||||
it("handles model key", () => {
|
||||
const result = parseDirectiveToken({
|
||||
key: "model",
|
||||
value: "speech-01-240228",
|
||||
policy,
|
||||
});
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.model).toBe("speech-01-240228");
|
||||
});
|
||||
|
||||
it("handles speed key with valid value", () => {
|
||||
const result = parseDirectiveToken({ key: "speed", value: "1.5", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.speed).toBe(1.5);
|
||||
});
|
||||
|
||||
it("warns on invalid speed", () => {
|
||||
const result = parseDirectiveToken({ key: "speed", value: "5.0", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.overrides).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns on non-decimal speed values", () => {
|
||||
const result = parseDirectiveToken({ key: "speed", value: "0x1", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.overrides).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles vol key", () => {
|
||||
const result = parseDirectiveToken({ key: "vol", value: "3", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.vol).toBe(3);
|
||||
});
|
||||
|
||||
it("warns on vol=0 (exclusive minimum)", () => {
|
||||
const result = parseDirectiveToken({ key: "vol", value: "0", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("warns on non-decimal volume values", () => {
|
||||
const result = parseDirectiveToken({ key: "vol", value: "0x3", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.overrides).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles volume alias", () => {
|
||||
const result = parseDirectiveToken({ key: "volume", value: "5", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.vol).toBe(5);
|
||||
});
|
||||
|
||||
it("handles pitch key", () => {
|
||||
const result = parseDirectiveToken({ key: "pitch", value: "-3", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides?.pitch).toBe(-3);
|
||||
});
|
||||
|
||||
it("warns on out-of-range pitch", () => {
|
||||
const result = parseDirectiveToken({ key: "pitch", value: "20", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("warns on non-decimal pitch values", () => {
|
||||
const result = parseDirectiveToken({ key: "pitch", value: "0x3", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.overrides).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns handled=false for unknown keys", () => {
|
||||
const result = parseDirectiveToken({
|
||||
key: "unknown_key",
|
||||
value: "whatever",
|
||||
policy,
|
||||
});
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses voice when policy disallows it", () => {
|
||||
const result = parseDirectiveToken({
|
||||
key: "voice",
|
||||
value: "test",
|
||||
policy: { ...policy, allowVoice: false },
|
||||
});
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses model when policy disallows it", () => {
|
||||
const result = parseDirectiveToken({
|
||||
key: "model",
|
||||
value: "test",
|
||||
policy: { ...policy, allowModelId: false },
|
||||
});
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.overrides).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesize", () => {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedEnv = { ...process.env };
|
||||
let tempStateDir: string;
|
||||
let tempAgentDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempStateDir = await mkdtemp(path.join(tmpdir(), "openclaw-minimax-tts-synth-"));
|
||||
tempAgentDir = path.join(tempStateDir, "agents", "main", "agent");
|
||||
await mkdir(tempAgentDir, { recursive: true });
|
||||
process.env = {
|
||||
...savedEnv,
|
||||
OPENCLAW_AGENT_DIR: tempAgentDir,
|
||||
OPENCLAW_STATE_DIR: tempStateDir,
|
||||
};
|
||||
clearMinimaxAuthEnv();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
transcodeAudioBufferToOpusMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = savedFetch;
|
||||
process.env = { ...savedEnv };
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
vi.restoreAllMocks();
|
||||
await rm(tempStateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function firstFetchCall(): unknown[] {
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected MiniMax TTS fetch call");
|
||||
}
|
||||
return call as unknown[];
|
||||
}
|
||||
|
||||
function firstFetchInit(): RequestInit | undefined {
|
||||
return firstFetchCall()[1] as RequestInit | undefined;
|
||||
}
|
||||
|
||||
function firstFetchBody(): Record<string, unknown> {
|
||||
const init = firstFetchInit();
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected MiniMax TTS fetch init body");
|
||||
}
|
||||
return JSON.parse(init.body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("requests non-streaming hex audio and decodes the hex response", async () => {
|
||||
const hexAudio = Buffer.from("fake-audio-data").toString("hex");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { audio: hexAudio } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello world",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test", baseUrl: "https://api.minimaxi.com" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
expect(result.outputFormat).toBe("mp3");
|
||||
expect(result.fileExtension).toBe(".mp3");
|
||||
expect(result.voiceCompatible).toBe(false);
|
||||
expect(result.audioBuffer.toString()).toBe("fake-audio-data");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const url = firstFetchCall()[0];
|
||||
expect(url).toBe("https://api.minimaxi.com/v1/t2a_v2");
|
||||
const body = firstFetchBody();
|
||||
expect(body.model).toBe("speech-2.8-hd");
|
||||
expect(body.text).toBe("Hello world");
|
||||
expect(body.stream).toBe(false);
|
||||
expect(body.output_format).toBe("hex");
|
||||
expect((body.voice_setting as Record<string, unknown>).voice_id).toBe(
|
||||
"English_expressive_narrator",
|
||||
);
|
||||
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("transcodes MiniMax MP3 to Opus for voice-note targets", async () => {
|
||||
const hexAudio = Buffer.from("fake-mp3-data").toString("hex");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { audio: hexAudio } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("fake-opus-data"));
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello world",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test", baseUrl: "https://api.minimaxi.com" },
|
||||
target: "voice-note",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
expect(result.outputFormat).toBe("opus");
|
||||
expect(result.fileExtension).toBe(".opus");
|
||||
expect(result.voiceCompatible).toBe(true);
|
||||
expect(result.audioBuffer.toString()).toBe("fake-opus-data");
|
||||
expect(transcodeAudioBufferToOpusMock).toHaveBeenCalledWith({
|
||||
audioBuffer: Buffer.from("fake-mp3-data"),
|
||||
inputExtension: "mp3",
|
||||
tempPrefix: "tts-minimax-",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies overrides", async () => {
|
||||
const hexAudio = Buffer.from("audio").toString("hex");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
providerOverrides: {
|
||||
model: "speech-01-240228",
|
||||
voiceId: "custom_voice",
|
||||
speed: 1.5,
|
||||
vol: 1.5,
|
||||
pitch: 0.5,
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const body = firstFetchBody();
|
||||
expect(body.model).toBe("speech-01-240228");
|
||||
const voiceSetting = body.voice_setting as Record<string, unknown>;
|
||||
expect(voiceSetting.voice_id).toBe("custom_voice");
|
||||
expect(voiceSetting.speed).toBe(1.5);
|
||||
expect(voiceSetting.vol).toBe(1.5);
|
||||
expect(voiceSetting.pitch).toBe(0);
|
||||
});
|
||||
|
||||
it("drops malformed voice settings before synthesis", async () => {
|
||||
const hexAudio = Buffer.from("audio").toString("hex");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "sk-test",
|
||||
speed: 3,
|
||||
vol: -1,
|
||||
pitch: 20,
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const voiceSetting = firstFetchBody().voice_setting as Record<string, unknown>;
|
||||
expect(voiceSetting.speed).toBe(1);
|
||||
expect(voiceSetting.vol).toBe(1);
|
||||
expect(voiceSetting.pitch).toBe(0);
|
||||
});
|
||||
|
||||
it("uses a MiniMax Token Plan env var when no API key is configured", async () => {
|
||||
process.env.MINIMAX_CODING_API_KEY = "sk-cp-env";
|
||||
const hexAudio = Buffer.from("audio").toString("hex");
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Token plan TTS",
|
||||
cfg: {} as never,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const init = firstFetchInit();
|
||||
expect(init?.headers).toEqual({
|
||||
Authorization: "Bearer sk-cp-env",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a minimax-portal auth profile before env API keys", async () => {
|
||||
process.env.MINIMAX_API_KEY = "sk-env";
|
||||
seedMinimaxPortalProfile(tempAgentDir);
|
||||
const hexAudio = Buffer.from("audio").toString("hex");
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Portal TTS",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
"minimax-portal": { baseUrl: "https://api.minimaxi.com/anthropic" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const url = firstFetchCall()[0];
|
||||
const init = firstFetchInit();
|
||||
expect(url).toBe("https://api.minimaxi.com/v1/t2a_v2");
|
||||
expect(init?.headers).toEqual({
|
||||
Authorization: "Bearer portal-token",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when API key is missing", async () => {
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
).rejects.toThrow("MiniMax TTS auth missing");
|
||||
});
|
||||
|
||||
it("throws on API error with response body", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response("Unauthorized", { status: 401 }),
|
||||
);
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
).rejects.toThrow("MiniMax TTS API error (401): Unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listVoices", () => {
|
||||
it("returns known voices", async () => {
|
||||
const listVoices = provider.listVoices;
|
||||
if (!listVoices) {
|
||||
throw new Error("Expected MiniMax provider listVoices");
|
||||
}
|
||||
const voices = await listVoices({} as never);
|
||||
expect(voices).toStrictEqual([
|
||||
{
|
||||
id: "English_expressive_narrator",
|
||||
name: "English_expressive_narrator",
|
||||
},
|
||||
{
|
||||
id: "Chinese (Mandarin)_Warm_Girl",
|
||||
name: "Chinese (Mandarin)_Warm_Girl",
|
||||
},
|
||||
{
|
||||
id: "Chinese (Mandarin)_Lively_Girl",
|
||||
name: "Chinese (Mandarin)_Lively_Girl",
|
||||
},
|
||||
{
|
||||
id: "Chinese (Mandarin)_Gentle_Boy",
|
||||
name: "Chinese (Mandarin)_Gentle_Boy",
|
||||
},
|
||||
{
|
||||
id: "Chinese (Mandarin)_Steady_Boy",
|
||||
name: "Chinese (Mandarin)_Steady_Boy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
329
extensions/minimax/speech-provider.ts
Normal file
329
extensions/minimax/speech-provider.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
isProviderAuthProfileConfigured,
|
||||
type OpenClawConfig,
|
||||
resolveProviderAuthProfileApiKey,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderOverrides,
|
||||
SpeechProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import {
|
||||
asObject,
|
||||
parseSpeechDirectiveNumberOverride,
|
||||
trimToUndefined,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
DEFAULT_MINIMAX_TTS_BASE_URL,
|
||||
MINIMAX_TTS_MODELS,
|
||||
MINIMAX_TTS_VOICES,
|
||||
minimaxTTS,
|
||||
normalizeMinimaxTtsBaseUrl,
|
||||
} from "./tts.js";
|
||||
|
||||
const MINIMAX_PORTAL_PROVIDER_ID = "minimax-portal";
|
||||
const MINIMAX_TOKEN_PLAN_ENV_VARS = [
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
"MINIMAX_CODE_PLAN_KEY",
|
||||
"MINIMAX_CODING_API_KEY",
|
||||
] as const;
|
||||
|
||||
type MinimaxTtsProviderConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
voiceId: string;
|
||||
speed?: number;
|
||||
vol?: number;
|
||||
pitch?: number;
|
||||
};
|
||||
|
||||
type MinimaxTtsProviderOverrides = {
|
||||
model?: string;
|
||||
voiceId?: string;
|
||||
speed?: number;
|
||||
vol?: number;
|
||||
pitch?: number;
|
||||
};
|
||||
|
||||
function resolveConfiguredPortalTtsBaseUrl(cfg: OpenClawConfig | undefined): string | undefined {
|
||||
const providers = asObject(asObject(cfg?.models)?.providers);
|
||||
const portalProvider = asObject(providers?.[MINIMAX_PORTAL_PROVIDER_ID]);
|
||||
const portalBaseUrl = trimToUndefined(portalProvider?.baseUrl);
|
||||
return portalBaseUrl ? normalizeMinimaxTtsBaseUrl(portalBaseUrl) : undefined;
|
||||
}
|
||||
|
||||
function resolveMinimaxTokenPlanEnvKey(): string | undefined {
|
||||
for (const envVar of MINIMAX_TOKEN_PLAN_ENV_VARS) {
|
||||
const value = trimToUndefined(process.env[envVar]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveMinimaxPortalProfileToken(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
): Promise<string | undefined> {
|
||||
return await resolveProviderAuthProfileApiKey({
|
||||
cfg,
|
||||
provider: MINIMAX_PORTAL_PROVIDER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveMinimaxTtsApiKey(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
configApiKey?: string;
|
||||
}): Promise<string | undefined> {
|
||||
return (
|
||||
params.configApiKey ??
|
||||
(await resolveMinimaxPortalProfileToken(params.cfg)) ??
|
||||
resolveMinimaxTokenPlanEnvKey() ??
|
||||
trimToUndefined(process.env.MINIMAX_API_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMinimaxProviderConfig(
|
||||
rawConfig: Record<string, unknown>,
|
||||
cfg?: OpenClawConfig,
|
||||
): MinimaxTtsProviderConfig {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
const raw = asObject(providers?.minimax) ?? asObject(rawConfig.minimax);
|
||||
return {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: raw?.apiKey,
|
||||
path: "messages.tts.providers.minimax.apiKey",
|
||||
}),
|
||||
baseUrl: normalizeMinimaxTtsBaseUrl(
|
||||
trimToUndefined(raw?.baseUrl) ??
|
||||
trimToUndefined(process.env.MINIMAX_API_HOST) ??
|
||||
resolveConfiguredPortalTtsBaseUrl(cfg) ??
|
||||
DEFAULT_MINIMAX_TTS_BASE_URL,
|
||||
),
|
||||
model:
|
||||
trimToUndefined(raw?.model) ??
|
||||
trimToUndefined(process.env.MINIMAX_TTS_MODEL) ??
|
||||
"speech-2.8-hd",
|
||||
voiceId:
|
||||
trimToUndefined(raw?.voiceId) ??
|
||||
trimToUndefined(process.env.MINIMAX_TTS_VOICE_ID) ??
|
||||
"English_expressive_narrator",
|
||||
speed: normalizeMinimaxSpeed(raw?.speed),
|
||||
vol: normalizeMinimaxVolume(raw?.vol),
|
||||
pitch: normalizeMinimaxPitch(raw?.pitch),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMinimaxSpeed(value: unknown): number | undefined {
|
||||
return asFiniteNumberInRange(value, { min: 0.5, max: 2 });
|
||||
}
|
||||
|
||||
function normalizeMinimaxVolume(value: unknown): number | undefined {
|
||||
return asFiniteNumberInRange(value, { min: 0, max: 10, minExclusive: true });
|
||||
}
|
||||
|
||||
function normalizeMinimaxPitch(value: unknown): number | undefined {
|
||||
const pitch = asFiniteNumberInRange(value, { min: -12, max: 12 });
|
||||
return pitch !== undefined ? Math.trunc(pitch) : undefined;
|
||||
}
|
||||
|
||||
function readMinimaxProviderConfig(
|
||||
config: SpeechProviderConfig,
|
||||
cfg?: OpenClawConfig,
|
||||
): MinimaxTtsProviderConfig {
|
||||
const normalized = normalizeMinimaxProviderConfig({}, cfg);
|
||||
return {
|
||||
apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey,
|
||||
baseUrl: normalizeMinimaxTtsBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl),
|
||||
model: trimToUndefined(config.model) ?? normalized.model,
|
||||
voiceId: trimToUndefined(config.voiceId) ?? normalized.voiceId,
|
||||
speed: normalizeMinimaxSpeed(config.speed) ?? normalized.speed,
|
||||
vol: normalizeMinimaxVolume(config.vol) ?? normalized.vol,
|
||||
pitch: normalizeMinimaxPitch(config.pitch) ?? normalized.pitch,
|
||||
};
|
||||
}
|
||||
|
||||
function readMinimaxOverrides(
|
||||
overrides: SpeechProviderOverrides | undefined,
|
||||
): MinimaxTtsProviderOverrides {
|
||||
if (!overrides) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
model: trimToUndefined(overrides.model),
|
||||
voiceId: trimToUndefined(overrides.voiceId),
|
||||
speed: normalizeMinimaxSpeed(overrides.speed),
|
||||
vol: normalizeMinimaxVolume(overrides.vol),
|
||||
pitch: normalizeMinimaxPitch(overrides.pitch),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
|
||||
handled: boolean;
|
||||
overrides?: SpeechProviderOverrides;
|
||||
warnings?: string[];
|
||||
} {
|
||||
switch (ctx.key) {
|
||||
case "voice":
|
||||
case "voiceid":
|
||||
case "voice_id":
|
||||
case "minimax_voice":
|
||||
case "minimaxvoice":
|
||||
if (!ctx.policy.allowVoice) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { voiceId: ctx.value } };
|
||||
case "model":
|
||||
case "minimax_model":
|
||||
case "minimaxmodel":
|
||||
if (!ctx.policy.allowModelId) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { model: ctx.value } };
|
||||
case "speed": {
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "speed",
|
||||
range: { min: 0.5, max: 2 },
|
||||
warning: (value) => `invalid MiniMax speed "${value}" (0.5-2.0)`,
|
||||
});
|
||||
}
|
||||
case "vol":
|
||||
case "volume": {
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "vol",
|
||||
range: { min: 0, minExclusive: true, max: 10 },
|
||||
warning: (value) => `invalid MiniMax volume "${value}" (0-10, exclusive)`,
|
||||
});
|
||||
}
|
||||
case "pitch": {
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "pitch",
|
||||
range: { min: -12, max: 12 },
|
||||
warning: (value) => `invalid MiniMax pitch "${value}" (-12 to 12)`,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMinimaxSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
autoSelectOrder: 40,
|
||||
defaultModel: MINIMAX_TTS_MODELS[0],
|
||||
models: MINIMAX_TTS_MODELS,
|
||||
voices: MINIMAX_TTS_VOICES,
|
||||
resolveConfig: ({ rawConfig, cfg }) => normalizeMinimaxProviderConfig(rawConfig, cfg),
|
||||
parseDirectiveToken,
|
||||
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
||||
const base = normalizeMinimaxProviderConfig(baseTtsConfig);
|
||||
return {
|
||||
...base,
|
||||
...(talkProviderConfig.apiKey === undefined
|
||||
? {}
|
||||
: {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: talkProviderConfig.apiKey,
|
||||
path: "talk.providers.minimax.apiKey",
|
||||
}),
|
||||
}),
|
||||
...(trimToUndefined(talkProviderConfig.baseUrl) == null
|
||||
? {}
|
||||
: { baseUrl: normalizeMinimaxTtsBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) }),
|
||||
...(trimToUndefined(talkProviderConfig.modelId) == null
|
||||
? {}
|
||||
: { model: trimToUndefined(talkProviderConfig.modelId) }),
|
||||
...(trimToUndefined(talkProviderConfig.voiceId) == null
|
||||
? {}
|
||||
: { voiceId: trimToUndefined(talkProviderConfig.voiceId) }),
|
||||
...(normalizeMinimaxSpeed(talkProviderConfig.speed) == null
|
||||
? {}
|
||||
: { speed: normalizeMinimaxSpeed(talkProviderConfig.speed) }),
|
||||
...(normalizeMinimaxVolume(talkProviderConfig.vol) == null
|
||||
? {}
|
||||
: { vol: normalizeMinimaxVolume(talkProviderConfig.vol) }),
|
||||
...(normalizeMinimaxPitch(talkProviderConfig.pitch) == null
|
||||
? {}
|
||||
: { pitch: normalizeMinimaxPitch(talkProviderConfig.pitch) }),
|
||||
};
|
||||
},
|
||||
resolveTalkOverrides: ({ params }) => ({
|
||||
...(trimToUndefined(params.voiceId) == null
|
||||
? {}
|
||||
: { voiceId: trimToUndefined(params.voiceId) }),
|
||||
...(trimToUndefined(params.modelId) == null
|
||||
? {}
|
||||
: { model: trimToUndefined(params.modelId) }),
|
||||
...(normalizeMinimaxSpeed(params.speed) == null
|
||||
? {}
|
||||
: { speed: normalizeMinimaxSpeed(params.speed) }),
|
||||
...(normalizeMinimaxVolume(params.vol) == null
|
||||
? {}
|
||||
: { vol: normalizeMinimaxVolume(params.vol) }),
|
||||
...(normalizeMinimaxPitch(params.pitch) == null
|
||||
? {}
|
||||
: { pitch: normalizeMinimaxPitch(params.pitch) }),
|
||||
}),
|
||||
listVoices: async () => MINIMAX_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
|
||||
isConfigured: ({ cfg, providerConfig }) =>
|
||||
Boolean(
|
||||
readMinimaxProviderConfig(providerConfig, cfg).apiKey ||
|
||||
isProviderAuthProfileConfigured({ cfg, provider: MINIMAX_PORTAL_PROVIDER_ID }) ||
|
||||
resolveMinimaxTokenPlanEnvKey() ||
|
||||
process.env.MINIMAX_API_KEY,
|
||||
),
|
||||
synthesize: async (req) => {
|
||||
const config = readMinimaxProviderConfig(req.providerConfig, req.cfg);
|
||||
const overrides = readMinimaxOverrides(req.providerOverrides);
|
||||
const apiKey = await resolveMinimaxTtsApiKey({
|
||||
cfg: req.cfg,
|
||||
configApiKey: config.apiKey,
|
||||
});
|
||||
if (!apiKey) {
|
||||
throw new Error("MiniMax TTS auth missing");
|
||||
}
|
||||
const audioBuffer = await minimaxTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
model: overrides.model ?? config.model,
|
||||
voiceId: overrides.voiceId ?? config.voiceId,
|
||||
speed: overrides.speed ?? config.speed,
|
||||
vol: overrides.vol ?? config.vol,
|
||||
pitch: overrides.pitch ?? config.pitch,
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
if (req.target === "voice-note") {
|
||||
const opusBuffer = await transcodeAudioBufferToOpus({
|
||||
audioBuffer,
|
||||
inputExtension: "mp3",
|
||||
tempPrefix: "tts-minimax-",
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
return {
|
||||
audioBuffer: opusBuffer,
|
||||
outputFormat: "opus",
|
||||
fileExtension: ".opus",
|
||||
voiceCompatible: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
audioBuffer,
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
277
extensions/minimax/src/minimax-web-search-provider.runtime.ts
Normal file
277
extensions/minimax/src/minimax-web-search-provider.runtime.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import {
|
||||
createProviderHttpError,
|
||||
formatProviderHttpErrorMessage,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
buildSearchCacheKey,
|
||||
formatCliCommand,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readPositiveIntegerParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
MAX_SEARCH_COUNT,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
type SearchConfigRecord,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-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_TOKEN_PLAN_ENV_VARS = [
|
||||
"MINIMAX_CODE_PLAN_KEY",
|
||||
"MINIMAX_CODING_API_KEY",
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
] 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_TOKEN_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<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;
|
||||
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<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) {
|
||||
throw await createProviderHttpError(res, "MiniMax Search API error");
|
||||
}
|
||||
|
||||
const data = await readProviderJsonResponse<MiniMaxSearchResponse>(
|
||||
res,
|
||||
"MiniMax Search API error",
|
||||
);
|
||||
|
||||
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
|
||||
throw new Error(
|
||||
formatProviderHttpErrorMessage({
|
||||
label: "MiniMax Search API error",
|
||||
status: data.base_resp.status_code,
|
||||
detail: 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 Token Plan key or OAuth token. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, MINIMAX_OAUTH_TOKEN, or MINIMAX_API_KEY in the Gateway environment.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeMiniMaxWebSearchProviderTool(
|
||||
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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 =
|
||||
readPositiveIntegerParam(params, "count", {
|
||||
max: MAX_SEARCH_COUNT,
|
||||
message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`,
|
||||
}) ??
|
||||
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,
|
||||
readMiniMaxSearchJsonResponse: readProviderJsonResponse<MiniMaxSearchResponse>,
|
||||
} as const;
|
||||
export { testing as __testing };
|
||||
178
extensions/minimax/src/minimax-web-search-provider.test.ts
Normal file
178
extensions/minimax/src/minimax-web-search-provider.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// Minimax tests cover minimax web search provider plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { minimaxWebSearchTesting } from "../test-api.js";
|
||||
|
||||
const {
|
||||
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
|
||||
MINIMAX_SEARCH_ENDPOINT_CN,
|
||||
resolveMiniMaxApiKey,
|
||||
resolveMiniMaxEndpoint,
|
||||
resolveMiniMaxRegion,
|
||||
readMiniMaxSearchJsonResponse,
|
||||
} = minimaxWebSearchTesting;
|
||||
|
||||
function restoreEnvValue(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
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 originalOauthToken = process.env.MINIMAX_OAUTH_TOKEN;
|
||||
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_OAUTH_TOKEN;
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnvValue("MINIMAX_API_HOST", originalApiHost);
|
||||
restoreEnvValue("MINIMAX_CODE_PLAN_KEY", originalCodePlanKey);
|
||||
restoreEnvValue("MINIMAX_CODING_API_KEY", originalCodingApiKey);
|
||||
restoreEnvValue("MINIMAX_OAUTH_TOKEN", originalOauthToken);
|
||||
restoreEnvValue("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 token-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");
|
||||
});
|
||||
|
||||
it("accepts MINIMAX_OAUTH_TOKEN before the legacy API-key fallback", () => {
|
||||
process.env.MINIMAX_OAUTH_TOKEN = "oauth-token";
|
||||
process.env.MINIMAX_API_KEY = "plain-key";
|
||||
expect(resolveMiniMaxApiKey()).toBe("oauth-token");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports malformed Search API JSON with a stable provider error", async () => {
|
||||
await expect(
|
||||
readMiniMaxSearchJsonResponse(new Response("{ nope"), "MiniMax Search API error"),
|
||||
).rejects.toThrow("MiniMax Search API error: malformed JSON response");
|
||||
});
|
||||
});
|
||||
61
extensions/minimax/src/minimax-web-search-provider.ts
Normal file
61
extensions/minimax/src/minimax-web-search-provider.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
|
||||
const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey";
|
||||
const MINIMAX_TOKEN_PLAN_ENV_VARS = [
|
||||
"MINIMAX_CODE_PLAN_KEY",
|
||||
"MINIMAX_CODING_API_KEY",
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
] as const;
|
||||
const MINIMAX_WEB_SEARCH_ENV_VARS = [...MINIMAX_TOKEN_PLAN_ENV_VARS, "MINIMAX_API_KEY"] as const;
|
||||
|
||||
const loadMiniMaxWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./minimax-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
const MiniMaxSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
id: "minimax",
|
||||
label: "MiniMax Search",
|
||||
hint: "Structured results via MiniMax Token Plan search API",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "MiniMax Token Plan key or OAuth token",
|
||||
envVars: [...MINIMAX_WEB_SEARCH_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: 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 loadMiniMaxWebSearchRuntime();
|
||||
return await executeMiniMaxWebSearchProviderTool(ctx, args);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
12
extensions/minimax/test-api.ts
Normal file
12
extensions/minimax/test-api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Minimax API module exposes the plugin public contract.
|
||||
export {
|
||||
buildMinimaxImageGenerationProvider,
|
||||
buildMinimaxPortalImageGenerationProvider,
|
||||
} from "./image-generation-provider.js";
|
||||
export { buildMinimaxMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
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";
|
||||
23
extensions/minimax/thinking.ts
Normal file
23
extensions/minimax/thinking.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// MiniMax thinking policy keeps M3 active by default while preserving M2.x leak prevention.
|
||||
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
const BUDGET_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
|
||||
const ADAPTIVE_THINKING_LEVELS = ["off", "adaptive"] as const;
|
||||
|
||||
export function resolveMinimaxThinkingProfile(
|
||||
modelId: string,
|
||||
): ProviderThinkingProfile | undefined {
|
||||
if (/^MiniMax-M3(\b|[-.])/i.test(modelId)) {
|
||||
return {
|
||||
levels: ADAPTIVE_THINKING_LEVELS.map((id) => ({ id })),
|
||||
defaultLevel: "adaptive",
|
||||
};
|
||||
}
|
||||
if (/^MiniMax-M2(?:\b|[-.])/i.test(modelId)) {
|
||||
return {
|
||||
levels: BUDGET_THINKING_LEVELS.map((id) => ({ id })),
|
||||
defaultLevel: "off",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
16
extensions/minimax/tsconfig.json
Normal file
16
extensions/minimax/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
122
extensions/minimax/tts.test.ts
Normal file
122
extensions/minimax/tts.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Minimax tests cover tts plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { minimaxTTS } from "./tts.js";
|
||||
|
||||
describe("minimaxTTS", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caps oversized request timeout before arming abort timers", async () => {
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(0 as unknown as ReturnType<typeof setTimeout>);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({ data: { audio: Buffer.from("audio").toString("hex") } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const audio = await minimaxTTS({
|
||||
text: "hello",
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.minimax.io",
|
||||
model: "speech-2.8-hd",
|
||||
voiceId: "English_expressive_narrator",
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
|
||||
expect(audio.toString()).toBe("audio");
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
timeoutMs: MAX_TIMER_TIMEOUT_MS,
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("throws on base_resp envelope error even when data.audio is present (regression #76904)", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
data: { audio: Buffer.from("placeholder").toString("hex") },
|
||||
base_resp: { status_code: 1002, status_msg: "Quota exceeded" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
minimaxTTS({
|
||||
text: "hello",
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.minimax.io",
|
||||
model: "speech-2.8-hd",
|
||||
voiceId: "English_expressive_narrator",
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow("MiniMax TTS API error (1002): Quota exceeded");
|
||||
});
|
||||
|
||||
it("throws on base_resp envelope error with empty audio", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
base_resp: { status_code: 1001, status_msg: "Rate limit" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
minimaxTTS({
|
||||
text: "hello",
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.minimax.io",
|
||||
model: "speech-2.8-hd",
|
||||
voiceId: "English_expressive_narrator",
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow("MiniMax TTS API error (1001): Rate limit");
|
||||
});
|
||||
|
||||
it("succeeds when base_resp.status_code is 0", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
data: { audio: Buffer.from("real-audio").toString("hex") },
|
||||
base_resp: { status_code: 0, status_msg: "success" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const audio = await minimaxTTS({
|
||||
text: "hello",
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.minimax.io",
|
||||
model: "speech-2.8-hd",
|
||||
voiceId: "English_expressive_narrator",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(audio.toString()).toBe("real-audio");
|
||||
});
|
||||
});
|
||||
140
extensions/minimax/tts.ts
Normal file
140
extensions/minimax/tts.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Minimax plugin module implements tts behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
export const DEFAULT_MINIMAX_TTS_BASE_URL = "https://api.minimax.io";
|
||||
|
||||
export const MINIMAX_TTS_MODELS = [
|
||||
"speech-2.8-hd",
|
||||
"speech-2.8-turbo",
|
||||
"speech-2.6-hd",
|
||||
"speech-2.6-turbo",
|
||||
"speech-02-hd",
|
||||
"speech-02-turbo",
|
||||
"speech-01-hd",
|
||||
"speech-01-turbo",
|
||||
"speech-01-240228",
|
||||
] as const;
|
||||
|
||||
export const MINIMAX_TTS_VOICES = [
|
||||
"English_expressive_narrator",
|
||||
"Chinese (Mandarin)_Warm_Girl",
|
||||
"Chinese (Mandarin)_Lively_Girl",
|
||||
"Chinese (Mandarin)_Gentle_Boy",
|
||||
"Chinese (Mandarin)_Steady_Boy",
|
||||
] as const;
|
||||
|
||||
export function normalizeMinimaxTtsBaseUrl(baseUrl?: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_MINIMAX_TTS_BASE_URL;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "").replace(/\/(?:anthropic|v1)$/i, "");
|
||||
}
|
||||
|
||||
function normalizeMinimaxTtsPitch(pitch: number): number {
|
||||
return Math.trunc(pitch);
|
||||
}
|
||||
|
||||
export async function minimaxTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
voiceId: string;
|
||||
speed?: number;
|
||||
vol?: number;
|
||||
pitch?: number;
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
const {
|
||||
text,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
voiceId,
|
||||
speed = 1,
|
||||
vol = 1,
|
||||
pitch = 0,
|
||||
format = "mp3",
|
||||
sampleRate = 32000,
|
||||
timeoutMs,
|
||||
} = params;
|
||||
const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), safeTimeoutMs);
|
||||
|
||||
try {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${baseUrl}/v1/t2a_v2`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
text,
|
||||
stream: false,
|
||||
output_format: "hex",
|
||||
voice_setting: {
|
||||
voice_id: voiceId,
|
||||
speed,
|
||||
vol,
|
||||
pitch: normalizeMinimaxTtsPitch(pitch),
|
||||
},
|
||||
audio_setting: {
|
||||
format,
|
||||
sample_rate: sampleRate,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
timeoutMs: safeTimeoutMs,
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
||||
auditContext: "minimax.tts",
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "MiniMax TTS API error");
|
||||
|
||||
const body = await readProviderJsonResponse<{
|
||||
data?: { audio?: string };
|
||||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
}>(response, "minimax.tts");
|
||||
|
||||
// Check base_resp for envelope errors (HTTP 200 with non-zero status_code).
|
||||
// Other MiniMax providers (image, video, music, web-search) already check this.
|
||||
// Without this check, quota/billing errors with placeholder audio are silently accepted.
|
||||
if (
|
||||
body.base_resp &&
|
||||
typeof body.base_resp.status_code === "number" &&
|
||||
body.base_resp.status_code !== 0
|
||||
) {
|
||||
const msg = body.base_resp.status_msg ?? "unknown error";
|
||||
throw new Error(`MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`);
|
||||
}
|
||||
|
||||
const hexAudio = body?.data?.audio;
|
||||
if (!hexAudio) {
|
||||
throw new Error("MiniMax TTS API returned no audio data");
|
||||
}
|
||||
|
||||
return Buffer.from(hexAudio, "hex");
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
306
extensions/minimax/video-generation-provider.test.ts
Normal file
306
extensions/minimax/video-generation-provider.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// Minimax tests cover video generation provider plugin behavior.
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getMinimaxProviderHttpMocks,
|
||||
installMinimaxProviderHttpMockCleanup,
|
||||
loadMinimaxVideoGenerationProviderModule,
|
||||
} from "./provider-http.test-helpers.js";
|
||||
|
||||
const {
|
||||
resolveApiKeyForProviderMock,
|
||||
postJsonRequestMock,
|
||||
fetchWithTimeoutMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
} = getMinimaxProviderHttpMocks();
|
||||
|
||||
let buildMinimaxVideoGenerationProvider: Awaited<
|
||||
ReturnType<typeof loadMinimaxVideoGenerationProviderModule>
|
||||
>["buildMinimaxVideoGenerationProvider"];
|
||||
let buildMinimaxPortalVideoGenerationProvider: Awaited<
|
||||
ReturnType<typeof loadMinimaxVideoGenerationProviderModule>
|
||||
>["buildMinimaxPortalVideoGenerationProvider"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildMinimaxVideoGenerationProvider, buildMinimaxPortalVideoGenerationProvider } =
|
||||
await loadMinimaxVideoGenerationProviderModule());
|
||||
});
|
||||
|
||||
installMinimaxProviderHttpMockCleanup();
|
||||
|
||||
function expectMinimaxFetchCall(index: number, url: string) {
|
||||
const call = fetchWithTimeoutMock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected MiniMax fetch call ${index + 1}`);
|
||||
}
|
||||
const [actualUrl, init, timeoutMs, fetchFn] = call;
|
||||
expect(actualUrl).toBe(url);
|
||||
expect(init?.method).toBe("GET");
|
||||
expect(Number.isInteger(timeoutMs)).toBe(true);
|
||||
expect(timeoutMs).toBeGreaterThan(0);
|
||||
expect(fetchFn).toBe(fetch);
|
||||
}
|
||||
|
||||
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0): Record<string, unknown> {
|
||||
const call = mock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call ${index}`);
|
||||
}
|
||||
return call[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function streamedVideoResponse(bytes: string): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bytes));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "video/mp4" } },
|
||||
);
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function oversizedJsonResponse(): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(1024 * 1024).fill(0x20));
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("minimax video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
const provider = buildMinimaxVideoGenerationProvider();
|
||||
expectExplicitVideoGenerationCapabilities(provider);
|
||||
expect(provider.capabilities.generate?.resolutions).toEqual(["768P", "1080P"]);
|
||||
expect(provider.capabilities.imageToVideo?.resolutions).toEqual(["768P", "1080P"]);
|
||||
});
|
||||
|
||||
it("creates a task, polls status, and downloads the generated video", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
task_id: "task-123",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: "task-123",
|
||||
status: "Success",
|
||||
video_url: "https://example.com/out.mp4",
|
||||
file_id: "file-1",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/webm" }),
|
||||
arrayBuffer: async () => Buffer.from("webm-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "minimax",
|
||||
model: "MiniMax-Hailuo-2.3",
|
||||
prompt: "A fox sprints across snowy hills",
|
||||
cfg: {},
|
||||
durationSeconds: 5,
|
||||
resolution: "720P",
|
||||
});
|
||||
|
||||
const request = mockCallArg(postJsonRequestMock);
|
||||
expect(request.url).toBe("https://api.minimax.io/v1/video_generation");
|
||||
const body = request.body as Record<string, unknown>;
|
||||
expect(body.duration).toBe(6);
|
||||
expect(body.resolution).toBe("768P");
|
||||
expect(result.videos).toHaveLength(1);
|
||||
expect(result.videos[0]?.fileName).toBe("video-1.webm");
|
||||
expect(result.metadata?.taskId).toBe("task-123");
|
||||
expect(result.metadata?.fileId).toBe("file-1");
|
||||
});
|
||||
|
||||
it("rejects generated video downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
task_id: "task-too-large",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: "task-too-large",
|
||||
status: "Success",
|
||||
video_url: "https://example.com/too-large.mp4",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
|
||||
|
||||
const provider = buildMinimaxVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "minimax",
|
||||
model: "MiniMax-Hailuo-2.3",
|
||||
prompt: "short video",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
).rejects.toThrow("MiniMax generated video download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("downloads via file_id when the status response omits video_url", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
task_id: "task-456",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: "task-456",
|
||||
status: "Success",
|
||||
file_id: "file-9",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
file: {
|
||||
file_id: "file-9",
|
||||
filename: "output_aigc.mp4",
|
||||
download_url: "https://example.com/download.mp4",
|
||||
},
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("mp4-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "minimax",
|
||||
model: "MiniMax-Hailuo-2.3",
|
||||
prompt: "A fox sprints across snowy hills",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expectMinimaxFetchCall(1, "https://api.minimax.io/v1/files/retrieve?file_id=file-9");
|
||||
expectMinimaxFetchCall(2, "https://example.com/download.mp4");
|
||||
expect(result.videos).toHaveLength(1);
|
||||
expect(result.metadata?.taskId).toBe("task-456");
|
||||
expect(result.metadata?.fileId).toBe("file-9");
|
||||
expect(result.metadata?.videoUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects oversized file_id metadata JSON before downloading", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
task_id: "task-metadata-too-large",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
task_id: "task-metadata-too-large",
|
||||
status: "Success",
|
||||
file_id: "file-too-large",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(oversizedJsonResponse());
|
||||
|
||||
const provider = buildMinimaxVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "minimax",
|
||||
model: "MiniMax-Hailuo-2.3",
|
||||
prompt: "A fox sprints across snowy hills",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("MiniMax generated video metadata: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expectMinimaxFetchCall(1, "https://api.minimax.io/v1/files/retrieve?file_id=file-too-large");
|
||||
expect(fetchWithTimeoutMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes portal video generation through minimax-portal auth and HTTP config", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
task_id: "task-portal",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: "task-portal",
|
||||
status: "Success",
|
||||
video_url: "https://example.com/portal.mp4",
|
||||
base_resp: { status_code: 0 },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("mp4-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildMinimaxPortalVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "minimax-portal",
|
||||
model: "MiniMax-Hailuo-2.3",
|
||||
prompt: "A neon city street at night",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://wrong.example/anthropic",
|
||||
models: [],
|
||||
},
|
||||
"minimax-portal": {
|
||||
baseUrl: "https://api.minimaxi.com/anthropic",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockCallArg(resolveApiKeyForProviderMock).provider).toBe("minimax-portal");
|
||||
const httpConfigParams = mockCallArg(resolveProviderHttpRequestConfigMock);
|
||||
expect(httpConfigParams.baseUrl).toBe("https://api.minimaxi.com");
|
||||
expect(httpConfigParams.provider).toBe("minimax-portal");
|
||||
expect(httpConfigParams.capability).toBe("video");
|
||||
expect(httpConfigParams.transport).toBe("http");
|
||||
expect(mockCallArg(postJsonRequestMock).url).toBe(
|
||||
"https://api.minimaxi.com/v1/video_generation",
|
||||
);
|
||||
expectMinimaxFetchCall(
|
||||
0,
|
||||
"https://api.minimaxi.com/v1/query/video_generation?task_id=task-portal",
|
||||
);
|
||||
});
|
||||
});
|
||||
482
extensions/minimax/video-generation-provider.ts
Normal file
482
extensions/minimax/video-generation-provider.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
import { toImageDataUrl } from "openclaw/plugin-sdk/image-generation";
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
createProviderOperationDeadline,
|
||||
createProviderOperationTimeoutResolver,
|
||||
fetchProviderDownloadResponse,
|
||||
fetchProviderOperationResponse,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
resolveProviderHttpRequestConfig,
|
||||
waitProviderOperationPollInterval,
|
||||
type ProviderOperationTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
|
||||
const DEFAULT_MINIMAX_VIDEO_BASE_URL = "https://api.minimax.io";
|
||||
const DEFAULT_MINIMAX_VIDEO_MODEL = "MiniMax-Hailuo-2.3";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 1_200_000;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const MAX_POLL_ATTEMPTS = 120;
|
||||
const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const MINIMAX_MODEL_ALLOWED_DURATIONS: Readonly<Record<string, readonly number[]>> = {
|
||||
"MiniMax-Hailuo-2.3": [6, 10],
|
||||
"MiniMax-Hailuo-02": [6, 10],
|
||||
};
|
||||
const MINIMAX_MODEL_ALLOWED_RESOLUTIONS: Readonly<Record<string, readonly string[]>> = {
|
||||
"MiniMax-Hailuo-2.3": ["768P", "1080P"],
|
||||
"MiniMax-Hailuo-2.3-Fast": ["768P", "1080P"],
|
||||
"MiniMax-Hailuo-02": ["768P", "1080P"],
|
||||
};
|
||||
const MINIMAX_RESOLUTION_ORDER = ["480P", "720P", "768P", "1080P"] as const;
|
||||
|
||||
type MinimaxBaseResp = {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
|
||||
type MinimaxCreateResponse = {
|
||||
task_id?: string;
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
type MinimaxQueryResponse = {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
file_id?: string;
|
||||
video_url?: string;
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
type MinimaxFileRetrieveResponse = {
|
||||
file?: {
|
||||
download_url?: string;
|
||||
filename?: string;
|
||||
};
|
||||
base_resp?: MinimaxBaseResp;
|
||||
};
|
||||
|
||||
function resolveMinimaxVideoBaseUrl(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
providerId: string,
|
||||
): string {
|
||||
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
|
||||
if (!direct) {
|
||||
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
|
||||
}
|
||||
try {
|
||||
return new URL(direct).origin;
|
||||
} catch {
|
||||
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGeneratedVideoMaxBytes(req: VideoGenerationRequest): number {
|
||||
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return Math.floor(configured * 1024 * 1024);
|
||||
}
|
||||
return DEFAULT_GENERATED_VIDEO_MAX_BYTES;
|
||||
}
|
||||
|
||||
function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: string): void {
|
||||
if (!baseResp || typeof baseResp.status_code !== "number" || baseResp.status_code === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFirstFrameImage(req: VideoGenerationRequest): string | undefined {
|
||||
const input = req.inputImages?.[0];
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const inputUrl = normalizeOptionalString(input.url);
|
||||
if (inputUrl) {
|
||||
return inputUrl;
|
||||
}
|
||||
if (!input.buffer) {
|
||||
throw new Error("MiniMax image-to-video input is missing image data.");
|
||||
}
|
||||
return toImageDataUrl({ ...input, buffer: input.buffer, defaultMimeType: "image/png" });
|
||||
}
|
||||
|
||||
function resolveDurationSeconds(params: {
|
||||
model: string;
|
||||
durationSeconds: number | undefined;
|
||||
}): number | undefined {
|
||||
if (typeof params.durationSeconds !== "number" || !Number.isFinite(params.durationSeconds)) {
|
||||
return undefined;
|
||||
}
|
||||
const rounded = Math.max(1, Math.round(params.durationSeconds));
|
||||
const allowed = MINIMAX_MODEL_ALLOWED_DURATIONS[params.model];
|
||||
if (!allowed || allowed.length === 0) {
|
||||
return rounded;
|
||||
}
|
||||
return allowed.reduce((best, current) =>
|
||||
Math.abs(current - rounded) < Math.abs(best - rounded) ? current : best,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveResolution(params: {
|
||||
model: string;
|
||||
resolution: string | undefined;
|
||||
}): string | undefined {
|
||||
const requested = normalizeOptionalString(params.resolution)?.toUpperCase();
|
||||
if (!requested) {
|
||||
return undefined;
|
||||
}
|
||||
const allowed = MINIMAX_MODEL_ALLOWED_RESOLUTIONS[params.model];
|
||||
if (!allowed || allowed.length === 0 || allowed.includes(requested)) {
|
||||
return requested;
|
||||
}
|
||||
const requestedIndex = MINIMAX_RESOLUTION_ORDER.indexOf(
|
||||
requested as (typeof MINIMAX_RESOLUTION_ORDER)[number],
|
||||
);
|
||||
if (requestedIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return allowed.reduce((best, current) => {
|
||||
const currentIndex = MINIMAX_RESOLUTION_ORDER.indexOf(
|
||||
current as (typeof MINIMAX_RESOLUTION_ORDER)[number],
|
||||
);
|
||||
const bestIndex = MINIMAX_RESOLUTION_ORDER.indexOf(
|
||||
best as (typeof MINIMAX_RESOLUTION_ORDER)[number],
|
||||
);
|
||||
if (currentIndex < 0) {
|
||||
return best;
|
||||
}
|
||||
if (bestIndex < 0) {
|
||||
return current;
|
||||
}
|
||||
return Math.abs(currentIndex - requestedIndex) < Math.abs(bestIndex - requestedIndex)
|
||||
? current
|
||||
: best;
|
||||
});
|
||||
}
|
||||
|
||||
async function pollMinimaxVideo(params: {
|
||||
taskId: string;
|
||||
headers: Headers;
|
||||
timeoutMs?: number;
|
||||
baseUrl: string;
|
||||
fetchFn: typeof fetch;
|
||||
}): Promise<MinimaxQueryResponse> {
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: params.timeoutMs,
|
||||
label: `MiniMax video generation task ${params.taskId}`,
|
||||
});
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
const url = new URL(`${params.baseUrl}/v1/query/video_generation`);
|
||||
url.searchParams.set("task_id", params.taskId);
|
||||
const response = await fetchProviderOperationResponse({
|
||||
stage: "poll",
|
||||
url: url.toString(),
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: params.headers,
|
||||
},
|
||||
timeoutMs: createProviderOperationTimeoutResolver({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn: params.fetchFn,
|
||||
provider: "minimax",
|
||||
requestFailedMessage: "MiniMax video status request failed",
|
||||
});
|
||||
const payload = await readProviderJsonResponse<MinimaxQueryResponse>(
|
||||
response,
|
||||
"MiniMax video generation failed",
|
||||
);
|
||||
assertMinimaxBaseResp(payload.base_resp, "MiniMax video generation failed");
|
||||
switch (normalizeOptionalString(payload.status)) {
|
||||
case "Success":
|
||||
return payload;
|
||||
case "Fail":
|
||||
throw new Error(
|
||||
normalizeOptionalString(payload.base_resp?.status_msg) ||
|
||||
"MiniMax video generation failed",
|
||||
);
|
||||
default:
|
||||
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(`MiniMax video generation task ${params.taskId} did not finish in time`);
|
||||
}
|
||||
|
||||
async function downloadVideoFromUrl(params: {
|
||||
url: string;
|
||||
timeoutMs?: ProviderOperationTimeoutMs;
|
||||
fetchFn: typeof fetch;
|
||||
maxBytes: number;
|
||||
}): Promise<GeneratedVideoAsset> {
|
||||
const response = await fetchProviderDownloadResponse({
|
||||
url: params.url,
|
||||
init: { method: "GET" },
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: params.fetchFn,
|
||||
provider: "minimax",
|
||||
requestFailedMessage: "MiniMax generated video download failed",
|
||||
});
|
||||
const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4";
|
||||
const buffer = await readResponseWithLimit(response, params.maxBytes, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`MiniMax generated video download exceeds ${maxBytes} bytes`),
|
||||
});
|
||||
return {
|
||||
buffer,
|
||||
mimeType,
|
||||
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadVideoFromFileId(params: {
|
||||
fileId: string;
|
||||
headers: Headers;
|
||||
timeoutMs?: ProviderOperationTimeoutMs;
|
||||
baseUrl: string;
|
||||
fetchFn: typeof fetch;
|
||||
maxBytes: number;
|
||||
}): Promise<GeneratedVideoAsset> {
|
||||
const url = new URL(`${params.baseUrl}/v1/files/retrieve`);
|
||||
url.searchParams.set("file_id", params.fileId);
|
||||
const metadataResponse = await fetchProviderOperationResponse({
|
||||
stage: "download",
|
||||
url: url.toString(),
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: params.headers,
|
||||
},
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: params.fetchFn,
|
||||
provider: "minimax",
|
||||
requestFailedMessage: "MiniMax generated video metadata request failed",
|
||||
});
|
||||
const metadata = await readProviderJsonResponse<MinimaxFileRetrieveResponse>(
|
||||
metadataResponse,
|
||||
"MiniMax generated video metadata",
|
||||
);
|
||||
assertMinimaxBaseResp(metadata.base_resp, "MiniMax generated video metadata request failed");
|
||||
const downloadUrl = normalizeOptionalString(metadata.file?.download_url);
|
||||
if (!downloadUrl) {
|
||||
throw new Error("MiniMax generated video metadata missing download_url");
|
||||
}
|
||||
const response = await fetchProviderDownloadResponse({
|
||||
url: downloadUrl,
|
||||
init: { method: "GET" },
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: params.fetchFn,
|
||||
provider: "minimax",
|
||||
requestFailedMessage: "MiniMax generated video download failed",
|
||||
});
|
||||
const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4";
|
||||
const buffer = await readResponseWithLimit(response, params.maxBytes, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`MiniMax generated video download exceeds ${maxBytes} bytes`),
|
||||
});
|
||||
return {
|
||||
buffer,
|
||||
mimeType,
|
||||
fileName:
|
||||
normalizeOptionalString(metadata.file?.filename) ||
|
||||
`video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxVideoProvider(providerId: string): VideoGenerationProvider {
|
||||
return {
|
||||
id: providerId,
|
||||
label: "MiniMax",
|
||||
defaultModel: DEFAULT_MINIMAX_VIDEO_MODEL,
|
||||
models: [
|
||||
DEFAULT_MINIMAX_VIDEO_MODEL,
|
||||
"MiniMax-Hailuo-2.3-Fast",
|
||||
"MiniMax-Hailuo-02",
|
||||
"I2V-01-Director",
|
||||
"I2V-01-live",
|
||||
"I2V-01",
|
||||
],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: providerId,
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxVideos: 1,
|
||||
maxDurationSeconds: 10,
|
||||
supportedDurationSecondsByModel: MINIMAX_MODEL_ALLOWED_DURATIONS,
|
||||
resolutions: ["768P", "1080P"],
|
||||
supportsResolution: true,
|
||||
supportsWatermark: false,
|
||||
},
|
||||
imageToVideo: {
|
||||
enabled: true,
|
||||
maxVideos: 1,
|
||||
maxInputImages: 1,
|
||||
maxDurationSeconds: 10,
|
||||
supportedDurationSecondsByModel: MINIMAX_MODEL_ALLOWED_DURATIONS,
|
||||
resolutions: ["768P", "1080P"],
|
||||
supportsResolution: true,
|
||||
supportsWatermark: false,
|
||||
},
|
||||
videoToVideo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
async generateVideo(req) {
|
||||
if ((req.inputVideos?.length ?? 0) > 0) {
|
||||
throw new Error("MiniMax video generation does not support video reference inputs.");
|
||||
}
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: providerId,
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("MiniMax API key missing");
|
||||
}
|
||||
|
||||
const fetchFn = fetch;
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: req.timeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS,
|
||||
label: "MiniMax video generation",
|
||||
});
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolveMinimaxVideoBaseUrl(req.cfg, providerId),
|
||||
defaultBaseUrl: DEFAULT_MINIMAX_VIDEO_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: providerId,
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
const model = normalizeOptionalString(req.model) ?? DEFAULT_MINIMAX_VIDEO_MODEL;
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt: req.prompt,
|
||||
};
|
||||
const firstFrameImage = resolveFirstFrameImage(req);
|
||||
if (firstFrameImage) {
|
||||
body.first_frame_image = firstFrameImage;
|
||||
}
|
||||
const resolution = resolveResolution({
|
||||
model,
|
||||
resolution: req.resolution,
|
||||
});
|
||||
if (resolution) {
|
||||
body.resolution = resolution;
|
||||
}
|
||||
const durationSeconds = resolveDurationSeconds({
|
||||
model,
|
||||
durationSeconds: req.durationSeconds,
|
||||
});
|
||||
if (typeof durationSeconds === "number") {
|
||||
body.duration = durationSeconds;
|
||||
}
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/v1/video_generation`,
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "MiniMax video generation failed");
|
||||
const submitted = await readProviderJsonResponse<MinimaxCreateResponse>(
|
||||
response,
|
||||
"MiniMax video generation failed",
|
||||
);
|
||||
assertMinimaxBaseResp(submitted.base_resp, "MiniMax video generation failed");
|
||||
const taskId = normalizeOptionalString(submitted.task_id);
|
||||
if (!taskId) {
|
||||
throw new Error("MiniMax video generation response missing task_id");
|
||||
}
|
||||
const completed = await pollMinimaxVideo({
|
||||
taskId,
|
||||
headers,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_OPERATION_TIMEOUT_MS,
|
||||
}),
|
||||
baseUrl,
|
||||
fetchFn,
|
||||
});
|
||||
const videoUrl = normalizeOptionalString(completed.video_url);
|
||||
const fileId = normalizeOptionalString(completed.file_id);
|
||||
const video = videoUrl
|
||||
? await downloadVideoFromUrl({
|
||||
url: videoUrl,
|
||||
timeoutMs: createProviderOperationTimeoutResolver({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
maxBytes: resolveGeneratedVideoMaxBytes(req),
|
||||
})
|
||||
: fileId
|
||||
? await downloadVideoFromFileId({
|
||||
fileId,
|
||||
headers,
|
||||
timeoutMs: createProviderOperationTimeoutResolver({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
baseUrl,
|
||||
fetchFn,
|
||||
maxBytes: resolveGeneratedVideoMaxBytes(req),
|
||||
})
|
||||
: (() => {
|
||||
throw new Error(
|
||||
"MiniMax video generation completed without a video URL or file_id",
|
||||
);
|
||||
})();
|
||||
return {
|
||||
videos: [video],
|
||||
model,
|
||||
metadata: {
|
||||
taskId,
|
||||
status: completed.status,
|
||||
fileId,
|
||||
videoUrl,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return buildMinimaxVideoProvider("minimax");
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return buildMinimaxVideoProvider("minimax-portal");
|
||||
}
|
||||
36
extensions/minimax/web-search-contract-api.ts
Normal file
36
extensions/minimax/web-search-contract-api.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Minimax API module exposes the plugin public contract.
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
|
||||
const MINIMAX_TOKEN_PLAN_ENV_VARS = [
|
||||
"MINIMAX_CODE_PLAN_KEY",
|
||||
"MINIMAX_CODING_API_KEY",
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
] as const;
|
||||
const MINIMAX_WEB_SEARCH_ENV_VARS = [...MINIMAX_TOKEN_PLAN_ENV_VARS, "MINIMAX_API_KEY"] as const;
|
||||
|
||||
export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const credentialPath = "plugins.entries.minimax.config.webSearch.apiKey";
|
||||
|
||||
return {
|
||||
id: "minimax",
|
||||
label: "MiniMax Search",
|
||||
hint: "Structured results via MiniMax Token Plan search API",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "MiniMax Token Plan key or OAuth token",
|
||||
envVars: [...MINIMAX_WEB_SEARCH_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,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath,
|
||||
searchCredential: { type: "top-level" },
|
||||
configuredCredential: { pluginId: "minimax" },
|
||||
}),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
2
extensions/minimax/web-search-provider.ts
Normal file
2
extensions/minimax/web-search-provider.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Minimax provider module implements model/runtime integration.
|
||||
export { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
|
||||
Reference in New Issue
Block a user