1515 * limitations under the License.
1616 */
1717
18- import { expect } from 'chai' ;
18+ import chai , { AssertionError } from 'chai' ;
19+ import chaiAsPromised from 'chai-as-promised' ;
20+ chai . use ( chaiAsPromised ) ;
21+ const expect = chai . expect ;
22+
1923import {
2024 BackendType ,
2125 Content ,
@@ -29,7 +33,17 @@ import {
2933 URLRetrievalStatus ,
3034 getGenerativeModel
3135} from '../src' ;
32- import { testConfigs , TOKEN_COUNT_DELTA } from './constants' ;
36+ import {
37+ cheapestModel ,
38+ defaultAIInstance ,
39+ defaultGenerativeModel ,
40+ testConfigs ,
41+ TOKEN_COUNT_DELTA
42+ } from './constants' ;
43+ import {
44+ TIMEOUT_EXPIRED_MESSAGE
45+ } from '../src/requests/request' ;
46+ import { isNode } from '@firebase/util' ;
3347
3448describe ( 'Generate Content' , function ( ) {
3549 this . timeout ( 20_000 ) ;
@@ -370,4 +384,154 @@ describe('Generate Content', function () {
370384 } ) ;
371385 } ) ;
372386 } ) ;
387+
388+ describe ( 'Request Options' , async ( ) => {
389+ const defaultAbortReason = isNode ( )
390+ ? 'This operation was aborted'
391+ : 'signal is aborted without reason' ;
392+ describe ( 'unary' , async ( ) => {
393+ it ( 'timeout cancels request' , async ( ) => {
394+ await expect (
395+ defaultGenerativeModel . generateContent ( 'hello' , { timeout : 100 } )
396+ ) . to . be . rejectedWith ( DOMException , TIMEOUT_EXPIRED_MESSAGE ) ;
397+ } ) ;
398+
399+ it ( 'long timeout does not cancel request' , async ( ) => {
400+ const result = await defaultGenerativeModel . generateContent ( 'hello' , {
401+ timeout : 50_000
402+ } ) ;
403+ expect ( result . response . text ( ) . length ) . to . be . greaterThan ( 0 ) ;
404+ } ) ;
405+
406+ it ( 'abort signal with no reason causes request to throw AbortError' , async ( ) => {
407+ const abortController = new AbortController ( ) ;
408+ const responsePromise = defaultGenerativeModel . generateContent (
409+ 'hello' ,
410+ { signal : abortController . signal }
411+ ) ;
412+ abortController . abort ( ) ;
413+ await expect ( responsePromise )
414+ . to . be . rejectedWith ( DOMException , defaultAbortReason )
415+ . and . eventually . have . property ( 'name' , 'AbortError' ) ;
416+ } ) ;
417+
418+ it ( 'abort signal with string reason causes request to throw reason string' , async ( ) => {
419+ const abortController = new AbortController ( ) ;
420+ const responsePromise = defaultGenerativeModel . generateContent (
421+ 'hello' ,
422+ { signal : abortController . signal }
423+ ) ;
424+ const reason = 'Cancelled' ;
425+ abortController . abort ( reason ) ;
426+ await expect ( responsePromise ) . to . be . rejectedWith ( reason ) ;
427+ } ) ;
428+
429+ it ( 'abort signal with error reason causes request to throw reason error' , async ( ) => {
430+ const abortController = new AbortController ( ) ;
431+ const responsePromise = defaultGenerativeModel . generateContent (
432+ 'hello' ,
433+ { signal : abortController . signal }
434+ ) ;
435+ abortController . abort ( new Error ( 'Cancelled' ) ) ;
436+ // `fetch()` will reject with the exact object we passed to `abort()`. Since we throw a generic
437+ // Error, we cannot differentiate between this error and other generic fetch errors, which
438+ // we wrap in an AIError.
439+ await expect ( responsePromise )
440+ . to . be . rejectedWith ( Error , 'Cancelled' )
441+ . and . eventually . have . property ( 'name' , 'FirebaseError' ) ;
442+ } ) ;
443+ } ) ;
444+
445+ describe ( 'streaming' , async ( ) => {
446+ it ( 'timeout cancels initial request' , async ( ) => {
447+ await expect (
448+ defaultGenerativeModel . generateContent ( 'hello' , { timeout : 50 } )
449+ ) . to . be . rejectedWith ( DOMException , TIMEOUT_EXPIRED_MESSAGE ) ;
450+ } ) ;
451+
452+ it ( 'timeout does not cancel request once streaming has begun' , async ( ) => {
453+ const generativeModel = getGenerativeModel ( defaultAIInstance , {
454+ model : cheapestModel
455+ } ) ;
456+ // Setting a timeout that will be in the interval between the stream starting and ending.
457+ // Since the timeout will expire once the stream has begun, it should have already been
458+ // cleared, and so it shouldn't abort the stream.
459+ const { stream, response } =
460+ await generativeModel . generateContentStream (
461+ 'tell me a short story with 200 words.' ,
462+ { timeout : 1_000 }
463+ ) ;
464+
465+ // We should be able to get through the entire stream without an error being thrown
466+ // from the async generator.
467+ for await ( const chunk of stream ) {
468+ expect ( chunk . text ( ) . length ) . to . be . greaterThan ( 0 ) ;
469+ }
470+
471+ expect ( ( await response ) . text ( ) . length ) . to . be . greaterThan ( 0 ) ;
472+ } ) ;
473+
474+ it ( 'abort signal without reason should cancel stream with default abort reason' , async ( ) => {
475+ const abortController = new AbortController ( ) ;
476+ const generativeModel = getGenerativeModel ( defaultAIInstance , {
477+ model : cheapestModel
478+ } ) ;
479+ const { stream, response } =
480+ await generativeModel . generateContentStream (
481+ 'tell me a short story with 200 words.' ,
482+ { signal : abortController . signal }
483+ ) ;
484+
485+ // As soon as the initial request resolves and the stream starts, abort the stream.
486+ abortController . abort ( ) ;
487+
488+ try {
489+ for await ( const _ of stream ) {
490+ expect . fail ( 'Expected stream to throw an error' ) ;
491+ }
492+ expect . fail ( 'Expected stream to throw an error' ) ;
493+ } catch ( err ) {
494+ if ( ( err as Error ) instanceof AssertionError ) {
495+ throw err ;
496+ }
497+ expect ( err ) . to . be . instanceof ( DOMException ) ;
498+ expect ( ( err as Error ) . name ) . to . equal ( 'AbortError' ) ;
499+ expect ( ( err as Error ) . message ) . to . equal ( defaultAbortReason ) ;
500+ }
501+
502+ await expect ( response )
503+ . to . be . rejectedWith ( DOMException , defaultAbortReason )
504+ . and . to . eventually . have . property ( 'name' , 'AbortError' ) ;
505+ } ) ;
506+
507+ it ( 'abort signal with reason string should cancel stream with string abort reason' , async ( ) => {
508+ const abortController = new AbortController ( ) ;
509+ const generativeModel = getGenerativeModel ( defaultAIInstance , {
510+ model : cheapestModel
511+ } ) ;
512+ const { stream, response } =
513+ await generativeModel . generateContentStream (
514+ 'tell me a short story with 200 words.' ,
515+ { signal : abortController . signal }
516+ ) ;
517+
518+ // As soon as the initial request resolves and the stream starts, abort the stream.
519+ abortController . abort ( 'Cancelled' ) ;
520+
521+ try {
522+ for await ( const _ of stream ) {
523+ expect . fail ( 'Expected stream to throw an error' ) ;
524+ }
525+ expect . fail ( 'Expected stream to throw an error' ) ;
526+ } catch ( err ) {
527+ if ( ( err as Error ) instanceof AssertionError ) {
528+ throw err ;
529+ }
530+ expect ( err ) . to . equal ( 'Cancelled' ) ;
531+ }
532+
533+ await expect ( response ) . to . be . rejectedWith ( 'Cancelled' ) ;
534+ } ) ;
535+ } ) ;
536+ } ) ;
373537} ) ;
0 commit comments