mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
chore(pixverse): publish as external plugin
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+20
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
Generated
+3
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user