Studio autoload: quant-size ordering for cached repos, alias dedupe, incremental resolution
Cached GGUF repos now order by the quant that will actually load instead of the row size_bytes, which sums every downloaded quant and pushed a repo holding one small quant behind larger models. Cached entries apply the same seen gate local rows use and skip keys are scoped to the backend load target, so a cached repo and an indexed local row aliasing the same files no longer spend two attempt slots on one failure. The fallback resolves candidates through the bounded worker pool and consumes them incrementally from a size-ordered pool after a short settle grace, so one slow folder scan no longer stalls the send path behind the transport timeout.
This commit is contained in:
parent
d74e7066d2
commit
4791969bc9
2 changed files with 318 additions and 188 deletions
|
|
@ -1433,6 +1433,17 @@ function autoLoadCandidateKey(
|
|||
return `${kind}:${normalizeLoadTargetKey(id)}:${(ggufVariant ?? "").toLowerCase()}`;
|
||||
}
|
||||
|
||||
// Skip keys use the backend load target, not the display id: a cached repo
|
||||
// and an indexed local row aliasing the same files share the key, so a file
|
||||
// that failed through one row is not retried through the other.
|
||||
function autoLoadSkipKey(candidate: AutoLoadCandidate): string {
|
||||
return autoLoadCandidateKey(
|
||||
candidate.kind,
|
||||
candidate.loadId ?? candidate.id,
|
||||
candidate.ggufVariant,
|
||||
);
|
||||
}
|
||||
|
||||
function findCachedRepo<T extends { repo_id: string }>(
|
||||
repos: T[],
|
||||
id: string,
|
||||
|
|
@ -1546,27 +1557,11 @@ function normalizeLoadTargetKey(value: string): string {
|
|||
// indexed folders can saturate the connection pool and disk.
|
||||
const AUTO_LOAD_VARIANT_SCAN_CONCURRENCY = 4;
|
||||
|
||||
/** Map with at most `limit` requests in flight, preserving order. */
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from(
|
||||
{ length: Math.max(1, Math.min(limit, items.length)) },
|
||||
async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await fn(items[index]);
|
||||
}
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
// Settle window before the fallback starts consuming resolved candidates:
|
||||
// when scans finish quickly (the common case) the pool is complete first and
|
||||
// keeps the exact smallest-first order; slow scans stop blocking the send
|
||||
// path after this window and join the pool in size order as they resolve.
|
||||
const AUTO_LOAD_RESOLVE_GRACE_MS = 2500;
|
||||
|
||||
type ResolvedLocalCandidate = {
|
||||
candidate: AutoLoadCandidate;
|
||||
|
|
@ -1834,9 +1829,7 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
: {}),
|
||||
}))
|
||||
) {
|
||||
skippedAutoLoadCandidates.add(
|
||||
autoLoadCandidateKey(candidate.kind, candidate.id, candidate.ggufVariant),
|
||||
);
|
||||
skippedAutoLoadCandidates.add(autoLoadSkipKey(candidate));
|
||||
return false;
|
||||
}
|
||||
loadAttempts += 1;
|
||||
|
|
@ -2073,19 +2066,22 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
skippedAutoLoadCandidates.add(
|
||||
autoLoadCandidateKey(
|
||||
rememberedCandidate?.kind ??
|
||||
(row.model_format === "gguf" ? "gguf" : "model"),
|
||||
row.id,
|
||||
rememberedCandidate?.ggufVariant ?? lastLoaded.ggufVariant,
|
||||
),
|
||||
rememberedCandidate
|
||||
? autoLoadSkipKey(rememberedCandidate)
|
||||
: autoLoadCandidateKey(
|
||||
row.model_format === "gguf" ? "gguf" : "model",
|
||||
localRowLoadTarget(row),
|
||||
lastLoaded.ggufVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (lastLoaded.kind === "gguf") {
|
||||
const repo = findCachedRepo(ggufRepos, lastLoaded.id);
|
||||
if (repo && lastLoaded.ggufVariant) {
|
||||
markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path);
|
||||
// Not marked seen: if this exact quant fails, the fallback pool
|
||||
// may still pick another complete quant from this repo (only the
|
||||
// failed candidate key below is excluded).
|
||||
try {
|
||||
const variants = await listGgufVariants(repo.repo_id, undefined, {
|
||||
preferLocalCache: true,
|
||||
|
|
@ -2123,14 +2119,17 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
skippedAutoLoadCandidates.add(
|
||||
autoLoadCandidateKey("gguf", repo.repo_id, lastLoaded.ggufVariant),
|
||||
autoLoadCandidateKey(
|
||||
"gguf",
|
||||
repo.load_id || repo.repo_id,
|
||||
lastLoaded.ggufVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const repo = findCachedRepo(modelRepos, lastLoaded.id);
|
||||
if (repo) {
|
||||
markSeen("model", repo.load_id || repo.repo_id, repo.cache_path);
|
||||
try {
|
||||
toast("Loading last used model…", {
|
||||
id: toastId,
|
||||
|
|
@ -2154,7 +2153,7 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
skippedAutoLoadCandidates.add(
|
||||
autoLoadCandidateKey("model", repo.repo_id),
|
||||
autoLoadCandidateKey("model", repo.load_id || repo.repo_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2166,11 +2165,21 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
});
|
||||
}
|
||||
|
||||
// Deterministic on-device fallback: complete/loadable GGUF models first,
|
||||
// then complete/loadable non-GGUF models, smallest first within each
|
||||
// group, merging managed-cache repos with backend-indexed local rows.
|
||||
// On-device fallback: complete/loadable GGUF models first, then
|
||||
// complete/loadable non-GGUF models, smallest first within each group,
|
||||
// merging managed-cache repos with backend-indexed local rows. Both
|
||||
// cached repos and local rows order on the size of the quant that will
|
||||
// actually load: a multi-quant repo's row size_bytes SUMS every quant,
|
||||
// which would push a repo holding one small quant behind larger models.
|
||||
type FallbackCandidate =
|
||||
| { type: "cached-gguf"; repo: CachedGgufRepo; sizeBytes: number }
|
||||
| {
|
||||
type: "cached-gguf";
|
||||
repo: CachedGgufRepo;
|
||||
variant: GgufVariantDetail;
|
||||
sizeBytes: number;
|
||||
/** Re-queued quant of an already-visited repo (skips the seen gate). */
|
||||
retry?: boolean;
|
||||
}
|
||||
| { type: "cached-model"; repo: CachedModelRepo; sizeBytes: number }
|
||||
| {
|
||||
type: "local";
|
||||
|
|
@ -2180,12 +2189,78 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
/** Re-queued quant of an already-visited row (skips the seen gate). */
|
||||
retry?: boolean;
|
||||
};
|
||||
const bySizeAsc = (a: FallbackCandidate, b: FallbackCandidate): number =>
|
||||
a.sizeBytes - b.sizeBytes;
|
||||
const isModelKindEntry = (entry: FallbackCandidate): boolean =>
|
||||
entry.type === "cached-model" ||
|
||||
(entry.type === "local" && entry.candidate.kind === "model");
|
||||
const isSkippedAutoLoadCandidate = (c: AutoLoadCandidate): boolean =>
|
||||
skippedAutoLoadCandidates.has(
|
||||
autoLoadCandidateKey(c.kind, c.id, c.ggufVariant),
|
||||
);
|
||||
skippedAutoLoadCandidates.has(autoLoadSkipKey(c));
|
||||
// Candidates are resolved through a bounded worker pool and consumed
|
||||
// incrementally from a size-ordered pool: awaiting every folder scan
|
||||
// before the first attempt let one slow folder stall the send path
|
||||
// behind the transport timeout. Ordered insertion keeps GGUF entries
|
||||
// ahead of safetensors entries and each group smallest-first, so
|
||||
// late-resolving scans and requeued quants land in the same global
|
||||
// order a full pre-sort would give.
|
||||
const readyPool: FallbackCandidate[] = [];
|
||||
const insertReady = (entry: FallbackCandidate): void => {
|
||||
let at = 0;
|
||||
if (isModelKindEntry(entry)) {
|
||||
while (at < readyPool.length && !isModelKindEntry(readyPool[at])) {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
while (
|
||||
at < readyPool.length &&
|
||||
isModelKindEntry(readyPool[at]) === isModelKindEntry(entry) &&
|
||||
readyPool[at].sizeBytes <= entry.sizeBytes
|
||||
) {
|
||||
at += 1;
|
||||
}
|
||||
readyPool.splice(at, 0, entry);
|
||||
};
|
||||
// Non-GGUF cached repos need no scan: their snapshot loads whole.
|
||||
for (const repo of modelRepos) {
|
||||
insertReady({
|
||||
type: "cached-model",
|
||||
repo,
|
||||
sizeBytes: sizeOrUnknownBytes(repo.size_bytes),
|
||||
});
|
||||
}
|
||||
// Smallest complete, auto-loadable, not-yet-skipped quant of a managed
|
||||
// cache repo; null when none remains.
|
||||
const resolveCachedGgufEntry = async (
|
||||
repo: CachedGgufRepo,
|
||||
): Promise<Extract<FallbackCandidate, { type: "cached-gguf" }> | null> => {
|
||||
const variants = await listGgufVariants(repo.repo_id, undefined, {
|
||||
preferLocalCache: true,
|
||||
localPath: repo.cache_path,
|
||||
});
|
||||
const downloaded = variants.variants
|
||||
.filter(
|
||||
(v) => v.downloaded && !v.partial && isAutoLoadableGgufVariant(v),
|
||||
)
|
||||
.sort((a, b) => a.size_bytes - b.size_bytes);
|
||||
for (const variant of downloaded) {
|
||||
if (
|
||||
skippedAutoLoadCandidates.has(
|
||||
autoLoadCandidateKey(
|
||||
"gguf",
|
||||
repo.load_id || repo.repo_id,
|
||||
variant.quant,
|
||||
),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
type: "cached-gguf",
|
||||
repo,
|
||||
variant,
|
||||
sizeBytes: sizeOrUnknownBytes(variant.size_bytes),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// Directory-based GGUF rows resolve a quant automatically; only non-GGUF
|
||||
// variant-requiring rows have no background resolution path.
|
||||
const cascadeLocalRows = localRows.filter(
|
||||
|
|
@ -2193,111 +2268,148 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
row.model_format === "gguf" ||
|
||||
row.capabilities?.requires_variant !== true,
|
||||
);
|
||||
// Resolve local candidates BEFORE ordering: a multi-quant folder's row
|
||||
// size_bytes sums every quant in it, so the cascade must order on the
|
||||
// resolved quant's own size or a folder with a small quant would lose to
|
||||
// a larger single-quant model.
|
||||
const localEntries = (
|
||||
await mapWithConcurrency(
|
||||
cascadeLocalRows,
|
||||
AUTO_LOAD_VARIANT_SCAN_CONCURRENCY,
|
||||
async (row): Promise<FallbackCandidate | null> => {
|
||||
try {
|
||||
const resolved = await resolveLocalRowCandidate(
|
||||
row,
|
||||
null,
|
||||
isSkippedAutoLoadCandidate,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
return {
|
||||
type: "local" as const,
|
||||
row,
|
||||
candidate: resolved.candidate,
|
||||
sizeBytes: resolved.sizeBytes,
|
||||
};
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
const resolutionJobs: Array<() => Promise<FallbackCandidate | null>> = [
|
||||
...ggufRepos.map((repo) => () => resolveCachedGgufEntry(repo)),
|
||||
...cascadeLocalRows.map(
|
||||
(row) => async (): Promise<FallbackCandidate | null> => {
|
||||
const resolved = await resolveLocalRowCandidate(
|
||||
row,
|
||||
null,
|
||||
isSkippedAutoLoadCandidate,
|
||||
);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "local",
|
||||
row,
|
||||
candidate: resolved.candidate,
|
||||
sizeBytes: resolved.sizeBytes,
|
||||
};
|
||||
},
|
||||
)
|
||||
).filter((entry): entry is FallbackCandidate => entry !== null);
|
||||
const ggufGroup: FallbackCandidate[] = [
|
||||
...ggufRepos.map((repo) => ({
|
||||
type: "cached-gguf" as const,
|
||||
repo,
|
||||
sizeBytes: sizeOrUnknownBytes(repo.size_bytes),
|
||||
})),
|
||||
...localEntries.filter(
|
||||
(entry) => entry.type === "local" && entry.candidate.kind === "gguf",
|
||||
),
|
||||
].sort(bySizeAsc);
|
||||
const modelGroup: FallbackCandidate[] = [
|
||||
...modelRepos.map((repo) => ({
|
||||
type: "cached-model" as const,
|
||||
repo,
|
||||
sizeBytes: sizeOrUnknownBytes(repo.size_bytes),
|
||||
})),
|
||||
...localEntries.filter(
|
||||
(entry) => entry.type === "local" && entry.candidate.kind === "model",
|
||||
),
|
||||
].sort(bySizeAsc);
|
||||
|
||||
const queue: FallbackCandidate[] = [...ggufGroup, ...modelGroup];
|
||||
const isModelKindEntry = (entry: FallbackCandidate): boolean =>
|
||||
entry.type === "cached-model" ||
|
||||
(entry.type === "local" && entry.candidate.kind === "model");
|
||||
for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
|
||||
const candidate = queue[queueIndex];
|
||||
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break;
|
||||
];
|
||||
let pendingJobs = resolutionJobs.length;
|
||||
let progressWaiters: Array<() => void> = [];
|
||||
const signalProgress = (): void => {
|
||||
const waiters = progressWaiters;
|
||||
progressWaiters = [];
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const nextProgress = (): Promise<void> =>
|
||||
new Promise((resolve) => progressWaiters.push(resolve));
|
||||
const runResolutionJobs = async (): Promise<void> => {
|
||||
let nextJob = 0;
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{
|
||||
length: Math.max(
|
||||
1,
|
||||
Math.min(AUTO_LOAD_VARIANT_SCAN_CONCURRENCY, resolutionJobs.length),
|
||||
),
|
||||
},
|
||||
async () => {
|
||||
while (nextJob < resolutionJobs.length) {
|
||||
const job = resolutionJobs[nextJob];
|
||||
nextJob += 1;
|
||||
let entry: FallbackCandidate | null = null;
|
||||
try {
|
||||
entry = await job();
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
}
|
||||
pendingJobs -= 1;
|
||||
if (entry) {
|
||||
insertReady(entry);
|
||||
}
|
||||
signalProgress();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
const resolutionDone = runResolutionJobs();
|
||||
if (pendingJobs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const graceTimer = setTimeout(resolve, AUTO_LOAD_RESOLVE_GRACE_MS);
|
||||
resolutionDone.then(() => {
|
||||
clearTimeout(graceTimer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
while (loadAttempts < MAX_AUTO_LOAD_ATTEMPTS) {
|
||||
const candidate = readyPool.shift();
|
||||
if (!candidate) {
|
||||
if (pendingJobs <= 0) {
|
||||
break;
|
||||
}
|
||||
await nextProgress();
|
||||
continue;
|
||||
}
|
||||
if (candidate.type === "cached-gguf") {
|
||||
const repo = candidate.repo;
|
||||
markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path);
|
||||
if (!candidate.retry) {
|
||||
// A shared load target may already have been visited through an
|
||||
// indexed local row (e.g. a scan folder aliasing this cache).
|
||||
if (isSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path)) {
|
||||
continue;
|
||||
}
|
||||
markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path);
|
||||
}
|
||||
const skipKey = autoLoadCandidateKey(
|
||||
"gguf",
|
||||
repo.load_id || repo.repo_id,
|
||||
candidate.variant.quant,
|
||||
);
|
||||
if (skippedAutoLoadCandidates.has(skipKey)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const variants = await listGgufVariants(repo.repo_id, undefined, {
|
||||
preferLocalCache: true,
|
||||
localPath: repo.cache_path,
|
||||
});
|
||||
const downloaded = variants.variants
|
||||
.filter(
|
||||
(v) => v.downloaded && !v.partial && isAutoLoadableGgufVariant(v),
|
||||
)
|
||||
.sort((a, b) => a.size_bytes - b.size_bytes);
|
||||
if (downloaded.length > 0) {
|
||||
const variant = downloaded[0];
|
||||
if (
|
||||
skippedAutoLoadCandidates.has(
|
||||
autoLoadCandidateKey("gguf", repo.repo_id, variant.quant),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
loadId: repo.load_id,
|
||||
kind: "gguf",
|
||||
ggufVariant: variant.quant,
|
||||
maxSeqLength: 0,
|
||||
successLabel: `Loaded ${repo.repo_id} (${variant.quant})`,
|
||||
inventoryId: repo.inventory_id ?? null,
|
||||
source: "hf_cache",
|
||||
})
|
||||
) {
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
}
|
||||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
loadId: repo.load_id,
|
||||
kind: "gguf",
|
||||
ggufVariant: candidate.variant.quant,
|
||||
maxSeqLength: 0,
|
||||
successLabel: `Loaded ${repo.repo_id} (${candidate.variant.quant})`,
|
||||
inventoryId: repo.inventory_id ?? null,
|
||||
source: "hf_cache",
|
||||
})
|
||||
) {
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
}
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
skippedAutoLoadCandidates.add(skipKey);
|
||||
// A quant that passed validation can still fail /load (corrupt
|
||||
// file, llama.cpp startup error). Re-enter the repo's next
|
||||
// complete quant into the global size order, so one repo of
|
||||
// failing quants cannot starve a smaller model elsewhere.
|
||||
// Validation blocks are model-scoped, so they get no requeue.
|
||||
try {
|
||||
const next = await resolveCachedGgufEntry(repo);
|
||||
if (next) {
|
||||
insertReady({ ...next, retry: true });
|
||||
}
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (candidate.type === "cached-model") {
|
||||
const repo = candidate.repo;
|
||||
if (isSeen("model", repo.load_id || repo.repo_id, repo.cache_path)) {
|
||||
continue;
|
||||
}
|
||||
markSeen("model", repo.load_id || repo.repo_id, repo.cache_path);
|
||||
if (
|
||||
skippedAutoLoadCandidates.has(
|
||||
autoLoadCandidateKey("model", repo.repo_id),
|
||||
autoLoadCandidateKey("model", repo.load_id || repo.repo_id),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
|
|
@ -2319,6 +2431,9 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
}
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
skippedAutoLoadCandidates.add(
|
||||
autoLoadCandidateKey("model", repo.load_id || repo.repo_id),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -2339,19 +2454,9 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
}
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
skippedAutoLoadCandidates.add(
|
||||
autoLoadCandidateKey(
|
||||
localCandidate.kind,
|
||||
localCandidate.id,
|
||||
localCandidate.ggufVariant,
|
||||
),
|
||||
);
|
||||
// A quant that passed validation can still fail /load (corrupt
|
||||
// file, llama.cpp startup error). Re-enter the folder's next
|
||||
// complete quant into the GLOBAL size order (still ahead of the
|
||||
// safetensors group) instead of retrying inline, so one folder of
|
||||
// failing quants cannot starve a smaller model elsewhere.
|
||||
// Validation blocks are model-scoped, so they get no requeue.
|
||||
skippedAutoLoadCandidates.add(autoLoadSkipKey(localCandidate));
|
||||
// Same requeue as the cached-gguf branch: the folder's next complete
|
||||
// quant re-enters the global size order instead of retrying inline.
|
||||
try {
|
||||
const next = await resolveLocalRowCandidate(
|
||||
row,
|
||||
|
|
@ -2359,22 +2464,13 @@ export async function autoLoadOnDeviceModel(): Promise<{
|
|||
isSkippedAutoLoadCandidate,
|
||||
);
|
||||
if (next) {
|
||||
const retryEntry: FallbackCandidate = {
|
||||
insertReady({
|
||||
type: "local",
|
||||
row,
|
||||
candidate: next.candidate,
|
||||
sizeBytes: next.sizeBytes,
|
||||
retry: true,
|
||||
};
|
||||
let insertAt = queueIndex + 1;
|
||||
while (
|
||||
insertAt < queue.length &&
|
||||
!isModelKindEntry(queue[insertAt]) &&
|
||||
queue[insertAt].sizeBytes <= retryEntry.sizeBytes
|
||||
) {
|
||||
insertAt += 1;
|
||||
}
|
||||
queue.splice(insertAt, 0, retryEntry);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
hadNonTrustFailure = true;
|
||||
|
|
|
|||
|
|
@ -815,66 +815,102 @@ def test_directory_gguf_rows_resolve_variant_like_picker():
|
|||
|
||||
|
||||
def test_remembered_local_failure_does_not_block_folder_fallback():
|
||||
"""A failed remembered local quant must exclude only that exact candidate
|
||||
key, not mark the whole row as seen; otherwise a folder with another
|
||||
complete quant can never fall back and Send falsely reports no model."""
|
||||
"""A failed remembered model must exclude only that exact candidate key,
|
||||
not mark the whole row or repo as seen; otherwise a folder or cache repo
|
||||
with another complete quant can never fall back and Send falsely reports
|
||||
no model. Applies to local rows and managed-cache repos alike."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
remembered_block = auto_load.split("isManagedCacheSource(lastLoaded.source)", 1)[1]
|
||||
remembered_block = remembered_block.split('} else if (lastLoaded.kind === "gguf")', 1)[0]
|
||||
remembered_block = auto_load.split("if (lastLoaded) {", 1)[1]
|
||||
remembered_block = remembered_block.split("// On-device fallback", 1)[0]
|
||||
assert (
|
||||
"markSeen(" not in remembered_block
|
||||
), "remembered-local retry must not pre-mark the row as deduped"
|
||||
assert "rememberedCandidate?.ggufVariant ?? lastLoaded.ggufVariant" in remembered_block
|
||||
), "remembered paths must not pre-mark their row/repo as deduped"
|
||||
assert "autoLoadSkipKey(rememberedCandidate)" in remembered_block
|
||||
|
||||
|
||||
def test_local_fallback_orders_by_resolved_quant_size():
|
||||
"""A GGUF folder row's size_bytes sums every quant in the folder, so the
|
||||
smallest-first cascade must order local candidates by the resolved
|
||||
quant's own size; otherwise a folder with a small quant loses to a
|
||||
larger single-quant model."""
|
||||
def test_fallback_orders_by_resolved_quant_size():
|
||||
"""A GGUF folder or cache repo row's size_bytes sums every quant in it, so
|
||||
the smallest-first fallback must order both local rows and cached repos by
|
||||
the resolved quant's own size; otherwise a repo holding one small quant
|
||||
loses to a larger single-quant model."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
|
||||
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
|
||||
assert "sizeBytes: sizeOrUnknownBytes(entry.size_bytes)" in resolve_fn
|
||||
auto_load = _autoload_section()
|
||||
# Local candidates are resolved BEFORE the groups are sorted.
|
||||
assert "const localEntries = (" in auto_load
|
||||
assert auto_load.index("const localEntries = (") < auto_load.index(
|
||||
"const ggufGroup: FallbackCandidate[]"
|
||||
)
|
||||
# Local rows order on the resolved quant size.
|
||||
assert "sizeBytes: resolved.sizeBytes" in auto_load
|
||||
# Cached GGUF repos order on the resolved quant size too.
|
||||
assert "const resolveCachedGgufEntry" in auto_load
|
||||
assert "sizeBytes: sizeOrUnknownBytes(variant.size_bytes)" in auto_load
|
||||
# The all-variant row sum only orders non-GGUF cached repos, whose
|
||||
# snapshot loads whole.
|
||||
seed_block = auto_load.split("for (const repo of modelRepos)", 1)[1]
|
||||
seed_block = seed_block.split("const resolveCachedGgufEntry", 1)[0]
|
||||
assert "sizeOrUnknownBytes(repo.size_bytes)" in seed_block
|
||||
assert auto_load.count("sizeOrUnknownBytes(repo.size_bytes)") == 1
|
||||
|
||||
|
||||
def test_cascade_retries_next_quant_after_load_failure():
|
||||
"""A failed /api/inference/load (not just a blocked validation) must mark
|
||||
that quant skipped and re-enter the folder's next complete quant into the
|
||||
GLOBAL size order (still ahead of the safetensors group) instead of
|
||||
retrying inline, so one folder of failing quants cannot starve a smaller
|
||||
model elsewhere; single-candidate rows resolve to null once skipped, and
|
||||
the attempt cap bounds total loads."""
|
||||
that quant skipped and re-enter the folder's or repo's next complete quant
|
||||
into the GLOBAL size order (still ahead of the safetensors group) instead
|
||||
of retrying inline, so one folder of failing quants cannot starve a
|
||||
smaller model elsewhere; single-candidate rows resolve to null once
|
||||
skipped, and the attempt cap bounds total loads."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
# No inline retry loop: retries flow through the shared queue.
|
||||
# No inline retry loop: retries re-enter the shared ordered pool.
|
||||
assert "while (localCandidate" not in auto_load
|
||||
assert "const queue: FallbackCandidate[] = [...ggufGroup, ...modelGroup]" in auto_load
|
||||
assert "retry: true," in auto_load
|
||||
assert "queue.splice(insertAt, 0, retryEntry)" in auto_load
|
||||
# Reinsertion respects the GGUF-before-safetensors group boundary and the
|
||||
# ascending size order among the remaining candidates.
|
||||
assert "!isModelKindEntry(queue[insertAt])" in auto_load
|
||||
assert "queue[insertAt].sizeBytes <= retryEntry.sizeBytes" in auto_load
|
||||
assert "insertReady({ ...next, retry: true })" in auto_load
|
||||
# Ordered insertion respects the GGUF-before-safetensors group boundary
|
||||
# and the ascending size order among the remaining candidates.
|
||||
assert "!isModelKindEntry(readyPool[at])" in auto_load
|
||||
assert "readyPool[at].sizeBytes <= entry.sizeBytes" in auto_load
|
||||
# Requeued entries bypass the seen gate; fresh rows still dedupe.
|
||||
assert "if (!candidate.retry) {" in auto_load
|
||||
# The cascade catch records the failed quant before requeueing.
|
||||
catch_block = auto_load.split("// A quant that passed validation can still fail /load", 1)[0]
|
||||
assert "skippedAutoLoadCandidates.add(" in catch_block
|
||||
assert "skippedAutoLoadCandidates.add(skipKey)" in auto_load
|
||||
assert "skippedAutoLoadCandidates.add(autoLoadSkipKey(localCandidate))" in auto_load
|
||||
# Termination guard: a skipped single candidate resolves to null.
|
||||
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
|
||||
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
|
||||
assert "if (isSkippedCandidate?.(candidate)) return null;" in resolve_fn
|
||||
|
||||
|
||||
def test_cached_rows_deduped_against_local_aliases():
|
||||
"""A cached repo and an indexed local row can alias the same files (e.g.
|
||||
a scan folder pointing into an HF cache). The fallback must not spend a
|
||||
second load attempt re-trying files already visited or failed through the
|
||||
other row: cached branches apply the same seen gate local rows use, and
|
||||
skip keys are scoped to the backend load target both rows share."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert 'if (isSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path))' in auto_load
|
||||
assert 'if (isSeen("model", repo.load_id || repo.repo_id, repo.cache_path))' in auto_load
|
||||
key_fn = src.split("function autoLoadSkipKey", 1)[1]
|
||||
key_fn = key_fn.split("\nfunction ", 1)[0]
|
||||
assert "candidate.loadId ?? candidate.id" in key_fn
|
||||
|
||||
|
||||
def test_send_not_blocked_by_full_inventory_resolution():
|
||||
"""Pressing Send must not wait for every /gguf-variants folder scan before
|
||||
the first load attempt: candidates resolve through a bounded worker pool
|
||||
and are consumed incrementally after a short settle grace, so one slow
|
||||
folder cannot stall the send path behind the transport timeout."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "const AUTO_LOAD_RESOLVE_GRACE_MS" in src
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
# The consumer never awaits full resolution; it waits for the grace
|
||||
# window (cut short when resolution finishes) and then per-completion.
|
||||
assert "await resolutionDone" not in auto_load
|
||||
assert "clearTimeout(graceTimer)" in auto_load
|
||||
assert "await nextProgress();" in auto_load
|
||||
assert "if (pendingJobs <= 0) {" in auto_load
|
||||
|
||||
|
||||
def test_autoload_keys_preserve_posix_path_case():
|
||||
"""Linux filesystems distinguish /models/Foo from /models/foo, so seen
|
||||
keys and remembered-model matching must not fold case on POSIX paths;
|
||||
|
|
@ -906,8 +942,6 @@ def test_local_variant_scans_bounded_concurrency():
|
|||
at once."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
assert "const AUTO_LOAD_VARIANT_SCAN_CONCURRENCY" in src
|
||||
assert "async function mapWithConcurrency" in src
|
||||
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
|
||||
assert "await mapWithConcurrency(" in auto_load
|
||||
assert "AUTO_LOAD_VARIANT_SCAN_CONCURRENCY," in auto_load
|
||||
assert "Math.min(AUTO_LOAD_VARIANT_SCAN_CONCURRENCY, resolutionJobs.length)" in auto_load
|
||||
assert "await Promise.all(\n cascadeLocalRows.map(" not in auto_load
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue