← back to Exo
Ciaran/hf token (#1250)
8027d7933f24fc2cf8439d6504867315d0e825a3 · 2026-01-22 20:39:53 +0000 · ciaranbor
## Motivation
black-forest-labs models require hf auth and signup to download. We
don't handle this gracefully.
https://github.com/exo-explore/exo/issues/1242
## Changes
- Handle auth errors
- Surface error to UI and suggest resolution
- Support using HF_TOKEN env variable for auto
- Hide image functionality behind `EXO_ENABLE_IMAGE_MODELS=true` for now
## Why It Works
Users are presented with actionable feedback when issue occurs
## Test Plan
### Manual Testing
Confirmed loading black-forest-labs model in UI presents the issue in
the UI.
Confirmed both `hf auto login` and setting `HF_TOKEN` resolve the issue
Files touched
M app/EXO/EXO/ContentView.swiftM app/EXO/EXO/ExoProcessController.swiftM dashboard/package-lock.jsonM dashboard/src/routes/+page.svelteM src/exo/master/api.pyM src/exo/shared/constants.pyM src/exo/shared/models/model_cards.pyM src/exo/worker/download/download_utils.pyM src/exo/worker/download/huggingface_utils.pyM src/exo/worker/main.pyM src/exo/worker/plan.py
Diff
commit 8027d7933f24fc2cf8439d6504867315d0e825a3
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Thu Jan 22 20:39:53 2026 +0000
Ciaran/hf token (#1250)
## Motivation
black-forest-labs models require hf auth and signup to download. We
don't handle this gracefully.
https://github.com/exo-explore/exo/issues/1242
## Changes
- Handle auth errors
- Surface error to UI and suggest resolution
- Support using HF_TOKEN env variable for auto
- Hide image functionality behind `EXO_ENABLE_IMAGE_MODELS=true` for now
## Why It Works
Users are presented with actionable feedback when issue occurs
## Test Plan
### Manual Testing
Confirmed loading black-forest-labs model in UI presents the issue in
the UI.
Confirmed both `hf auto login` and setting `HF_TOKEN` resolve the issue
---
app/EXO/EXO/ContentView.swift | 45 ++++
app/EXO/EXO/ExoProcessController.swift | 24 +++
dashboard/package-lock.json | 10 +
dashboard/src/routes/+page.svelte | 44 ++++
src/exo/master/api.py | 5 +-
src/exo/shared/constants.py | 4 +
src/exo/shared/models/model_cards.py | 312 ++++++++++++++-------------
src/exo/worker/download/download_utils.py | 35 +++
src/exo/worker/download/huggingface_utils.py | 6 +-
src/exo/worker/main.py | 29 ++-
src/exo/worker/plan.py | 4 +-
11 files changed, 361 insertions(+), 157 deletions(-)
diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
index a604fa46..7c9def14 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -24,6 +24,8 @@ struct ContentView: View {
@State private var bugReportMessage: String?
@State private var uninstallInProgress = false
@State private var pendingNamespace: String = ""
+ @State private var pendingHFToken: String = ""
+ @State private var pendingEnableImageModels = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -303,6 +305,49 @@ struct ContentView: View {
.disabled(pendingNamespace == controller.customNamespace)
}
}
+ VStack(alignment: .leading, spacing: 4) {
+ Text("HuggingFace Token")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ HStack {
+ SecureField("optional", text: $pendingHFToken)
+ .textFieldStyle(.roundedBorder)
+ .font(.caption2)
+ .onAppear {
+ pendingHFToken = controller.hfToken
+ }
+ Button("Save & Restart") {
+ controller.hfToken = pendingHFToken
+ if controller.status == .running || controller.status == .starting {
+ controller.restart()
+ }
+ }
+ .font(.caption2)
+ .disabled(pendingHFToken == controller.hfToken)
+ }
+ }
+ Divider()
+ HStack {
+ Toggle(
+ "Enable Image Models (experimental)", isOn: $pendingEnableImageModels
+ )
+ .toggleStyle(.switch)
+ .font(.caption2)
+ .onAppear {
+ pendingEnableImageModels = controller.enableImageModels
+ }
+
+ Spacer()
+
+ Button("Save & Restart") {
+ controller.enableImageModels = pendingEnableImageModels
+ if controller.status == .running || controller.status == .starting {
+ controller.restart()
+ }
+ }
+ .font(.caption2)
+ .disabled(pendingEnableImageModels == controller.enableImageModels)
+ }
HoverButton(title: "Check for Updates", small: true) {
updater.checkForUpdates()
}
diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift
index 69d8e02a..2dec3868 100644
--- a/app/EXO/EXO/ExoProcessController.swift
+++ b/app/EXO/EXO/ExoProcessController.swift
@@ -3,6 +3,8 @@ import Combine
import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
+private let hfTokenKey = "EXOHFToken"
+private let enableImageModelsKey = "EXOEnableImageModels"
@MainActor
final class ExoProcessController: ObservableObject {
@@ -37,6 +39,22 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(customNamespace, forKey: customNamespaceKey)
}
}
+ @Published var hfToken: String = {
+ return UserDefaults.standard.string(forKey: hfTokenKey) ?? ""
+ }()
+ {
+ didSet {
+ UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
+ }
+ }
+ @Published var enableImageModels: Bool = {
+ return UserDefaults.standard.bool(forKey: enableImageModelsKey)
+ }()
+ {
+ didSet {
+ UserDefaults.standard.set(enableImageModels, forKey: enableImageModelsKey)
+ }
+ }
private var process: Process?
private var runtimeDirectoryURL: URL?
@@ -191,6 +209,12 @@ final class ExoProcessController: ObservableObject {
var environment = ProcessInfo.processInfo.environment
environment["EXO_RUNTIME_DIR"] = runtimeURL.path
environment["EXO_LIBP2P_NAMESPACE"] = computeNamespace()
+ if !hfToken.isEmpty {
+ environment["HF_TOKEN"] = hfToken
+ }
+ if enableImageModels {
+ environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
+ }
var paths: [String] = []
if let existing = environment["PATH"], !existing.isEmpty {
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 345c73d2..e28a6b60 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -865,6 +865,7 @@
"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -904,6 +905,7 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -1520,6 +1522,7 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1529,6 +1532,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1941,6 +1945,7 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -2648,6 +2653,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -2690,6 +2696,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -2862,6 +2869,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz",
"integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3006,6 +3014,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3027,6 +3036,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index f32948fc..99fa1049 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -732,6 +732,8 @@
instanceWrapped: unknown,
): {
isDownloading: boolean;
+ isFailed: boolean;
+ errorMessage: string | null;
progress: DownloadProgress | null;
statusText: string;
perNode: Array<{
@@ -743,6 +745,8 @@
if (!downloadsData || Object.keys(downloadsData).length === 0) {
return {
isDownloading: false,
+ isFailed: false,
+ errorMessage: null,
progress: null,
statusText: "RUNNING",
perNode: [],
@@ -754,6 +758,8 @@
if (!instance || typeof instance !== "object") {
return {
isDownloading: false,
+ isFailed: false,
+ errorMessage: null,
progress: null,
statusText: "PREPARING",
perNode: [],
@@ -809,6 +815,26 @@
downloadKind
] as Record<string, unknown>;
+ // Handle DownloadFailed - return immediately with error info
+ if (downloadKind === "DownloadFailed") {
+ const downloadModelId = extractModelIdFromDownload(downloadPayload);
+ if (
+ instanceModelId &&
+ downloadModelId &&
+ downloadModelId === instanceModelId
+ ) {
+ return {
+ isDownloading: false,
+ isFailed: true,
+ errorMessage:
+ (downloadPayload.errorMessage as string) || "Download failed",
+ progress: null,
+ statusText: "FAILED",
+ perNode: [],
+ };
+ }
+ }
+
if (downloadKind !== "DownloadOngoing") continue;
if (!downloadPayload) continue;
@@ -844,6 +870,8 @@
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
+ isFailed: statusInfo.statusText === "FAILED",
+ errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: [],
@@ -856,6 +884,8 @@
return {
isDownloading: true,
+ isFailed: false,
+ errorMessage: null,
progress: {
totalBytes,
downloadedBytes,
@@ -2061,6 +2091,13 @@
>
{downloadInfo.statusText}
</div>
+ {#if downloadInfo.isFailed && downloadInfo.errorMessage}
+ <div
+ class="text-xs text-red-400/80 font-mono mt-1 break-words"
+ >
+ {downloadInfo.errorMessage}
+ </div>
+ {/if}
{/if}
</div>
</div>
@@ -2993,6 +3030,13 @@
>
{downloadInfo.statusText}
</div>
+ {#if downloadInfo.isFailed && downloadInfo.errorMessage}
+ <div
+ class="text-xs text-red-400/80 font-mono mt-1 break-words"
+ >
+ {downloadInfo.errorMessage}
+ </div>
+ {/if}
{/if}
</div>
</div>
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index 5c0a46b1..cdcd30d0 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -21,7 +21,10 @@ from loguru import logger
from exo.master.image_store import ImageStore
from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
-from exo.shared.constants import EXO_IMAGE_CACHE_DIR, EXO_MAX_CHUNK_SIZE
+from exo.shared.constants import (
+ EXO_IMAGE_CACHE_DIR,
+ EXO_MAX_CHUNK_SIZE,
+)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
from exo.shared.models.model_cards import (
diff --git a/src/exo/shared/constants.py b/src/exo/shared/constants.py
index 242d520d..dfd610b4 100644
--- a/src/exo/shared/constants.py
+++ b/src/exo/shared/constants.py
@@ -49,3 +49,7 @@ LIBP2P_COMMANDS_TOPIC = "commands"
EXO_MAX_CHUNK_SIZE = 512 * 1024
EXO_IMAGE_CACHE_DIR = EXO_CACHE_HOME / "images"
+
+EXO_ENABLE_IMAGE_MODELS = (
+ os.getenv("EXO_ENABLE_IMAGE_MODELS", "false").lower() == "true"
+)
diff --git a/src/exo/shared/models/model_cards.py b/src/exo/shared/models/model_cards.py
index 13b6e9aa..aa5d857a 100644
--- a/src/exo/shared/models/model_cards.py
+++ b/src/exo/shared/models/model_cards.py
@@ -9,6 +9,7 @@ from huggingface_hub import model_info
from loguru import logger
from pydantic import BaseModel, Field, PositiveInt, field_validator
+from exo.shared.constants import EXO_ENABLE_IMAGE_MODELS
from exo.shared.types.common import ModelId
from exo.shared.types.memory import Memory
from exo.utils.pydantic_ext import CamelCaseModel
@@ -410,161 +411,166 @@ MODEL_CARDS: dict[str, ModelCard] = {
supports_tensor=True,
tasks=[ModelTask.TextGeneration],
),
- # Image models commented out - feature not stable (see https://github.com/exo-explore/exo/issues/1242)
- # "flux1-schnell": ModelCard(
- # model_id=ModelId("black-forest-labs/FLUX.1-schnell"),
- # storage_size=Memory.from_bytes(23782357120 + 9524621312),
- # n_layers=57,
- # hidden_size=1,
- # supports_tensor=False,
- # tasks=[ModelTask.TextToImage],
- # components=[
- # ComponentInfo(
- # component_name="text_encoder",
- # component_path="text_encoder/",
- # storage_size=Memory.from_kb(0),
- # n_layers=12,
- # can_shard=False,
- # safetensors_index_filename=None, # Single file
- # ),
- # ComponentInfo(
- # component_name="text_encoder_2",
- # component_path="text_encoder_2/",
- # storage_size=Memory.from_bytes(9524621312),
- # n_layers=24,
- # can_shard=False,
- # safetensors_index_filename="model.safetensors.index.json",
- # ),
- # ComponentInfo(
- # component_name="transformer",
- # component_path="transformer/",
- # storage_size=Memory.from_bytes(23782357120),
- # n_layers=57, # 19 transformer_blocks + 38 single_transformer_blocks
- # can_shard=True,
- # safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
- # ),
- # ComponentInfo(
- # component_name="vae",
- # component_path="vae/",
- # storage_size=Memory.from_kb(0),
- # n_layers=None,
- # can_shard=False,
- # safetensors_index_filename=None,
- # ),
- # ],
- # ),
- # "flux1-dev": ModelCard(
- # model_id=ModelId("black-forest-labs/FLUX.1-dev"),
- # storage_size=Memory.from_bytes(23782357120 + 9524621312),
- # n_layers=57,
- # hidden_size=1,
- # supports_tensor=False,
- # tasks=[ModelTask.TextToImage, ModelTask.ImageToImage],
- # components=[
- # ComponentInfo(
- # component_name="text_encoder",
- # component_path="text_encoder/",
- # storage_size=Memory.from_kb(0),
- # n_layers=12,
- # can_shard=False,
- # safetensors_index_filename=None, # Single file
- # ),
- # ComponentInfo(
- # component_name="text_encoder_2",
- # component_path="text_encoder_2/",
- # storage_size=Memory.from_bytes(9524621312),
- # n_layers=24,
- # can_shard=False,
- # safetensors_index_filename="model.safetensors.index.json",
- # ),
- # ComponentInfo(
- # component_name="transformer",
- # component_path="transformer/",
- # storage_size=Memory.from_bytes(23802816640),
- # n_layers=57, # 19 transformer_blocks + 38 single_transformer_blocks
- # can_shard=True,
- # safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
- # ),
- # ComponentInfo(
- # component_name="vae",
- # component_path="vae/",
- # storage_size=Memory.from_kb(0),
- # n_layers=None,
- # can_shard=False,
- # safetensors_index_filename=None,
- # ),
- # ],
- # ),
- # "qwen-image": ModelCard(
- # model_id=ModelId("Qwen/Qwen-Image"),
- # storage_size=Memory.from_bytes(16584333312 + 40860802176),
- # n_layers=60, # Qwen has 60 transformer blocks (all joint-style)
- # hidden_size=1,
- # supports_tensor=False,
- # tasks=[ModelTask.TextToImage, ModelTask.ImageToImage],
- # components=[
- # ComponentInfo(
- # component_name="text_encoder",
- # component_path="text_encoder/",
- # storage_size=Memory.from_kb(16584333312),
- # n_layers=12,
- # can_shard=False,
- # safetensors_index_filename=None, # Single file
- # ),
- # ComponentInfo(
- # component_name="transformer",
- # component_path="transformer/",
- # storage_size=Memory.from_bytes(40860802176),
- # n_layers=60,
- # can_shard=True,
- # safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
- # ),
- # ComponentInfo(
- # component_name="vae",
- # component_path="vae/",
- # storage_size=Memory.from_kb(0),
- # n_layers=None,
- # can_shard=False,
- # safetensors_index_filename=None,
- # ),
- # ],
- # ),
- # "qwen-image-edit-2509": ModelCard(
- # model_id=ModelId("Qwen/Qwen-Image-Edit-2509"),
- # storage_size=Memory.from_bytes(16584333312 + 40860802176),
- # n_layers=60, # Qwen has 60 transformer blocks (all joint-style)
- # hidden_size=1,
- # supports_tensor=False,
- # tasks=[ModelTask.ImageToImage],
- # components=[
- # ComponentInfo(
- # component_name="text_encoder",
- # component_path="text_encoder/",
- # storage_size=Memory.from_kb(16584333312),
- # n_layers=12,
- # can_shard=False,
- # safetensors_index_filename=None, # Single file
- # ),
- # ComponentInfo(
- # component_name="transformer",
- # component_path="transformer/",
- # storage_size=Memory.from_bytes(40860802176),
- # n_layers=60,
- # can_shard=True,
- # safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
- # ),
- # ComponentInfo(
- # component_name="vae",
- # component_path="vae/",
- # storage_size=Memory.from_kb(0),
- # n_layers=None,
- # can_shard=False,
- # safetensors_index_filename=None,
- # ),
- # ],
- # ),
}
+_IMAGE_MODEL_CARDS: dict[str, ModelCard] = {
+ "flux1-schnell": ModelCard(
+ model_id=ModelId("black-forest-labs/FLUX.1-schnell"),
+ storage_size=Memory.from_bytes(23782357120 + 9524621312),
+ n_layers=57,
+ hidden_size=1,
+ supports_tensor=False,
+ tasks=[ModelTask.TextToImage],
+ components=[
+ ComponentInfo(
+ component_name="text_encoder",
+ component_path="text_encoder/",
+ storage_size=Memory.from_kb(0),
+ n_layers=12,
+ can_shard=False,
+ safetensors_index_filename=None, # Single file
+ ),
+ ComponentInfo(
+ component_name="text_encoder_2",
+ component_path="text_encoder_2/",
+ storage_size=Memory.from_bytes(9524621312),
+ n_layers=24,
+ can_shard=False,
+ safetensors_index_filename="model.safetensors.index.json",
+ ),
+ ComponentInfo(
+ component_name="transformer",
+ component_path="transformer/",
+ storage_size=Memory.from_bytes(23782357120),
+ n_layers=57, # 19 transformer_blocks + 38 single_transformer_blocks
+ can_shard=True,
+ safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
+ ),
+ ComponentInfo(
+ component_name="vae",
+ component_path="vae/",
+ storage_size=Memory.from_kb(0),
+ n_layers=None,
+ can_shard=False,
+ safetensors_index_filename=None,
+ ),
+ ],
+ ),
+ "flux1-dev": ModelCard(
+ model_id=ModelId("black-forest-labs/FLUX.1-dev"),
+ storage_size=Memory.from_bytes(23782357120 + 9524621312),
+ n_layers=57,
+ hidden_size=1,
+ supports_tensor=False,
+ tasks=[ModelTask.TextToImage, ModelTask.ImageToImage],
+ components=[
+ ComponentInfo(
+ component_name="text_encoder",
+ component_path="text_encoder/",
+ storage_size=Memory.from_kb(0),
+ n_layers=12,
+ can_shard=False,
+ safetensors_index_filename=None, # Single file
+ ),
+ ComponentInfo(
+ component_name="text_encoder_2",
+ component_path="text_encoder_2/",
+ storage_size=Memory.from_bytes(9524621312),
+ n_layers=24,
+ can_shard=False,
+ safetensors_index_filename="model.safetensors.index.json",
+ ),
+ ComponentInfo(
+ component_name="transformer",
+ component_path="transformer/",
+ storage_size=Memory.from_bytes(23802816640),
+ n_layers=57, # 19 transformer_blocks + 38 single_transformer_blocks
+ can_shard=True,
+ safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
+ ),
+ ComponentInfo(
+ component_name="vae",
+ component_path="vae/",
+ storage_size=Memory.from_kb(0),
+ n_layers=None,
+ can_shard=False,
+ safetensors_index_filename=None,
+ ),
+ ],
+ ),
+ "qwen-image": ModelCard(
+ model_id=ModelId("Qwen/Qwen-Image"),
+ storage_size=Memory.from_bytes(16584333312 + 40860802176),
+ n_layers=60, # Qwen has 60 transformer blocks (all joint-style)
+ hidden_size=1,
+ supports_tensor=False,
+ tasks=[ModelTask.TextToImage, ModelTask.ImageToImage],
+ components=[
+ ComponentInfo(
+ component_name="text_encoder",
+ component_path="text_encoder/",
+ storage_size=Memory.from_kb(16584333312),
+ n_layers=12,
+ can_shard=False,
+ safetensors_index_filename=None, # Single file
+ ),
+ ComponentInfo(
+ component_name="transformer",
+ component_path="transformer/",
+ storage_size=Memory.from_bytes(40860802176),
+ n_layers=60,
+ can_shard=True,
+ safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
+ ),
+ ComponentInfo(
+ component_name="vae",
+ component_path="vae/",
+ storage_size=Memory.from_kb(0),
+ n_layers=None,
+ can_shard=False,
+ safetensors_index_filename=None,
+ ),
+ ],
+ ),
+ "qwen-image-edit-2509": ModelCard(
+ model_id=ModelId("Qwen/Qwen-Image-Edit-2509"),
+ storage_size=Memory.from_bytes(16584333312 + 40860802176),
+ n_layers=60, # Qwen has 60 transformer blocks (all joint-style)
+ hidden_size=1,
+ supports_tensor=False,
+ tasks=[ModelTask.ImageToImage],
+ components=[
+ ComponentInfo(
+ component_name="text_encoder",
+ component_path="text_encoder/",
+ storage_size=Memory.from_kb(16584333312),
+ n_layers=12,
+ can_shard=False,
+ safetensors_index_filename=None, # Single file
+ ),
+ ComponentInfo(
+ component_name="transformer",
+ component_path="transformer/",
+ storage_size=Memory.from_bytes(40860802176),
+ n_layers=60,
+ can_shard=True,
+ safetensors_index_filename="diffusion_pytorch_model.safetensors.index.json",
+ ),
+ ComponentInfo(
+ component_name="vae",
+ component_path="vae/",
+ storage_size=Memory.from_kb(0),
+ n_layers=None,
+ can_shard=False,
+ safetensors_index_filename=None,
+ ),
+ ],
+ ),
+}
+
+if EXO_ENABLE_IMAGE_MODELS:
+ MODEL_CARDS.update(_IMAGE_MODEL_CARDS)
+
class ConfigData(BaseModel):
model_config = {"extra": "ignore"} # Allow unknown fields
diff --git a/src/exo/worker/download/download_utils.py b/src/exo/worker/download/download_utils.py
index 273621dd..8816d701 100644
--- a/src/exo/worker/download/download_utils.py
+++ b/src/exo/worker/download/download_utils.py
@@ -40,9 +40,31 @@ from exo.worker.download.huggingface_utils import (
get_allow_patterns,
get_auth_headers,
get_hf_endpoint,
+ get_hf_token,
)
+class HuggingFaceAuthenticationError(Exception):
+ """Raised when HuggingFace returns 401/403 for a model download."""
+
+
+async def _build_auth_error_message(status_code: int, model_id: ModelId) -> str:
+ token = await get_hf_token()
+ if status_code == 401 and token is None:
+ return (
+ f"Model '{model_id}' requires authentication. "
+ f"Set HF_TOKEN in the app's Advanced settings, set the HF_TOKEN environment variable, or run `hf auth login`. "
+ f"Get a token at https://huggingface.co/settings/tokens"
+ )
+ elif status_code == 403:
+ return (
+ f"Access denied to '{model_id}'. "
+ f"Please accept the model terms at https://huggingface.co/{model_id}"
+ )
+ else:
+ return f"Authentication failed for '{model_id}' (HTTP {status_code})"
+
+
def trim_etag(etag: str) -> str:
if (etag[0] == '"' and etag[-1] == '"') or (etag[0] == "'" and etag[-1] == "'"):
return etag[1:-1]
@@ -147,6 +169,8 @@ async def fetch_file_list_with_retry(
for attempt in range(n_attempts):
try:
return await _fetch_file_list(model_id, revision, path, recursive)
+ except HuggingFaceAuthenticationError:
+ raise
except Exception as e:
if attempt == n_attempts - 1:
raise e
@@ -167,6 +191,9 @@ async def _fetch_file_list(
create_http_session(timeout_profile="short") as session,
session.get(url, headers=headers) as response,
):
+ if response.status in [401, 403]:
+ msg = await _build_auth_error_message(response.status, model_id)
+ raise HuggingFaceAuthenticationError(msg)
if response.status == 200:
data_json = await response.text()
data = TypeAdapter(list[FileListEntry]).validate_json(data_json)
@@ -256,6 +283,9 @@ async def file_meta(
# Otherwise, follow the redirect to get authoritative size/hash
redirected_location = r.headers.get("location")
return await file_meta(model_id, revision, path, redirected_location)
+ if r.status in [401, 403]:
+ msg = await _build_auth_error_message(r.status, model_id)
+ raise HuggingFaceAuthenticationError(msg)
content_length = int(
r.headers.get("x-linked-size") or r.headers.get("content-length") or 0
)
@@ -279,6 +309,8 @@ async def download_file_with_retry(
return await _download_file(
model_id, revision, path, target_dir, on_progress
)
+ except HuggingFaceAuthenticationError:
+ raise
except Exception as e:
if isinstance(e, FileNotFoundError) or attempt == n_attempts - 1:
raise e
@@ -322,6 +354,9 @@ async def _download_file(
):
if r.status == 404:
raise FileNotFoundError(f"File not found: {url}")
+ if r.status in [401, 403]:
+ msg = await _build_auth_error_message(r.status, model_id)
+ raise HuggingFaceAuthenticationError(msg)
assert r.status in [200, 206], (
f"Failed to download {path} from {url}: {r.status}"
)
diff --git a/src/exo/worker/download/huggingface_utils.py b/src/exo/worker/download/huggingface_utils.py
index abe12107..d86bd547 100644
--- a/src/exo/worker/download/huggingface_utils.py
+++ b/src/exo/worker/download/huggingface_utils.py
@@ -68,7 +68,11 @@ def get_hf_home() -> Path:
async def get_hf_token() -> str | None:
- """Retrieve the Hugging Face token from the user's HF_HOME directory."""
+ """Retrieve the Hugging Face token from HF_TOKEN env var or HF_HOME directory."""
+ # Check environment variable first
+ if token := os.environ.get("HF_TOKEN"):
+ return token
+ # Fall back to file-based token
token_path = get_hf_home() / "token"
if await aios.path.exists(token_path):
async with aiofiles.open(token_path, "r") as f:
diff --git a/src/exo/worker/main.py b/src/exo/worker/main.py
index 153a9d67..e861884a 100644
--- a/src/exo/worker/main.py
+++ b/src/exo/worker/main.py
@@ -38,6 +38,7 @@ from exo.shared.types.tasks import (
from exo.shared.types.topology import Connection, SocketConnection
from exo.shared.types.worker.downloads import (
DownloadCompleted,
+ DownloadFailed,
DownloadOngoing,
DownloadPending,
DownloadProgress,
@@ -443,7 +444,33 @@ class Worker:
last_progress_time = current_time()
self.shard_downloader.on_progress(download_progress_callback)
- self._tg.start_soon(self.shard_downloader.ensure_shard, task.shard_metadata)
+
+ async def download_with_error_handling() -> None:
+ try:
+ await self.shard_downloader.ensure_shard(task.shard_metadata)
+ except Exception as e:
+ error_message = str(e)
+ logger.error(
+ f"Download failed for {task.shard_metadata.model_card.model_id}: {error_message}"
+ )
+ failed_status = DownloadFailed(
+ node_id=self.node_id,
+ shard_metadata=task.shard_metadata,
+ error_message=error_message,
+ )
+ self.download_status[task.shard_metadata.model_card.model_id] = (
+ failed_status
+ )
+ await self.event_sender.send(
+ NodeDownloadProgress(download_progress=failed_status)
+ )
+ await self.event_sender.send(
+ TaskStatusUpdated(
+ task_id=task.task_id, task_status=TaskStatus.Failed
+ )
+ )
+
+ self._tg.start_soon(download_with_error_handling)
async def _forward_events(self) -> None:
with self.event_receiver as events:
diff --git a/src/exo/worker/plan.py b/src/exo/worker/plan.py
index 9f8f7d52..7877b96c 100644
--- a/src/exo/worker/plan.py
+++ b/src/exo/worker/plan.py
@@ -20,6 +20,7 @@ from exo.shared.types.tasks import (
)
from exo.shared.types.worker.downloads import (
DownloadCompleted,
+ DownloadFailed,
DownloadOngoing,
DownloadProgress,
)
@@ -122,7 +123,8 @@ def _model_needs_download(
if isinstance(runner.status, RunnerIdle) and (
model_id not in download_status
or not isinstance(
- download_status[model_id], (DownloadOngoing, DownloadCompleted)
+ download_status[model_id],
+ (DownloadOngoing, DownloadCompleted, DownloadFailed),
)
):
# We don't invalidate download_status randomly in case a file gets deleted on disk
← ac6efa74 add kimi tool parseing
·
back to Exo
·
Replace LaunchDaemon with dynamic Thunderbolt Bridge loop de 43f12f5d →