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:
7
extensions/xiaomi/api.ts
Normal file
7
extensions/xiaomi/api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Xiaomi API module exposes the plugin public contract.
|
||||
export { XIAOMI_DEFAULT_MODEL_ID, buildXiaomiProvider } from "./provider-catalog.js";
|
||||
export {
|
||||
applyXiaomiConfig,
|
||||
applyXiaomiProviderConfig,
|
||||
XIAOMI_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
683
extensions/xiaomi/index.test.ts
Normal file
683
extensions/xiaomi/index.test.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
// Xiaomi tests cover index plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
resolveProviderPluginChoice,
|
||||
type RegisteredProviderCollections,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runSingleProviderCatalog } from "../test-support/provider-model-test-helpers.js";
|
||||
import xiaomiPlugin from "./index.js";
|
||||
import { createMiMoThinkingWrapper } from "./stream.js";
|
||||
|
||||
type OpenAICompletionsModel = Model<"openai-completions">;
|
||||
|
||||
type PayloadCapture = {
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ThinkingPayload = {
|
||||
type?: unknown;
|
||||
};
|
||||
|
||||
type ReplayToolCall = {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
function?: {
|
||||
name?: unknown;
|
||||
arguments?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type RegisteredProvider = RegisteredProviderCollections["providers"][number];
|
||||
type FakeStream = {
|
||||
result: () => Promise<unknown>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<unknown>;
|
||||
};
|
||||
|
||||
const emptyUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
function requireThinkingProfileResolver(
|
||||
provider: RegisteredProvider,
|
||||
): NonNullable<RegisteredProvider["resolveThinkingProfile"]> {
|
||||
if (!provider.resolveThinkingProfile) {
|
||||
throw new Error("Xiaomi provider did not register a thinking profile resolver");
|
||||
}
|
||||
return provider.resolveThinkingProfile;
|
||||
}
|
||||
|
||||
const readToolCall = { type: "toolCall", id: "call_1", name: "read", arguments: {} };
|
||||
const readToolResult = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
};
|
||||
const readTool = {
|
||||
name: "read",
|
||||
description: "Read data",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false },
|
||||
};
|
||||
|
||||
const registerXiaomiPlugin = () =>
|
||||
registerProviderPlugin({
|
||||
plugin: xiaomiPlugin,
|
||||
id: "xiaomi",
|
||||
name: "Xiaomi Provider",
|
||||
});
|
||||
|
||||
async function getXiaomiProvider() {
|
||||
const { providers } = await registerXiaomiPlugin();
|
||||
return requireRegisteredProvider(providers, "xiaomi");
|
||||
}
|
||||
|
||||
async function getXiaomiTokenPlanProvider() {
|
||||
const { providers } = await registerXiaomiPlugin();
|
||||
return requireRegisteredProvider(providers, "xiaomi-token-plan");
|
||||
}
|
||||
|
||||
function mimoReasoningModel(
|
||||
id: "mimo-v2-pro" | "mimo-v2-omni" | "mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.6-pro",
|
||||
provider: "xiaomi" | "xiaomi-token-plan" = "xiaomi",
|
||||
): OpenAICompletionsModel {
|
||||
return {
|
||||
provider,
|
||||
id,
|
||||
name: id,
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.xiaomimimo.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 32_000,
|
||||
compat: {},
|
||||
} as OpenAICompletionsModel;
|
||||
}
|
||||
|
||||
function replayAssistantMessage(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
content: Array<Record<string, unknown>>;
|
||||
stopReason: "stop" | "toolUse";
|
||||
}) {
|
||||
return {
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
content: params.content,
|
||||
usage: emptyUsage,
|
||||
stopReason: params.stopReason,
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function readToolReplayContext(assistantMessage: ReturnType<typeof replayAssistantMessage>) {
|
||||
return {
|
||||
messages: [{ role: "user", content: "hi", timestamp: 1 }, assistantMessage, readToolResult],
|
||||
tools: [readTool],
|
||||
} as Context;
|
||||
}
|
||||
|
||||
function mimoReasoningToolReplayContext(provider = "xiaomi") {
|
||||
return readToolReplayContext(
|
||||
replayAssistantMessage({
|
||||
provider,
|
||||
model: "mimo-v2.5-pro",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "call reasoning",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
readToolCall,
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createPayloadCapturingStream(capture: PayloadCapture, model: OpenAICompletionsModel) {
|
||||
return (
|
||||
_streamModel: OpenAICompletionsModel,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, m: unknown) => unknown },
|
||||
) => {
|
||||
capture.payload = buildOpenAICompletionsParams(model, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capture.payload, model);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): FakeStream {
|
||||
return {
|
||||
async result() {
|
||||
return params.resultMessage;
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return (async function* () {
|
||||
for (const event of params.events) {
|
||||
yield event;
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResultStreamFn(params: { events?: unknown[]; resultMessage: unknown }): StreamFn {
|
||||
return () =>
|
||||
createFakeStream({
|
||||
events: params.events ?? [],
|
||||
resultMessage: params.resultMessage,
|
||||
}) as ReturnType<StreamFn>;
|
||||
}
|
||||
|
||||
function requireThinkingWrapper(
|
||||
wrapper: ReturnType<typeof createMiMoThinkingWrapper>,
|
||||
label: string,
|
||||
): NonNullable<ReturnType<typeof createMiMoThinkingWrapper>> {
|
||||
if (!wrapper) {
|
||||
throw new Error(`expected MiMo thinking wrapper for ${label}`);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function readThinking(payload: Record<string, unknown> | undefined): ThinkingPayload | undefined {
|
||||
return payload?.thinking as ThinkingPayload | undefined;
|
||||
}
|
||||
|
||||
function readPayloadMessage(
|
||||
capture: PayloadCapture,
|
||||
index: number,
|
||||
): Record<string, unknown> | undefined {
|
||||
return (capture.payload?.messages as Array<Record<string, unknown>> | undefined)?.[index];
|
||||
}
|
||||
|
||||
function readFirstToolCall(
|
||||
message: Record<string, unknown> | undefined,
|
||||
): ReplayToolCall | undefined {
|
||||
return (message?.tool_calls as ReplayToolCall[] | undefined)?.[0];
|
||||
}
|
||||
|
||||
describe("xiaomi provider plugin", () => {
|
||||
it("registers Xiaomi pay-as-you-go auth metadata", async () => {
|
||||
const { providers } = await registerXiaomiPlugin();
|
||||
const provider = requireRegisteredProvider(providers, "xiaomi");
|
||||
const resolved = resolveProviderPluginChoice({
|
||||
providers,
|
||||
choice: "xiaomi-api-key",
|
||||
});
|
||||
|
||||
expect(provider.id).toBe("xiaomi");
|
||||
expect(provider.label).toBe("Xiaomi");
|
||||
expect(provider.envVars).toEqual(["XIAOMI_API_KEY"]);
|
||||
expect(provider.auth).toHaveLength(1);
|
||||
expect(provider.auth[0]?.label).toBe("Xiaomi API key (Pay-as-you-go)");
|
||||
if (!resolved) {
|
||||
throw new Error("expected Xiaomi api-key auth choice");
|
||||
}
|
||||
expect(resolved.provider.id).toBe("xiaomi");
|
||||
expect(resolved.method.id).toBe("api-key");
|
||||
});
|
||||
|
||||
it("registers Xiaomi Token Plan regional auth metadata", async () => {
|
||||
const { providers } = await registerXiaomiPlugin();
|
||||
const provider = requireRegisteredProvider(providers, "xiaomi-token-plan");
|
||||
const resolved = resolveProviderPluginChoice({
|
||||
providers,
|
||||
choice: "xiaomi-token-plan-sgp",
|
||||
});
|
||||
|
||||
expect(provider.id).toBe("xiaomi-token-plan");
|
||||
expect(provider.label).toBe("Xiaomi Token Plan");
|
||||
expect(provider.envVars).toEqual(["XIAOMI_TOKEN_PLAN_API_KEY"]);
|
||||
expect(
|
||||
provider.auth.map((method) => ({
|
||||
id: method.id,
|
||||
label: method.label,
|
||||
hint: method.hint,
|
||||
choiceId: method.wizard?.choiceId,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "token-plan-ams",
|
||||
label: "Xiaomi Token Plan (Europe)",
|
||||
hint: "Endpoint preset: token-plan-ams.xiaomimimo.com/v1",
|
||||
choiceId: "xiaomi-token-plan-ams",
|
||||
},
|
||||
{
|
||||
id: "token-plan-cn",
|
||||
label: "Xiaomi Token Plan (China)",
|
||||
hint: "Endpoint preset: token-plan-cn.xiaomimimo.com/v1",
|
||||
choiceId: "xiaomi-token-plan-cn",
|
||||
},
|
||||
{
|
||||
id: "token-plan-sgp",
|
||||
label: "Xiaomi Token Plan (Singapore)",
|
||||
hint: "Endpoint preset: token-plan-sgp.xiaomimimo.com/v1",
|
||||
choiceId: "xiaomi-token-plan-sgp",
|
||||
},
|
||||
]);
|
||||
if (!resolved) {
|
||||
throw new Error("expected Xiaomi token-plan auth choice");
|
||||
}
|
||||
expect(resolved.provider.id).toBe("xiaomi-token-plan");
|
||||
expect(resolved.method.id).toBe("token-plan-sgp");
|
||||
});
|
||||
|
||||
it("builds the static Xiaomi model catalog with reasoning flags", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
const catalogProvider = await runSingleProviderCatalog(provider);
|
||||
|
||||
expect(catalogProvider.api).toBe("openai-completions");
|
||||
expect(catalogProvider.baseUrl).toBe("https://api.xiaomimimo.com/v1");
|
||||
|
||||
const modelIds = catalogProvider.models?.map((m) => m.id);
|
||||
expect(modelIds).toContain("mimo-v2-pro");
|
||||
expect(modelIds).toContain("mimo-v2-omni");
|
||||
expect(modelIds).toContain("mimo-v2-flash");
|
||||
|
||||
expect(catalogProvider.models?.find((m) => m.id === "mimo-v2-pro")?.reasoning).toBe(true);
|
||||
expect(catalogProvider.models?.find((m) => m.id === "mimo-v2-omni")?.reasoning).toBe(true);
|
||||
expect(catalogProvider.models?.find((m) => m.id === "mimo-v2-flash")?.reasoning).toBeFalsy();
|
||||
});
|
||||
|
||||
it("exposes Token Plan v2.5 catalog rows only after a provider config selects a region", async () => {
|
||||
const provider = await getXiaomiTokenPlanProvider();
|
||||
|
||||
const missingConfig = await provider.catalog?.run({
|
||||
config: {},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "tp-test" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "tp-test",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
} as never);
|
||||
expect(missingConfig).toBeNull();
|
||||
|
||||
const configured = await provider.catalog?.run({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
"xiaomi-token-plan": {
|
||||
baseUrl: "https://token-plan-cn.xiaomimimo.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "tp-test" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "tp-test",
|
||||
mode: "api_key",
|
||||
source: "profile",
|
||||
profileId: "xiaomi-token-plan:default",
|
||||
}),
|
||||
} as never);
|
||||
if (!configured || !("provider" in configured)) {
|
||||
throw new Error("expected configured Xiaomi Token Plan catalog");
|
||||
}
|
||||
expect(configured.provider.baseUrl).toBe("https://token-plan-cn.xiaomimimo.com/v1");
|
||||
expect(configured.provider.api).toBe("openai-completions");
|
||||
expect(configured.provider.models?.map((model) => model.id)).toEqual([
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
]);
|
||||
expect(configured.provider.models?.find((model) => model.id === "mimo-v2.5")?.input).toEqual([
|
||||
"text",
|
||||
"image",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects token-plan keys on the pay-as-you-go auth choice", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
const method = provider.auth[0];
|
||||
if (!method?.runNonInteractive) {
|
||||
throw new Error("expected Xiaomi pay-as-you-go non-interactive auth");
|
||||
}
|
||||
|
||||
await expect(
|
||||
method.runNonInteractive({
|
||||
authChoice: "xiaomi-api-key",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: { xiaomiApiKey: "tp-test" },
|
||||
runtime: {} as never,
|
||||
resolveApiKey: async () => ({
|
||||
key: "tp-test",
|
||||
source: "flag",
|
||||
}),
|
||||
toApiKeyCredential: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toThrow(
|
||||
"This looks like a Xiaomi MiMo Token Plan key (tp-...). " +
|
||||
"Re-run onboarding with one of: --auth-choice xiaomi-token-plan-cn, " +
|
||||
"--auth-choice xiaomi-token-plan-sgp, or --auth-choice xiaomi-token-plan-ams.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects pay-as-you-go keys on Token Plan auth choices", async () => {
|
||||
const provider = await getXiaomiTokenPlanProvider();
|
||||
const method = provider.auth.find((entry) => entry.id === "token-plan-ams");
|
||||
if (!method?.runNonInteractive) {
|
||||
throw new Error("expected Xiaomi Token Plan non-interactive auth");
|
||||
}
|
||||
|
||||
await expect(
|
||||
method.runNonInteractive({
|
||||
authChoice: "xiaomi-token-plan-ams",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: { xiaomiTokenPlanApiKey: "sk-test" },
|
||||
runtime: {} as never,
|
||||
resolveApiKey: async () => ({
|
||||
key: "sk-test",
|
||||
source: "flag",
|
||||
}),
|
||||
toApiKeyCredential: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toThrow(
|
||||
"This looks like a Xiaomi MiMo pay-as-you-go key (sk-...). " +
|
||||
`Re-run onboarding with --auth-choice xiaomi-api-key or pass --xiaomi-api-key.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects keys that do not start with sk- on the pay-as-you-go auth choice", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
const method = provider.auth[0];
|
||||
if (!method?.runNonInteractive) {
|
||||
throw new Error("expected Xiaomi pay-as-you-go non-interactive auth");
|
||||
}
|
||||
|
||||
await expect(
|
||||
method.runNonInteractive({
|
||||
authChoice: "xiaomi-api-key",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: { xiaomiApiKey: "bad-key" },
|
||||
runtime: {} as never,
|
||||
resolveApiKey: async () => ({
|
||||
key: "bad-key",
|
||||
source: "flag",
|
||||
}),
|
||||
toApiKeyCredential: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toThrow(
|
||||
'Xiaomi MiMo pay-as-you-go keys must start with "sk-". The entered key does not match the expected format.',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects keys that do not start with tp- on Token Plan auth choices", async () => {
|
||||
const provider = await getXiaomiTokenPlanProvider();
|
||||
const method = provider.auth.find((entry) => entry.id === "token-plan-ams");
|
||||
if (!method?.runNonInteractive) {
|
||||
throw new Error("expected Xiaomi Token Plan non-interactive auth");
|
||||
}
|
||||
|
||||
await expect(
|
||||
method.runNonInteractive({
|
||||
authChoice: "xiaomi-token-plan-ams",
|
||||
config: {},
|
||||
baseConfig: {},
|
||||
opts: { xiaomiTokenPlanApiKey: "bad-key" },
|
||||
runtime: {} as never,
|
||||
resolveApiKey: async () => ({
|
||||
key: "bad-key",
|
||||
source: "flag",
|
||||
}),
|
||||
toApiKeyCredential: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toThrow(
|
||||
'Xiaomi MiMo Token Plan keys must start with "tp-". The entered key does not match the expected format.',
|
||||
);
|
||||
});
|
||||
|
||||
it("owns OpenAI-compatible replay policy", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
|
||||
const replayPolicy = provider.buildReplayPolicy?.({ modelApi: "openai-completions" } as never);
|
||||
expect(replayPolicy?.sanitizeToolCallIds).toBe(true);
|
||||
expect(replayPolicy?.toolCallIdMode).toBe("strict");
|
||||
expect(replayPolicy?.validateGeminiTurns).toBe(true);
|
||||
expect(replayPolicy?.validateAnthropicTurns).toBe(true);
|
||||
});
|
||||
|
||||
it("marks resolved MiMo models for empty array items omission", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
const model = mimoReasoningModel("mimo-v2.5");
|
||||
|
||||
const normalized = provider.normalizeResolvedModel?.({
|
||||
provider: "xiaomi",
|
||||
modelId: model.id,
|
||||
modelApi: model.api,
|
||||
model,
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
(normalized?.compat as { omitEmptyArrayItems?: unknown } | undefined)?.omitEmptyArrayItems,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("advertises thinking profiles for MiMo reasoning models only", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
const resolveThinkingProfile = requireThinkingProfileResolver(provider);
|
||||
const expectedLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
for (const modelId of [
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.6-pro",
|
||||
]) {
|
||||
const profile = resolveThinkingProfile({ provider: "xiaomi", modelId } as never);
|
||||
expect(profile?.levels.map((l) => l.id)).toEqual(expectedLevels);
|
||||
expect(profile?.defaultLevel).toBe("high");
|
||||
}
|
||||
|
||||
expect(resolveThinkingProfile({ provider: "xiaomi", modelId: "mimo-v2-flash" } as never)).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("isModernModelRef returns true only for MiMo reasoning models", async () => {
|
||||
const provider = await getXiaomiProvider();
|
||||
|
||||
expect(
|
||||
provider.isModernModelRef?.({ provider: "xiaomi", modelId: "mimo-v2.5-pro" } as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({ provider: "xiaomi", modelId: "mimo-v2.6-pro" } as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({ provider: "xiaomi", modelId: "mimo-v2-pro" } as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({ provider: "xiaomi", modelId: "mimo-v2-flash" } as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("adds blank reasoning_content for replayed tool calls from non-xiaomi turns", async () => {
|
||||
const capture: PayloadCapture = {};
|
||||
const model = mimoReasoningModel("mimo-v2.5-pro");
|
||||
const context = readToolReplayContext(
|
||||
replayAssistantMessage({
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
content: [readToolCall],
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
);
|
||||
const baseStreamFn = createPayloadCapturingStream(capture, model);
|
||||
|
||||
const wrapThinkingHigh = requireThinkingWrapper(
|
||||
createMiMoThinkingWrapper(baseStreamFn as never, "high"),
|
||||
"high",
|
||||
);
|
||||
await wrapThinkingHigh(model, context, {});
|
||||
|
||||
const assistantMessage = readPayloadMessage(capture, 1);
|
||||
expect(assistantMessage?.role).toBe("assistant");
|
||||
expect(assistantMessage?.reasoning_content).toBe("");
|
||||
const toolCall = readFirstToolCall(assistantMessage);
|
||||
expect(toolCall?.id).toBe("call_1");
|
||||
expect(toolCall?.type).toBe("function");
|
||||
expect(toolCall?.function?.name).toBe("read");
|
||||
expect(toolCall?.function?.arguments).toBe("{}");
|
||||
});
|
||||
|
||||
it("preserves replayed reasoning_content when MiMo thinking is enabled", async () => {
|
||||
const capture: PayloadCapture = {};
|
||||
const model = mimoReasoningModel("mimo-v2.5-pro", "xiaomi-token-plan");
|
||||
const context = mimoReasoningToolReplayContext("xiaomi-token-plan");
|
||||
const baseStreamFn = createPayloadCapturingStream(capture, model);
|
||||
|
||||
const wrapThinkingHigh = requireThinkingWrapper(
|
||||
createMiMoThinkingWrapper(baseStreamFn as never, "high"),
|
||||
"high",
|
||||
);
|
||||
await wrapThinkingHigh(model, context, {});
|
||||
|
||||
expect(readThinking(capture.payload)?.type).toBe("enabled");
|
||||
const assistantMessage = readPayloadMessage(capture, 1);
|
||||
expect(assistantMessage?.role).toBe("assistant");
|
||||
expect(assistantMessage?.reasoning_content).toBe("call reasoning");
|
||||
const toolCall = readFirstToolCall(assistantMessage);
|
||||
expect(toolCall?.id).toBe("call_1");
|
||||
expect(toolCall?.type).toBe("function");
|
||||
expect(toolCall?.function?.name).toBe("read");
|
||||
});
|
||||
|
||||
it("strips reasoning_content when MiMo thinking is disabled", async () => {
|
||||
const capture: PayloadCapture = {};
|
||||
const model = mimoReasoningModel("mimo-v2-pro");
|
||||
const context = mimoReasoningToolReplayContext();
|
||||
const baseStreamFn = createPayloadCapturingStream(capture, model);
|
||||
|
||||
const wrapThinkingNone = requireThinkingWrapper(
|
||||
createMiMoThinkingWrapper(baseStreamFn as never, "none" as never),
|
||||
"none",
|
||||
);
|
||||
await wrapThinkingNone(model, context, {});
|
||||
|
||||
expect(readThinking(capture.payload)?.type).toBe("disabled");
|
||||
expect((capture.payload!.messages as Array<Record<string, unknown>>)[1]).not.toHaveProperty(
|
||||
"reasoning_content",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["mimo-v2-pro", "mimo-v2-omni"] as const)(
|
||||
"promotes reasoning-only terminal output to visible text for %s",
|
||||
async (modelId) => {
|
||||
const model = mimoReasoningModel(modelId);
|
||||
const wrapped = requireThinkingWrapper(
|
||||
createMiMoThinkingWrapper(
|
||||
createResultStreamFn({
|
||||
events: [
|
||||
{
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "MiMo final answer" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
resultMessage: {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "MiMo final answer" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
}),
|
||||
"high",
|
||||
),
|
||||
modelId,
|
||||
);
|
||||
|
||||
const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream;
|
||||
const events: unknown[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "MiMo final answer" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
},
|
||||
]);
|
||||
await expect(stream.result()).resolves.toEqual({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "MiMo final answer" }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not promote reasoning when the MiMo assistant turn also has text or tool calls", async () => {
|
||||
const model = mimoReasoningModel("mimo-v2-pro");
|
||||
const textMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "internal" },
|
||||
{ type: "text", text: "already visible" },
|
||||
],
|
||||
stopReason: "stop",
|
||||
};
|
||||
const toolMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "call reasoning" }, readToolCall],
|
||||
stopReason: "toolUse",
|
||||
};
|
||||
|
||||
for (const resultMessage of [textMessage, toolMessage]) {
|
||||
const wrapped = requireThinkingWrapper(
|
||||
createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"),
|
||||
"mixed-content",
|
||||
);
|
||||
const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream;
|
||||
|
||||
await expect(stream.result()).resolves.toEqual(resultMessage);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not promote reasoning-only output for newer MiMo replay models", async () => {
|
||||
const model = mimoReasoningModel("mimo-v2.5-pro");
|
||||
const resultMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "actual reasoning" }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
const wrapped = requireThinkingWrapper(
|
||||
createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"),
|
||||
"mimo-v2.5-pro",
|
||||
);
|
||||
const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream;
|
||||
|
||||
await expect(stream.result()).resolves.toEqual(resultMessage);
|
||||
});
|
||||
});
|
||||
432
extensions/xiaomi/index.ts
Normal file
432
extensions/xiaomi/index.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
// Xiaomi plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
ProviderCatalogContext,
|
||||
ProviderAuthResult,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
buildApiKeyCredential,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeApiKeyInput,
|
||||
normalizeOptionalSecretInput,
|
||||
type SecretInput,
|
||||
upsertAuthProfileWithLock,
|
||||
validateApiKeyInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import {
|
||||
applyModelCompatPatch,
|
||||
buildProviderReplayFamilyHooks,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage";
|
||||
import {
|
||||
applyXiaomiConfig,
|
||||
applyXiaomiTokenPlanConfig,
|
||||
XIAOMI_DEFAULT_MODEL_REF,
|
||||
XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
import {
|
||||
buildXiaomiProvider,
|
||||
buildXiaomiTokenPlanProvider,
|
||||
XIAOMI_PROVIDER_ID,
|
||||
XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
type XiaomiTokenPlanRegion,
|
||||
} from "./provider-catalog.js";
|
||||
import { buildXiaomiSpeechProvider } from "./speech-provider.js";
|
||||
import { createMiMoThinkingWrapper } from "./stream.js";
|
||||
import { resolveMiMoThinkingProfile } from "./thinking.js";
|
||||
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
|
||||
const PAYG_FLAG_NAME = "--xiaomi-api-key";
|
||||
const PAYG_OPTION_KEY = "xiaomiApiKey";
|
||||
const PAYG_ENV_VAR = "XIAOMI_API_KEY";
|
||||
const TOKEN_PLAN_FLAG_NAME = "--xiaomi-token-plan-api-key";
|
||||
const TOKEN_PLAN_OPTION_KEY = "xiaomiTokenPlanApiKey";
|
||||
const TOKEN_PLAN_ENV_VAR = "XIAOMI_TOKEN_PLAN_API_KEY";
|
||||
const XIAOMI_WIZARD_GROUP = {
|
||||
groupId: "xiaomi",
|
||||
groupLabel: "Xiaomi",
|
||||
groupHint: "Pay-as-you-go / Token Plan",
|
||||
};
|
||||
const XIAOMI_PROVIDER_HOOKS = {
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
normalizeResolvedModel: ({ model }: { model: ProviderRuntimeModel }) =>
|
||||
applyModelCompatPatch(model, { omitEmptyArrayItems: true }),
|
||||
wrapStreamFn: (ctx: {
|
||||
streamFn?: Parameters<typeof createMiMoThinkingWrapper>[0];
|
||||
thinkingLevel?: Parameters<typeof createMiMoThinkingWrapper>[1];
|
||||
}) => createMiMoThinkingWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||
resolveThinkingProfile: ({ modelId }: { modelId: string }) => resolveMiMoThinkingProfile(modelId),
|
||||
isModernModelRef: ({ modelId }: { modelId: string }) =>
|
||||
Boolean(resolveMiMoThinkingProfile(modelId)),
|
||||
};
|
||||
|
||||
function trimConfiguredBaseUrl(
|
||||
ctx: ProviderCatalogContext,
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
const configuredProvider = ctx.config.models?.providers?.[providerId];
|
||||
const baseUrl =
|
||||
typeof configuredProvider?.baseUrl === "string" ? configuredProvider.baseUrl.trim() : "";
|
||||
return baseUrl || undefined;
|
||||
}
|
||||
|
||||
function hasConfiguredProviderEntry(ctx: ProviderCatalogContext, providerId: string): boolean {
|
||||
const configuredProvider = ctx.config.models?.providers?.[providerId];
|
||||
return Boolean(configuredProvider && typeof configuredProvider === "object");
|
||||
}
|
||||
|
||||
function resolveXiaomiCatalog(params: {
|
||||
ctx: ProviderCatalogContext;
|
||||
providerId: string;
|
||||
buildProvider: () => ReturnType<typeof buildXiaomiProvider>;
|
||||
requireConfiguredProvider?: boolean;
|
||||
requireBaseUrl?: boolean;
|
||||
}) {
|
||||
const apiKey = params.ctx.resolveProviderApiKey(params.providerId).apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
params.requireConfiguredProvider === true &&
|
||||
!hasConfiguredProviderEntry(params.ctx, params.providerId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const explicitBaseUrl = trimConfiguredBaseUrl(params.ctx, params.providerId);
|
||||
if (params.requireBaseUrl === true && !explicitBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
...params.buildProvider(),
|
||||
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
|
||||
const updated = await upsertAuthProfileWithLock(params);
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildXiaomiKeyMismatchMessage(params: {
|
||||
actualKey: string;
|
||||
expectedKind: "payg" | "token-plan";
|
||||
}): string | undefined {
|
||||
const normalized = params.actualKey.trim().toLowerCase();
|
||||
const expectedPrefix = params.expectedKind === "payg" ? "sk-" : "tp-";
|
||||
const kindLabel = params.expectedKind === "payg" ? "pay-as-you-go" : "Token Plan";
|
||||
|
||||
if (normalized.startsWith(expectedPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
if (params.expectedKind === "payg" && normalized.startsWith("tp-")) {
|
||||
return (
|
||||
"This looks like a Xiaomi MiMo Token Plan key (tp-...). " +
|
||||
"Re-run onboarding with one of: --auth-choice xiaomi-token-plan-cn, " +
|
||||
"--auth-choice xiaomi-token-plan-sgp, or --auth-choice xiaomi-token-plan-ams."
|
||||
);
|
||||
}
|
||||
if (params.expectedKind === "token-plan" && normalized.startsWith("sk-")) {
|
||||
return (
|
||||
"This looks like a Xiaomi MiMo pay-as-you-go key (sk-...). " +
|
||||
`Re-run onboarding with --auth-choice xiaomi-api-key or pass ${PAYG_FLAG_NAME}.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`Xiaomi MiMo ${kindLabel} keys must start with "${expectedPrefix}". ` +
|
||||
"The entered key does not match the expected format."
|
||||
);
|
||||
}
|
||||
|
||||
function assertCompatibleXiaomiKey(params: {
|
||||
actualKey: string;
|
||||
expectedKind: "payg" | "token-plan";
|
||||
}): void {
|
||||
const message = buildXiaomiKeyMismatchMessage(params);
|
||||
if (message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfileId(providerId: string): string {
|
||||
return `${providerId}:default`;
|
||||
}
|
||||
|
||||
async function runXiaomiApiKeyAuth(
|
||||
ctx: ProviderAuthContext,
|
||||
params: {
|
||||
providerId: string;
|
||||
optionKey: string;
|
||||
envVar: string;
|
||||
promptMessage: string;
|
||||
expectedKind: "payg" | "token-plan";
|
||||
defaultModel: string;
|
||||
applyConfig: (cfg: OpenClawConfig) => OpenClawConfig;
|
||||
},
|
||||
): Promise<ProviderAuthResult> {
|
||||
let capturedSecretInput: SecretInput | undefined;
|
||||
let capturedCredential = false;
|
||||
let capturedMode: "plaintext" | "ref" | undefined;
|
||||
const profileId = resolveProfileId(params.providerId);
|
||||
const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token:
|
||||
normalizeOptionalSecretInput(ctx.opts?.[params.optionKey]) ??
|
||||
normalizeOptionalSecretInput(ctx.opts?.token),
|
||||
tokenProvider: normalizeOptionalSecretInput(ctx.opts?.[params.optionKey])
|
||||
? params.providerId
|
||||
: normalizeOptionalSecretInput(ctx.opts?.tokenProvider),
|
||||
secretInputMode:
|
||||
ctx.allowSecretRefPrompt === false
|
||||
? (ctx.secretInputMode ?? "plaintext")
|
||||
: ctx.secretInputMode,
|
||||
config: ctx.config,
|
||||
env: ctx.env,
|
||||
expectedProviders: [params.providerId],
|
||||
provider: params.providerId,
|
||||
envLabel: params.envVar,
|
||||
promptMessage: params.promptMessage,
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: ctx.prompter,
|
||||
setCredential: async (key, mode) => {
|
||||
capturedSecretInput = key;
|
||||
capturedCredential = true;
|
||||
capturedMode = mode;
|
||||
},
|
||||
});
|
||||
assertCompatibleXiaomiKey({
|
||||
actualKey: apiKey,
|
||||
expectedKind: params.expectedKind,
|
||||
});
|
||||
if (!capturedCredential) {
|
||||
throw new Error(`Missing Xiaomi API key for provider "${params.providerId}".`);
|
||||
}
|
||||
const credentialInput = capturedSecretInput ?? "";
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: buildApiKeyCredential(
|
||||
params.providerId,
|
||||
credentialInput,
|
||||
undefined,
|
||||
capturedMode ? { secretInputMode: capturedMode } : undefined,
|
||||
),
|
||||
},
|
||||
],
|
||||
configPatch: params.applyConfig(ctx.config),
|
||||
defaultModel: params.defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
async function runXiaomiApiKeyAuthNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveContext,
|
||||
params: {
|
||||
providerId: string;
|
||||
optionKey: string;
|
||||
flagName: `--${string}`;
|
||||
envVar: string;
|
||||
expectedKind: "payg" | "token-plan";
|
||||
applyConfig: (cfg: OpenClawConfig) => OpenClawConfig;
|
||||
},
|
||||
) {
|
||||
const resolved = await ctx.resolveApiKey({
|
||||
provider: params.providerId,
|
||||
flagValue: normalizeOptionalSecretInput(ctx.opts[params.optionKey]),
|
||||
flagName: params.flagName,
|
||||
envVar: params.envVar,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
assertCompatibleXiaomiKey({
|
||||
actualKey: resolved.key,
|
||||
expectedKind: params.expectedKind,
|
||||
});
|
||||
|
||||
const profileId = resolveProfileId(params.providerId);
|
||||
if (resolved.source !== "profile") {
|
||||
const credential = ctx.toApiKeyCredential({
|
||||
provider: params.providerId,
|
||||
resolved,
|
||||
});
|
||||
if (!credential) {
|
||||
return null;
|
||||
}
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId,
|
||||
credential,
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
}
|
||||
|
||||
const next = applyAuthProfileConfig(ctx.config, {
|
||||
profileId,
|
||||
provider: params.providerId,
|
||||
mode: "api_key",
|
||||
});
|
||||
return params.applyConfig(next);
|
||||
}
|
||||
|
||||
function createPaygAuthMethod(): ProviderAuthMethod {
|
||||
return {
|
||||
id: "api-key",
|
||||
label: "Xiaomi API key (Pay-as-you-go)",
|
||||
hint: "Endpoint: api.xiaomimimo.com/v1",
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId: "xiaomi-api-key",
|
||||
choiceLabel: "Xiaomi API key (Pay-as-you-go)",
|
||||
choiceHint: "Endpoint: api.xiaomimimo.com/v1",
|
||||
...XIAOMI_WIZARD_GROUP,
|
||||
},
|
||||
run: async (ctx) =>
|
||||
await runXiaomiApiKeyAuth(ctx, {
|
||||
providerId: XIAOMI_PROVIDER_ID,
|
||||
optionKey: PAYG_OPTION_KEY,
|
||||
envVar: PAYG_ENV_VAR,
|
||||
promptMessage: "Enter Xiaomi MiMo API key (pay-as-you-go, sk-...)",
|
||||
expectedKind: "payg",
|
||||
defaultModel: XIAOMI_DEFAULT_MODEL_REF,
|
||||
applyConfig: applyXiaomiConfig,
|
||||
}),
|
||||
runNonInteractive: async (ctx) =>
|
||||
await runXiaomiApiKeyAuthNonInteractive(ctx, {
|
||||
providerId: XIAOMI_PROVIDER_ID,
|
||||
optionKey: PAYG_OPTION_KEY,
|
||||
flagName: PAYG_FLAG_NAME,
|
||||
envVar: PAYG_ENV_VAR,
|
||||
expectedKind: "payg",
|
||||
applyConfig: applyXiaomiConfig,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createTokenPlanAuthMethod(region: XiaomiTokenPlanRegion): ProviderAuthMethod {
|
||||
const regionLabel = region === "ams" ? "Europe" : region === "cn" ? "China" : "Singapore";
|
||||
const choiceId = `xiaomi-token-plan-${region}`;
|
||||
const choiceLabel = `Xiaomi Token Plan (${regionLabel})`;
|
||||
const choiceHint = `Endpoint preset: token-plan-${region}.xiaomimimo.com/v1`;
|
||||
return {
|
||||
id: `token-plan-${region}`,
|
||||
label: choiceLabel,
|
||||
hint: choiceHint,
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId,
|
||||
choiceLabel,
|
||||
choiceHint,
|
||||
...XIAOMI_WIZARD_GROUP,
|
||||
},
|
||||
run: async (ctx) =>
|
||||
await runXiaomiApiKeyAuth(ctx, {
|
||||
providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
optionKey: TOKEN_PLAN_OPTION_KEY,
|
||||
envVar: TOKEN_PLAN_ENV_VAR,
|
||||
promptMessage: `Enter Xiaomi MiMo Token Plan API key (tp-...) for ${regionLabel}`,
|
||||
expectedKind: "token-plan",
|
||||
defaultModel: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyXiaomiTokenPlanConfig(cfg, region),
|
||||
}),
|
||||
runNonInteractive: async (ctx) =>
|
||||
await runXiaomiApiKeyAuthNonInteractive(ctx, {
|
||||
providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
optionKey: TOKEN_PLAN_OPTION_KEY,
|
||||
flagName: TOKEN_PLAN_FLAG_NAME,
|
||||
envVar: TOKEN_PLAN_ENV_VAR,
|
||||
expectedKind: "token-plan",
|
||||
applyConfig: (cfg) => applyXiaomiTokenPlanConfig(cfg, region),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: XIAOMI_PROVIDER_ID,
|
||||
name: "Xiaomi Provider",
|
||||
description: "Bundled Xiaomi provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: XIAOMI_PROVIDER_ID,
|
||||
label: "Xiaomi",
|
||||
docsPath: "/providers/xiaomi",
|
||||
envVars: [PAYG_ENV_VAR],
|
||||
auth: [createPaygAuthMethod()],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) =>
|
||||
resolveXiaomiCatalog({
|
||||
ctx,
|
||||
providerId: XIAOMI_PROVIDER_ID,
|
||||
buildProvider: buildXiaomiProvider,
|
||||
}),
|
||||
},
|
||||
...XIAOMI_PROVIDER_HOOKS,
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
|
||||
providerIds: [XIAOMI_PROVIDER_ID],
|
||||
envDirect: [ctx.env.XIAOMI_API_KEY],
|
||||
});
|
||||
return apiKey ? { token: apiKey } : null;
|
||||
},
|
||||
fetchUsageSnapshot: async () => ({
|
||||
provider: XIAOMI_PROVIDER_ID,
|
||||
displayName: PROVIDER_LABELS.xiaomi,
|
||||
windows: [],
|
||||
}),
|
||||
});
|
||||
|
||||
api.registerProvider({
|
||||
id: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
label: "Xiaomi Token Plan",
|
||||
docsPath: "/providers/xiaomi",
|
||||
envVars: [TOKEN_PLAN_ENV_VAR],
|
||||
auth: [
|
||||
createTokenPlanAuthMethod("ams"),
|
||||
createTokenPlanAuthMethod("cn"),
|
||||
createTokenPlanAuthMethod("sgp"),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) =>
|
||||
resolveXiaomiCatalog({
|
||||
ctx,
|
||||
providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
buildProvider: buildXiaomiTokenPlanProvider,
|
||||
requireConfiguredProvider: true,
|
||||
requireBaseUrl: true,
|
||||
}),
|
||||
},
|
||||
...XIAOMI_PROVIDER_HOOKS,
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
|
||||
providerIds: [XIAOMI_TOKEN_PLAN_PROVIDER_ID],
|
||||
envDirect: [ctx.env.XIAOMI_TOKEN_PLAN_API_KEY],
|
||||
});
|
||||
return apiKey ? { token: apiKey } : null;
|
||||
},
|
||||
fetchUsageSnapshot: async () => ({
|
||||
provider: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
displayName: "Xiaomi MiMo Token Plan",
|
||||
windows: [],
|
||||
}),
|
||||
});
|
||||
|
||||
api.registerSpeechProvider(buildXiaomiSpeechProvider());
|
||||
},
|
||||
});
|
||||
85
extensions/xiaomi/onboard.test.ts
Normal file
85
extensions/xiaomi/onboard.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Xiaomi tests cover onboard plugin behavior.
|
||||
import {
|
||||
expectProviderOnboardMergedLegacyConfig,
|
||||
expectProviderOnboardPrimaryModel,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyXiaomiConfig,
|
||||
applyXiaomiProviderConfig,
|
||||
applyXiaomiTokenPlanConfig,
|
||||
applyXiaomiTokenPlanProviderConfig,
|
||||
} from "./onboard.js";
|
||||
import { buildXiaomiProvider, buildXiaomiTokenPlanProvider } from "./provider-catalog.js";
|
||||
|
||||
describe("xiaomi onboard", () => {
|
||||
it("adds Xiaomi provider with correct settings", () => {
|
||||
const cfg = applyXiaomiConfig({});
|
||||
const provider = cfg.models?.providers?.xiaomi;
|
||||
expect(provider).toEqual(buildXiaomiProvider());
|
||||
expect(provider?.models.map((m) => m.id)).toEqual([
|
||||
"mimo-v2-flash",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
]);
|
||||
expect(cfg.agents?.defaults?.models?.["xiaomi/mimo-v2-flash"]).toEqual({ alias: "Xiaomi" });
|
||||
expect(cfg.agents?.defaults?.model).toEqual({ primary: "xiaomi/mimo-v2-flash" });
|
||||
expectProviderOnboardPrimaryModel({
|
||||
applyConfig: applyXiaomiConfig,
|
||||
modelRef: "xiaomi/mimo-v2-flash",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges Xiaomi models and keeps existing provider overrides", () => {
|
||||
const provider = expectProviderOnboardMergedLegacyConfig({
|
||||
applyProviderConfig: applyXiaomiProviderConfig,
|
||||
providerId: "xiaomi",
|
||||
providerApi: "openai-completions",
|
||||
baseUrl: "https://api.xiaomimimo.com/v1",
|
||||
legacyApi: "openai-completions",
|
||||
legacyModelId: "custom-model",
|
||||
legacyModelName: "Custom",
|
||||
});
|
||||
expect(provider?.models.map((m) => m.id)).toEqual([
|
||||
"custom-model",
|
||||
"mimo-v2-flash",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds Xiaomi Token Plan provider with a regional endpoint preset", () => {
|
||||
const cfg = applyXiaomiTokenPlanConfig({}, "ams");
|
||||
const provider = cfg.models?.providers?.["xiaomi-token-plan"];
|
||||
expect(provider).toEqual({
|
||||
...buildXiaomiTokenPlanProvider(),
|
||||
baseUrl: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||
});
|
||||
expect(provider?.models.map((m) => m.id)).toEqual(["mimo-v2.5-pro", "mimo-v2.5"]);
|
||||
expect(cfg.agents?.defaults?.models?.["xiaomi-token-plan/mimo-v2.5-pro"]).toEqual({
|
||||
alias: "Xiaomi MiMo V2.5 Pro",
|
||||
});
|
||||
expect(cfg.agents?.defaults?.model).toEqual({ primary: "xiaomi-token-plan/mimo-v2.5-pro" });
|
||||
expectProviderOnboardPrimaryModel({
|
||||
applyConfig: (config) => applyXiaomiTokenPlanConfig(config, "ams"),
|
||||
modelRef: "xiaomi-token-plan/mimo-v2.5-pro",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges Xiaomi Token Plan models and rewrites the selected regional base URL", () => {
|
||||
const provider = expectProviderOnboardMergedLegacyConfig({
|
||||
applyProviderConfig: (config) => applyXiaomiTokenPlanProviderConfig(config, "sgp"),
|
||||
providerId: "xiaomi-token-plan",
|
||||
providerApi: "openai-completions",
|
||||
baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
legacyApi: "openai-completions",
|
||||
legacyModelId: "custom-token-plan-model",
|
||||
legacyModelName: "Custom Token Plan",
|
||||
});
|
||||
expect(provider?.models.map((m) => m.id)).toEqual([
|
||||
"custom-token-plan-model",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
]);
|
||||
});
|
||||
});
|
||||
109
extensions/xiaomi/onboard.ts
Normal file
109
extensions/xiaomi/onboard.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Xiaomi setup module handles plugin onboarding behavior.
|
||||
import {
|
||||
createDefaultModelsPresetAppliers,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
buildXiaomiProvider,
|
||||
buildXiaomiTokenPlanProvider,
|
||||
resolveXiaomiTokenPlanBaseUrl,
|
||||
XIAOMI_DEFAULT_MODEL_ID,
|
||||
XIAOMI_PROVIDER_ID,
|
||||
XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID,
|
||||
XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
type XiaomiTokenPlanRegion,
|
||||
} from "./provider-catalog.js";
|
||||
|
||||
export const XIAOMI_DEFAULT_MODEL_REF = `${XIAOMI_PROVIDER_ID}/${XIAOMI_DEFAULT_MODEL_ID}`;
|
||||
export const XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF = `${XIAOMI_TOKEN_PLAN_PROVIDER_ID}/${XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID}`;
|
||||
|
||||
const xiaomiPresetAppliers = createDefaultModelsPresetAppliers({
|
||||
primaryModelRef: XIAOMI_DEFAULT_MODEL_REF,
|
||||
resolveParams: (_cfg: OpenClawConfig) => {
|
||||
const defaultProvider = buildXiaomiProvider();
|
||||
return {
|
||||
providerId: XIAOMI_PROVIDER_ID,
|
||||
api: defaultProvider.api ?? "openai-completions",
|
||||
baseUrl: defaultProvider.baseUrl,
|
||||
defaultModels: defaultProvider.models ?? [],
|
||||
defaultModelId: XIAOMI_DEFAULT_MODEL_ID,
|
||||
aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const xiaomiTokenPlanPresetAppliers = createDefaultModelsPresetAppliers({
|
||||
primaryModelRef: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF,
|
||||
resolveParams: (_cfg: OpenClawConfig) => {
|
||||
const defaultProvider = buildXiaomiTokenPlanProvider();
|
||||
return {
|
||||
providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
api: defaultProvider.api ?? "openai-completions",
|
||||
baseUrl: defaultProvider.baseUrl,
|
||||
defaultModels: defaultProvider.models ?? [],
|
||||
defaultModelId: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID,
|
||||
aliases: (() => {
|
||||
const defaultModel = defaultProvider.models?.find(
|
||||
(m) => m.id === XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID,
|
||||
);
|
||||
return [
|
||||
{
|
||||
modelRef: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF,
|
||||
alias: defaultModel?.name ?? "MiMo V2.5 Pro",
|
||||
},
|
||||
];
|
||||
})(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function withProviderBaseUrl(
|
||||
cfg: OpenClawConfig,
|
||||
providerId: string,
|
||||
baseUrl: string,
|
||||
): OpenClawConfig {
|
||||
const providers: Record<string, unknown> = {
|
||||
...cfg.models?.providers,
|
||||
[providerId]: {
|
||||
...cfg.models?.providers?.[providerId],
|
||||
baseUrl,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...cfg.models,
|
||||
providers,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return xiaomiPresetAppliers.applyProviderConfig(cfg);
|
||||
}
|
||||
|
||||
export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return xiaomiPresetAppliers.applyConfig(cfg);
|
||||
}
|
||||
|
||||
export function applyXiaomiTokenPlanProviderConfig(
|
||||
cfg: OpenClawConfig,
|
||||
region: XiaomiTokenPlanRegion,
|
||||
): OpenClawConfig {
|
||||
return withProviderBaseUrl(
|
||||
xiaomiTokenPlanPresetAppliers.applyProviderConfig(cfg),
|
||||
XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
resolveXiaomiTokenPlanBaseUrl(region),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyXiaomiTokenPlanConfig(
|
||||
cfg: OpenClawConfig,
|
||||
region: XiaomiTokenPlanRegion,
|
||||
): OpenClawConfig {
|
||||
return withProviderBaseUrl(
|
||||
xiaomiTokenPlanPresetAppliers.applyConfig(cfg),
|
||||
XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
resolveXiaomiTokenPlanBaseUrl(region),
|
||||
);
|
||||
}
|
||||
199
extensions/xiaomi/openclaw.plugin.json
Normal file
199
extensions/xiaomi/openclaw.plugin.json
Normal file
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"id": "xiaomi",
|
||||
"icon": "https://cdn.simpleicons.org/xiaomi",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["xiaomi", "xiaomi-token-plan"],
|
||||
"autoEnableWhenConfiguredProviders": ["xiaomi", "xiaomi-token-plan"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "xiaomi",
|
||||
"envVars": ["XIAOMI_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "xiaomi-token-plan",
|
||||
"envVars": ["XIAOMI_TOKEN_PLAN_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"xiaomi": {
|
||||
"baseUrl": "https://api.xiaomimimo.com/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "mimo-v2-flash",
|
||||
"name": "Xiaomi MiMo V2 Flash",
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 8192,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mimo-v2-pro",
|
||||
"name": "Xiaomi MiMo V2 Pro",
|
||||
"input": ["text"],
|
||||
"reasoning": true,
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 32000,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mimo-v2-omni",
|
||||
"name": "Xiaomi MiMo V2 Omni",
|
||||
"input": ["text", "image"],
|
||||
"reasoning": true,
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 32000,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"xiaomi-token-plan": {
|
||||
"baseUrl": "https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "mimo-v2.5-pro",
|
||||
"name": "Xiaomi MiMo V2.5 Pro",
|
||||
"input": ["text"],
|
||||
"reasoning": true,
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 3,
|
||||
"cacheRead": 0.2,
|
||||
"cacheWrite": 0,
|
||||
"tieredPricing": [
|
||||
{ "input": 1, "output": 3, "cacheRead": 0.2, "cacheWrite": 0, "range": [0, 256000] },
|
||||
{ "input": 1, "output": 3, "cacheRead": 0.4, "cacheWrite": 0, "range": [256000] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mimo-v2.5",
|
||||
"name": "Xiaomi MiMo V2.5",
|
||||
"input": ["text", "image"],
|
||||
"reasoning": true,
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.4,
|
||||
"output": 2,
|
||||
"cacheRead": 0.08,
|
||||
"cacheWrite": 0,
|
||||
"tieredPricing": [
|
||||
{ "input": 0.4, "output": 2, "cacheRead": 0.08, "cacheWrite": 0, "range": [0, 256000] },
|
||||
{ "input": 0.4, "output": 2, "cacheRead": 0.16, "cacheWrite": 0, "range": [256000] }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"xiaomi": "static",
|
||||
"xiaomi-token-plan": "runtime"
|
||||
}
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "xiaomi-native",
|
||||
"hosts": [
|
||||
"api.xiaomimimo.com",
|
||||
"token-plan-ams.xiaomimimo.com",
|
||||
"token-plan-cn.xiaomimimo.com",
|
||||
"token-plan-sgp.xiaomimimo.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"speechProviders": ["xiaomi"]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"xiaomi": ["XIAOMI_API_KEY"],
|
||||
"xiaomi-token-plan": ["XIAOMI_TOKEN_PLAN_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "xiaomi",
|
||||
"method": "api-key",
|
||||
"choiceId": "xiaomi-api-key",
|
||||
"choiceLabel": "Xiaomi API key (Pay-as-you-go)",
|
||||
"groupId": "xiaomi",
|
||||
"groupLabel": "Xiaomi",
|
||||
"groupHint": "Pay-as-you-go / Token Plan",
|
||||
"optionKey": "xiaomiApiKey",
|
||||
"cliFlag": "--xiaomi-api-key",
|
||||
"cliOption": "--xiaomi-api-key <key>",
|
||||
"cliDescription": "Xiaomi MiMo pay-as-you-go API key"
|
||||
},
|
||||
{
|
||||
"provider": "xiaomi-token-plan",
|
||||
"method": "token-plan-ams",
|
||||
"choiceId": "xiaomi-token-plan-ams",
|
||||
"choiceLabel": "Xiaomi Token Plan (Europe)",
|
||||
"choiceHint": "Endpoint preset: token-plan-ams.xiaomimimo.com/v1",
|
||||
"groupId": "xiaomi",
|
||||
"groupLabel": "Xiaomi",
|
||||
"groupHint": "Pay-as-you-go / Token Plan",
|
||||
"optionKey": "xiaomiTokenPlanApiKey",
|
||||
"cliFlag": "--xiaomi-token-plan-api-key",
|
||||
"cliOption": "--xiaomi-token-plan-api-key <key>",
|
||||
"cliDescription": "Xiaomi MiMo Token Plan API key"
|
||||
},
|
||||
{
|
||||
"provider": "xiaomi-token-plan",
|
||||
"method": "token-plan-cn",
|
||||
"choiceId": "xiaomi-token-plan-cn",
|
||||
"choiceLabel": "Xiaomi Token Plan (China)",
|
||||
"choiceHint": "Endpoint preset: token-plan-cn.xiaomimimo.com/v1",
|
||||
"groupId": "xiaomi",
|
||||
"groupLabel": "Xiaomi",
|
||||
"groupHint": "Pay-as-you-go / Token Plan",
|
||||
"optionKey": "xiaomiTokenPlanApiKey",
|
||||
"cliFlag": "--xiaomi-token-plan-api-key",
|
||||
"cliOption": "--xiaomi-token-plan-api-key <key>",
|
||||
"cliDescription": "Xiaomi MiMo Token Plan API key"
|
||||
},
|
||||
{
|
||||
"provider": "xiaomi-token-plan",
|
||||
"method": "token-plan-sgp",
|
||||
"choiceId": "xiaomi-token-plan-sgp",
|
||||
"choiceLabel": "Xiaomi Token Plan (Singapore)",
|
||||
"choiceHint": "Endpoint preset: token-plan-sgp.xiaomimimo.com/v1",
|
||||
"groupId": "xiaomi",
|
||||
"groupLabel": "Xiaomi",
|
||||
"groupHint": "Pay-as-you-go / Token Plan",
|
||||
"optionKey": "xiaomiTokenPlanApiKey",
|
||||
"cliFlag": "--xiaomi-token-plan-api-key",
|
||||
"cliOption": "--xiaomi-token-plan-api-key <key>",
|
||||
"cliDescription": "Xiaomi MiMo Token Plan API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
extensions/xiaomi/package.json
Normal file
15
extensions/xiaomi/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/xiaomi-provider",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Xiaomi provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
35
extensions/xiaomi/provider-catalog.ts
Normal file
35
extensions/xiaomi/provider-catalog.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Xiaomi provider module implements model/runtime integration.
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
export const XIAOMI_PROVIDER_ID = "xiaomi";
|
||||
export const XIAOMI_TOKEN_PLAN_PROVIDER_ID = "xiaomi-token-plan";
|
||||
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
|
||||
export const XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID = "mimo-v2.5-pro";
|
||||
|
||||
const XIAOMI_TOKEN_PLAN_BASE_URLS = {
|
||||
ams: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||
cn: "https://token-plan-cn.xiaomimimo.com/v1",
|
||||
sgp: "https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
} as const;
|
||||
|
||||
export type XiaomiTokenPlanRegion = keyof typeof XIAOMI_TOKEN_PLAN_BASE_URLS;
|
||||
|
||||
export function buildXiaomiProvider(): ModelProviderConfig {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: XIAOMI_PROVIDER_ID,
|
||||
catalog: manifest.modelCatalog.providers.xiaomi,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildXiaomiTokenPlanProvider(): ModelProviderConfig {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
|
||||
catalog: manifest.modelCatalog.providers[XIAOMI_TOKEN_PLAN_PROVIDER_ID],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveXiaomiTokenPlanBaseUrl(region: XiaomiTokenPlanRegion): string {
|
||||
return XIAOMI_TOKEN_PLAN_BASE_URLS[region];
|
||||
}
|
||||
446
extensions/xiaomi/speech-provider.test.ts
Normal file
446
extensions/xiaomi/speech-provider.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
// Xiaomi tests cover speech provider plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcodeAudioBufferToOpusMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
transcodeAudioBufferToOpus: transcodeAudioBufferToOpusMock,
|
||||
}));
|
||||
|
||||
import { buildXiaomiSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
function makeOversizedStreamResponse(): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES));
|
||||
controller.enqueue(new Uint8Array(1));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("buildXiaomiSpeechProvider", () => {
|
||||
const provider = buildXiaomiSpeechProvider();
|
||||
|
||||
describe("metadata", () => {
|
||||
it("registers Xiaomi MiMo as a speech provider", () => {
|
||||
expect(provider.id).toBe("xiaomi");
|
||||
expect(provider.aliases).toContain("mimo");
|
||||
expect(provider.models).toContain("mimo-v2.5-tts");
|
||||
expect(provider.models).toContain("mimo-v2-tts");
|
||||
expect(provider.models).toContain("mimo-v2.5-tts-voicedesign");
|
||||
expect(provider.voices).toContain("mimo_default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConfigured", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
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 is available", () => {
|
||||
delete process.env.XIAOMI_API_KEY;
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when XIAOMI_API_KEY env var is set", () => {
|
||||
process.env.XIAOMI_API_KEY = "sk-env";
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfig", () => {
|
||||
it("reads providers.xiaomi settings", () => {
|
||||
const config = provider.resolveConfig!({
|
||||
rawConfig: {
|
||||
providers: {
|
||||
xiaomi: {
|
||||
baseUrl: "https://example.com/v1/",
|
||||
model: "mimo-v2-tts",
|
||||
voice: "default_en",
|
||||
format: "wav",
|
||||
style: "Bright and fast.",
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: {} as never,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
expect(config).toEqual({
|
||||
apiKey: undefined,
|
||||
baseUrl: "https://example.com/v1",
|
||||
model: "mimo-v2-tts",
|
||||
voice: "default_en",
|
||||
format: "wav",
|
||||
style: "Bright and fast.",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the mimo provider config alias", () => {
|
||||
const config = provider.resolveConfig!({
|
||||
rawConfig: { providers: { mimo: { voiceId: "default_zh" } } },
|
||||
cfg: {} as never,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
expect(config.voice).toBe("default_zh");
|
||||
});
|
||||
|
||||
it("accepts generic model and speaker voice aliases", () => {
|
||||
const config = provider.resolveConfig!({
|
||||
rawConfig: {
|
||||
providers: {
|
||||
xiaomi: {
|
||||
modelId: "mimo-v2.5-tts-voicedesign",
|
||||
speakerVoice: "Chloe",
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: {} as never,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
expect(config.model).toBe("mimo-v2.5-tts-voicedesign");
|
||||
expect(config.voice).toBe("Chloe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDirectiveToken", () => {
|
||||
const policy = {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
};
|
||||
|
||||
it("handles voice, model, style, and format tokens", () => {
|
||||
expect(provider.parseDirectiveToken!({ key: "voice", value: "default_en", policy })).toEqual({
|
||||
handled: true,
|
||||
overrides: { voice: "default_en" },
|
||||
});
|
||||
expect(provider.parseDirectiveToken!({ key: "model", value: "mimo-v2-tts", policy })).toEqual(
|
||||
{ handled: true, overrides: { model: "mimo-v2-tts" } },
|
||||
);
|
||||
expect(provider.parseDirectiveToken!({ key: "style", value: "whispered", policy })).toEqual({
|
||||
handled: true,
|
||||
overrides: { style: "whispered" },
|
||||
});
|
||||
expect(provider.parseDirectiveToken!({ key: "format", value: "wav", policy })).toEqual({
|
||||
handled: true,
|
||||
overrides: { format: "wav" },
|
||||
});
|
||||
});
|
||||
|
||||
it("warns on invalid format", () => {
|
||||
const result = provider.parseDirectiveToken!({ key: "format", value: "ogg", policy });
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesize", () => {
|
||||
const savedFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
transcodeAudioBufferToOpusMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
globalThis.fetch = savedFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("makes the Xiaomi chat completions TTS call and decodes audio", async () => {
|
||||
const audio = Buffer.from("fake-mp3-audio").toString("base64");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello from OpenClaw.",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "sk-test",
|
||||
model: "mimo-v2-tts",
|
||||
voice: "default_en",
|
||||
style: "Bright.",
|
||||
},
|
||||
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-mp3-audio");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [url, init] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(url).toBe("https://api.xiaomimimo.com/v1/chat/completions");
|
||||
expect(init?.headers).toEqual({
|
||||
"api-key": "sk-test",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
const body = JSON.parse(init!.body as string);
|
||||
expect(body.model).toBe("mimo-v2-tts");
|
||||
expect(body.messages).toEqual([
|
||||
{ role: "user", content: "Bright." },
|
||||
{ role: "assistant", content: "Hello from OpenClaw." },
|
||||
]);
|
||||
expect(body.audio).toEqual({ format: "mp3", voice: "default_en" });
|
||||
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits voice and uses configured style for Xiaomi voice design models", async () => {
|
||||
const audio = Buffer.from("fake-wav-audio").toString("base64");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello from OpenClaw.",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "sk-test",
|
||||
modelId: "mimo-v2.5-tts-voicedesign",
|
||||
speakerVoice: "Chloe",
|
||||
format: "wav",
|
||||
style: "Warm, bright, natural voice.",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
expect(result.outputFormat).toBe("wav");
|
||||
expect(result.fileExtension).toBe(".wav");
|
||||
expect(result.voiceCompatible).toBe(false);
|
||||
expect(result.audioBuffer.toString()).toBe("fake-wav-audio");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [, init] = mockFetch.mock.calls[0] ?? [];
|
||||
const body = JSON.parse(init!.body as string);
|
||||
expect(body.model).toBe("mimo-v2.5-tts-voicedesign");
|
||||
expect(body.messages).toEqual([
|
||||
{ role: "user", content: "Warm, bright, natural voice." },
|
||||
{ role: "assistant", content: "Hello from OpenClaw." },
|
||||
]);
|
||||
expect(body.audio).toEqual({ format: "wav" });
|
||||
});
|
||||
|
||||
it("uses a default style for Xiaomi voice design models", async () => {
|
||||
const audio = Buffer.from("fake-mp3-audio").toString("base64");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Hello from OpenClaw.",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "sk-test",
|
||||
model: "mimo-v2.5-tts-voicedesign",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [, init] = mockFetch.mock.calls[0] ?? [];
|
||||
const body = JSON.parse(init!.body as string);
|
||||
expect(body.messages).toHaveLength(2);
|
||||
expect(body.messages[0]?.role).toBe("user");
|
||||
expect(body.messages[0]?.content).toContain("natural");
|
||||
expect(body.messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Hello from OpenClaw.",
|
||||
});
|
||||
expect(body.audio).toEqual({ format: "mp3" });
|
||||
});
|
||||
|
||||
it("transcodes Xiaomi output to Opus for voice-note targets", async () => {
|
||||
const audio = Buffer.from("fake-mp3-audio").toString("base64");
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("fake-opus-audio"));
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello from OpenClaw.",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
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-audio");
|
||||
expect(transcodeAudioBufferToOpusMock).toHaveBeenCalledWith({
|
||||
audioBuffer: Buffer.from("fake-mp3-audio"),
|
||||
inputExtension: "mp3",
|
||||
tempPrefix: "tts-xiaomi-",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
it("transcodes Xiaomi voice design output to Opus for voice-note targets", async () => {
|
||||
const audio = Buffer.from("fake-wav-audio").toString("base64");
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("fake-opus-audio"));
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Hello from OpenClaw.",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "sk-test",
|
||||
model: "mimo-v2.5-tts-voicedesign",
|
||||
format: "wav",
|
||||
},
|
||||
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-audio");
|
||||
expect(transcodeAudioBufferToOpusMock).toHaveBeenCalledWith({
|
||||
audioBuffer: Buffer.from("fake-wav-audio"),
|
||||
inputExtension: "wav",
|
||||
tempPrefix: "tts-xiaomi-",
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
const [, init] = vi.mocked(globalThis.fetch).mock.calls[0] ?? [];
|
||||
const body = JSON.parse(init!.body as string);
|
||||
expect(body.audio).toEqual({ format: "wav" });
|
||||
});
|
||||
|
||||
it("caps oversized TTS request timeouts before scheduling or fetching", async () => {
|
||||
const audio = Buffer.from("fake-mp3-audio").toString("base64");
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
const clearTimeoutSpy = vi
|
||||
.spyOn(globalThis, "clearTimeout")
|
||||
.mockImplementation(() => undefined);
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await provider.synthesize({
|
||||
text: "Hello from OpenClaw.",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
target: "audio-file",
|
||||
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when API key is missing", async () => {
|
||||
const savedKey = process.env.XIAOMI_API_KEY;
|
||||
delete process.env.XIAOMI_API_KEY;
|
||||
try {
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
).rejects.toThrow("Xiaomi API key missing");
|
||||
} finally {
|
||||
if (savedKey === undefined) {
|
||||
delete process.env.XIAOMI_API_KEY;
|
||||
} else {
|
||||
process.env.XIAOMI_API_KEY = savedKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when the API response has no audio data", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ choices: [{ message: {} }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
).rejects.toThrow("Xiaomi TTS API returned no audio data");
|
||||
});
|
||||
|
||||
it("bounds oversized Xiaomi TTS success response reads", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(makeOversizedStreamResponse());
|
||||
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
).rejects.toThrow("Xiaomi TTS API: JSON response exceeds 16777216 bytes");
|
||||
});
|
||||
});
|
||||
});
|
||||
339
extensions/xiaomi/speech-provider.ts
Normal file
339
extensions/xiaomi/speech-provider.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Xiaomi provider module implements model/runtime integration.
|
||||
import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderOverrides,
|
||||
SpeechProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const DEFAULT_XIAOMI_TTS_BASE_URL = "https://api.xiaomimimo.com/v1";
|
||||
const DEFAULT_XIAOMI_TTS_MODEL = "mimo-v2.5-tts";
|
||||
const DEFAULT_XIAOMI_TTS_VOICE = "mimo_default";
|
||||
const DEFAULT_XIAOMI_TTS_FORMAT = "mp3";
|
||||
const XIAOMI_TTS_VOICE_DESIGN_MODEL = "mimo-v2.5-tts-voicedesign";
|
||||
const DEFAULT_XIAOMI_TTS_VOICE_DESIGN_STYLE =
|
||||
"Warm, natural, and friendly voice with clear pronunciation and conversational pacing.";
|
||||
|
||||
const XIAOMI_TTS_MODELS = ["mimo-v2.5-tts", "mimo-v2-tts", XIAOMI_TTS_VOICE_DESIGN_MODEL] as const;
|
||||
|
||||
const XIAOMI_TTS_VOICES = [
|
||||
"mimo_default",
|
||||
"default_zh",
|
||||
"default_en",
|
||||
"Mia",
|
||||
"Chloe",
|
||||
"Milo",
|
||||
"Dean",
|
||||
] as const;
|
||||
|
||||
const XIAOMI_TTS_FORMATS = ["mp3", "wav"] as const;
|
||||
|
||||
type XiaomiTtsFormat = (typeof XIAOMI_TTS_FORMATS)[number];
|
||||
|
||||
type XiaomiTtsProviderConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
format: XiaomiTtsFormat;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
type XiaomiTtsOverrides = {
|
||||
model?: string;
|
||||
voice?: string;
|
||||
format?: XiaomiTtsFormat;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function normalizeXiaomiTtsBaseUrl(baseUrl?: string): string {
|
||||
return (baseUrl?.trim() || DEFAULT_XIAOMI_TTS_BASE_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeXiaomiTtsFormat(value: unknown): XiaomiTtsFormat | undefined {
|
||||
const normalized = trimToUndefined(value)?.toLowerCase();
|
||||
return XIAOMI_TTS_FORMATS.includes(normalized as XiaomiTtsFormat)
|
||||
? (normalized as XiaomiTtsFormat)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveXiaomiTtsConfigRecord(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
return asObject(providers?.xiaomi) ?? asObject(providers?.mimo) ?? asObject(rawConfig.xiaomi);
|
||||
}
|
||||
|
||||
function normalizeXiaomiTtsProviderConfig(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): XiaomiTtsProviderConfig {
|
||||
const raw = resolveXiaomiTtsConfigRecord(rawConfig);
|
||||
return {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: raw?.apiKey,
|
||||
path: "messages.tts.providers.xiaomi.apiKey",
|
||||
}),
|
||||
baseUrl: normalizeXiaomiTtsBaseUrl(
|
||||
trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.XIAOMI_BASE_URL),
|
||||
),
|
||||
model:
|
||||
trimToUndefined(raw?.model) ??
|
||||
trimToUndefined(raw?.modelId) ??
|
||||
trimToUndefined(process.env.XIAOMI_TTS_MODEL) ??
|
||||
DEFAULT_XIAOMI_TTS_MODEL,
|
||||
voice:
|
||||
trimToUndefined(raw?.speakerVoice) ??
|
||||
trimToUndefined(raw?.speakerVoiceId) ??
|
||||
trimToUndefined(raw?.voice) ??
|
||||
trimToUndefined(raw?.voiceId) ??
|
||||
trimToUndefined(process.env.XIAOMI_TTS_VOICE) ??
|
||||
DEFAULT_XIAOMI_TTS_VOICE,
|
||||
format:
|
||||
normalizeXiaomiTtsFormat(raw?.format) ??
|
||||
normalizeXiaomiTtsFormat(process.env.XIAOMI_TTS_FORMAT) ??
|
||||
DEFAULT_XIAOMI_TTS_FORMAT,
|
||||
style: trimToUndefined(raw?.style),
|
||||
};
|
||||
}
|
||||
|
||||
function readXiaomiTtsProviderConfig(config: SpeechProviderConfig): XiaomiTtsProviderConfig {
|
||||
const normalized = normalizeXiaomiTtsProviderConfig({});
|
||||
return {
|
||||
apiKey:
|
||||
normalizeResolvedSecretInputString({
|
||||
value: config.apiKey,
|
||||
path: "messages.tts.providers.xiaomi.apiKey",
|
||||
}) ?? normalized.apiKey,
|
||||
baseUrl: normalizeXiaomiTtsBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl),
|
||||
model: trimToUndefined(config.model) ?? trimToUndefined(config.modelId) ?? normalized.model,
|
||||
voice:
|
||||
trimToUndefined(config.speakerVoice) ??
|
||||
trimToUndefined(config.speakerVoiceId) ??
|
||||
trimToUndefined(config.voice) ??
|
||||
trimToUndefined(config.voiceId) ??
|
||||
normalized.voice,
|
||||
format: normalizeXiaomiTtsFormat(config.format) ?? normalized.format,
|
||||
style: trimToUndefined(config.style) ?? normalized.style,
|
||||
};
|
||||
}
|
||||
|
||||
function readXiaomiTtsOverrides(
|
||||
overrides: SpeechProviderOverrides | undefined,
|
||||
): XiaomiTtsOverrides {
|
||||
if (!overrides) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
model: trimToUndefined(overrides.model) ?? trimToUndefined(overrides.modelId),
|
||||
voice:
|
||||
trimToUndefined(overrides.speakerVoice) ??
|
||||
trimToUndefined(overrides.speakerVoiceId) ??
|
||||
trimToUndefined(overrides.voice) ??
|
||||
trimToUndefined(overrides.voiceId),
|
||||
format: normalizeXiaomiTtsFormat(overrides.format),
|
||||
style: trimToUndefined(overrides.style),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
|
||||
handled: boolean;
|
||||
overrides?: SpeechProviderOverrides;
|
||||
warnings?: string[];
|
||||
} {
|
||||
switch (ctx.key) {
|
||||
case "voice":
|
||||
case "voiceid":
|
||||
case "voice_id":
|
||||
case "mimo_voice":
|
||||
case "xiaomi_voice":
|
||||
if (!ctx.policy.allowVoice) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { voice: ctx.value } };
|
||||
case "model":
|
||||
case "mimo_model":
|
||||
case "xiaomi_model":
|
||||
if (!ctx.policy.allowModelId) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { model: ctx.value } };
|
||||
case "style":
|
||||
case "mimo_style":
|
||||
case "xiaomi_style":
|
||||
if (!ctx.policy.allowVoiceSettings) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { style: ctx.value } };
|
||||
case "format":
|
||||
case "responseformat":
|
||||
case "response_format": {
|
||||
if (!ctx.policy.allowVoiceSettings) {
|
||||
return { handled: true };
|
||||
}
|
||||
const format = normalizeXiaomiTtsFormat(ctx.value);
|
||||
if (!format) {
|
||||
return { handled: true, warnings: [`invalid Xiaomi TTS format "${ctx.value}"`] };
|
||||
}
|
||||
return { handled: true, overrides: { format } };
|
||||
}
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
function buildXiaomiTtsMessages(params: { text: string; style?: string }) {
|
||||
const style = trimToUndefined(params.style);
|
||||
return [
|
||||
...(style ? [{ role: "user" as const, content: style }] : []),
|
||||
{ role: "assistant" as const, content: params.text },
|
||||
];
|
||||
}
|
||||
|
||||
function isXiaomiVoiceDesignModel(model: string): boolean {
|
||||
return model === XIAOMI_TTS_VOICE_DESIGN_MODEL;
|
||||
}
|
||||
|
||||
function resolveXiaomiVoiceDesignStyle(style: string | undefined): string {
|
||||
return trimToUndefined(style) ?? DEFAULT_XIAOMI_TTS_VOICE_DESIGN_STYLE;
|
||||
}
|
||||
|
||||
function buildXiaomiTtsAudio(params: { model: string; voice: string; format: XiaomiTtsFormat }): {
|
||||
format: XiaomiTtsFormat;
|
||||
voice?: string;
|
||||
} {
|
||||
if (isXiaomiVoiceDesignModel(params.model)) {
|
||||
return { format: params.format };
|
||||
}
|
||||
return { format: params.format, voice: params.voice };
|
||||
}
|
||||
|
||||
function decodeXiaomiAudioData(body: unknown): Buffer {
|
||||
const root = asObject(body);
|
||||
const choices = Array.isArray(root?.choices) ? root.choices : [];
|
||||
const firstChoice = asObject(choices[0]);
|
||||
const message = asObject(firstChoice?.message);
|
||||
const audio = asObject(message?.audio);
|
||||
const audioData = trimToUndefined(audio?.data);
|
||||
if (!audioData) {
|
||||
throw new Error("Xiaomi TTS API returned no audio data");
|
||||
}
|
||||
return Buffer.from(audioData, "base64");
|
||||
}
|
||||
|
||||
async function xiaomiTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
format: XiaomiTtsFormat;
|
||||
style?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
const { text, apiKey, baseUrl, model, voice, format, style, timeoutMs } = params;
|
||||
const requestTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
const resolvedStyle = isXiaomiVoiceDesignModel(model)
|
||||
? resolveXiaomiVoiceDesignStyle(style)
|
||||
: style;
|
||||
|
||||
try {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: buildXiaomiTtsMessages({ text, style: resolvedStyle }),
|
||||
audio: buildXiaomiTtsAudio({ model, voice, format }),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
timeoutMs: requestTimeoutMs,
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
||||
auditContext: "xiaomi.tts",
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Xiaomi TTS API error");
|
||||
const body = await readProviderJsonResponse<unknown>(response, "Xiaomi TTS API");
|
||||
return decodeXiaomiAudioData(body);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildXiaomiSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "xiaomi",
|
||||
label: "Xiaomi MiMo",
|
||||
aliases: ["mimo"],
|
||||
autoSelectOrder: 45,
|
||||
defaultModel: DEFAULT_XIAOMI_TTS_MODEL,
|
||||
models: XIAOMI_TTS_MODELS,
|
||||
voices: XIAOMI_TTS_VOICES,
|
||||
resolveConfig: ({ rawConfig }) => normalizeXiaomiTtsProviderConfig(rawConfig),
|
||||
parseDirectiveToken,
|
||||
listVoices: async () => XIAOMI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
|
||||
isConfigured: ({ providerConfig }) =>
|
||||
Boolean(readXiaomiTtsProviderConfig(providerConfig).apiKey || process.env.XIAOMI_API_KEY),
|
||||
synthesize: async (req) => {
|
||||
const config = readXiaomiTtsProviderConfig(req.providerConfig);
|
||||
const overrides = readXiaomiTtsOverrides(req.providerOverrides);
|
||||
const apiKey = config.apiKey || process.env.XIAOMI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Xiaomi API key missing");
|
||||
}
|
||||
const outputFormat = overrides.format ?? config.format;
|
||||
const audioBuffer = await xiaomiTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
model: overrides.model ?? config.model,
|
||||
voice: overrides.voice ?? config.voice,
|
||||
format: outputFormat,
|
||||
style: overrides.style ?? config.style,
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
if (req.target === "voice-note") {
|
||||
const opusBuffer = await transcodeAudioBufferToOpus({
|
||||
audioBuffer,
|
||||
inputExtension: outputFormat,
|
||||
tempPrefix: "tts-xiaomi-",
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
return {
|
||||
audioBuffer: opusBuffer,
|
||||
outputFormat: "opus",
|
||||
fileExtension: ".opus",
|
||||
voiceCompatible: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
audioBuffer,
|
||||
outputFormat,
|
||||
fileExtension: `.${outputFormat}`,
|
||||
voiceCompatible: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
46
extensions/xiaomi/stream.ts
Normal file
46
extensions/xiaomi/stream.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Xiaomi plugin module implements stream behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
createDeepSeekV4OpenAICompatibleThinkingWrapper,
|
||||
createThinkingOnlyFinalTextWrapper,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { isMiMoProviderId, isMiMoReasoningModelRef } from "./thinking.js";
|
||||
|
||||
const MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS = new Set(["mimo-v2-pro", "mimo-v2-omni"]);
|
||||
|
||||
function normalizeMiMoModelId(modelId: unknown): string | undefined {
|
||||
if (typeof modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = modelId.trim().toLowerCase().split(":", 1)[0];
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function shouldPromoteMiMoReasoningToVisibleText(model: Parameters<StreamFn>[0]): boolean {
|
||||
return (
|
||||
isMiMoProviderId(model.provider) &&
|
||||
MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS.has(normalizeMiMoModelId(model.id) ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
export function createMiMoThinkingWrapper(
|
||||
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
|
||||
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
|
||||
): ProviderWrapStreamFnContext["streamFn"] {
|
||||
const wrapped = createDeepSeekV4OpenAICompatibleThinkingWrapper({
|
||||
baseStreamFn,
|
||||
thinkingLevel,
|
||||
shouldPatchModel: isMiMoReasoningModelRef,
|
||||
});
|
||||
// Legacy MiMo V2 can put the final user-visible answer in reasoning_content.
|
||||
// Only promote terminal thinking-only output; replay/tool-call reasoning stays untouched.
|
||||
return createThinkingOnlyFinalTextWrapper({
|
||||
baseStreamFn: wrapped,
|
||||
shouldPatchModel: shouldPromoteMiMoReasoningToVisibleText,
|
||||
});
|
||||
}
|
||||
46
extensions/xiaomi/thinking.ts
Normal file
46
extensions/xiaomi/thinking.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Xiaomi plugin module implements thinking behavior.
|
||||
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { XIAOMI_PROVIDER_ID, XIAOMI_TOKEN_PLAN_PROVIDER_ID } from "./provider-catalog.js";
|
||||
|
||||
const MIMO_REASONING_MODEL_IDS = new Set([
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.6-pro",
|
||||
]);
|
||||
|
||||
export function isMiMoReasoningModelId(modelId: string): boolean {
|
||||
return MIMO_REASONING_MODEL_IDS.has(modelId.toLowerCase());
|
||||
}
|
||||
|
||||
export function isMiMoProviderId(providerId: unknown): boolean {
|
||||
return providerId === XIAOMI_PROVIDER_ID || providerId === XIAOMI_TOKEN_PLAN_PROVIDER_ID;
|
||||
}
|
||||
|
||||
export function isMiMoReasoningModelRef(model: { provider?: string; id?: unknown }): boolean {
|
||||
return (
|
||||
isMiMoProviderId(model.provider) &&
|
||||
typeof model.id === "string" &&
|
||||
isMiMoReasoningModelId(model.id)
|
||||
);
|
||||
}
|
||||
|
||||
const MIMO_THINKING_LEVEL_IDS = [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
const MIMO_THINKING_PROFILE = {
|
||||
levels: MIMO_THINKING_LEVEL_IDS.map((id) => ({ id })),
|
||||
defaultLevel: "high",
|
||||
} satisfies ProviderThinkingProfile;
|
||||
|
||||
export function resolveMiMoThinkingProfile(modelId: string): ProviderThinkingProfile | undefined {
|
||||
return isMiMoReasoningModelId(modelId) ? MIMO_THINKING_PROFILE : undefined;
|
||||
}
|
||||
16
extensions/xiaomi/tsconfig.json
Normal file
16
extensions/xiaomi/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"
|
||||
]
|
||||
}
|
||||
56
extensions/xiaomi/xiaomi.live.test.ts
Normal file
56
extensions/xiaomi/xiaomi.live.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Xiaomi tests cover xiaomi 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";
|
||||
|
||||
const XIAOMI_API_KEY = process.env.XIAOMI_API_KEY?.trim() ?? "";
|
||||
const LIVE = isLiveTestEnabled() && XIAOMI_API_KEY.length > 0;
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
|
||||
const registerXiaomiPlugin = () =>
|
||||
registerProviderPlugin({
|
||||
plugin,
|
||||
id: "xiaomi",
|
||||
name: "Xiaomi Provider",
|
||||
});
|
||||
|
||||
describeLive("xiaomi plugin live", () => {
|
||||
it("synthesizes MiMo TTS through the registered speech provider", async () => {
|
||||
const { speechProviders } = await registerXiaomiPlugin();
|
||||
const provider = requireRegisteredProvider(speechProviders, "xiaomi");
|
||||
|
||||
const audioFile = await provider.synthesize({
|
||||
text: "OpenClaw Xiaomi MiMo text to speech integration test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: { apiKey: XIAOMI_API_KEY, format: "mp3", voice: "mimo_default" },
|
||||
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 MiMo TTS as an Opus voice note", async () => {
|
||||
const { speechProviders } = await registerXiaomiPlugin();
|
||||
const provider = requireRegisteredProvider(speechProviders, "xiaomi");
|
||||
|
||||
const voiceNote = await provider.synthesize({
|
||||
text: "OpenClaw Xiaomi MiMo voice note test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig: { apiKey: XIAOMI_API_KEY, format: "mp3", voice: "mimo_default" },
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user