@@ -300,6 +300,73 @@ describe("empty cache protection", () => {
300300 expect ( mockSet ) . toHaveBeenCalledWith ( "openrouter" , mockModels )
301301 } )
302302
303+ it ( "reuses an in-flight fetch for concurrent getModels() calls to the same provider" , async ( ) => {
304+ // Finding #11: getModels() previously had no de-duplication at all -- two concurrent
305+ // cache-miss calls would each independently fire their own provider fetch. It now
306+ // shares the same inFlightRefresh single-flight coordinator refreshModels() uses.
307+ const mockModels = {
308+ "openrouter/model" : {
309+ maxTokens : 8192 ,
310+ contextWindow : 128000 ,
311+ supportsPromptCache : false ,
312+ description : "OpenRouter model" ,
313+ } ,
314+ }
315+
316+ let resolvePromise : ( value : typeof mockModels ) => void
317+ const delayedPromise = new Promise < typeof mockModels > ( ( resolve ) => {
318+ resolvePromise = resolve
319+ } )
320+ mockGetOpenRouterModels . mockReturnValue ( delayedPromise )
321+ mockGet . mockReturnValue ( undefined )
322+
323+ const promise1 = getModels ( { provider : "openrouter" } )
324+ const promise2 = getModels ( { provider : "openrouter" } )
325+
326+ expect ( mockGetOpenRouterModels ) . toHaveBeenCalledTimes ( 1 )
327+
328+ resolvePromise ! ( mockModels )
329+
330+ const [ result1 , result2 ] = await Promise . all ( [ promise1 , promise2 ] )
331+ expect ( result1 ) . toEqual ( mockModels )
332+ expect ( result2 ) . toEqual ( mockModels )
333+ } )
334+
335+ it ( "shares a single in-flight fetch between getModels() and refreshModels() for the same key" , async ( ) => {
336+ // The two entry points must converge on the same coordinator so a getModels() cache
337+ // miss racing a concurrent refreshModels() call can't produce two unordered writes to
338+ // the same cache key -- whichever fetch happened to finish last previously won,
339+ // regardless of which one was actually more current.
340+ const mockModels = {
341+ "openrouter/model" : {
342+ maxTokens : 8192 ,
343+ contextWindow : 128000 ,
344+ supportsPromptCache : false ,
345+ description : "OpenRouter model" ,
346+ } ,
347+ }
348+
349+ let resolvePromise : ( value : typeof mockModels ) => void
350+ const delayedPromise = new Promise < typeof mockModels > ( ( resolve ) => {
351+ resolvePromise = resolve
352+ } )
353+ mockGetOpenRouterModels . mockReturnValue ( delayedPromise )
354+ mockGet . mockReturnValue ( undefined )
355+
356+ const { refreshModels } = await import ( "../modelCache" )
357+
358+ const getPromise = getModels ( { provider : "openrouter" } )
359+ const refreshPromise = refreshModels ( { provider : "openrouter" } )
360+
361+ expect ( mockGetOpenRouterModels ) . toHaveBeenCalledTimes ( 1 )
362+
363+ resolvePromise ! ( mockModels )
364+
365+ const [ getResult , refreshResult ] = await Promise . all ( [ getPromise , refreshPromise ] )
366+ expect ( getResult ) . toEqual ( mockModels )
367+ expect ( refreshResult ) . toEqual ( mockModels )
368+ } )
369+
303370 it ( "re-arms the empty-response throttle after a non-empty response from an auth-scoped provider" , async ( ) => {
304371 // zoo-gateway is auth-scoped and skips caching entirely, but a non-empty response
305372 // must still clear the empty-response throttle so a later empty response is reported again.
@@ -525,6 +592,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => {
525592 let freshRefreshModels : ModelCacheModule [ "refreshModels" ]
526593 let freshMockGetOpenRouterModels : Mock < typeof getOpenRouterModels >
527594 let freshMockGetLiteLLMModels : Mock < typeof getLiteLLMModels >
595+ let freshMockGetZooGatewayModels : Mock < typeof getZooGatewayModels >
528596
529597 beforeEach ( async ( ) => {
530598 // The empty-response throttle is deliberately module-level, persistent state (once per
@@ -535,11 +603,13 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => {
535603 const modelCacheModule : ModelCacheModule = await import ( "../modelCache" )
536604 const openRouterModule = await import ( "../openrouter" )
537605 const liteLLMModule = await import ( "../litellm" )
606+ const zooGatewayModule = await import ( "../zoo-gateway" )
538607
539608 freshGetModels = modelCacheModule . getModels
540609 freshRefreshModels = modelCacheModule . refreshModels
541610 freshMockGetOpenRouterModels = openRouterModule . getOpenRouterModels as Mock < typeof getOpenRouterModels >
542611 freshMockGetLiteLLMModels = liteLLMModule . getLiteLLMModels as Mock < typeof getLiteLLMModels >
612+ freshMockGetZooGatewayModels = zooGatewayModule . getZooGatewayModels as Mock < typeof getZooGatewayModels >
543613
544614 const NodeCacheModule = await import ( "node-cache" )
545615 const MockedNodeCache = vi . mocked ( NodeCacheModule . default )
@@ -632,6 +702,37 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => {
632702
633703 expect ( FreshTelemetryService . instance . captureEvent ) . toHaveBeenCalledTimes ( 2 )
634704 } )
705+
706+ it ( "throttles zoo-gateway independently per session token, even though caching itself is skipped" , async ( ) => {
707+ // zoo-gateway is auth-scoped (see AUTH_SCOPED_PROVIDERS) and never persists to the
708+ // memory/disk cache, but the empty-response throttle must still discriminate by
709+ // identity: a sign-out/sign-in cycle to a different account carries a different
710+ // session token (apiKey) on the same gateway URL, and must not have its empty-response
711+ // signal suppressed by the previous account's throttle entry.
712+ const { TelemetryService : FreshTelemetryService } = await import ( "@roo-code/telemetry" )
713+
714+ freshMockGetZooGatewayModels . mockResolvedValue ( { } )
715+
716+ await freshGetModels ( { provider : "zoo-gateway" , apiKey : "account-a-token" } )
717+ await freshGetModels ( { provider : "zoo-gateway" , apiKey : "account-a-token" } )
718+ expect ( FreshTelemetryService . instance . captureEvent ) . toHaveBeenCalledTimes ( 1 )
719+
720+ await freshGetModels ( { provider : "zoo-gateway" , apiKey : "account-b-token" } )
721+ expect ( FreshTelemetryService . instance . captureEvent ) . toHaveBeenCalledTimes ( 2 )
722+ } )
723+
724+ it ( "throttles zoo-gateway independently per gateway baseUrl" , async ( ) => {
725+ // Same session token, different gateway endpoint (e.g. staging vs. production) --
726+ // must also be treated as a distinct identity for throttle purposes.
727+ const { TelemetryService : FreshTelemetryService } = await import ( "@roo-code/telemetry" )
728+
729+ freshMockGetZooGatewayModels . mockResolvedValue ( { } )
730+
731+ await freshGetModels ( { provider : "zoo-gateway" , apiKey : "token" , baseUrl : "https://gateway-a.example.com" } )
732+ await freshGetModels ( { provider : "zoo-gateway" , apiKey : "token" , baseUrl : "https://gateway-b.example.com" } )
733+
734+ expect ( FreshTelemetryService . instance . captureEvent ) . toHaveBeenCalledTimes ( 2 )
735+ } )
635736} )
636737
637738describe ( "key-scoped cache key derivation" , ( ) => {
0 commit comments