@@ -305,63 +305,72 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
305305 return models
306306 }
307307
308- // Route the cache-miss fetch through the same single-flight coordinator refreshModels()
309- // uses (inFlightRefresh) , keyed on the same compound cacheKey. Without this, concurrent
308+ // Route the cache-miss fetch through dedupedFetch(), the same single-flight coordinator
309+ // refreshModels() uses , keyed on the same compound cacheKey. Without this, concurrent
310310 // getModels() calls for the same key each independently miss the cache and fire their own
311311 // redundant provider fetch, and a getModels() fetch racing a refreshModels() fetch for the
312312 // same key has no ordering guarantee -- whichever call's memoryCache.set() lands last wins,
313- // even if it started (and thus reflects) an earlier, staler request. Sharing the map means
314- // every caller for a given key -- get or refresh -- converges on one in-flight fetch.
315- if ( ! shouldSkipCache ) {
316- const existingRequest = inFlightRefresh . get ( cacheKey )
317- if ( existingRequest ) {
318- return existingRequest
319- }
320- }
321-
322- const fetchPromise = ( async ( ) : Promise < ModelRecord > => {
323- try {
324- const fetched = await fetchModelsFromProvider ( options )
325- const modelCount = Object . keys ( fetched ) . length
326-
327- // Only cache non-empty results so a failed API response doesn't get persisted
328- // as if the provider had no models. Auth-scoped providers skip caching entirely.
329- if ( modelCount > 0 ) {
330- // Clear the empty-response throttle for any non-empty response, including from
331- // auth-scoped providers that skip caching, so a later empty response is reported again.
332- reportedEmptyModelResponse . delete ( cacheKey )
333-
334- if ( ! shouldSkipCache ) {
335- memoryCache . set ( cacheKey , fetched )
336-
337- await writeModels ( cacheKey , fetched ) . catch ( ( err ) =>
338- console . error ( `[MODEL_CACHE] Error writing ${ cacheKey } models to file cache:` , err ) ,
339- )
340- }
341- } else {
342- captureModelCacheEmptyResponseOnce ( provider , cacheKey , {
343- context : "getModels" ,
344- hasExistingCache : false ,
345- } )
346- }
313+ // even if it started (and thus reflects) an earlier, staler request. Sharing dedupedFetch()
314+ // means every caller for a given key -- get or refresh -- converges on one underlying
315+ // provider fetch. Each entry point still applies its own success/failure contract on top
316+ // (see below) rather than returning the shared promise directly, so a fetch failure that
317+ // refreshModels() degrades to cached data doesn't surface as a silent stale result to
318+ // getModels(), and a fetch failure joined from refreshModels() still re-throws for
319+ // getModels() callers.
320+ const sharedFetch = shouldSkipCache ? fetchModelsFromProvider ( options ) : dedupedFetch ( cacheKey , options )
347321
348- return fetched
349- } catch ( error ) {
350- // Log the error and re-throw it so the caller can handle it (e.g., show a UI message).
351- console . error ( `[getModels] Failed to fetch models in modelCache for ${ provider } :` , error )
322+ try {
323+ const fetched = await sharedFetch
324+ const modelCount = Object . keys ( fetched ) . length
325+
326+ // Only cache non-empty results so a failed API response doesn't get persisted
327+ // as if the provider had no models. Auth-scoped providers skip caching entirely.
328+ if ( modelCount > 0 ) {
329+ // Clear the empty-response throttle for any non-empty response, including from
330+ // auth-scoped providers that skip caching, so a later empty response is reported again.
331+ reportedEmptyModelResponse . delete ( cacheKey )
352332
353- throw error // Re-throw the original error to be handled by the caller.
354- } finally {
355333 if ( ! shouldSkipCache ) {
356- inFlightRefresh . delete ( cacheKey )
334+ memoryCache . set ( cacheKey , fetched )
335+
336+ await writeModels ( cacheKey , fetched ) . catch ( ( err ) =>
337+ console . error ( `[MODEL_CACHE] Error writing ${ cacheKey } models to file cache:` , err ) ,
338+ )
357339 }
340+ } else {
341+ captureModelCacheEmptyResponseOnce ( provider , cacheKey , {
342+ context : "getModels" ,
343+ hasExistingCache : false ,
344+ } )
358345 }
359- } ) ( )
360346
361- if ( ! shouldSkipCache ) {
362- inFlightRefresh . set ( cacheKey , fetchPromise )
347+ return fetched
348+ } catch ( error ) {
349+ // Log the error and re-throw it so the caller can handle it (e.g., show a UI message).
350+ console . error ( `[getModels] Failed to fetch models in modelCache for ${ provider } :` , error )
351+
352+ throw error // Re-throw the original error to be handled by the caller.
353+ }
354+ }
355+
356+ /**
357+ * Single-flight the raw provider fetch for a cache key across getModels() and refreshModels().
358+ * Callers apply their own caching/degradation/telemetry behavior on top of the resolved value
359+ * or rejection -- this only ensures at most one fetchModelsFromProvider() call is in flight per
360+ * cache key at a time.
361+ */
362+ function dedupedFetch ( cacheKey : string , options : GetModelsOptions ) : Promise < ModelRecord > {
363+ const existingRequest = inFlightRefresh . get ( cacheKey )
364+ if ( existingRequest ) {
365+ return existingRequest
363366 }
364367
368+ const fetchPromise = fetchModelsFromProvider ( options ) . finally ( ( ) => {
369+ inFlightRefresh . delete ( cacheKey )
370+ } )
371+
372+ inFlightRefresh . set ( cacheKey , fetchPromise )
373+
365374 return fetchPromise
366375}
367376
@@ -380,30 +389,20 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
380389
381390 const shouldSkipCache = isAuthScopedProvider ( provider )
382391
383- // Check if there's already an in-flight refresh for this provider+url combination.
384- // This prevents race conditions where multiple concurrent refreshes might
385- // overwrite each other's results. Skip de-duplication for auth-scoped
386- // providers because two concurrent calls may carry different tokens
387- // (e.g., after a sign-out/sign-in within the same session) and we must
388- // not return the first caller's results to the second caller.
389- if ( ! shouldSkipCache ) {
390- const existingRequest = inFlightRefresh . get ( cacheKey )
391- if ( existingRequest ) {
392- return existingRequest
393- }
394- }
395-
396- // Create the refresh promise and track it.
392+ // De-duplication is skipped for auth-scoped providers because two concurrent calls may
393+ // carry different tokens (e.g., after a sign-out/sign-in within the same session) and we
394+ // must not return the first caller's results to the second caller.
397395 //
398- // The `finally` cleanup below runs only after the first `await` inside this async
399- // function yields, which cannot happen until the current synchronous run -- including
400- // the `inFlightRefresh.set(cacheKey, ...)` registration below -- has completed. So the
401- // entry is always present in the map before `finally` can delete it; the registration
402- // can never be lost to a microtask race even if the fetch resolves immediately.
396+ // Shares the same underlying fetch getModels() uses (see dedupedFetch) so a refreshModels()
397+ // call racing a getModels() cache-miss for the same key converges on one provider fetch --
398+ // but each function still applies its own success/failure contract on the result below
399+ // rather than sharing that promise's resolution/rejection wholesale.
400+ const sharedFetch = shouldSkipCache ? fetchModelsFromProvider ( options ) : dedupedFetch ( cacheKey , options )
401+
403402 const refreshPromise = ( async ( ) : Promise < ModelRecord > => {
404403 try {
405404 // Force fresh API fetch - skip getModelsFromCache() check
406- const models = await fetchModelsFromProvider ( options )
405+ const models = await sharedFetch
407406 const modelCount = Object . keys ( models ) . length
408407
409408 // Get existing cached data for comparison
@@ -443,19 +442,9 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
443442 return { }
444443 }
445444 return getModelsFromCache ( options ) || { }
446- } finally {
447- // Always clean up the in-flight tracking
448- if ( ! shouldSkipCache ) {
449- inFlightRefresh . delete ( cacheKey )
450- }
451445 }
452446 } ) ( )
453447
454- // Track the in-flight request (auth-scoped providers are excluded; see above).
455- if ( ! shouldSkipCache ) {
456- inFlightRefresh . set ( cacheKey , refreshPromise )
457- }
458-
459448 return refreshPromise
460449}
461450
0 commit comments