feat(pixverse): add api region selection

This commit is contained in:
Vincent Koc
2026-05-27 06:23:57 +02:00
parent c18370574e
commit b3083de4f2
8 changed files with 175 additions and 32 deletions

View File

@@ -9,15 +9,16 @@ read_when:
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.
| Property | Value |
| --------------- | --------------------------------------------------------------------- |
| Provider id | `pixverse` |
| Plugin | bundled, `enabledByDefault: true` |
| Auth env var | `PIXVERSE_API_KEY` |
| Onboarding flag | `--auth-choice pixverse-api-key` |
| Direct CLI flag | `--pixverse-api-key <key>` |
| API | PixVerse Platform API v2 (`video_id` submission plus result polling) |
| Default model | `pixverse/v6` |
| Property | Value |
| ------------------ | -------------------------------------------------------------------- |
| Provider id | `pixverse` |
| Plugin | bundled, `enabledByDefault: true` |
| Auth env var | `PIXVERSE_API_KEY` |
| Onboarding flag | `--auth-choice pixverse-api-key` |
| Direct CLI flag | `--pixverse-api-key <key>` |
| API | PixVerse Platform API v2 (`video_id` submission plus result polling) |
| Default model | `pixverse/v6` |
| Default API region | International |
## Getting started
@@ -41,19 +42,19 @@ OpenClaw ships a bundled `pixverse` provider for hosted PixVerse video generatio
The provider exposes PixVerse generation models through OpenClaw's shared video tool.
| Mode | Models | Reference input |
| -------------- | ---------------------- | ----------------------- |
| Text-to-video | `v6` (default), `c1` | None |
| Image-to-video | `v6` (default), `c1` | 1 local or remote image |
| Mode | Models | Reference input |
| -------------- | -------------------- | ----------------------- |
| Text-to-video | `v6` (default), `c1` | None |
| Image-to-video | `v6` (default), `c1` | 1 local or remote image |
Local image references are uploaded to PixVerse before the image-to-video request. Remote image URLs are passed through the PixVerse image upload endpoint as `image_url`.
| Option | Supported values |
| ------------- | ----------------------------------------------------- |
| Duration | 1-15 seconds |
| Resolution | `360P`, `540P`, `720P`, `1080P` |
| Aspect ratio | `16:9`, `4:3`, `1:1`, `3:4`, `9:16`, `2:3`, `3:2`, `21:9` for text-to-video |
| Generated audio | `audio: true` |
| Option | Supported values |
| --------------- | --------------------------------------------------------------------------- |
| Duration | 1-15 seconds |
| Resolution | `360P`, `540P`, `720P`, `1080P` |
| Aspect ratio | `16:9`, `4:3`, `1:1`, `3:4`, `9:16`, `2:3`, `3:2`, `21:9` for text-to-video |
| Generated audio | `audio: true` |
<Note>
PixVerse image template generation is not exposed through `image_generate` yet. That API is template-id driven, while OpenClaw's shared image-generation contract does not currently have a PixVerse-specific typed option bag.
@@ -63,14 +64,14 @@ PixVerse image template generation is not exposed through `image_generate` yet.
The video provider accepts these optional provider-specific keys:
| Option | Type | Effect |
| ------------------------------ | -------- | -------------------------------------- |
| `seed` | number | Deterministic seed when supported |
| `negativePrompt` / `negative_prompt` | string | Negative prompt |
| `quality` | string | PixVerse quality such as `720p` |
| `motionMode` / `motion_mode` | string | Image-to-video motion mode |
| `cameraMovement` / `camera_movement` | string | PixVerse camera movement preset |
| `templateId` / `template_id` | number | Activated PixVerse template id |
| Option | Type | Effect |
| ------------------------------------ | ------ | --------------------------------- |
| `seed` | number | Deterministic seed when supported |
| `negativePrompt` / `negative_prompt` | string | Negative prompt |
| `quality` | string | PixVerse quality such as `720p` |
| `motionMode` / `motion_mode` | string | Image-to-video motion mode |
| `cameraMovement` / `camera_movement` | string | PixVerse camera movement preset |
| `templateId` / `template_id` | number | Activated PixVerse template id |
## Configuration
@@ -89,8 +90,27 @@ The video provider accepts these optional provider-specific keys:
## Advanced configuration
<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:
```json5
{
models: {
providers: {
pixverse: {
region: "cn", // "international" or "cn"
},
},
},
}
```
</Accordion>
<Accordion title="Custom base URL">
Set `models.providers.pixverse.baseUrl` only when routing through a trusted compatible proxy.
`baseUrl` takes precedence over `region`.
```json5
{
@@ -103,6 +123,7 @@ The video provider accepts these optional provider-specific keys:
},
}
```
</Accordion>
<Accordion title="Task polling">

View File

@@ -329,4 +329,91 @@ describe("pixverse video generation provider", () => {
"https://proxy.example/openapi/v2/video/result/123",
);
});
it("uses the configured CN API region", 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: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "cn endpoint",
cfg: {
models: {
providers: {
pixverse: {
region: "cn",
},
},
},
} as never,
});
expect(firstPostJsonRequest().url).toBe(
"https://app-api.pixverseai.cn/openapi/v2/video/text/generate",
);
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
"https://app-api.pixverseai.cn/openapi/v2/video/result/123",
);
});
it("prefers configured baseUrl over API region", 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: 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: {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
region: "cn",
},
},
},
} as never,
});
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
"https://proxy.example/openapi/v2/video/result/123",
);
});
});

View File

@@ -22,7 +22,12 @@ import type {
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_PIXVERSE_BASE_URL = "https://app-api.pixverse.ai/openapi/v2";
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;
@@ -42,6 +47,8 @@ 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;
@@ -68,10 +75,31 @@ type PixVerseVideoResultResponse = {
};
function resolvePixVerseBaseUrl(req: VideoGenerationRequest): string {
return (
normalizeOptionalString(req.cfg?.models?.providers?.pixverse?.baseUrl) ??
DEFAULT_PIXVERSE_BASE_URL
);
const provider = req.cfg?.models?.providers?.pixverse;
const configuredBaseUrl = normalizeOptionalString(provider?.baseUrl);
if (configuredBaseUrl) {
return configuredBaseUrl;
}
const region = resolvePixVerseApiRegion(provider?.region);
return PIXVERSE_BASE_URL_BY_REGION[region];
}
function resolvePixVerseApiRegion(value: unknown): PixVerseApiRegion {
const region = normalizeOptionalString(value)?.toLowerCase();
switch (region) {
case "cn":
case "china":
case "mainland":
case "pai":
return "cn";
case "global":
case "intl":
case "international":
case undefined:
return DEFAULT_PIXVERSE_REGION;
default:
throw new Error(`Unsupported PixVerse API region "${region}". Use "international" or "cn".`);
}
}
function normalizePixVerseModel(model: string | undefined): string {

View File

@@ -394,6 +394,7 @@ const TARGET_KEYS = [
"models.providers.*.contextWindow",
"models.providers.*.contextTokens",
"models.providers.*.maxTokens",
"models.providers.*.region",
"models.providers.*.headers",
"models.providers.*.models",
"agents",

View File

@@ -957,6 +957,8 @@ export const FIELD_HELP: Record<string, string> = {
"Default maximum output token budget applied to models under this provider when a model entry does not set maxTokens.",
"models.providers.*.timeoutSeconds":
"Optional per-provider model request timeout in seconds. For built-in providers, this can be set as a standalone overlay. For custom providers, set it alongside the provider baseUrl and models. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.",
"models.providers.*.region":
"Optional provider deployment/API region interpreted by providers that expose regional endpoints. Use provider docs for supported values; baseUrl overrides usually take precedence when both are set.",
"models.providers.*.injectNumCtxForOpenAICompat":
"Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.",
"models.providers.*.params":

View File

@@ -577,6 +577,7 @@ export const FIELD_LABELS: Record<string, string> = {
"models.providers.*.contextTokens": "Model Provider Context Tokens",
"models.providers.*.maxTokens": "Model Provider Max Tokens",
"models.providers.*.timeoutSeconds": "Model Provider Request Timeout",
"models.providers.*.region": "Model Provider Region",
"models.providers.*.injectNumCtxForOpenAICompat": "Model Provider Inject num_ctx (OpenAI Compat)",
"models.providers.*.params": "Model Provider Runtime Parameters",
"models.providers.*.headers": "Model Provider Headers",

View File

@@ -171,6 +171,8 @@ export type ModelProviderConfig = {
contextTokens?: number;
maxTokens?: number;
timeoutSeconds?: number;
/** Optional provider deployment/API region used by provider plugins that expose regional endpoints. */
region?: string;
injectNumCtxForOpenAICompat?: boolean;
/** Provider-specific runtime parameters interpreted by provider plugins. */
params?: Record<string, unknown>;

View File

@@ -462,6 +462,7 @@ const ModelProviderSchema = z
contextTokens: z.number().int().positive().optional(),
maxTokens: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
region: z.string().min(1).optional(),
injectNumCtxForOpenAICompat: z.boolean().optional(),
params: z.record(z.string(), z.unknown()).optional(),
agentRuntime: ModelAgentRuntimePolicySchema,