refactor: drop provider catalog cache, fetch on page mount with skeleton

This commit is contained in:
9qeklajc
2026-09-30 02:21:37 +02:00
parent a713f81961
commit 1c61dc1bd0
6 changed files with 32 additions and 274 deletions
+13 -79
View File
@@ -1,8 +1,6 @@
import asyncio
import json import json
import re import re
import secrets import secrets
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -56,9 +54,6 @@ async def _refresh_provider_model_paths(upstream_provider_id: int) -> None:
"""Queue discovery sync without blocking the committed admin mutation.""" """Queue discovery sync without blocking the committed admin mutation."""
from ..upstream.model_paths import schedule_model_paths_refresh_for_provider from ..upstream.model_paths import schedule_model_paths_refresh_for_provider
# Every provider/model mutation funnels through here, so it is also the one
# place that can keep the cached admin catalog from serving a stale listing.
invalidate_remote_models_cache(upstream_provider_id)
await schedule_model_paths_refresh_for_provider(upstream_provider_id) await schedule_model_paths_refresh_for_provider(upstream_provider_id)
@@ -1188,7 +1183,6 @@ async def delete_upstream_provider(provider_id: str) -> dict[str, object]:
await session.delete(provider) await session.delete(provider)
await session.commit() await session.commit()
invalidate_remote_models_cache(deleted_id)
await reinitialize_upstreams() await reinitialize_upstreams()
await refresh_model_maps() await refresh_model_maps()
return {"ok": True, "deleted_id": deleted_id} return {"ok": True, "deleted_id": deleted_id}
@@ -1202,78 +1196,13 @@ async def get_provider_types() -> list[dict[str, object]]:
return [cls.get_provider_metadata() for cls in upstream_provider_classes] return [cls.get_provider_metadata() for cls in upstream_provider_classes]
# The admin catalog view is opened repeatedly and by several panels at once,
# while every miss costs a live upstream round trip. Keep the raw listing for a
# short window and let concurrent readers share one in-flight fetch.
_REMOTE_MODELS_TTL_SECONDS = 120.0
_REMOTE_MODELS_FETCH_TIMEOUT_SECONDS = 20.0
_remote_models_cache: dict[int, tuple[float, list]] = {}
_remote_models_locks: dict[int, asyncio.Lock] = {}
# Bumped on every invalidation so a fetch that started against the old provider
# config cannot write its result back after the cache was cleared.
_remote_models_generation = 0
def invalidate_remote_models_cache(provider_pk: int | None = None) -> None:
global _remote_models_generation
_remote_models_generation += 1
if provider_pk is None:
_remote_models_cache.clear()
_remote_models_locks.clear()
else:
_remote_models_cache.pop(provider_pk, None)
async def _get_remote_models(
provider: UpstreamProviderRow, provider_pk: int, force_refresh: bool = False
) -> list:
from ..upstream.helpers import _instantiate_provider
now = time.monotonic()
cached = _remote_models_cache.get(provider_pk)
if not force_refresh and cached and now - cached[0] < _REMOTE_MODELS_TTL_SECONDS:
return cached[1]
lock = _remote_models_locks.setdefault(provider_pk, asyncio.Lock())
async with lock:
cached = _remote_models_cache.get(provider_pk)
now = time.monotonic()
if (
not force_refresh
and cached
and now - cached[0] < _REMOTE_MODELS_TTL_SECONDS
):
return cached[1]
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
return []
generation = _remote_models_generation
try:
models = await asyncio.wait_for(
upstream_instance.fetch_models(),
timeout=_REMOTE_MODELS_FETCH_TIMEOUT_SECONDS,
)
except Exception as e:
logger.error(f"Failed to fetch models from {provider.provider_type}: {e}")
# A stale listing beats an empty one for an operator view.
return cached[1] if cached else []
if generation == _remote_models_generation:
_remote_models_cache[provider_pk] = (time.monotonic(), models)
return models
@admin_router.get( @admin_router.get(
"/api/upstream-providers/{provider_id}/models", "/api/upstream-providers/{provider_id}/models",
dependencies=[Depends(require_admin_api)], dependencies=[Depends(require_admin_api)],
) )
async def get_provider_models( async def get_provider_models(provider_id: str) -> dict[str, object]:
provider_id: str, from ..upstream.helpers import _instantiate_provider
include_remote: bool = Query(True),
refresh_remote: bool = Query(False),
) -> dict[str, object]:
async with create_session() as session: async with create_session() as session:
provider = await _get_upstream_provider_by_ref(session, provider_id) provider = await _get_upstream_provider_by_ref(session, provider_id)
provider_pk = _provider_pk(provider) provider_pk = _provider_pk(provider)
@@ -1285,11 +1214,16 @@ async def get_provider_models(
apply_fees=False, apply_fees=False,
) )
upstream_models: list = [] upstream_models = []
if include_remote: upstream_instance = _instantiate_provider(provider)
upstream_models = await _get_remote_models( if upstream_instance:
provider, provider_pk, force_refresh=refresh_remote try:
) raw_models = await upstream_instance.fetch_models()
upstream_models = raw_models
except Exception as e:
logger.error(
f"Failed to fetch models from {provider.provider_type}: {e}"
)
db_model_ids = {model.id for model in db_models} db_model_ids = {model.id for model in db_models}
filtered_remote_models = [ filtered_remote_models = [
-15
View File
@@ -31,18 +31,3 @@ def _isolate_redemption_negative_cache() -> Iterator[None]:
redemption_negative_cache.clear() redemption_negative_cache.clear()
yield yield
redemption_negative_cache.clear() redemption_negative_cache.clear()
@pytest.fixture(autouse=True)
def _isolate_admin_remote_models_cache() -> Iterator[None]:
"""Clear the admin catalog cache between tests.
Provider primary keys restart at 1 for every fresh test database, so a
cached listing from an earlier test would otherwise answer for a different
provider that happens to reuse the same key.
"""
from routstr.core.admin import invalidate_remote_models_cache
invalidate_remote_models_cache()
yield
invalidate_remote_models_cache()
@@ -1,112 +0,0 @@
"""Cache behavior of the admin provider catalog listing."""
import asyncio
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from routstr.core import admin
from routstr.core.admin import _get_remote_models, invalidate_remote_models_cache
PROVIDER = SimpleNamespace(provider_type="generic")
def _upstream(fetch: Any) -> Any:
return patch(
"routstr.upstream.helpers._instantiate_provider",
return_value=SimpleNamespace(fetch_models=fetch),
)
@pytest.mark.asyncio
async def test_second_read_is_served_from_cache() -> None:
fetch = AsyncMock(return_value=["a"])
with _upstream(fetch):
assert await _get_remote_models(PROVIDER, 1) == ["a"] # type: ignore[arg-type]
assert await _get_remote_models(PROVIDER, 1) == ["a"] # type: ignore[arg-type]
assert fetch.await_count == 1
@pytest.mark.asyncio
async def test_force_refresh_and_invalidation_refetch() -> None:
fetch = AsyncMock(side_effect=[["a"], ["b"], ["c"]])
with _upstream(fetch):
await _get_remote_models(PROVIDER, 1) # type: ignore[arg-type]
assert await _get_remote_models(PROVIDER, 1, force_refresh=True) == ["b"] # type: ignore[arg-type]
invalidate_remote_models_cache(1)
assert await _get_remote_models(PROVIDER, 1) == ["c"] # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_expired_entry_is_refetched() -> None:
fetch = AsyncMock(side_effect=[["a"], ["b"]])
with _upstream(fetch):
await _get_remote_models(PROVIDER, 1) # type: ignore[arg-type]
stamp, models = admin._remote_models_cache[1]
admin._remote_models_cache[1] = (
stamp - admin._REMOTE_MODELS_TTL_SECONDS - 1,
models,
)
assert await _get_remote_models(PROVIDER, 1) == ["b"] # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_failed_refresh_falls_back_to_stale_listing() -> None:
fetch = AsyncMock(side_effect=[["a"], RuntimeError("upstream down")])
with _upstream(fetch):
await _get_remote_models(PROVIDER, 1) # type: ignore[arg-type]
assert await _get_remote_models(PROVIDER, 1, force_refresh=True) == ["a"] # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_failed_first_fetch_returns_empty_and_caches_nothing() -> None:
fetch = AsyncMock(side_effect=RuntimeError("upstream down"))
with _upstream(fetch):
assert await _get_remote_models(PROVIDER, 1) == [] # type: ignore[arg-type]
assert 1 not in admin._remote_models_cache
@pytest.mark.asyncio
async def test_concurrent_readers_share_one_fetch() -> None:
release = asyncio.Event()
async def slow_fetch() -> list[str]:
await release.wait()
return ["a"]
fetch = AsyncMock(side_effect=slow_fetch)
with _upstream(fetch):
readers = [
asyncio.create_task(_get_remote_models(PROVIDER, 1)) # type: ignore[arg-type]
for _ in range(5)
]
await asyncio.sleep(0)
release.set()
results = await asyncio.gather(*readers)
assert results == [["a"]] * 5
assert fetch.await_count == 1
@pytest.mark.asyncio
async def test_invalidation_during_fetch_discards_in_flight_result() -> None:
release = asyncio.Event()
async def slow_fetch() -> list[str]:
await release.wait()
return ["old"]
with _upstream(AsyncMock(side_effect=slow_fetch)):
reader = asyncio.create_task(_get_remote_models(PROVIDER, 1)) # type: ignore[arg-type]
await asyncio.sleep(0)
invalidate_remote_models_cache(1)
release.set()
assert await reader == ["old"]
assert 1 not in admin._remote_models_cache
@pytest.mark.asyncio
async def test_uninstantiable_provider_returns_empty() -> None:
with patch("routstr.upstream.helpers._instantiate_provider", return_value=None):
assert await _get_remote_models(PROVIDER, 1) == [] # type: ignore[arg-type]
+6 -24
View File
@@ -28,7 +28,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Trash2, Ban, CheckCircle, Loader2, Plus } from 'lucide-react'; import { Trash2, Ban, CheckCircle, Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
sortModels, sortModels,
@@ -137,7 +137,6 @@ export function ModelSelector({
models, models,
groups, groups,
isLoading: isLoadingModels, isLoading: isLoadingModels,
isFetchingRemote,
error: modelsError, error: modelsError,
refetch: refetchModels, refetch: refetchModels,
} = useModelsWithProviders(); } = useModelsWithProviders();
@@ -865,19 +864,11 @@ export function ModelSelector({
</div> </div>
{Object.keys(groupedModels).length === 0 ? ( {Object.keys(groupedModels).length === 0 ? (
isFetchingRemote ? ( <div className='border-border/40 rounded-lg border border-dashed p-4 text-center sm:p-5'>
<div className='grid gap-3 sm:gap-4'> <p className='text-muted-foreground text-sm'>
<Skeleton className='h-[200px]' /> Try broadening your search or switch to a different provider scope.
<Skeleton className='h-[200px]' /> </p>
</div> </div>
) : (
<div className='border-border/40 rounded-lg border border-dashed p-4 text-center sm:p-5'>
<p className='text-muted-foreground text-sm'>
Try broadening your search or switch to a different provider
scope.
</p>
</div>
)
) : null} ) : null}
{/* Provider Groups or Filtered Models */} {/* Provider Groups or Filtered Models */}
@@ -927,15 +918,6 @@ export function ModelSelector({
); );
})} })}
{/* The stored rows render first; provider catalogs arrive after their
upstream calls return, so the list says more is still on the way. */}
{isFetchingRemote && Object.keys(groupedModels).length > 0 ? (
<div className='border-border/40 text-muted-foreground flex items-center justify-center gap-2 rounded-lg border border-dashed p-3 text-sm sm:p-4'>
<Loader2 className='h-4 w-4 animate-spin' />
Loading provider catalogs…
</div>
) : null}
{/* Forms and Dialogs */} {/* Forms and Dialogs */}
{modelDialogState.providerId && ( {modelDialogState.providerId && (
<AddProviderModelDialog <AddProviderModelDialog
+4 -11
View File
@@ -317,14 +317,9 @@ export class AdminService {
); );
} }
static async getProviderModels( static async getProviderModels(providerId: number): Promise<ProviderModels> {
providerId: number,
options: { includeRemote?: boolean } = {}
): Promise<ProviderModels> {
const query =
options.includeRemote === false ? '?include_remote=false' : '';
const data = await apiClient.get<ProviderModels>( const data = await apiClient.get<ProviderModels>(
`/admin/api/upstream-providers/${providerId}/models${query}` `/admin/api/upstream-providers/${providerId}/models`
); );
// Convert pricing for all models in the list so the UI receives "per 1M tokens" values // Convert pricing for all models in the list so the UI receives "per 1M tokens" values
@@ -433,9 +428,7 @@ export class AdminService {
); );
} }
static async getModelsWithProviders( static async getModelsWithProviders(): Promise<{
options: { includeRemote?: boolean } = {}
): Promise<{
models: AdminModelAsModel[]; models: AdminModelAsModel[];
groups: AdminModelGroup[]; groups: AdminModelGroup[];
}> { }> {
@@ -460,7 +453,7 @@ export class AdminService {
try { try {
return { return {
provider, provider,
models: await this.getProviderModels(provider.id, options), models: await this.getProviderModels(provider.id),
}; };
} catch (error) { } catch (error) {
console.error( console.error(
+9 -33
View File
@@ -1,50 +1,26 @@
'use client'; 'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin'; import { AdminService } from '@/lib/api/services/admin';
export const modelsWithProvidersQueryKey = ['models-with-providers'] as const; export const modelsWithProvidersQueryKey = ['models-with-providers'] as const;
const localModelsQueryKey = ['models-with-providers', 'local'] as const;
/** /**
* Shared catalog read for every models view. * Shared catalog read for every models view, so the page shell and the
* * selector panel share one request instead of each fanning out to providers.
* The database rows come back without touching an upstream, so they render
* first; the listing that needs live provider calls replaces them once it
* lands. Both queries live under one key prefix, so a single
* `invalidateQueries(['models-with-providers'])` still refreshes the pair, and
* every consumer of this hook shares one request instead of fanning out again.
*/ */
export function useModelsWithProviders() { export function useModelsWithProviders() {
const queryClient = useQueryClient(); const query = useQuery({
const localQuery = useQuery({
queryKey: localModelsQueryKey,
queryFn: () =>
AdminService.getModelsWithProviders({ includeRemote: false }),
refetchOnWindowFocus: false,
staleTime: 30_000,
});
const fullQuery = useQuery({
queryKey: modelsWithProvidersQueryKey, queryKey: modelsWithProvidersQueryKey,
queryFn: () => AdminService.getModelsWithProviders(), queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: 60_000,
}); });
const data = fullQuery.data ?? localQuery.data;
return { return {
models: data?.models ?? [], models: query.data?.models ?? [],
groups: data?.groups ?? [], groups: query.data?.groups ?? [],
isLoading: !data && (localQuery.isLoading || fullQuery.isLoading), isLoading: query.isLoading,
isFetchingRemote: fullQuery.isFetching, error: query.error,
error: data ? null : (fullQuery.error ?? localQuery.error), refetch: query.refetch,
refetch: async () => {
await queryClient.invalidateQueries({
queryKey: modelsWithProvidersQueryKey,
});
},
}; };
} }