Skip to content

fix(model-cache): dedupe concurrent fetches and throttle empty-response telemetry - #1072

Merged
edelauna merged 5 commits into
mainfrom
issue/830-modelcache
Aug 1, 2026
Merged

fix(model-cache): dedupe concurrent fetches and throttle empty-response telemetry#1072
edelauna merged 5 commits into
mainfrom
issue/830-modelcache

Conversation

@edelauna

@edelauna edelauna commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Related GitHub Issue

Refs: #830 (model-cache concurrency and empty-response telemetry — split from #835; independent of #1069, #1070, and #1071)

Description

getModels() had no in-flight deduplication. Two concurrent cache-miss calls for the same provider each fired their own provider fetch, and a getModels() fetch racing a refreshModels() fetch for the same key had no ordering guarantee — whichever call's cache write landed last won, even if it reflected an earlier, staler request.

getModels() now shares the same inFlightRefresh single-flight map that refreshModels() already used, keyed on the existing compound cache key from getCacheKey(). Every caller for a given key — get or refresh — converges on one in-flight fetch.

MODEL_CACHE_EMPTY_RESPONSE telemetry also fired on every empty response, so a persistently broken endpoint re-fired the event on every cache check. It now reports at most once per cache key (captureModelCacheEmptyResponseOnce) until a non-empty response re-arms it. This applies to auth-scoped providers too (zoo-gateway, kimi-code), even though they skip caching entirely.

To keep the throttle and in-flight map correctly isolated per credential for auth-scoped providers, zoo-gateway was added to URL_SCOPED_PROVIDERS and both zoo-gateway and kimi-code were added to KEY_SCOPED_PROVIDERS. This only changes the key used for the throttle/in-flight coordination — actual cache reads/writes for these providers stay fully skipped (shouldSkipCache is unchanged), so a sign-out/sign-in cycle still never serves one account's model list to another.

Non-empty cache-write behavior and the graceful-degradation behavior on a failed refresh are both unchanged.

Test Procedure

Ran the model-cache unit suite directly:

pnpm --filter roo-cline exec vitest run api/providers/fetchers/__tests__/modelCache.spec.ts

43 tests pass, including 12 new cases covering:

  • concurrent getModels() calls share one provider fetch
  • concurrent getModels() and refreshModels() share the same in-flight fetch
  • different endpoints/keys do not share a fetch
  • repeated empty results emit one telemetry event
  • a non-empty response re-arms reporting for a later empty response
  • auth-scoped providers (zoo-gateway) never share results or throttle state across credentials, session tokens, or gateway URLs

Pre-Submission Checklist

Documentation Updates

  • No documentation updates are required.

Summary by CodeRabbit

  • Improvements
    • Prevented duplicate model requests when multiple loads or refreshes occur simultaneously.
    • Improved recovery after empty model responses so later requests can succeed.
    • Strengthened isolation between provider connections, endpoints, and authentication sessions.
    • Improved diagnostics for repeated empty responses while reducing redundant telemetry activity.
    • Enhanced model handling for additional provider connection types.
    • Preserved independent request behavior for authentication-scoped connections.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf71921-88ab-44d8-aacf-e57067768047

📥 Commits

Reviewing files that changed from the base of the PR and between 7a4cd75 and 8ef0cf3.

📒 Files selected for processing (2)
  • src/api/providers/fetchers/__tests__/modelCache.spec.ts
  • src/api/providers/fetchers/modelCache.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/api/providers/fetchers/modelCache.ts

📝 Walkthrough

Walkthrough

The model cache now scopes provider identities, throttles repeated empty-response telemetry, and shares non-auth-scoped in-flight requests between getModels and refreshModels. Tests cover cache isolation, failure behavior, telemetry reset, and Zoo Gateway authentication isolation.

Changes

Model cache behavior

Layer / File(s) Summary
Scoped identity and telemetry throttling
src/api/providers/fetchers/modelCache.ts, src/api/providers/fetchers/__tests__/modelCache.spec.ts
Cache and telemetry keys include provider, endpoint, credentials, session token, and context where required. Empty-response telemetry is throttled per key and resets after a non-empty response.
Shared fetch and refresh flow
src/api/providers/fetchers/modelCache.ts, src/api/providers/fetchers/__tests__/modelCache.spec.ts
Non-auth-scoped getModels and refreshModels calls share in-flight requests. Auth-scoped providers remain independent. Tests cover result sharing, cleanup, errors, key isolation, and entry-point behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: awaiting-review

Suggested reviewers: taltas

Sequence Diagram(s)

sequenceDiagram
  participant getModels
  participant refreshModels
  participant dedupedFetch
  participant providerFetcher
  participant telemetry
  getModels->>dedupedFetch: request models
  refreshModels->>dedupedFetch: reuse request for same key
  dedupedFetch->>providerFetcher: fetch models once
  providerFetcher-->>dedupedFetch: empty or non-empty result
  dedupedFetch->>telemetry: report throttled empty result
  dedupedFetch-->>getModels: return result
  dedupedFetch-->>refreshModels: return result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: concurrent fetch deduplication and throttled empty-response telemetry.
Description check ✅ Passed The description covers the linked issue, implementation details, test procedure, scope, checklist, and documentation impact.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue/830-modelcache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@edelauna edelauna changed the title fix(model-cache): dedupe concurrent fetches and throttle empty-respon… fix(model-cache): dedupe concurrent fetches and throttle empty-response telemetry Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.37209% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/api/providers/fetchers/modelCache.ts 88.37% 1 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/api/providers/fetchers/__tests__/modelCache.spec.ts (2)

483-493: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bind the LiteLLM mock to the request arguments, not to call order.

mockResolvedValueOnce chaining ties each result to invocation order. The test claims endpoint isolation, so assert that each result matches its own baseUrl. An implementation change that reorders the two fetches would then fail instead of passing with swapped payloads.

♻️ Proposed refactor
-			mockGetLiteLLMModels.mockResolvedValueOnce(mockModelsA).mockResolvedValueOnce(mockModelsB)
+			mockGetLiteLLMModels.mockImplementation(async (_apiKey, baseUrl) =>
+				baseUrl === "http://server-a:4000" ? mockModelsA : mockModelsB,
+			)
 			mockGet.mockReturnValue(undefined)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/fetchers/__tests__/modelCache.spec.ts` around lines 483 -
493, Update the LiteLLM mock setup in the getModels concurrency test to return
mockModelsA or mockModelsB based on the request’s baseUrl rather than
mockResolvedValueOnce call order. Keep the parallel requests and assertions,
ensuring each result remains tied to its corresponding endpoint even if
invocation order changes.

884-923: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The comment states behavior the code does not implement.

getModels excludes auth-scoped providers from inFlightRefresh entirely (modelCache.ts lines 315-320 and 361-363). The map never keys auth-scoped identities. This test passes because no deduplication occurs at all for zooGateway, not because the key is compound. The same assertion would hold for two calls with an identical token.

Correct the comment, and assert the actual guarantee.

♻️ Proposed refactor
-		// The in-flight fetch map must key on the full compound identity for auth-scoped
-		// providers too, so a slow fetch for one account's session token can never resolve
-		// into a concurrent call carrying a different account's token.
+		// Auth-scoped providers bypass the in-flight map (see AUTH_SCOPED_PROVIDERS), so
+		// concurrent calls are never deduplicated. A slow fetch for one account's session
+		// token can therefore never resolve into a call carrying a different account's token.

Add a companion case that pins the no-dedup guarantee for an identical token:

it("never deduplicates concurrent zoo-gateway fetches, even for the same token", async () => {
	freshMockGetZooGatewayModels.mockResolvedValue({})

	await Promise.all([
		freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }),
		freshGetModels({ provider: providerIdentifiers.zooGateway, apiKey: "same-token" }),
	])

	expect(freshMockGetZooGatewayModels).toHaveBeenCalledTimes(2)
})

As per coding guidelines: "Use package-local unit tests for pure logic, parsing, state transitions, validation, serialization, request construction, retry decisions, and error handling."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/fetchers/__tests__/modelCache.spec.ts` around lines 884 -
923, The test’s comment incorrectly claims auth-scoped fetches are deduplicated
by compound identity, while getModels intentionally excludes them from
inFlightRefresh. Update the existing test comment and assertions to describe and
verify the actual no-deduplication guarantee, and add a companion case using
identical tokens that confirms concurrent zoo-gateway calls still invoke
freshMockGetZooGatewayModels twice.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/providers/fetchers/modelCache.ts`:
- Around line 308-320: Normalize joined inFlightRefresh promises separately in
getModels and refreshModels so each API preserves its own failure behavior:
getModels must propagate fetch errors and clean up the shared entry, while
refreshModels must fall back to existing cache or {}. Update the existingRequest
handling around getModels and refreshModels, without changing the shared cache
key or single-flight coordination.

---

Nitpick comments:
In `@src/api/providers/fetchers/__tests__/modelCache.spec.ts`:
- Around line 483-493: Update the LiteLLM mock setup in the getModels
concurrency test to return mockModelsA or mockModelsB based on the request’s
baseUrl rather than mockResolvedValueOnce call order. Keep the parallel requests
and assertions, ensuring each result remains tied to its corresponding endpoint
even if invocation order changes.
- Around line 884-923: The test’s comment incorrectly claims auth-scoped fetches
are deduplicated by compound identity, while getModels intentionally excludes
them from inFlightRefresh. Update the existing test comment and assertions to
describe and verify the actual no-deduplication guarantee, and add a companion
case using identical tokens that confirms concurrent zoo-gateway calls still
invoke freshMockGetZooGatewayModels twice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1eb0af9e-2c5f-4f50-b6bb-ac1c2549e4c4

📥 Commits

Reviewing files that changed from the base of the PR and between cd29243 and 33f7b04.

📒 Files selected for processing (2)
  • src/api/providers/fetchers/__tests__/modelCache.spec.ts
  • src/api/providers/fetchers/modelCache.ts

Comment thread src/api/providers/fetchers/modelCache.ts Outdated
@edelauna
edelauna marked this pull request as ready for review July 31, 2026 14:04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/api/providers/fetchers/modelCache.ts (1)

402-448: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Inline the refreshPromise IIFE.

The IIFE existed to register the promise in inFlightRefresh before awaiting it. dedupedFetch now owns that registration, so the wrapper adds a nesting level and no behavior. refreshModels is already async, so the body can run directly.

♻️ Proposed simplification
-	const refreshPromise = (async (): Promise<ModelRecord> => {
-		try {
-			// Force fresh API fetch - skip getModelsFromCache() check
-			const models = await sharedFetch
-			const modelCount = Object.keys(models).length
+	try {
+		// Force fresh API fetch - skip getModelsFromCache() check
+		const models = await sharedFetch
+		const modelCount = Object.keys(models).length
 
-			// Get existing cached data for comparison
-			const existingCache = shouldSkipCache ? undefined : getModelsFromCache(options)
-			const existingCount = existingCache ? Object.keys(existingCache).length : 0
+		// Get existing cached data for comparison
+		const existingCache = shouldSkipCache ? undefined : getModelsFromCache(options)
+		const existingCount = existingCache ? Object.keys(existingCache).length : 0
 
-			if (modelCount === 0) {
-				captureModelCacheEmptyResponseOnce(provider, cacheKey, {
-					context: "refreshModels",
-					hasExistingCache: existingCount > 0,
-					existingCacheSize: existingCount,
-				})
-				if (existingCount > 0) {
-					return existingCache!
-				} else {
-					return {}
-				}
-			}
+		if (modelCount === 0) {
+			captureModelCacheEmptyResponseOnce(provider, cacheKey, {
+				context: "refreshModels",
+				hasExistingCache: existingCount > 0,
+				existingCacheSize: existingCount,
+			})
+			return existingCount > 0 ? existingCache! : {}
+		}
 
-			reportedEmptyModelResponse.delete(cacheKey)
+		reportedEmptyModelResponse.delete(cacheKey)
 
-			if (!shouldSkipCache) {
-				memoryCache.set(cacheKey, models)
+		if (!shouldSkipCache) {
+			memoryCache.set(cacheKey, models)
 
-				await writeModels(cacheKey, models).catch((err) =>
-					console.error(`[refreshModels] Error writing ${cacheKey} models to disk:`, err),
-				)
-			}
+			await writeModels(cacheKey, models).catch((err) =>
+				console.error(`[refreshModels] Error writing ${cacheKey} models to disk:`, err),
+			)
+		}
 
-			return models
-		} catch (error) {
-			// ...
-			console.error(`[refreshModels] Failed to refresh ${cacheKey} models:`, error)
-			if (shouldSkipCache) {
-				return {}
-			}
-			return getModelsFromCache(options) || {}
-		}
-	})()
-
-	return refreshPromise
+		return models
+	} catch (error) {
+		// Log the error for debugging, then return existing cache if available (graceful degradation).
+		// For auth-scoped providers (zoo-gateway) we MUST NOT return cached models from a prior
+		// session, since they could belong to a different user -- return empty instead.
+		console.error(`[refreshModels] Failed to refresh ${cacheKey} models:`, error)
+		if (shouldSkipCache) {
+			return {}
+		}
+		return getModelsFromCache(options) || {}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/fetchers/modelCache.ts` around lines 402 - 448, Inline the
body of the refreshPromise IIFE directly into the surrounding refreshModels
flow, removing the unnecessary refreshPromise declaration and invocation.
Preserve the existing fetch, cache update, empty-response handling, error
fallback, and return behavior; rely on dedupedFetch for inFlightRefresh
registration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/api/providers/fetchers/modelCache.ts`:
- Around line 402-448: Inline the body of the refreshPromise IIFE directly into
the surrounding refreshModels flow, removing the unnecessary refreshPromise
declaration and invocation. Preserve the existing fetch, cache update,
empty-response handling, error fallback, and return behavior; rely on
dedupedFetch for inFlightRefresh registration.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: feeaad65-3eb1-4404-8730-e9f461acf00d

📥 Commits

Reviewing files that changed from the base of the PR and between 33f7b04 and 055e052.

📒 Files selected for processing (2)
  • src/api/providers/fetchers/__tests__/modelCache.spec.ts
  • src/api/providers/fetchers/modelCache.ts

@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Jul 31, 2026
@github-actions github-actions Bot removed the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 1, 2026
@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 1, 2026
Comment thread src/api/providers/fetchers/__tests__/modelCache.spec.ts Outdated

@JamesRobert20 JamesRobert20 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks solid and nearly ready to merge. One change requested:

Please fix the misleading comment in the zoo-gateway auth-isolation test (modelCache.spec.ts). It currently says isolation comes from compound keys in the in-flight map, but auth-scoped providers bypass dedupedFetch entirely. Rewrite the comment to match that, and optionally add a same-token case that expects two fetcher calls.

Everything else LGTM.

@github-actions github-actions Bot added awaiting-author PR is waiting for the author to address requested changes and removed awaiting-review PR changes are ready and waiting for maintainer re-review labels Aug 1, 2026
@github-actions github-actions Bot removed the awaiting-author PR is waiting for the author to address requested changes label Aug 1, 2026
@edelauna
edelauna requested a review from JamesRobert20 August 1, 2026 11:46
@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 1, 2026

@JamesRobert20 JamesRobert20 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good

@edelauna
edelauna added this pull request to the merge queue Aug 1, 2026
Merged via the queue into main with commit 2ecb300 Aug 1, 2026
17 checks passed
@edelauna
edelauna deleted the issue/830-modelcache branch August 1, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-review PR changes are ready and waiting for maintainer re-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants