mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 04:23:28 +00:00
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:
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1126
-3
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user