Skip to content

Commit cebead0

Browse files
committed
feat: add HuggingFace mirror URL setting for missing-model downloads
1 parent 28bd73a commit cebead0

4 files changed

Lines changed: 177 additions & 10 deletions

File tree

src/platform/missingModel/missingModelDownload.test.ts

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,43 @@ import {
44
clearMetadataCache,
55
downloadModel,
66
fetchModelMetadata,
7+
HUGGINGFACE_MIRROR_SETTING_ID,
78
isModelDownloadable,
89
isTrustedHuggingFaceUrl,
910
openGatedRepoPage,
11+
resolveHuggingFaceUrl,
1012
toBrowsableUrl
1113
} from './missingModelDownload'
1214

13-
const { fetchMock, mockIsDesktop, mockSidebarTabStore, mockStartDownload } =
14-
vi.hoisted(() => ({
15-
fetchMock: vi.fn(),
16-
mockIsDesktop: { value: false },
17-
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
18-
mockStartDownload: vi.fn()
19-
}))
15+
const {
16+
fetchMock,
17+
mockHuggingFaceMirror,
18+
mockIsDesktop,
19+
mockSidebarTabStore,
20+
mockStartDownload
21+
} = vi.hoisted(() => ({
22+
fetchMock: vi.fn(),
23+
mockHuggingFaceMirror: { value: '' as string | undefined },
24+
mockIsDesktop: { value: false },
25+
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
26+
mockStartDownload: vi.fn()
27+
}))
2028

2129
vi.mock('@/platform/distribution/types', () => ({
2230
get isDesktop() {
2331
return mockIsDesktop.value
2432
}
2533
}))
2634

35+
vi.mock('@/platform/settings/settingStore', () => ({
36+
useSettingStore: () => ({
37+
get: (key: string) =>
38+
key === HUGGINGFACE_MIRROR_SETTING_ID
39+
? mockHuggingFaceMirror.value
40+
: undefined
41+
})
42+
}))
43+
2744
vi.mock('@/stores/electronDownloadStore', () => ({
2845
useElectronDownloadStore: () => ({
2946
start: mockStartDownload
@@ -38,6 +55,7 @@ beforeEach(() => {
3855
vi.stubGlobal('fetch', fetchMock)
3956
clearMetadataCache()
4057
delete window.__comfyDesktop2
58+
mockHuggingFaceMirror.value = ''
4159
})
4260

4361
describe('fetchModelMetadata', () => {
@@ -715,3 +733,122 @@ describe('downloadModel', () => {
715733
})
716734
})
717735
})
736+
737+
describe('resolveHuggingFaceUrl', () => {
738+
it('returns the URL unchanged when no mirror is configured', () => {
739+
mockHuggingFaceMirror.value = ''
740+
expect(
741+
resolveHuggingFaceUrl('https://huggingface.co/org/model/resolve/main/x')
742+
).toBe('https://huggingface.co/org/model/resolve/main/x')
743+
})
744+
745+
it('returns the URL unchanged when the mirror is whitespace', () => {
746+
mockHuggingFaceMirror.value = ' '
747+
expect(
748+
resolveHuggingFaceUrl('https://huggingface.co/org/model/resolve/main/x')
749+
).toBe('https://huggingface.co/org/model/resolve/main/x')
750+
})
751+
752+
it('rewrites huggingface.co to the configured mirror', () => {
753+
mockHuggingFaceMirror.value = 'https://hf-mirror.com'
754+
expect(
755+
resolveHuggingFaceUrl('https://huggingface.co/org/model/resolve/main/x')
756+
).toBe('https://hf-mirror.com/org/model/resolve/main/x')
757+
})
758+
759+
it('strips a trailing slash from the mirror', () => {
760+
mockHuggingFaceMirror.value = 'https://hf-mirror.com/'
761+
expect(
762+
resolveHuggingFaceUrl('https://huggingface.co/org/model/resolve/main/x')
763+
).toBe('https://hf-mirror.com/org/model/resolve/main/x')
764+
})
765+
766+
it('trims surrounding whitespace from the mirror', () => {
767+
mockHuggingFaceMirror.value = ' https://hf-mirror.com '
768+
expect(
769+
resolveHuggingFaceUrl('https://huggingface.co/org/model/resolve/main/x')
770+
).toBe('https://hf-mirror.com/org/model/resolve/main/x')
771+
})
772+
773+
it('leaves non-HuggingFace URLs untouched', () => {
774+
mockHuggingFaceMirror.value = 'https://hf-mirror.com'
775+
expect(
776+
resolveHuggingFaceUrl('https://civitai.com/api/download/models/12345')
777+
).toBe('https://civitai.com/api/download/models/12345')
778+
})
779+
780+
it('does not rewrite URLs whose path merely contains huggingface.co', () => {
781+
mockHuggingFaceMirror.value = 'https://hf-mirror.com'
782+
expect(
783+
resolveHuggingFaceUrl(
784+
'https://example.com/huggingface.co/org/model/resolve/main/x'
785+
)
786+
).toBe('https://example.com/huggingface.co/org/model/resolve/main/x')
787+
})
788+
789+
it('routes the Desktop2 download through the mirror', () => {
790+
mockHuggingFaceMirror.value = 'https://hf-mirror.com'
791+
const desktopDownloadModel = vi
792+
.fn<
793+
(url: string, filename: string, directory: string) => Promise<boolean>
794+
>()
795+
.mockResolvedValue(true)
796+
window.__comfyDesktop2 = {
797+
isRemote: () => false,
798+
downloadModel: desktopDownloadModel
799+
}
800+
801+
downloadModel(
802+
{
803+
name: 'model.safetensors',
804+
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
805+
directory: 'checkpoints'
806+
},
807+
{}
808+
)
809+
810+
expect(desktopDownloadModel).toHaveBeenCalledWith(
811+
'https://hf-mirror.com/org/model/resolve/main/model.safetensors',
812+
'model.safetensors',
813+
'checkpoints'
814+
)
815+
})
816+
817+
it('routes the Electron download store through the mirror', () => {
818+
mockHuggingFaceMirror.value = 'https://hf-mirror.com'
819+
mockIsDesktop.value = true
820+
821+
downloadModel(
822+
{
823+
name: 'model.safetensors',
824+
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
825+
directory: 'checkpoints'
826+
},
827+
{ checkpoints: ['/models/checkpoints'] }
828+
)
829+
830+
expect(mockStartDownload).toHaveBeenCalledWith({
831+
url: 'https://hf-mirror.com/org/model/resolve/main/model.safetensors',
832+
savePath: '/models/checkpoints',
833+
filename: 'model.safetensors'
834+
})
835+
})
836+
837+
it('routes the file-size HEAD probe through the mirror', async () => {
838+
mockHuggingFaceMirror.value = 'https://hf-mirror.com'
839+
fetchMock.mockResolvedValueOnce({
840+
ok: true,
841+
headers: new Headers({ 'content-length': '42' })
842+
})
843+
844+
const metadata = await fetchModelMetadata(
845+
'https://huggingface.co/org/model/resolve/main/probe.safetensors'
846+
)
847+
848+
expect(metadata.fileSize).toBe(42)
849+
expect(fetchMock).toHaveBeenCalledWith(
850+
'https://hf-mirror.com/org/model/resolve/main/probe.safetensors',
851+
{ method: 'HEAD' }
852+
)
853+
})
854+
})

src/platform/missingModel/missingModelDownload.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
22
import { isDesktop } from '@/platform/distribution/types'
3+
import { useSettingStore } from '@/platform/settings/settingStore'
34
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
45
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
56
import type { ComfyDesktop2Bridge } from '@/types'
@@ -37,6 +38,25 @@ function isModelUrlAllowlisted(url: string): boolean {
3738

3839
const MODEL_LIBRARY_TAB_ID = 'model-library'
3940

41+
export const HUGGINGFACE_MIRROR_SETTING_ID =
42+
'Comfy.ModelLibrary.HuggingFaceMirror'
43+
44+
/**
45+
* Rewrites `huggingface.co` URLs to a user-configured mirror so that
46+
* users behind networks blocking `huggingface.co` can still use the
47+
* "Download All missing models" button. Empty/missing setting returns
48+
* the URL unchanged. Non-HuggingFace URLs are returned unchanged.
49+
*/
50+
export function resolveHuggingFaceUrl(url: string): string {
51+
const mirror = useSettingStore()
52+
.get(HUGGINGFACE_MIRROR_SETTING_ID)
53+
?.trim()
54+
.replace(/\/+$/, '')
55+
if (!mirror) return url
56+
if (!hasHuggingFaceHost(url)) return url
57+
return url.replace('https://huggingface.co', mirror)
58+
}
59+
4060
export interface ModelWithUrl {
4161
name: string
4262
url: string
@@ -48,7 +68,8 @@ async function startDesktop2ModelDownload(
4868
model: ModelWithUrl
4969
): Promise<void> {
5070
try {
51-
await bridge.downloadModel?.(model.url, model.name, model.directory)
71+
const url = resolveHuggingFaceUrl(model.url)
72+
await bridge.downloadModel?.(url, model.name, model.directory)
5273
} catch (error: unknown) {
5374
console.error('Failed to start Desktop2 model download:', error)
5475
}
@@ -139,7 +160,7 @@ export function downloadModel(
139160
if (modelPaths?.[0]) {
140161
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
141162
void useElectronDownloadStore().start({
142-
url: model.url,
163+
url: resolveHuggingFaceUrl(model.url),
143164
savePath: modelPaths[0],
144165
filename: model.name
145166
})
@@ -225,7 +246,7 @@ const HUGGING_FACE_GATED_ERROR_CODE = 'GatedRepo'
225246
async function fetchHeadMetadata(url: string): Promise<MetadataFetchResult> {
226247
try {
227248
// Deliberately uncredentialed HEADs prevent re-checks from clearing gating.
228-
const response = await fetch(url, { method: 'HEAD' })
249+
const response = await fetch(resolveHuggingFaceUrl(url), { method: 'HEAD' })
229250
if (!response.ok) {
230251
if (
231252
isTrustedHuggingFaceUrl(url) &&

src/platform/settings/constants/coreSettings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,14 @@ export const CORE_SETTINGS: SettingParams[] = [
437437
options: ['filename', 'title'],
438438
defaultValue: 'title'
439439
},
440+
{
441+
id: 'Comfy.ModelLibrary.HuggingFaceMirror',
442+
name: 'HuggingFace mirror URL',
443+
tooltip:
444+
'Optional mirror for Hugging Face model downloads. Leave empty to use the official huggingface.co (e.g. https://hf-mirror.com). Applies to the "Download All missing models" workflow button and the file-size probe shown in the UI.',
445+
type: 'text',
446+
defaultValue: ''
447+
},
440448
{
441449
id: 'Comfy.Locale',
442450
name: 'Language',

src/schemas/apiSchema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ const zSettings = z.object({
343343
'Comfy.LinkRelease.Action': zLinkReleaseTriggerAction,
344344
'Comfy.LinkRelease.ActionShift': zLinkReleaseTriggerAction,
345345
'Comfy.ModelLibrary.AutoLoadAll': z.boolean(),
346+
'Comfy.ModelLibrary.HuggingFaceMirror': z.string(),
346347
'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']),
347348
'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(),
348349
'Comfy.NodeSearchBoxImpl.FollowCursor': z.boolean(),

0 commit comments

Comments
 (0)