@@ -39,19 +39,69 @@ vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
3939 } ;
4040} ) ;
4141
42- function sseResponse ( lines : string [ ] ) : Response {
42+ function sseResponse ( lines : string [ ] , options ?: { releaseLock ?: ( ) => void } ) : Response {
4343 const 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}
56106
57107function stalledSseResponse ( line : string ) : Response {
@@ -150,6 +200,32 @@ describe("openrouter music generation provider", () => {
150200 expect ( release ) . toHaveBeenCalledOnce ( ) ;
151201 } ) ;
152202
203+ 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+
153229 it ( "decodes independently padded OpenRouter audio chunks" , async ( ) => {
154230 postJsonRequestMock . mockResolvedValue ( {
155231 response : sseResponse ( [
0 commit comments