Skip to content

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

Draft
edelauna wants to merge 1 commit into
mainfrom
issue/830-modelcache
Draft

fix(model-cache): dedupe concurrent fetches and throttle empty-response telemetry#1072
edelauna wants to merge 1 commit 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

  • Bug Fixes
    • Improved model loading when multiple requests or refreshes occur simultaneously.
    • Prevented empty model results from being cached, allowing subsequent attempts to recover correctly.
    • Improved isolation of model results across different providers, servers, credentials, and sessions.
    • Improved handling of Zoo Gateway and Kimi Code model requests.
    • Reduced redundant reporting for repeated empty model responses.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e301abf9-d1a6-4b7e-9ac3-d30cac360627

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)

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

✅ All modified and coverable lines are covered by tests.

📢 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 on lines +308 to +320
// Route the cache-miss fetch through the same single-flight coordinator refreshModels()
// uses (inFlightRefresh), keyed on the same compound cacheKey. Without this, concurrent
// getModels() calls for the same key each independently miss the cache and fire their own
// redundant provider fetch, and a getModels() fetch racing a refreshModels() fetch for the
// same key has no ordering guarantee -- whichever call's memoryCache.set() lands last wins,
// even if it started (and thus reflects) an earlier, staler request. Sharing the map means
// every caller for a given key -- get or refresh -- converges on one in-flight fetch.
if (!shouldSkipCache) {
const existingRequest = inFlightRefresh.get(cacheKey)
if (existingRequest) {
return existingRequest
}
}

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate refreshModels callers and check for try/catch or .catch() handling.
set -euo pipefail

rg -n -C 8 --type=ts -g '!**/__tests__/**' '\brefreshModels\s*\('

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
fd -a 'modelCache.ts|.*modelCache.*' . | sed 's#^\./##'

echo
echo "== ModelCache outline =="
ast-grep outline src/api/providers/fetchers/modelCache.ts --view compact 2>/dev/null || true

echo
echo "== Relevant modelCache sections =="
sed -n '1,120p' src/api/providers/fetchers/modelCache.ts
printf '\n---\n'
sed -n '260,480p' src/api/providers/fetchers/modelCache.ts

echo
echo "== Broader refreshModels mentions =="
rg -n --type=ts -g '!**/__tests__/**' '\brefreshModels\s*\b|\binFlightRefresh\b' . || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 20272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== modelCache.spec outline =="
ast-grep outline src/api/providers/fetchers/__tests__/modelCache.spec.ts --view compact 2>/dev/null || true

echo
echo "== modelCache.spec relevant sections =="
wc -l src/api/providers/fetchers/__tests__/inFlightRefresh getModels join getModels join refreshModels getModels refreshModels inFlightRefresh getModels getModels getModels
sed -n '1,220p' src/api/providers/fetchers/__tests__/modelCache.spec.ts
printf '\n---\n'
sed -n '220,480p' src/api/providers/fetchers/__tests__/modelCache.spec.ts
printf '\n---\n'
sed -n '480,720p' src/api/providers/fetchers/__tests__/modelCache.spec.ts

echo
echo "== Search in-flight behavior in tests =="
rg -n --type=ts -C 6 'inFlightRefresh|concurrent|same key|re-throw|degrade|cached fetch error|graceful|failed|join|same flight|getModels|refreshModels' src/api/providers/fetchers/__tests__/modelCache.spec.ts || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 777


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== modelCache.spec outline =="
ast-grep outline src/api/providers/fetchers/__tests__/modelCache.spec.ts --view compact 2>/dev/null || true

echo
echo "== modelCache.spec relevant sections =="
wc -l src/api/providers/fetchers/__tests__/modelCache.spec.ts
sed -n '1,220p' src/api/providers/fetchers/__tests__/modelCache.spec.ts
printf '\n---\n'
sed -n '220,480p' src/api/providers/fetchers/__tests__/modelCache.spec.ts
printf '\n---\n'
sed -n '480,720p' src/api/providers/fetchers/__tests__/modelCache.spec.ts

echo
echo "== Search in-flight behavior in tests =="
rg -n --type=ts -C 6 'inFlightRefresh|concurrent|same key|re-throw|degrade|cached fetch error|graceful|failed|join|same flight|getModels|refreshModels' src/api/providers/fetchers/__tests__/modelCache.spec.ts || true

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Caller files =="
for f in webview-ui/src/components/settings/providers/Unbound.tsx webview-ui/src/components/settings/providers/LiteLLM.tsx webview-ui/src/components/settings/providers/Poe.tsx webview-ui/src/components/settings/providers/Ollama.tsx webview-ui/src/components/settings/providers/Moonshot.tsx webview-ui/src/components/settings/providers/Requesty.tsx webview-ui/src/components/settings/providers/OpenCodeGo.tsx; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    nl -ba "$f" | sed -n '1,175p'
  fi
