Problem
When Zoo needs model metadata for a provider, the request goes through the model cache and out to the provider's catalog endpoint over plain HTTP. Nothing about that outbound request is cancellable: a caller that gives up (a timeout, a cancelled task, a closed preview) cannot stop the HTTP request, and the cache keeps the shared in-flight entry alive until the request settles. The fetcher layer under src/api/providers/fetchers/ accepts no cancellation input, and the gap spans every fetcher routed through the shared single-flight, not just OpenRouter.
Observed in version v3.82.1
Evidence
- The catalog refresh is a shared single-flight.
inFlightRefresh is a module-level map keyed per provider cache key (src/api/providers/fetchers/modelCache.ts:45), and dedupedFetch hands every joiner the one shared promise and deletes the entry only in a .finally() on the fetch promise (src/api/providers/fetchers/modelCache.ts:363-380). A waiter that stops waiting does not affect the entry or the request; only settle removes it.
- The OpenRouter catalog fetch is a bare
axios.get with no request timeout and no signal (src/api/providers/fetchers/openrouter.ts:102). Against a hung or slow endpoint the request has no bound.
- No caller-supplied cancellation reaches the catalog fetch path. The shared options type has no signal field (
src/shared/api.ts:171-201), so neither the model-cache entry points (getModels at src/api/providers/fetchers/modelCache.ts:297, refreshModels at :391) nor any fetcher dispatched by fetchModelsFromProvider (src/api/providers/fetchers/modelCache.ts:224-284) can receive one. Whatever cancellation a caller applies at its own layer, the shared cached fetch and the provider HTTP call keep running.
- The fetchers behind the single-flight do not share one HTTP client, so a fix cannot assume one cancellation mechanism: axios calls with no timeout and no signal (
openrouter.ts:102, vercel-ai-gateway.ts:63, ollama.ts:85, requesty.ts:21, unbound.ts:17), an axios call with its own timeout (litellm.ts:32), fetch calls with their own AbortController and timeout that never see a caller's intent (deepseek.ts:27-33, moonshot.ts:29-35), and fetchers that delegate to an SDK whose model-list calls expose no cancellation parameter (poe.ts:8, and lmstudio.ts:71-75, the WebSocket LMStudioClient behind a bare axios connection probe at lmstudio.ts:69).
Consequence
A hung or slow catalog endpoint leaves the shared in-flight entry pending until the request settles. Callers that stop waiting leave the HTTP request running; repeated triggers against a misconfigured endpoint accumulate stale fetches behind the single-flight map. Previews can advertise stale model capabilities because a superseded fetch is still the one in flight. A timed-out catalog fetch can outlive every caller that wanted it.
Proposed approach
Only the fetcher layer: src/api/providers/fetchers/, with one named exception: the GetModelsOptions type in src/shared/api.ts, the options object the model-cache entry points already accept. Thread an optional caller AbortSignal through the model-cache entry points into dedupedFetch, which tracks its waiters and passes the cancellation to whichever fetcher fetchModelsFromProvider dispatches to. The network-level abort uses whatever cancellation the fetcher's HTTP client already exposes: axios signal, fetch signal, or an SDK abort option. No new cancellation plumbing is invented for clients that expose none; for those fetchers, aborting still releases the shared entry and stops every waiter from waiting. Where waiters share one fetch, the underlying request aborts only when the last waiter leaves. Every fetch routed through the single-flight gets a bounded timeout, so a hung endpoint cannot hold the entry pending indefinitely.
This issue is confined to the fetcher layer. Caller-side bounded waits and cancellation checks are outside it; that half shipped with #1505. The per-model OpenRouter endpoints path (getOpenRouterModelEndpoints, called directly from modelEndpointCache.ts:59) bypasses the inFlightRefresh single-flight entirely, so it is outside this issue too. The auth-scoped fetch path (AUTH_SCOPED_PROVIDERS at modelCache.ts:106-109, routed past dedupedFetch at modelCache.ts:321 and :405) likewise bypasses the inFlightRefresh single-flight entirely, so it too is outside this issue.
Acceptance criteria
getModels and refreshModels accept an optional AbortSignal, and a fetch dispatched through the single-flight receives the caller's cancellation.
- A caller that aborts stops waiting at the moment of abort: its promise rejects instead of waiting on the pending request to settle.
- When the last waiter is gone, the in-flight entry for that cache key is released, and a caller arriving after the release starts a fresh request instead of joining the aborted one.
- With two or more waiters sharing one fetch, one waiter aborting leaves the underlying request running; the request is aborted when, and only when, the last waiter leaves.
- Where the fetcher's HTTP client natively supports cancellation, the last-waiter abort cancels the request at the network level; where the client exposes no cancellation surface, the release and stop-waiting criteria still hold for that fetcher.
- A catalog fetch started through the single-flight carries a bounded timeout, so a hung endpoint cannot keep the entry pending past that bound.
- Tests hold the HTTP layer pending, abort the caller, and assert the behavior above: request aborted where the client supports it, entry released, waiters stopped, and a later joiner served by a fresh request.
Problem
When Zoo needs model metadata for a provider, the request goes through the model cache and out to the provider's catalog endpoint over plain HTTP. Nothing about that outbound request is cancellable: a caller that gives up (a timeout, a cancelled task, a closed preview) cannot stop the HTTP request, and the cache keeps the shared in-flight entry alive until the request settles. The fetcher layer under
src/api/providers/fetchers/accepts no cancellation input, and the gap spans every fetcher routed through the shared single-flight, not just OpenRouter.Observed in version v3.82.1
Evidence
inFlightRefreshis a module-level map keyed per provider cache key (src/api/providers/fetchers/modelCache.ts:45), anddedupedFetchhands every joiner the one shared promise and deletes the entry only in a.finally()on the fetch promise (src/api/providers/fetchers/modelCache.ts:363-380). A waiter that stops waiting does not affect the entry or the request; only settle removes it.axios.getwith no request timeout and no signal (src/api/providers/fetchers/openrouter.ts:102). Against a hung or slow endpoint the request has no bound.src/shared/api.ts:171-201), so neither the model-cache entry points (getModelsatsrc/api/providers/fetchers/modelCache.ts:297,refreshModelsat:391) nor any fetcher dispatched byfetchModelsFromProvider(src/api/providers/fetchers/modelCache.ts:224-284) can receive one. Whatever cancellation a caller applies at its own layer, the shared cached fetch and the provider HTTP call keep running.openrouter.ts:102,vercel-ai-gateway.ts:63,ollama.ts:85,requesty.ts:21,unbound.ts:17), an axios call with its own timeout (litellm.ts:32),fetchcalls with their ownAbortControllerand timeout that never see a caller's intent (deepseek.ts:27-33,moonshot.ts:29-35), and fetchers that delegate to an SDK whose model-list calls expose no cancellation parameter (poe.ts:8, andlmstudio.ts:71-75, the WebSocketLMStudioClientbehind a bare axios connection probe atlmstudio.ts:69).Consequence
A hung or slow catalog endpoint leaves the shared in-flight entry pending until the request settles. Callers that stop waiting leave the HTTP request running; repeated triggers against a misconfigured endpoint accumulate stale fetches behind the single-flight map. Previews can advertise stale model capabilities because a superseded fetch is still the one in flight. A timed-out catalog fetch can outlive every caller that wanted it.
Proposed approach
Only the fetcher layer:
src/api/providers/fetchers/, with one named exception: theGetModelsOptionstype insrc/shared/api.ts, the options object the model-cache entry points already accept. Thread an optional callerAbortSignalthrough the model-cache entry points intodedupedFetch, which tracks its waiters and passes the cancellation to whichever fetcherfetchModelsFromProviderdispatches to. The network-level abort uses whatever cancellation the fetcher's HTTP client already exposes: axiossignal,fetchsignal, or an SDK abort option. No new cancellation plumbing is invented for clients that expose none; for those fetchers, aborting still releases the shared entry and stops every waiter from waiting. Where waiters share one fetch, the underlying request aborts only when the last waiter leaves. Every fetch routed through the single-flight gets a bounded timeout, so a hung endpoint cannot hold the entry pending indefinitely.This issue is confined to the fetcher layer. Caller-side bounded waits and cancellation checks are outside it; that half shipped with #1505. The per-model OpenRouter endpoints path (
getOpenRouterModelEndpoints, called directly frommodelEndpointCache.ts:59) bypasses theinFlightRefreshsingle-flight entirely, so it is outside this issue too. The auth-scoped fetch path (AUTH_SCOPED_PROVIDERSatmodelCache.ts:106-109, routed pastdedupedFetchatmodelCache.ts:321and:405) likewise bypasses theinFlightRefreshsingle-flight entirely, so it too is outside this issue.Acceptance criteria
getModelsandrefreshModelsaccept an optionalAbortSignal, and a fetch dispatched through the single-flight receives the caller's cancellation.