feat(download): support ModelScope repositories in the URL downloader

ModelScope became a linkable source, but downloading from it was impossible:
the URL picker only recognised huggingface.co, the file listing hit a
huggingface-only endpoint, the resolve URL was hardcoded, and the default
path template always wrote into a `huggingface/` directory.

Move the download knowledge into the providers so the handlers stay generic:

- `ModelSource` gains `list_files()`, `file_download_url()`,
  `default_revision` and `default_subdir`. `HuggingFaceSource` keeps the Hub
  tree API (`/api/models/{id}/tree/{rev}`, LFS-aware sizes, `main`).
  `ModelScopeSource` uses `/api/v1/models/{id}/repo/files?Revision=master`
  — which reports real byte sizes for LFS files, so no HEAD probe is needed,
  and which only accepts `master` (an HF-imported repo still 404s on `main`)
  — and downloads through `/models/{id}/resolve/{rev}/{path}`. That URL
  redirects to a CDN target carrying a time-limited `auth_key`, so it is
  rebuilt on every request and never cached, which is also what keeps
  resumable Range requests working.
- `hf_handlers.py`/`HfHandler` become `model_source_handlers.py`/
  `ModelSourceHandler` with `list_model_source_files` and
  `download_model_source`. New routes `/api/lm/model-source-files` and
  `/api/lm/download-model-source`; the old `/api/lm/hf-repo-files` and
  `/api/lm/download-hf-model` paths stay as aliases, and a payload without
  `platform` still means Hugging Face, so existing callers are unaffected.
- A downloaded sidecar now records `source_platform` + `source_url` (with the
  `hf_url` alias only for Hugging Face) instead of always writing `hf_url`,
  and `use_default_paths` files ModelScope downloads under
  `modelscope/<owner>/<repo>`. The now-unused shared HF aiohttp session and
  its shutdown hook are gone; providers open short-lived sessions.
- Frontend: `detectUrlType` returns the platform-neutral
  `model-source-repo` / `model-source-file` plus an explicit `platform`, the
  DownloadManager's `hf*` state and methods are renamed to `source*`, every
  `source === 'huggingface'` check becomes `isExternalModelSource()`, and
  batch groups are keyed by `platform:repo` so the same `owner/name` on two
  sites renders as two groups. A bare `owner/name` still means Hugging Face.
- `is_valid_source_id()` centralises repo-id validation (exactly
  `owner/name`, no traversal, no leading dot). This also fixes the old HF
  download check that rejected any dot in the name, i.e. legitimate repos
  such as `black-forest-labs/FLUX.1-dev`.

Verified against the live APIs: the example repo lists 8 weight files with
correct sizes, and a ranged GET of the built resolve URL returns 206 after
following the redirect to the CDN. Backend 2853 passed; frontend 1143 JS +
91 Vue passed. The nine locales carry the refreshed download copy in the
next commit.
This commit is contained in:
Will Miao
2026-09-14 07:42:51 +08:00
parent b9bf006998
commit 38d4c59b4c
22 changed files with 1953 additions and 667 deletions
+46 -9
View File
@@ -8,6 +8,7 @@ import {
isValidModelType,
DOWNLOAD_ENDPOINTS,
HF_ENDPOINTS,
MODEL_SOURCE_ENDPOINTS,
WS_ENDPOINTS
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
@@ -1367,30 +1368,52 @@ export class BaseModelApiClient {
}
}
async fetchHfRepoFiles(repo, revision = 'main') {
/**
* List the downloadable weight files of an external repository.
* @param {string} repo - `owner/name`
* @param {string} [platform] - `huggingface` (default) or `modelscope`
* @param {string} [revision] - branch; each site has its own default
*/
async fetchModelSourceFiles(repo, platform = 'huggingface', revision = '') {
try {
const params = new URLSearchParams({ repo, revision });
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
const params = new URLSearchParams({ repo, platform });
if (revision) params.set('revision', revision);
const response = await fetch(`${MODEL_SOURCE_ENDPOINTS.repoFiles}?${params}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to fetch HF repo files');
throw new Error(err.error || 'Failed to fetch repository files');
}
return await response.json();
} catch (error) {
console.error('Error fetching HF repo files:', error);
console.error('Error fetching repository files:', error);
throw error;
}
}
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
/** Backwards-compatible Hugging Face wrapper. */
async fetchHfRepoFiles(repo, revision = 'main') {
return this.fetchModelSourceFiles(repo, 'huggingface', revision);
}
async downloadModelSource({
platform = 'huggingface',
repo,
filename,
revision,
modelRoot,
relativePath,
useDefaultPaths,
download_id,
}) {
try {
const response = await fetch(HF_ENDPOINTS.download, {
const response = await fetch(MODEL_SOURCE_ENDPOINTS.download, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform,
repo,
filename,
revision: revision || 'main',
revision: revision || '',
model_root: modelRoot,
relative_path: relativePath || '',
use_default_paths: useDefaultPaths || false,
@@ -1404,11 +1427,25 @@ export class BaseModelApiClient {
return await response.json();
} catch (error) {
console.error('Error downloading HF model:', error);
console.error('Error downloading model:', error);
throw error;
}
}
/** Backwards-compatible Hugging Face wrapper. */
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
return this.downloadModelSource({
platform: 'huggingface',
repo,
filename,
revision: revision || 'main',
modelRoot,
relativePath,
useDefaultPaths,
download_id,
});
}
_buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';