chore(pixverse): publish as external plugin

This commit is contained in:
Vincent Koc
2026-05-27 06:50:56 +02:00
parent b3083de4f2
commit 53662094c3
21 changed files with 859 additions and 87 deletions
+1
View File
@@ -163,6 +163,7 @@ commands.
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [pixverse](/plugins/reference/pixverse) | Adds PixVerse video generation provider support to OpenClaw. | `@openclaw/pixverse-provider`<br />npm; ClawHub | contracts: videoGenerationProviders |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
+1
View File
@@ -96,6 +96,7 @@ pnpm plugins:inventory:gen
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by the NVIDIA OpenShell CLI with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
| [pixverse](/plugins/reference/pixverse) | Adds PixVerse video generation provider support to OpenClaw. | `@openclaw/pixverse-provider`<br />npm; ClawHub | contracts: videoGenerationProviders |
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`<br />included in OpenClaw | plugin |
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |
| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`<br />source checkout only | plugin |
+23
View File
@@ -0,0 +1,23 @@
---
summary: "Adds PixVerse video generation provider support to OpenClaw."
read_when:
- You are installing, configuring, or auditing the pixverse plugin
title: "PixVerse plugin"
---
# PixVerse plugin
Adds PixVerse video generation provider support to OpenClaw.
## Distribution
- Package: `@openclaw/pixverse-provider`
- Install route: npm; ClawHub
## Surface
contracts: videoGenerationProviders
## Related docs
- [pixverse](/providers/pixverse)
+23 -3
View File
@@ -7,12 +7,12 @@ read_when:
- You want to make PixVerse the default video provider
---
OpenClaw ships a bundled `pixverse` provider for hosted PixVerse video generation. The plugin is enabled by default and registers the `pixverse` provider against the `videoGenerationProviders` contract.
OpenClaw provides `pixverse` as an official external plugin for hosted PixVerse video generation. The plugin registers the `pixverse` provider against the `videoGenerationProviders` contract.
| Property | Value |
| ------------------ | -------------------------------------------------------------------- |
| Provider id | `pixverse` |
| Plugin | bundled, `enabledByDefault: true` |
| Plugin package | `@openclaw/pixverse-provider` |
| Auth env var | `PIXVERSE_API_KEY` |
| Onboarding flag | `--auth-choice pixverse-api-key` |
| Direct CLI flag | `--pixverse-api-key <key>` |
@@ -23,10 +23,22 @@ OpenClaw ships a bundled `pixverse` provider for hosted PixVerse video generatio
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/pixverse-provider
openclaw gateway restart
```
</Step>
<Step title="Set the API key">
```bash
openclaw onboard --auth-choice pixverse-api-key
```
The wizard asks whether to use the International endpoint
(`https://app-api.pixverse.ai/openapi/v2`) or the CN endpoint
(`https://app-api.pixverseai.cn/openapi/v2`) before writing `region` and
`baseUrl` into the provider config.
</Step>
<Step title="Set PixVerse as the default video provider">
```bash
@@ -92,7 +104,13 @@ The video provider accepts these optional provider-specific keys:
<AccordionGroup>
<Accordion title="API region">
OpenClaw defaults to the international PixVerse API. Set `models.providers.pixverse.region`
when your key belongs to a specific PixVerse platform region:
manually when your key belongs to a specific PixVerse platform region, or use
`openclaw onboard --auth-choice pixverse-api-key` to choose one in the setup wizard:
| Region value | PixVerse API base URL |
| --------------- | --------------------------------------------- |
| `international` | `https://app-api.pixverse.ai/openapi/v2` |
| `cn` | `https://app-api.pixverseai.cn/openapi/v2` |
```json5
{
@@ -100,6 +118,8 @@ The video provider accepts these optional provider-specific keys:
providers: {
pixverse: {
region: "cn", // "international" or "cn"
baseUrl: "https://app-api.pixverseai.cn/openapi/v2",
models: [],
},
},
},
+12
View File
@@ -0,0 +1,12 @@
export const PIXVERSE_PROVIDER_ID = "pixverse";
export const PIXVERSE_BASE_URL_BY_REGION = {
international: "https://app-api.pixverse.ai/openapi/v2",
cn: "https://app-api.pixverseai.cn/openapi/v2",
} as const;
export type PixVerseApiRegion = keyof typeof PIXVERSE_BASE_URL_BY_REGION;
export const DEFAULT_PIXVERSE_REGION = "international" satisfies PixVerseApiRegion;
export const DEFAULT_PIXVERSE_MODEL_ID = "v6";
export const PIXVERSE_DEFAULT_VIDEO_MODEL_REF = `${PIXVERSE_PROVIDER_ID}/${DEFAULT_PIXVERSE_MODEL_ID}`;
+196
View File
@@ -0,0 +1,196 @@
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import {
PIXVERSE_BASE_URL_BY_REGION,
PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
PIXVERSE_PROVIDER_ID,
} from "./constants.js";
import plugin from "./index.js";
import { applyPixVerseConfig, applyPixVerseProviderConfig } from "./onboard.js";
function registerPixVerseProvider() {
const captured = capturePluginRegistration(plugin);
expect(captured.videoGenerationProviders.map((provider) => provider.id)).toEqual([
PIXVERSE_PROVIDER_ID,
]);
const provider = captured.providers[0];
if (!provider) {
throw new Error("expected PixVerse setup provider");
}
expect(provider.id).toBe(PIXVERSE_PROVIDER_ID);
return provider;
}
function createRuntimeContext(region: "international" | "cn") {
const select = vi.fn(async (params: { message: string }) => {
expect(params.message).toBe("Select PixVerse API region");
return region;
});
const ctx = {
config: {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
},
},
},
},
env: {},
prompter: {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
select,
multiselect: vi.fn(),
text: vi.fn(async () => "pixverse-test-key"),
confirm: vi.fn(),
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
},
runtime: {
error: vi.fn(),
exit: vi.fn(),
log: vi.fn(),
},
secretInputMode: "plaintext",
isRemote: false,
openUrl: vi.fn(),
oauth: {
createVpsAwareHandlers: vi.fn(),
},
} as never;
return { ctx, select };
}
describe("pixverse plugin", () => {
it("registers provider auth for the setup wizard", () => {
const provider = registerPixVerseProvider();
const auth = provider?.auth?.[0];
expect(provider).toMatchObject({
id: PIXVERSE_PROVIDER_ID,
label: "PixVerse",
docsPath: "/providers/pixverse",
envVars: ["PIXVERSE_API_KEY"],
});
expect(auth).toMatchObject({
id: "api-key",
label: "PixVerse API key",
kind: "api_key",
wizard: {
choiceId: "pixverse-api-key",
choiceLabel: "PixVerse API key",
choiceHint: "Prompts for International or CN endpoint",
groupId: "pixverse",
groupLabel: "PixVerse",
groupHint: "Video generation",
onboardingScopes: ["image-generation"],
},
});
});
it("prompts for the PixVerse region and writes provider config", async () => {
const provider = registerPixVerseProvider();
const auth = provider?.auth?.[0];
if (!auth) {
throw new Error("expected PixVerse auth method");
}
const { ctx, select } = createRuntimeContext("cn");
const result = await auth.run(ctx);
const regionSelect = select.mock.calls[0]?.[0];
expect(regionSelect).toEqual({
message: "Select PixVerse API region",
initialValue: "international",
options: [
{
value: "international",
label: "International",
hint: PIXVERSE_BASE_URL_BY_REGION.international,
},
{
value: "cn",
label: "CN",
hint: PIXVERSE_BASE_URL_BY_REGION.cn,
},
],
});
expect(result.profiles).toEqual([
{
profileId: "pixverse:default",
credential: {
type: "api_key",
provider: PIXVERSE_PROVIDER_ID,
key: "pixverse-test-key",
},
},
]);
expect(result.configPatch?.models?.providers?.pixverse).toEqual({
baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn,
models: [],
params: { quality: "720p" },
region: "cn",
});
expect(result.defaultModel).toBeUndefined();
expect(result.configPatch?.agents?.defaults?.videoGenerationModel).toEqual({
primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
});
expect(result.notes).toEqual([`PixVerse endpoint: CN (${PIXVERSE_BASE_URL_BY_REGION.cn})`]);
});
it("only resets custom baseUrl when a region is explicitly selected", () => {
const config = {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
},
},
},
};
expect(
applyPixVerseProviderConfig(config, "international").models?.providers?.pixverse,
).toEqual({
baseUrl: "https://proxy.example/openapi/v2",
models: [],
params: { quality: "720p" },
region: "international",
});
expect(
applyPixVerseProviderConfig(config, "cn", { resetBaseUrl: true }).models?.providers?.pixverse,
).toEqual({
baseUrl: PIXVERSE_BASE_URL_BY_REGION.cn,
models: [],
params: { quality: "720p" },
region: "cn",
});
});
it("preserves an existing video generation default", () => {
const result = applyPixVerseConfig(
{
agents: {
defaults: {
videoGenerationModel: {
primary: "openai/sora-2",
},
},
},
},
"international",
);
expect(result.agents?.defaults?.videoGenerationModel).toEqual({
primary: "openai/sora-2",
});
});
});
+11 -2
View File
@@ -1,11 +1,20 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { PIXVERSE_PROVIDER_ID } from "./constants.js";
import { buildPixVerseApiKeyAuthMethod } from "./onboard.js";
import { buildPixVerseVideoGenerationProvider } from "./video-generation-provider.js";
export default definePluginEntry({
id: "pixverse",
id: PIXVERSE_PROVIDER_ID,
name: "PixVerse Provider",
description: "Bundled PixVerse video provider plugin",
description: "Official external PixVerse video provider plugin",
register(api) {
api.registerProvider({
id: PIXVERSE_PROVIDER_ID,
label: "PixVerse",
docsPath: "/providers/pixverse",
envVars: ["PIXVERSE_API_KEY"],
auth: [buildPixVerseApiKeyAuthMethod()],
});
api.registerVideoGenerationProvider(buildPixVerseVideoGenerationProvider());
},
});
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@openclaw/pixverse-provider",
"version": "2026.5.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/pixverse-provider",
"version": "2026.5.26",
"peerDependencies": {
"openclaw": ">=2026.5.26"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
}
}
}
+252
View File
@@ -0,0 +1,252 @@
import {
type ProviderAuthContext,
type ProviderAuthMethod,
type ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
applyAuthProfileConfig,
buildApiKeyCredential,
ensureApiKeyFromOptionEnvOrPrompt,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
type OpenClawConfig,
type SecretInput,
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth-api-key";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-onboard";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
DEFAULT_PIXVERSE_REGION,
PIXVERSE_BASE_URL_BY_REGION,
PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
PIXVERSE_PROVIDER_ID,
type PixVerseApiRegion,
} from "./constants.js";
const PROFILE_ID = `${PIXVERSE_PROVIDER_ID}:default`;
type PixVerseAuthResult = {
profiles: Array<{ profileId: string; credential: ReturnType<typeof buildApiKeyCredential> }>;
configPatch: OpenClawConfig;
notes: string[];
};
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
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 normalizePixVerseRegion(value: unknown): PixVerseApiRegion | undefined {
const region = normalizeOptionalString(value)?.toLowerCase();
switch (region) {
case "cn":
case "china":
case "mainland":
case "pai":
return "cn";
case "global":
case "intl":
case "international":
return "international";
default:
return undefined;
}
}
function pixVerseRegionNote(region: PixVerseApiRegion): string {
const label = region === "cn" ? "CN" : "International";
return `PixVerse endpoint: ${label} (${PIXVERSE_BASE_URL_BY_REGION[region]})`;
}
export function applyPixVerseProviderConfig(
cfg: OpenClawConfig,
region: PixVerseApiRegion,
options?: { resetBaseUrl?: boolean },
): OpenClawConfig {
const existingProvider: Partial<ModelProviderConfig> =
cfg.models?.providers?.[PIXVERSE_PROVIDER_ID] ?? {};
const selectedBaseUrl = PIXVERSE_BASE_URL_BY_REGION[region];
const baseUrl = options?.resetBaseUrl
? selectedBaseUrl
: (normalizeOptionalString(existingProvider.baseUrl) ?? selectedBaseUrl);
return {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
[PIXVERSE_PROVIDER_ID]: {
...existingProvider,
baseUrl,
models: existingProvider.models ?? [],
region,
},
},
},
};
}
export function applyPixVerseConfig(
cfg: OpenClawConfig,
region: PixVerseApiRegion,
options?: { resetBaseUrl?: boolean },
): OpenClawConfig {
const next = applyPixVerseProviderConfig(cfg, region, options);
if (next.agents?.defaults?.videoGenerationModel) {
return next;
}
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
videoGenerationModel: {
primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF,
},
},
},
};
}
async function promptForPixVerseRegion(ctx: ProviderAuthContext): Promise<PixVerseApiRegion> {
return await ctx.prompter.select<PixVerseApiRegion>({
message: "Select PixVerse API region",
initialValue: DEFAULT_PIXVERSE_REGION,
options: [
{
value: "international",
label: "International",
hint: PIXVERSE_BASE_URL_BY_REGION.international,
},
{
value: "cn",
label: "CN",
hint: PIXVERSE_BASE_URL_BY_REGION.cn,
},
],
});
}
async function runPixVerseApiKeyAuth(ctx: ProviderAuthContext): Promise<PixVerseAuthResult> {
let capturedSecretInput: SecretInput | undefined;
let capturedCredential = false;
let capturedMode: "plaintext" | "ref" | undefined;
await ensureApiKeyFromOptionEnvOrPrompt({
token:
normalizeOptionalSecretInput(ctx.opts?.pixverseApiKey) ??
normalizeOptionalSecretInput(ctx.opts?.token),
tokenProvider: normalizeOptionalSecretInput(ctx.opts?.pixverseApiKey)
? PIXVERSE_PROVIDER_ID
: normalizeOptionalSecretInput(ctx.opts?.tokenProvider),
secretInputMode:
ctx.allowSecretRefPrompt === false
? (ctx.secretInputMode ?? "plaintext")
: ctx.secretInputMode,
config: ctx.config,
env: ctx.env,
expectedProviders: [PIXVERSE_PROVIDER_ID],
provider: PIXVERSE_PROVIDER_ID,
envLabel: "PIXVERSE_API_KEY",
promptMessage: "Enter PixVerse API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: ctx.prompter,
setCredential: async (apiKey, mode) => {
capturedSecretInput = apiKey;
capturedCredential = true;
capturedMode = mode;
},
});
if (!capturedCredential) {
throw new Error("Missing PixVerse API key.");
}
const region = await promptForPixVerseRegion(ctx);
return {
profiles: [
{
profileId: PROFILE_ID,
credential: buildApiKeyCredential(
PIXVERSE_PROVIDER_ID,
capturedSecretInput ?? "",
undefined,
capturedMode
? {
secretInputMode: capturedMode,
config: ctx.config,
}
: undefined,
),
},
],
configPatch: applyPixVerseConfig(ctx.config, region, { resetBaseUrl: true }),
notes: [pixVerseRegionNote(region)],
};
}
async function runPixVerseApiKeyAuthNonInteractive(ctx: ProviderAuthMethodNonInteractiveContext) {
const resolved = await ctx.resolveApiKey({
provider: PIXVERSE_PROVIDER_ID,
flagValue: normalizeOptionalSecretInput(ctx.opts.pixverseApiKey),
flagName: "--pixverse-api-key",
envVar: "PIXVERSE_API_KEY",
});
if (!resolved) {
return null;
}
if (resolved.source !== "profile") {
const credential = ctx.toApiKeyCredential({
provider: PIXVERSE_PROVIDER_ID,
resolved,
});
if (!credential) {
return null;
}
await upsertAuthProfileWithLockOrThrow({
profileId: PROFILE_ID,
credential,
agentDir: ctx.agentDir,
});
}
const next = applyAuthProfileConfig(ctx.config, {
profileId: PROFILE_ID,
provider: PIXVERSE_PROVIDER_ID,
mode: "api_key",
});
const explicitRegion = normalizePixVerseRegion(ctx.opts.pixverseRegion);
return applyPixVerseConfig(next, explicitRegion ?? DEFAULT_PIXVERSE_REGION, {
resetBaseUrl: explicitRegion !== undefined,
});
}
export function buildPixVerseApiKeyAuthMethod(): ProviderAuthMethod {
return {
id: "api-key",
label: "PixVerse API key",
hint: "Video generation API key",
kind: "api_key",
wizard: {
choiceId: "pixverse-api-key",
choiceLabel: "PixVerse API key",
choiceHint: "Prompts for International or CN endpoint",
groupId: "pixverse",
groupLabel: "PixVerse",
groupHint: "Video generation",
onboardingScopes: ["image-generation"],
},
run: runPixVerseApiKeyAuth,
runNonInteractive: runPixVerseApiKeyAuthNonInteractive,
};
}
+3 -1
View File
@@ -1,5 +1,6 @@
{
"id": "pixverse",
"description": "Adds PixVerse video generation provider support to OpenClaw.",
"activation": {
"onStartup": false
},
@@ -13,9 +14,10 @@
"method": "api-key",
"choiceId": "pixverse-api-key",
"choiceLabel": "PixVerse API key",
"choiceHint": "Wizard prompts for International or CN endpoint.",
"groupId": "pixverse",
"groupLabel": "PixVerse",
"groupHint": "API key",
"groupHint": "Video generation",
"onboardingScopes": ["image-generation"],
"optionKey": "pixverseApiKey",
"cliFlag": "--pixverse-api-key",
+30 -3
View File
@@ -1,15 +1,42 @@
{
"name": "@openclaw/pixverse-provider",
"version": "2026.5.26",
"private": true,
"description": "OpenClaw PixVerse video provider plugin",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.26"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"npmSpec": "@openclaw/pixverse-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.26"
},
"compat": {
"pluginApi": ">=2026.5.26"
},
"build": {
"openclawVersion": "2026.5.26"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}
@@ -5,8 +5,14 @@ import {
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { beforeAll, describe, expect, it, vi } from "vitest";
const { postJsonRequestMock, postMultipartRequestMock, fetchWithTimeoutMock } =
getProviderHttpMocks();
const {
postJsonRequestMock,
postMultipartRequestMock,
fetchWithTimeoutMock,
pollProviderOperationJsonMock,
resolveProviderHttpRequestConfigMock,
sanitizeConfiguredModelProviderRequestMock,
} = getProviderHttpMocks();
let buildPixVerseVideoGenerationProvider: typeof import("./video-generation-provider.js").buildPixVerseVideoGenerationProvider;
@@ -32,6 +38,23 @@ function firstMultipartRequest() {
return call[0] as { url?: string; body?: FormData; headers?: Headers };
}
function firstPollRequest() {
const [call] = pollProviderOperationJsonMock.mock.calls;
if (!call) {
throw new Error("expected PixVerse status poll request");
}
return call[0] as {
url?: string;
allowPrivateNetwork?: boolean;
dispatcherPolicy?: unknown;
};
}
function pollFetchHeaders(callIndex: number): Headers | undefined {
const [, init] = fetchWithTimeoutMock.mock.calls[callIndex] ?? [];
return (init as { headers?: Headers } | undefined)?.headers;
}
describe("pixverse video generation provider", () => {
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildPixVerseVideoGenerationProvider());
@@ -330,6 +353,142 @@ describe("pixverse video generation provider", () => {
);
});
it("uses the guarded provider transport for status polling", async () => {
const dispatcherPolicy = { mode: "direct" };
resolveProviderHttpRequestConfigMock.mockReturnValueOnce({
baseUrl: "https://proxy.example/openapi/v2",
allowPrivateNetwork: true,
headers: new Headers({ "API-KEY": "provider-key", "X-Proxy": "enabled" }),
dispatcherPolicy,
} as never);
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "custom base",
cfg: {},
});
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
expect(firstPostJsonRequest().headers?.get("X-Proxy")).toBe("enabled");
expect(firstPollRequest()).toMatchObject({
url: "https://proxy.example/openapi/v2/video/result/123",
allowPrivateNetwork: true,
dispatcherPolicy,
});
const pollHeaders = pollFetchHeaders(0);
expect(pollHeaders?.get("X-Proxy")).toBe("enabled");
});
it("passes configured provider request overrides into the HTTP resolver", async () => {
const request = {
allowPrivateNetwork: true,
headers: { "X-Proxy": "enabled" },
};
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "custom request config",
cfg: {
models: {
providers: {
pixverse: {
request,
},
},
},
} as never,
});
expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(request);
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
expect.objectContaining({ request }),
);
});
it("uses a fresh trace id for each status poll", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 5 },
}),
headers: new Headers(),
})
.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "fresh trace ids",
cfg: {},
});
const firstHeaders = pollFetchHeaders(0);
const secondHeaders = pollFetchHeaders(1);
expect(firstHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
expect(secondHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
expect(secondHeaders?.get("Ai-trace-id")).not.toBe(firstHeaders?.get("Ai-trace-id"));
});
it("uses the configured CN API region", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
@@ -5,13 +5,12 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
createProviderOperationTimeoutResolver,
fetchProviderOperationResponse,
pollProviderOperationJson,
postJsonRequest,
postMultipartRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
sanitizeConfiguredModelProviderRequest,
type ProviderOperationDeadline,
} from "openclaw/plugin-sdk/provider-http";
import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -21,14 +20,15 @@ import type {
VideoGenerationRequest,
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
import {
DEFAULT_PIXVERSE_MODEL_ID,
DEFAULT_PIXVERSE_REGION,
PIXVERSE_BASE_URL_BY_REGION,
PIXVERSE_PROVIDER_ID,
type PixVerseApiRegion,
} from "./constants.js";
const PIXVERSE_BASE_URL_BY_REGION = {
international: "https://app-api.pixverse.ai/openapi/v2",
cn: "https://app-api.pixverseai.cn/openapi/v2",
} as const;
const DEFAULT_PIXVERSE_REGION = "international";
const DEFAULT_PIXVERSE_BASE_URL = PIXVERSE_BASE_URL_BY_REGION[DEFAULT_PIXVERSE_REGION];
const DEFAULT_PIXVERSE_MODEL = "v6";
const DEFAULT_PIXVERSE_QUALITY = "540p";
const DEFAULT_TIMEOUT_MS = 300_000;
const POLL_INTERVAL_MS = 5_000;
@@ -47,8 +47,6 @@ const PIXVERSE_TEXT_ASPECT_RATIOS = [
] as const;
const PIXVERSE_QUALITIES = ["360p", "540p", "720p", "1080p"] as const;
type PixVerseApiRegion = keyof typeof PIXVERSE_BASE_URL_BY_REGION;
type PixVerseEnvelope<T> = {
ErrCode?: unknown;
ErrMsg?: unknown;
@@ -75,7 +73,7 @@ type PixVerseVideoResultResponse = {
};
function resolvePixVerseBaseUrl(req: VideoGenerationRequest): string {
const provider = req.cfg?.models?.providers?.pixverse;
const provider = req.cfg?.models?.providers?.[PIXVERSE_PROVIDER_ID];
const configuredBaseUrl = normalizeOptionalString(provider?.baseUrl);
if (configuredBaseUrl) {
return configuredBaseUrl;
@@ -104,7 +102,7 @@ function resolvePixVerseApiRegion(value: unknown): PixVerseApiRegion {
function normalizePixVerseModel(model: string | undefined): string {
const normalized = normalizeOptionalString(model)?.replace(/^pixverse\//iu, "");
return normalized?.toLowerCase() || DEFAULT_PIXVERSE_MODEL;
return normalized?.toLowerCase() || DEFAULT_PIXVERSE_MODEL_ID;
}
function resolvePixVerseQuality(req: VideoGenerationRequest): string {
@@ -142,19 +140,15 @@ function appendOptionalString(body: Record<string, unknown>, key: string, value:
}
}
function buildHeaderEntries(apiKey: string, contentType?: string): Record<string, string> {
const headers: Record<string, string> = {
"API-KEY": apiKey,
"Ai-trace-id": randomUUID(),
};
function buildPixVerseHeaders(headers: Headers, contentType?: string): Headers {
const next = new Headers(headers);
next.set("Ai-trace-id", randomUUID());
if (contentType) {
headers["Content-Type"] = contentType;
next.set("Content-Type", contentType);
} else {
next.delete("Content-Type");
}
return headers;
}
function buildHeaders(apiKey: string, contentType?: string): Headers {
return new Headers(buildHeaderEntries(apiKey, contentType));
return next;
}
function readPixVerseSuccess<T>(payload: PixVerseEnvelope<T>, label: string): T {
@@ -285,44 +279,31 @@ function readPixVerseFailureMessage(payload: PixVerseVideoResultResponse): strin
async function pollPixVerseVideo(params: {
videoId: number;
apiKey: string;
baseUrl: string;
deadline: ProviderOperationDeadline;
fetchFn: typeof fetch;
allowPrivateNetwork: boolean;
dispatcherPolicy?: Parameters<typeof postJsonRequest>[0]["dispatcherPolicy"];
headers: Headers;
}): Promise<PixVerseVideoResultResponse> {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchProviderOperationResponse({
stage: "poll",
url: `${params.baseUrl}/video/result/${params.videoId}`,
init: {
method: "GET",
headers: buildHeaders(params.apiKey),
},
timeoutMs: createProviderOperationTimeoutResolver({
deadline: params.deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn: params.fetchFn,
provider: "pixverse",
requestFailedMessage: "PixVerse video status request failed",
});
const payload = await readPixVerseJson<PixVerseVideoResultResponse>(
response,
"PixVerse video status request failed",
);
if (readPixVerseStatus(payload) === 1) {
return payload;
}
const failureMessage = readPixVerseFailureMessage(payload);
if (failureMessage) {
throw new Error(failureMessage);
}
await waitProviderOperationPollInterval({
deadline: params.deadline,
pollIntervalMs: POLL_INTERVAL_MS,
});
}
throw new Error(`PixVerse video generation task ${params.videoId} did not finish in time`);
const readResult = (payload: PixVerseEnvelope<PixVerseVideoResultResponse>) =>
readPixVerseSuccess(payload, "PixVerse video status request failed");
const payload = await pollProviderOperationJson<PixVerseEnvelope<PixVerseVideoResultResponse>>({
url: `${params.baseUrl}/video/result/${params.videoId}`,
headers: () => buildPixVerseHeaders(params.headers),
deadline: params.deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
fetchFn: params.fetchFn,
maxAttempts: MAX_POLL_ATTEMPTS,
pollIntervalMs: POLL_INTERVAL_MS,
requestFailedMessage: "PixVerse video status request failed",
timeoutMessage: `PixVerse video generation task ${params.videoId} did not finish in time`,
isComplete: (candidate) => readPixVerseStatus(readResult(candidate)) === 1,
getFailureMessage: (candidate) => readPixVerseFailureMessage(readResult(candidate)),
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
});
return readResult(payload);
}
function extractPixVerseVideo(payload: PixVerseVideoResultResponse): GeneratedVideoAsset {
@@ -344,14 +325,14 @@ function extractPixVerseVideo(payload: PixVerseVideoResultResponse): GeneratedVi
export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "pixverse",
id: PIXVERSE_PROVIDER_ID,
label: "PixVerse",
defaultModel: DEFAULT_PIXVERSE_MODEL,
defaultModel: DEFAULT_PIXVERSE_MODEL_ID,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
models: [...PIXVERSE_VIDEO_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "pixverse",
provider: PIXVERSE_PROVIDER_ID,
agentDir,
}),
capabilities: {
@@ -416,7 +397,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
}
const auth = await resolveApiKeyForProvider({
provider: "pixverse",
provider: PIXVERSE_PROVIDER_ID,
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
@@ -427,6 +408,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
const model = normalizePixVerseModel(req.model);
const fetchFn = fetch;
const providerConfig = req.cfg?.models?.providers?.[PIXVERSE_PROVIDER_ID];
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "PixVerse video generation",
@@ -435,8 +417,11 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
resolveProviderHttpRequestConfig({
baseUrl: resolvePixVerseBaseUrl(req),
defaultBaseUrl: DEFAULT_PIXVERSE_BASE_URL,
defaultHeaders: buildHeaderEntries(auth.apiKey, "application/json"),
provider: "pixverse",
request: sanitizeConfiguredModelProviderRequest(providerConfig?.request),
defaultHeaders: {
"API-KEY": auth.apiKey,
},
provider: PIXVERSE_PROVIDER_ID,
capability: "video",
transport: "http",
});
@@ -446,7 +431,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
if (image) {
const upload = await postMultipartRequest({
url: `${baseUrl}/image/upload`,
headers: buildHeaders(auth.apiKey),
headers: buildPixVerseHeaders(headers),
body: buildUploadImageForm(image),
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
@@ -472,7 +457,7 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
const endpoint = imageId === undefined ? "/video/text/generate" : "/video/img/generate";
const create = await postJsonRequest({
url: `${baseUrl}${endpoint}`,
headers,
headers: buildPixVerseHeaders(headers, "application/json"),
body: buildVideoBody(req, model, imageId),
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
@@ -492,10 +477,12 @@ export function buildPixVerseVideoGenerationProvider(): VideoGenerationProvider
);
const completed = await pollPixVerseVideo({
videoId,
apiKey: auth.apiKey,
baseUrl,
deadline,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
headers,
});
return {
videos: [extractPixVerseVideo(completed)],
+1
View File
@@ -85,6 +85,7 @@
"!dist/extensions/nextcloud-talk/**",
"!dist/extensions/nostr/**",
"!dist/extensions/qqbot/**",
"!dist/extensions/pixverse/**",
"!dist/extensions/qa-channel/**",
"!dist/extensions/qa-lab/**",
"!dist/extensions/qa-matrix/**",
+3
View File
@@ -1239,6 +1239,9 @@ importers:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/policy:
dependencies:
@@ -114,6 +114,7 @@ function humanizeId(value) {
["opencode", "OpenCode"],
["openrouter", "OpenRouter"],
["otel", "OpenTelemetry"],
["pixverse", "PixVerse"],
["qa", "QA"],
["qqbot", "QQ Bot"],
["qwen", "Qwen"],
@@ -112,6 +112,47 @@
"minHostVersion": ">=2026.5.1-beta.1"
}
}
},
{
"name": "@openclaw/pixverse-provider",
"description": "OpenClaw PixVerse video provider plugin",
"source": "official",
"kind": "provider",
"openclaw": {
"plugin": {
"id": "pixverse",
"label": "PixVerse"
},
"providers": [
{
"id": "pixverse",
"name": "PixVerse",
"docs": "/providers/pixverse",
"categories": ["cloud", "video"],
"authChoices": [
{
"method": "api-key",
"choiceId": "pixverse-api-key",
"choiceLabel": "PixVerse API key",
"choiceHint": "Wizard prompts for International or CN endpoint.",
"groupId": "pixverse",
"groupLabel": "PixVerse",
"groupHint": "Video generation",
"optionKey": "pixverseApiKey",
"cliFlag": "--pixverse-api-key",
"cliOption": "--pixverse-api-key <key>",
"cliDescription": "PixVerse API key",
"onboardingScopes": ["image-generation"]
}
]
}
],
"install": {
"npmSpec": "@openclaw/pixverse-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.26"
}
}
}
]
}
@@ -278,6 +278,16 @@ describe("media-generation runtime shared normalization", () => {
).toBe("540P");
});
it("does not map across image and video resolution units", () => {
expect(
resolveClosestResolution({
requestedResolution: "4K",
supportedResolutions: ["768P", "1080P"],
order: ["360P", "480P", "540P", "720P", "768P", "1080P"],
}),
).toBeUndefined();
});
it("clamps durations to the closest supported max", () => {
expect(normalizeDurationToClosestMax(12, 8)).toBe(8);
expect(normalizeDurationToClosestMax(6, 8)).toBe(6);
+12 -6
View File
@@ -466,17 +466,17 @@ export function resolveClosestResolution<TResolution extends string>(params: {
return params.requestedResolution;
}
const requestedNumeric = parseResolutionRank(params.requestedResolution);
if (typeof requestedNumeric === "number") {
if (requestedNumeric) {
let bestValue: TResolution | undefined;
let bestScore: { primary: number; secondary: number; tertiary: string } | null = null;
for (const candidate of supported) {
const candidateNumeric = parseResolutionRank(candidate);
if (typeof candidateNumeric !== "number") {
if (!candidateNumeric || candidateNumeric.unit !== requestedNumeric.unit) {
continue;
}
const score = {
primary: Math.abs(candidateNumeric - requestedNumeric),
secondary: candidateNumeric < requestedNumeric ? 1 : 0,
primary: Math.abs(candidateNumeric.value - requestedNumeric.value),
secondary: candidateNumeric.value < requestedNumeric.value ? 1 : 0,
tertiary: candidate,
};
if (compareScores(score, bestScore)) {
@@ -516,7 +516,9 @@ export function resolveClosestResolution<TResolution extends string>(params: {
return bestValue;
}
function parseResolutionRank(resolution: string | undefined): number | undefined {
function parseResolutionRank(
resolution: string | undefined,
): { value: number; unit: "K" | "P" } | undefined {
const match = resolution?.trim().match(/^(\d+(?:\.\d+)?)([kp])$/iu);
if (!match) {
return undefined;
@@ -525,7 +527,11 @@ function parseResolutionRank(resolution: string | undefined): number | undefined
if (!Number.isFinite(value)) {
return undefined;
}
return match[2]?.toUpperCase() === "K" ? value * 1000 : value;
const unit = match[2]?.toUpperCase() === "K" ? "K" : "P";
return {
value: unit === "K" ? value * 1000 : value,
unit,
};
}
export function normalizeDurationToClosestMax(
+2 -2
View File
@@ -150,7 +150,7 @@ export async function waitProviderOperationPollInterval(params: {
export async function pollProviderOperationJson<TPayload>(
params: {
url: string;
headers: Headers;
headers: Headers | (() => Headers);
deadline: ProviderOperationDeadline;
defaultTimeoutMs: number;
fetchFn: typeof fetch;
@@ -165,7 +165,7 @@ export async function pollProviderOperationJson<TPayload>(
for (let attempt = 0; attempt < params.maxAttempts; attempt += 1) {
const init = {
method: "GET",
headers: params.headers,
headers: typeof params.headers === "function" ? params.headers() : params.headers,
};
const timeoutMs = createProviderOperationTimeoutResolver({
deadline: params.deadline,
@@ -177,11 +177,12 @@ providerHttpMocks.fetchProviderDownloadResponseMock.mockImplementation(
providerHttpMocks.pollProviderOperationJsonMock.mockImplementation(
async (params: PollProviderOperationJsonParams) => {
for (let attempt = 0; attempt < params.maxAttempts; attempt += 1) {
const headers = typeof params.headers === "function" ? params.headers() : params.headers;
const response = await providerHttpMocks.fetchWithTimeoutMock(
params.url,
{
method: "GET",
headers: params.headers,
headers,
},
params.defaultTimeoutMs,
params.fetchFn,