done

echo
echo "== refreshModels call sites with handler context =="
rg -n -C 5 --type tsx --type ts -g 'webview-ui/src/components/settings/providers/**' 'refreshModels\s*\(' webview-ui/src/components/settings/providers || true

echo
echo "== initializeModelCacheRefresh caller context =="
sed -n '460,520p' src/api/providers/fetchers/modelCache.ts
rg -n -C 4 'initializeModelCacheRefresh\s*\(' . --type ts --type tsx || true

echo
echo "== Behavioral probe for Promise rejection propagation on shared promise =="
node - <<'JS'
let inFlightRefresh = new Map()

function makeGetModels() {
  const promise = Promise.reject(new Error("getModels fetch failed"))
  inFlightRefresh.set("k", promise)
  return promise
}

function makeRefreshModels() {
  const existingRequest = inFlightRefresh.get("k")
  if (existingRequest) {
    return existingRequest
  }
}

const get = makeGetModels()
const refresh = makeRefreshModels()

for (const [name, promise] of [["get", get], ["refresh", refresh]]) {
  promise.then(
    (value) => console.log(name, "resolved", value),
    (error) => console.log(name, "rejected", error.message)
  )
}

process.off("unhandledRejection", () => {})
// Keep Node 2+ seconds to observe unhandled rejection if no handler existed after joining.
setTimeout(() => {
  console.log(JSON.stringify({ getRejectionPropagates: get.status === "rejected", refreshRejectionPropagates: refresh.status === "rejected" }), null, 2)
}, 50)
JS

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 283


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Caller files =="
for f in webview-ui/src/components/settings/providers/Unbound.tsx webview-ui/src/components/settings/providers/LiteLLM.tsx webview-ui/src/components/settings/providers/Poe.tsx webview-ui/src/components/settings/providers/Ollama.tsx webview-ui/src/components/settings/providers/Moonshot.tsx webview-ui/src/components/settings/providers/Requesty.tsx webview-ui/src/components/settings/providers/OpenCodeGo.tsx; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    awk '{printf "%6d\t%s\n", NR, $0}' "$f" | sed -n '1,185p'
  fi
done

echo
echo "== refreshModels call sites with handler context =="
rg -n -C 5 --type tsx --type ts -g 'webview-ui/src/components/settings/providers/**' 'refreshModels\s*\(' webview-ui/src/components/settings/providers || true

echo
echo "== initializeModelCacheRefresh caller context =="
awk '{printf "%6d\t%s\n", NR, $0}' src/api/providers/fetchers/modelCache.ts | sed -n '460,520p'
rg -n -C 4 'initializeModelCacheRefresh\s*\(' . --type ts --type tsx || true

echo
echo "== Behavioral probe for Promise rejection propagation on shared promise =="
node - <<'JS'
const inFlightRefresh = new Map()

let getError, refreshError
const unhandled = []
process.on("unhandledRejection", (rej) => unhandled.push(rej.message))
process.on("rejectionHandled", (promise) => {});

function makeGetModels() {
  const promise = Promise.reject(new Error("getModels fetch failed"))
  inFlightRefresh.set("k", promise)
  return promise
}

function makeRefreshModels() {
  const existingRequest = inFlightRefresh.get("k")
  if (existingRequest) return existingRequest
}

const get = makeGetModels()
const refresh = makeRefreshModels()

get.then(
  () => {},
  (e) => { getError = e && e.message }
)
refresh.then(
  () => {},
  (e) => { refreshError = e && e.message }
)

process.off("unhandledRejection", () => {})
// The joined promise is not unhandled if any call site attaches a rejection handler.
setTimeout(() => {
  console.log(JSON.stringify({ get: getError, refresh: refreshError, unhandled }, null, 2))
}, 50)
JS

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 49822


Normalize the joined in-flight promise at each entry point.

getModels rereleases and deletes the shared inFlightRefresh promise on fetch failure, while refreshModels degrades to existing cache or {}. A refreshModels call joining a failed getModels request now rejects instead of returning degraded cache, and a getModels call joining a failed refreshModels request now hides the fetch error. Wrap the joined promise according to each API's contract, or split the in-flight map.

🤖 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 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant