@@ -44,6 +44,11 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema)
4444// deduplicate each other's in-flight refreshes.
4545const inFlightRefresh = new Map < string , Promise < ModelRecord > > ( )
4646
47+ // Same single-flight guard for auth-scoped providers. Without this, two concurrent getModels()
48+ // calls (e.g. two panels opening on startup) both miss the session cache and each fire their own
49+ // provider fetch. Keyed on the same compound cache key as authSessionCache.
50+ const inFlightAuthScopedFetch = new Map < string , Promise < ModelRecord > > ( )
51+
4752// Cache keys (see getCacheKey) for which we've already reported an empty model response this
4853// session. A persistently-empty endpoint (e.g. misconfigured server) would otherwise re-fire this
4954// event on every cache refresh; gate it to at most once per distinct provider+server+key identity
@@ -237,44 +242,62 @@ async function resolveAuthScopedModels(
237242 return existing . models
238243 }
239244
240- try {
241- const fetched = await fetchAuthScopedModelsFromProvider (
242- assertAuthScopedGetModelsOptions ( options ) ,
243- existing ?. etag ,
244- )
245+ // Coalesce concurrent fetches for the same session identity so two panels
246+ // opening simultaneously don't each fire their own provider request.
247+ const existingFlight = inFlightAuthScopedFetch . get ( cacheKey )
248+ if ( existingFlight && ! forceRefresh ) {
249+ return existingFlight
250+ }
251+
252+ const fetchPromise = ( async ( ) => {
253+ try {
254+ const fetched = await fetchAuthScopedModelsFromProvider (
255+ assertAuthScopedGetModelsOptions ( options ) ,
256+ existing ?. etag ,
257+ )
258+
259+ if ( fetched . notModified ) {
260+ // Re-read from the Map: a concurrent sign-out could have cleared
261+ // the entry between when we captured `existing` and now.
262+ const current = getAuthSessionEntry ( cacheKey )
263+ if ( current && Object . keys ( current . models ) . length > 0 ) {
264+ touchAuthSessionEntry ( cacheKey , current )
265+ reportedEmptyModelResponse . delete ( cacheKey )
266+ return current . models
267+ }
268+ } else {
269+ const modelCount = Object . keys ( fetched . models ) . length
270+ if ( modelCount > 0 ) {
271+ setAuthSessionEntry ( cacheKey , fetched . models , fetched . etag )
272+ reportedEmptyModelResponse . delete ( cacheKey )
273+ return fetched . models
274+ }
275+
276+ captureModelCacheEmptyResponseOnce ( provider , cacheKey , {
277+ context : forceRefresh ? "refreshModels" : "getModels" ,
278+ hasExistingCache : Boolean ( existing && Object . keys ( existing . models ) . length > 0 ) ,
279+ ...( existing ? { existingCacheSize : Object . keys ( existing . models ) . length } : { } ) ,
280+ } )
281+ }
245282
246- if ( fetched . notModified ) {
247283 if ( existing && Object . keys ( existing . models ) . length > 0 ) {
248- touchAuthSessionEntry ( cacheKey , existing )
249- reportedEmptyModelResponse . delete ( cacheKey )
250284 return existing . models
251285 }
252- } else {
253- const modelCount = Object . keys ( fetched . models ) . length
254- if ( modelCount > 0 ) {
255- setAuthSessionEntry ( cacheKey , fetched . models , fetched . etag )
256- reportedEmptyModelResponse . delete ( cacheKey )
257- return fetched . models
258- }
259-
260- captureModelCacheEmptyResponseOnce ( provider , cacheKey , {
261- context : forceRefresh ? "refreshModels" : "getModels" ,
262- hasExistingCache : Boolean ( existing && Object . keys ( existing . models ) . length > 0 ) ,
263- ...( existing ? { existingCacheSize : Object . keys ( existing . models ) . length } : { } ) ,
264- } )
265- }
266286
267- if ( existing && Object . keys ( existing . models ) . length > 0 ) {
268- return existing . models
287+ return fetched . models
288+ } catch ( error ) {
289+ if ( existing && Object . keys ( existing . models ) . length > 0 ) {
290+ return existing . models
291+ }
292+ throw error
269293 }
294+ } ) ( )
270295
271- return fetched . models
272- } catch ( error ) {
273- if ( existing && Object . keys ( existing . models ) . length > 0 ) {
274- return existing . models
275- }
276- throw error
277- }
296+ // Wrap cleanup in the promise itself so the stored value IS what callers await —
297+ // a detached .finally() would produce an unhandled rejection when the fetch fails.
298+ const fetchWithCleanup = fetchPromise . finally ( ( ) => inFlightAuthScopedFetch . delete ( cacheKey ) )
299+ inFlightAuthScopedFetch . set ( cacheKey , fetchWithCleanup )
300+ return fetchWithCleanup
278301}
279302
280303// Memoize derived digests so the deliberately-structureless KDF runs at most once per
@@ -389,6 +412,12 @@ async function readModels(cacheKey: string): Promise<ModelRecord | undefined> {
389412async function fetchModelsFromProvider ( options : GetModelsOptions ) : Promise < ModelRecord > {
390413 const { provider } = options
391414
415+ if ( isAuthScopedProvider ( provider ) ) {
416+ throw new Error (
417+ `fetchModelsFromProvider must not be called for auth-scoped provider "${ provider } " — use resolveAuthScopedModels instead` ,
418+ )
419+ }
420+
392421 let models : ModelRecord
393422
394423 switch ( provider ) {
@@ -432,21 +461,6 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
432461 case providerIdentifiers . moonshot :
433462 models = await getMoonshotModels ( options . baseUrl , options . apiKey )
434463 break
435- case providerIdentifiers . zooGateway : {
436- const result = await getZooGatewayModels ( {
437- zooSessionToken : options . apiKey ,
438- zooGatewayBaseUrl : options . baseUrl ,
439- } )
440- if ( result . kind === "not_modified" ) {
441- models = { }
442- break
443- }
444- models = result . models
445- break
446- }
447- case providerIdentifiers . kimiCode :
448- models = await getKimiCodeModels ( options . apiKey )
449- break
450464 default : {
451465 // Ensures router is exhaustively checked if RouterName is a strict union.
452466 const exhaustiveCheck : never = provider
0 commit comments