feat: add MiniMax provider support with model metadata enrichment

- Add MiniMax temperature clamping in model-probe (temperature > 0 required)
- Add known-providers metadata database for MiniMax-M2.7 and M2.7-highspeed
- Enrich provider model details from built-in presets when config is incomplete
- Add vitest test framework with 21 unit tests and 3 integration tests
- Document MiniMax provider setup in README (English and Chinese)
This commit is contained in:
PR Bot
2026-03-26 11:41:51 +08:00
parent 81deb042b5
commit 1babaa2fe2
9 changed files with 1506 additions and 9 deletions
+56
View File
@@ -75,6 +75,34 @@ OPENCLAW_HOME=/opt/openclaw
npm run start
```
## Supported Providers
The dashboard includes built-in metadata for the following LLM providers, enabling automatic enrichment of model details (context window, max output tokens, etc.) when they appear in your OpenClaw config:
| Provider | Models | API Type | Base URL |
|----------|--------|----------|----------|
| **[MiniMax](https://platform.minimax.io)** | `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` | OpenAI-compatible | `https://api.minimax.io/v1` |
To use MiniMax with OpenClaw, add the following to your `models.json`:
```json
{
"providers": {
"minimax": {
"baseUrl": "https://api.minimax.io/v1",
"api": "openai-completions",
"apiKey": "your-minimax-api-key",
"models": [
{ "id": "MiniMax-M2.7", "name": "MiniMax-M2.7" },
{ "id": "MiniMax-M2.7-highspeed", "name": "MiniMax-M2.7-highspeed" }
]
}
}
}
```
For more details, see the [MiniMax API Reference](https://platform.minimax.io/docs/api-reference/text-openai-api).
## Docker Deployment
You can also deploy the dashboard using Docker:
@@ -171,6 +199,34 @@ OPENCLAW_HOME=/opt/openclaw
npm run start
```
## 支持的 Provider
仪表盘内置以下 LLM Provider 的模型元数据,当它们出现在 OpenClaw 配置中时,会自动填充上下文窗口、最大输出等详细信息:
| Provider | 模型 | API 类型 | Base URL |
|----------|------|----------|----------|
| **[MiniMax](https://platform.minimax.io)** | `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` | OpenAI 兼容 | `https://api.minimax.io/v1` |
`models.json` 中添加 MiniMax 配置:
```json
{
"providers": {
"minimax": {
"baseUrl": "https://api.minimax.io/v1",
"api": "openai-completions",
"apiKey": "your-minimax-api-key",
"models": [
{ "id": "MiniMax-M2.7", "name": "MiniMax-M2.7" },
{ "id": "MiniMax-M2.7-highspeed", "name": "MiniMax-M2.7-highspeed" }
]
}
}
}
```
更多信息请参考 [MiniMax API 文档](https://platform.minimax.io/docs/api-reference/text-openai-api)。
## 作者联系方式(contact
小红书:[主页](https://xhslink.com/m/AsJKWgEBt1I)
<br/>微信:xmanr123
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { getKnownProvider, enrichModelMeta } from "../lib/known-providers";
describe("getKnownProvider", () => {
it("returns MiniMax provider metadata for exact match", () => {
const provider = getKnownProvider("minimax");
expect(provider).not.toBeNull();
expect(provider!.displayName).toBe("MiniMax");
expect(provider!.api).toBe("openai-completions");
expect(provider!.baseUrl).toBe("https://api.minimax.io/v1");
});
it("returns MiniMax provider metadata for case-insensitive match", () => {
const provider = getKnownProvider("MiniMax");
expect(provider).not.toBeNull();
expect(provider!.displayName).toBe("MiniMax");
});
it("returns null for unknown provider", () => {
const provider = getKnownProvider("unknown-provider");
expect(provider).toBeNull();
});
it("includes both MiniMax models", () => {
const provider = getKnownProvider("minimax");
expect(provider!.models).toHaveLength(2);
const ids = provider!.models.map((m) => m.id);
expect(ids).toContain("MiniMax-M2.7");
expect(ids).toContain("MiniMax-M2.7-highspeed");
});
it("MiniMax models have correct contextWindow and maxTokens", () => {
const provider = getKnownProvider("minimax");
for (const model of provider!.models) {
expect(model.contextWindow).toBe(204800);
expect(model.maxTokens).toBe(192000);
expect(model.reasoning).toBe(false);
expect(model.input).toEqual(["text"]);
}
});
});
describe("enrichModelMeta", () => {
it("fills in missing metadata for known MiniMax model", () => {
const model = { id: "MiniMax-M2.7", name: "MiniMax-M2.7" };
const enriched = enrichModelMeta("minimax", model);
expect(enriched.contextWindow).toBe(204800);
expect(enriched.maxTokens).toBe(192000);
expect(enriched.reasoning).toBe(false);
expect(enriched.input).toEqual(["text"]);
});
it("does not override existing metadata", () => {
const model = {
id: "MiniMax-M2.7",
name: "Custom Name",
contextWindow: 100000,
maxTokens: 50000,
reasoning: true,
input: ["text", "image"],
};
const enriched = enrichModelMeta("minimax", model);
expect(enriched.name).toBe("Custom Name");
expect(enriched.contextWindow).toBe(100000);
expect(enriched.maxTokens).toBe(50000);
expect(enriched.reasoning).toBe(true);
expect(enriched.input).toEqual(["text", "image"]);
});
it("fills in undefined fields while keeping defined ones", () => {
const model = {
id: "MiniMax-M2.7-highspeed",
name: "Highspeed",
contextWindow: undefined as unknown as number,
maxTokens: 50000,
};
const enriched = enrichModelMeta("minimax", model);
expect(enriched.name).toBe("Highspeed");
expect(enriched.contextWindow).toBe(204800);
expect(enriched.maxTokens).toBe(50000);
});
it("returns model unchanged for unknown provider", () => {
const model = { id: "gpt-4", name: "GPT-4" };
const enriched = enrichModelMeta("openai", model);
expect(enriched).toEqual(model);
});
it("returns model unchanged for unknown model in known provider", () => {
const model = { id: "unknown-model", name: "Unknown" };
const enriched = enrichModelMeta("minimax", model);
expect(enriched).toEqual(model);
});
it("performs case-insensitive model ID matching", () => {
const model = { id: "minimax-m2.7" };
const enriched = enrichModelMeta("minimax", model);
expect(enriched.contextWindow).toBe(204800);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
const API_KEY = process.env.MINIMAX_API_KEY;
const BASE_URL = "https://api.minimax.io/v1";
describe.skipIf(!API_KEY)("MiniMax API E2E", () => {
it("completes basic chat with MiniMax-M2.7", async () => {
const response = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "MiniMax-M2.7",
messages: [{ role: "user", content: 'Say "test passed"' }],
max_tokens: 20,
temperature: 1,
}),
});
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.choices[0].message.content).toBeTruthy();
}, 30000);
it("completes chat with temperature=1 (recommended for MiniMax)", async () => {
const response = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "MiniMax-M2.7",
messages: [{ role: "user", content: "Reply with one word" }],
max_tokens: 8,
temperature: 1,
}),
});
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.choices[0].message.content).toBeTruthy();
}, 30000);
it("completes chat with MiniMax-M2.7-highspeed", async () => {
const response = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "MiniMax-M2.7-highspeed",
messages: [{ role: "user", content: 'Reply with "ok"' }],
max_tokens: 10,
temperature: 1,
}),
});
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.choices[0].message.content).toBeTruthy();
}, 30000);
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
/**
* Tests for MiniMax provider detection logic in model-probe.ts.
*
* The actual probeModelDirect function depends on file system (loadProviderConfig),
* so we test the provider detection logic in isolation.
*/
describe("MiniMax provider detection", () => {
function detectTemperature(providerId: string): number {
const isKimiProvider = providerId === "kimi-coding" || providerId === "moonshot";
const isMiniMaxProvider = providerId.toLowerCase().startsWith("minimax");
return (isKimiProvider || isMiniMaxProvider) ? 1 : 0;
}
it("uses temperature=1 for minimax provider", () => {
expect(detectTemperature("minimax")).toBe(1);
});
it("uses temperature=1 for MiniMax (case-insensitive)", () => {
expect(detectTemperature("MiniMax")).toBe(1);
});
it("uses temperature=1 for minimax-custom provider", () => {
expect(detectTemperature("minimax-custom")).toBe(1);
});
it("uses temperature=1 for kimi-coding provider", () => {
expect(detectTemperature("kimi-coding")).toBe(1);
});
it("uses temperature=1 for moonshot provider", () => {
expect(detectTemperature("moonshot")).toBe(1);
});
it("uses temperature=0 for openai provider", () => {
expect(detectTemperature("openai")).toBe(0);
});
it("uses temperature=0 for anthropic provider", () => {
expect(detectTemperature("anthropic")).toBe(0);
});
it("uses temperature=0 for generic provider", () => {
expect(detectTemperature("custom-llm")).toBe(0);
});
});
describe("MiniMax model reference parsing", () => {
function parseModelRef(modelStr: string): { providerId: string; modelId: string } {
const [providerId, ...rest] = modelStr.split("/");
return { providerId: providerId || "", modelId: rest.join("/") || providerId || "" };
}
it("parses minimax/MiniMax-M2.7", () => {
const ref = parseModelRef("minimax/MiniMax-M2.7");
expect(ref.providerId).toBe("minimax");
expect(ref.modelId).toBe("MiniMax-M2.7");
});
it("parses minimax/MiniMax-M2.7-highspeed", () => {
const ref = parseModelRef("minimax/MiniMax-M2.7-highspeed");
expect(ref.providerId).toBe("minimax");
expect(ref.modelId).toBe("MiniMax-M2.7-highspeed");
});
});
+4 -3
View File
@@ -4,6 +4,7 @@ import path from "path";
import { getConfigCache, setConfigCache } from "@/lib/config-cache";
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
import { shouldHidePlatformChannel } from "@/lib/platforms";
import { enrichModelMeta } from "@/lib/known-providers";
// 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
@@ -445,7 +446,7 @@ export async function GET() {
// 提取模型 providers
let providers = Object.entries(config.models?.providers || {}).map(
([providerId, provider]: [string, any]) => {
const models = (provider.models || []).map((m: any) => ({
const models = (provider.models || []).map((m: any) => enrichModelMeta(providerId, {
id: m.id,
name: m.name || m.id,
contextWindow: m.contextWindow,
@@ -523,14 +524,14 @@ export async function GET() {
for (const m of inferredModels) {
const exists = target.models.find((x: any) => x.id === m.id);
if (!exists) {
target.models.push({
target.models.push(enrichModelMeta(providerId, {
id: m.id,
name: m.name || m.id,
contextWindow: undefined,
maxTokens: undefined,
reasoning: undefined,
input: undefined,
});
}));
} else if (!exists.name) {
exists.name = m.name || exists.id;
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Well-known provider model metadata.
*
* When a provider appears in the OpenClaw config without full model details
* (contextWindow, maxTokens, etc.), these presets fill in the gaps so the
* dashboard can display richer information.
*/
export interface KnownModelMeta {
id: string;
name: string;
contextWindow: number;
maxTokens: number;
reasoning: boolean;
input: string[];
}
export interface KnownProviderMeta {
displayName: string;
api: string;
baseUrl: string;
models: KnownModelMeta[];
}
const KNOWN_PROVIDERS: Record<string, KnownProviderMeta> = {
minimax: {
displayName: "MiniMax",
api: "openai-completions",
baseUrl: "https://api.minimax.io/v1",
models: [
{
id: "MiniMax-M2.7",
name: "MiniMax-M2.7",
contextWindow: 204800,
maxTokens: 192000,
reasoning: false,
input: ["text"],
},
{
id: "MiniMax-M2.7-highspeed",
name: "MiniMax-M2.7-highspeed",
contextWindow: 204800,
maxTokens: 192000,
reasoning: false,
input: ["text"],
},
],
},
};
/**
* Look up a known provider by its ID (case-insensitive).
*/
export function getKnownProvider(providerId: string): KnownProviderMeta | null {
const normalized = providerId.toLowerCase();
return KNOWN_PROVIDERS[normalized] ?? null;
}
/**
* Enrich a model entry with known metadata when fields are missing.
*/
export function enrichModelMeta(
providerId: string,
model: { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; input?: string[] },
): typeof model {
const knownProvider = getKnownProvider(providerId);
if (!knownProvider) return model;
const knownModel = knownProvider.models.find(
(m) => m.id === model.id || m.id.toLowerCase() === model.id.toLowerCase(),
);
if (!knownModel) return model;
return {
...model,
name: model.name || knownModel.name,
contextWindow: model.contextWindow ?? knownModel.contextWindow,
maxTokens: model.maxTokens ?? knownModel.maxTokens,
reasoning: model.reasoning ?? knownModel.reasoning,
input: model.input ?? knownModel.input,
};
}
+3 -2
View File
@@ -192,9 +192,10 @@ async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeRe
if (!providerCfg?.baseUrl || !providerCfg.api || !providerCfg.apiKey) return null;
const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_PROBE_TIMEOUT_MS;
// Kimi providers require temperature=1
// Kimi and MiniMax providers require temperature > 0
const isKimiProvider = params.providerId === "kimi-coding" || params.providerId === "moonshot";
const temperature = isKimiProvider ? 1 : 0;
const isMiniMaxProvider = params.providerId.toLowerCase().startsWith("minimax");
const temperature = (isKimiProvider || isMiniMaxProvider) ? 1 : 0;
const headers: Record<string, string> = {
"content-type": "application/json",
+1126 -3
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@tailwindcss/postcss": "^4.0.0",
@@ -18,5 +19,8 @@
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"vitest": "^4.1.1"
}
}