




















@@ -39,19 +39,69 @@ vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
3939};
4040});
414142-function sseResponse(lines: string[]): Response {
42+function sseResponse(lines: string[], options?: { releaseLock?: () => void }): Response {
4343const encoder = new TextEncoder();
44-return new Response(
45-new ReadableStream({
46-start(controller) {
47-for (const line of lines) {
48-controller.enqueue(encoder.encode(line));
49-}
50-controller.close();
51-},
52-}),
53-{ status: 200, headers: { "content-type": "text/event-stream" } },
54-);
44+if (!options?.releaseLock) {
45+return new Response(
46+new ReadableStream({
47+start(controller) {
48+for (const line of lines) {
49+controller.enqueue(encoder.encode(line));
50+}
51+controller.close();
52+},
53+}),
54+{ status: 200, headers: { "content-type": "text/event-stream" } },
55+);
56+}
57+58+const chunks: Array<ReadableStreamReadResult<Uint8Array>> = lines.map((line) => ({
59+done: false,
60+value: encoder.encode(line),
61+}));
62+chunks.push({ done: true, value: undefined });
63+const reader = {
64+read: async () => chunks.shift() ?? { done: true, value: undefined },
65+cancel: async () => undefined,
66+releaseLock: options.releaseLock,
67+} as ReadableStreamDefaultReader<Uint8Array>;
68+69+return {
70+ok: true,
71+status: 200,
72+headers: new Headers({ "content-type": "text/event-stream" }),
73+body: {
74+getReader: () => reader,
75+},
76+} as Response;
77+}
78+79+function sseResponseLines(params: {
80+audio?: string;
81+transcript?: string;
82+done?: boolean;
83+}): string[] {
84+const lines: string[] = [];
85+if (params.audio || params.transcript) {
86+lines.push(
87+`data: ${JSON.stringify({
88+ choices: [
89+ {
90+ delta: {
91+ audio: {
92+ ...(params.audio ? { data: params.audio } : {}),
93+ ...(params.transcript ? { transcript: params.transcript } : {}),
94+ },
95+ },
96+ },
97+ ],
98+ })}\n`,
99+);
100+}
101+if (params.done) {
102+lines.push("data: [DONE]\n");
103+}
104+return lines;
55105}
5610657107function stalledSseResponse(line: string): Response {
@@ -150,6 +200,32 @@ describe("openrouter music generation provider", () => {
150200expect(release).toHaveBeenCalledOnce();
151201});
152202203+it("releases OpenRouter audio stream readers after completion", async () => {
204+const releaseLock = vi.fn();
205+postJsonRequestMock.mockResolvedValue({
206+response: sseResponse(
207+sseResponseLines({
208+audio: Buffer.from("wav-bytes").toString("base64"),
209+done: true,
210+}),
211+{ releaseLock },
212+),
213+release: vi.fn(async () => {}),
214+});
215+216+await expect(
217+buildOpenRouterMusicGenerationProvider().generateMusic({
218+provider: "openrouter",
219+model: "google/lyria-3-pro-preview",
220+prompt: "release stream reader",
221+cfg: {},
222+}),
223+).resolves.toMatchObject({
224+tracks: [{ mimeType: "audio/wav" }],
225+});
226+expect(releaseLock).toHaveBeenCalledTimes(1);
227+});
228+153229it("decodes independently padded OpenRouter audio chunks", async () => {
154230postJsonRequestMock.mockResolvedValue({
155231response: sseResponse([
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。