← back to Exo
Downloads
449fdac27a01d55e176cd6539cdd72c03d325ba4 · 2025-07-21 22:42:37 +0100 · Alex Cheema
Files touched
M pyproject.tomlM shared/graphs.pyA shared/types/graphs/pydantic.pyM shared/types/state.pyM shared/types/worker/shards.pyM uv.lockA worker/download/download_utils.pyA worker/download/huggingface_utils.pyA worker/download/impl_shard_downloader.pyA worker/download/model_cards.pyA worker/download/model_meta.pyA worker/download/shard_downloader.pyA worker/download/test_download.pyM worker/main.pyM worker/tests/conftest.pyM worker/tests/test_serdes.pyM worker/tests/test_supervisor.pyM worker/tests/test_worker_handlers.pyM worker/tests/test_worker_plan.py
Diff
commit 449fdac27a01d55e176cd6539cdd72c03d325ba4
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Mon Jul 21 22:42:37 2025 +0100
Downloads
---
pyproject.toml | 5 +-
shared/graphs.py | 42 ++-
shared/types/graphs/pydantic.py | 8 +
shared/types/state.py | 8 +-
shared/types/worker/shards.py | 15 +-
uv.lock | 245 ++++++++++++++++++
worker/download/download_utils.py | 430 +++++++++++++++++++++++++++++++
worker/download/huggingface_utils.py | 97 +++++++
worker/download/impl_shard_downloader.py | 128 +++++++++
worker/download/model_cards.py | 133 ++++++++++
worker/download/model_meta.py | 124 +++++++++
worker/download/shard_downloader.py | 96 +++++++
worker/download/test_download.py | 54 ++++
worker/main.py | 9 +-
worker/tests/conftest.py | 6 +-
worker/tests/test_serdes.py | 2 +
worker/tests/test_supervisor.py | 6 +
worker/tests/test_worker_handlers.py | 15 +-
worker/tests/test_worker_plan.py | 5 +-
19 files changed, 1404 insertions(+), 24 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 6f88bc0b..6b3c4719 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,8 +5,11 @@ description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
+ "aiofiles>=24.1.0",
+ "aiohttp>=3.12.14",
"exo-master",
"exo-worker",
+ "types-aiofiles>=24.1.0.20250708",
]
# dependencies only required for development
@@ -108,4 +111,4 @@ extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
[tool.pytest.ini_options]
pythonpath = "."
-asyncio_mode = "auto"
\ No newline at end of file
+asyncio_mode = "auto"
diff --git a/shared/graphs.py b/shared/graphs.py
index 892f3558..a48474da 100644
--- a/shared/graphs.py
+++ b/shared/graphs.py
@@ -1,8 +1,9 @@
from dataclasses import dataclass
-from typing import Mapping, Set
+from typing import Any, Callable, Mapping, Set
import rustworkx as rx
from pydantic import TypeAdapter
+from pydantic_core import core_schema
from shared.types.graphs.common import (
Edge,
@@ -15,6 +16,7 @@ from shared.types.graphs.common import (
VertexIdT,
VertexTypeT,
)
+from shared.types.graphs.pydantic import PydanticGraph
@dataclass(frozen=True)
@@ -52,6 +54,44 @@ class Graph(MutableGraphProtocol[EdgeTypeT, VertexTypeT, EdgeIdT, VertexIdT]):
self._vertex_id_to_index = {}
self._edge_id_to_endpoints = {}
+ # TODO: I'm not sure if this is the right thing, but we'll simplify the graph stuff anyway so fine for now.
+ @classmethod
+ def __get_pydantic_core_schema__(
+ cls,
+ _source: type[Any],
+ handler: Callable[[Any], core_schema.CoreSchema],
+ ) -> core_schema.CoreSchema:
+ pydantic_graph_schema = handler(PydanticGraph)
+
+ def serializer(
+ instance: "Graph[EdgeTypeT, VertexTypeT, EdgeIdT, VertexIdT]",
+ ) -> dict[str, Any]:
+ return {
+ "vertices": list(instance.get_vertex_data(instance.list_vertices())),
+ "edges": list(instance.get_edge_data(instance.list_edges())),
+ }
+
+ return core_schema.json_or_python_schema(
+ json_schema=pydantic_graph_schema,
+ python_schema=core_schema.no_info_plain_validator_function(cls.validate),
+ serialization=core_schema.plain_serializer_function_ser_schema(serializer),
+ )
+
+ @classmethod
+ def validate(
+ cls, value: Any # type: ignore
+ ) -> "Graph[EdgeTypeT, VertexTypeT, EdgeIdT, VertexIdT]":
+ if isinstance(value, cls):
+ return value
+
+ if isinstance(value, dict):
+ raise NotImplementedError(
+ "Deserializing a Graph from a dictionary is not yet supported. "
+ "Please initialize the Graph object directly."
+ )
+
+ raise TypeError("Unsupported type for Graph validation")
+
###
# GraphProtocol methods
###
diff --git a/shared/types/graphs/pydantic.py b/shared/types/graphs/pydantic.py
new file mode 100644
index 00000000..2ff9e557
--- /dev/null
+++ b/shared/types/graphs/pydantic.py
@@ -0,0 +1,8 @@
+from typing import Any, List
+
+from pydantic import BaseModel
+
+
+class PydanticGraph(BaseModel):
+ vertices: List[Any]
+ edges: List[Any]
\ No newline at end of file
diff --git a/shared/types/state.py b/shared/types/state.py
index 59a7b1c9..59d51957 100644
--- a/shared/types/state.py
+++ b/shared/types/state.py
@@ -1,8 +1,8 @@
from collections.abc import Mapping, Sequence
from enum import Enum
-from queue import Queue
+from typing import List
-from pydantic import BaseModel, TypeAdapter
+from pydantic import BaseModel, Field, TypeAdapter
from shared.types.common import NodeId
from shared.types.graphs.topology import (
@@ -36,6 +36,6 @@ class State(BaseModel):
edge_base=TypeAdapter(TopologyEdge), vertex_base=TypeAdapter(TopologyNode)
)
history: Sequence[OrphanedPartOfTopology] = []
- task_inbox: Queue[Task] = Queue()
- task_outbox: Queue[Task] = Queue()
+ task_inbox: List[Task] = Field(default_factory=list)
+ task_outbox: List[Task] = Field(default_factory=list)
cache_policy: CachePolicy = CachePolicy.KeepAll
diff --git a/shared/types/worker/shards.py b/shared/types/worker/shards.py
index 3decee54..a8fe5526 100644
--- a/shared/types/worker/shards.py
+++ b/shared/types/worker/shards.py
@@ -1,7 +1,7 @@
from enum import Enum
from typing import Annotated, Generic, Literal, TypeAlias, TypeVar
-from pydantic import BaseModel, DirectoryPath, Field, TypeAdapter
+from pydantic import BaseModel, Field, TypeAdapter
from shared.types.common import NodeId
from shared.types.models import ModelId
@@ -24,7 +24,6 @@ class BaseShardMetadata(BaseModel, Generic[PartitionStrategyT]):
device_rank: int
world_size: int
model_id: ModelId
- model_path: DirectoryPath
class PipelineShardMetadata(BaseShardMetadata[Literal[PartitionStrategy.pipeline]]):
@@ -37,6 +36,18 @@ class PipelineShardMetadata(BaseShardMetadata[Literal[PartitionStrategy.pipeline
)
start_layer: Annotated[int, Field(ge=0)]
end_layer: Annotated[int, Field(ge=0)]
+ n_layers: Annotated[int, Field(ge=0)]
+
+ @property
+ def is_first_layer(self) -> bool:
+ return self.start_layer == 0
+
+ @property
+ def is_last_layer(self) -> bool:
+ return self.end_layer == self.n_layers - 1
+
+ def __hash__(self) -> int:
+ return hash((self.model_id, self.start_layer, self.end_layer, self.n_layers))
ShardMetadata = Annotated[
diff --git a/uv.lock b/uv.lock
index c10602aa..7bbee8d9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -20,6 +20,68 @@ members = [
"exo-worker",
]
+[[package]]
+name = "aiofiles"
+version = "24.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" },
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.12.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e39ad954107ebf213a2325038a3e7a506be3d98e1435e1f82086eec4cde2/aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2", size = 7822921, upload-time = "2025-07-10T13:05:33.968Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/48/e0d2fa8ac778008071e7b79b93ab31ef14ab88804d7ba71b5c964a7c844e/aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767", size = 695471, upload-time = "2025-07-10T13:04:20.124Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/e7/f73206afa33100804f790b71092888f47df65fd9a4cd0e6800d7c6826441/aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e", size = 473128, upload-time = "2025-07-10T13:04:21.928Z" },
+ { url = "https://files.pythonhosted.org/packages/df/e2/4dd00180be551a6e7ee979c20fc7c32727f4889ee3fd5b0586e0d47f30e1/aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63", size = 465426, upload-time = "2025-07-10T13:04:24.071Z" },
+ { url = "https://files.pythonhosted.org/packages/de/dd/525ed198a0bb674a323e93e4d928443a680860802c44fa7922d39436b48b/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d", size = 1704252, upload-time = "2025-07-10T13:04:26.049Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/b1/01e542aed560a968f692ab4fc4323286e8bc4daae83348cd63588e4f33e3/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab", size = 1685514, upload-time = "2025-07-10T13:04:28.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/06/93669694dc5fdabdc01338791e70452d60ce21ea0946a878715688d5a191/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4", size = 1737586, upload-time = "2025-07-10T13:04:30.195Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/3a/18991048ffc1407ca51efb49ba8bcc1645961f97f563a6c480cdf0286310/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026", size = 1786958, upload-time = "2025-07-10T13:04:32.482Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a8/81e237f89a32029f9b4a805af6dffc378f8459c7b9942712c809ff9e76e5/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd", size = 1709287, upload-time = "2025-07-10T13:04:34.493Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e3/bd67a11b0fe7fc12c6030473afd9e44223d456f500f7cf526dbaa259ae46/aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88", size = 1622990, upload-time = "2025-07-10T13:04:36.433Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ba/e0cc8e0f0d9ce0904e3cf2d6fa41904e379e718a013c721b781d53dcbcca/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086", size = 1676015, upload-time = "2025-07-10T13:04:38.958Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/b3/1e6c960520bda094c48b56de29a3d978254637ace7168dd97ddc273d0d6c/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933", size = 1707678, upload-time = "2025-07-10T13:04:41.275Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/19/929a3eb8c35b7f9f076a462eaa9830b32c7f27d3395397665caa5e975614/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151", size = 1650274, upload-time = "2025-07-10T13:04:43.483Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/81682a6f20dd1b18ce3d747de8eba11cbef9b270f567426ff7880b096b48/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8", size = 1726408, upload-time = "2025-07-10T13:04:45.577Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/17/884938dffaa4048302985483f77dfce5ac18339aad9b04ad4aaa5e32b028/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3", size = 1759879, upload-time = "2025-07-10T13:04:47.663Z" },
+ { url = "https://files.pythonhosted.org/packages/95/78/53b081980f50b5cf874359bde707a6eacd6c4be3f5f5c93937e48c9d0025/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758", size = 1708770, upload-time = "2025-07-10T13:04:49.944Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
[[package]]
name = "aiosqlite"
version = "0.21.0"
@@ -54,6 +116,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
+[[package]]
+name = "attrs"
+version = "25.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+]
+
[[package]]
name = "certifi"
version = "2025.7.14"
@@ -97,8 +168,11 @@ name = "exo"
version = "0.2.0"
source = { editable = "." }
dependencies = [
+ { name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "exo-master", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "exo-worker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "types-aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
[package.optional-dependencies]
@@ -116,9 +190,12 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "aiofiles", specifier = ">=24.1.0" },
+ { name = "aiohttp", specifier = ">=3.12.14" },
{ name = "exo-master", editable = "master" },
{ name = "exo-worker", editable = "worker" },
{ name = "mlx", marker = "extra == 'darwin'" },
+ { name = "types-aiofiles", specifier = ">=24.1.0.20250708" },
]
provides-extras = ["darwin"]
@@ -234,6 +311,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
]
+[[package]]
+name = "frozenlist"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" },
+ { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" },
+ { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" },
+ { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" },
+ { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" },
+ { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" },
+ { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" },
+ { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" },
+ { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" },
+ { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
+]
+
[[package]]
name = "fsspec"
version = "2025.7.0"
@@ -487,6 +603,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/e7/d0e576397b61bf90a0bb27819443f723258acd8dd1207684fdef29243ce4/mlx_lm-0.26.0-py3-none-any.whl", hash = "sha256:b00294c26242cd50db4b6e3ec3a2baf1cfdf8ca49a5e6057dce14642fabe0d21", size = 217671, upload-time = "2025-07-08T20:21:29.448Z" },
]
+[[package]]
+name = "multidict"
+version = "6.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/1d/0bebcbbb4f000751fbd09957257903d6e002943fc668d841a4cf2fb7f872/multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55", size = 75843, upload-time = "2025-06-30T15:52:16.155Z" },
+ { url = "https://files.pythonhosted.org/packages/07/8f/cbe241b0434cfe257f65c2b1bcf9e8d5fb52bc708c5061fb29b0fed22bdf/multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b", size = 45053, upload-time = "2025-06-30T15:52:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/32/d2/0b3b23f9dbad5b270b22a3ac3ea73ed0a50ef2d9a390447061178ed6bdb8/multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65", size = 43273, upload-time = "2025-06-30T15:52:19.346Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/fe/6eb68927e823999e3683bc49678eb20374ba9615097d085298fd5b386564/multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3", size = 237124, upload-time = "2025-06-30T15:52:20.773Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/ab/320d8507e7726c460cb77117848b3834ea0d59e769f36fdae495f7669929/multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c", size = 256892, upload-time = "2025-06-30T15:52:22.242Z" },
+ { url = "https://files.pythonhosted.org/packages/76/60/38ee422db515ac69834e60142a1a69111ac96026e76e8e9aa347fd2e4591/multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6", size = 240547, upload-time = "2025-06-30T15:52:23.736Z" },
+ { url = "https://files.pythonhosted.org/packages/27/fb/905224fde2dff042b030c27ad95a7ae744325cf54b890b443d30a789b80e/multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8", size = 266223, upload-time = "2025-06-30T15:52:25.185Z" },
+ { url = "https://files.pythonhosted.org/packages/76/35/dc38ab361051beae08d1a53965e3e1a418752fc5be4d3fb983c5582d8784/multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca", size = 267262, upload-time = "2025-06-30T15:52:26.969Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/a3/0a485b7f36e422421b17e2bbb5a81c1af10eac1d4476f2ff92927c730479/multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884", size = 254345, upload-time = "2025-06-30T15:52:28.467Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/59/bcdd52c1dab7c0e0d75ff19cac751fbd5f850d1fc39172ce809a74aa9ea4/multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7", size = 252248, upload-time = "2025-06-30T15:52:29.938Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a4/2d96aaa6eae8067ce108d4acee6f45ced5728beda55c0f02ae1072c730d1/multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b", size = 250115, upload-time = "2025-06-30T15:52:31.416Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d2/ed9f847fa5c7d0677d4f02ea2c163d5e48573de3f57bacf5670e43a5ffaa/multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c", size = 249649, upload-time = "2025-06-30T15:52:32.996Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/af/9155850372563fc550803d3f25373308aa70f59b52cff25854086ecb4a79/multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b", size = 261203, upload-time = "2025-06-30T15:52:34.521Z" },
+ { url = "https://files.pythonhosted.org/packages/36/2f/c6a728f699896252cf309769089568a33c6439626648843f78743660709d/multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1", size = 258051, upload-time = "2025-06-30T15:52:35.999Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/60/689880776d6b18fa2b70f6cc74ff87dd6c6b9b47bd9cf74c16fecfaa6ad9/multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6", size = 249601, upload-time = "2025-06-30T15:52:37.473Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/58/aaf8114cf34966e084a8cc9517771288adb53465188843d5a19862cb6dc3/multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134", size = 82811, upload-time = "2025-06-30T15:52:43.281Z" },
+ { url = "https://files.pythonhosted.org/packages/71/af/5402e7b58a1f5b987a07ad98f2501fdba2a4f4b4c30cf114e3ce8db64c87/multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37", size = 48304, upload-time = "2025-06-30T15:52:45.026Z" },
+ { url = "https://files.pythonhosted.org/packages/39/65/ab3c8cafe21adb45b24a50266fd747147dec7847425bc2a0f6934b3ae9ce/multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8", size = 46775, upload-time = "2025-06-30T15:52:46.459Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ba/9fcc1b332f67cc0c0c8079e263bfab6660f87fe4e28a35921771ff3eea0d/multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1", size = 229773, upload-time = "2025-06-30T15:52:47.88Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/14/0145a251f555f7c754ce2dcbcd012939bbd1f34f066fa5d28a50e722a054/multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373", size = 250083, upload-time = "2025-06-30T15:52:49.366Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d4/d5c0bd2bbb173b586c249a151a26d2fb3ec7d53c96e42091c9fef4e1f10c/multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e", size = 228980, upload-time = "2025-06-30T15:52:50.903Z" },
+ { url = "https://files.pythonhosted.org/packages/21/32/c9a2d8444a50ec48c4733ccc67254100c10e1c8ae8e40c7a2d2183b59b97/multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f", size = 257776, upload-time = "2025-06-30T15:52:52.764Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d0/14fa1699f4ef629eae08ad6201c6b476098f5efb051b296f4c26be7a9fdf/multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0", size = 256882, upload-time = "2025-06-30T15:52:54.596Z" },
+ { url = "https://files.pythonhosted.org/packages/da/88/84a27570fbe303c65607d517a5f147cd2fc046c2d1da02b84b17b9bdc2aa/multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc", size = 247816, upload-time = "2025-06-30T15:52:56.175Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/60/dca352a0c999ce96a5d8b8ee0b2b9f729dcad2e0b0c195f8286269a2074c/multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f", size = 245341, upload-time = "2025-06-30T15:52:57.752Z" },
+ { url = "https://files.pythonhosted.org/packages/50/ef/433fa3ed06028f03946f3993223dada70fb700f763f70c00079533c34578/multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471", size = 235854, upload-time = "2025-06-30T15:52:59.74Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/1f/487612ab56fbe35715320905215a57fede20de7db40a261759690dc80471/multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2", size = 243432, upload-time = "2025-06-30T15:53:01.602Z" },
+ { url = "https://files.pythonhosted.org/packages/da/6f/ce8b79de16cd885c6f9052c96a3671373d00c59b3ee635ea93e6e81b8ccf/multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648", size = 252731, upload-time = "2025-06-30T15:53:03.517Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/fe/a2514a6aba78e5abefa1624ca85ae18f542d95ac5cde2e3815a9fbf369aa/multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d", size = 247086, upload-time = "2025-06-30T15:53:05.48Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/22/b788718d63bb3cce752d107a57c85fcd1a212c6c778628567c9713f9345a/multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c", size = 243338, upload-time = "2025-06-30T15:53:07.522Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" },
+]
+
[[package]]
name = "networkx"
version = "3.5"
@@ -566,6 +721,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "propcache"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" },
+ { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" },
+ { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" },
+ { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" },
+ { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" },
+ { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
+]
+
[[package]]
name = "protobuf"
version = "6.31.1"
@@ -889,6 +1081,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/88/beb33a79a382fcd2aed0be5222bdc47f41e4bfe7aaa90ae1374f1d8ea2af/transformers-4.53.2-py3-none-any.whl", hash = "sha256:db8f4819bb34f000029c73c3c557e7d06fc1b8e612ec142eecdae3947a9c78bf", size = 10826609, upload-time = "2025-07-11T12:39:05.461Z" },
]
+[[package]]
+name = "types-aiofiles"
+version = "24.1.0.20250708"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/d6/5c44761bc11cb5c7505013a39f397a9016bfb3a5c932032b2db16c38b87b/types_aiofiles-24.1.0.20250708.tar.gz", hash = "sha256:c8207ed7385491ce5ba94da02658164ebd66b69a44e892288c9f20cbbf5284ff", size = 14322, upload-time = "2025-07-08T03:14:44.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/e9/4e0cc79c630040aae0634ac9393341dc2aff1a5be454be9741cc6cc8989f/types_aiofiles-24.1.0.20250708-py3-none-any.whl", hash = "sha256:07f8f06465fd415d9293467d1c66cd074b2c3b62b679e26e353e560a8cf63720", size = 14320, upload-time = "2025-07-08T03:14:44.009Z" },
+]
+
[[package]]
name = "types-protobuf"
version = "6.30.2.20250703"
@@ -927,3 +1128,47 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
+
+[[package]]
+name = "yarl"
+version = "1.20.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" },
+ { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" },
+ { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" },
+ { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" },
+ { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" },
+ { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" },
+ { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" },
+ { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
+]
diff --git a/worker/download/download_utils.py b/worker/download/download_utils.py
new file mode 100644
index 00000000..ce7f2090
--- /dev/null
+++ b/worker/download/download_utils.py
@@ -0,0 +1,430 @@
+import asyncio
+import hashlib
+import os
+import shutil
+import tempfile
+import time
+import traceback
+from datetime import timedelta
+from pathlib import Path
+from typing import Annotated, Callable, Dict, List, Literal, Optional, Tuple, Union
+from urllib.parse import urljoin
+
+import aiofiles
+import aiofiles.os as aios
+import aiohttp
+from pydantic import BaseModel, DirectoryPath, Field, TypeAdapter
+
+from shared.constants import EXO_HOME
+from shared.types.worker.shards import ShardMetadata
+from worker.download.huggingface_utils import (
+ filter_repo_objects,
+ get_allow_patterns,
+ get_auth_headers,
+ get_hf_endpoint,
+)
+
+
+class ModelSafetensorsIndexMetadata(BaseModel):
+ total_size: Annotated[int, Field(ge=0)]
+
+class ModelSafetensorsIndex(BaseModel):
+ metadata: Optional[ModelSafetensorsIndexMetadata]
+ weight_map: Dict[str, str]
+
+class FileListEntry(BaseModel):
+ type: Literal["file", "directory"]
+ path: str
+ size: int | None = None
+
+class RepoFileDownloadProgress(BaseModel):
+ """Progress information for an individual file within a repository download."""
+
+ repo_id: str
+ repo_revision: str
+ file_path: str
+ downloaded: int
+ downloaded_this_session: int
+ total: int
+ speed: float # bytes per second
+ eta: timedelta
+ status: Literal["not_started", "in_progress", "complete"]
+ start_time: float
+
+ class Config:
+ frozen = True
+
+class RepoDownloadProgress(BaseModel):
+ """Aggregated download progress information for a repository/shard combination.
+
+ This structure captures the overall progress of downloading the files
+ required to materialise a particular *shard* of a model. It purposely
+ mirrors the key summary fields emitted by the `RepoProgressEvent` so that
+ the event payload can be cleanly projected onto the long-lived cluster
+ state.
+ """
+
+ repo_id: str
+ repo_revision: str
+ shard: ShardMetadata
+
+ # progress totals
+ completed_files: int
+ total_files: int
+ downloaded_bytes: int
+ downloaded_bytes_this_session: int
+ total_bytes: int
+
+ # speed / eta
+ overall_speed: float # bytes per second
+ overall_eta: timedelta
+
+ # lifecycle status
+ status: Literal["not_started", "in_progress", "complete"]
+
+ # fine-grained file progress keyed by file_path
+ file_progress: Dict[str, RepoFileDownloadProgress] = Field(default_factory=dict)
+
+ class Config:
+ frozen = True # allow use as dict keys if desired
+
+def build_model_path(model_id: str) -> DirectoryPath:
+ return EXO_HOME / "models" / model_id.replace("/", "--")
+
+def exo_tmp() -> Path:
+ return Path(tempfile.gettempdir())/"exo"
+
+async def resolve_model_path_for_repo(repo_id: str) -> Path:
+ return (await ensure_models_dir())/repo_id.replace("/", "--")
+
+async def ensure_exo_home() -> Path:
+ await aios.makedirs(EXO_HOME, exist_ok=True)
+ return EXO_HOME
+
+async def ensure_exo_tmp() -> Path:
+ await aios.makedirs(exo_tmp(), exist_ok=True)
+ return exo_tmp()
+
+async def has_exo_home_read_access() -> bool:
+ try:
+ return await aios.access(EXO_HOME, os.R_OK)
+ except OSError:
+ return False
+
+async def has_exo_home_write_access() -> bool:
+ try:
+ return await aios.access(EXO_HOME, os.W_OK)
+ except OSError:
+ return False
+
+async def ensure_models_dir() -> Path:
+ models_dir = EXO_HOME/"models"
+ await aios.makedirs(models_dir, exist_ok=True)
+ return models_dir
+
+async def delete_model(repo_id: str) -> bool:
+ model_dir = await ensure_models_dir()/repo_id.replace("/", "--")
+ if not await aios.path.exists(model_dir):
+ return False
+ await asyncio.to_thread(shutil.rmtree, model_dir, ignore_errors=False)
+ return True
+
+async def seed_models(seed_dir: Union[str, Path]):
+ """Move model in resources folder of app to .cache/huggingface/hub"""
+ source_dir = Path(seed_dir)
+ dest_dir = await ensure_models_dir()
+ for path in source_dir.iterdir():
+ if path.is_dir() and path.name.startswith("models--"):
+ dest_path = dest_dir/path.name
+ if await aios.path.exists(dest_path):
+ print('Skipping moving model to .cache directory')
+ else:
+ try:
+ await aios.rename(str(path), str(dest_path))
+ except Exception:
+ print(f"Error seeding model {path} to {dest_path}")
+ traceback.print_exc()
+
+async def fetch_file_list_with_cache(repo_id: str, revision: str = "main", recursive: bool = False) -> List[FileListEntry]:
+ cache_file = (await ensure_exo_tmp())/f"{repo_id.replace('/', '--')}--{revision}--file_list.json"
+ if await aios.path.exists(cache_file):
+ async with aiofiles.open(cache_file, 'r') as f:
+ return TypeAdapter(List[FileListEntry]).validate_json(await f.read())
+ file_list = await fetch_file_list_with_retry(repo_id, revision, recursive=recursive)
+ await aios.makedirs(cache_file.parent, exist_ok=True)
+ async with aiofiles.open(cache_file, 'w') as f:
+ await f.write(TypeAdapter(List[FileListEntry]).dump_json(file_list).decode())
+ return file_list
+
+
+async def fetch_file_list_with_retry(repo_id: str, revision: str = "main", path: str = "", recursive: bool = False) -> List[FileListEntry]:
+ n_attempts = 30
+ for attempt in range(n_attempts):
+ try:
+ return await _fetch_file_list(repo_id, revision, path, recursive)
+ except Exception as e:
+ if attempt == n_attempts - 1:
+ raise e
+ await asyncio.sleep(min(8, 0.1 * float(2.0 ** int(attempt))))
+ raise Exception(f"Failed to fetch file list for {repo_id=} {revision=} {path=} {recursive=}")
+
+async def _fetch_file_list(repo_id: str, revision: str = "main", path: str = "", recursive: bool = False) -> List[FileListEntry]:
+ api_url = f"{get_hf_endpoint()}/api/models/{repo_id}/tree/{revision}"
+ url = f"{api_url}/{path}" if path else api_url
+
+ headers = await get_auth_headers()
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30, connect=10, sock_read=30, sock_connect=10)) as session, session.get(url, headers=headers) as response:
+ if response.status == 200:
+ data_json = await response.text()
+ data = TypeAdapter(list[FileListEntry]).validate_json(data_json)
+ files: list[FileListEntry] = []
+ for item in data:
+ if item.type == "file":
+ files.append(FileListEntry.model_validate(item))
+ elif item.type == "directory" and recursive:
+ subfiles = await _fetch_file_list(repo_id, revision, item.path, recursive)
+ files.extend(subfiles)
+ return files
+ else:
+ raise Exception(f"Failed to fetch file list: {response.status}")
+
+async def calc_hash(path: Path, hash_type: Literal["sha1", "sha256"] = "sha1") -> str:
+ hasher = hashlib.sha1() if hash_type == "sha1" else hashlib.sha256()
+ if hash_type == "sha1":
+ header = f"blob {(await aios.stat(path)).st_size}\0".encode()
+ hasher.update(header)
+ async with aiofiles.open(path, 'rb') as f:
+ while chunk := await f.read(8 * 1024 * 1024):
+ hasher.update(chunk)
+ return hasher.hexdigest()
+
+async def file_meta(repo_id: str, revision: str, path: str) -> Tuple[int, str]:
+ url = urljoin(f"{get_hf_endpoint()}/{repo_id}/resolve/{revision}/", path)
+ headers = await get_auth_headers()
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1800, connect=60, sock_read=1800, sock_connect=60)) as session, session.head(url, headers=headers) as r:
+ content_length = int(r.headers.get('x-linked-size') or r.headers.get('content-length') or 0)
+ etag = r.headers.get('X-Linked-ETag') or r.headers.get('ETag') or r.headers.get('Etag')
+ assert content_length > 0, f"No content length for {url}"
+ assert etag is not None, f"No remote hash for {url}"
+ if (etag[0] == '"' and etag[-1] == '"') or (etag[0] == "'" and etag[-1] == "'"):
+ etag = etag[1:-1]
+ return content_length, etag
+
+async def download_file_with_retry(repo_id: str, revision: str, path: str, target_dir: Path, on_progress: Callable[[int, int], None] = lambda _, __: None) -> Path:
+ n_attempts = 30
+ for attempt in range(n_attempts):
+ try:
+ return await _download_file(repo_id, revision, path, target_dir, on_progress)
+ except Exception as e:
+ if isinstance(e, FileNotFoundError) or attempt == n_attempts - 1:
+ raise e
+ print(f"Download error on attempt {attempt}/{n_attempts} for {repo_id=} {revision=} {path=} {target_dir=}")
+ traceback.print_exc()
+ await asyncio.sleep(min(8, 0.1 * (2.0 ** attempt)))
+ raise Exception(f"Failed to download file {repo_id=} {revision=} {path=} {target_dir=}")
+
+async def _download_file(repo_id: str, revision: str, path: str, target_dir: Path, on_progress: Callable[[int, int], None] = lambda _, __: None) -> Path:
+ if await aios.path.exists(target_dir/path):
+ return target_dir/path
+ await aios.makedirs((target_dir/path).parent, exist_ok=True)
+ length, etag = await file_meta(repo_id, revision, path)
+ remote_hash = etag[:-5] if etag.endswith("-gzip") else etag
+ partial_path = target_dir/f"{path}.partial"
+ resume_byte_pos = (await aios.stat(partial_path)).st_size if (await aios.path.exists(partial_path)) else None
+ if resume_byte_pos != length:
+ url = urljoin(f"{get_hf_endpoint()}/{repo_id}/resolve/{revision}/", path)
+ headers = await get_auth_headers()
+ if resume_byte_pos:
+ headers['Range'] = f'bytes={resume_byte_pos}-'
+ n_read = resume_byte_pos or 0
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1800, connect=60, sock_read=1800, sock_connect=60)) as session, session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=1800, connect=60, sock_read=1800, sock_connect=60)) as r:
+ if r.status == 404:
+ raise FileNotFoundError(f"File not found: {url}")
+ assert r.status in [200, 206], f"Failed to download {path} from {url}: {r.status}"
+ async with aiofiles.open(partial_path, 'ab' if resume_byte_pos else 'wb') as f:
+ while chunk := await r.content.read(8 * 1024 * 1024):
+ on_progress(n_read := n_read + await f.write(chunk), length)
+
+ final_hash = await calc_hash(partial_path, hash_type="sha256" if len(remote_hash) == 64 else "sha1")
+ integrity = final_hash == remote_hash
+ if not integrity:
+ try:
+ await aios.remove(partial_path)
+ except Exception as e:
+ print(f"Error removing partial file {partial_path}: {e}")
+ raise Exception(f"Downloaded file {target_dir/path} has hash {final_hash} but remote hash is {remote_hash}")
+ await aios.rename(partial_path, target_dir/path)
+ return target_dir/path
+
+
+def calculate_repo_progress(shard: ShardMetadata, repo_id: str, revision: str, file_progress: Dict[str, RepoFileDownloadProgress], all_start_time: float) -> RepoDownloadProgress:
+ all_total_bytes = sum(p.total for p in file_progress.values())
+ all_downloaded_bytes = sum(p.downloaded for p in file_progress.values())
+ all_downloaded_bytes_this_session = sum(p.downloaded_this_session for p in file_progress.values())
+ elapsed_time = time.time() - all_start_time
+ all_speed = all_downloaded_bytes_this_session / elapsed_time if elapsed_time > 0 else 0
+ all_eta = timedelta(seconds=(all_total_bytes - all_downloaded_bytes) / all_speed) if all_speed > 0 else timedelta(seconds=0)
+ status = (
+ "complete"
+ if all(p.status == "complete" for p in file_progress.values())
+ else "in_progress" if any(p.status == "in_progress" for p in file_progress.values()) else "not_started"
+ )
+ return RepoDownloadProgress(
+ repo_id=repo_id,
+ repo_revision=revision,
+ shard=shard,
+ completed_files=len([p for p in file_progress.values() if p.downloaded == p.total]),
+ total_files=len(file_progress),
+ downloaded_bytes=all_downloaded_bytes,
+ downloaded_bytes_this_session=all_downloaded_bytes_this_session,
+ total_bytes=all_total_bytes,
+ overall_speed=all_speed,
+ overall_eta=all_eta,
+ status=status,
+ file_progress=file_progress,
+ )
+
+async def get_weight_map(repo_id: str, revision: str = "main") -> Dict[str, str]:
+ target_dir = (await ensure_exo_tmp())/repo_id.replace("/", "--")
+ index_file = await download_file_with_retry(repo_id, revision, "model.safetensors.index.json", target_dir)
+ async with aiofiles.open(index_file, 'r') as f:
+ index_data = ModelSafetensorsIndex.model_validate_json(await f.read())
+ return index_data.weight_map
+
+async def resolve_allow_patterns(shard: ShardMetadata) -> List[str]:
+ try:
+ weight_map = await get_weight_map(str(shard.model_id))
+ return get_allow_patterns(weight_map, shard)
+ except Exception:
+ print(f"Error getting weight map for {shard.model_id=}")
+ traceback.print_exc()
+ return ["*"]
+
+async def get_downloaded_size(path: Path) -> int:
+ partial_path = path.with_suffix(path.suffix + ".partial")
+ if await aios.path.exists(path):
+ return (await aios.stat(path)).st_size
+ if await aios.path.exists(partial_path):
+ return (await aios.stat(partial_path)).st_size
+ return 0
+
+async def download_progress_for_local_path(repo_id: str, shard: ShardMetadata, local_path: Path) -> RepoDownloadProgress:
+ # Scan local files for accurate progress reporting
+ file_progress: Dict[str, RepoFileDownloadProgress] = {}
+ total_files = 0
+ total_bytes = 0
+
+ if await aios.path.isdir(local_path):
+ # Recursively count files and sizes
+ for root, _, files in os.walk(local_path):
+ for f in files:
+ if f.endswith(('.safetensors', '.bin', '.pt', '.gguf', '.json')):
+ file_path = Path(root) / f
+ size = (await aios.stat(file_path)).st_size
+ rel_path = str(file_path.relative_to(local_path))
+ file_progress[rel_path] = RepoFileDownloadProgress(
+ repo_id=repo_id,
+ repo_revision="local",
+ file_path=rel_path,
+ downloaded=size,
+ downloaded_this_session=0,
+ total=size,
+ speed=0,
+ eta=timedelta(0),
+ status="complete",
+ start_time=time.time()
+ )
+ total_files += 1
+ total_bytes += size
+ else:
+ raise ValueError(f"Local path {local_path} is not a directory")
+
+ return RepoDownloadProgress(
+ repo_id=repo_id,
+ repo_revision="local",
+ shard=shard,
+ completed_files=total_files,
+ total_files=total_files,
+ downloaded_bytes=total_bytes,
+ downloaded_bytes_this_session=0,
+ total_bytes=total_bytes,
+ overall_speed=0,
+ overall_eta=timedelta(0),
+ status="complete",
+ file_progress=file_progress,
+ )
+
+async def download_shard(shard: ShardMetadata,
+ on_progress: Callable[[ShardMetadata, RepoDownloadProgress], None],
+ max_parallel_downloads: int = 8,
+ skip_download: bool = False,
+ allow_patterns: List[str] | None = None) -> tuple[Path, RepoDownloadProgress]:
+ if not skip_download:
+ print(f"Downloading {shard.model_id=}")
+
+ # Handle local paths
+ if await aios.path.exists(str(shard.model_id)):
+ print(f"Using local model path {shard.model_id}")
+ local_path = Path(str(shard.model_id))
+ return local_path, await download_progress_for_local_path(str(shard.model_id), shard, local_path)
+
+ revision = "main"
+ target_dir = await ensure_models_dir()/str(shard.model_id).replace("/", "--")
+ if not skip_download:
+ await aios.makedirs(target_dir, exist_ok=True)
+
+ if not allow_patterns:
+ allow_patterns = await resolve_allow_patterns(shard)
+
+ print(f"Downloading {shard.model_id=} with {allow_patterns=}")
+
+ all_start_time = time.time()
+ # TODO: currently not recursive. Some models might require subdirectories - thus this will need to be changed.
+ file_list = await fetch_file_list_with_cache(str(shard.model_id), revision, recursive=False)
+ filtered_file_list = list(filter_repo_objects(file_list, allow_patterns=allow_patterns, key=lambda x: x.path))
+ file_progress: Dict[str, RepoFileDownloadProgress] = {}
+ def on_progress_wrapper(file: FileListEntry, curr_bytes: int, total_bytes: int):
+ start_time = file_progress[file.path].start_time if file.path in file_progress else time.time()
+ downloaded_this_session = file_progress[file.path].downloaded_this_session + (curr_bytes - file_progress[file.path].downloaded) if file.path in file_progress else curr_bytes
+ speed = downloaded_this_session / (time.time() - start_time) if time.time() - start_time > 0 else 0
+ eta = timedelta(seconds=(total_bytes - curr_bytes) / speed) if speed > 0 else timedelta(seconds=0)
+ file_progress[file.path] = RepoFileDownloadProgress(
+ repo_id=str(shard.model_id),
+ repo_revision=revision,
+ file_path=file.path,
+ downloaded=curr_bytes,
+ downloaded_this_session=downloaded_this_session,
+ total=total_bytes,
+ speed=speed,
+ eta=eta,
+ status="complete" if curr_bytes == total_bytes else "in_progress",
+ start_time=start_time,
+ )
+ on_progress(shard, calculate_repo_progress(shard, str(shard.model_id), revision, file_progress, all_start_time))
+ for file in filtered_file_list:
+ downloaded_bytes = await get_downloaded_size(target_dir/file.path)
+ file_progress[file.path] = RepoFileDownloadProgress(
+ repo_id=str(shard.model_id),
+ repo_revision=revision,
+ file_path=file.path,
+ downloaded=downloaded_bytes,
+ downloaded_this_session=0,
+ total=file.size or 0,
+ speed=0,
+ eta=timedelta(0),
+ status="complete" if downloaded_bytes == file.size else "not_started",
+ start_time=time.time(),
+ )
+
+ semaphore = asyncio.Semaphore(max_parallel_downloads)
+ async def download_with_semaphore(file: FileListEntry):
+ async with semaphore:
+ await download_file_with_retry(str(shard.model_id), revision, file.path, target_dir, lambda curr_bytes, total_bytes: on_progress_wrapper(file, curr_bytes, total_bytes))
+ if not skip_download:
+ await asyncio.gather(*[download_with_semaphore(file) for file in filtered_file_list])
+ final_repo_progress = calculate_repo_progress(shard, str(shard.model_id), revision, file_progress, all_start_time)
+ on_progress(shard, final_repo_progress)
+ if gguf := next((f for f in filtered_file_list if f.path.endswith(".gguf")), None):
+ return target_dir/gguf.path, final_repo_progress
+ else:
+ return target_dir, final_repo_progress
diff --git a/worker/download/huggingface_utils.py b/worker/download/huggingface_utils.py
new file mode 100644
index 00000000..a3d8a781
--- /dev/null
+++ b/worker/download/huggingface_utils.py
@@ -0,0 +1,97 @@
+import os
+from fnmatch import fnmatch
+from pathlib import Path
+from typing import Callable, Dict, Generator, Iterable, List, Optional, TypeVar, Union
+
+import aiofiles
+import aiofiles.os as aios
+
+from shared.types.worker.shards import ShardMetadata
+
+T = TypeVar("T")
+
+def filter_repo_objects(
+ items: Iterable[T],
+ *,
+ allow_patterns: Optional[Union[List[str], str]] = None,
+ ignore_patterns: Optional[Union[List[str], str]] = None,
+ key: Optional[Callable[[T], str]] = None,
+) -> Generator[T, None, None]:
+ if isinstance(allow_patterns, str):
+ allow_patterns = [allow_patterns]
+ if isinstance(ignore_patterns, str):
+ ignore_patterns = [ignore_patterns]
+ if allow_patterns is not None:
+ allow_patterns = [_add_wildcard_to_directories(p) for p in allow_patterns]
+ if ignore_patterns is not None:
+ ignore_patterns = [_add_wildcard_to_directories(p) for p in ignore_patterns]
+
+ if key is None:
+ def _identity(item: T) -> str:
+ if isinstance(item, str):
+ return item
+ if isinstance(item, Path):
+ return str(item)
+ raise ValueError(f"Please provide `key` argument in `filter_repo_objects`: `{item}` is not a string.")
+ key = _identity
+
+ for item in items:
+ path = key(item)
+ if allow_patterns is not None and not any(fnmatch(path, r) for r in allow_patterns):
+ continue
+ if ignore_patterns is not None and any(fnmatch(path, r) for r in ignore_patterns):
+ continue
+ yield item
+
+def _add_wildcard_to_directories(pattern: str) -> str:
+ if pattern[-1] == "/":
+ return pattern + "*"
+ return pattern
+
+def get_hf_endpoint() -> str:
+ return os.environ.get('HF_ENDPOINT', "https://huggingface.co")
+
+def get_hf_home() -> Path:
+ """Get the Hugging Face home directory."""
+ return Path(os.environ.get("HF_HOME", Path.home()/".cache"/"huggingface"))
+
+async def get_hf_token() -> Optional[str]:
+ """Retrieve the Hugging Face token from the user's HF_HOME directory."""
+ token_path = get_hf_home()/"token"
+ if await aios.path.exists(token_path):
+ async with aiofiles.open(token_path, 'r') as f:
+ return (await f.read()).strip()
+ return None
+
+async def get_auth_headers() -> dict[str, str]:
+ """Get authentication headers if a token is available."""
+ token = await get_hf_token()
+ if token:
+ return {"Authorization": f"Bearer {token}"}
+ return {}
+
+def extract_layer_num(tensor_name: str) -> Optional[int]:
+ # This is a simple example and might need to be adjusted based on the actual naming convention
+ parts = tensor_name.split('.')
+ for part in parts:
+ if part.isdigit():
+ return int(part)
+ return None
+
+def get_allow_patterns(weight_map: Dict[str, str], shard: ShardMetadata) -> List[str]:
+ default_patterns = set(["*.json", "*.py", "tokenizer.model", "*.tiktoken", "*.txt"])
+ shard_specific_patterns: set[str] = set()
+ if weight_map:
+ for tensor_name, filename in weight_map.items():
+ layer_num = extract_layer_num(tensor_name)
+ if layer_num is not None and shard.start_layer <= layer_num <= shard.end_layer:
+ shard_specific_patterns.add(filename)
+ sorted_file_names = sorted(weight_map.values())
+ if shard.is_first_layer:
+ shard_specific_patterns.add(sorted_file_names[0])
+ elif shard.is_last_layer:
+ shard_specific_patterns.add(sorted_file_names[-1])
+ else:
+ shard_specific_patterns = set(["*.safetensors"])
+ print(f"get_allow_patterns {shard=} {shard_specific_patterns=}")
+ return list(default_patterns | shard_specific_patterns)
diff --git a/worker/download/impl_shard_downloader.py b/worker/download/impl_shard_downloader.py
new file mode 100644
index 00000000..d8e329e3
--- /dev/null
+++ b/worker/download/impl_shard_downloader.py
@@ -0,0 +1,128 @@
+import asyncio
+from pathlib import Path
+from typing import AsyncIterator, Callable, Dict, List, Optional
+
+from shared.types.worker.shards import (
+ PartitionStrategy,
+ PipelineShardMetadata,
+ ShardMetadata,
+)
+from worker.download.download_utils import RepoDownloadProgress, download_shard
+from worker.download.model_cards import MODEL_CARDS
+from worker.download.model_meta import get_model_meta
+from worker.download.shard_downloader import ShardDownloader
+
+
+def exo_shard_downloader(max_parallel_downloads: int = 8) -> ShardDownloader:
+ return SingletonShardDownloader(CachedShardDownloader(ResumableShardDownloader(max_parallel_downloads)))
+
+async def build_base_shard(model_id: str) -> Optional[ShardMetadata]:
+ model_meta = await get_model_meta(model_id)
+ # print(f"build_base_shard {model_id=} {model_meta=}")
+ return PipelineShardMetadata(
+ model_id=model_id,
+ partition_strategy=PartitionStrategy.pipeline,
+ device_rank=0,
+ world_size=1,
+ start_layer=0,
+ end_layer=model_meta.n_layers - 1,
+ n_layers=model_meta.n_layers,
+ )
+
+async def build_full_shard(model_id: str) -> Optional[PipelineShardMetadata]:
+ base_shard = await build_base_shard(model_id)
+ if base_shard is None:
+ return None
+ return PipelineShardMetadata(
+ model_id=base_shard.model_id,
+ partition_strategy=base_shard.partition_strategy,
+ device_rank=base_shard.device_rank,
+ world_size=base_shard.world_size,
+ start_layer=base_shard.start_layer,
+ end_layer=base_shard.n_layers - 1,
+ n_layers=base_shard.n_layers,
+ )
+
+class SingletonShardDownloader(ShardDownloader):
+ def __init__(self, shard_downloader: ShardDownloader):
+ self.shard_downloader = shard_downloader
+ self.active_downloads: Dict[ShardMetadata, asyncio.Task[Path]] = {}
+
+ def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None:
+ self.shard_downloader.on_progress(callback)
+
+ async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path:
+ if shard not in self.active_downloads:
+ self.active_downloads[shard] = asyncio.create_task(self.shard_downloader.ensure_shard(shard, config_only))
+ try:
+ return await self.active_downloads[shard]
+ finally:
+ if shard in self.active_downloads and self.active_downloads[shard].done():
+ del self.active_downloads[shard]
+
+ async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]:
+ async for path, status in self.shard_downloader.get_shard_download_status():
+ yield path, status
+
+class CachedShardDownloader(ShardDownloader):
+ def __init__(self, shard_downloader: ShardDownloader):
+ self.shard_downloader = shard_downloader
+ self.cache: Dict[tuple[str, ShardMetadata], Path] = {}
+
+ def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None:
+ self.shard_downloader.on_progress(callback)
+
+ async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path:
+ if (shard.model_id, shard) in self.cache:
+ # print(f"ensure_shard cache hit {shard=}")
+ return self.cache[(shard.model_id, shard)]
+
+ # print(f"ensure_shard cache miss {shard=}")
+ target_dir = await self.shard_downloader.ensure_shard(shard, config_only)
+ self.cache[(shard.model_id, shard)] = target_dir
+ return target_dir
+
+ async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]:
+ async for path, status in self.shard_downloader.get_shard_download_status():
+ yield path, status
+
+class ResumableShardDownloader(ShardDownloader):
+ def __init__(self, max_parallel_downloads: int = 8):
+ self.max_parallel_downloads = max_parallel_downloads
+ self.on_progress_callbacks: List[Callable[[ShardMetadata, RepoDownloadProgress], None]] = []
+
+ def on_progress_wrapper(self, shard: ShardMetadata, progress: RepoDownloadProgress) -> None:
+ for callback in self.on_progress_callbacks:
+ callback(shard, progress)
+
+ def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None:
+ self.on_progress_callbacks.append(callback)
+
+ async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path:
+ allow_patterns = ["config.json"] if config_only else None
+
+ # print(f"ensure_shard {shard=} {config_only=} {allow_patterns=}")
+ target_dir, _ = await download_shard(shard, self.on_progress_wrapper, max_parallel_downloads=self.max_parallel_downloads, allow_patterns=allow_patterns)
+ return target_dir
+
+ async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]:
+ # print("get_shard_download_status")
+ async def _status_for_model(model_id: str) -> Optional[tuple[Path, RepoDownloadProgress]]:
+ """Helper coroutine that builds the shard for a model and gets its download status."""
+ shard = await build_full_shard(model_id)
+ if shard is None:
+ return None
+ return await download_shard(shard, self.on_progress_wrapper, skip_download=True)
+
+ # Kick off download status coroutines concurrently
+ tasks = [asyncio.create_task(_status_for_model(model_id)) for model_id in MODEL_CARDS]
+
+ for task in asyncio.as_completed(tasks):
+ try:
+ result = await task
+ if result is None:
+ continue
+ path, progress = result
+ yield (path, progress)
+ except Exception as e:
+ print("Error downloading shard:", e)
diff --git a/worker/download/model_cards.py b/worker/download/model_cards.py
new file mode 100644
index 00000000..b0ac69df
--- /dev/null
+++ b/worker/download/model_cards.py
@@ -0,0 +1,133 @@
+from typing import List
+
+from pydantic import BaseModel
+
+
+class ModelCard(BaseModel):
+ id: str
+ repo_id: str
+ name: str
+ description: str
+ tags: List[str]
+
+MODEL_CARDS = {
+ "llama-3.3": ModelCard(
+ id="llama-3.3",
+ repo_id="mlx-community/Llama-3.3-70B-Instruct-4bit",
+ name="Llama 3.3 70B",
+ description="The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)",
+ tags=[]),
+ "llama-3.3:70b": ModelCard(
+ id="llama-3.3:70b",
+ repo_id="mlx-community/Llama-3.3-70B-Instruct-4bit",
+ name="Llama 3.3 70B",
+ description="The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)",
+ tags=[]),
+ "llama-3.2": ModelCard(
+ id="llama-3.2",
+ repo_id="mlx-community/Llama-3.2-1B-Instruct-4bit",
+ name="Llama 3.2 1B",
+ description="Llama 3.2 is a large language model trained on the Llama 3.2 dataset.",
+ tags=[]),
+ "llama-3.2:1b": ModelCard(
+ id="llama-3.2:1b",
+ repo_id="mlx-community/Llama-3.2-1B-Instruct-4bit",
+ name="Llama 3.2 1B",
+ description="Llama 3.2 is a large language model trained on the Llama 3.2 dataset.",
+ tags=[]),
+ "llama-3.2:3b": ModelCard(
+ id="llama-3.2:3b",
+ repo_id="mlx-community/Llama-3.2-3B-Instruct-4bit",
+ name="Llama 3.2 3B",
+ description="Llama 3.2 is a large language model trained on the Llama 3.2 dataset.",
+ tags=[]),
+ "llama-3.1:8b": ModelCard(
+ id="llama-3.1:8b",
+ repo_id="mlx-community/Meta-Llama-3.1-8B-Instruct-4bit",
+ name="Llama 3.1 8B",
+ description="Llama 3.1 is a large language model trained on the Llama 3.1 dataset.",
+ tags=[]),
+ "llama-3.1-70b": ModelCard(
+ id="llama-3.1-70b",
+ repo_id="mlx-community/Meta-Llama-3.1-70B-Instruct-4bit",
+ name="Llama 3.1 70B",
+ description="Llama 3.1 is a large language model trained on the Llama 3.1 dataset.",
+ tags=[]),
+ "deepseek-r1": ModelCard(
+ id="deepseek-r1",
+ repo_id="mlx-community/DeepSeek-R1-4bit",
+ name="DeepSeek R1 671B (4-bit)",
+ description="DeepSeek R1 is a large language model trained on the DeepSeek R1 dataset.",
+ tags=[]),
+ "deepseek-r1:671b": ModelCard(
+ id="deepseek-r1:671b", # TODO: make sure model_id matches up for identical models
+ repo_id="mlx-community/DeepSeek-R1-4bit",
+ name="DeepSeek R1 671B",
+ description="DeepSeek R1 is a large language model trained on the DeepSeek R1 dataset.",
+ tags=[]),
+ "deepseek-v3": ModelCard(
+ id="deepseek-v3",
+ repo_id="mlx-community/DeepSeek-V3-0324-4bit",
+ name="DeepSeek V3 4B",
+ description="DeepSeek V3 is a large language model trained on the DeepSeek V3 dataset.",
+ tags=[]),
+ "deepseek-v3:671b": ModelCard(
+ id="deepseek-v3:671b",
+ repo_id="mlx-community/DeepSeek-V3-0324-4bit",
+ name="DeepSeek V3 671B",
+ description="DeepSeek V3 is a large language model trained on the DeepSeek V3 dataset.",
+ tags=[]),
+ "phi-3-mini": ModelCard(
+ id="phi-3-mini",
+ repo_id="mlx-community/Phi-3-mini-128k-instruct-4bit",
+ name="Phi 3 Mini 128k",
+ description="Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.",
+ tags=[]),
+ "phi-3-mini:128k": ModelCard(
+ id="phi-3-mini:128k",
+ repo_id="mlx-community/Phi-3-mini-128k-instruct-4bit",
+ name="Phi 3 Mini 128k",
+ description="Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.",
+ tags=[]),
+ "qwen3-0.6b": ModelCard(
+ id="qwen3-0.6b",
+ repo_id="mlx-community/Qwen3-0.6B-4bit",
+ name="Qwen3 0.6B",
+ description="Qwen3 0.6B is a large language model trained on the Qwen3 0.6B dataset.",
+ tags=[]),
+ "qwen3-30b": ModelCard(
+ id="qwen3-30b",
+ repo_id="mlx-community/Qwen3-30B-A3B-4bit",
+ name="Qwen3 30B (Active 3B)",
+ description="Qwen3 30B is a large language model trained on the Qwen3 30B dataset.",
+ tags=[]),
+ "granite-3.3-2b": ModelCard(
+ id="granite-3.3-2b",
+ repo_id="mlx-community/granite-3.3-2b-instruct-fp16",
+ name="Granite 3.3 2B",
+ description="Granite-3.3-2B-Instruct is a 2-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.",
+ tags=[]),
+ "granite-3.3-8b": ModelCard(
+ id="granite-3.3-8b",
+ repo_id="mlx-community/granite-3.3-8b-instruct-fp16",
+ name="Granite 3.3 8B",
+ description="Granite-3.3-8B-Instruct is a 8-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.",
+ tags=[]),
+ "smol-lm-135m": ModelCard(
+ id="smol-lm-135m",
+ repo_id="mlx-community/SmolLM-135M-4bit",
+ name="Smol LM 135M",
+ description="SmolLM is a series of state-of-the-art small language models available in three sizes: 135M, 360M, and 1.7B parameters. ",
+ tags=[]),
+}
+
+def get_huggingface_id(model: str) -> str:
+ if "mlx-community/" in model:
+ return model
+ if model not in MODEL_CARDS:
+ raise ValueError(f"Model {model} not found")
+ return MODEL_CARDS[model].repo_id
+
+if __name__ == "__main__":
+ for model in MODEL_CARDS:
+ print(f"{model} -> {get_huggingface_id(model)}")
diff --git a/worker/download/model_meta.py b/worker/download/model_meta.py
new file mode 100644
index 00000000..f0022723
--- /dev/null
+++ b/worker/download/model_meta.py
@@ -0,0 +1,124 @@
+import json
+from typing import Annotated, Dict, Optional
+
+import aiofiles
+from huggingface_hub import model_info
+from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError
+from pydantic import BaseModel, Field
+
+from shared.types.models import ModelMetadata
+from worker.download.download_utils import (
+ ModelSafetensorsIndex,
+ download_file_with_retry,
+ ensure_exo_tmp,
+)
+from worker.download.model_cards import MODEL_CARDS
+
+
+class ConfigData(BaseModel):
+ num_hidden_layers: Optional[Annotated[int, Field(ge=0)]]
+ num_layers: Optional[Annotated[int, Field(ge=0)]]
+ n_layer: Optional[Annotated[int, Field(ge=0)]]
+
+async def get_config_data(model_id: str) -> Optional[ConfigData]:
+ """Downloads and parses config.json for a model."""
+ try:
+ model_card = MODEL_CARDS[model_id]
+ target_dir = (await ensure_exo_tmp())/model_card.repo_id.replace("/", "--")
+ config_path = await download_file_with_retry(model_card.repo_id, "main", "config.json", target_dir, lambda curr_bytes, total_bytes: print(f"Downloading config.json for {model_id}: {curr_bytes}/{total_bytes}"))
+ async with aiofiles.open(config_path, 'r') as f:
+ return ConfigData.model_validate_json(await f.read())
+ except EntryNotFoundError:
+ print(f"Warning: config.json not found for {model_id}. Layers/type from config unavailable.")
+ except json.JSONDecodeError:
+ print(f"Error: Failed to parse config.json for {model_id}.")
+ except Exception as e:
+ print(f"Error: Error processing config.json for {model_id}: {e}")
+ return None
+
+def get_num_layers(config_data: Optional[ConfigData], model_id: str) -> Optional[int]:
+ """Extracts number of layers from config data."""
+ if not config_data:
+ return None
+
+ if config_data.num_hidden_layers is not None:
+ return config_data.num_hidden_layers
+ if config_data.num_layers is not None:
+ return config_data.num_layers
+ if config_data.n_layer is not None:
+ return config_data.n_layer
+
+ print(f"Warning: No known layer key or valid number in config.json for {model_id}. Config: {config_data.model_dump_json()}")
+ return None
+
+async def get_safetensors_size(model_id: str) -> Optional[int]:
+ """Gets model size from safetensors index or falls back to HF API."""
+ try:
+ model_card = MODEL_CARDS[model_id]
+ target_dir = (await ensure_exo_tmp())/model_card.repo_id.replace("/", "--")
+ index_path = await download_file_with_retry(model_card.repo_id, "main", "model.safetensors.index.json", target_dir, lambda curr_bytes, total_bytes: print(f"Downloading model.safetensors.index.json for {model_id}: {curr_bytes}/{total_bytes}"))
+ async with aiofiles.open(index_path, 'r') as f:
+ index_data = ModelSafetensorsIndex.model_validate_json(await f.read())
+
+ metadata = index_data.metadata
+ if metadata is not None:
+ return metadata.total_size
+ print(f"Warning: Could not extract total_size from safetensors index metadata for {model_id}. Metadata: {index_data.model_dump_json()}")
+
+ except EntryNotFoundError:
+ print(f"Warning: model.safetensors.index.json not found for {model_id}.")
+ except json.JSONDecodeError:
+ print(f"Error: Failed to parse model.safetensors.index.json for {model_id}.")
+ except Exception as e:
+ print(f"Error: Error processing model.safetensors.index.json for {model_id}: {e}")
+
+ print(f"Warning: Could not determine safetensors total size from index for {model_id}. Falling back to model_info API call.")
+ try:
+ info = model_info(model_id)
+ if info.safetensors is not None:
+ return info.safetensors.total
+ print(f"Warning: Could not get safetensors total size from model_info API for {model_id}. Safetensors info: {info}")
+ except HfHubHTTPError as e:
+ print(f"Error: HTTP Error while fetching model info from API for {model_id}: {e}")
+ except Exception as e:
+ print(f"Error: Error getting total size from huggingface info API for {model_id}: {e}")
+ return None
+
+_model_meta_cache: Dict[str, ModelMetadata] = {}
+async def get_model_meta(model_id: str) -> ModelMetadata:
+ if model_id in _model_meta_cache:
+ return _model_meta_cache[model_id]
+ model_meta = await _get_model_meta(model_id)
+ _model_meta_cache[model_id] = model_meta
+ return model_meta
+
+async def _get_model_meta(model_id: str) -> ModelMetadata:
+ """Fetches storage size and number of layers for a Hugging Face model, returns Pydantic ModelMeta."""
+ model_card = MODEL_CARDS[model_id]
+ num_layers_val: Optional[int] = None
+ mem_size_bytes_val: Optional[int] = None
+ try:
+ config_data = await get_config_data(model_id)
+ # get_num_layers is synchronous
+ num_layers_val = get_num_layers(config_data, model_id)
+ mem_size_bytes_val = await get_safetensors_size(model_id)
+
+ except HfHubHTTPError as e:
+ print(f"Error: HTTP Error encountered for '{model_id}': {e}")
+ except Exception as e:
+ print(f"Error: Unexpected error during metadata fetching for '{model_id}': {e}")
+
+ # Fallbacks for missing metadata
+ if mem_size_bytes_val is None:
+ print(f"Warning: Could not determine model size for {model_id}. Defaulting to 0 bytes.")
+ mem_size_bytes_val = 0
+ if num_layers_val is None:
+ print(f"Warning: Could not determine number of layers for {model_id}. Defaulting to 0 layers.")
+ num_layers_val = 0
+
+ return ModelMetadata(
+ model_id=model_id,
+ pretty_name=model_card.name,
+ storage_size_kilobytes=mem_size_bytes_val // 1024,
+ n_layers=num_layers_val,
+ )
diff --git a/worker/download/shard_downloader.py b/worker/download/shard_downloader.py
new file mode 100644
index 00000000..b76aa9ec
--- /dev/null
+++ b/worker/download/shard_downloader.py
@@ -0,0 +1,96 @@
+from abc import ABC, abstractmethod
+from datetime import timedelta
+from pathlib import Path
+from typing import AsyncIterator, Callable
+
+from shared.types.worker.shards import (
+ PartitionStrategy,
+ PipelineShardMetadata,
+ ShardMetadata,
+)
+from worker.download.download_utils import RepoDownloadProgress
+
+
+class ShardDownloader(ABC):
+ @abstractmethod
+ async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path:
+ """
+ Ensures that the shard is downloaded.
+ Does not allow multiple overlapping downloads at once.
+ If you try to download a Shard which overlaps a Shard that is already being downloaded,
+ the download will be cancelled and a new download will start.
+
+ Args:
+ shard (Shard): The shard to download.
+ inference_engine_name (str): The inference engine used on the node hosting the shard
+ """
+
+ @abstractmethod
+ def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None:
+ pass
+
+ @abstractmethod
+ async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]:
+ """Get the download status of shards.
+
+ Yields:
+ tuple[Path, RepoDownloadProgress]: The path and progress of a shard download.
+ """
+ yield (
+ Path("/tmp/noop_shard"),
+ RepoDownloadProgress(
+ repo_id="noop",
+ repo_revision="noop",
+ shard=PipelineShardMetadata(
+ model_id="noop",
+ partition_strategy=PartitionStrategy.pipeline,
+ device_rank=0,
+ world_size=1,
+ start_layer=0,
+ end_layer=0,
+ n_layers=1,
+ ),
+ completed_files=0,
+ total_files=0,
+ downloaded_bytes=0,
+ downloaded_bytes_this_session=0,
+ total_bytes=0,
+ overall_speed=0,
+ overall_eta=timedelta(seconds=0),
+ status="complete",
+ )
+ )
+
+
+class NoopShardDownloader(ShardDownloader):
+ async def ensure_shard(self, shard: ShardMetadata, config_only: bool = False) -> Path:
+ return Path("/tmp/noop_shard")
+
+ def on_progress(self, callback: Callable[[ShardMetadata, RepoDownloadProgress], None]) -> None:
+ pass
+
+ async def get_shard_download_status(self) -> AsyncIterator[tuple[Path, RepoDownloadProgress]]:
+ yield (
+ Path("/tmp/noop_shard"),
+ RepoDownloadProgress(
+ repo_id="noop",
+ repo_revision="noop",
+ shard=PipelineShardMetadata(
+ model_id="noop",
+ partition_strategy=PartitionStrategy.pipeline,
+ device_rank=0,
+ world_size=1,
+ start_layer=0,
+ end_layer=0,
+ n_layers=1,
+ ),
+ completed_files=0,
+ total_files=0,
+ downloaded_bytes=0,
+ downloaded_bytes_this_session=0,
+ total_bytes=0,
+ overall_speed=0,
+ overall_eta=timedelta(seconds=0),
+ status="complete",
+ )
+ )
diff --git a/worker/download/test_download.py b/worker/download/test_download.py
new file mode 100644
index 00000000..db38313f
--- /dev/null
+++ b/worker/download/test_download.py
@@ -0,0 +1,54 @@
+import time
+
+import pytest
+
+from shared.types.models import ModelId
+from shared.types.worker.shards import PartitionStrategy, PipelineShardMetadata
+from worker.download.impl_shard_downloader import exo_shard_downloader
+from worker.download.shard_downloader import ShardDownloader
+
+
+@pytest.mark.asyncio
+async def test_shard_downloader():
+ shard_downloader: ShardDownloader = exo_shard_downloader()
+ shard_downloader.on_progress(
+ lambda shard, progress: print(f"Download progress: {progress}")
+ )
+
+ shard_metadata = PipelineShardMetadata(
+ model_id=ModelId("mlx-community/Llama-3.2-1B-Instruct-4bit"),
+ partition_strategy=PartitionStrategy.pipeline,
+ device_rank=0,
+ world_size=1,
+ start_layer=0,
+ end_layer=100,
+ n_layers=100,
+ )
+ path = await shard_downloader.ensure_shard(shard_metadata)
+ assert path.exists()
+
+ downloaded_model_path = path.parent / "mlx-community--Llama-3.2-1B-Instruct-4bit"
+ assert (downloaded_model_path / "config.json").exists()
+ assert (downloaded_model_path / "model.safetensors").exists()
+ assert (downloaded_model_path / "model.safetensors.index.json").exists()
+ assert (downloaded_model_path / "special_tokens_map.json").exists()
+ assert (downloaded_model_path / "tokenizer.json").exists()
+ assert (downloaded_model_path / "tokenizer_config.json").exists()
+
+ expected_files_and_sizes = [
+ ("config.json", 1121),
+ ("model.safetensors", 695283921),
+ ("model.safetensors.index.json", 26159),
+ ("special_tokens_map.json", 296),
+ ("tokenizer.json", 17209920),
+ ("tokenizer_config.json", 54558),
+ ]
+ for filename, expected_size in expected_files_and_sizes:
+ file_path = downloaded_model_path / filename
+ assert file_path.stat().st_size == expected_size, f"{filename} size mismatch"
+
+ start_time = time.monotonic()
+ path_again = await shard_downloader.ensure_shard(shard_metadata)
+ duration = time.monotonic() - start_time
+ assert path_again == path
+ assert duration < 5, f"Second call to ensure_shard took too long: {duration:.2f}s"
diff --git a/worker/main.py b/worker/main.py
index 52094970..e7f7f21a 100644
--- a/worker/main.py
+++ b/worker/main.py
@@ -1,6 +1,6 @@
import asyncio
import os
-from asyncio.queues import Queue
+from asyncio import Queue
from functools import partial
from logging import Logger
from typing import AsyncGenerator, Optional
@@ -39,6 +39,7 @@ from shared.types.worker.runners import (
RunningRunnerStatus,
)
from shared.types.worker.shards import ShardMetadata
+from worker.download.download_utils import build_model_path
from worker.runner.runner_supervisor import RunnerSupervisor
@@ -56,7 +57,7 @@ class AssignedRunner(BaseModel):
@property
def is_downloaded(self) -> bool:
# TODO: Do this properly with huggingface validating each of the files.
- return os.path.exists(self.shard_metadata.model_path)
+ return os.path.exists(build_model_path(self.shard_metadata.model_id))
def status_update_event(self) -> RunnerStatusUpdated:
return RunnerStatusUpdated(
@@ -334,7 +335,9 @@ class Worker:
# Handle state updates
async def _loop(self):
while True:
- state_copy = self.state.model_copy(deep=True)
+ state_copy = self.state.model_copy(deep=False)
+ state_copy.task_inbox = []
+ state_copy.task_outbox = []
op: RunnerOp | None = self.plan(state_copy)
diff --git a/worker/tests/conftest.py b/worker/tests/conftest.py
index 4fae4868..f5d2f93b 100644
--- a/worker/tests/conftest.py
+++ b/worker/tests/conftest.py
@@ -30,7 +30,7 @@ from worker.main import Worker
@pytest.fixture
-def pipeline_shard_meta():
+def pipeline_shard_meta(tmp_path: Path):
def _pipeline_shard_meta(
num_nodes: int = 1, device_rank: int = 0
) -> PipelineShardMetadata:
@@ -46,9 +46,7 @@ def pipeline_shard_meta():
return PipelineShardMetadata(
device_rank=device_rank,
model_id=ModelId(uuid.uuid4()),
- model_path=Path(
- "~/.exo/models/mlx-community--Llama-3.2-1B-Instruct-4bit/"
- ).expanduser(),
+ n_layers=total_layers,
start_layer=start_layer,
end_layer=end_layer,
world_size=num_nodes,
diff --git a/worker/tests/test_serdes.py b/worker/tests/test_serdes.py
index a90552db..7ae81bf3 100644
--- a/worker/tests/test_serdes.py
+++ b/worker/tests/test_serdes.py
@@ -1,3 +1,4 @@
+from pathlib import Path
from typing import Callable, TypeVar
from pydantic import BaseModel, TypeAdapter
@@ -26,6 +27,7 @@ def assert_equal_serdes(obj: T, typeadapter: TypeAdapter[T]):
def test_supervisor_setup_message_serdes(
pipeline_shard_meta: Callable[..., PipelineShardMetadata],
hosts: Callable[..., list[Host]],
+ tmp_path: Path,
):
setup_message = SetupMessage(
model_shard_meta=pipeline_shard_meta(1, 0),
diff --git a/worker/tests/test_supervisor.py b/worker/tests/test_supervisor.py
index c5df37e9..686630e5 100644
--- a/worker/tests/test_supervisor.py
+++ b/worker/tests/test_supervisor.py
@@ -1,4 +1,5 @@
import asyncio
+from pathlib import Path
from typing import Callable
import pytest
@@ -26,6 +27,7 @@ async def test_supervisor_single_node_response(
pipeline_shard_meta: Callable[..., PipelineShardMetadata],
hosts: Callable[..., list[Host]],
chat_task: Task,
+ tmp_path: Path,
):
"""Test that asking for the capital of France returns 'Paris' in the response"""
model_shard_meta = pipeline_shard_meta(1, 0)
@@ -62,6 +64,7 @@ async def test_supervisor_two_node_response(
pipeline_shard_meta: Callable[..., PipelineShardMetadata],
hosts: Callable[..., list[Host]],
chat_task: Task,
+ tmp_path: Path,
):
"""Test that asking for the capital of France returns 'Paris' in the response"""
supervisor_0 = await RunnerSupervisor.create(
@@ -116,6 +119,7 @@ async def test_supervisor_early_stopping(
pipeline_shard_meta: Callable[..., PipelineShardMetadata],
hosts: Callable[..., list[Host]],
chat_task: Task,
+ tmp_path: Path,
):
"""Test that asking for the capital of France returns 'Paris' in the response"""
model_shard_meta = pipeline_shard_meta(1, 0)
@@ -166,6 +170,7 @@ async def test_supervisor_handles_terminated_runner(
pipeline_shard_meta: Callable[..., PipelineShardMetadata],
hosts: Callable[..., list[Host]],
chat_task: Task,
+ tmp_path: Path,
):
"""Test that the supervisor handles a terminated runner"""
model_shard_meta = pipeline_shard_meta(1, 0)
@@ -190,6 +195,7 @@ async def test_supervisor_handles_killed_runner(
pipeline_shard_meta: Callable[..., PipelineShardMetadata],
hosts: Callable[..., list[Host]],
chat_task: Task,
+ tmp_path: Path,
):
"""Test that the supervisor handles a killed runner"""
model_shard_meta = pipeline_shard_meta(1, 0)
diff --git a/worker/tests/test_worker_handlers.py b/worker/tests/test_worker_handlers.py
index e676cb3f..04390658 100644
--- a/worker/tests/test_worker_handlers.py
+++ b/worker/tests/test_worker_handlers.py
@@ -1,6 +1,7 @@
## Tests for worker state handlers
import asyncio
+from pathlib import Path
from typing import Callable
import pytest
@@ -35,7 +36,7 @@ def user_message():
return "What, according to Douglas Adams, is the meaning of life, the universe and everything?"
@pytest.mark.asyncio
-async def test_assign_op(worker: Worker, instance: Callable[[NodeId], Instance]):
+async def test_assign_op(worker: Worker, instance: Callable[[NodeId], Instance], tmp_path: Path):
await worker.start()
await asyncio.sleep(0.01)
@@ -67,7 +68,7 @@ async def test_assign_op(worker: Worker, instance: Callable[[NodeId], Instance])
assert isinstance(worker.assigned_runners[runner_id].status, ReadyRunnerStatus)
@pytest.mark.asyncio
-async def test_unassign_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance]):
+async def test_unassign_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], tmp_path: Path):
worker, runner_id, _ = worker_with_assigned_runner
unassign_op = UnassignRunnerOp(
@@ -84,7 +85,7 @@ async def test_unassign_op(worker_with_assigned_runner: tuple[Worker, RunnerId,
assert len(events) == 0
@pytest.mark.asyncio
-async def test_runner_up_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], chat_task: Task):
+async def test_runner_up_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], chat_task: Task, tmp_path: Path):
worker, runner_id, _ = worker_with_assigned_runner
runner_up_op = RunnerUpOp(runner_id=runner_id)
@@ -117,7 +118,7 @@ async def test_runner_up_op(worker_with_assigned_runner: tuple[Worker, RunnerId,
await runner.astop() # Neat cleanup.
@pytest.mark.asyncio
-async def test_runner_down_op(worker_with_running_runner: tuple[Worker, RunnerId, Instance]):
+async def test_runner_down_op(worker_with_running_runner: tuple[Worker, RunnerId, Instance], tmp_path: Path):
worker, runner_id, _ = worker_with_running_runner
runner_down_op = RunnerDownOp(runner_id=runner_id)
@@ -130,7 +131,7 @@ async def test_runner_down_op(worker_with_running_runner: tuple[Worker, RunnerId
assert isinstance(events[0].runner_status, ReadyRunnerStatus)
@pytest.mark.asyncio
-async def test_download_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance]):
+async def test_download_op(worker_with_assigned_runner: tuple[Worker, RunnerId, Instance], tmp_path: Path):
worker, runner_id, instance_obj = worker_with_assigned_runner
print(f'{worker.assigned_runners=}')
@@ -153,7 +154,7 @@ async def test_download_op(worker_with_assigned_runner: tuple[Worker, RunnerId,
@pytest.mark.asyncio
async def test_execute_task_op(
worker_with_running_runner: tuple[Worker, RunnerId, Instance],
- chat_task: Task):
+ chat_task: Task, tmp_path: Path):
worker, runner_id, _ = worker_with_running_runner
execute_task_op = ExecuteTaskOp(
@@ -187,7 +188,7 @@ async def test_execute_task_op(
@pytest.mark.asyncio
async def test_execute_task_fails(
worker_with_running_runner: tuple[Worker, RunnerId, Instance],
- chat_task: Task):
+ chat_task: Task, tmp_path: Path):
worker, runner_id, _ = worker_with_running_runner
messages = chat_task.task_params.messages
diff --git a/worker/tests/test_worker_plan.py b/worker/tests/test_worker_plan.py
index cdc59623..c2c71508 100644
--- a/worker/tests/test_worker_plan.py
+++ b/worker/tests/test_worker_plan.py
@@ -23,6 +23,7 @@ from shared.types.worker.runners import (
ShardAssignments,
)
from shared.types.worker.shards import PipelineShardMetadata
+from worker.download.download_utils import build_model_path
from worker.main import AssignedRunner, Worker
@@ -148,9 +149,9 @@ def _build_worker_state(
device_rank=0,
world_size=1,
model_id=model_id,
- model_path=model_subdir,
start_layer=0,
end_layer=0,
+ n_layers=1,
)
shard_assignments = ShardAssignments(
@@ -233,7 +234,7 @@ def test_worker_plan(case: PlanTestCase, tmp_path: Path, monkeypatch: pytest.Mon
)
worker.assigned_runners[ctx.runner_id] = assigned_runner
- path_downloaded_map[str(ctx.shard_metadata.model_path)] = runner_case.downloaded
+ path_downloaded_map[str(build_model_path(ctx.shard_metadata.model_id))] = runner_case.downloaded
# Stub filesystem existence check ------------------------------------------------------
from worker import main as worker_main # local import for module-scoped os
← cb101e3d Refactor model types
·
back to Exo
·
fix sqlite connector 108128b6 →