Skip to content

Commit e2c3825

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 21d07f0 + ebb72ba commit e2c3825

34 files changed

Lines changed: 4464 additions & 611 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai
1212
- QA/packaging: stop packaged CLI startup and completion cache generation from reading repo-only QA scenario markdown by routing QA command registration through a narrow facade. (#64648) Thanks @obviyus.
1313
- Control UI/webchat: persist agent-run TTS audio replies into webchat history before finalization so tool-generated audio reaches webchat clients again. (#63514) thanks @bittoby
1414
- macOS/Talk Mode: after granting microphone permission on first enable, continue starting Talk Mode instead of requiring a second toggle. (#62459) Thanks @ggarber.
15+
- OpenAI/Codex OAuth: stop rewriting the upstream authorize URL scopes so new Codex sign-ins do not fail with `invalid_scope` before returning an authorization code. (#64713) Thanks @fuller-stack-dev.
16+
- Google/Veo: stop sending the unsupported `numberOfVideos` request field so Gemini Developer API Veo runs do not fail before OpenClaw can complete the intended Google video generation path. (#64723) thanks @velvet-shark
1517

1618
## 2026.4.10
1719

@@ -156,6 +158,7 @@ Docs: https://docs.openclaw.ai
156158
- QA/lab: add character-vibes evaluation reports with model selection and parallel runs so live QA can compare candidate behavior faster.
157159
- Plugins/provider-auth: let provider manifests declare `providerAuthAliases` so provider variants can share env vars, auth profiles, config-backed auth, and API-key onboarding choices without core-specific wiring.
158160
- iOS: pin release versioning to an explicit CalVer in `apps/ios/version.json`, keep TestFlight iteration on the same short version until maintainers intentionally promote the next gateway version, and add the documented `pnpm ios:version:pin -- --from-gateway` workflow for release trains. (#63001) Thanks @ngutman.
161+
- Tools/video_generate: extend the tool and the Plugin SDK with `providerOptions` (vendor-specific options forwarded as a JSON object), `inputAudios` / `audioRef` / `audioRefs` reference audio inputs, per-asset semantic role hints (`imageRoles` / `videoRoles` / `audioRoles`) using a typed `VideoGenerationAssetRole` union, a new `"adaptive"` aspect-ratio sentinel, and `maxInputAudios` provider capability declarations. Providers opt into `providerOptions` by declaring a typed `capabilities.providerOptions` schema (`{ seed: "number", draft: "boolean", ... }`); unknown keys and type mismatches cause the runtime fallback loop to skip the candidate with a visible warning and an `attempts` entry, so vendor-specific options never silently reach the wrong provider. Also raises the in-tool image input cap to 9 and updates the docs table to list all new parameters. (#61987) Thanks @xieyongliang.
159162

160163
### Fixes
161164

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
ee16273fa5ad8c5408e9dad8d96fde86dfa666ef8eb44840b78135814ff97173 plugin-sdk-api-baseline.json
2-
2bd0d5edf23e6a889d6bedb74d0d06411dd7750dac6ebf24971c789f8a69253a plugin-sdk-api-baseline.jsonl
1+
7a9bb7a5e4b243e2123af94301ba363d57eddab2baa6378d16cd37a1cb8a55f7 plugin-sdk-api-baseline.json
2+
2bdca027d5fda72399479569927cd34d18b56b242e4b12ac45e7c2352e551c77 plugin-sdk-api-baseline.jsonl

docs/tools/video-generation.md

Lines changed: 98 additions & 77 deletions
Large diffs are not rendered by default.

extensions/byteplus/video-generation-provider.test.ts

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,35 @@ beforeAll(async () => {
1414

1515
installProviderHttpMockCleanup();
1616

17+
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
18+
postJsonRequestMock.mockResolvedValue({
19+
response: {
20+
json: async () => ({
21+
id: "task_123",
22+
}),
23+
},
24+
release: vi.fn(async () => {}),
25+
});
26+
fetchWithTimeoutMock
27+
.mockResolvedValueOnce({
28+
json: async () => ({
29+
id: "task_123",
30+
status: "succeeded",
31+
content: {
32+
video_url: "https://example.com/byteplus.mp4",
33+
},
34+
model: params?.model ?? "seedance-1-0-lite-t2v-250428",
35+
}),
36+
})
37+
.mockResolvedValueOnce({
38+
headers: new Headers({ "content-type": "video/mp4" }),
39+
arrayBuffer: async () => Buffer.from("mp4-bytes"),
40+
});
41+
}
42+
1743
describe("byteplus video generation provider", () => {
1844
it("creates a content-generation task, polls, and downloads the video", async () => {
19-
postJsonRequestMock.mockResolvedValue({
20-
response: {
21-
json: async () => ({
22-
id: "task_123",
23-
}),
24-
},
25-
release: vi.fn(async () => {}),
26-
});
27-
fetchWithTimeoutMock
28-
.mockResolvedValueOnce({
29-
json: async () => ({
30-
id: "task_123",
31-
status: "succeeded",
32-
content: {
33-
video_url: "https://example.com/byteplus.mp4",
34-
},
35-
model: "seedance-1-0-lite-t2v-250428",
36-
}),
37-
})
38-
.mockResolvedValueOnce({
39-
headers: new Headers({ "content-type": "video/mp4" }),
40-
arrayBuffer: async () => Buffer.from("mp4-bytes"),
41-
});
45+
mockSuccessfulBytePlusTask();
4246

4347
const provider = buildBytePlusVideoGenerationProvider();
4448
const result = await provider.generateVideo({
@@ -60,4 +64,57 @@ describe("byteplus video generation provider", () => {
6064
}),
6165
);
6266
});
67+
68+
it("switches t2v image requests to i2v models and lowercases resolution", async () => {
69+
mockSuccessfulBytePlusTask({ model: "seedance-1-0-lite-i2v-250428" });
70+
71+
const provider = buildBytePlusVideoGenerationProvider();
72+
await provider.generateVideo({
73+
provider: "byteplus",
74+
model: "seedance-1-0-lite-t2v-250428",
75+
prompt: "Animate this still image",
76+
resolution: "720P",
77+
inputImages: [{ url: "https://example.com/first-frame.png" }],
78+
cfg: {},
79+
});
80+
81+
const request = postJsonRequestMock.mock.calls[0]?.[0] as { body?: Record<string, unknown> };
82+
expect(request.body).toMatchObject({
83+
model: "seedance-1-0-lite-i2v-250428",
84+
resolution: "720p",
85+
content: [
86+
{ type: "text", text: "Animate this still image" },
87+
{
88+
type: "image_url",
89+
image_url: { url: "https://example.com/first-frame.png" },
90+
role: "first_frame",
91+
},
92+
],
93+
});
94+
});
95+
96+
it("maps declared providerOptions into the request body", async () => {
97+
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
98+
99+
const provider = buildBytePlusVideoGenerationProvider();
100+
await provider.generateVideo({
101+
provider: "byteplus",
102+
model: "seedance-1-0-pro-250528",
103+
prompt: "A cinematic lobster montage",
104+
providerOptions: {
105+
seed: 42,
106+
draft: true,
107+
camera_fixed: false,
108+
},
109+
cfg: {},
110+
});
111+
112+
const request = postJsonRequestMock.mock.calls[0]?.[0] as { body?: Record<string, unknown> };
113+
expect(request.body).toMatchObject({
114+
model: "seedance-1-0-pro-250528",
115+
seed: 42,
116+
resolution: "480p",
117+
camera_fixed: false,
118+
});
119+
});
63120
});

extensions/byteplus/video-generation-provider.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
141141
agentDir,
142142
}),
143143
capabilities: {
144+
providerOptions: {
145+
seed: "number",
146+
draft: "boolean",
147+
camera_fixed: "boolean",
148+
},
144149
generate: {
145150
maxVideos: 1,
146151
maxDurationSeconds: 12,
@@ -191,6 +196,17 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
191196
capability: "video",
192197
transport: "http",
193198
});
199+
// Seedance 1.0 has separate T2V and I2V model IDs (e.g. seedance-1-0-lite-t2v-250428 vs
200+
// seedance-1-0-lite-i2v-250428). When input images are provided with a T2V model, auto-
201+
// switch to the corresponding I2V variant so the API does not reject with task_type mismatch.
202+
// 1.5 Pro uses a single model ID for both modes and is unaffected by this substitution.
203+
const hasInputImages = (req.inputImages?.length ?? 0) > 0;
204+
const requestedModel = normalizeOptionalString(req.model) || DEFAULT_BYTEPLUS_VIDEO_MODEL;
205+
const resolvedModel =
206+
hasInputImages && requestedModel.includes("-t2v-")
207+
? requestedModel.replace("-t2v-", "-i2v-")
208+
: requestedModel;
209+
194210
const content: Array<Record<string, unknown>> = [{ type: "text", text: req.prompt }];
195211
const imageUrl = resolveBytePlusImageUrl(req);
196212
if (imageUrl) {
@@ -201,15 +217,18 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
201217
});
202218
}
203219
const body: Record<string, unknown> = {
204-
model: normalizeOptionalString(req.model) || DEFAULT_BYTEPLUS_VIDEO_MODEL,
220+
model: resolvedModel,
205221
content,
206222
};
207223
const aspectRatio = normalizeOptionalString(req.aspectRatio);
208224
if (aspectRatio) {
209225
body.ratio = aspectRatio;
210226
}
211-
if (req.resolution) {
212-
body.resolution = req.resolution;
227+
// Seedance API requires lowercase resolution values (e.g. "480p", "720p"); uppercase
228+
// variants like "480P" are rejected with InvalidParameter.
229+
const resolution = normalizeOptionalString(req.resolution)?.toLowerCase();
230+
if (resolution) {
231+
body.resolution = resolution;
213232
}
214233
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
215234
body.duration = Math.max(1, Math.round(req.durationSeconds));
@@ -221,6 +240,23 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
221240
body.watermark = req.watermark;
222241
}
223242

243+
// Forward declared providerOptions: seed, draft, camerafixed.
244+
// draft=true forces 480p resolution for faster generation.
245+
const opts = req.providerOptions ?? {};
246+
const seed = typeof opts.seed === "number" ? opts.seed : undefined;
247+
const draft = opts.draft === true;
248+
// Official JSON body field is camera_fixed (with underscore).
249+
const cameraFixed = typeof opts.camera_fixed === "boolean" ? opts.camera_fixed : undefined;
250+
if (seed != null) {
251+
body.seed = seed;
252+
}
253+
if (draft && !body.resolution) {
254+
body.resolution = "480p";
255+
}
256+
if (cameraFixed != null) {
257+
body.camera_fixed = cameraFixed;
258+
}
259+
224260
const { response, release } = await postJsonRequest({
225261
url: `${baseUrl}/contents/generations/tasks`,
226262
headers,
@@ -255,7 +291,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
255291
});
256292
return {
257293
videos: [video],
258-
model: completed.model ?? req.model ?? DEFAULT_BYTEPLUS_VIDEO_MODEL,
294+
model: completed.model ?? resolvedModel,
259295
metadata: {
260296
taskId,
261297
status: completed.status,
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const resolveFeishuRuntimeAccountMock = vi.hoisted(() => vi.fn());
4+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
5+
const createReplyPrefixContextMock = vi.hoisted(() => vi.fn());
6+
const createCommentTypingReactionLifecycleMock = vi.hoisted(() => vi.fn());
7+
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
8+
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
9+
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
10+
11+
vi.mock("./accounts.js", () => ({
12+
resolveFeishuRuntimeAccount: resolveFeishuRuntimeAccountMock,
13+
}));
14+
15+
vi.mock("./client.js", () => ({
16+
createFeishuClient: createFeishuClientMock,
17+
}));
18+
19+
vi.mock("./comment-dispatcher-runtime-api.js", () => ({
20+
createReplyPrefixContext: createReplyPrefixContextMock,
21+
}));
22+
23+
vi.mock("./comment-reaction.js", () => ({
24+
createCommentTypingReactionLifecycle: createCommentTypingReactionLifecycleMock,
25+
}));
26+
27+
vi.mock("./drive.js", () => ({
28+
deliverCommentThreadText: deliverCommentThreadTextMock,
29+
}));
30+
31+
vi.mock("./runtime.js", () => ({
32+
getFeishuRuntime: getFeishuRuntimeMock,
33+
}));
34+
35+
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
36+
37+
describe("createFeishuCommentReplyDispatcher", () => {
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
resolveFeishuRuntimeAccountMock.mockReturnValue({
41+
accountId: "main",
42+
appId: "app_id",
43+
appSecret: "app_secret",
44+
domain: "feishu",
45+
config: {},
46+
});
47+
createFeishuClientMock.mockReturnValue({});
48+
createReplyPrefixContextMock.mockReturnValue({
49+
responsePrefix: undefined,
50+
responsePrefixContextProvider: undefined,
51+
});
52+
deliverCommentThreadTextMock.mockResolvedValue({
53+
delivery_mode: "reply_comment",
54+
reply_id: "reply_1",
55+
});
56+
createCommentTypingReactionLifecycleMock.mockReturnValue({
57+
start: vi.fn(async () => {}),
58+
cleanup: vi.fn(async () => {}),
59+
});
60+
createReplyDispatcherWithTypingMock.mockImplementation(() => ({
61+
dispatcher: {
62+
markComplete: vi.fn(),
63+
waitForIdle: vi.fn(async () => {}),
64+
},
65+
replyOptions: {},
66+
markDispatchIdle: vi.fn(),
67+
markRunComplete: vi.fn(),
68+
}));
69+
getFeishuRuntimeMock.mockReturnValue({
70+
channel: {
71+
text: {
72+
resolveTextChunkLimit: vi.fn(() => 4000),
73+
resolveChunkMode: vi.fn(() => "line"),
74+
chunkTextWithMode: vi.fn((text: string) => [text]),
75+
},
76+
reply: {
77+
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
78+
resolveHumanDelayConfig: vi.fn(() => undefined),
79+
},
80+
},
81+
});
82+
});
83+
84+
it("sends final comment text without waiting for typing cleanup", async () => {
85+
let resolveCleanup: (() => void) | undefined;
86+
const cleanup = vi.fn(
87+
() =>
88+
new Promise<void>((resolve) => {
89+
resolveCleanup = resolve;
90+
}),
91+
);
92+
createCommentTypingReactionLifecycleMock.mockReturnValue({
93+
start: vi.fn(async () => {}),
94+
cleanup,
95+
});
96+
97+
createFeishuCommentReplyDispatcher({
98+
cfg: {} as never,
99+
agentId: "main",
100+
runtime: { log: vi.fn(), error: vi.fn() } as never,
101+
accountId: "main",
102+
fileToken: "doc_token_1",
103+
fileType: "docx",
104+
commentId: "comment_1",
105+
replyId: "reply_1",
106+
isWholeComment: false,
107+
});
108+
109+
const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0];
110+
const deliverPromise = options.deliver({ text: "hello world" }, { kind: "final" });
111+
const status = await Promise.race([
112+
deliverPromise.then(() => "done"),
113+
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 0)),
114+
]);
115+
116+
expect(status).toBe("done");
117+
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
118+
expect.anything(),
119+
expect.objectContaining({
120+
file_token: "doc_token_1",
121+
file_type: "docx",
122+
comment_id: "comment_1",
123+
content: "hello world",
124+
is_whole_comment: false,
125+
}),
126+
);
127+
expect(cleanup).not.toHaveBeenCalled();
128+
129+
options.onCleanup?.();
130+
expect(cleanup).toHaveBeenCalledTimes(1);
131+
132+
resolveCleanup?.();
133+
await deliverPromise;
134+
});
135+
136+
it("starts the typing reaction from dispatcher onReplyStart", async () => {
137+
const start = vi.fn(async () => {});
138+
createCommentTypingReactionLifecycleMock.mockReturnValue({
139+
start,
140+
cleanup: vi.fn(async () => {}),
141+
});
142+
143+
createFeishuCommentReplyDispatcher({
144+
cfg: {} as never,
145+
agentId: "main",
146+
runtime: { log: vi.fn(), error: vi.fn() } as never,
147+
accountId: "main",
148+
fileToken: "doc_token_1",
149+
fileType: "docx",
150+
commentId: "comment_1",
151+
replyId: "reply_1",
152+
isWholeComment: false,
153+
});
154+
155+
const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0];
156+
await options.onReplyStart?.();
157+
158+
expect(start).toHaveBeenCalledTimes(1);
159+
});
160+
});

0 commit comments

Comments
 (0)