diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 79eff0a7aa..3be6fc5505 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -5350,10 +5350,16 @@ class LlamaCppBackend: # Resolve by variant so a newer revision's filename does not hide # the complete older copy. Size-check against that older snapshot's # own revision when its metadata remains available. + # Local-only loads skip the remote size check outright: the + # hub API behind it (get_paths_info) performs no offline-mode + # check and HF_HUB_OFFLINE is baked into hub constants at + # import, so only a per-call skip reliably keeps this off the + # network. A truncated cache then fails the load into + # candidate failover instead of being caught up front. cached_main = cached_gguf_for_load( hf_repo, hf_variant, - verify_sizes = True, + verify_sizes = not local_files_only, hf_token = hf_token, ) else: @@ -5361,7 +5367,10 @@ class LlamaCppBackend: cached_main = ( candidate[0] if candidate is not None - and _cached_candidate_matches_revision_size(hf_repo, candidate, hf_token) + and ( + local_files_only + or _cached_candidate_matches_revision_size(hf_repo, candidate, hf_token) + ) else None ) if cached_main is not None: diff --git a/studio/backend/hub/services/models/cache_inventory.py b/studio/backend/hub/services/models/cache_inventory.py index eebe242a9b..581d68d43c 100644 --- a/studio/backend/hub/services/models/cache_inventory.py +++ b/studio/backend/hub/services/models/cache_inventory.py @@ -443,6 +443,7 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload: # rather than only the all-revisions total. rev_category_sizes: dict[str, dict[str, int]] = {} rev_snapshot_mtimes: dict[str, float] = {} + rev_has_config: dict[str, bool] = {} has_config = False has_adapter_config = False has_adapter_weights = False @@ -474,6 +475,7 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload: continue if name == "config.json": has_config = True + rev_has_config[rev_id] = True continue if name == "adapter_config.json": has_adapter_config = True @@ -520,13 +522,21 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload: size_bytes = sum(size for size, _mtime in selected_blobs.values()) # The one snapshot a load resolves (newest snapshot-dir mtime) among the - # revisions actually holding selected-format weights; falls back to the - # all-revisions total when no revision reports one. + # revisions holding selected-format weights. For safetensors rows the + # snapshot resolvers additionally require config.json in the SAME + # revision, so sizing must use that predicate too: a weight-only newer + # revision would otherwise be sized while the older complete revision is + # what actually loads. Falls back to weight-only revisions, then to the + # all-revisions total. weight_revs = [ rev_id for rev_id, sizes in rev_category_sizes.items() if sizes.get(selected_category, 0) > 0 ] + if selected_category == "safetensors": + complete_revs = [rev_id for rev_id in weight_revs if rev_has_config.get(rev_id, False)] + if complete_revs: + weight_revs = complete_revs if weight_revs: newest_rev = max(weight_revs, key = lambda rev_id: rev_snapshot_mtimes.get(rev_id, 0.0)) snapshot_size_bytes = rev_category_sizes[newest_rev].get(selected_category, 0) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 1ad9a35d76..f41f5c64f8 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -2140,12 +2140,16 @@ export async function autoLoadOnDeviceModel(): Promise<{ }; } - // The platform snapshot the picker's format gates key on: hydrated by the - // bounded best-effort prefetch above, else the store's boot-detected - // client-side values (the same defaults the whole UI uses pre-hydration). + // The platform snapshot the picker's format gates key on. Format gates + // only apply once the BACKEND-reported platform has been fetched: the + // boot-detected fallback describes the browser, which may differ from the + // host (a Mac browser against a remote Linux/CUDA backend would wrongly + // gate out every cached non-GGUF candidate). While the platform is + // unknown, candidates flow ungated and an actually chat-only backend + // rejects ineligible ones at validation without consuming an attempt. const platformState = usePlatformStore.getState(); const platform: AutoLoadPlatform = { - chatOnly: platformState.isChatOnly(), + chatOnly: platformState.fetched ? platformState.isChatOnly() : false, isMac: platformState.deviceType === "mac", }; const ggufRepos = allGgufRepos.filter(isAutoLoadableCachedRepo); @@ -2184,6 +2188,31 @@ export async function autoLoadOnDeviceModel(): Promise<{ ), ); + // One variant scan per cached repo per run: the remembered-model lookup + // and the cascade share the same result, so a stalled repository times + // out ONCE instead of gating Send through a second identical bounded + // request. Rejections are memoized too, on purpose: a repo whose scan + // already failed this run is not rescanned. + const repoVariantScans = new Map< + string, + ReturnType + >(); + const scanRepoVariants = ( + repoId: string, + localPath: string | null | undefined, + ) => { + const key = `${repoId}|${localPath ?? ""}`; + let pending = repoVariantScans.get(key); + if (!pending) { + pending = listGgufVariantsBounded(repoId, { + preferLocalCache: true, + localPath, + }); + repoVariantScans.set(key, pending); + } + return pending; + }; + try { if (lastLoaded) { if (!isManagedCacheSource(lastLoaded.source)) { @@ -2230,10 +2259,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ // may still pick another complete quant from this repo (only the // failed candidate key below is excluded). try { - const variants = await listGgufVariantsBounded(repo.repo_id, { - preferLocalCache: true, - localPath: repo.cache_path, - }); + const variants = await scanRepoVariants(repo.repo_id, repo.cache_path); const variant = variants.variants.find( (entry) => entry.downloaded && @@ -2388,10 +2414,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ const resolveCachedGgufEntry = async ( repo: CachedGgufRepo, ): Promise | null> => { - const variants = await listGgufVariantsBounded(repo.repo_id, { - preferLocalCache: true, - localPath: repo.cache_path, - }); + const variants = await scanRepoVariants(repo.repo_id, repo.cache_path); const downloaded = variants.variants .filter( (v) => v.downloaded && !v.partial && isAutoLoadableGgufVariant(v), diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 340f5f44df..b533b524de 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -297,8 +297,13 @@ def test_chat_autoload_scopes_variant_lookup_to_cached_repo_path(): retained from a previously selected Hugging Face cache.""" src = _read("features/chat/api/chat-adapter.ts") auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] - assert auto_load.count("preferLocalCache: true") >= 2 - assert auto_load.count("localPath: repo.cache_path") >= 2 + # Both cached-repo lookups (remembered model and cascade) route through + # the memoized scanRepoVariants, which carries the cache-scoped params. + scan_fn = auto_load.split("const scanRepoVariants = (", 1)[1] + scan_fn = scan_fn.split("return pending;", 1)[0] + assert "preferLocalCache: true" in scan_fn + assert "localPath," in scan_fn + assert auto_load.count("await scanRepoVariants(repo.repo_id, repo.cache_path)") == 2 chat_api = _read("features/chat/api/chat-api.ts") variants_fn = chat_api.split("export async function listGgufVariants", 1)[1] @@ -1291,6 +1296,32 @@ def test_generation_and_scan_paths_stay_bounded_and_local(): assert adapter.count("expandSeenValues(value)") == 2 +def test_local_only_gguf_reuse_and_platform_gates_are_authoritative(): + """Round-18 gates. get_paths_info performs no offline-mode check and + HF_HUB_OFFLINE is baked into hub constants at import, so local-only GGUF + reuse skips the remote size verification per-call instead of relying on + the env guard. Platform format gates only apply once the BACKEND-reported + platform is fetched (the browser fallback may describe a different + machine). snapshot_size_bytes uses the resolvers' complete-revision + predicate (config plus weights in the SAME revision). Cached-repo variant + scans are memoized per run so a stalled repo times out once.""" + llama = _read_backend("core/inference/llama_cpp.py") + assert "verify_sizes = not local_files_only," in llama + reuse = llama.split("_cached_complete_candidate(hf_repo, gguf_filename, gguf_extra_shards)", 1)[1] + reuse = reuse.split("cached_main is not None", 1)[0] + assert "local_files_only" in reuse and "_cached_candidate_matches_revision_size" in reuse + + adapter = _read("features/chat/api/chat-adapter.ts") + assert "chatOnly: platformState.fetched ? platformState.isChatOnly() : false," in adapter + assert "const repoVariantScans = new Map<" in adapter + assert "const scanRepoVariants = (" in adapter + assert adapter.count("await scanRepoVariants(repo.repo_id, repo.cache_path)") == 2 + + inventory = _read_backend("hub/services/models/cache_inventory.py") + assert "rev_has_config" in inventory + assert 'if selected_category == "safetensors":' in inventory + + def test_gguf_background_loads_never_download_companions(): """A cached GGUF load can still fetch from the Hub through its optional companions (mmproj, MTP drafter) or a cache-miss main quant. Background