Compare commits

...
1 Commits
Author SHA1 Message Date
Evan 4430b0daf9 api streams 2026-05-07 17:07:40 +01:00
44 changed files with 1312 additions and 1013 deletions

No files matched your search

Generated
+134 -115
View File
@@ -142,9 +142,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
@@ -182,7 +182,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"synstructure",
]
@@ -194,7 +194,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -216,7 +216,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -227,7 +227,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -372,9 +372,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.42"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -608,7 +608,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -619,7 +619,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -681,7 +681,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -725,7 +725,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -781,7 +781,7 @@ dependencies = [
]
[[package]]
name = "exo_pyo3_bindings"
name = "exo_net"
version = "0.0.1"
dependencies = [
"env_logger",
@@ -789,15 +789,17 @@ dependencies = [
"futures-lite",
"log",
"networking",
"parking_lot",
"pin-project",
"pyo3",
"pyo3-async-runtimes",
"pyo3-log",
"pyo3-stub-gen",
"rand 0.10.1",
"serde_json",
"tokio",
"tracing",
"zenoh",
"zerompk",
]
[[package]]
@@ -808,7 +810,7 @@ checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -974,7 +976,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -1084,7 +1086,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -1123,6 +1125,12 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
version = "0.5.0"
@@ -1332,12 +1340,12 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.12.1"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"hashbrown 0.17.0",
"serde",
"serde_core",
]
@@ -1362,9 +1370,9 @@ dependencies = [
[[package]]
name = "inventory"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e"
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
dependencies = [
"rustversion",
]
@@ -1387,7 +1395,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -1450,7 +1458,7 @@ checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -1496,7 +1504,7 @@ dependencies = [
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -1524,7 +1532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -1819,11 +1827,9 @@ dependencies = [
"parking_lot",
"rand 0.10.1",
"tokio",
"tracing",
"zenoh",
"zenoh-plugin-storage-manager",
"zenoh-plugin-trait",
"zerompk",
]
[[package]]
@@ -2057,9 +2063,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "5.1.0"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d"
checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
dependencies = [
"num-traits",
]
@@ -2160,7 +2166,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2181,7 +2187,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.12.1",
"indexmap 2.14.0",
"serde",
]
@@ -2245,7 +2251,7 @@ dependencies = [
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2283,7 +2289,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2397,7 +2403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2411,9 +2417,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.103"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
@@ -2459,7 +2465,7 @@ checksum = "bcd7d70ee0ca1661c40407e6f84e4463ef2658c90a9e2fbbd4515b2bcdfcaeca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2501,7 +2507,7 @@ dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2514,19 +2520,19 @@ dependencies = [
"proc-macro2",
"pyo3-build-config",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
name = "pyo3-stub-gen"
version = "0.17.2"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "398b833826a83ca72c1e26d1b2c7c71f9ca7c3bfc74eacc663901895c362ae33"
checksum = "b695173a33bec37b6acd288efe74f902f123f5891e5ff5ad53ff429e18833c14"
dependencies = [
"anyhow",
"chrono",
"either",
"indexmap 2.12.1",
"indexmap 2.14.0",
"inventory",
"itertools 0.14.0",
"log",
@@ -2536,22 +2542,25 @@ dependencies = [
"ordered-float",
"pyo3",
"pyo3-stub-gen-derive",
"rustpython-parser",
"serde",
"toml",
"serde_json",
"time",
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "pyo3-stub-gen-derive"
version = "0.17.2"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2426ba759d848787239d80f9fdb1f223786976f87fb6c3da8188ca7c17744b28"
checksum = "c2f901834d55c74f36be3353994062e3c3b3d47fda25572f5535e99434612ac8"
dependencies = [
"heck",
"indexmap 2.12.1",
"indexmap 2.14.0",
"proc-macro2",
"quote",
"rustpython-parser",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -2613,9 +2622,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.42"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -2765,7 +2774,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3091,7 +3100,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3166,7 +3175,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3177,7 +3186,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3195,9 +3204,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.3"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -3212,7 +3221,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.12.1",
"indexmap 2.14.0",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
@@ -3230,7 +3239,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3239,7 +3248,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.12.1",
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
@@ -3484,9 +3493,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.111"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -3501,7 +3510,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3536,7 +3545,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3547,7 +3556,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3674,7 +3683,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -3719,13 +3728,28 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
dependencies = [
"indexmap 2.12.1",
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_datetime 0.7.3",
"toml_parser",
"toml_writer",
"winnow",
"winnow 0.7.14",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.2",
]
[[package]]
@@ -3737,32 +3761,41 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.23.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832"
dependencies = [
"indexmap 2.12.1",
"toml_datetime",
"indexmap 2.14.0",
"toml_datetime 0.7.3",
"toml_parser",
"winnow",
"winnow 0.7.14",
]
[[package]]
name = "toml_parser"
version = "1.0.4"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
"winnow 1.0.2",
]
[[package]]
name = "toml_writer"
version = "1.0.4"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tracing"
@@ -3784,7 +3817,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -4017,7 +4050,7 @@ checksum = "3b5bb2756c16fb66f80cfbf5fb0e0c09a7001e739f453c9ec241b9c8b1556fda"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -4092,7 +4125,7 @@ checksum = "8c44ce98e7227a04eeb4cf9c784109a5c9710e54849ceb4f09f8597247897f1e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"unzip-n",
]
@@ -4186,7 +4219,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -4216,7 +4249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.12.1",
"indexmap 2.14.0",
"wasm-encoder",
"wasmparser",
]
@@ -4229,7 +4262,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap 2.12.1",
"indexmap 2.14.0",
"semver",
]
@@ -4345,7 +4378,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -4356,7 +4389,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -4633,6 +4666,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -4667,9 +4706,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap 2.12.1",
"indexmap 2.14.0",
"prettyplease",
"syn 2.0.111",
"syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -4685,7 +4724,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -4698,7 +4737,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap 2.12.1",
"indexmap 2.14.0",
"log",
"serde",
"serde_derive",
@@ -4717,7 +4756,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.12.1",
"indexmap 2.14.0",
"log",
"semver",
"serde",
@@ -4785,7 +4824,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"synstructure",
]
@@ -4884,7 +4923,7 @@ dependencies = [
"serde_json",
"serde_with",
"serde_yaml",
"toml",
"toml 0.9.8",
"tracing",
"uhlc",
"validated_struct",
@@ -5147,7 +5186,7 @@ checksum = "9310b02a8f6dc4bd04d9ce6b318b9d00182aeeeeca60410003307d63a2569a3f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"zenoh-keyexpr",
]
@@ -5363,7 +5402,7 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
@@ -5383,7 +5422,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
"synstructure",
]
@@ -5393,26 +5432,6 @@ version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerompk"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4bfaccde2671513aa564585b2e9ccf0a0ccaf9689477abfd60e53d3806afa51"
dependencies = [
"zerompk_derive",
]
[[package]]
name = "zerompk_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2197ce2b0c4f95ebfd8b09134ecfe0dcc152481b21bacd7a4b1976df1ccfdfc8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "zerotrie"
version = "0.2.3"
@@ -5443,7 +5462,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.117",
]
[[package]]
+14 -2
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["rust/exo_pyo3_bindings", "rust/networking"]
members = ["rust/exo_net", "rust/networking"]
[workspace.package]
version = "0.0.1"
@@ -32,7 +32,19 @@ env_logger = "0.11.10"
# networking
zenoh = "=1.9.0"
zerompk = "0.4.2"
async-stream = "0.3.6"
netwatcher = "0.6.0"
parking_lot = "0.12.5"
pin-project = "1.1.10"
pyo3 = "0.27.2"
pyo3-async-runtimes = "0.27.0"
pyo3-log = "0.13.2"
pyo3-stub-gen = "0.22.2"
rand = "0.10.1"
serde_json = "1.0.149"
tracing = "0.1.44"
zenoh-plugin-storage-manager = { version = "=1.9.0", default-features = false }
zenoh-plugin-trait = "=1.9.0"
[workspace.lints.rust]
static_mut_refs = "warn" # Or use "warn" instead of deny
+1 -1
View File
@@ -110,7 +110,7 @@
nixpkgs-fmt.enable = true;
ruff-format = {
enable = true;
excludes = [ "rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi" ];
excludes = [ "rust/exo_net/exo_net.pyi" ];
};
rustfmt = {
enable = true;
+2 -2
View File
@@ -22,8 +22,8 @@ sync-clean:
uv sync --all-packages --force-reinstall --no-cache
rust-rebuild:
PYO3_PYTHON="$(uv run python -c 'import sys; print(sys.executable)')" cargo run --bin stub_gen
uv sync --reinstall-package exo_pyo3_bindings
cargo run --bin stub_gen
uv sync --reinstall-package exo_net
build-dashboard:
#!/usr/bin/env bash
+3 -3
View File
@@ -15,7 +15,7 @@ dependencies = [
"huggingface-hub>=1.8.0",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo-pyo3-bindings", # rust bindings
"exo-net", # rust bindings
"anyio==4.11.0",
"mlx==0.31.2; sys_platform == 'darwin'",
"mlx-lm; sys_platform=='darwin'",
@@ -75,10 +75,10 @@ cuda13 = [
###
[tool.uv.workspace]
members = ["rust/exo_pyo3_bindings", "bench"]
members = ["rust/exo_net", "bench"]
[tool.uv.sources]
exo-pyo3-bindings = { workspace = true }
exo-net = { workspace = true }
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/deepseek-v4" }
torch = [
+6 -5
View File
@@ -35,17 +35,18 @@ let
# Replace workspace exo_pyo3_bindings with Nix-built wheel.
# Preserve passthru so mkVirtualEnv can resolve dependency groups.
# Copy .pyi stub + py.typed marker so basedpyright can find the types.
exo-pyo3-bindings = pkgs.stdenv.mkDerivation {
pname = "exo-pyo3-bindings";
exo-net = pkgs.stdenv.mkDerivation {
pname = "exo-net";
version = "0.1.0";
src = self'.packages.exo_pyo3_bindings;
src = self'.packages.exo-net;
# Install from pre-built wheel
nativeBuildInputs = [ final.pyprojectWheelHook ];
dontStrip = true;
passthru = prev.exo-pyo3-bindings.passthru or { };
postInstall = ''
local siteDir=$out/${final.python.sitePackages}/exo_pyo3_bindings
cp ${inputs.self}/rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi $siteDir/
local siteDir=$out/${final.python.sitePackages}/exo_net
cp ${inputs.self}/rust/exo_net/exo_net.pyi $siteDir/
touch $siteDir/py.typed
'';
};
Symlink
+1
View File
@@ -0,0 +1 @@
/nix/store/3945qvxy6hla1aa6dkzlcv99d2d4bw56-exo
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "exo_net"
version = { workspace = true }
edition = { workspace = true }
publish = false
[lib]
doctest = false
path = "src/lib.rs"
name = "exo_net"
# "cdylib" needed to produce shared library for Python to import
# "rlib" needed for stub-gen to run
crate-type = ["cdylib", "rlib"]
[[bin]]
path = "src/bin/stub_gen.rs"
name = "stub_gen"
doc = false
[lints]
workspace = true
[dependencies]
networking.workspace = true
extend.workspace = true
# interop
pyo3 = { workspace = true, features = ["experimental-async"] }
pyo3-stub-gen.workspace = true
pyo3-async-runtimes = { workspace = true, features = [
"attributes",
"tokio-runtime",
"testing",
] }
pyo3-log.workspace = true
# async runtime
tokio = { workspace = true, features = ["full", "tracing"] }
futures-lite.workspace = true
pin-project.workspace = true
# Tracing
log.workspace = true
env_logger.workspace = true
# Networking
zenoh.workspace = true
rand.workspace = true
serde_json.workspace = true
parking_lot.workspace = true
tracing.workspace = true
File renamed without changes.
+81
View File
@@ -0,0 +1,81 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401, F403, F405
import builtins
import collections.abc
import typing
__all__ = [
"NetReceiver",
"NetSender",
"NetworkingHandle",
"PyFromSwarm",
"PySession",
"StateProxy",
]
@typing.final
class NetReceiver:
def recv(self) -> collections.abc.Awaitable[bytes]: ...
@typing.final
class NetSender:
def send(self, data: bytes) -> collections.abc.Awaitable[bool]: ...
@typing.final
class NetworkingHandle:
@staticmethod
def new(
identity: bytes,
bootstrap_peers: typing.Sequence[builtins.str],
listen_port: builtins.int,
) -> tuple[NetworkingHandle, PySession]: ...
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Subscribe to a `GossipSub` topic.
Returns `True` if the subscription worked. Returns `False` if we were already subscribed.
"""
async def gossipsub_unsubscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Unsubscribes from a `GossipSub` topic.
Returns `True` if we were subscribed to this topic. Returns `False` if we were not subscribed.
"""
async def gossipsub_publish(self, topic: builtins.str, data: bytes) -> None:
r"""
Publishes a message with multiple topics to the `GossipSub` network.
If no peers are found that subscribe to this topic, throws `NoPeersSubscribedToTopicError` exception.
"""
async def recv(self) -> PyFromSwarm: ...
class PyFromSwarm:
@typing.final
class Connection(PyFromSwarm):
__match_args__ = ("connected",)
@property
def connected(self) -> builtins.bool: ...
def __new__(cls, connected: builtins.bool) -> PyFromSwarm.Connection: ...
@typing.final
class Message(PyFromSwarm):
__match_args__ = (
"topic",
"data",
)
@property
def topic(self) -> builtins.str: ...
@property
def data(self) -> bytes: ...
def __new__(cls, topic: builtins.str, data: bytes) -> PyFromSwarm.Message: ...
@typing.final
class PySession:
def net_receiver(self, key: builtins.str) -> NetReceiver: ...
def net_sender(self, key: builtins.str) -> NetSender: ...
def state_proxy(self) -> StateProxy: ...
@typing.final
class StateProxy:
def snapshot(self) -> collections.abc.Awaitable[str]: ...
+24
View File
@@ -0,0 +1,24 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401
import builtins
import collections.abc
import typing
@typing.final
class NetReceiver: ...
@typing.final
class NetSender: ...
@typing.final
class PySession:
@staticmethod
def init() -> collections.abc.Awaitable[PySession]: ...
def net_receiver(self, key: builtins.str) -> NetReceiver: ...
def net_sender(self, key: builtins.str) -> NetSender: ...
def state_proxy(self) -> StateProxy: ...
@typing.final
class StateProxy:
def snapshot(self) -> collections.abc.Awaitable[str]: ...
@@ -3,24 +3,22 @@ requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "exo_pyo3_bindings"
version = "0.2.1"
name = "exo_net"
version = "0.3.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Andrei Cravtov", email = "the.andrei.cravtov@gmail.com" },
{ name = "Evan Quiney", email = "evanev7@gmail.com" },
{ name = "Andrei Cravtov", email = "the.andrei.cravtov@gmail.com" },
]
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
dev = ["exo_pyo3_bindings", "pytest>=8.4.0", "pytest-asyncio>=1.0.0"]
dev = ["exo-net", "pytest>=8.4.0", "pytest-asyncio>=1.0.0"]
[tool.maturin]
#purelib = true
#python-source = "python"
module-name = "exo_pyo3_bindings"
module-name = "exo_net"
features = ["pyo3/extension-module", "pyo3/experimental-async"]
[tool.pytest.ini_options]
@@ -2,7 +2,7 @@ use pyo3_stub_gen::Result;
fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().filter_or("RUST_LOG", "info")).init();
let stub = exo_pyo3_bindings::stub_info()?;
let stub = exo_net::stub_info()?;
stub.generate()?;
Ok(())
}
@@ -5,21 +5,21 @@
//!
mod allow_threading;
mod ident;
// mod ident;
mod networking;
mod point_to_point;
mod session;
// mod state;
use crate::ident::PyKeypair;
use crate::networking::networking_submodule;
use crate::point_to_point::{NetReceiver, NetSender};
use crate::session::PySession;
//use crate::state::StateProxy;
use pyo3::prelude::PyModule;
use pyo3::types::PyModuleMethods;
use pyo3::{Bound, PyResult, pyclass, pymodule};
use pyo3::{Bound, PyResult, pymodule};
use pyo3_stub_gen::define_stub_info_gatherer;
/// Namespace for all the constants used by this crate.
pub(crate) mod r#const {
pub const MPSC_CHANNEL_SIZE: usize = 1024;
}
/// Namespace for crate-wide extension traits/methods
pub(crate) mod ext {
use crate::allow_threading::AllowThreads;
@@ -151,7 +151,7 @@ pub(crate) mod ext {
/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule(name = "exo_pyo3_bindings")]
#[pymodule(name = "exo_net")]
fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
// install logger
pyo3_log::init();
@@ -162,7 +162,12 @@ fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
// TODO: for now this is all NOT a submodule, but figure out how to make the submodule system
// work with maturin, where the types generate correctly, in the right folder, without
// too many importing issues...
m.add_class::<PyKeypair>()?;
// m.add_class::<PyKeypair>()?;
// networking_submodule(m)?;
// m.add_class::<StateProxy>()?;
m.add_class::<PySession>()?;
m.add_class::<NetReceiver>()?;
m.add_class::<NetSender>()?;
networking_submodule(m)?;
// top-level constructs
@@ -1,15 +1,13 @@
use std::pin::Pin;
use std::sync::Arc;
use crate::r#const::MPSC_CHANNEL_SIZE;
use crate::ext::{ByteArrayExt as _, FutureExt, PyErrExt as _};
use crate::ext::{ResultExt as _, TokioMpscSenderExt as _};
use crate::ident::PyKeypair;
use crate::pyclass;
use crate::session::PySession;
use futures_lite::{Stream, StreamExt as _};
use networking::swarm::{FromSwarm, ToSwarm, create_swarm};
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::{PyModule, PyModuleMethods as _};
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyBytes;
use pyo3::{Bound, Py, PyAny, PyErr, PyResult, Python, pymethods};
use pyo3_stub_gen::derive::{
@@ -53,29 +51,42 @@ impl PyNetworkingHandle {
// ---- Lifecycle management methods ----
#[new]
#[pyo3(signature = (identity, bootstrap_peers, listen_port))]
fn py_new(
identity: Bound<'_, PyKeypair>,
#[staticmethod]
fn new<'py>(
identity: Bound<'py, PyBytes>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> PyResult<Self> {
) -> PyResult<(PyNetworkingHandle, PySession)> {
// create communication channels
let (to_swarm, from_client) = mpsc::channel(MPSC_CHANNEL_SIZE);
let (to_swarm, from_client) = mpsc::channel(1024);
// get identity
let identity = identity.borrow().0.clone();
let identity = u128::from_le_bytes(
identity
.extract::<'_, Vec<u8>>()?
.try_into()
.map_err(|_| PyValueError::new_err("invalid identity bytes"))?,
);
// create networking swarm (within tokio context!! or it crashes)
let _guard = pyo3_async_runtimes::tokio::get_runtime().enter();
let swarm = create_swarm(identity, from_client, bootstrap_peers, listen_port)
.map(|it| it.into_stream())
let swarm = pyo3_async_runtimes::tokio::get_runtime()
.block_on(create_swarm(
identity,
from_client,
bootstrap_peers,
listen_port,
))
.pyerr()?;
Ok(Self {
swarm: Arc::new(Mutex::new(swarm)),
to_swarm,
})
let session = swarm.session.z.clone();
Ok((
PyNetworkingHandle {
swarm: Arc::new(Mutex::new(swarm.into_stream())),
to_swarm,
},
PySession { session },
))
}
#[gen_stub(skip)]
+112
View File
@@ -0,0 +1,112 @@
use std::sync::Arc;
use pyo3::exceptions::PyConnectionError;
use pyo3::prelude::*;
use pyo3::{exceptions::PyStopAsyncIteration, types::PyBytes};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use zenoh::Result;
use zenoh::{
handlers::FifoChannelHandler,
pubsub::{Publisher, Subscriber},
sample::Sample,
};
use crate::ext::ByteArrayExt;
#[gen_stub_pyclass]
#[pyclass]
pub struct NetReceiver {
pub subscriber: Subscriber<FifoChannelHandler<Sample>>,
}
#[gen_stub_pymethods]
#[pymethods]
impl NetReceiver {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[bytes]",
imports=("collections.abc")
))]
pub fn recv<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, {
assert!(
self.subscriber.receiver_count() == 1,
"tried to receive twice on the same receiver"
);
let subscriber = self.subscriber.clone();
async move {
match subscriber.recv_async().await {
Err(_) => {
// stream closed;
Err(PyStopAsyncIteration::new_err(()))
}
Ok(sample) => Ok(sample.payload().to_bytes().to_vec().pybytes()),
}
}
})
}
}
#[gen_stub_pyclass]
#[pyclass]
pub struct NetSender {
pub publisher: Arc<Publisher<'static>>,
pub first: bool,
}
#[gen_stub_pymethods]
#[pymethods]
impl NetSender {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[bool]",
imports=("collections.abc")
))]
pub fn send<'py>(
&'py mut self,
py: Python<'py>,
data: Bound<'py, PyBytes>,
) -> PyResult<Bound<'py, PyAny>> {
let is_first = self.first;
self.first = false;
pyo3_async_runtimes::tokio::future_into_py(py, {
let publisher = Arc::clone(&self.publisher);
// clone the data so py can have it back
let bytes = data.as_bytes().to_vec();
async move {
if is_first {
log::warn!("sender waiting for listener");
wait_for_listener(&*publisher)
.await
.map_err(|e| PyConnectionError::new_err(e.to_string()))?;
log::warn!("listener found");
}
log::warn!("checking for matcher");
if !publisher
.matching_status()
.await
.map_err(|e| PyConnectionError::new_err(e.to_string()))?
.matching()
{
log::warn!("no matcher found");
return Ok(false);
}
log::warn!("publishing");
publisher
.put(&bytes)
.await
.map_err(|e| PyConnectionError::new_err(e.to_string()))?;
Ok(true)
}
})
}
}
async fn wait_for_listener<'a>(publisher: &Publisher<'a>) -> Result<()> {
let matcher = publisher.matching_listener().await?;
if publisher.matching_status().await?.matching() {
return Ok(());
}
while let Ok(status) = matcher.recv_async().await {
if status.matching() {
break;
}
}
Ok(())
}
+70
View File
@@ -0,0 +1,70 @@
use std::sync::Arc;
use pyo3::{exceptions::PyValueError, prelude::*};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use zenoh::Session;
use zenoh::Wait;
use crate::{
point_to_point::{NetReceiver, NetSender},
//state::StateProxy,
};
#[gen_stub_pyclass]
#[pyclass]
pub struct PySession {
pub session: Session,
}
#[gen_stub_pymethods]
#[pymethods]
impl PySession {
/* for now construct with NetworkingHandle
#[staticmethod]
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[PySession]",
imports=("collections.abc")
))]
pub fn init<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
Ok(Self {
session: networking::open(
networking::cfg(rand::random(), 0).expect("default cfg is valid"),
)
.await
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?,
})
})
}
*/
pub fn net_receiver<'py>(&self, key: String) -> PyResult<NetReceiver> {
Ok(NetReceiver {
subscriber: self
.session
.declare_subscriber(key)
.wait()
// C5: key format error
.map_err(|e| PyValueError::new_err(e.to_string()))?,
})
}
pub fn net_sender<'py>(&self, key: String) -> PyResult<NetSender> {
Ok(NetSender {
publisher: Arc::new(
self.session
.declare_publisher(key)
.wait()
// C5: key format error, could be declaration error
.map_err(|e| PyValueError::new_err(e.to_string()))?,
),
first: true,
})
}
//pub fn state_proxy(&self) -> StateProxy {
//StateProxy {
//session: self.session.clone(),
//}
//}
}
+78
View File
@@ -0,0 +1,78 @@
use pyo3::{exceptions::PyValueError, prelude::*};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use serde_json::{Map, Value};
use zenoh::{Result, Session, sample::SampleFields};
#[gen_stub_pyclass]
#[pyclass]
pub struct StateProxy {
pub session: Session,
}
#[gen_stub_pymethods]
#[pymethods]
impl StateProxy {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[str]",
imports=("collections.abc")
))]
pub fn snapshot<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, {
let session = self.session.clone();
async move {
Self::_snapshot(session)
.await
.map_err(|e| PyValueError::new_err(e.to_string()))
.map(|v| v.to_string())
}
})
}
}
impl StateProxy {
async fn _snapshot(session: Session) -> Result<Value> {
let q = session.get("storage/mem1/**").await?;
let mut v = Value::Object(Map::default());
while let Ok(sample) = q.recv_async().await {
let mut cur_v = &mut v;
let Ok(sample) = sample.into_result() else {
continue;
};
// skip storage/mem1
let SampleFields {
payload, key_expr, ..
} = sample.into();
let mut iter = key_expr.split('/').skip(2).peekable();
loop {
let Some(p) = iter.next() else {
break;
};
if iter.peek().is_none() {
// terminal; write value into json
let existing = cur_v
.as_object_mut()
.expect("path terminated unexpectedly - value stored at some/path and some/path/two")
.insert(p.to_owned(), Value::String(payload.try_to_string()?.to_string()));
if let Some(value) = existing {
assert!(value.is_string())
// could log, but string overwrites are fine
}
} else {
// non-terminal; ensure key exists in v, then replace cur with that object
cur_v = cur_v
.as_object_mut()
.expect("path terminated unexpectedly - value stored at some/path and some/path/two")
.entry(p)
.or_insert(Value::Object(Map::default()));
assert!(
cur_v.is_object(),
"path terminated unexpectedly - value stored at some/path and some/path/two"
)
}
}
}
Ok(v)
}
}
-44
View File
@@ -1,44 +0,0 @@
use crate::ext::ResultExt as _;
use pyo3::types::{PyBytes, PyBytesMethods as _};
use pyo3::{Bound, PyResult, Python, pyclass, pymethods};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
/// Identity keypair of a node.
#[gen_stub_pyclass]
#[pyclass(name = "Keypair", frozen)]
#[repr(transparent)]
pub struct PyKeypair(pub u128);
#[gen_stub_pymethods]
#[pymethods]
#[allow(clippy::needless_pass_by_value)]
impl PyKeypair {
/// Generate a new Ed25519 keypair.
#[staticmethod]
fn generate() -> Self {
Self(rand::random())
}
/// Construct an Ed25519 keypair from secret key bytes
#[staticmethod]
fn from_bytes(bytes: Bound<'_, PyBytes>) -> PyResult<Self> {
let bytes = Vec::from(bytes.as_bytes());
Ok(Self(u128::from_le_bytes(
bytes
.try_into()
.map_err(|_| "passed too many bytes to from_bytes")
.pyerr()?,
)))
}
/// Get the secret key bytes underlying the keypair
fn to_bytes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
let bytes = self.0.to_le_bytes();
Ok(PyBytes::new(py, &bytes))
}
/// Convert the `Keypair` into the corresponding `PeerId` string, which we use as our `NodeId`.
fn to_node_id(&self) -> String {
format!("{:x}", self.0)
}
}
@@ -1,36 +0,0 @@
import asyncio
import pytest
from exo_pyo3_bindings import (
Keypair,
NetworkingHandle,
PyFromSwarm,
)
@pytest.mark.asyncio
async def test_sleep_on_multiple_items() -> None:
print("PYTHON: starting handle")
h = NetworkingHandle(Keypair.generate(), [], 0)
print("PYTHON: handle started")
rt = asyncio.create_task(_await_recv(h))
# sleep for 4 ticks
for i in range(10):
await asyncio.sleep(1)
await h.gossipsub_publish("topic", b"somehting or other")
async def _await_recv(h: NetworkingHandle):
while True:
event = await h.recv()
match event:
case PyFromSwarm.Connection() as c:
print(f"PYTHON: connection update: {c}")
case PyFromSwarm.Message() as m:
print(f"PYTHON: message: {m}")
if __name__ == "__main__":
asyncio.run(test_sleep_on_multiple_items())
+7 -10
View File
@@ -4,19 +4,16 @@ version.workspace = true
edition.workspace = true
[dependencies]
async-stream = "0.3.6"
async-stream.workspace = true
futures-lite.workspace = true
netwatcher = { version = "0.6.0", features = ["tokio"] }
parking_lot = "0.12.5"
netwatcher = { workspace = true, features = ["tokio"] }
parking_lot.workspace = true
tokio = { workspace = true, features = ["full"] }
zenoh = { version = "=1.9.0", features = ["internal", "plugins", "unstable"] }
zenoh-plugin-storage-manager = { version = "=1.9.0", default-features = false }
zenoh-plugin-trait = "=1.9.0"
zerompk = { version = "0.4.2", features = ["derive"] }
rand = "0.10.1"
tracing = "0.1.44"
zenoh = { workspace = true, features = ["internal", "plugins", "unstable"] }
zenoh-plugin-storage-manager.workspace = true
zenoh-plugin-trait.workspace = true
rand.workspace = true
log.workspace = true
[lints]
workspace = true
+4 -3
View File
@@ -1,5 +1,5 @@
use log::info;
use networking;
use tracing::info;
use zenoh::Result;
#[tokio::main]
@@ -9,14 +9,15 @@ async fn main() -> Result<()> {
let cfg = networking::cfg(rand::random(), 0)?;
let session = networking::open(cfg).await?;
let _tok = session
.z
.liveliness()
.declare_token(format!("nodes/{}/live", session.zid()))
.declare_token(format!("nodes/{}/live", session.z.zid()))
.await?;
let key_expr = "storage/mem1/name";
let payload = "me";
info!("Putting Data ('{key_expr}': '{payload}')...");
session.put(key_expr, payload).await?;
session.z.put(key_expr, payload).await?;
tokio::signal::ctrl_c().await?;
Ok(())
}
+4 -2
View File
@@ -1,5 +1,5 @@
use log::info;
use networking;
use tracing::info;
use zenoh::Result;
#[tokio::main]
@@ -9,10 +9,12 @@ async fn main() -> Result<()> {
let cfg = networking::cfg(rand::random(), 0)?;
let session = networking::open(cfg).await?;
let _tok = session
.z
.liveliness()
.declare_token(format!("nodes/{}/live", session.zid()))
.declare_token(format!("nodes/{}/live", session.z.zid()))
.await?;
let _sub = session
.z
.liveliness()
.declare_subscriber("nodes/*/live")
.history(true)
+10 -21
View File
@@ -1,10 +1,7 @@
use std::{
env,
ops::{Deref, DerefMut},
panic,
};
use std::{env, panic, sync::Arc};
use netwatcher::WatchHandle;
use parking_lot::Mutex;
use tokio::{sync::mpsc, task::JoinHandle};
use zenoh::{Result, Session as ZSession, config::WhatAmI, internal::runtime::Runtime};
use zenoh_plugin_storage_manager::StoragesPlugin;
@@ -49,11 +46,11 @@ pub async fn open(cfg: zenoh::Config) -> Result<Session> {
.plugins_manager(plugins)
.build()
.await?;
let session = zenoh::session::init(runtime.clone().into()).await?;
let z = zenoh::session::init(runtime.clone().into()).await?;
runtime.start().await?;
let _watch_all_handle = watch_all(runtime).await?;
Ok(Session {
session,
z,
_watch_all_handle,
})
}
@@ -64,7 +61,9 @@ async fn watch_all(runtime: Runtime) -> Result<WatchAllHandle> {
cfg.insert_json5("scouting/multicast/interface", "\"auto\"")?;
let mut scout = zenoh::scout(WhatAmI::Peer, cfg.clone()).await?;
let (send, mut recv) = mpsc::unbounded_channel();
let _sync = netwatcher::watch_interfaces_with_callback(move |u| _ = send.send(u))?;
let _sync = Arc::new(Mutex::new(netwatcher::watch_interfaces_with_callback(
move |u| _ = send.send(u),
)?));
let _async = tokio::task::spawn(async move {
loop {
tokio::select! {
@@ -90,27 +89,17 @@ async fn watch_all(runtime: Runtime) -> Result<WatchAllHandle> {
}
pub struct Session {
pub session: ZSession,
pub z: ZSession,
_watch_all_handle: WatchAllHandle,
}
impl Deref for Session {
type Target = ZSession;
fn deref(&self) -> &Self::Target {
&self.session
}
}
impl DerefMut for Session {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.session
}
}
impl Drop for WatchAllHandle {
fn drop(&mut self) {
log::error!("aborting iface watcher");
self._async.abort();
}
}
pub struct WatchAllHandle {
_sync: WatchHandle,
_sync: Arc<Mutex<WatchHandle>>,
_async: JoinHandle<Result<()>>,
}
+15 -12
View File
@@ -6,7 +6,6 @@ use std::pin::Pin;
use futures_lite::Stream;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::info;
use zenoh::Result;
use zenoh::Session;
use zenoh::handlers::FifoChannelHandler;
@@ -14,7 +13,6 @@ use zenoh::liveliness::LivelinessToken;
use zenoh::pubsub::Subscriber;
use zenoh::sample::Sample;
use zenoh::sample::SampleKind;
use zerompk::{FromMessagePack, ToMessagePack};
#[derive(Debug)]
pub enum ToSwarm {
@@ -32,7 +30,7 @@ pub enum ToSwarm {
result_sender: oneshot::Sender<Result<()>>,
},
}
#[derive(Debug, ToMessagePack, FromMessagePack)]
#[derive(Debug)]
pub enum FromSwarm {
Message { topic: String, data: Vec<u8> },
Discovered {},
@@ -41,26 +39,27 @@ pub enum FromSwarm {
pub type Topics = HashMap<String, Subscriber<()>>;
pub struct Swarm {
cfg: zenoh::Config,
pub session: crate::Session,
from_client: mpsc::Receiver<ToSwarm>,
}
impl Swarm {
pub fn into_stream(self) -> Pin<Box<dyn Stream<Item = FromSwarm> + Send>> {
let Swarm {
cfg,
session,
mut from_client,
} = self;
let stream = async_stream::stream! {
// very important!
let mut session = session;
let (mut to_topics, mut from_topics) = mpsc::channel(1024);
let mut topics = Topics::new();
let Ok(mut session) = crate::open(cfg).await else { return; };
let Ok((_token, discovery)) = register_liveness(&mut session).await else { return; };
let Ok((_token, discovery)) = register_liveness(&mut session.z).await else { return; };
loop {
tokio::select! {
msg = from_client.recv() => {
let Some(msg) = msg else { break };
on_message(&mut session, &mut topics, &mut to_topics, msg).await;
on_message(&mut session.z, &mut topics, &mut to_topics, msg).await;
}
event = from_topics.recv() => {
if let Some(event) = event {
@@ -73,11 +72,11 @@ impl Swarm {
let nid = key_expr.strip_prefix("nodes/").and_then(|s| s.strip_suffix("/live"));
yield match token.kind() {
SampleKind::Put => {
info!("discovered: {nid:?}");
log::info!("discovered: {nid:?}");
FromSwarm::Discovered {}
}
SampleKind::Delete => {
info!("expired: {nid:?}");
log::info!("expired: {nid:?}");
FromSwarm::Expired {}
}
}
@@ -171,7 +170,7 @@ async fn on_message(
}
}
pub fn create_swarm(
pub async fn create_swarm(
identity: u128,
from_client: mpsc::Receiver<ToSwarm>,
bootstrap_peers: Vec<String>,
@@ -182,5 +181,9 @@ pub fn create_swarm(
todo!();
}
let cfg = crate::cfg(identity, listen_port)?;
Ok(Swarm { cfg, from_client })
let session = crate::open(cfg).await?;
Ok(Swarm {
session,
from_client,
})
}
+4 -3
View File
@@ -55,6 +55,7 @@
];
OPENSSL_NO_VENDOR = "1";
MATURIN_NO_INSTALL_RUST = "1";
# Required for pyo3 tests to find libpython
LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.python313 ];
@@ -81,11 +82,11 @@
config = {
packages = {
# Python bindings wheel via maturin
exo_pyo3_bindings = craneLib.buildPackage (
exo-net = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts;
pname = "exo_pyo3_bindings";
pname = "exo-net";
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [
pkgs.maturin
@@ -95,7 +96,7 @@
maturin build \
--release \
--manylinux off \
--manifest-path rust/exo_pyo3_bindings/Cargo.toml \
--manifest-path rust/exo_net/Cargo.toml \
--features "pyo3/extension-module,pyo3/experimental-async" \
--interpreter ${pkgs.python313}/bin/python \
--out dist
-5
View File
@@ -20,7 +20,6 @@ from exo.shared.types.chunks import (
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import (
Base64Image,
InputMessage,
@@ -181,7 +180,6 @@ def ollama_request_to_text_generation(
async def generate_ollama_chat_stream(
_command_id: CommandId,
chunk_stream: AsyncGenerator[
ErrorChunk | ToolCallChunk | TokenChunk | PrefillProgressChunk, None
],
@@ -264,7 +262,6 @@ async def generate_ollama_chat_stream(
async def collect_ollama_chat_response(
_command_id: CommandId,
chunk_stream: AsyncGenerator[
ErrorChunk | ToolCallChunk | TokenChunk | PrefillProgressChunk, None
],
@@ -369,7 +366,6 @@ def ollama_generate_request_to_text_generation(
async def generate_ollama_generate_stream(
_command_id: CommandId,
chunk_stream: AsyncGenerator[
ErrorChunk | ToolCallChunk | TokenChunk | PrefillProgressChunk, None
],
@@ -442,7 +438,6 @@ async def generate_ollama_generate_stream(
async def collect_ollama_generate_response(
_command_id: CommandId,
chunk_stream: AsyncGenerator[
ErrorChunk | ToolCallChunk | TokenChunk | PrefillProgressChunk, None
],
+258 -299
View File
@@ -5,6 +5,7 @@ import json
import random
import time
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http import HTTPStatus
from pathlib import Path
@@ -12,7 +13,7 @@ from typing import Annotated, Any, Literal, cast
from uuid import uuid4
import anyio
from anyio import BrokenResourceError, ClosedResourceError
from exo_net import NetSender, PySession
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
@@ -21,6 +22,7 @@ from hypercorn.asyncio import serve # pyright: ignore[reportUnknownVariableType
from hypercorn.config import Config
from hypercorn.typing import ASGIFramework
from loguru import logger
from pydantic import TypeAdapter
from exo.api.adapters.chat_completions import (
chat_request_to_text_generation,
@@ -140,10 +142,12 @@ from exo.shared.models.model_cards import (
)
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
from exo.shared.types.chunks import (
ErrorChunk,
ImageChunk,
Chunk,
ImageGenerationChunk,
InputImageChunk,
PrefillProgressChunk,
StatusChunk,
TextGenerationChunk,
TokenChunk,
ToolCallChunk,
)
@@ -157,7 +161,6 @@ from exo.shared.types.commands import (
DeleteInstance,
DeleteInstanceLink,
DownloadCommand,
ForwarderCommand,
ForwarderDownloadCommand,
ImageEdits,
ImageGeneration,
@@ -171,7 +174,6 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, Id, NodeId, SystemId
from exo.shared.types.events import (
ChunkGenerated,
Event,
IndexedEvent,
InstanceDeleted,
@@ -197,7 +199,7 @@ from exo.shared.types.worker.downloads import DownloadCompleted
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding
from exo.utils.banner import print_startup_banner
from exo.utils.channels import Receiver, Sender, channel
from exo.utils.channels import Receiver, Sender
from exo.utils.disk_event_log import DiskEventLog
from exo.utils.power_sampler import PowerSampler
from exo.utils.task_group import TaskGroup
@@ -230,6 +232,90 @@ def _require_disaggregation_enabled() -> None:
)
@dataclass
class Transport:
z: PySession
cancel_scopes: dict[CommandId, anyio.CancelScope] = field(
init=False, default_factory=dict
)
command_sender: NetSender = field(init=False)
paused: bool = field(init=False, default=False)
paused_ev: anyio.Event = field(init=False, default_factory=anyio.Event)
def __post_init__(self):
# TODO: retire root keyspace
self.command_sender = self.z.net_sender("orchestrator")
async def send_command(self, command: Command) -> bool:
while self.paused:
await self.paused_ev.wait()
return await self.command_sender.send(command.model_dump_json().encode("utf-8"))
async def stream_text(
self,
command_id: CommandId,
) -> AsyncGenerator[TextGenerationChunk | StatusChunk]:
async for chunk in self.stream(command_id):
if isinstance(chunk, (TextGenerationChunk | StatusChunk)):
yield chunk
async def stream_images(
self,
command_id: CommandId,
) -> AsyncGenerator[ImageGenerationChunk]:
async for chunk in self.stream(command_id):
if isinstance(chunk, (ImageGenerationChunk)):
yield chunk
async def stream(
self,
command_id: CommandId,
) -> AsyncGenerator[Chunk,]:
try:
with anyio.CancelScope() as cs:
self.cancel_scopes[command_id] = cs
# recv from any node
receiver = self.z.net_receiver(
f"runners/*/active_tasks/{command_id}/chunks"
)
while True:
data = await receiver.recv()
yield (
chunk := cast(
Chunk,
TypeAdapter(Chunk).validate_json(
data, strict=True, extra="forbid"
),
)
)
if (
not isinstance(chunk, StatusChunk)
and chunk.finish_reason is not None
):
break
except anyio.get_cancelled_exc_class():
with anyio.CancelScope(shield=True):
await self.command_sender.send(
TaskCancelled(cancelled_command_id=command_id)
.model_dump_json()
.encode("utf-8")
)
finally:
self.cancel_scopes.pop(command_id, None)
with anyio.CancelScope(shield=True):
await self.command_sender.send(
TaskFinished(finished_command_id=command_id)
.model_dump_json()
.encode("utf-8")
)
def cancel(self, command_id: CommandId) -> bool:
if (cs := self.cancel_scopes.get(command_id, None)) is not None:
cs.cancel()
return True
return False
class API:
def __init__(
self,
@@ -237,15 +323,14 @@ class API:
*,
port: int,
event_receiver: Receiver[IndexedEvent],
command_sender: Sender[ForwarderCommand],
download_command_sender: Sender[ForwarderDownloadCommand],
# This lets us pause the API if an election is running
election_receiver: Receiver[ElectionMessage],
session: PySession,
) -> None:
self.state = State()
self._event_log = DiskEventLog(_API_EVENT_LOG_DIR)
self._system_id = SystemId()
self.command_sender = command_sender
self.download_command_sender = download_command_sender
self.event_receiver = event_receiver
self.election_receiver = election_receiver
@@ -254,9 +339,6 @@ class API:
self.port = port
self._sent_image_hashes: set[str] = set()
self.paused: bool = False
self.paused_ev: anyio.Event = anyio.Event()
self.app = FastAPI()
@self.app.middleware("http")
@@ -280,13 +362,7 @@ class API:
name="dashboard",
)
self._text_generation_queues: dict[
CommandId,
Sender[TokenChunk | ErrorChunk | ToolCallChunk | PrefillProgressChunk],
] = {}
self._image_generation_queues: dict[
CommandId, Sender[ImageChunk | ErrorChunk]
] = {}
self.transport = Transport(session)
self._image_store = ImageStore(EXO_IMAGE_CACHE_DIR)
self._tg: TaskGroup = TaskGroup()
@@ -307,9 +383,9 @@ class API:
def unpause(self, result_clock: int):
logger.info("Unpausing API")
self.last_completed_election = result_clock
self.paused = False
self.paused_ev.set()
self.paused_ev = anyio.Event()
self.transport.paused = False
self.transport.paused_ev.set()
self.transport.paused_ev = anyio.Event()
def _setup_exception_handlers(self) -> None:
self.app.exception_handler(HTTPException)(self.http_exception_handler)
@@ -424,7 +500,7 @@ class API:
instance_meta=payload.instance_meta,
min_nodes=payload.min_nodes,
)
await self._send(command)
await self.transport.send_command(command)
return CreateInstanceResponse(
message="Command received.",
@@ -449,7 +525,7 @@ class API:
command = CreateInstance(
instance=instance,
)
await self._send(command)
await self.transport.send_command(command)
return CreateInstanceResponse(
message="Command received.",
@@ -632,7 +708,7 @@ class API:
command = DeleteInstance(
instance_id=instance_id,
)
await self._send(command)
await self.transport.send_command(command)
return DeleteInstanceResponse(
message="Command received.",
command_id=command.command_id,
@@ -667,7 +743,7 @@ class API:
prefill_instances=list(body.prefill_instances),
decode_instances=list(body.decode_instances),
)
await self._send(command)
await self.transport.send_command(command)
return InstanceLinkResponse(
message="Command received.", command_id=command.command_id
)
@@ -677,64 +753,24 @@ class API:
) -> InstanceLinkResponse:
_require_disaggregation_enabled()
command = DeleteInstanceLink(link_id=link_id)
await self._send(command)
await self.transport.send_command(command)
return InstanceLinkResponse(
message="Command received.", command_id=command.command_id
)
async def cancel_command(self, command_id: CommandId) -> CancelCommandResponse:
"""Cancel an active command by closing its stream and notifying workers."""
sender = self._text_generation_queues.get(
command_id
) or self._image_generation_queues.get(command_id)
if sender is None:
if self.transport.cancel(command_id):
return CancelCommandResponse(
message="Command cancelled.",
command_id=command_id,
)
else:
raise HTTPException(
status_code=404,
detail="Command not found or already completed",
)
await self._send(TaskCancelled(cancelled_command_id=command_id))
sender.close()
return CancelCommandResponse(
message="Command cancelled.",
command_id=command_id,
)
async def _token_chunk_stream(
self, command_id: CommandId
) -> AsyncGenerator[
TokenChunk | ErrorChunk | ToolCallChunk | PrefillProgressChunk, None
]:
"""Yield chunks for a given command until completion.
This is the internal low-level stream used by all API adapters.
"""
try:
self._text_generation_queues[command_id], recv = channel[
TokenChunk | ErrorChunk | ToolCallChunk | PrefillProgressChunk
]()
with recv as token_chunks:
async for chunk in token_chunks:
yield chunk
if isinstance(chunk, PrefillProgressChunk):
continue
if chunk.finish_reason is not None:
break
except anyio.get_cancelled_exc_class():
command = TaskCancelled(cancelled_command_id=command_id)
with anyio.CancelScope(shield=True):
await self.command_sender.send(
ForwarderCommand(origin=self._system_id, command=command)
)
raise
finally:
await self._send(TaskFinished(finished_command_id=command_id))
if command_id in self._text_generation_queues:
del self._text_generation_queues[command_id]
async def _collect_text_generation_with_stats(
self, command_id: CommandId
) -> BenchChatCompletionResponse:
@@ -749,7 +785,7 @@ class API:
async with anyio.create_task_group() as tg:
tg.start_soon(sampler.run)
async for chunk in self._token_chunk_stream(command_id):
async for chunk in self.transport.stream_text(command_id):
if isinstance(chunk, PrefillProgressChunk):
continue
@@ -816,7 +852,7 @@ class API:
images = task_params.images
if not images:
command = TextGeneration(task_params=task_params)
await self._send(command)
await self.transport.send_command(command)
return command
hashes = [hashlib.sha256(img.encode("ascii")).hexdigest() for img in images]
@@ -833,7 +869,7 @@ class API:
new_images.append((idx, img))
if not new_images:
await self._send(command)
await self.transport.send_command(command)
return command
all_chunks: list[tuple[int, str]] = []
@@ -842,7 +878,7 @@ class API:
all_chunks.append((img_idx, img_data[i : i + EXO_MAX_CHUNK_SIZE]))
for global_idx, (img_idx, chunk_data) in enumerate(all_chunks):
await self._send(
await self.transport.send_command(
SendInputChunk(
chunk=InputImageChunk(
model=task_params.model,
@@ -855,7 +891,7 @@ class API:
)
)
await self._send(command)
await self.transport.send_command(command)
return command
async def chat_completions(
@@ -875,7 +911,7 @@ class API:
with_sse_keepalive(
generate_chat_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
),
media_type="text/event-stream",
@@ -889,7 +925,7 @@ class API:
return StreamingResponse(
collect_chat_response(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/json",
)
@@ -918,7 +954,7 @@ class API:
with_sse_keepalive(
generate_chat_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
),
media_type="text/event-stream",
@@ -1024,7 +1060,7 @@ class API:
command = ImageGeneration(
task_params=payload,
)
await self._send(command)
await self.transport.send_command(command)
# Check if streaming is requested
if payload.stream and payload.partial_images and payload.partial_images > 0:
@@ -1060,105 +1096,85 @@ class API:
image_metadata: dict[tuple[int, bool], tuple[int | None, int | None]] = {}
images_complete = 0
try:
self._image_generation_queues[command_id], recv = channel[
ImageChunk | ErrorChunk
]()
with recv as chunks:
async for chunk in chunks:
if chunk.finish_reason == "error":
error_response = ErrorResponse(
error=ErrorInfo(
message=chunk.error_message or "Internal server error",
type="InternalServerError",
code=500,
)
)
yield f"data: {error_response.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
key = (chunk.image_index, chunk.is_partial)
if key not in image_chunks:
image_chunks[key] = {}
image_total_chunks[key] = chunk.total_chunks
image_metadata[key] = (
chunk.partial_index,
chunk.total_partials,
)
image_chunks[key][chunk.chunk_index] = chunk.data
# Check if this image is complete
if len(image_chunks[key]) == image_total_chunks[key]:
full_data = "".join(
image_chunks[key][i] for i in range(len(image_chunks[key]))
)
partial_idx, total_partials = image_metadata[key]
if chunk.is_partial:
# Yield partial image event (always use b64_json for partials)
event_data = {
"type": "partial",
"image_index": chunk.image_index,
"partial_index": partial_idx,
"total_partials": total_partials,
"format": str(chunk.format),
"data": {
"b64_json": full_data
if response_format == "b64_json"
else None,
},
}
yield f"data: {json.dumps(event_data)}\n\n"
else:
# Final image
if response_format == "url":
image_bytes = base64.b64decode(full_data)
content_type = _format_to_content_type(chunk.format)
stored = self._image_store.store(
image_bytes, content_type
)
url = self._build_image_url(request, stored.image_id)
event_data = {
"type": "final",
"image_index": chunk.image_index,
"format": str(chunk.format),
"data": {"url": url},
}
else:
event_data = {
"type": "final",
"image_index": chunk.image_index,
"format": str(chunk.format),
"data": {"b64_json": full_data},
}
yield f"data: {json.dumps(event_data)}\n\n"
images_complete += 1
if images_complete >= num_images:
yield "data: [DONE]\n\n"
break
# Clean up completed image chunks
del image_chunks[key]
del image_total_chunks[key]
del image_metadata[key]
except anyio.get_cancelled_exc_class():
command = TaskCancelled(cancelled_command_id=command_id)
with anyio.CancelScope(shield=True):
await self.command_sender.send(
ForwarderCommand(origin=self._system_id, command=command)
async for chunk in self.transport.stream_images(command_id):
if chunk.finish_reason == "error":
error_response = ErrorResponse(
error=ErrorInfo(
message=chunk.error_message or "Internal server error",
type="InternalServerError",
code=500,
)
)
raise
finally:
await self._send(TaskFinished(finished_command_id=command_id))
if command_id in self._image_generation_queues:
del self._image_generation_queues[command_id]
yield f"data: {error_response.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
key = (chunk.image_index, chunk.is_partial)
if key not in image_chunks:
image_chunks[key] = {}
image_total_chunks[key] = chunk.total_chunks
image_metadata[key] = (
chunk.partial_index,
chunk.total_partials,
)
image_chunks[key][chunk.chunk_index] = chunk.data
# Check if this image is complete
if len(image_chunks[key]) == image_total_chunks[key]:
full_data = "".join(
image_chunks[key][i] for i in range(len(image_chunks[key]))
)
partial_idx, total_partials = image_metadata[key]
if chunk.is_partial:
# Yield partial image event (always use b64_json for partials)
event_data = {
"type": "partial",
"image_index": chunk.image_index,
"partial_index": partial_idx,
"total_partials": total_partials,
"format": str(chunk.format),
"data": {
"b64_json": full_data
if response_format == "b64_json"
else None,
},
}
yield f"data: {json.dumps(event_data)}\n\n"
else:
# Final image
if response_format == "url":
image_bytes = base64.b64decode(full_data)
content_type = _format_to_content_type(chunk.format)
stored = self._image_store.store(image_bytes, content_type)
url = self._build_image_url(request, stored.image_id)
event_data = {
"type": "final",
"image_index": chunk.image_index,
"format": str(chunk.format),
"data": {"url": url},
}
else:
event_data = {
"type": "final",
"image_index": chunk.image_index,
"format": str(chunk.format),
"data": {"b64_json": full_data},
}
yield f"data: {json.dumps(event_data)}\n\n"
images_complete += 1
if images_complete >= num_images:
yield "data: [DONE]\n\n"
break
# Clean up completed image chunks
del image_chunks[key]
del image_total_chunks[key]
del image_metadata[key]
async def _collect_image_chunks(
self,
@@ -1177,74 +1193,55 @@ class API:
images_complete = 0
stats: ImageGenerationStats | None = None
try:
self._image_generation_queues[command_id], recv = channel[
ImageChunk | ErrorChunk
]()
while images_complete < num_images:
with recv as chunks:
async for chunk in chunks:
if chunk.finish_reason == "error":
raise HTTPException(
status_code=500,
detail=chunk.error_message or "Internal server error",
)
if chunk.is_partial:
continue
if chunk.image_index not in image_chunks:
image_chunks[chunk.image_index] = {}
image_total_chunks[chunk.image_index] = chunk.total_chunks
image_formats[chunk.image_index] = chunk.format
image_chunks[chunk.image_index][chunk.chunk_index] = chunk.data
if capture_stats and chunk.stats is not None:
stats = chunk.stats
if (
len(image_chunks[chunk.image_index])
== image_total_chunks[chunk.image_index]
):
images_complete += 1
if images_complete >= num_images:
break
images: list[ImageData] = []
for image_idx in range(num_images):
chunks_dict = image_chunks[image_idx]
full_data = "".join(chunks_dict[i] for i in range(len(chunks_dict)))
if response_format == "url" and request is not None:
image_bytes = base64.b64decode(full_data)
content_type = _format_to_content_type(image_formats.get(image_idx))
stored = self._image_store.store(image_bytes, content_type)
url = self._build_image_url(request, stored.image_id)
images.append(ImageData(b64_json=None, url=url))
else:
images.append(
ImageData(
b64_json=full_data
if response_format == "b64_json"
else None,
url=None,
)
while images_complete < num_images:
async for chunk in self.transport.stream_images(command_id):
if chunk.finish_reason == "error":
raise HTTPException(
status_code=500,
detail=chunk.error_message or "Internal server error",
)
return (images, stats if capture_stats else None)
except anyio.get_cancelled_exc_class():
command = TaskCancelled(cancelled_command_id=command_id)
with anyio.CancelScope(shield=True):
await self.command_sender.send(
ForwarderCommand(origin=self._system_id, command=command)
if chunk.is_partial:
continue
if chunk.image_index not in image_chunks:
image_chunks[chunk.image_index] = {}
image_total_chunks[chunk.image_index] = chunk.total_chunks
image_formats[chunk.image_index] = chunk.format
image_chunks[chunk.image_index][chunk.chunk_index] = chunk.data
if capture_stats and chunk.stats is not None:
stats = chunk.stats
if (
len(image_chunks[chunk.image_index])
== image_total_chunks[chunk.image_index]
):
images_complete += 1
if images_complete >= num_images:
break
images: list[ImageData] = []
for image_idx in range(num_images):
chunks_dict = image_chunks[image_idx]
full_data = "".join(chunks_dict[i] for i in range(len(chunks_dict)))
if response_format == "url" and request is not None:
image_bytes = base64.b64decode(full_data)
content_type = _format_to_content_type(image_formats.get(image_idx))
stored = self._image_store.store(image_bytes, content_type)
url = self._build_image_url(request, stored.image_id)
images.append(ImageData(b64_json=None, url=url))
else:
images.append(
ImageData(
b64_json=full_data if response_format == "b64_json" else None,
url=None,
)
)
raise
finally:
await self._send(TaskFinished(finished_command_id=command_id))
if command_id in self._image_generation_queues:
del self._image_generation_queues[command_id]
return (images, stats if capture_stats else None)
async def _collect_image_generation(
self,
@@ -1294,7 +1291,7 @@ class API:
command = ImageGeneration(
task_params=payload,
)
await self._send(command)
await self.transport.send_command(command)
return await self._collect_image_generation_with_stats(
request=request,
@@ -1357,7 +1354,7 @@ class API:
f"Sending input image: {len(image_data)} bytes in {total_chunks} chunks"
)
for chunk_index, chunk_data in enumerate(data_chunks):
await self._send(
await self.transport.send_command(
SendInputChunk(
chunk=InputImageChunk(
model=resolved_model,
@@ -1369,7 +1366,7 @@ class API:
)
)
await self._send(command)
await self.transport.send_command(command)
return command
async def image_edits(
@@ -1497,7 +1494,7 @@ class API:
generate_claude_stream(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
),
media_type="text/event-stream",
@@ -1512,7 +1509,7 @@ class API:
collect_claude_response(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/json",
)
@@ -1533,7 +1530,7 @@ class API:
generate_responses_stream(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
),
media_type="text/event-stream",
@@ -1549,7 +1546,7 @@ class API:
collect_responses_response(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/json",
)
@@ -1575,8 +1572,7 @@ class API:
if payload.stream:
return StreamingResponse(
generate_ollama_chat_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/x-ndjson",
headers={
@@ -1588,8 +1584,7 @@ class API:
else:
return StreamingResponse(
collect_ollama_chat_response(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/json",
)
@@ -1611,8 +1606,7 @@ class API:
if payload.stream:
return StreamingResponse(
generate_ollama_generate_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/x-ndjson",
headers={
@@ -1624,8 +1618,7 @@ class API:
else:
return StreamingResponse(
collect_ollama_generate_response(
command.command_id,
self._token_chunk_stream(command.command_id),
self.transport.stream_text(command.command_id),
),
media_type="application/json",
)
@@ -1761,11 +1754,8 @@ class API:
status_code=400, detail=f"Failed to fetch model: {exc}"
) from exc
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=AddCustomModelCard(model_card=card),
)
await self.transport.command_sender.send(
AddCustomModelCard(model_card=card).model_dump_json().encode("utf-8")
)
# Immediately update the local cache so the subsequent GET /models
@@ -1790,11 +1780,8 @@ class API:
if card is None or not card.is_custom:
raise HTTPException(status_code=404, detail="Custom model card not found")
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=DeleteCustomModelCard(model_id=model_id),
)
await self.transport.command_sender.send(
DeleteCustomModelCard(model_id=model_id).model_dump_json().encode("utf-8")
)
return JSONResponse(
@@ -1859,7 +1846,6 @@ class API:
shutdown_ev.set()
finally:
self._event_log.close()
self.command_sender.close()
self.event_receiver.close()
async def run_api(self, ev: anyio.Event):
@@ -1883,23 +1869,6 @@ class API:
self.state = apply(self.state, i_event)
event = i_event.event
if isinstance(event, ChunkGenerated):
if queue := self._image_generation_queues.get(
event.command_id, None
):
assert isinstance(event.chunk, ImageChunk)
try:
await queue.send(event.chunk)
except (BrokenResourceError, ClosedResourceError):
self._image_generation_queues.pop(event.command_id, None)
if queue := self._text_generation_queues.get(
event.command_id, None
):
assert not isinstance(event.chunk, ImageChunk)
try:
await queue.send(event.chunk)
except (BrokenResourceError, ClosedResourceError):
self._text_generation_queues.pop(event.command_id, None)
if isinstance(event, InstanceDeleted):
self._close_streams_for_instance(event.instance_id)
if isinstance(event, TracesMerged):
@@ -1914,10 +1883,7 @@ class API:
task, (TextGenerationTask, ImageGenerationTask, ImageEditsTask)
):
continue
if sender := self._text_generation_queues.pop(task.command_id, None):
sender.close()
if sender := self._image_generation_queues.pop(task.command_id, None):
sender.close()
self.transport.cancel(task.command_id)
def _save_merged_trace(self, event: TracesMerged) -> None:
traces = [
@@ -1938,7 +1904,7 @@ class API:
with self.election_receiver as ems:
async for message in ems:
if message.clock > self.last_completed_election:
self.paused = True
self.transport.paused = True
async def _cleanup_expired_images(self):
"""Periodically clean up expired images from the store."""
@@ -1949,13 +1915,6 @@ class API:
if removed > 0:
logger.debug(f"Cleaned up {removed} expired images")
async def _send(self, command: Command):
while self.paused:
await self.paused_ev.wait()
await self.command_sender.send(
ForwarderCommand(origin=self._system_id, command=command)
)
async def _send_download(self, command: DownloadCommand):
await self.download_command_sender.send(
ForwarderDownloadCommand(origin=self._system_id, command=command)
+16 -14
View File
@@ -5,8 +5,10 @@ import resource
import signal
from dataclasses import dataclass, field
from typing import Self
from uuid import uuid4
import anyio
from exo_net import PySession
from loguru import logger
from pydantic import PositiveInt
@@ -16,7 +18,7 @@ from exo.download.coordinator import DownloadCoordinator
from exo.download.impl_shard_downloader import exo_shard_downloader
from exo.master.main import Master
from exo.routing.event_router import EventRouter
from exo.routing.router import Router, get_node_id_keypair
from exo.routing.router import Router
from exo.shared.constants import EXO_LOG
from exo.shared.election import Election, ElectionResult
from exo.shared.logging import logger_cleanup, logger_setup
@@ -39,31 +41,31 @@ class Node:
api: API | None
node_id: NodeId
session: PySession
offline: bool
_api_port: int
_tg: TaskGroup = field(init=False, default_factory=TaskGroup)
@classmethod
async def create(cls, args: "Args") -> Self:
keypair = get_node_id_keypair()
node_id = NodeId(keypair.to_node_id())
node_id_bytes = uuid4()
node_id = NodeId(str(node_id_bytes))
session_id = SessionId(master_node_id=node_id, election_clock=0)
router = Router.create(
keypair,
router, session = Router.create(
node_id_bytes.bytes,
bootstrap_peers=args.bootstrap_peers,
listen_port=args.libp2p_port,
)
await router.register_topic(topics.GLOBAL_EVENTS)
await router.register_topic(topics.LOCAL_EVENTS)
await router.register_topic(topics.COMMANDS)
await router.register_topic(topics.ELECTION_MESSAGES)
await router.register_topic(topics.CONNECTION_MESSAGES)
await router.register_topic(topics.DOWNLOAD_COMMANDS)
event_router = EventRouter(
session_id,
command_sender=router.sender(topics.COMMANDS),
external_outbound=router.sender(topics.LOCAL_EVENTS),
external_inbound=router.receiver(topics.GLOBAL_EVENTS),
command_sender=session.net_sender("orchestrator"),
)
logger.info(f"Starting node {node_id}")
@@ -85,9 +87,9 @@ class Node:
node_id,
port=args.api_port,
event_receiver=event_router.receiver(),
command_sender=router.sender(topics.COMMANDS),
download_command_sender=router.sender(topics.DOWNLOAD_COMMANDS),
election_receiver=router.receiver(topics.ELECTION_MESSAGES),
session=session,
)
else:
api = None
@@ -97,9 +99,9 @@ class Node:
node_id,
event_receiver=event_router.receiver(),
event_sender=event_router.sender(),
command_sender=router.sender(topics.COMMANDS),
download_command_sender=router.sender(topics.DOWNLOAD_COMMANDS),
api_port=args.api_port,
session=session,
)
else:
worker = None
@@ -111,8 +113,8 @@ class Node:
event_sender=event_router.sender(),
global_event_sender=router.sender(topics.GLOBAL_EVENTS),
local_event_receiver=router.receiver(topics.LOCAL_EVENTS),
command_receiver=router.receiver(topics.COMMANDS),
download_command_sender=router.sender(topics.DOWNLOAD_COMMANDS),
session=session,
)
er_send, er_recv = channel[ElectionResult]()
@@ -125,7 +127,6 @@ class Node:
election_message_sender=router.sender(topics.ELECTION_MESSAGES),
election_message_receiver=router.receiver(topics.ELECTION_MESSAGES),
connection_message_receiver=router.receiver(topics.CONNECTION_MESSAGES),
command_receiver=router.receiver(topics.COMMANDS),
election_result_sender=er_send,
)
@@ -139,6 +140,7 @@ class Node:
master,
api,
node_id,
session,
args.offline,
args.api_port,
)
@@ -188,7 +190,7 @@ class Node:
self.event_router.shutdown()
self.event_router = EventRouter(
result.session_id,
self.router.sender(topics.COMMANDS),
self.session.net_sender("orchestrator"),
self.router.receiver(topics.GLOBAL_EVENTS),
self.router.sender(topics.LOCAL_EVENTS),
)
@@ -209,10 +211,10 @@ class Node:
event_sender=self.event_router.sender(),
global_event_sender=self.router.sender(topics.GLOBAL_EVENTS),
local_event_receiver=self.router.receiver(topics.LOCAL_EVENTS),
command_receiver=self.router.receiver(topics.COMMANDS),
download_command_sender=self.router.sender(
topics.DOWNLOAD_COMMANDS
),
session=self.session,
)
self._tg.start_soon(self.master.run)
elif (
@@ -248,11 +250,11 @@ class Node:
self.node_id,
event_receiver=self.event_router.receiver(),
event_sender=self.event_router.sender(),
command_sender=self.router.sender(topics.COMMANDS),
download_command_sender=self.router.sender(
topics.DOWNLOAD_COMMANDS
),
api_port=self._api_port,
session=self.session,
)
self._tg.start_soon(self.worker.run)
if self.api:
+275 -282
View File
@@ -1,7 +1,10 @@
from datetime import datetime, timedelta, timezone
from typing import cast
import anyio
from exo_net import PySession
from loguru import logger
from pydantic import TypeAdapter
from exo.master.placement import (
add_instance_to_placements,
@@ -15,11 +18,11 @@ from exo.shared.apply import apply
from exo.shared.constants import EXO_EVENT_LOG_DIR, EXO_TRACING_ENABLED
from exo.shared.types.commands import (
AddCustomModelCard,
Command,
CreateInstance,
DeleteCustomModelCard,
DeleteInstance,
DeleteInstanceLink,
ForwarderCommand,
ForwarderDownloadCommand,
ImageEdits,
ImageGeneration,
@@ -121,18 +124,18 @@ class Master:
node_id: NodeId,
session_id: SessionId,
*,
command_receiver: Receiver[ForwarderCommand],
event_sender: Sender[Event],
local_event_receiver: Receiver[LocalForwarderEvent],
global_event_sender: Sender[GlobalForwarderEvent],
download_command_sender: Sender[ForwarderDownloadCommand],
session: PySession,
):
self.node_id = node_id
self.session_id = session_id
self.state = State()
self._tg: TaskGroup = TaskGroup()
self.command_task_mapping: dict[CommandId, TaskId] = {}
self.command_receiver = command_receiver
self.session = session
self.local_event_receiver = local_event_receiver
self.global_event_sender = global_event_sender
self.download_command_sender = download_command_sender
@@ -155,301 +158,291 @@ class Master:
self._event_log.close()
self.global_event_sender.close()
self.local_event_receiver.close()
self.command_receiver.close()
async def shutdown(self):
logger.info("Stopping Master")
self._tg.cancel_tasks()
async def _command_processor(self) -> None:
with self.command_receiver as commands:
async for forwarder_command in commands:
try:
logger.info(f"Executing command: {forwarder_command.command}")
receiver = self.session.net_receiver("orchestrator")
while True:
command = cast(
Command, TypeAdapter(Command).validate_json(await receiver.recv())
)
try:
logger.info(f"Executing command: {command}")
generated_events: list[Event] = []
command = forwarder_command.command
instance_task_counts: dict[InstanceId, int] = {}
match command:
case TestCommand():
pass
case TextGeneration():
prefill_only: set[InstanceId] = set()
for link in self.state.instance_links.values():
prefill_only.update(link.prefill_instances)
for link in self.state.instance_links.values():
prefill_only.difference_update(link.decode_instances)
generated_events: list[Event] = []
instance_task_counts: dict[InstanceId, int] = {}
match command:
case TestCommand():
pass
case TextGeneration():
prefill_only: set[InstanceId] = set()
for link in self.state.instance_links.values():
prefill_only.update(link.prefill_instances)
for link in self.state.instance_links.values():
prefill_only.difference_update(link.decode_instances)
for instance in self.state.instances.values():
if (
instance.shard_assignments.model_id
== command.task_params.model
and instance.instance_id not in prefill_only
):
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
for task in self.state.tasks.values()
if task.instance_id == instance.instance_id
and task.task_status in in_flight
)
instance_task_counts[instance.instance_id] = (
task_count
)
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
)
available_instance_ids = sorted(
instance_task_counts.keys(),
key=lambda instance_id: instance_task_counts[
instance_id
],
)
decode_instance_id = available_instance_ids[0]
task_id = TaskId()
params = command.task_params.model_copy(
update={
"prefill_endpoint": _prefill_endpoint_for(
self.state, decode_instance_id
),
}
)
generated_events.append(
TaskCreated(
task_id=task_id,
task=TextGenerationTask(
task_id=task_id,
command_id=command.command_id,
instance_id=decode_instance_id,
task_status=TaskStatus.Pending,
task_params=params,
),
)
)
self.command_task_mapping[command.command_id] = task_id
case ImageGeneration():
for instance in self.state.instances.values():
if (
instance.shard_assignments.model_id
== command.task_params.model
):
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
for task in self.state.tasks.values()
if task.instance_id == instance.instance_id
and task.task_status in in_flight
)
instance_task_counts[instance.instance_id] = (
task_count
)
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
)
available_instance_ids = sorted(
instance_task_counts.keys(),
key=lambda instance_id: instance_task_counts[
instance_id
],
)
task_id = TaskId()
selected_instance_id = available_instance_ids[0]
generated_events.append(
TaskCreated(
task_id=task_id,
task=ImageGenerationTask(
task_id=task_id,
command_id=command.command_id,
instance_id=selected_instance_id,
task_status=TaskStatus.Pending,
task_params=command.task_params,
),
)
)
self.command_task_mapping[command.command_id] = task_id
if EXO_TRACING_ENABLED:
selected_instance = self.state.instances.get(
selected_instance_id
)
if selected_instance:
ranks = set(
shard.device_rank
for shard in selected_instance.shard_assignments.runner_to_shard.values()
)
self._expected_ranks[task_id] = ranks
case ImageEdits():
for instance in self.state.instances.values():
if (
instance.shard_assignments.model_id
== command.task_params.model
):
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
for task in self.state.tasks.values()
if task.instance_id == instance.instance_id
and task.task_status in in_flight
)
instance_task_counts[instance.instance_id] = (
task_count
)
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
)
available_instance_ids = sorted(
instance_task_counts.keys(),
key=lambda instance_id: instance_task_counts[
instance_id
],
)
task_id = TaskId()
selected_instance_id = available_instance_ids[0]
generated_events.append(
TaskCreated(
task_id=task_id,
task=ImageEditsTask(
task_id=task_id,
command_id=command.command_id,
instance_id=selected_instance_id,
task_status=TaskStatus.Pending,
task_params=command.task_params,
),
)
)
self.command_task_mapping[command.command_id] = task_id
if EXO_TRACING_ENABLED:
selected_instance = self.state.instances.get(
selected_instance_id
)
if selected_instance:
ranks = set(
shard.device_rank
for shard in selected_instance.shard_assignments.runner_to_shard.values()
)
self._expected_ranks[task_id] = ranks
case DeleteInstance():
placement = delete_instance(command, self.state.instances)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
)
for cmd in cancel_unnecessary_downloads(
placement, self.state.downloads
for instance in self.state.instances.values():
if (
instance.shard_assignments.model_id
== command.task_params.model
and instance.instance_id not in prefill_only
):
await self.download_command_sender.send(
ForwarderDownloadCommand(
origin=self._system_id, command=cmd
)
)
generated_events.extend(transition_events)
case PlaceInstance():
placement = place_instance(
command,
self.state.topology,
self.state.instances,
self.state.node_memory,
self.state.node_network,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
)
generated_events.extend(transition_events)
case CreateInstance():
placement = add_instance_to_placements(
command,
self.state.topology,
self.state.instances,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
)
generated_events.extend(transition_events)
case SendInputChunk(chunk=chunk):
generated_events.append(
InputChunkReceived(
command_id=chunk.command_id,
chunk=chunk,
)
)
case TaskCancelled():
if (
task_id := self.command_task_mapping.get(
command.cancelled_command_id
)
) is not None:
generated_events.append(
TaskStatusUpdated(
task_status=TaskStatus.Cancelled,
task_id=task_id,
)
)
else:
logger.warning(
f"Nonexistent command {command.cancelled_command_id} cancelled"
)
case TaskFinished():
if (
task_id := self.command_task_mapping.pop(
command.finished_command_id, None
)
) is not None:
generated_events.append(TaskDeleted(task_id=task_id))
else:
logger.warning(
f"Finished command {command.finished_command_id} finished"
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
for task in self.state.tasks.values()
if task.instance_id == instance.instance_id
and task.task_status in in_flight
)
instance_task_counts[instance.instance_id] = task_count
case AddCustomModelCard():
generated_events.append(
CustomModelCardAdded(model_card=command.model_card)
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
)
case DeleteCustomModelCard():
generated_events.append(
CustomModelCardDeleted(model_id=command.model_id)
)
case SetInstanceLink():
link = InstanceLink(
link_id=command.link_id,
prefill_instances=list(
dict.fromkeys(command.prefill_instances)
available_instance_ids = sorted(
instance_task_counts.keys(),
key=lambda instance_id: instance_task_counts[instance_id],
)
decode_instance_id = available_instance_ids[0]
task_id = TaskId()
params = command.task_params.model_copy(
update={
"prefill_endpoint": _prefill_endpoint_for(
self.state, decode_instance_id
),
decode_instances=list(
dict.fromkeys(command.decode_instances)
}
)
generated_events.append(
TaskCreated(
task_id=task_id,
task=TextGenerationTask(
task_id=task_id,
command_id=command.command_id,
instance_id=decode_instance_id,
task_status=TaskStatus.Pending,
task_params=params,
),
)
generated_events.append(InstanceLinkCreated(link=link))
case DeleteInstanceLink():
generated_events.append(
InstanceLinkDeleted(link_id=command.link_id)
)
case RequestEventLog():
end = len(self._event_log)
for i, event in enumerate(
self._event_log.read_range(command.since_idx, end),
start=command.since_idx,
)
self.command_task_mapping[command.command_id] = task_id
case ImageGeneration():
for instance in self.state.instances.values():
if (
instance.shard_assignments.model_id
== command.task_params.model
):
await self._send_event(IndexedEvent(idx=i, event=event))
for event in generated_events:
await self.event_sender.send(event)
except ValueError as e:
logger.opt(exception=e).warning("Error in command processor")
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
for task in self.state.tasks.values()
if task.instance_id == instance.instance_id
and task.task_status in in_flight
)
instance_task_counts[instance.instance_id] = task_count
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
)
available_instance_ids = sorted(
instance_task_counts.keys(),
key=lambda instance_id: instance_task_counts[instance_id],
)
task_id = TaskId()
selected_instance_id = available_instance_ids[0]
generated_events.append(
TaskCreated(
task_id=task_id,
task=ImageGenerationTask(
task_id=task_id,
command_id=command.command_id,
instance_id=selected_instance_id,
task_status=TaskStatus.Pending,
task_params=command.task_params,
),
)
)
self.command_task_mapping[command.command_id] = task_id
if EXO_TRACING_ENABLED:
selected_instance = self.state.instances.get(
selected_instance_id
)
if selected_instance:
self._expected_ranks[task_id] = set(
shard.device_rank
for shard in selected_instance.shard_assignments.runner_to_shard.values()
)
case ImageEdits():
for instance in self.state.instances.values():
if (
instance.shard_assignments.model_id
== command.task_params.model
):
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
for task in self.state.tasks.values()
if task.instance_id == instance.instance_id
and task.task_status in in_flight
)
instance_task_counts[instance.instance_id] = task_count
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
)
available_instance_ids = sorted(
instance_task_counts.keys(),
key=lambda instance_id: instance_task_counts[instance_id],
)
task_id = TaskId()
selected_instance_id = available_instance_ids[0]
generated_events.append(
TaskCreated(
task_id=task_id,
task=ImageEditsTask(
task_id=task_id,
command_id=command.command_id,
instance_id=selected_instance_id,
task_status=TaskStatus.Pending,
task_params=command.task_params,
),
)
)
self.command_task_mapping[command.command_id] = task_id
if EXO_TRACING_ENABLED:
selected_instance = self.state.instances.get(
selected_instance_id
)
if selected_instance:
self._expected_ranks[task_id] = set(
shard.device_rank
for shard in selected_instance.shard_assignments.runner_to_shard.values()
)
case DeleteInstance():
placement = delete_instance(command, self.state.instances)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
)
for cmd in cancel_unnecessary_downloads(
placement, self.state.downloads
):
await self.download_command_sender.send(
ForwarderDownloadCommand(
origin=self._system_id, command=cmd
)
)
generated_events.extend(transition_events)
case PlaceInstance():
placement = place_instance(
command,
self.state.topology,
self.state.instances,
self.state.node_memory,
self.state.node_network,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
)
generated_events.extend(transition_events)
case CreateInstance():
placement = add_instance_to_placements(
command,
self.state.topology,
self.state.instances,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
)
generated_events.extend(transition_events)
case SendInputChunk(chunk=chunk):
generated_events.append(
InputChunkReceived(
command_id=chunk.command_id,
chunk=chunk,
)
)
case TaskCancelled():
if (
task_id := self.command_task_mapping.get(
command.cancelled_command_id
)
) is not None:
generated_events.append(
TaskStatusUpdated(
task_status=TaskStatus.Cancelled,
task_id=task_id,
)
)
else:
logger.warning(
f"Nonexistent command {command.cancelled_command_id} cancelled"
)
case TaskFinished():
if (
task_id := self.command_task_mapping.pop(
command.finished_command_id, None
)
) is not None:
generated_events.append(TaskDeleted(task_id=task_id))
else:
logger.warning(
f"Finished command {command.finished_command_id} finished"
)
case AddCustomModelCard():
generated_events.append(
CustomModelCardAdded(model_card=command.model_card)
)
case DeleteCustomModelCard():
generated_events.append(
CustomModelCardDeleted(model_id=command.model_id)
)
case SetInstanceLink():
link = InstanceLink(
link_id=command.link_id,
prefill_instances=list(
dict.fromkeys(command.prefill_instances)
),
decode_instances=list(
dict.fromkeys(command.decode_instances)
),
)
generated_events.append(InstanceLinkCreated(link=link))
case DeleteInstanceLink():
generated_events.append(
InstanceLinkDeleted(link_id=command.link_id)
)
case RequestEventLog():
# We should just be able to send everything, since other buffers will ignore old messages
# rate limit to 1000 at a time
end = min(command.since_idx + 1000, len(self._event_log))
for i, event in enumerate(
self._event_log.read_range(command.since_idx, end),
start=command.since_idx,
):
await self._send_event(IndexedEvent(idx=i, event=event))
for event in generated_events:
await self.event_sender.send(event)
except ValueError as e:
logger.opt(exception=e).warning("Error in command processor")
# These plan loops are the cracks showing in our event sourcing architecture - more things could be commands
async def _plan(self) -> None:
+1 -1
View File
@@ -1,4 +1,4 @@
from exo_pyo3_bindings import PyFromSwarm
from exo_net import PyFromSwarm
from exo.utils.pydantic_ext import FrozenModel
+7 -6
View File
@@ -6,7 +6,7 @@ from anyio import BrokenResourceError, ClosedResourceError
from anyio.abc import CancelScope
from loguru import logger
from exo.shared.types.commands import ForwarderCommand, RequestEventLog
from exo.shared.types.commands import RequestEventLog
from exo.shared.types.common import SessionId, SystemId
from exo.shared.types.events import (
Event,
@@ -19,11 +19,13 @@ from exo.utils.channels import Receiver, Sender, channel
from exo.utils.event_buffer import OrderedBuffer
from exo.utils.task_group import TaskGroup
from exo_net import NetSender
@dataclass
class EventRouter:
session_id: SessionId
command_sender: Sender[ForwarderCommand]
command_sender: NetSender
external_inbound: Receiver[GlobalForwarderEvent]
external_outbound: Sender[LocalForwarderEvent]
_system_id: SystemId = field(init=False, default_factory=SystemId)
@@ -152,10 +154,9 @@ class EventRouter:
f"Nack attempt {self._nack_attempts}: Requesting Event Log from {since_idx}"
)
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=RequestEventLog(since_idx=since_idx),
)
RequestEventLog(since_idx=since_idx)
.model_dump_json()
.encode("utf-8")
)
finally:
if self._nack_cancel_scope is scope:
+7 -49
View File
@@ -2,8 +2,6 @@ from collections.abc import Sequence
from copy import copy
from itertools import count
from math import inf
from os import PathLike
from pathlib import Path
from typing import cast
from anyio import (
@@ -12,15 +10,9 @@ from anyio import (
move_on_after,
sleep_forever,
)
from exo_pyo3_bindings import (
Keypair,
NetworkingHandle,
PyFromSwarm,
)
from filelock import FileLock
from exo_net import NetworkingHandle, PyFromSwarm, PySession
from loguru import logger
from exo.shared.constants import EXO_NODE_ID_KEYPAIR
from exo.utils.channels import Receiver, Sender, channel
from exo.utils.pydantic_ext import FrozenModel
from exo.utils.task_group import TaskGroup
@@ -102,13 +94,14 @@ class Router:
@classmethod
def create(
cls,
identity: Keypair,
identity: bytes,
bootstrap_peers: Sequence[str] = (),
listen_port: int = 0,
) -> "Router":
return cls(
handle=NetworkingHandle(identity, list(bootstrap_peers), listen_port)
) -> "tuple[Router, PySession]":
handle, session = NetworkingHandle.new(
identity, list(bootstrap_peers), listen_port
)
return cls(handle=handle), session
def __init__(self, handle: NetworkingHandle):
self.topic_routers: dict[str, TopicRouter[FrozenModel]] = {}
@@ -189,9 +182,7 @@ class Router:
logger.debug(from_swarm)
match from_swarm:
case PyFromSwarm.Message(topic, data):
logger.trace(
f"Received message on {topic} with payload {data}"
)
logger.trace(f"Received message on {topic} with payload {data}")
if topic not in self.topic_routers:
logger.warning(
f"Received message on unknown or inactive topic {topic}"
@@ -228,36 +219,3 @@ class Router:
"Sending overlarge payload, network performance may be temporarily degraded"
)
await self._net.gossipsub_publish(topic, data)
def get_node_id_keypair(
path: str | bytes | PathLike[str] | PathLike[bytes] = EXO_NODE_ID_KEYPAIR,
) -> Keypair:
"""
Obtains the :class:`Keypair` associated with this node-ID.
Obtain the :class:`PeerId` by from it.
"""
# TODO(evan): bring back node id persistence once we figure out how to deal with duplicates
return Keypair.generate()
def lock_path(path: str | bytes | PathLike[str] | PathLike[bytes]) -> Path:
return Path(str(path) + ".lock")
# operate with cross-process lock to avoid race conditions
with FileLock(lock_path(path)):
with open(path, "a+b") as f: # opens in append-mode => starts at EOF
# if non-zero EOF, then file exists => use to get node-ID
if f.tell() != 0:
f.seek(0) # go to start & read protobuf-encoded bytes
protobuf_encoded = f.read()
try: # if decoded successfully, save & return
return Keypair.from_bytes(protobuf_encoded)
except ValueError as e: # on runtime error, assume corrupt file
logger.warning(f"Encountered error when trying to get keypair: {e}")
# if no valid credentials, create new ones and persist
with open(path, "w+b") as f:
keypair = Keypair.generate()
f.write(keypair.to_bytes())
return keypair
-14
View File
@@ -9,7 +9,6 @@ from anyio import (
from loguru import logger
from exo.routing.connection_message import ConnectionMessage
from exo.shared.types.commands import ForwarderCommand
from exo.shared.types.common import NodeId, SessionId
from exo.utils.channels import Receiver, Sender
from exo.utils.pydantic_ext import FrozenModel
@@ -22,7 +21,6 @@ class ElectionMessage(FrozenModel):
clock: int
seniority: int
proposed_session: SessionId
commands_seen: int
# Could eventually include a list of neighbour nodes for centrality
def __lt__(self, other: Self) -> bool:
@@ -30,8 +28,6 @@ class ElectionMessage(FrozenModel):
return self.clock < other.clock
if self.seniority != other.seniority:
return self.seniority < other.seniority
elif self.commands_seen != other.commands_seen:
return self.commands_seen < other.commands_seen
else:
return (
self.proposed_session.master_node_id
@@ -54,7 +50,6 @@ class Election:
election_message_sender: Sender[ElectionMessage],
election_result_sender: Sender[ElectionResult],
connection_message_receiver: Receiver[ConnectionMessage],
command_receiver: Receiver[ForwarderCommand],
is_candidate: bool = True,
seniority: int = 0,
):
@@ -64,7 +59,6 @@ class Election:
self.seniority = seniority if is_candidate else -1
self.clock = 0
self.node_id = node_id
self.commands_seen = 0
# Every node spawns as master
self.current_session: SessionId = SessionId(
master_node_id=node_id, election_clock=0
@@ -75,7 +69,6 @@ class Election:
self._em_receiver = election_message_receiver
self._er_sender = election_result_sender
self._cm_receiver = connection_message_receiver
self._co_receiver = command_receiver
# Campaign state
self._candidates: list[ElectionMessage] = []
@@ -89,7 +82,6 @@ class Election:
async with self._tg as tg:
tg.start_soon(self._election_receiver)
tg.start_soon(self._connection_receiver)
tg.start_soon(self._command_counter)
# And start an election immediately, that instantly resolves
candidates: list[ElectionMessage] = []
@@ -179,11 +171,6 @@ class Election:
logger.debug("Campaign started")
logger.debug("Connection message added")
async def _command_counter(self) -> None:
with self._co_receiver as commands:
async for _command in commands:
self.commands_seen += 1
async def _campaign(
self, candidates: list[ElectionMessage], campaign_timeout: float
) -> None:
@@ -261,5 +248,4 @@ class Election:
),
clock=c,
seniority=self.seniority,
commands_seen=self.commands_seen,
)
+2
View File
@@ -87,4 +87,6 @@ class PrefillProgressChunk(BaseChunk):
StatusChunk = PrefillProgressChunk
GenerationChunk = TokenChunk | ImageChunk | ToolCallChunk | ErrorChunk
TextGenerationChunk = TokenChunk | ToolCallChunk | ErrorChunk
ImageGenerationChunk = ImageChunk | ErrorChunk
Chunk = StatusChunk | GenerationChunk
+1 -2
View File
@@ -159,10 +159,9 @@ Event = (
| NodeTimedOut
| NodeGatheredInfo
| NodeDownloadProgress
| ChunkGenerated
| InputChunkReceived
| TopologyEdgeCreated
| TopologyEdgeDeleted
| InputChunkReceived
| TracesCollected
| TracesMerged
| CustomModelCardAdded
+2 -2
View File
@@ -7,7 +7,7 @@ import mlx.core as mx
from mlx_lm.tokenizer_utils import TokenizerWrapper
from exo.shared.types.common import ModelId
from exo.shared.types.events import Event
from exo.shared.types.events import ChunkGenerated, Event
from exo.shared.types.tasks import TaskId
from exo.shared.types.worker.instances import BoundInstance
from exo.shared.types.worker.runner_response import ModelLoadingResponse
@@ -32,7 +32,7 @@ from .vision import VisionProcessor
@dataclass
class MlxBuilder(Builder):
model_id: ModelId
event_sender: MpSender[Event]
event_sender: MpSender[Event | ChunkGenerated]
cancel_receiver: MpReceiver[TaskId]
inference_model: Model | None = None
tokenizer: TokenizerWrapper | None = None
+8 -7
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
import anyio
from anyio import fail_after, to_thread
from exo_net import PySession
from loguru import logger
from exo.api.types import ImageEditsTaskParams
@@ -65,16 +66,17 @@ class Worker:
*,
event_receiver: Receiver[IndexedEvent],
event_sender: Sender[Event],
session: PySession,
# This is for requesting updates. It doesn't need to be a general command sender right now,
# but I think it's the correct way to be thinking about commands
command_sender: Sender[ForwarderCommand],
download_command_sender: Sender[ForwarderDownloadCommand],
api_port: int,
):
self.node_id: NodeId = node_id
self.event_receiver = event_receiver
self.event_sender = event_sender
self.command_sender = command_sender
self.session = session
self.command_sender = session.net_sender("orchestrator")
self.download_command_sender = download_command_sender
self.api_port = api_port
@@ -114,7 +116,6 @@ class Worker:
# Actual shutdown code - waits for all tasks to complete before executing.
logger.info("Stopping Worker")
self.event_sender.close()
self.command_sender.close()
self.download_command_sender.close()
for runner in self.runners.values():
runner.shutdown()
@@ -209,10 +210,9 @@ class Worker:
f"Instance {iid} exceeded {EXO_MAX_INSTANCE_RETRIES} retries, requesting deletion"
)
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=DeleteInstance(instance_id=iid),
)
DeleteInstance(instance_id=iid)
.model_dump_json()
.encode("utf-8")
)
continue
@@ -375,6 +375,7 @@ class Worker:
runner = RunnerSupervisor.create(
bound_instance=task.bound_instance,
event_sender=self.event_sender.clone(),
session=self.session,
)
self.runners[task.bound_instance.bound_runner_id] = runner
self._tg.start_soon(runner.run)
@@ -96,7 +96,7 @@ class SequentialGenerator(Engine):
model_id: ModelId
device_rank: int
cancel_receiver: MpReceiver[TaskId]
event_sender: MpSender[Event]
event_sender: MpSender[Event | ChunkGenerated]
vision_processor: VisionProcessor | None = None
check_for_cancel_every: int = 50
@@ -327,7 +327,7 @@ class BatchGenerator(Engine):
model_id: ModelId
device_rank: int
cancel_receiver: MpReceiver[TaskId]
event_sender: MpSender[Event]
event_sender: MpSender[Event | ChunkGenerated]
check_for_cancel_every: int = 50
vision_processor: VisionProcessor | None = None
+1 -1
View File
@@ -86,7 +86,7 @@ class Runner:
self,
bound_instance: BoundInstance,
builder: Builder,
event_sender: MpSender[Event],
event_sender: MpSender[Event | ChunkGenerated],
task_receiver: MpReceiver[Task],
):
self.event_sender = event_sender
+28 -17
View File
@@ -10,9 +10,11 @@ from anyio import (
ClosedResourceError,
to_thread,
)
from exo_net import NetSender, PySession
from loguru import logger
from exo.shared.types.chunks import ErrorChunk
from exo.shared.types.commands import CommandId
from exo.shared.types.events import (
ChunkGenerated,
Event,
@@ -45,20 +47,18 @@ from exo.utils.channels import MpReceiver, MpSender, Sender, mp_channel
from exo.utils.task_group import TaskGroup
from exo.worker.runner.bootstrap import entrypoint
PREFILL_TIMEOUT_SECONDS = 60
DECODE_TIMEOUT_SECONDS = 5
@dataclass(eq=False)
class RunnerSupervisor:
shard_metadata: ShardMetadata
bound_instance: BoundInstance
runner_process: mp.Process
initialize_timeout: float
_ev_recv: MpReceiver[Event]
_ev_recv: MpReceiver[Event | ChunkGenerated]
_task_sender: MpSender[Task]
_event_sender: Sender[Event]
_cancel_sender: MpSender[TaskId]
session: PySession
_tg: TaskGroup = field(default_factory=TaskGroup, init=False)
status: RunnerStatus = field(default_factory=RunnerIdle, init=False)
pending: dict[TaskId, anyio.Event] = field(default_factory=dict, init=False)
@@ -75,9 +75,9 @@ class RunnerSupervisor:
*,
bound_instance: BoundInstance,
event_sender: Sender[Event],
initialize_timeout: float = 400,
session: PySession,
) -> Self:
ev_send, ev_recv = mp_channel[Event]()
ev_send, ev_recv = mp_channel[Event | ChunkGenerated]()
task_sender, task_recv = mp_channel[Task]()
cancel_sender, cancel_recv = mp_channel[TaskId]()
@@ -99,11 +99,11 @@ class RunnerSupervisor:
bound_instance=bound_instance,
shard_metadata=shard_metadata,
runner_process=runner_process,
initialize_timeout=initialize_timeout,
_ev_recv=ev_recv,
_task_sender=task_sender,
_cancel_sender=cancel_sender,
_event_sender=event_sender,
session=session,
)
return self
@@ -210,9 +210,19 @@ class RunnerSupervisor:
await self._check_runner(TimeoutError("cancel pipe blocked"))
async def _forward_events(self):
pubs: dict[CommandId, NetSender] = {}
try:
with self._ev_recv as events:
async for event in events:
if isinstance(event, ChunkGenerated):
if event.command_id not in pubs:
pubs[event.command_id] = self.session.net_sender(
f"runners/{self.bound_instance.bound_runner_id}/active_tasks/{event.command_id}/chunks"
)
pub = pubs[event.command_id]
await pub.send(event.chunk.model_dump_json().encode("utf-8"))
continue
if isinstance(event, RunnerStatusUpdated):
self.status = event.runner_status
if isinstance(event, TaskAcknowledged):
@@ -275,17 +285,18 @@ class RunnerSupervisor:
for task in self.in_progress.values():
if isinstance(task, (TextGeneration, ImageGeneration, ImageEdits)):
with anyio.CancelScope(shield=True):
await self._event_sender.send(
ChunkGenerated(
command_id=task.command_id,
chunk=ErrorChunk(
model=self.shard_metadata.model_card.model_id,
error_message=(
"Runner shutdown before completing command "
f"({cause})"
),
send = self.session.net_sender(
f"runners/{self.bound_instance.bound_runner_id}/active_tasks/{task.command_id}/chunks"
)
await send.send(
ErrorChunk(
model=self.shard_metadata.model_card.model_id,
error_message=(
f"Runner shutdown before completing command ({cause})"
),
)
.model_dump_json()
.encode("utf-8")
)
try:
+15
View File
@@ -0,0 +1,15 @@
import anyio
from exo_net import StateProxy
async def main():
sp = await StateProxy.init()
while True:
data = await sp.snapshot()
if data != "{}":
print(data)
await anyio.sleep(1)
if __name__ == "__main__":
anyio.run(main)
Generated
+16 -16
View File
@@ -22,7 +22,7 @@ prerelease-mode = "allow"
members = [
"exo",
"exo-bench",
"exo-pyo3-bindings",
"exo-net",
]
constraints = [{ name = "transformers", specifier = ">=5.6.2" }]
overrides = [
@@ -385,7 +385,7 @@ dependencies = [
{ name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "exo-pyo3-bindings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "exo-net", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
@@ -394,7 +394,7 @@ dependencies = [
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mflux", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.31.1", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx-lm", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx-vlm", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
@@ -416,21 +416,21 @@ build = [
]
cpu = [
{ name = "mlx", version = "0.31.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "(sys_platform == 'darwin' and extra == 'extra-3-exo-cpu') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "(sys_platform == 'darwin' and extra == 'extra-3-exo-cpu') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx-cpu", marker = "sys_platform == 'linux'" },
{ name = "mlx-lm", marker = "sys_platform == 'linux'" },
{ name = "mlx-vlm", marker = "sys_platform == 'linux'" },
]
cuda12 = [
{ name = "mlx", version = "0.31.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "(sys_platform == 'darwin' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "(sys_platform == 'darwin' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx-cuda-12", marker = "sys_platform == 'linux'" },
{ name = "mlx-lm", marker = "sys_platform == 'linux'" },
{ name = "mlx-vlm", marker = "sys_platform == 'linux'" },
]
cuda13 = [
{ name = "mlx", version = "0.31.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "(sys_platform == 'darwin' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "(sys_platform == 'darwin' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx-cuda-13", marker = "sys_platform == 'linux'" },
{ name = "mlx-lm", marker = "sys_platform == 'linux'" },
{ name = "mlx-vlm", marker = "sys_platform == 'linux'" },
@@ -451,7 +451,7 @@ requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "aiohttp", specifier = ">=3.12.14" },
{ name = "anyio", specifier = "==4.11.0" },
{ name = "exo-pyo3-bindings", editable = "rust/exo_pyo3_bindings" },
{ name = "exo-net", editable = "rust/exo_net" },
{ name = "fastapi", specifier = ">=0.116.1" },
{ name = "filelock", specifier = ">=3.18.0" },
{ name = "httpx", specifier = ">=0.28.1" },
@@ -541,13 +541,13 @@ requires-dist = [
]
[[package]]
name = "exo-pyo3-bindings"
version = "0.2.1"
source = { editable = "rust/exo_pyo3_bindings" }
name = "exo-net"
version = "0.3.0"
source = { editable = "rust/exo_net" }
[package.dev-dependencies]
dev = [
{ name = "exo-pyo3-bindings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "exo-net", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
@@ -556,7 +556,7 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "exo-pyo3-bindings", editable = "rust/exo_pyo3_bindings" },
{ name = "exo-net", editable = "rust/exo_net" },
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
]
@@ -1213,7 +1213,7 @@ dependencies = [
{ name = "hf-transfer", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "huggingface-hub", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "matplotlib", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "opencv-python", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "piexif", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
@@ -1263,7 +1263,7 @@ wheels = [
[[package]]
name = "mlx"
version = "0.32.0.dev20260427+cc3f3e60"
version = "0.32.0.dev20260429+cc3f3e60"
source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }
resolution-markers = [
"sys_platform == 'darwin'",
@@ -1315,7 +1315,7 @@ source = { git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Fdeepseek-v4
dependencies = [
{ name = "jinja2", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.31.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pyyaml", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
@@ -1332,7 +1332,7 @@ dependencies = [
{ name = "fastapi", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "miniaudio", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.31.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260427+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx", version = "0.32.0.dev20260429+cc3f3e60", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#cc3f3e60be1289506125f2fa19b73b05aa770df8" }, marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "mlx-lm", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "opencv-python", marker = "sys_platform == 'darwin' or (sys_platform == 'linux' and extra == 'extra-3-exo-cpu') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda12') or (sys_platform == 'linux' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },