Compare commits

...

19 Commits

Author SHA1 Message Date
Evan c6fab7fa97 fix: only shutdown runners that error unrecoverably
previously we would shutdown a runner that had previously failed,
leading to loops of create -> shutdown -> create.

now when a runner critically errors, we set its status to Shutdown, and
delete it. a failed runner is expected
2026-03-25 16:46:06 +00:00
vskiwi fc1ae90111 fix: DeepSeek V3.2 warmup crash and tool calling + add catalog cards (#1769)
## Summary

DeepSeek V3.2 (`DeepseekV32ForCausalLM`) is already supported by exo's
inference engine (architecture whitelisted in `model_cards.py`, DSML
encoding added in #1548), but **doesn't work out of the box** due to two
bugs:

### Bug 1: `warmup_inference` passes empty model ID

`warmup_inference()` in `generate.py` accepts `model_id: ModelId` as a
parameter but creates `TextGenerationTaskParams(model=ModelId(""), ...)`
instead of using it. Since `_needs_dsml_encoding()` checks
`"deepseek-v3.2" in task_params.model.lower()`, the empty string never
matches → falls back to `tokenizer.apply_chat_template()` →
**ValueError** because V3.2 has no Jinja chat template.

**Fix:** `model=ModelId("")` → `model=model_id` (one line).

### Bug 2: `_needs_dsml_encoding` limited to tool calling

`_needs_dsml_encoding()` returns `True` only when `task_params.tools` is
present or tool messages exist in `chat_template_messages`. For warmup
and regular chat requests without tools → `return False` → Jinja
fallback → **ValueError**.

Unlike V3.1 (which has a `.jinja` chat template file that transformers
picks up automatically), V3.2 **has no Jinja template at all** — it uses
Python-based DSML encoding for all message types.

**Fix:** For V3.2, always return `True` — DSML encoding handles all
message types.

### Catalog cards

Added inference model cards for:
- `mlx-community/DeepSeek-V3.2-8bit`
- `mlx-community/DeepSeek-V3.2-4bit`

Parameters taken from model `config.json` on HuggingFace, storage sizes
from HF API. Capabilities include `thinking_toggle` (related: #1456).

## Notes

- The model ID string matching approach (`"deepseek-v3.2" in
model.lower()`) is acknowledged tech debt — see #1371 for the planned
architecture-based approach.

## Test plan

- [x] Start exo with DeepSeek V3.2 model → warmup should complete
without crash
- [x] Send a regular chat message (no tools) → should get a response
- [x] Send a chat message with tools → should work as before
- [x] V3.2 cards should appear in the dashboard model catalog

---------

Co-authored-by: user <user@m1.note>
Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
Co-authored-by: Evan <evanev7@gmail.com>
2026-03-25 16:20:35 +00:00
rltakashige 565ed41c13 Fix occasional warmup bugs by using mlx_generate (#1794)
## Motivation

Warmup occasionally had issues; e.g. #1748 and #1793 because we were
using a standard stream_generate, all of which are issues that are
resolved in the wrapper function mlx_generate.
2026-03-25 15:53:54 +00:00
DeepZima 2da740c387 Feat/static peer discovery (#1690)
**Enabling peers to be discovered in environments where mDNS is
unavailable (SSH sessions, headless servers, Docker).**

## Motivation
Exo discovers peers exclusively via mDNS, which works great on a local
network but breaks once you move beyond a single L2 broadcast domain:

- SSH sessions on macOS — TCC blocks mDNS multicast from non-GUI
sessions (#1488)
- Headless servers/rack machines — #1682 ("DGX Spark does not find other
nodes")
- Docker Compose — mDNS is often unavailable across container networks;
e.g. #1462 (E2E test framework) needs an alternative

Related works: 
#1488 (working implementation made by @AlexCheema and closed because SSH
had a GUI workaround),
#1023 (Headscale WAN then closed due to merge conflicts), 
#1656 (discovery cleanup, open). 

This PR introduces an optional bootstrap mechanism for peer discovery
while leaving the existing mDNS behavior unchanged.

## Changes
Adds two new CLI flags:

- `--bootstrap-peers` (env: `EXO_BOOTSTRAP_PEERS`) — comma-separated
libp2p multiaddrs to dial on startup and retry periodically
- `--libp2p-port` — fixed TCP port for libp2p to listen on (default:
OS-assigned). Required when bootstrap peers, so other nodes know which
port to dial.

8 files: 
- `rust/networking/src/discovery.rs`: Store bootstrap addrs, dial in
existing retry loop
- `rust/networking/src/swarm.rs`: Thread `bootstrap_peers` parameter to
`Behaviour`
- `rust/networking/examples/chatroom.rs`: Updated call site for new
create_swarm signature
- `rust/networking/tests/bootstrap_peers.rs`: Integration tests
- `rust/exo_pyo3_bindings/src/networking.rs`: Accept optional
`bootstrap_peers` in PyO3 constructor
- `rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi` : Update type stub 
- `src/exo/routing/router.py`: Pass peers to `NetworkingHandle` 
- `src/exo/main.py` : `--bootstrap-peers` CLI arg +
`EXO_BOOTSTRAP_PEERS` env var

## Why It Works

Bootstrap peers are dialed in the existing retry loop — the same path
taken by peers when mDNS-discovered. The swarm handles connection, Noise
handshake, and gossipsub mesh joining from there.

PeerId is intentionally not required in the multiaddr, the Noise
handshake discovers it.

Docker Compose example:

```yaml
services:
  exo-1:
    environment:
      EXO_BOOTSTRAP_PEERS: "/ip4/exo-2/tcp/30000"
  exo-2:
    environment:
      EXO_BOOTSTRAP_PEERS: "/ip4/exo-1/tcp/30000"
```

## Test Plan

### Manual Testing
<details>
<summary>Docker Compose config</summary>

```
services:
  exo-node1:
    build:
      context: .
      dockerfile: Dockerfile.bootstrap-test
    container_name: exo-bootstrap-node1
    hostname: exo-node1
    command: ["-q", "--libp2p-port", "30000", "--bootstrap-peers", "/ip4/172.30.20.3/tcp/30000"]
    environment:
      - EXO_LIBP2P_NAMESPACE=bootstrap-test
    ports:
      - "52415:52415"
    networks:
      bootstrap-net:
        ipv4_address: 172.30.20.2
    deploy:
      resources:
        limits:
          memory: 4g

  exo-node2:
    build:
      context: .
      dockerfile: Dockerfile.bootstrap-test
    container_name: exo-bootstrap-node2
    hostname: exo-node2
    command: ["-q", "--libp2p-port", "30000", "--bootstrap-peers", "/ip4/172.30.20.2/tcp/30000"]
    environment:
      - EXO_LIBP2P_NAMESPACE=bootstrap-test
    ports:
      - "52416:52415"
    networks:
      bootstrap-net:
        ipv4_address: 172.30.20.3
    deploy:
      resources:
        limits:
          memory: 4g

networks:
  bootstrap-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.30.20.0/24
```
</details> 

Two containers on a bridge network (`172.30.20.0/24`), fixed IPs,
`--libp2p-port 30000`, cross-referencing `--bootstrap-peers`.

Both nodes found each other and established a connection then ran the
election protocol.

### Automated Testing

4 Rust integration tests in `rust/networking/tests/bootstrap_peers.rs`
(`cargo test -p networking`):

| Test | What it verifies | Result |
|------|-----------------|--------|
| `two_nodes_connect_via_bootstrap_peers` | Node B discovers Node A via
bootstrap addr (real TCP connection) | PASS |
| `create_swarm_with_empty_bootstrap_peers` | Backward compatibility —
no bootstrap peers works | PASS |
| `create_swarm_ignores_invalid_bootstrap_addrs` | Invalid multiaddrs
silently filtered | PASS |
| `create_swarm_with_fixed_port` | `listen_port` parameter works | PASS
|

All 4 pass. The connection test takes ~6s

---------

Signed-off-by: DeepZima <deepzima@outlook.com>
Co-authored-by: Evan <evanev7@gmail.com>
2026-03-25 10:55:12 +00:00
rltakashige 7117d748ec Update dependencies including mlx 0.31.2 (#1789)
Update mlx fork to 0.31.2 and mflux to 0.17.2
2026-03-25 06:03:19 +00:00
Alex Cheema 178c617bbb Rename Nemotron to NVIDIA in model picker with logo (#1790)
## Motivation

The Nemotron model family in the model picker sidebar was displaying as
"Nemotron" with a generic checkmark icon. Since these models are
NVIDIA's Nemotron models, the category should be branded as "NVIDIA"
with the official NVIDIA logo, consistent with how other families are
branded (e.g., "llama" → "Meta", "gpt-oss" → "OpenAI").

## Changes

- **FamilySidebar.svelte**: Added `nemotron: "NVIDIA"` to the
`familyNames` mapping so the sidebar displays "NVIDIA" instead of
"Nemotron"
- **FamilyLogos.svelte**: Added the NVIDIA "eye" logo as an inline SVG
for the `nemotron` family, matching the `viewBox="0 0 24 24"` /
`fill="currentColor"` pattern used by all other brand logos
- **ModelPickerModal.svelte**: Added `"nemotron"` to the `familyOrder`
array so NVIDIA appears in a consistent position in the sidebar

## Why It Works

The model picker derives categories from the `family` field in TOML
model cards. Nemotron models already have `family = "nemotron"`, but the
three UI components (display name, logo, sort order) lacked explicit
entries for it, causing fallback behavior (auto-capitalized name,
checkmark icon, alphabetical sorting). Adding explicit entries for all
three aligns NVIDIA with the existing brand pattern.

## Test Plan

### Manual Testing
<!-- Hardware: N/A - dashboard UI change only -->
- Built dashboard successfully (`npm run build`)
- Verified the NVIDIA logo renders in the sidebar alongside existing
brand logos

### Automated Testing
- No test changes needed — this is a purely cosmetic dashboard change

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:56:59 +00:00
rltakashige 7277c90389 Fix enable thinking (#1786)
## Motivation

Minor regression from #1746 

## Why It Works

Pass thinking properly

## Test Plan

### Manual Testing
Works, it thinks
2026-03-24 16:26:25 -07:00
Evan Quiney 7ee88c1f05 override macmon in flake (#1747)
updates macmon to an upstream fork that fixes m5 max issues.
might see if the upstream version gets merged before we release.

---------

Co-authored-by: Alex Cheema <alexcheema123@gmail.com>
2026-03-24 17:30:19 +00:00
Evan Quiney 509533d49e send error finish reason on failing to parse a tool call (#1785)
a simplification of #1757 which is now stale
2026-03-24 17:21:39 +00:00
rltakashige b6240a97e8 Prevent Qwen3.5 looping by using mlx lm fork (#1784)
## Motivation

Move back to an MLX LM to improve the Qwen 3.5 experience. 

## Test Plan

### Manual Testing
Seems to loop less from testing, no speed regressions.
2026-03-24 17:16:41 +00:00
rltakashige 6cdfbb7e8b Add HF_ENDPOINT in the app settings (#1783)
## Motivation
Some users (primarily in China) are unable to access HuggingFace.co. HF
supports the HF_ENDPOINT env variable, and we also support it, but there
is no way to easily do that from the app currently.

## Test Plan

### Manual Testing

hf-mirror works
empty field works
google.com endpoint fails
2026-03-24 17:05:49 +00:00
Evan Quiney fac6832e5f fix warmup consistency for slow machines (#1748)
a fix from pr #1643 which is now stale - should make prefill more
consistent on very slow machines

## testing
qwen-3.5-35b-a3b loads normally
gpt-oss-120b-mxfp4-q8 loads normally
2026-03-24 16:45:55 +00:00
rltakashige 7df3774ca2 Improve batch performance and stats reporting (#1777)
## Motivation

Batch generation reports incorrect statistics, as mlx lm never clears
the original stats, meaning they get polluted over time.
The dashboard also seems considerably slower than bench statistics.
We also have a large discrepancy between B=1 batch generating and
mlx_generate.
Extracting logprobs is massively expensive, causing up to a 25% slowdown
compared to pure batching.
```
[ 12:02:01.1240AM | INFO    ] step overhead: 3.49ms (next=12.49ms total=15.99ms)
[ 12:02:02.1600AM | INFO    ] step overhead: 3.23ms (next=13.01ms total=16.24ms)
[ 12:02:03.2228AM | INFO    ] step overhead: 3.28ms (next=13.38ms total=16.66ms)
[ 12:02:04.2798AM | INFO    ] step overhead: 3.25ms (next=12.84ms total=16.10ms)
[ 12:02:05.3152AM | INFO    ] step overhead: 3.18ms (next=12.61ms total=15.79ms)
[ 12:02:06.3522AM | INFO    ] step overhead: 3.41ms (next=12.83ms total=16.25ms)
[ 12:02:07.3987AM | INFO    ] step overhead: 3.38ms (next=13.14ms total=16.52ms)
[ 12:02:08.4537AM | INFO    ] step overhead: 1.84ms (next=19.44ms total=21.28ms)
```

## Changes

1. Report stats ourselves instead of using mlx lm's stats for batch
generation (they use perf_counter anyway).
2. Adjust exo bench to match
3. Improve logprobs extraction speed by 10x, improving tps for dashboard
& any requests for logprobs
4. Use an SSE comment to align the speed to the real numbers at the end
of generation
5. Patch mlx for several optimizations given our assumptions and use
cases (e.g. use vllm style RoPE).
6. Switch MLX LM version to latest main, including support for Nemotron
Super and some Qwen3.5 fixes.

## Why It Works
1. Exo bench no longer reports polluted stats
2. Exo bench now handles the reported per-request stats rather than the
aggregate stats
3. The decode speed now jumps back to a real number at the end of the
generation
4. Large batch speedup for rotating KV cache models + 1:1 matching cache
with vllm

## Test Plan

### Manual Testing
Needs testing on OpenCode and CC
Needs eval testing

### Automated Testing
Only going to show the performance optimization difference after the
accurate reporting:

**GPT OSS 20B MXFP4 Q8 (large change)**
Before:
<img width="2466" height="1534" alt="image"
src="https://github.com/user-attachments/assets/88b50637-fca2-4db4-9413-b9eee6e2057e"
/>
<img width="2410" height="1240" alt="image"
src="https://github.com/user-attachments/assets/21e5c76a-2f5f-44d2-8953-121b3ebdbd68"
/>


After:
<img width="2476" height="1472" alt="image"
src="https://github.com/user-attachments/assets/fec5cfbd-fff8-430a-b12e-a329410107a2"
/>
<img width="2454" height="1236" alt="image"
src="https://github.com/user-attachments/assets/0400344b-a4a6-42c0-a9dd-4ee91ade714a"
/>



**Qwen 3.5 35B A3B 8bit (No change)**
Before:
<img width="2414" height="1396" alt="image"
src="https://github.com/user-attachments/assets/e75f0b38-df5d-49fd-ab90-bc1667d981b3"
/>


After:
<img width="2346" height="1234" alt="image"
src="https://github.com/user-attachments/assets/eabfb59c-851f-4d88-b927-e1e699a75cc6"
/>


**Llama 3.2 1B Instruct 4bit (small change)**
Before:
<img width="2516" height="1220" alt="image"
src="https://github.com/user-attachments/assets/c2873655-acff-4536-8263-fb8aea33db80"
/>

After:
<img width="2566" height="1370" alt="image"
src="https://github.com/user-attachments/assets/15f95c75-1c2f-4474-85a2-88c4d0a32543"
/>
2026-03-24 14:03:03 +00:00
ciaranbor 248919c2a8 Fix first start in offline mode crash (#1782)
## Motivation

Running exo in offline mode on a machine where a model has never been
downloaded causes a crash

## Changes

- `download_shard` now catches `FileNotFoundError` from
`fetch_file_list_with_cache` and returns a `not_started` progress
instead of propagating the exception

## Why It Works

A status query should never crash its caller. By returning
`not_started`, the coordinator's existing offline guard (`if
self.offline:` at line 198) is reached and emits a graceful
`DownloadFailed` event. This also eliminates the warning spam on startup
where the status iterator catches the same exception for every
predefined model.

## Test Plan

### Manual Testing
- Start exo with `--offline` on a machine with no cached models, request
a model via the API — should get a graceful failure instead of a crash
2026-03-24 13:58:23 +00:00
ciaranbor 49951e1b1a Sync custom model cards across nodes (#1768)
## Motivation

Custom model cards were only saved locally on the node that handled the
API request.

## Changes

- Added AddCustomModelCard and DeleteCustomModelCard commands
- Added CustomModelCardAdded and CustomModelCardDeleted events
- Added custom_model_cards field to cluster State
- Master handles new commands by emitting corresponding events
- Workers persist model cards to disk and update the in-memory cache on
event receipt
- Separated fetch_from_hf (pure fetch) from disk persistence (now
handled by event-sourcing layer)
- Exposed add_to_card_cache() helper for the worker to update the cache

## Why It Works

- Follows the existing event-sourcing pattern: API → Command → Master →
Event → all Workers
- Every node applies the same events, so custom model cards are
consistent across the cluster

## Test Plan

### Manual Testing

Add/delete a custom model via the API on one node, verify it
appears/disappears on all nodes

### Automated Testing

Existing tests cover apply() logic; new event types follow the same
discriminated-union pattern
2026-03-24 12:51:36 +00:00
ciaranbor e06e70a835 Prefer higher model download % for placement (#1767)
## Motivation

When placing a model instance across the cluster, the master previously
only considered available RAM. This meant it could pick a node that
hasn't downloaded the model yet, even when another node already has it
(or is further along in downloading it).

## Changes

- Added download_status parameter to place_instance() in placement.py
- Added _get_node_download_fraction() to compute 0.0–1.0 download
progress per node/model
- Added _cycle_download_score() to sum download fractions across a
cycle's nodes
- Cycle selection now uses a (download_score, available_ram) tuple key —
download progress is the primary sort, RAM is the tiebreaker
- Passed self.state.downloads into place_instance() from master/main.py

## Why It Works

Python's tuple comparison gives download progress strict priority over
RAM, so a node with the model already downloaded will always be
preferred over one with more free RAM but no download.

## Test Plan

### Automated Testing

3 new tests cover: completed download preferred, higher partial progress
preferred, failed download not preferred over no-download node
2026-03-24 12:11:56 +00:00
vskiwi e9fdd8d4af improve logging: add dates to verbose stderr, match file log level to verbosity (#1772)
## Summary

- **Add ISO date to verbose stderr format**: when running with `-v`, the
stderr timestamp changes from `HH:mm:ss.SSS` to `YYYY-MM-DD
HH:mm:ss.SSS`, matching the file log format. This makes it possible to
correlate entries across days when stderr is captured by launchd,
systemd, Docker, or file redirection.
- **File log respects verbosity**: `exo.log` now uses DEBUG level when
`-v` is passed, so the persistent log has the same detail as stderr.
Previously it was always INFO regardless of verbosity.
- **Startup banner with PID**: adds a visual separator and process ID to
the startup message, making it easy to identify session boundaries in
long-running logs (e.g. `grep "Starting EXO"`).

The non-verbose (default) stderr format is unchanged — end-user terminal
experience is not affected.

## Motivation

When exo runs as a service (launchd, systemd, Docker), stderr is
typically captured to a file. Without calendar dates in the timestamp,
it is impossible to tell which day a log entry belongs to. This caused
misidentification of log entries during a multi-day RDMA debugging
session on a 4-node cluster.

The file log (`exo.log`) already had dates and rotation, but only
captured INFO level — missing the DEBUG output needed for postmortem
analysis.

## Test plan

- [x] `basedpyright` — 0 errors, 0 warnings, 0 notes
- [x] `ruff check` — all checks passed
- [x] `pytest` — 249 passed, 1 skipped

Co-authored-by: user <user@m1.note>
2026-03-23 16:06:26 +00:00
Evan Quiney 07598a3af1 teeny refactor (#1753)
api.py keeps growing. it's not tied to the master, so should have it's
own top level folder.

## testing
ci, pytest
2026-03-19 15:57:50 +00:00
ciaranbor 63f57fc193 Ciaran/dashboard download bug (#1755)
## Motivation

Download progress in the dashboard was broken: mainly treating all
download statuses as ongoing

## Changes

- Backend (apply.py): Deduplicate download progress events by model_id
instead of full shard_metadata, preventing duplicate entries per node
- Dashboard (+page.svelte): Extract shared collectDownloadStatus()
helper that both getModelDownloadStatus and getInstanceDownloadStatus
use, eliminating ~100 lines of duplicated logic. Adds proper handling
for
DownloadCompleted/DownloadFailed events, uses a Map to deduplicate
per-node entries, and introduces a typed NodeDownloadStatus with
explicit status states (downloading/completed/partial/pending)
- ModelCard: Replace single aggregate progress bar with per-node
download bars, each color-coded by status. Instance preview now scopes
download status to participating nodes only

## Why It Works

- Deduplicating by model_id in apply.py ensures each node has exactly
one download entry per model
- The perNodeMap in the frontend keeps only the latest event per node,
preventing duplicate bars
- Handling DownloadCompleted allows the UI to show finished downloads
instead of dropping them
- Scoping instance previews to assigned nodes avoids showing irrelevant
download progress
2026-03-19 14:47:45 +00:00
99 changed files with 2652 additions and 1205 deletions
+1 -1
View File
@@ -2396,7 +2396,7 @@ def degrees(a: array, /, *, stream: Stream | Device | None = ...) -> array:
array: The angles in degrees.
"""
def depends(inputs: array | Sequence[array], dependencies: array | Sequence[array]):
def depends[T](inputs: T, dependencies: array | Sequence[array]) -> T:
"""
Insert dependencies between arrays in the graph. The outputs are
identical to ``inputs`` but with dependencies on ``dependencies``.
+2 -6
View File
@@ -1,9 +1,5 @@
"""
This type stub file was generated by pyright.
"""
from layers import *
from utils import *
from .layers import *
from .utils import *
from . import init as init
from . import losses as losses
+16 -20
View File
@@ -1,20 +1,16 @@
"""
This type stub file was generated by pyright.
"""
from activations import *
from base import *
from containers import *
from convolution import *
from convolution_transpose import *
from distributed import *
from dropout import *
from embedding import *
from linear import *
from normalization import *
from pooling import *
from positional_encoding import *
from quantized import *
from recurrent import *
from transformer import *
from upsample import *
from .activations import *
from .base import *
from .containers import *
from .convolution import *
from .convolution_transpose import *
from .distributed import *
from .dropout import *
from .embedding import *
from .linear import *
from .normalization import *
from .pooling import *
from .positional_encoding import *
from .quantized import *
from .recurrent import *
from .transformer import *
from .upsample import *
+1 -1
View File
@@ -53,7 +53,7 @@ class Module(dict):
mx.eval(model.parameters())
"""
__call__: Callable
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
def __init__(self) -> None:
"""Should be called by the subclasses of ``Module``."""
+10 -5
View File
@@ -30,7 +30,7 @@ def str2bool(string): # -> bool:
def setup_arg_parser(): # -> ArgumentParser:
"""Set up and return the argument parser."""
generation_stream = ...
generation_stream: mx.Stream
@contextlib.contextmanager
def wired_limit(
@@ -266,12 +266,12 @@ def _merge_caches(caches: Any) -> List[Any]: ...
class Batch:
uids: List[int]
y: mx.array
logprobs: mx.array
logprobs: List[mx.array] | mx.array
max_tokens: List[int]
num_tokens: List[int]
cache: List[Any]
samplers: List[Any]
logits_processors: List[Any]
samplers: List[Callable[[mx.array], mx.array] | None]
logits_processors: List[List[Callable[[mx.array, mx.array], mx.array]]]
tokens: List[mx.array]
def __len__(self) -> int: ...
def filter(self, keep_idx: List[int]) -> None: ...
@@ -279,13 +279,18 @@ class Batch:
def extract_cache(self, idx: int) -> List[Any]: ...
class BatchGenerator:
model: Any
model: nn.Module
sampler: Callable[[mx.array], mx.array]
stop_tokens: set[int]
max_kv_size: Optional[int]
prefill_step_size: int
completion_batch_size: int
prefill_batch_size: int
unprocessed_prompts: List[Any]
active_batch: Optional[Batch]
prompt_progress_callback: Callable[[List[Tuple[int, int, int]]], None]
_stats: BatchStats
_next_count: int
@dataclass
class Response:
+25 -31
View File
@@ -88,8 +88,8 @@ def create_attention_mask(
) -> array | Literal["causal"] | None: ...
class _BaseCache(Cache):
keys: mx.array
values: mx.array
keys: mx.array | None
values: mx.array | None
offset: int
@property
def state(self) -> tuple[mx.array | None, mx.array | None]: ...
@@ -268,29 +268,14 @@ class CacheList(_BaseCache):
"""
class BatchKVCache(_BaseCache):
step = ...
def __init__(self, left_padding: List[int]) -> None:
"""
The BatchKV cache expects inputs to be left-padded.
E.g. the following prompts:
[1, 3, 5]
[7]
[2, 6, 8, 9]
Should be padded like so:
[0, 1, 3, 5]
[0, 0, 0, 7]
[2, 6, 8, 9]
And ``left_padding`` specifies the amount of padding for each.
In this case, ``left_padding = [1, 3, 0]``.
"""
def update_and_fetch(self, keys, values): # -> tuple[array | Any, array | Any]:
...
step: int
keys: array | None
values: array | None
offset: array
left_padding: array
_idx: int
def __init__(self, left_padding: List[int]) -> None: ...
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
@property
def state(
self,
@@ -316,12 +301,21 @@ class BatchKVCache(_BaseCache):
"""
class BatchRotatingKVCache(_BaseCache):
step = ...
def __init__(self, max_size, left_padding: List[int]) -> None: ...
def update_and_fetch(
self, keys, values
): # -> tuple[array | Any, array | Any] | tuple[array | Any, array | Any | None]:
...
step: int
keys: array | None
values: array | None
offset: array
left_padding: array
max_size: int
_idx: int
_offset: int
rotated: bool
_lengths: array | None
def __init__(self, max_size: int, left_padding: List[int]) -> None: ...
def _trim(self, trim_size: int, v: array, append: array | None = ...) -> array: ...
def _update_in_place(self, keys: array, values: array) -> tuple[array, array]: ...
def _update_concat(self, keys: array, values: array) -> tuple[array, array]: ...
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
@property
def state(
self,
@@ -0,0 +1,35 @@
from typing import Optional
import mlx.core as mx
def compute_g(A_log: mx.array, a: mx.array, dt_bias: mx.array) -> mx.array: ...
def gated_delta_update(
q: mx.array,
k: mx.array,
v: mx.array,
a: mx.array,
b: mx.array,
A_log: mx.array,
dt_bias: mx.array,
state: Optional[mx.array] = ...,
mask: Optional[mx.array] = ...,
use_kernel: bool = ...,
) -> tuple[mx.array, mx.array]: ...
def gated_delta_ops(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: Optional[mx.array] = ...,
mask: Optional[mx.array] = ...,
) -> tuple[mx.array, mx.array]: ...
def gated_delta_kernel(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = ...,
) -> tuple[mx.array, mx.array]: ...
+51
View File
@@ -0,0 +1,51 @@
from typing import Any, Optional
import mlx.nn as nn
class YarnRoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
scaling_factor: float = ...,
original_max_position_embeddings: int = ...,
beta_fast: float = ...,
beta_slow: float = ...,
mscale: float = ...,
mscale_all_dim: float = ...,
) -> None: ...
class Llama3RoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
scaling_factor: float = ...,
original_max_position_embeddings: int = ...,
low_freq_factor: float = ...,
high_freq_factor: float = ...,
) -> None: ...
class SuScaledRoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
short_factor: Any = ...,
long_factor: Any = ...,
original_max_position_embeddings: int = ...,
) -> None: ...
def initialize_rope(
dims: int,
base: float = ...,
traditional: bool = ...,
scaling_config: Optional[dict[str, Any]] = ...,
max_position_embeddings: Optional[int] = ...,
) -> nn.Module: ...
+11 -2
View File
@@ -11,9 +11,18 @@ To run EXO from source:
```bash
brew install uv
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
```bash
brew install macmon
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup toolchain install nightly
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
Use the pinned fork revision used by this repo instead of Homebrew `macmon`.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
macmon \
--force
```
```bash
+12 -2
View File
@@ -95,11 +95,10 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
- [uv](https://github.com/astral-sh/uv) (for Python dependency management)
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
- [node](https://github.com/nodejs/node) (for building the dashboard)
```bash
brew install uv macmon node
brew install uv node
```
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
@@ -107,6 +106,17 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup toolchain install nightly
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
Install the pinned fork revision used by this repo instead of Homebrew `macmon`.
Homebrew `macmon 0.6.1` still crashes on Apple M5.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
macmon \
--force
```
Clone the repo, build the dashboard, and run exo:
+12
View File
@@ -4,6 +4,7 @@ import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let hfEndpointKey = "EXOHFEndpoint"
private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
@@ -53,6 +54,14 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
}
}
@Published var hfEndpoint: String = {
return UserDefaults.standard.string(forKey: hfEndpointKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(hfEndpoint, forKey: hfEndpointKey)
}
}
@Published var enableImageModels: Bool = {
return UserDefaults.standard.bool(forKey: enableImageModelsKey)
}()
@@ -273,6 +282,9 @@ final class ExoProcessController: ObservableObject {
if !hfToken.isEmpty {
environment["HF_TOKEN"] = hfToken
}
if !hfEndpoint.isEmpty {
environment["HF_ENDPOINT"] = hfEndpoint
}
if enableImageModels {
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
}
+15
View File
@@ -12,6 +12,7 @@ struct SettingsView: View {
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@State private var pendingHFEndpoint: String = ""
@State private var pendingEnableImageModels = false
@State private var pendingOfflineMode = false
@State private var needsRestart = false
@@ -42,6 +43,7 @@ struct SettingsView: View {
.onAppear {
pendingNamespace = controller.customNamespace
pendingHFToken = controller.hfToken
pendingHFEndpoint = controller.hfEndpoint
pendingEnableImageModels = controller.enableImageModels
pendingOfflineMode = controller.offlineMode
needsRestart = false
@@ -74,6 +76,17 @@ struct SettingsView: View {
.foregroundColor(.secondary)
}
Section {
LabeledContent("HuggingFace Endpoint") {
TextField("default", text: $pendingHFEndpoint)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
Toggle("Offline Mode", isOn: $pendingOfflineMode)
Text("Skip internet checks and use only locally available models.")
@@ -454,6 +467,7 @@ struct SettingsView: View {
private var hasGeneralChanges: Bool {
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
|| pendingHFEndpoint != controller.hfEndpoint
|| pendingOfflineMode != controller.offlineMode
}
@@ -464,6 +478,7 @@ struct SettingsView: View {
private func applyGeneralSettings() {
controller.customNamespace = pendingNamespace
controller.hfToken = pendingHFToken
controller.hfEndpoint = pendingHFEndpoint
controller.offlineMode = pendingOfflineMode
restartIfRunning()
}
+5 -7
View File
@@ -501,23 +501,21 @@ def main() -> int:
for x, _ in batch_results
if x["stats"]["generation_tps"] > 0
]
agg_gen_tps = (
per_req_tps = (
mean(valid_gen_tps) if valid_gen_tps else 0.0
)
gen_tps = agg_gen_tps / concurrency
agg_gen_tps = per_req_tps * concurrency
logger.info(
f"[concurrent {concurrency}x] "
f"agg_gen_tps={agg_gen_tps:.2f} "
f"gen_tps={gen_tps:.2f} "
f"per_req_tps={per_req_tps:.2f} "
f"errors={batch_errors}"
)
if runs:
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
gen_tps = mean(
x["stats"]["generation_tps"] / x["concurrency"]
for x in runs
)
per_req_tps = mean(x["stats"]["generation_tps"] for x in runs)
gen_tps = per_req_tps * concurrency
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
peak = mean(
@@ -88,6 +88,12 @@
d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z"
/>
</svg>
{:else if family === "nemotron"}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"
/>
</svg>
{:else}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -31,6 +31,7 @@
kimi: "Kimi",
flux: "FLUX",
"qwen-image": "Qwen Img",
nemotron: "NVIDIA",
};
function getFamilyName(family: string): string {
+52 -30
View File
@@ -16,7 +16,9 @@
perNode?: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
}>;
} | null;
nodes?: Record<string, NodeInfo>;
@@ -145,10 +147,7 @@
return `${s}s`;
}
const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
const progress = $derived(downloadStatus?.progress);
const percentage = $derived(progress?.percentage ?? 0);
let expandedNodes = $state<Set<string>>(new Set());
const perNode = $derived(downloadStatus?.perNode ?? []);
function toggleNodeDetails(nodeId: string): void {
const next = new Set(expandedNodes);
@@ -587,23 +586,49 @@
</span>
</div>
<!-- Download Status -->
{#if isDownloading && progress}
<!-- Download Status (per-node) -->
{#if perNode.length > 0}
<div class="mb-2 space-y-1">
<div class="flex items-center justify-between text-xs font-mono">
<span class="text-blue-400 tracking-wider uppercase">Downloading</span
>
<span class="text-white/60"
>{percentage.toFixed(1)}% &middot; {formatSpeed(progress.speed)}
&middot; {formatEta(progress.etaMs)}</span
>
</div>
<div class="h-1 bg-exo-medium-gray/30 rounded overflow-hidden">
<div
class="h-full bg-blue-500/70 transition-all duration-300"
style="width: {percentage}%"
></div>
<div
class="text-[10px] font-mono text-white/20 tracking-widest uppercase"
>
Download progress
</div>
{#each perNode as node}
<div class="flex items-center gap-2 text-xs font-mono">
<span class="text-white/40 w-20 truncate" title={node.nodeId}
>{node.nodeName}</span
>
<div
class="flex-1 h-1 bg-exo-medium-gray/30 rounded overflow-hidden"
>
<div
class="h-full transition-all duration-300 {node.status ===
'downloading'
? 'bg-blue-500/70'
: node.status === 'completed'
? 'bg-exo-yellow/40'
: 'bg-white/20'}"
style="width: {node.percentage}%"
></div>
</div>
<span
class="text-right {node.status === 'completed'
? 'text-exo-yellow/60'
: node.status === 'downloading'
? 'text-blue-400/60'
: 'text-white/30'}"
>
{#if node.status === "downloading" && node.progress}
{Math.round(node.percentage)}% {formatSpeed(
node.progress.speed,
)}
{:else}
{node.percentage > 0 ? `${Math.round(node.percentage)}%` : "0%"}
{/if}
</span>
</div>
{/each}
</div>
{/if}
@@ -662,15 +687,7 @@
{@const allConnections =
isDebugMode && usedNodes.length > 1
? (() => {
const conns: Array<{
ip: string;
iface: string | null;
from: string;
to: string;
midX: number;
midY: number;
arrow: string;
}> = [];
const conns: Array = [];
for (let i = 0; i < usedNodes.length; i++) {
for (let j = i + 1; j < usedNodes.length; j++) {
const n1 = usedNodes[i];
@@ -682,7 +699,12 @@
const toPos = nodePositions[c.to];
const arrow =
fromPos && toPos ? getArrow(fromPos, toPos) : "→";
conns.push({ ...c, midX, midY, arrow });
conns.push({
...c,
midX,
midY,
arrow,
});
}
}
}
@@ -459,6 +459,7 @@
"llama",
"flux",
"qwen-image",
"nemotron",
];
return Array.from(families).sort((a, b) => {
const aIdx = familyOrder.indexOf(a);
+28 -5
View File
@@ -1793,6 +1793,14 @@ class AppStore {
this.persistConversation(targetConversationId);
}
},
{
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
}
},
},
);
// Final update
@@ -1990,6 +1998,14 @@ class AppStore {
this.persistConversation(targetConversationId);
}
},
{
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
}
},
},
);
// Final cleanup of the message (if conversation still exists)
@@ -2397,7 +2413,7 @@ class AppStore {
let streamedContent = "";
let streamedThinking = "";
let serverTpsReceived = false;
interface ChatCompletionChunk {
choices?: Array<{
delta?: { content?: string; reasoning_content?: string };
@@ -2462,7 +2478,6 @@ class AppStore {
tokenCount += 1;
this.totalTokens = tokenCount;
// Update real-time TPS during streaming
if (firstTokenTime !== null && tokenCount > 1) {
const elapsed = performance.now() - firstTokenTime;
this.tps = (tokenCount / elapsed) * 1000;
@@ -2513,16 +2528,24 @@ class AppStore {
startedAt: this.prefillProgress?.startedAt ?? performance.now(),
};
},
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
serverTpsReceived = true;
}
},
},
);
// Clear prefill progress after stream ends
this.prefillProgress = null;
// Calculate final TPS
if (firstTokenTime !== null && tokenCount > 1) {
// Use server-side TPS if available, otherwise fall back to client-side
if (!serverTpsReceived && firstTokenTime !== null && tokenCount > 1) {
const totalGenerationTime = performance.now() - firstTokenTime;
this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
this.tps = (tokenCount / totalGenerationTime) * 1000;
}
// Final cleanup of the message (if conversation still exists)
+198 -247
View File
@@ -42,6 +42,7 @@
setSelectedChatModel,
selectedChatModel,
sendMessage,
thinkingEnabled,
generateImage,
editImage,
editingImage,
@@ -852,7 +853,7 @@
) {
const model = selectedChatModel();
if (!model) {
sendMessage(content, files, null);
sendMessage(content, files, thinkingEnabled());
return;
}
@@ -880,7 +881,7 @@
}
// Default: text chat
sendMessage(content, files, null);
sendMessage(content, files, thinkingEnabled());
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
@@ -1535,34 +1536,44 @@
}
// Helper to get download status for a model (checks all downloads for matching model ID)
function getModelDownloadStatus(modelId: string): {
type NodeDownloadStatus = {
nodeId: string;
nodeName: string;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
};
// Shared helper: collect per-node download status for a model across a set of nodes.
// Handles deduplication, entry parsing, and aggregation in one place.
function collectDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}>;
perNode: NodeDownloadStatus[];
failedError: string | null;
} {
const empty = {
isDownloading: false,
progress: null,
perNode: [] as NodeDownloadStatus[],
failedError: null,
};
if (!downloadsData || Object.keys(downloadsData).length === 0) {
return { isDownloading: false, progress: null, perNode: [] };
return empty;
}
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
const perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}> = [];
// Deduplicate by nodeId — a node can have multiple entries for the same model
// (e.g. PipelineShardMetadata + TensorShardMetadata). Keep the last entry,
// which is the most recently applied event.
const perNodeMap = new Map<string, NodeDownloadStatus>();
// Check all nodes for downloads matching this model
const nodeIdSet = nodeIds ? new Set(nodeIds) : null;
for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
if (nodeIdSet && !nodeIdSet.has(nodeId)) continue;
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
@@ -1575,29 +1586,45 @@
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending"
)
continue;
if (!downloadPayload) continue;
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (!downloadModelId || downloadModelId !== modelId) continue;
// Match if the model ID contains or equals the requested model
// (handles cases like "mlx-community/Meta-Llama..." matching)
if (
!downloadModelId ||
!downloadModelId.includes(modelId.split("/").pop() || modelId)
) {
// Try exact match or partial match
if (downloadModelId !== modelId) continue;
// DownloadFailed — return with any data collected so far
if (downloadKind === "DownloadFailed") {
return {
isDownloading: false,
progress: null,
perNode: Array.from(perNodeMap.values()),
failedError:
(downloadPayload.errorMessage as string) ||
(downloadPayload.error_message as string) ||
"Download failed",
};
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending" &&
downloadKind !== "DownloadCompleted"
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
if (downloadKind === "DownloadCompleted") {
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "completed",
percentage: 100,
progress: null,
});
continue;
}
// For DownloadPending with partial bytes (paused/resumed downloads),
// synthesize a progress object from the top-level downloaded/total fields
let progress: DownloadProgress | null;
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
@@ -1610,44 +1637,67 @@
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
isDownloading = true;
progress = {
totalBytes: pendingTotal,
downloadedBytes: pendingDownloaded,
speed: 0,
etaMs: 0,
percentage:
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
completedFiles: 0,
totalFiles: 0,
files: [],
};
} else {
isDownloading = true;
progress = parseDownloadProgress(downloadPayload);
const pct =
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: pendingDownloaded > 0 ? "partial" : "pending",
percentage: pct,
progress: null,
});
continue;
}
if (progress) {
// Sum all values across nodes - each node downloads independently
totalBytes += progress.totalBytes;
downloadedBytes += progress.downloadedBytes;
totalSpeed += progress.speed;
completedFiles += progress.completedFiles;
totalFiles += progress.totalFiles;
allFiles.push(...progress.files);
// DownloadOngoing
const progress = parseDownloadProgress(downloadPayload);
if (
!progress ||
(progress.downloadedBytes <= 0 && progress.totalBytes <= 0)
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
perNode.push({ nodeId, nodeName, progress });
}
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "downloading",
percentage: progress.percentage,
progress,
});
}
}
// Aggregate from deduplicated per-node entries
const perNode = Array.from(perNodeMap.values());
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
for (const node of perNode) {
if (node.status === "downloading" && node.progress) {
isDownloading = true;
totalBytes += node.progress.totalBytes;
downloadedBytes += node.progress.downloadedBytes;
totalSpeed += node.progress.speed;
completedFiles += node.progress.completedFiles;
totalFiles += node.progress.totalFiles;
allFiles.push(...node.progress.files);
}
}
if (!isDownloading) {
return { isDownloading: false, progress: null, perNode: [] };
return {
isDownloading: false,
progress: null,
perNode,
failedError: null,
};
}
// ETA = total remaining bytes / total speed across all nodes
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
@@ -1664,9 +1714,21 @@
files: allFiles,
},
perNode,
failedError: null,
};
}
function getModelDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: NodeDownloadStatus[];
} {
return collectDownloadStatus(modelId, nodeIds);
}
// Helper to get download status for an instance
function getInstanceDownloadStatus(
instanceId: string,
@@ -1677,26 +1739,9 @@
errorMessage: string | null;
progress: DownloadProgress | null;
statusText: string;
perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}>;
perNode: NodeDownloadStatus[];
} {
if (!downloadsData || Object.keys(downloadsData).length === 0) {
// No download data yet — defer to runner status instead of assuming RUNNING
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: false,
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: [],
};
}
// Unwrap the instance
// Unwrap the instance to get shard assignments
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") {
return {
@@ -1716,132 +1761,9 @@
modelId?: string;
};
};
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const instanceModelId = inst.shardAssignments?.modelId;
// Build reverse mapping: runnerId -> nodeId
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
const perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}> = [];
// Check downloads for nodes that are part of this instance
for (const runnerId of Object.keys(runnerToShard)) {
const nodeId = runnerToNode[runnerId];
if (!nodeId) continue;
const nodeDownloads = downloadsData[nodeId];
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
const keys = Object.keys(downloadWrapped as Record<string, unknown>);
if (keys.length !== 1) continue;
const downloadKind = keys[0];
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
// Handle DownloadFailed - return immediately with error info
if (downloadKind === "DownloadFailed") {
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
return {
isDownloading: false,
isFailed: true,
errorMessage:
(downloadPayload.errorMessage as string) || "Download failed",
progress: null,
statusText: "FAILED",
perNode: [],
};
}
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending"
)
continue;
if (!downloadPayload) continue;
// Check if this download is for this instance's model
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
// For DownloadPending with partial bytes, synthesize progress
let progress: DownloadProgress | null;
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
downloadPayload.downloaded_bytes ??
downloadPayload.downloadedBytes,
);
const pendingTotal = getBytes(
downloadPayload.total ??
downloadPayload.total_bytes ??
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
isDownloading = true;
progress = {
totalBytes: pendingTotal,
downloadedBytes: pendingDownloaded,
speed: 0,
etaMs: 0,
percentage:
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
completedFiles: 0,
totalFiles: 0,
files: [],
};
} else {
isDownloading = true;
progress = parseDownloadProgress(downloadPayload);
}
if (progress) {
// Sum all values across nodes - each node downloads independently
totalBytes += progress.totalBytes;
downloadedBytes += progress.downloadedBytes;
totalSpeed += progress.speed;
completedFiles += progress.completedFiles;
totalFiles += progress.totalFiles;
allFiles.push(...progress.files);
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
perNode.push({ nodeId, nodeName, progress });
}
}
}
}
if (!isDownloading) {
// Check runner status for other states
if (!instanceModelId) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
@@ -1853,26 +1775,49 @@
};
}
// ETA = total remaining bytes / total speed across all nodes
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
// Get node IDs assigned to this instance
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
const instanceNodeIds = Object.keys(runnerToShard)
.map((runnerId) => runnerToNode[runnerId])
.filter(Boolean);
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
if (result.failedError) {
return {
isDownloading: false,
isFailed: true,
errorMessage: result.failedError,
progress: null,
statusText: "FAILED",
perNode: [],
};
}
if (!result.isDownloading) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: statusInfo.statusText === "FAILED",
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: result.perNode,
};
}
return {
isDownloading: true,
isFailed: false,
errorMessage: null,
progress: {
totalBytes,
downloadedBytes,
speed: totalSpeed,
etaMs,
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
completedFiles,
totalFiles,
files: allFiles,
},
progress: result.progress,
statusText: "DOWNLOADING",
perNode,
perNode: result.perNode,
};
}
@@ -4630,7 +4575,7 @@
type="button"
onclick={() => {
completeOnboarding();
sendMessage(chip);
sendMessage(chip, undefined, thinkingEnabled());
}}
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
>
@@ -5369,10 +5314,10 @@
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode as nodeProg}
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.progress.percentage),
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
@@ -5428,15 +5373,17 @@
>
<span
>{formatBytes(
nodeProg.progress.downloadedBytes,
nodeProg.progress?.downloadedBytes ??
0,
)} / {formatBytes(
nodeProg.progress.totalBytes,
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(nodeProg.progress.speed)}
ETA {formatEta(
nodeProg.progress.etaMs,
>{formatSpeed(
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
@@ -5444,14 +5391,14 @@
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress.files.length === 0}
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress.files as f}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
@@ -5927,12 +5874,15 @@
)}
{@const allPreviews = filteredPreviews()}
{#if selectedModel && allPreviews.length > 0}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
)}
{@const tags = modelTags()[selectedModel.id] || []}
<div class="space-y-3">
{#each allPreviews as apiPreview, i}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
apiPreview.memory_delta_by_node
? Object.keys(apiPreview.memory_delta_by_node)
: undefined,
)}
<div
role="group"
onmouseenter={() => {
@@ -6120,7 +6070,7 @@
onclick={() => {
chatLaunchState = "idle";
selectedChatCategory = null;
sendMessage(prompt);
sendMessage(prompt, undefined, thinkingEnabled());
}}
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
>
@@ -6503,10 +6453,10 @@
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode as nodeProg}
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.progress.percentage),
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
@@ -6565,16 +6515,17 @@
>
<span
>{formatBytes(
nodeProg.progress.downloadedBytes,
nodeProg.progress
?.downloadedBytes ?? 0,
)} / {formatBytes(
nodeProg.progress.totalBytes,
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(
nodeProg.progress.speed,
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress.etaMs,
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
@@ -6582,14 +6533,14 @@
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress.files.length === 0}
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress.files as f}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
+12 -1
View File
@@ -72,7 +72,7 @@
];
perSystem =
{ config, self', inputs', pkgs, lib, system, ... }:
{ config, self', pkgs, lib, system, ... }:
let
# Use pinned nixpkgs for swift-format (swift is broken on x86_64-linux in newer nixpkgs)
pkgsSwift = import inputs.nixpkgs-swift { inherit system; };
@@ -84,6 +84,17 @@
config.allowUnfreePredicate = pkg: (pkg.pname or "") == "metal-toolchain";
overlays = [
(import ./nix/apple-sdk-overlay.nix)
(final: prev: {
macmon = prev.macmon.overrideAttrs (_: {
version = "git";
src = final.fetchFromGitHub {
owner = "swiftraccoon";
repo = "macmon";
rev = "9154d234f763fbeffdcb4135d0bbbaf80609699b";
hash = "sha256-CwhilKNbs5XL9/tF5DMwyPBlE/hpmjGNTuxQ36sM50M=";
};
});
})
];
};
treefmt = {
+7
View File
@@ -1,5 +1,8 @@
export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
default: lint fmt
all: lint fmt check
fmt:
treefmt || nix fmt
@@ -31,6 +34,10 @@ build-dashboard:
package:
uv run pyinstaller packaging/pyinstaller/exo.spec
build-app: package
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
clean:
rm -rf **/__pycache__
rm -rf target/
+3 -2
View File
@@ -71,7 +71,9 @@ MACMON_PATH = shutil.which("macmon")
if MACMON_PATH is None:
raise SystemExit(
"macmon binary not found in PATH. "
"Install it via: brew install macmon"
"Install the pinned fork used by exo via: "
"cargo install --git https://github.com/swiftraccoon/macmon "
"--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b macmon --force"
)
BINARIES: list[tuple[str, str]] = [
@@ -120,4 +122,3 @@ coll = COLLECT(
upx_exclude=[],
name="exo",
)
+2 -2
View File
@@ -25,7 +25,7 @@ dependencies = [
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"mflux==0.16.9",
"mflux==0.17.2",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
@@ -61,7 +61,7 @@ members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo_pyo3_bindings = { 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/eval-left-padding-in-batched-rotation" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-deepseek-v32-indexer" }
# Uncomment to use local mlx/mlx-lm development versions:
# mlx = { path = "/Users/Shared/mlx", editable=true }
# mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true }
@@ -0,0 +1,13 @@
model_id = "mlx-community/DeepSeek-V3.2-4bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 378086226621
@@ -0,0 +1,13 @@
model_id = "mlx-community/DeepSeek-V3.2-8bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 755957120916
+1 -1
View File
@@ -42,7 +42,7 @@ class MessageTooLargeError(builtins.Exception):
@typing.final
class NetworkingHandle:
def __new__(cls, identity: Keypair) -> NetworkingHandle: ...
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Subscribe to a `GossipSub` topic.
+9 -2
View File
@@ -180,7 +180,12 @@ impl PyNetworkingHandle {
// ---- Lifecycle management methods ----
#[new]
fn py_new(identity: Bound<'_, PyKeypair>) -> PyResult<Self> {
#[pyo3(signature = (identity, bootstrap_peers, listen_port))]
fn py_new(
identity: Bound<'_, PyKeypair>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> PyResult<Self> {
// create communication channels
let (to_swarm, from_client) = mpsc::channel(MPSC_CHANNEL_SIZE);
@@ -189,7 +194,9 @@ impl PyNetworkingHandle {
// 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).pyerr()?.into_stream();
let swarm = create_swarm(identity, from_client, bootstrap_peers, listen_port)
.pyerr()?
.into_stream();
Ok(Self {
swarm: Arc::new(Mutex::new(swarm)),
+8 -3
View File
@@ -16,9 +16,14 @@ async fn main() {
let (to_swarm, from_client) = mpsc::channel(20);
// Configure swarm
let mut swarm = swarm::create_swarm(identity::Keypair::generate_ed25519(), from_client)
.expect("Swarm creation failed")
.into_stream();
let mut swarm = swarm::create_swarm(
identity::Keypair::generate_ed25519(),
from_client,
vec![],
0,
)
.expect("Swarm creation failed")
.into_stream();
// Create a Gossipsub topic & subscribe
let (tx, rx) = oneshot::channel();
+9 -1
View File
@@ -104,6 +104,7 @@ pub struct Behaviour {
// state-tracking for managed behaviors & mDNS-discovered peers
managed: managed::Behaviour,
mdns_discovered: HashMap<PeerId, BTreeSet<Multiaddr>>,
bootstrap_peers: Vec<Multiaddr>,
retry_delay: Delay, // retry interval
@@ -112,10 +113,11 @@ pub struct Behaviour {
}
impl Behaviour {
pub fn new(keypair: &identity::Keypair) -> io::Result<Self> {
pub fn new(keypair: &identity::Keypair, bootstrap_peers: Vec<Multiaddr>) -> io::Result<Self> {
Ok(Self {
managed: managed::Behaviour::new(keypair)?,
mdns_discovered: HashMap::new(),
bootstrap_peers,
retry_delay: Delay::new(RETRY_CONNECT_INTERVAL),
pending_events: WakerDeque::new(),
})
@@ -368,6 +370,12 @@ impl NetworkBehaviour for Behaviour {
self.dial(p, ma)
}
}
// dial bootstrap peers (for environments where mDNS is unavailable)
for addr in &self.bootstrap_peers {
self.pending_events.push_back(ToSwarm::Dial {
opts: DialOpts::unknown_peer_id().address(addr.clone()).build(),
})
}
self.retry_delay.reset(RETRY_CONNECT_INTERVAL) // reset timeout
}
+19 -6
View File
@@ -142,19 +142,29 @@ fn filter_swarm_event(event: SwarmEvent<BehaviourEvent>) -> Option<FromSwarm> {
}
}
/// Create and configure a swarm which listens to all ports on OS
/// Create and configure a swarm.
///
/// - `listen_port`: TCP port to listen on. `0` lets the OS assign one.
/// - `bootstrap_peers`: multiaddrs to dial for environments without mDNS.
pub fn create_swarm(
keypair: identity::Keypair,
from_client: mpsc::Receiver<ToSwarm>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> alias::AnyResult<Swarm> {
let parsed_bootstrap_peers: Vec<libp2p::Multiaddr> = bootstrap_peers
.iter()
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
let mut swarm = SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_other_transport(tcp_transport)?
.with_behaviour(Behaviour::new)?
.with_behaviour(|keypair| Behaviour::new(keypair, parsed_bootstrap_peers))?
.build();
// Listen on all interfaces and whatever port the OS assigns
swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?;
swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{listen_port}").parse()?)?;
Ok(Swarm { swarm, from_client })
}
@@ -246,9 +256,12 @@ mod behaviour {
}
impl Behaviour {
pub fn new(keypair: &identity::Keypair) -> alias::AnyResult<Self> {
pub fn new(
keypair: &identity::Keypair,
bootstrap_peers: Vec<libp2p::Multiaddr>,
) -> alias::AnyResult<Self> {
Ok(Self {
discovery: discovery::Behaviour::new(keypair)?,
discovery: discovery::Behaviour::new(keypair, bootstrap_peers)?,
gossipsub: gossipsub_behaviour(keypair),
})
}
+107
View File
@@ -0,0 +1,107 @@
use futures_lite::StreamExt;
use networking::swarm::{FromSwarm, create_swarm};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
/// Helper: find a free TCP port.
fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
/// Two nodes connect via bootstrap peers — no mDNS needed.
///
/// Node A listens on a fixed port. Node B bootstraps to A's address.
/// We verify that B emits `FromSwarm::Discovered` for A's peer ID.
#[tokio::test]
async fn two_nodes_connect_via_bootstrap_peers() {
let port_a = free_port();
// Node A: listens on a known port, no bootstrap peers
let keypair_a = libp2p::identity::Keypair::generate_ed25519();
let peer_id_a = keypair_a.public().to_peer_id();
let (_tx_a, rx_a) = mpsc::channel(16);
let swarm_a = create_swarm(keypair_a, rx_a, vec![], port_a).expect("create swarm A");
let mut stream_a = swarm_a.into_stream();
// Node B: bootstraps to A's address
let keypair_b = libp2p::identity::Keypair::generate_ed25519();
let (_tx_b, rx_b) = mpsc::channel(16);
let swarm_b = create_swarm(
keypair_b,
rx_b,
vec![format!("/ip4/127.0.0.1/tcp/{port_a}")],
0,
)
.expect("create swarm B");
let mut stream_b = swarm_b.into_stream();
// Wait for B to discover A (connection established)
let connected = timeout(Duration::from_secs(10), async {
loop {
tokio::select! {
Some(event) = stream_a.next() => {
// A will also see B connect, but we check from B's perspective
let _ = event;
}
Some(event) = stream_b.next() => {
if let FromSwarm::Discovered { peer_id } = event {
if peer_id == peer_id_a {
return true;
}
}
}
}
}
})
.await;
assert!(
connected.is_ok() && connected.unwrap(),
"Node B should discover Node A via bootstrap peer"
);
}
/// Empty bootstrap peers should work (backward compatible).
#[tokio::test]
async fn create_swarm_with_empty_bootstrap_peers() {
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(keypair, rx, vec![], 0);
assert!(
swarm.is_ok(),
"create_swarm with no bootstrap peers should succeed"
);
}
/// Invalid multiaddr strings are silently filtered out.
#[tokio::test]
async fn create_swarm_ignores_invalid_bootstrap_addrs() {
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(
keypair,
rx,
vec![
"not-a-valid-multiaddr".to_string(),
"".to_string(),
"/ip4/10.0.0.1/tcp/30000".to_string(), // valid
],
0,
);
assert!(
swarm.is_ok(),
"create_swarm should succeed even with invalid bootstrap addrs"
);
}
/// Fixed listen port works correctly.
#[tokio::test]
async fn create_swarm_with_fixed_port() {
let port = free_port();
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(keypair, rx, vec![], port);
assert!(swarm.is_ok(), "create_swarm with fixed port should succeed");
}
View File
@@ -4,7 +4,7 @@ import time
from collections.abc import AsyncGenerator
from typing import Any
from exo.shared.types.api import (
from exo.api.types import (
ChatCompletionChoice,
ChatCompletionMessage,
ChatCompletionMessageText,
@@ -202,6 +202,8 @@ async def generate_chat_stream(
usage=last_usage,
)
yield f"data: {tool_response.model_dump_json()}\n\n"
if chunk.stats is not None:
yield f": generation_stats {chunk.stats.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
@@ -216,7 +218,10 @@ async def generate_chat_stream(
yield f"data: {chunk_response.model_dump_json()}\n\n"
if chunk.finish_reason is not None:
if chunk.stats is not None:
yield f": generation_stats {chunk.stats.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
async def collect_chat_response(
@@ -5,14 +5,8 @@ import re
from collections.abc import AsyncGenerator
from typing import Any
from exo.shared.types.api import FinishReason, Usage
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.claude_api import (
from exo.api.types import FinishReason, Usage
from exo.api.types.claude_api import (
ClaudeContentBlock,
ClaudeContentBlockDeltaEvent,
ClaudeContentBlockStartEvent,
@@ -35,6 +29,12 @@ from exo.shared.types.claude_api import (
ClaudeToolUseBlock,
ClaudeUsage,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
@@ -4,14 +4,7 @@ import json
from collections.abc import AsyncGenerator
from typing import Any
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.ollama_api import (
from exo.api.types.ollama_api import (
OllamaChatRequest,
OllamaChatResponse,
OllamaDoneReason,
@@ -21,6 +14,13 @@ from exo.shared.types.ollama_api import (
OllamaToolCall,
OllamaToolFunction,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
@@ -4,15 +4,8 @@ from collections.abc import AsyncGenerator
from itertools import count
from typing import Any
from exo.shared.types.api import Usage
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.openai_responses import (
from exo.api.types import Usage
from exo.api.types.openai_responses import (
FunctionCallInputItem,
ResponseCompletedEvent,
ResponseContentPart,
@@ -42,6 +35,13 @@ from exo.shared.types.openai_responses import (
ResponseTextDoneEvent,
ResponseUsage,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import CommandId
from exo.shared.types.text_generation import (
InputMessage,
TextGenerationTaskParams,
+69 -53
View File
@@ -21,17 +21,17 @@ from hypercorn.config import Config
from hypercorn.typing import ASGIFramework
from loguru import logger
from exo.master.adapters.chat_completions import (
from exo.api.adapters.chat_completions import (
chat_request_to_text_generation,
collect_chat_response,
generate_chat_stream,
)
from exo.master.adapters.claude import (
from exo.api.adapters.claude import (
claude_request_to_text_generation,
collect_claude_response,
generate_claude_stream,
)
from exo.master.adapters.ollama import (
from exo.api.adapters.ollama import (
collect_ollama_chat_response,
collect_ollama_generate_response,
generate_ollama_chat_stream,
@@ -39,34 +39,12 @@ from exo.master.adapters.ollama import (
ollama_generate_request_to_text_generation,
ollama_request_to_text_generation,
)
from exo.master.adapters.responses import (
from exo.api.adapters.responses import (
collect_responses_response,
generate_responses_stream,
responses_request_to_text_generation,
)
from exo.master.event_log import DiskEventLog
from exo.master.image_store import ImageStore
from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
from exo.shared.constants import (
DASHBOARD_DIR,
EXO_CACHE_HOME,
EXO_EVENT_LOG_DIR,
EXO_IMAGE_CACHE_DIR,
EXO_MAX_CHUNK_SIZE,
EXO_TRACING_CACHE_DIR,
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
delete_custom_card,
get_model_cards,
is_custom_card,
)
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
from exo.shared.types.api import (
from exo.api.types import (
AddCustomModelParams,
AdvancedImageParams,
BenchChatCompletionRequest,
@@ -114,6 +92,47 @@ from exo.shared.types.api import (
TraceStatsResponse,
normalize_image_size,
)
from exo.api.types.claude_api import (
ClaudeMessagesRequest,
ClaudeMessagesResponse,
)
from exo.api.types.ollama_api import (
OllamaChatRequest,
OllamaChatResponse,
OllamaGenerateRequest,
OllamaGenerateResponse,
OllamaModelDetails,
OllamaModelTag,
OllamaPsModel,
OllamaPsResponse,
OllamaShowRequest,
OllamaShowResponse,
OllamaTagsResponse,
)
from exo.api.types.openai_responses import (
ResponsesRequest,
ResponsesResponse,
)
from exo.master.image_store import ImageStore
from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
from exo.shared.constants import (
DASHBOARD_DIR,
EXO_CACHE_HOME,
EXO_EVENT_LOG_DIR,
EXO_IMAGE_CACHE_DIR,
EXO_MAX_CHUNK_SIZE,
EXO_TRACING_CACHE_DIR,
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
get_card,
get_model_cards,
)
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
from exo.shared.types.chunks import (
ErrorChunk,
ImageChunk,
@@ -122,13 +141,11 @@ from exo.shared.types.chunks import (
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.claude_api import (
ClaudeMessagesRequest,
ClaudeMessagesResponse,
)
from exo.shared.types.commands import (
AddCustomModelCard,
Command,
CreateInstance,
DeleteCustomModelCard,
DeleteDownload,
DeleteInstance,
DownloadCommand,
@@ -151,29 +168,13 @@ from exo.shared.types.events import (
TracesMerged,
)
from exo.shared.types.memory import Memory
from exo.shared.types.ollama_api import (
OllamaChatRequest,
OllamaChatResponse,
OllamaGenerateRequest,
OllamaGenerateResponse,
OllamaModelDetails,
OllamaModelTag,
OllamaPsModel,
OllamaPsResponse,
OllamaShowRequest,
OllamaShowResponse,
OllamaTagsResponse,
)
from exo.shared.types.openai_responses import (
ResponsesRequest,
ResponsesResponse,
)
from exo.shared.types.state import State
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.disk_event_log import DiskEventLog
from exo.utils.power_sampler import PowerSampler
from exo.utils.task_group import TaskGroup
@@ -1558,7 +1559,7 @@ class API:
storage_size_megabytes=card.storage_size.in_mb,
supports_tensor=card.supports_tensor,
tasks=[task.value for task in card.tasks],
is_custom=is_custom_card(card.model_id),
is_custom=card.is_custom,
family=card.family,
quantization=card.quantization,
base_model=card.base_model,
@@ -1569,7 +1570,7 @@ class API:
)
async def add_custom_model(self, payload: AddCustomModelParams) -> ModelListModel:
"""Fetch a model from HuggingFace and save as a custom model card."""
"""Fetch a model from HuggingFace and save as a custom model card, then sync across the cluster."""
try:
card = await ModelCard.fetch_from_hf(payload.model_id)
except Exception as exc:
@@ -1577,6 +1578,13 @@ 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),
)
)
return ModelListModel(
id=card.model_id,
hugging_face_id=card.model_id,
@@ -1590,10 +1598,18 @@ class API:
)
async def delete_custom_model(self, model_id: ModelId) -> JSONResponse:
"""Delete a user-added custom model card."""
deleted = await delete_custom_card(model_id)
if not deleted:
"""Delete a user-added custom model card and sync deletion across the cluster."""
card = get_card(model_id)
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),
)
)
return JSONResponse(
{"message": "Model card deleted", "model_id": str(model_id)}
)
@@ -4,10 +4,11 @@ from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from exo.api.main import API
def test_http_exception_handler_formats_openai_style() -> None:
"""Test that HTTPException is converted to OpenAI-style error format."""
from exo.master.api import API
app = FastAPI()
@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from exo.api.main import API
from exo.shared.types.common import CommandId
def _make_api() -> Any:
"""Create a minimal API instance with cancel route and error handler."""
from exo.master.api import API
app = FastAPI()
api = object.__new__(API)
@@ -3,11 +3,11 @@
import pydantic
import pytest
from exo.master.adapters.claude import (
from exo.api.adapters.claude import (
claude_request_to_text_generation,
finish_reason_to_claude_stop_reason,
)
from exo.shared.types.claude_api import (
from exo.api.types.claude_api import (
ClaudeMessage,
ClaudeMessagesRequest,
ClaudeTextBlock,
@@ -4,12 +4,12 @@ import json
from collections.abc import AsyncGenerator
from typing import Any, cast
from exo.master.adapters.claude import (
from exo.api.adapters.claude import (
ClaudeMessagesResponse,
collect_claude_response,
generate_claude_stream,
)
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
from exo.shared.types.chunks import ErrorChunk, TokenChunk, ToolCallChunk
from exo.shared.types.common import CommandId, ModelId
@@ -7,11 +7,11 @@ The responses adapter converts it to TextGenerationTaskParams for the pipeline.
import pydantic
import pytest
from exo.shared.types.common import ModelId
from exo.shared.types.openai_responses import (
from exo.api.types.openai_responses import (
ResponseInputMessage,
ResponsesRequest,
)
from exo.shared.types.common import ModelId
class TestResponsesRequestValidation:
+57
View File
@@ -0,0 +1,57 @@
from .api import AddCustomModelParams as AddCustomModelParams
from .api import AdvancedImageParams as AdvancedImageParams
from .api import BenchChatCompletionRequest as BenchChatCompletionRequest
from .api import BenchChatCompletionResponse as BenchChatCompletionResponse
from .api import BenchImageGenerationResponse as BenchImageGenerationResponse
from .api import BenchImageGenerationTaskParams as BenchImageGenerationTaskParams
from .api import CancelCommandResponse as CancelCommandResponse
from .api import ChatCompletionChoice as ChatCompletionChoice
from .api import ChatCompletionMessage as ChatCompletionMessage
from .api import ChatCompletionMessageText as ChatCompletionMessageText
from .api import ChatCompletionRequest as ChatCompletionRequest
from .api import ChatCompletionResponse as ChatCompletionResponse
from .api import CompletionTokensDetails as CompletionTokensDetails
from .api import CreateInstanceParams as CreateInstanceParams
from .api import CreateInstanceResponse as CreateInstanceResponse
from .api import DeleteDownloadResponse as DeleteDownloadResponse
from .api import DeleteInstanceResponse as DeleteInstanceResponse
from .api import DeleteTracesRequest as DeleteTracesRequest
from .api import DeleteTracesResponse as DeleteTracesResponse
from .api import ErrorInfo as ErrorInfo
from .api import ErrorResponse as ErrorResponse
from .api import FinishReason as FinishReason
from .api import GenerationStats as GenerationStats
from .api import HuggingFaceSearchResult as HuggingFaceSearchResult
from .api import ImageData as ImageData
from .api import ImageEditsTaskParams as ImageEditsTaskParams
from .api import ImageGenerationResponse as ImageGenerationResponse
from .api import ImageGenerationStats as ImageGenerationStats
from .api import ImageGenerationTaskParams as ImageGenerationTaskParams
from .api import ImageListItem as ImageListItem
from .api import ImageListResponse as ImageListResponse
from .api import ImageSize as ImageSize
from .api import Logprobs as Logprobs
from .api import LogprobsContentItem as LogprobsContentItem
from .api import ModelList as ModelList
from .api import ModelListModel as ModelListModel
from .api import NodePowerStats as NodePowerStats
from .api import PlaceInstanceParams as PlaceInstanceParams
from .api import PlacementPreview as PlacementPreview
from .api import PlacementPreviewResponse as PlacementPreviewResponse
from .api import PowerUsage as PowerUsage
from .api import PromptTokensDetails as PromptTokensDetails
from .api import StartDownloadParams as StartDownloadParams
from .api import StartDownloadResponse as StartDownloadResponse
from .api import StreamingChoiceResponse as StreamingChoiceResponse
from .api import ToolCall as ToolCall
from .api import ToolCallItem as ToolCallItem
from .api import TopLogprobItem as TopLogprobItem
from .api import TraceCategoryStats as TraceCategoryStats
from .api import TraceEventResponse as TraceEventResponse
from .api import TraceListItem as TraceListItem
from .api import TraceListResponse as TraceListResponse
from .api import TraceRankStats as TraceRankStats
from .api import TraceResponse as TraceResponse
from .api import TraceStatsResponse as TraceStatsResponse
from .api import Usage as Usage
from .api import normalize_image_size as normalize_image_size
+24 -7
View File
@@ -744,13 +744,30 @@ async def download_shard(
logger.debug(f"Downloading {shard.model_card.model_id=} with {allow_patterns=}")
all_start_time = time.time()
file_list = await fetch_file_list_with_cache(
shard.model_card.model_id,
revision,
recursive=True,
skip_internet=skip_internet,
on_connection_lost=on_connection_lost,
)
try:
file_list = await fetch_file_list_with_cache(
shard.model_card.model_id,
revision,
recursive=True,
skip_internet=skip_internet,
on_connection_lost=on_connection_lost,
)
except FileNotFoundError:
not_started_progress = RepoDownloadProgress(
repo_id=str(shard.model_card.model_id),
repo_revision=revision,
shard=shard,
completed_files=0,
total_files=0,
downloaded=Memory.from_bytes(0),
downloaded_this_session=Memory.from_bytes(0),
total=Memory.from_bytes(0),
overall_speed=0.0,
overall_eta=timedelta(0),
status="not_started",
file_progress={},
)
return target_dir, not_started_progress
filtered_file_list = list(
filter_repo_objects(
file_list, allow_patterns=allow_patterns, key=lambda x: x.path
+30 -3
View File
@@ -11,9 +11,9 @@ from loguru import logger
from pydantic import PositiveInt
import exo.routing.topics as topics
from exo.api.main import API
from exo.download.coordinator import DownloadCoordinator
from exo.download.impl_shard_downloader import exo_shard_downloader
from exo.master.api import API # TODO: should API be in master?
from exo.master.main import Master
from exo.routing.event_router import EventRouter
from exo.routing.router import Router, get_node_id_keypair
@@ -47,7 +47,11 @@ class Node:
keypair = get_node_id_keypair()
node_id = NodeId(keypair.to_node_id())
session_id = SessionId(master_node_id=node_id, election_clock=0)
router = Router.create(keypair)
router = Router.create(
keypair,
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)
@@ -264,12 +268,17 @@ def main():
mp.set_start_method("spawn", force=True)
# TODO: Refactor the current verbosity system
logger_setup(EXO_LOG, args.verbosity)
logger.info("Starting EXO")
logger.info(f"{'=' * 40}")
logger.info(f"Starting EXO | pid={os.getpid()}")
logger.info(f"{'=' * 40}")
logger.info(f"EXO_LIBP2P_NAMESPACE: {os.getenv('EXO_LIBP2P_NAMESPACE')}")
if args.offline:
logger.info("Running in OFFLINE mode — no internet checks, local models only")
if args.bootstrap_peers:
logger.info(f"Bootstrap peers: {args.bootstrap_peers}")
if args.no_batch:
os.environ["EXO_NO_BATCH"] = "1"
logger.info("Continuous batching disabled (--no-batch)")
@@ -306,6 +315,8 @@ class Args(CamelCaseModel):
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
no_batch: bool = False
fast_synch: bool | None = None # None = auto, True = force on, False = force off
bootstrap_peers: list[str] = []
libp2p_port: int
@classmethod
def parse(cls) -> Self:
@@ -363,6 +374,22 @@ class Args(CamelCaseModel):
action="store_true",
help="Disable continuous batching, use sequential generation",
)
parser.add_argument(
"--bootstrap-peers",
type=lambda s: [p for p in s.split(",") if p],
default=os.getenv("EXO_BOOTSTRAP_PEERS", "").split(",")
if os.getenv("EXO_BOOTSTRAP_PEERS")
else [],
dest="bootstrap_peers",
help="Comma-separated libp2p multiaddrs to dial on startup (env: EXO_BOOTSTRAP_PEERS)",
)
parser.add_argument(
"--libp2p-port",
type=int,
default=0,
dest="libp2p_port",
help="Fixed TCP port for libp2p to listen on (0 = OS-assigned).",
)
fast_synch_group = parser.add_mutually_exclusive_group()
fast_synch_group.add_argument(
"--fast-synch",
+14 -1
View File
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
import anyio
from loguru import logger
from exo.master.event_log import DiskEventLog
from exo.master.placement import (
add_instance_to_placements,
cancel_unnecessary_downloads,
@@ -14,7 +13,9 @@ from exo.master.placement import (
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,
CreateInstance,
DeleteCustomModelCard,
DeleteInstance,
ForwarderCommand,
ForwarderDownloadCommand,
@@ -30,6 +31,8 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, NodeId, SessionId, SystemId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
GlobalForwarderEvent,
IndexedEvent,
@@ -61,6 +64,7 @@ from exo.shared.types.tasks import (
)
from exo.shared.types.worker.instances import InstanceId
from exo.utils.channels import Receiver, Sender
from exo.utils.disk_event_log import DiskEventLog
from exo.utils.event_buffer import MultiSourceBuffer
from exo.utils.task_group import TaskGroup
@@ -294,6 +298,7 @@ class Master:
self.state.instances,
self.state.node_memory,
self.state.node_network,
download_status=self.state.downloads,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
@@ -344,6 +349,14 @@ class Master:
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 RequestEventLog():
# We should just be able to send everything, since other buffers will ignore old messages
# rate limit to 1000 at a time
+57 -4
View File
@@ -32,7 +32,10 @@ from exo.shared.types.memory import Memory
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.worker.downloads import (
DownloadCompleted,
DownloadFailed,
DownloadOngoing,
DownloadPending,
DownloadProgress,
)
from exo.shared.types.worker.instances import (
@@ -60,6 +63,45 @@ def add_instance_to_placements(
return {**current_instances, command.instance.instance_id: command.instance}
def _get_node_download_fraction(
node_id: NodeId,
model_id: ModelId,
download_status: Mapping[NodeId, Sequence[DownloadProgress]],
) -> float:
"""Return the download fraction (0.01.0) for a model on a given node."""
for progress in download_status.get(node_id, []):
if progress.shard_metadata.model_card.model_id != model_id:
continue
match progress:
case DownloadCompleted():
return 1.0
case DownloadOngoing():
total = progress.download_progress.total.in_bytes
return (
progress.download_progress.downloaded.in_bytes / total
if total > 0
else 0.0
)
case DownloadPending():
total = progress.total.in_bytes
return progress.downloaded.in_bytes / total if total > 0 else 0.0
case DownloadFailed():
return 0.0
return 0.0
def _cycle_download_score(
cycle: Cycle,
model_id: ModelId,
download_status: Mapping[NodeId, Sequence[DownloadProgress]],
) -> float:
"""Sum of download fractions across all nodes in a cycle."""
return sum(
_get_node_download_fraction(node_id, model_id, download_status)
for node_id in cycle
)
def place_instance(
command: PlaceInstance,
topology: Topology,
@@ -67,6 +109,7 @@ def place_instance(
node_memory: Mapping[NodeId, MemoryUsage],
node_network: Mapping[NodeId, NodeNetworkInfo],
required_nodes: set[NodeId] | None = None,
download_status: Mapping[NodeId, Sequence[DownloadProgress]] | None = None,
) -> dict[InstanceId, Instance]:
cycles = topology.get_cycles()
candidate_cycles = list(filter(lambda it: len(it) >= command.min_nodes, cycles))
@@ -130,11 +173,21 @@ def place_instance(
if any(topology.node_is_leaf(node_id) for node_id in cycle)
]
resolved_download_status = download_status or {}
candidate_cycles = (
cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles
)
selected_cycle = max(
cycles_with_leaf_nodes if cycles_with_leaf_nodes != [] else smallest_cycles,
key=lambda cycle: sum(
(node_memory[node_id].ram_available for node_id in cycle),
start=Memory(),
candidate_cycles,
key=lambda cycle: (
_cycle_download_score(
cycle, command.model_card.model_id, resolved_download_status
),
sum(
(node_memory[node_id].ram_available for node_id in cycle),
start=Memory(),
),
),
)
+187 -1
View File
@@ -25,6 +25,12 @@ from exo.shared.types.profiling import NetworkInterfaceInfo, NodeNetworkInfo
from exo.shared.types.tasks import TaskId, TaskStatus, TextGeneration
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
from exo.shared.types.topology import Connection, SocketConnection
from exo.shared.types.worker.downloads import (
DownloadCompleted,
DownloadFailed,
DownloadOngoing,
DownloadProgressData,
)
from exo.shared.types.worker.instances import (
Instance,
InstanceId,
@@ -33,7 +39,7 @@ from exo.shared.types.worker.instances import (
MlxRingInstance,
)
from exo.shared.types.worker.runners import ShardAssignments
from exo.shared.types.worker.shards import Sharding
from exo.shared.types.worker.shards import PipelineShardMetadata, Sharding
@pytest.fixture
@@ -576,3 +582,183 @@ def test_get_transition_events_delete_instance_cancels_only_matching_tasks(
assert cancel_events[0].task_status == TaskStatus.Cancelled
assert len(delete_events) == 1
assert delete_events[0].instance_id == instance_id_a
def _make_shard_metadata(model_card: ModelCard) -> PipelineShardMetadata:
return PipelineShardMetadata(
model_card=model_card,
device_rank=0,
world_size=1,
start_layer=0,
end_layer=model_card.n_layers,
n_layers=model_card.n_layers,
)
def test_placement_prefers_cycle_with_downloaded_model(
model_card: ModelCard,
) -> None:
"""When two cycles are otherwise equal, prefer the one with the model already downloaded."""
topology = Topology()
model_card.storage_size = Memory.from_bytes(500)
node_a = NodeId()
node_b = NodeId()
node_memory = {
node_a: create_node_memory(1000),
node_b: create_node_memory(1000),
}
node_network = {
node_a: create_node_network(),
node_b: create_node_network(),
}
topology.add_node(node_a)
topology.add_node(node_b)
# No connections between them — two single-node cycles
shard_meta = _make_shard_metadata(model_card)
# node_b has the model fully downloaded, node_a does not
download_status = {
node_b: [
DownloadCompleted(
node_id=node_b,
shard_metadata=shard_meta,
total=model_card.storage_size,
),
],
}
cic = place_instance_command(model_card)
placements = place_instance(
cic, topology, {}, node_memory, node_network, download_status=download_status
)
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assert assigned_nodes == {node_b}
def test_placement_prefers_cycle_with_higher_download_progress(
model_card: ModelCard,
) -> None:
"""When two cycles are otherwise equal, prefer the one with more download progress."""
topology = Topology()
model_card.storage_size = Memory.from_bytes(1000)
node_a = NodeId()
node_b = NodeId()
node_memory = {
node_a: create_node_memory(1000),
node_b: create_node_memory(1000),
}
node_network = {
node_a: create_node_network(),
node_b: create_node_network(),
}
topology.add_node(node_a)
topology.add_node(node_b)
shard_meta = _make_shard_metadata(model_card)
# node_a: 30% downloaded, node_b: 80% downloaded
download_status = {
node_a: [
DownloadOngoing(
node_id=node_a,
shard_metadata=shard_meta,
download_progress=DownloadProgressData(
total=Memory.from_bytes(1000),
downloaded=Memory.from_bytes(300),
downloaded_this_session=Memory.from_bytes(300),
completed_files=0,
total_files=1,
speed=0.0,
eta_ms=0,
files={},
),
),
],
node_b: [
DownloadOngoing(
node_id=node_b,
shard_metadata=shard_meta,
download_progress=DownloadProgressData(
total=Memory.from_bytes(1000),
downloaded=Memory.from_bytes(800),
downloaded_this_session=Memory.from_bytes(800),
completed_files=0,
total_files=1,
speed=0.0,
eta_ms=0,
files={},
),
),
],
}
cic = place_instance_command(model_card)
placements = place_instance(
cic, topology, {}, node_memory, node_network, download_status=download_status
)
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assert assigned_nodes == {node_b}
def test_placement_does_not_prefer_cycle_with_failed_download(
model_card: ModelCard,
) -> None:
"""A failed download should count as 0% — not preferred over a node with no download history."""
topology = Topology()
model_card.storage_size = Memory.from_bytes(500)
node_a = NodeId()
node_b = NodeId()
# node_a has slightly more RAM so it would win on the RAM tiebreaker
node_memory = {
node_a: create_node_memory(1001),
node_b: create_node_memory(1000),
}
node_network = {
node_a: create_node_network(),
node_b: create_node_network(),
}
topology.add_node(node_a)
topology.add_node(node_b)
shard_meta = _make_shard_metadata(model_card)
# node_b has a failed download — should not be preferred
download_status = {
node_b: [
DownloadFailed(
node_id=node_b,
shard_metadata=shard_meta,
error_message="connection reset",
),
],
}
cic = place_instance_command(model_card)
placements = place_instance(
cic, topology, {}, node_memory, node_network, download_status=download_status
)
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
# node_a should win on RAM tiebreaker since failed download scores 0.0
assert assigned_nodes == {node_a}
+10 -2
View File
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from copy import copy
from itertools import count
from math import inf
@@ -102,8 +103,15 @@ class TopicRouter[T: CamelCaseModel]:
class Router:
@classmethod
def create(cls, identity: Keypair) -> "Router":
return cls(handle=NetworkingHandle(identity))
def create(
cls,
identity: Keypair,
bootstrap_peers: Sequence[str] = (),
listen_port: int = 0,
) -> "Router":
return cls(
handle=NetworkingHandle(identity, list(bootstrap_peers), listen_port)
)
def __init__(self, handle: NetworkingHandle):
self.topic_routers: dict[str, TopicRouter[CamelCaseModel]] = {}
+11 -1
View File
@@ -7,6 +7,8 @@ from loguru import logger
from exo.shared.types.common import NodeId
from exo.shared.types.events import (
ChunkGenerated,
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
IndexedEvent,
InputChunkReceived,
@@ -65,6 +67,8 @@ def event_apply(event: Event, state: State) -> State:
| InputChunkReceived()
| TracesCollected()
| TracesMerged()
| CustomModelCardAdded()
| CustomModelCardDeleted()
): # Pass-through events that don't modify state
return state
case InstanceCreated():
@@ -115,7 +119,13 @@ def apply_node_download_progress(event: NodeDownloadProgress, state: State) -> S
replaced = False
for i, existing_dp in enumerate(current):
if existing_dp.shard_metadata == dp.shard_metadata:
# TODO(ciaran): deduplicate by model_id for now. Will need to use
# shard_metadata again when pipeline and tensor downloads differ.
# For now this is fine
if (
existing_dp.shard_metadata.model_card.model_id
== dp.shard_metadata.model_card.model_id
):
current[i] = dp
replaced = True
break
+2 -2
View File
@@ -66,7 +66,7 @@ def logger_setup(log_file: Path | None, verbosity: int = 0):
else:
logger.add(
sys.__stderr__, # type: ignore
format="[ {time:HH:mm:ss.SSS} | <level>{level: <8}</level> | {name}:{function}:{line} ] <level>{message}</level>",
format="[ {time:YYYY-MM-DD HH:mm:ss.SSS} | <level>{level: <8}</level> | {name}:{function}:{line} ] <level>{message}</level>",
level="DEBUG",
colorize=True,
enqueue=True,
@@ -76,7 +76,7 @@ def logger_setup(log_file: Path | None, verbosity: int = 0):
logger.add(
log_file,
format="[ {time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} ] {message}",
level="INFO",
level="DEBUG" if verbosity > 0 else "INFO",
colorize=False,
enqueue=True,
rotation=lambda _, __: next(rotate_once),
+41 -28
View File
@@ -30,30 +30,42 @@ from exo.utils.pydantic_ext import CamelCaseModel
# kinda ugly...
# TODO: load search path from config.toml
_custom_cards_dir = Path(str(EXO_CUSTOM_MODEL_CARDS_DIR))
CARD_SEARCH_PATH = [
_BUILTIN_CARD_DIRS = [
Path(RESOURCES_DIR) / "inference_model_cards",
Path(RESOURCES_DIR) / "image_model_cards",
_custom_cards_dir,
]
_card_cache: dict[ModelId, "ModelCard"] = {}
async def _refresh_card_cache():
for path in CARD_SEARCH_PATH:
async for toml_file in path.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if card.model_id not in _card_cache:
_card_cache[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
async def _load_cards_from_dir(directory: Path, *, is_custom: bool) -> None:
"""Load all TOML model cards from a directory into the cache."""
async for toml_file in directory.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if is_custom:
card = card.model_copy(update={"is_custom": True})
if card.model_id not in _card_cache:
_card_cache[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
async def _refresh_card_cache() -> None:
for path in _BUILTIN_CARD_DIRS:
await _load_cards_from_dir(path, is_custom=False)
await _load_cards_from_dir(_custom_cards_dir, is_custom=True)
def _is_image_card(card: "ModelCard") -> bool:
return any(t in (ModelTask.TextToImage, ModelTask.ImageToImage) for t in card.tasks)
def get_card(model_id: ModelId) -> "ModelCard | None":
"""Look up a single model card from the cache by ID."""
return _card_cache.get(model_id)
async def get_model_cards() -> list["ModelCard"]:
if len(_card_cache) == 0:
await _refresh_card_cache()
@@ -92,6 +104,7 @@ class ModelCard(CamelCaseModel):
capabilities: list[str] = []
uses_cfg: bool = False
trust_remote_code: bool = True
is_custom: bool = False
@field_validator("tasks", mode="before")
@classmethod
@@ -100,7 +113,7 @@ class ModelCard(CamelCaseModel):
async def save(self, path: Path) -> None:
async with await open_file(path, "w") as f:
py = self.model_dump(exclude_none=True)
py = self.model_dump(exclude_none=True, exclude={"is_custom"})
data = tomlkit.dumps(py) # pyright: ignore[reportUnknownMemberType]
await f.write(data)
@@ -122,17 +135,24 @@ class ModelCard(CamelCaseModel):
if (mc := _card_cache.get(model_id)) is not None:
return mc
return await ModelCard.fetch_from_hf(model_id)
mc = await ModelCard.fetch_from_hf(model_id)
await mc.save_to_custom_dir()
_card_cache[model_id] = mc
return mc
@staticmethod
async def fetch_from_hf(model_id: ModelId) -> "ModelCard":
"""Fetches storage size and number of layers for a Hugging Face model, returns Pydantic ModelMeta."""
"""Fetches storage size and number of layers for a Hugging Face model, returns Pydantic ModelMeta.
This is a pure fetch it does NOT save to disk or update the cache.
Persistence is handled by the event-sourcing layer (worker event handler).
"""
# TODO: failure if files do not exist
config_data = await fetch_config_data(model_id)
num_layers = config_data.layer_count
mem_size_bytes = await fetch_safetensors_size(model_id)
mc = ModelCard(
return ModelCard(
model_id=ModelId(model_id),
storage_size=mem_size_bytes,
n_layers=num_layers,
@@ -141,10 +161,13 @@ class ModelCard(CamelCaseModel):
num_key_value_heads=config_data.num_key_value_heads,
tasks=[ModelTask.TextGeneration],
trust_remote_code=False,
is_custom=True,
)
await mc.save_to_custom_dir()
_card_cache[model_id] = mc
return mc
def add_to_card_cache(card: "ModelCard") -> None:
"""Add or update a model card in the in-memory cache."""
_card_cache[card.model_id] = card
async def delete_custom_card(model_id: ModelId) -> bool:
@@ -157,16 +180,6 @@ async def delete_custom_card(model_id: ModelId) -> bool:
return False
def is_custom_card(model_id: ModelId) -> bool:
"""Check if a model card exists in the custom cards directory."""
import os
card_path = Path(str(EXO_CUSTOM_MODEL_CARDS_DIR)) / (
ModelId(model_id).normalize() + ".toml"
)
return os.path.isfile(str(card_path))
class ConfigData(BaseModel):
model_config = {"extra": "ignore"} # Allow unknown fields
+4 -4
View File
@@ -1,18 +1,18 @@
from collections.abc import Generator
from typing import Any, Literal
from exo.shared.models.model_cards import ModelId
from exo.shared.types.api import (
from exo.api.types import (
FinishReason,
GenerationStats,
ImageGenerationStats,
ToolCallItem,
TopLogprobItem,
Usage,
)
from exo.shared.models.model_cards import ModelId
from exo.utils.pydantic_ext import TaggedModel
from .api import FinishReason
from .common import CommandId
from .worker.runner_response import ToolCallItem
class BaseChunk(TaggedModel):
+12 -2
View File
@@ -1,10 +1,10 @@
from pydantic import Field
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.api import (
from exo.api.types import (
ImageEditsTaskParams,
ImageGenerationTaskParams,
)
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.chunks import InputImageChunk
from exo.shared.types.common import CommandId, NodeId, SystemId
from exo.shared.types.text_generation import TextGenerationTaskParams
@@ -81,6 +81,14 @@ class CancelDownload(BaseCommand):
model_id: ModelId
class AddCustomModelCard(BaseCommand):
model_card: ModelCard
class DeleteCustomModelCard(BaseCommand):
model_id: ModelId
DownloadCommand = StartDownload | DeleteDownload | CancelDownload
@@ -96,6 +104,8 @@ Command = (
| TaskCancelled
| TaskFinished
| SendInputChunk
| AddCustomModelCard
| DeleteCustomModelCard
)
+12 -1
View File
@@ -3,9 +3,10 @@ from typing import final
from pydantic import Field
from exo.shared.models.model_cards import ModelCard
from exo.shared.topology import Connection
from exo.shared.types.chunks import GenerationChunk, InputImageChunk
from exo.shared.types.common import CommandId, Id, NodeId, SessionId, SystemId
from exo.shared.types.common import CommandId, Id, ModelId, NodeId, SessionId, SystemId
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.worker.downloads import DownloadProgress
from exo.shared.types.worker.instances import Instance, InstanceId
@@ -106,6 +107,14 @@ class TopologyEdgeDeleted(BaseEvent):
conn: Connection
class CustomModelCardAdded(BaseEvent):
model_card: ModelCard
class CustomModelCardDeleted(BaseEvent):
model_id: ModelId
@final
class TraceEventData(FrozenModel):
name: str
@@ -147,6 +156,8 @@ Event = (
| TopologyEdgeDeleted
| TracesCollected
| TracesMerged
| CustomModelCardAdded
| CustomModelCardDeleted
)
+1 -1
View File
@@ -2,7 +2,7 @@ from enum import Enum
from pydantic import Field
from exo.shared.types.api import (
from exo.api.types import (
ImageEditsTaskParams,
ImageGenerationTaskParams,
)
@@ -1,7 +1,7 @@
from collections.abc import Generator
from typing import Any, Literal
from exo.shared.types.api import (
from exo.api.types import (
FinishReason,
GenerationStats,
ImageGenerationStats,
+1 -1
View File
@@ -63,7 +63,7 @@ class RunnerShutdown(BaseRunnerStatus):
class RunnerFailed(BaseRunnerStatus):
error_message: str | None = None
error_message: str
RunnerStatus = (
+72 -18
View File
@@ -10,7 +10,6 @@ from typing import Self, cast
import anyio
from anyio import fail_after, open_process, to_thread
from anyio.streams.buffered import BufferedByteReceiveStream
from anyio.streams.text import TextReceiveStream
from loguru import logger
from pydantic import ValidationError
@@ -288,7 +287,7 @@ class ThunderboltBridgeInfo(TaggedModel):
)
)
except Exception as e:
logger.warning(f"Failed to gather Thunderbolt Bridge info: {e}")
logger.opt(exception=e).warning("Failed to gather Thunderbolt Bridge info")
return None
@@ -382,18 +381,61 @@ class InfoGatherer:
rdma_ctl_poll_interval: float | None = 10 if IS_DARWIN else None
disk_poll_interval: float | None = 30
_tg: TaskGroup = field(init=False, default_factory=TaskGroup)
_psutil_memory_fallback_enabled: bool = field(init=False, default=False)
def _get_macmon_path(self) -> str | None:
return os.getenv("EXO_MACMON_PATH") or shutil.which("macmon")
async def _can_read_macmon_metrics(self, macmon_path: str) -> bool:
try:
with fail_after(5):
proc = await anyio.run_process(
[macmon_path, "pipe", "--samples", "1", "--interval", "100"],
check=False,
)
except Exception as e:
logger.opt(exception=e).warning(
f"Failed to validate macmon at {macmon_path}"
)
return False
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
logger.warning(
f"macmon preflight failed with return code {proc.returncode}: "
f"{stderr or 'no stderr'}"
)
return False
stdout = proc.stdout.decode("utf-8", errors="replace").strip()
if not stdout:
logger.warning("macmon preflight returned no metrics")
return False
try:
MacmonMetrics.from_raw_json(stdout.splitlines()[0])
except ValidationError as e:
logger.opt(exception=e).warning(
"macmon preflight returned unexpected metrics JSON"
)
return False
return True
async def run(self):
async with self._tg as tg:
if IS_DARWIN:
if (macmon_path := shutil.which("macmon")) is not None:
tg.start_soon(self._monitor_macmon, macmon_path)
if (macmon_path := self._get_macmon_path()) is not None:
if await self._can_read_macmon_metrics(macmon_path):
tg.start_soon(self._monitor_macmon, macmon_path)
else:
logger.warning(
f"macmon at {macmon_path} is unusable, falling back to psutil memory monitoring"
)
else:
# macmon not installed — fall back to psutil for memory
logger.warning(
"macmon not found, falling back to psutil for memory monitoring"
)
self.memory_poll_rate = 1
tg.start_soon(self._monitor_system_profiler_thunderbolt_data)
tg.start_soon(self._monitor_thunderbolt_bridge_status)
tg.start_soon(self._monitor_rdma_ctl_status)
@@ -418,7 +460,7 @@ class InfoGatherer:
with fail_after(30):
await self.info_sender.send(await StaticNodeInformation.gather())
except Exception as e:
logger.warning(f"Error gathering static node info: {e}")
logger.opt(exception=e).warning("Error gathering static node info")
await anyio.sleep(self.static_info_poll_interval)
async def _monitor_misc(self):
@@ -429,7 +471,7 @@ class InfoGatherer:
with fail_after(10):
await self.info_sender.send(await MiscData.gather())
except Exception as e:
logger.warning(f"Error gathering misc data: {e}")
logger.opt(exception=e).warning("Error gathering misc data")
await anyio.sleep(self.misc_poll_interval)
async def _monitor_system_profiler_thunderbolt_data(self):
@@ -456,7 +498,7 @@ class InfoGatherer:
conns = [it for i in data if (it := i.conn()) is not None]
await self.info_sender.send(MacThunderboltConnections(conns=conns))
except Exception as e:
logger.warning(f"Error gathering Thunderbolt data: {e}")
logger.opt(exception=e).warning("Error gathering Thunderbolt data")
await anyio.sleep(self.system_profiler_interval)
async def _monitor_memory_usage(self):
@@ -474,7 +516,7 @@ class InfoGatherer:
MemoryUsage.from_psutil(override_memory=override_memory)
)
except Exception as e:
logger.warning(f"Error gathering memory usage: {e}")
logger.opt(exception=e).warning("Error gathering memory usage")
await anyio.sleep(self.memory_poll_rate)
async def _watch_system_info(self):
@@ -486,7 +528,7 @@ class InfoGatherer:
nics = await get_network_interfaces()
await self.info_sender.send(NodeNetworkInterfaces(ifaces=nics))
except Exception as e:
logger.warning(f"Error gathering network interfaces: {e}")
logger.opt(exception=e).warning("Error gathering network interfaces")
await anyio.sleep(self.interface_watcher_interval)
async def _monitor_thunderbolt_bridge_status(self):
@@ -499,7 +541,9 @@ class InfoGatherer:
if curr is not None:
await self.info_sender.send(curr)
except Exception as e:
logger.warning(f"Error gathering Thunderbolt Bridge status: {e}")
logger.opt(exception=e).warning(
"Error gathering Thunderbolt Bridge status"
)
await anyio.sleep(self.thunderbolt_bridge_poll_interval)
async def _monitor_rdma_ctl_status(self):
@@ -511,7 +555,7 @@ class InfoGatherer:
if curr is not None:
await self.info_sender.send(curr)
except Exception as e:
logger.warning(f"Error gathering RDMA ctl status: {e}")
logger.opt(exception=e).warning("Error gathering RDMA ctl status")
await anyio.sleep(self.rdma_ctl_poll_interval)
async def _monitor_disk_usage(self):
@@ -522,7 +566,7 @@ class InfoGatherer:
with fail_after(5):
await self.info_sender.send(await NodeDiskUsage.gather())
except Exception as e:
logger.warning(f"Error gathering disk usage: {e}")
logger.opt(exception=e).warning("Error gathering disk usage")
await anyio.sleep(self.disk_poll_interval)
async def _monitor_macmon(self, macmon_path: str):
@@ -545,15 +589,21 @@ class InfoGatherer:
if not p.stdout:
logger.critical("MacMon closed stdout")
return
stream = TextReceiveStream(BufferedByteReceiveStream(p.stdout))
stream = BufferedByteReceiveStream(p.stdout)
while True:
with fail_after(read_timeout):
text = await stream.receive()
await self.info_sender.send(MacmonMetrics.from_raw_json(text))
data = await stream.receive_until(
delimiter=b"\n", max_bytes=8 * 1024
)
text = data.decode("utf-8", errors="replace").strip()
metrics = MacmonMetrics.from_raw_json(text)
await self.info_sender.send(metrics)
except TimeoutError:
logger.warning(
f"MacMon produced no output for {read_timeout}s, restarting"
)
self.memory_poll_rate = 1
self._tg.start_soon(self._monitor_memory_usage)
except CalledProcessError as e:
stderr_msg = "no stderr"
stderr_output = cast(bytes | str | None, e.stderr)
@@ -566,6 +616,10 @@ class InfoGatherer:
logger.warning(
f"MacMon failed with return code {e.returncode}: {stderr_msg}"
)
self.memory_poll_rate = 1
self._tg.start_soon(self._monitor_memory_usage)
except Exception as e:
logger.warning(f"Error in macmon monitor: {e}")
logger.opt(exception=e).warning("Error in macmon monitor")
self.memory_poll_rate = 1
self._tg.start_soon(self._monitor_memory_usage)
await anyio.sleep(self.macmon_interval)
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import final
import anyio
from exo.shared.types.api import NodePowerStats, PowerUsage
from exo.api.types import NodePowerStats, PowerUsage
from exo.shared.types.common import NodeId
from exo.shared.types.profiling import SystemPerformanceProfile
@@ -2,8 +2,8 @@ from pathlib import Path
import pytest
from exo.master.event_log import DiskEventLog
from exo.shared.types.events import TestEvent
from exo.utils.disk_event_log import DiskEventLog
@pytest.fixture
+1 -1
View File
@@ -3,7 +3,7 @@ from collections.abc import Mapping
import anyio
import pytest
from exo.shared.types.api import PowerUsage
from exo.api.types import PowerUsage
from exo.shared.types.common import NodeId
from exo.shared.types.profiling import SystemPerformanceProfile
from exo.utils.power_sampler import PowerSampler
@@ -6,8 +6,8 @@ import mlx.core as mx
from mflux.models.common.config.config import Config
from PIL import Image
from exo.api.types import AdvancedImageParams
from exo.download.download_utils import build_model_path
from exo.shared.types.api import AdvancedImageParams
from exo.shared.types.worker.instances import BoundInstance
from exo.shared.types.worker.shards import CfgShardMetadata, PipelineShardMetadata
from exo.worker.engines.image.config import ImageModelConfig
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Generator, Literal
import mlx.core as mx
from PIL import Image
from exo.shared.types.api import (
from exo.api.types import (
AdvancedImageParams,
ImageEditsTaskParams,
ImageGenerationStats,
@@ -56,10 +56,10 @@ class QwenJointBlockWrapper(JointBlockWrapper[QwenTransformerBlock]):
attn = self.block.attn
img_mod_params = self.block.img_mod_linear(
self.block.img_mod_silu(text_embeddings) # pyright: ignore[reportUnknownArgumentType]
self.block.img_mod_silu(text_embeddings)
)
txt_mod_params = self.block.txt_mod_linear(
self.block.txt_mod_silu(text_embeddings) # pyright: ignore[reportUnknownArgumentType]
self.block.txt_mod_silu(text_embeddings)
)
img_mod1, img_mod2 = mx.split(img_mod_params, 2, axis=-1)
+2 -2
View File
@@ -57,8 +57,8 @@ from mlx_lm.models.step3p5 import Model as Step35Model
from mlx_lm.models.step3p5 import Step3p5MLP as Step35MLP
from mlx_lm.models.step3p5 import Step3p5Model as Step35InnerModel
from exo.shared.logging import logger
from exo.shared.types.worker.shards import PipelineShardMetadata
from exo.worker.runner.bootstrap import logger
if TYPE_CHECKING:
from mlx_lm.models.cache import Cache
@@ -480,7 +480,7 @@ def patch_tensor_model[T](model: T) -> T:
last = cache[-1] # pyright: ignore[reportAny]
dep_cache = last[0] if hasattr(last, "caches") else last # pyright: ignore[reportAny]
if hasattr(dep_cache, "keys"): # type: ignore
dep_cache.keys = mx.depends(dep_cache.keys, logits) # pyright: ignore[reportAny,reportUnknownMemberType]
dep_cache.keys = mx.depends(dep_cache.keys, logits) # pyright: ignore[reportAny]
return logits
+23 -2
View File
@@ -4,7 +4,7 @@ from typing import Any
from mlx_lm.chat_templates import deepseek_v32
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
BOS_TOKEN: str = deepseek_v32.bos_token
EOS_TOKEN: str = deepseek_v32.eos_token
@@ -15,7 +15,28 @@ USER_TOKEN = "<\uff5cUser\uff5c>"
ASSISTANT_TOKEN = "<\uff5cAssistant\uff5c>"
TOOL_CALLS_START = f"<{DSML_TOKEN}function_calls>"
TOOL_CALLS_END = f"</{DSML_TOKEN}function_calls>"
encode_messages = deepseek_v32.encode_messages
_ORPHAN_THINK_END = ASSISTANT_TOKEN + THINKING_END
_FIXED_THINK_BLOCK = ASSISTANT_TOKEN + THINKING_START + "\n" + THINKING_END
def encode_messages(
messages: list[dict[str, Any]],
thinking_mode: str = "thinking",
context: list[dict[str, Any]] | None = None,
drop_thinking: bool = True,
add_default_bos_token: bool = True,
tools: Any = None, # pyright: ignore[reportAny]
) -> str:
prompt: str = deepseek_v32.encode_messages(
messages,
thinking_mode=thinking_mode,
context=context,
drop_thinking=drop_thinking,
add_default_bos_token=add_default_bos_token,
tools=tools,
)
return prompt.replace(_ORPHAN_THINK_END, _FIXED_THINK_BLOCK)
_INVOKE_PATTERN = re.compile(
rf"<{re.escape(DSML_TOKEN)}invoke\s+name=\"([^\"]+)\">"
@@ -6,11 +6,14 @@ import mlx.core as mx
from mlx_lm.generate import (
BatchGenerator as MlxBatchGenerator,
)
from mlx_lm.generate import (
generation_stream,
)
from mlx_lm.models.cache import RotatingKVCache
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.tokenizer_utils import StreamingDetokenizer, TokenizerWrapper
from exo.shared.types.api import (
from exo.api.types import (
CompletionTokensDetails,
FinishReason,
GenerationStats,
@@ -63,6 +66,7 @@ class _EngineTask:
potential_stop_sequence_text: str = ""
completion_tokens: int = 0
generation_start_time: float = 0.0
generation_time_at_start: float = 0.0
in_thinking: bool = False
reasoning_tokens: int = 0
prefill_tps: float = 0.0
@@ -75,22 +79,23 @@ class ExoBatchGenerator:
group: mx.distributed.Group | None
kv_prefix_cache: KVPrefixCache | None
_exo_gen: MlxBatchGenerator = field(init=False)
_mlx_gen: MlxBatchGenerator = field(init=False)
_active_tasks: dict[int, _EngineTask] = field(default_factory=dict, init=False)
def __post_init__(self) -> None:
self._exo_gen = MlxBatchGenerator(
self._mlx_gen = MlxBatchGenerator(
model=self.model,
stop_tokens=set(eos_ids_from_tokenizer(self.tokenizer)),
prefill_step_size=4096,
)
self._mlx_gen._needs_topk = False # pyright: ignore[reportAttributeAccessIssue]
@property
def has_work(self) -> bool:
return (
bool(self._active_tasks)
or bool(self._exo_gen.unprocessed_prompts)
or self._exo_gen.active_batch is not None
or bool(self._mlx_gen.unprocessed_prompts)
or self._mlx_gen.active_batch is not None
)
def submit(
@@ -188,7 +193,7 @@ class ExoBatchGenerator:
max_tokens = task_params.max_output_tokens or MAX_TOKENS
uids = self._exo_gen.insert(
uids = self._mlx_gen.insert(
prompts=[last_tokens.tolist()],
max_tokens=[max_tokens],
caches=[list(cache)],
@@ -211,6 +216,7 @@ class ExoBatchGenerator:
on_generation_token=on_generation_token,
generation_start_time=time.perf_counter(),
prefill_tps=_prefill_tps,
generation_time_at_start=self._mlx_gen._stats.generation_time,
)
return uid
@@ -219,7 +225,12 @@ class ExoBatchGenerator:
if not self.has_work:
return []
responses = self._exo_gen.next()
self._mlx_gen._needs_topk = any( # pyright: ignore[reportAttributeAccessIssue]
t.task_params.logprobs for t in self._active_tasks.values()
)
_step_tic = time.perf_counter()
responses = self._mlx_gen.next()
_next_elapsed = time.perf_counter() - _step_tic
results: list[tuple[int, GenerationResponse]] = []
@@ -277,28 +288,31 @@ class ExoBatchGenerator:
logprob: float | None = None
top_logprobs: list[TopLogprobItem] | None = None
if task_params.logprobs:
logprob, top_logprobs = extract_top_logprobs(
logprobs=response.logprobs,
tokenizer=self.tokenizer,
top_logprobs=task_params.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=response.token,
)
with mx.stream(generation_stream):
logprob, top_logprobs = extract_top_logprobs(
logprobs=response.logprobs,
tokenizer=self.tokenizer,
top_logprobs=task_params.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=response.token,
precomputed_indices=getattr(response, "_topk_indices", None),
precomputed_values=getattr(response, "_topk_values", None),
precomputed_selected=getattr(
response, "_selected_logprob", None
),
)
stats: GenerationStats | None = None
usage: Usage | None = None
if is_done:
try:
mlx_stats = self._exo_gen.stats()
generation_tps = mlx_stats.generation_tps
except ZeroDivisionError:
generation_elapsed = (
time.perf_counter() - state.generation_start_time
)
generation_tps = (
state.completion_tokens / generation_elapsed
if generation_elapsed > 0
else 0.0
)
gen_time_delta = (
self._mlx_gen._stats.generation_time
- state.generation_time_at_start
)
generation_tps = (
state.completion_tokens / gen_time_delta
if gen_time_delta > 0
else 0.0
)
stats = GenerationStats(
prompt_tps=state.prefill_tps,
@@ -345,15 +359,22 @@ class ExoBatchGenerator:
-max_stop_len:
]
_step_elapsed = time.perf_counter() - _step_tic
_overhead = _step_elapsed - _next_elapsed
if self._mlx_gen._next_count % 64 == 0 and responses:
logger.debug(
f"step overhead: {_overhead * 1000:.2f}ms (next={_next_elapsed * 1000:.2f}ms total={_step_elapsed * 1000:.2f}ms)"
)
return results
def cancel(self, uids: list[int]) -> None:
self._exo_gen.remove(uids)
self._mlx_gen.remove(uids)
for uid in uids:
self._active_tasks.pop(uid, None)
def close(self) -> None:
self._exo_gen.close()
self._mlx_gen.close()
def _save_prefix_cache(
self,
@@ -372,9 +393,8 @@ class ExoBatchGenerator:
if len(all_prompt_tokens) > 0
else 0.0
)
if (
matched_index is not None
and hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
if matched_index is not None and (
prefix_hit_length > 1000 or hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
):
self.kv_prefix_cache.update_kv_cache(
matched_index,
@@ -13,7 +13,7 @@ from mlx_lm.models.cache import ArraysCache, RotatingKVCache
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.tokenizer_utils import TokenizerWrapper
from exo.shared.types.api import (
from exo.api.types import (
CompletionTokensDetails,
FinishReason,
GenerationStats,
@@ -179,7 +179,8 @@ def pipeline_parallel_prefill(
flush_prefill_sends()
assert _prompt_cache is not None
mx.eval([c.state for c in _prompt_cache]) # type: ignore
with mx.stream(generation_stream):
mx.eval([c.state for c in _prompt_cache]) # type: ignore
# Final callback matching generate_step
prompt_progress_callback(total, total)
@@ -312,52 +313,46 @@ def warmup_inference(
model_id: ModelId,
) -> int:
logger.info(f"warming up inference for instance: {model_id}")
t = time.monotonic()
content = "Prompt to warm up the inference engine. Repeat this."
warmup_task_params = TextGenerationTaskParams(
model=model_id,
input=[InputMessage(role="user", content=content)],
max_output_tokens=50,
temperature=0.0,
)
warmup_prompt = apply_chat_template(
tokenizer=tokenizer,
task_params=TextGenerationTaskParams(
model=ModelId(""),
input=[InputMessage(role="user", content=content)],
),
task_params=warmup_task_params,
)
tokens_generated = 0
cache = make_kv_cache(
model=model,
)
# Use a default sampler for warmup
sampler = make_sampler(temp=0.0)
mx_barrier(group)
logger.info("Generating warmup tokens")
for _r in stream_generate(
t = time.monotonic()
for _r in mlx_generate(
model=model,
tokenizer=tokenizer,
task=warmup_task_params,
prompt=warmup_prompt,
max_tokens=50,
sampler=sampler,
prompt_cache=cache,
prefill_step_size=2048,
kv_group_size=KV_GROUP_SIZE,
kv_bits=KV_BITS,
kv_prefix_cache=None,
group=group,
):
logger.info("Generated warmup token: " + str(_r.text))
tokens_generated += 1
logger.info("Generated ALL warmup tokens")
check_for_cancel_every = min(
math.ceil(tokens_generated / min(time.monotonic() - t, 0.001)), 100
)
mx_barrier(group)
logger.info(f"warmed up by generating {tokens_generated} tokens")
check_for_cancel_every = min(
math.ceil(tokens_generated / min(time.monotonic() - t, 0.001)), 100
)
if group is not None:
check_for_cancel_every = int(
mx.max(
@@ -398,52 +393,44 @@ def extract_top_logprobs(
tokenizer: TokenizerWrapper,
top_logprobs: int,
selected_token: int,
precomputed_indices: list[int] | None = None,
precomputed_values: list[float] | None = None,
precomputed_selected: float | None = None,
) -> tuple[float, list[TopLogprobItem]]:
"""Extract the selected token's logprob and top alternative tokens.
Args:
logprobs: Full vocabulary logprobs array from MLX
tokenizer: Tokenizer for decoding token IDs to strings
top_logprobs: Number of top alternatives to return
selected_token: The token ID that was actually sampled
Returns:
Tuple of (selected_token_logprob, list of TopLogprobItem for top alternatives)
"""
# Get the logprob of the selected token
selected_logprob = float(logprobs[selected_token].item())
# Get top indices (most probable tokens)
# mx.argpartition gives indices that would partition the array
# We negate logprobs since argpartition finds smallest, and we want largest
top_logprobs = min(top_logprobs, logprobs.shape[0]) # Don't exceed vocab size
top_indices = mx.argpartition(-logprobs, top_logprobs)[:top_logprobs]
# Get the actual logprob values for these indices
top_values = logprobs[top_indices]
# Sort by logprob (descending) for consistent ordering
sort_order = mx.argsort(-top_values)
top_indices = top_indices[sort_order]
top_values = top_values[sort_order]
if (
precomputed_indices is not None
and precomputed_values is not None
and precomputed_selected is not None
):
top_indices_list: list[int] = precomputed_indices[:top_logprobs]
top_values_list: list[float] = precomputed_values[:top_logprobs]
selected_logprob = precomputed_selected
else:
selected_logprob_arr = logprobs[selected_token]
top_logprobs = min(top_logprobs, logprobs.shape[0] - 1)
top_indices = mx.argpartition(-logprobs, top_logprobs)[:top_logprobs]
top_values = logprobs[top_indices]
sort_order = mx.argsort(-top_values)
top_indices = top_indices[sort_order]
top_values = top_values[sort_order]
mx.eval(selected_logprob_arr, top_indices, top_values)
selected_logprob = float(selected_logprob_arr.item())
top_indices_list = top_indices.tolist() # type: ignore
top_values_list = top_values.tolist() # type: ignore
# Convert to list of TopLogprobItem
top_logprob_items: list[TopLogprobItem] = []
for i in range(top_logprobs):
token_id = int(top_indices[i].item())
token_logprob = float(top_values[i].item())
for token_id, token_logprob in zip(top_indices_list, top_values_list, strict=True):
if math.isnan(token_logprob):
continue
# Decode token ID to string
token_str = tokenizer.decode([token_id])
# Get byte representation
token_bytes = list(token_str.encode("utf-8"))
top_logprob_items.append(
TopLogprobItem(
token=token_str,
logprob=token_logprob,
bytes=token_bytes,
bytes=list(token_str.encode("utf-8")),
)
)
@@ -624,12 +611,13 @@ def mlx_generate(
logprob: float | None = None
top_logprobs: list[TopLogprobItem] | None = None
if task.logprobs:
logprob, top_logprobs = extract_top_logprobs(
logprobs=out.logprobs,
tokenizer=tokenizer,
top_logprobs=task.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=out.token,
)
with mx.stream(generation_stream):
logprob, top_logprobs = extract_top_logprobs(
logprobs=out.logprobs,
tokenizer=tokenizer,
top_logprobs=task.top_logprobs or DEFAULT_TOP_LOGPROBS,
selected_token=out.token,
)
if is_done:
# Log generation stats
@@ -657,9 +645,9 @@ def mlx_generate(
if len(all_prompt_tokens) > 0
else 0.0
)
if (
matched_index is not None
and hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
if matched_index is not None and (
prefix_hit_length > 1000
or hit_ratio >= _MIN_PREFIX_HIT_RATIO_TO_UPDATE
):
kv_prefix_cache.update_kv_cache(
matched_index,
@@ -0,0 +1,14 @@
from exo.worker.engines.mlx.patches.opt_batch_gen import apply_batch_gen_patch
from exo.worker.engines.mlx.patches.standard_yarn_rope import patch_yarn_rope
_applied = False
def apply_mlx_patches() -> None:
global _applied
if _applied:
return
_applied = True
patch_yarn_rope()
# patch_gdn_softplus()
apply_batch_gen_patch()
@@ -0,0 +1,173 @@
import time
from typing import Any, cast
import mlx.core as mx
from mlx_lm.generate import BatchGenerator, generation_stream
_PRECOMPUTE_TOP_K = 20
_original_public_next = BatchGenerator.next
_pending_topk_idx: mx.array | None = None
_pending_topk_val: mx.array | None = None
_pending_selected_lps: mx.array | None = None
def _fast_next(self: BatchGenerator) -> list[BatchGenerator.Response]:
tic = time.perf_counter()
batch = self.active_batch
assert batch is not None
batch_size = len(batch)
prev_tokens = batch.y
prev_logprobs = batch.logprobs
has_processors = any(p for ps in batch.logits_processors for p in ps)
if has_processors:
for i, toks in enumerate(batch.tokens):
batch.tokens[i] = mx.concatenate([toks, prev_tokens[i : i + 1]])
logits = self.model(prev_tokens[:, None], cache=batch.cache)
logits = logits[:, -1, :]
if has_processors:
processed_logits: list[mx.array] = []
for e in range(batch_size):
sample_logits: mx.array = logits[e : e + 1]
for processor in batch.logits_processors[e]:
sample_logits = processor(batch.tokens[e], sample_logits)
processed_logits.append(sample_logits)
logits = mx.concatenate(processed_logits, axis=0)
logprobs = logits - mx.logsumexp(logits, axis=-1, keepdims=True)
if (
batch_size == 1
or any(batch.samplers)
and all(s is batch.samplers[0] for s in batch.samplers)
):
sampler = batch.samplers[0] or self.sampler
batch.y = sampler(logprobs)
elif any(batch.samplers):
all_samples: list[mx.array] = []
for e in range(batch_size):
s = batch.samplers[e] or self.sampler
all_samples.append(s(logprobs[e : e + 1]))
batch.y = mx.concatenate(all_samples, axis=0)
else:
batch.y = self.sampler(logprobs)
batch.logprobs = list(logprobs)
global _pending_topk_idx, _pending_topk_val, _pending_selected_lps
emit_topk_indices: list[list[int]] = (
cast(list[list[int]], _pending_topk_idx.tolist())
if _pending_topk_idx is not None
else []
)
emit_topk_values: list[list[float]] = (
cast(list[list[float]], _pending_topk_val.tolist())
if _pending_topk_val is not None
else []
)
emit_selected_lps: list[float] = (
cast(list[float], _pending_selected_lps.tolist())
if _pending_selected_lps is not None
else []
)
needs_topk: bool = getattr(self, "_needs_topk", False)
if needs_topk:
k = min(_PRECOMPUTE_TOP_K, logprobs.shape[1])
_pending_topk_idx = mx.argpartition(-logprobs, k, axis=1)[:, :k]
_pending_topk_val = mx.take_along_axis(logprobs, _pending_topk_idx, axis=1)
sort_order = mx.argsort(-_pending_topk_val, axis=1)
_pending_topk_idx = mx.take_along_axis(_pending_topk_idx, sort_order, axis=1)
_pending_topk_val = mx.take_along_axis(_pending_topk_val, sort_order, axis=1)
_pending_selected_lps = logprobs[mx.arange(batch_size), batch.y]
mx.async_eval(
batch.y,
*batch.logprobs,
*batch.tokens,
_pending_topk_idx,
_pending_topk_val,
_pending_selected_lps,
)
else:
_pending_topk_idx = None
_pending_topk_val = None
_pending_selected_lps = None
mx.async_eval(batch.y, *batch.logprobs, *batch.tokens)
prev_token_list: list[int] = cast(list[int], prev_tokens.tolist())
toc = time.perf_counter()
self._stats.generation_time += toc - tic
keep_idx: list[int] = []
end_idx: list[int] = []
responses: list[Any] = []
stop_tokens = self.stop_tokens
for e in range(batch_size):
t = prev_token_list[e]
uid = batch.uids[e]
num_tok = batch.num_tokens[e] + 1
batch.num_tokens[e] = num_tok
if t in stop_tokens:
finish_reason = "stop"
end_idx.append(e)
elif num_tok >= batch.max_tokens[e]:
finish_reason = "length"
end_idx.append(e)
else:
finish_reason = None
keep_idx.append(e)
cache = None
if finish_reason is not None:
cache = batch.extract_cache(e)
response = self.Response(uid, t, prev_logprobs[e], finish_reason, cache)
if emit_topk_indices and e < len(emit_topk_indices):
response._topk_indices = emit_topk_indices[e] # pyright: ignore[reportAttributeAccessIssue]
response._topk_values = emit_topk_values[e] # pyright: ignore[reportAttributeAccessIssue]
response._selected_logprob = emit_selected_lps[e] # pyright: ignore[reportAttributeAccessIssue]
responses.append(response)
if end_idx:
if keep_idx:
batch.filter(keep_idx)
if (
_pending_topk_idx is not None
and _pending_topk_val is not None
and _pending_selected_lps is not None
):
ki = mx.array(keep_idx)
_pending_topk_idx = _pending_topk_idx[ki]
_pending_topk_val = _pending_topk_val[ki]
_pending_selected_lps = _pending_selected_lps[ki]
else:
self.active_batch = None
_pending_topk_idx = None
_pending_topk_val = None
_pending_selected_lps = None
self._next_count += 1
if self._next_count % 512 == 0:
mx.clear_cache()
self._stats.generation_tokens += len(responses)
return responses
def _patched_public_next(self: BatchGenerator) -> list[BatchGenerator.Response]:
batch = self.active_batch
# Only do decode with fast_next
if batch is not None and not self.unprocessed_prompts:
with mx.stream(generation_stream):
return _fast_next(self)
return _original_public_next(self)
def apply_batch_gen_patch() -> None:
BatchGenerator.next = _patched_public_next
@@ -0,0 +1,118 @@
import math
import mlx.core as mx
from mlx_lm.models import rope_utils
_original_YarnRoPE_init = rope_utils.YarnRoPE.__init__ # noqa: N816
_original_initialize_rope = rope_utils.initialize_rope
def _patched_yarn_init(
self: rope_utils.YarnRoPE,
dims: int,
traditional: bool = False,
max_position_embeddings: int = 2048,
base: float = 10000,
scaling_factor: float = 1.0,
original_max_position_embeddings: int = 4096,
beta_fast: float = 32,
beta_slow: float = 1,
mscale: float = 1,
mscale_all_dim: float = 0,
truncate: bool = True,
) -> None:
"""Patch mlx_lm's YarnRoPE to match vLLM's inverse-frequency blending formula for compatability."""
super(rope_utils.YarnRoPE, self).__init__()
def yarn_find_correction_dim(num_rotations: float) -> float:
return (
dims
* math.log(original_max_position_embeddings / (num_rotations * 2 * math.pi))
) / (2 * math.log(base))
def yarn_find_correction_range() -> tuple[float, float]:
low: float = yarn_find_correction_dim(beta_fast)
high: float = yarn_find_correction_dim(beta_slow)
if truncate:
low = math.floor(low)
high = math.ceil(high)
return max(low, 0), min(high, dims - 1)
def yarn_get_mscale(scale: float = 1, ms: float = 1) -> float:
if scale <= 1:
return 1.0
return 0.1 * ms * math.log(scale) + 1.0
def yarn_linear_ramp_mask(min_val: float, max_val: float, dim: int) -> mx.array:
if min_val == max_val:
max_val += 0.001
linear_func = (mx.arange(dim, dtype=mx.float32) - min_val) / (max_val - min_val)
return mx.clip(linear_func, 0, 1)
self.mscale = yarn_get_mscale(scaling_factor, mscale) / yarn_get_mscale(
scaling_factor, mscale_all_dim
)
pos_freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
inv_freq_extrapolation = 1.0 / pos_freqs
inv_freq_interpolation = 1.0 / (scaling_factor * pos_freqs)
low, high = yarn_find_correction_range()
inv_freq_mask = 1.0 - yarn_linear_ramp_mask(low, high, dims // 2)
inv_freq = (
inv_freq_interpolation * (1 - inv_freq_mask)
+ inv_freq_extrapolation * inv_freq_mask
)
self._freqs = 1.0 / inv_freq
self.dims = dims
self.traditional = traditional
def _patched_initialize_rope(
dims: int,
base: float,
traditional: bool,
scaling_config: dict[str, str | int | float | bool] | None = None,
max_position_embeddings: int | None = None,
) -> object:
rope_type = "default"
if scaling_config is not None:
rope_type = str(
scaling_config.get("type") or scaling_config.get("rope_type", "default")
)
# All the yarn rope types supported in mlx lm
if rope_type in ("yarn", "deepseek_yarn"):
assert scaling_config is not None
cfg = scaling_config
def _float(key: str, default: float) -> float:
v = cfg.get(key)
return float(v) if v is not None else default
def _int(key: str, default: int) -> int:
v = cfg.get(key)
return int(v) if v is not None else default
return rope_utils.YarnRoPE(
dims=dims,
max_position_embeddings=max_position_embeddings or 2048,
traditional=traditional,
scaling_factor=_float("factor", 1.0),
base=base,
original_max_position_embeddings=_int(
"original_max_position_embeddings", 4096
),
beta_fast=_float("beta_fast", 32),
beta_slow=_float("beta_slow", 1),
mscale=_float("mscale", 1),
mscale_all_dim=_float("mscale_all_dim", 0),
)
return _original_initialize_rope(
dims, base, traditional, scaling_config, max_position_embeddings
)
def patch_yarn_rope() -> None:
rope_utils.YarnRoPE.__init__ = _patched_yarn_init
rope_utils.initialize_rope = _patched_initialize_rope
@@ -0,0 +1,290 @@
# type: ignore
import math
from unittest.mock import MagicMock
import mlx.core as mx
import mlx.nn as nn
import pytest
from mlx_lm.generate import BatchGenerator
from exo.worker.engines.mlx.generator.generate import extract_top_logprobs
from exo.worker.engines.mlx.patches.opt_batch_gen import (
_PRECOMPUTE_TOP_K,
apply_batch_gen_patch,
)
def _mock_tokenizer() -> MagicMock:
tok = MagicMock()
tok.decode = lambda ids: f"tok_{ids[0]}"
return tok
def _make_logprobs(values: list[float]) -> mx.array:
arr = mx.array(values, dtype=mx.float32)
mx.eval(arr)
return arr
class TestExtractTopLogprobsFallback:
def test_returns_correct_selected_logprob(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -0.5, -3.0, -4.0])
selected, _ = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=3, selected_token=2
)
assert selected == pytest.approx(-0.5)
def test_returns_top_k_sorted_descending(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -0.5, -3.0, -4.0])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=3, selected_token=0
)
logprob_values = [item.logprob for item in items]
assert logprob_values == sorted(logprob_values, reverse=True)
assert len(items) == 3
def test_top_tokens_are_most_probable(self) -> None:
lp = _make_logprobs([-5.0, -1.0, -3.0, -0.1, -2.0])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=2, selected_token=0
)
token_ids = [int(item.token.split("_")[1]) for item in items]
assert 3 in token_ids
assert 1 in token_ids
def test_top_logprobs_clamped_to_vocab_size(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -3.0, -4.0, -5.0])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=10, selected_token=0
)
assert len(items) == 4
def test_nan_logprobs_filtered(self) -> None:
lp = _make_logprobs([-1.0, float("nan"), -0.5])
_, items = extract_top_logprobs(
lp, _mock_tokenizer(), top_logprobs=3, selected_token=0
)
for item in items:
assert not math.isnan(item.logprob)
def test_token_bytes_correct(self) -> None:
tok = MagicMock()
tok.decode = lambda ids: "hello"
lp = _make_logprobs([-1.0, -2.0])
_, items = extract_top_logprobs(lp, tok, top_logprobs=2, selected_token=0)
assert items[0].bytes == list("hello".encode("utf-8"))
class TestExtractTopLogprobsPrecomputed:
def test_uses_precomputed_data(self) -> None:
lp = _make_logprobs([-99.0])
selected, items = extract_top_logprobs(
lp,
_mock_tokenizer(),
top_logprobs=2,
selected_token=0,
precomputed_indices=[3, 1, 0],
precomputed_values=[-0.1, -1.0, -5.0],
precomputed_selected=-0.1,
)
assert selected == pytest.approx(-0.1)
assert len(items) == 2
assert items[0].token == "tok_3"
assert items[0].logprob == pytest.approx(-0.1)
assert items[1].token == "tok_1"
assert items[1].logprob == pytest.approx(-1.0)
def test_slices_precomputed_to_requested_k(self) -> None:
lp = _make_logprobs([-99.0])
_, items = extract_top_logprobs(
lp,
_mock_tokenizer(),
top_logprobs=1,
selected_token=0,
precomputed_indices=[3, 1, 0, 2, 4],
precomputed_values=[-0.1, -1.0, -2.0, -3.0, -4.0],
precomputed_selected=-0.1,
)
assert len(items) == 1
assert items[0].token == "tok_3"
def test_falls_back_when_precomputed_partial(self) -> None:
lp = _make_logprobs([-1.0, -2.0, -0.5])
selected, items = extract_top_logprobs(
lp,
_mock_tokenizer(),
top_logprobs=2,
selected_token=2,
precomputed_indices=[0, 2],
precomputed_values=None,
precomputed_selected=None,
)
assert selected == pytest.approx(-0.5)
assert len(items) == 2
def test_precomputed_matches_fallback(self) -> None:
lp = _make_logprobs([-1.0, -0.3, -2.5, -0.1, -4.0, -0.8, -3.0, -1.5])
tok = _mock_tokenizer()
selected_fb, items_fb = extract_top_logprobs(
lp, tok, top_logprobs=5, selected_token=1
)
pre_indices = [item.token.split("_")[1] for item in items_fb]
pre_indices_int = [int(x) for x in pre_indices]
pre_values = [item.logprob for item in items_fb]
selected_pc, items_pc = extract_top_logprobs(
lp,
tok,
top_logprobs=5,
selected_token=1,
precomputed_indices=pre_indices_int,
precomputed_values=pre_values,
precomputed_selected=selected_fb,
)
assert selected_pc == pytest.approx(selected_fb)
assert len(items_pc) == len(items_fb)
for a, b in zip(items_pc, items_fb, strict=True):
assert a.token == b.token
assert a.logprob == pytest.approx(b.logprob)
def _tiny_model() -> nn.Module:
from mlx_lm.models.llama import Model, ModelArgs
mx.random.seed(42)
args = ModelArgs(
model_type="llama",
hidden_size=64,
num_hidden_layers=2,
intermediate_size=128,
num_attention_heads=2,
num_key_value_heads=1,
rms_norm_eps=1e-6,
vocab_size=256,
rope_theta=10000.0,
tie_word_embeddings=True,
)
model = Model(args)
mx.eval(model.parameters())
return model
@pytest.mark.slow
class TestBatchedTopKPrecompute:
@pytest.fixture(autouse=True)
def _reset_globals(self) -> None:
import exo.worker.engines.mlx.patches.opt_batch_gen as _mod
_mod._pending_topk_idx = None
_mod._pending_topk_val = None
_mod._pending_selected_lps = None
def _run_generator(
self, model: nn.Module, prompts: list[list[int]], steps: int, needs_topk: bool
) -> list[list[BatchGenerator.Response]]:
apply_batch_gen_patch()
gen = BatchGenerator(model=model, stop_tokens=set(), prefill_step_size=512)
gen._needs_topk = needs_topk
gen.insert(prompts)
all_responses: list[list[BatchGenerator.Response]] = []
for _ in range(steps + len(prompts)):
responses = gen.next()
if responses:
all_responses.append(responses)
if gen.active_batch is None and not gen.unprocessed_prompts:
break
gen.close()
return all_responses
def test_precomputed_topk_attached_to_responses(self) -> None:
model = _tiny_model()
steps = self._run_generator(model, [[1, 2, 3]], 5, needs_topk=True)
found_precomputed = False
for step_responses in steps:
for resp in step_responses:
if hasattr(resp, "_topk_indices"):
found_precomputed = True
assert hasattr(resp, "_topk_values"), (
"Response missing _topk_values"
)
assert hasattr(resp, "_selected_logprob"), (
"Response missing _selected_logprob"
)
assert len(resp._topk_indices) == _PRECOMPUTE_TOP_K
assert len(resp._topk_values) == _PRECOMPUTE_TOP_K
assert found_precomputed, "No responses had precomputed topk"
def test_no_topk_when_not_needed(self) -> None:
model = _tiny_model()
steps = self._run_generator(model, [[1, 2, 3]], 5, needs_topk=False)
for step_responses in steps:
for resp in step_responses:
assert not hasattr(resp, "_topk_indices")
def test_precomputed_matches_fallback_in_batch(self) -> None:
model = _tiny_model()
tok = _mock_tokenizer()
steps = self._run_generator(model, [[1, 2, 3]], 10, needs_topk=True)
for step_responses in steps[1:]:
for resp in step_responses:
if not hasattr(resp, "_topk_indices"):
continue
selected_fb, items_fb = extract_top_logprobs(
resp.logprobs, tok, top_logprobs=5, selected_token=resp.token
)
selected_pc, items_pc = extract_top_logprobs(
resp.logprobs,
tok,
top_logprobs=5,
selected_token=resp.token,
precomputed_indices=resp._topk_indices,
precomputed_values=resp._topk_values,
precomputed_selected=resp._selected_logprob,
)
assert selected_pc == pytest.approx(selected_fb, abs=1e-5)
for a, b in zip(items_pc, items_fb, strict=True):
assert a.token == b.token
assert a.logprob == pytest.approx(b.logprob, abs=1e-5)
def test_topk_correct_after_batch_shrink(self) -> None:
model = _tiny_model()
tok = _mock_tokenizer()
apply_batch_gen_patch()
gen = BatchGenerator(
model=model, stop_tokens={0}, prefill_step_size=512, max_tokens=3
)
gen._needs_topk = True
gen.insert([[1, 2, 3], [4, 5, 6]], max_tokens=[3, 20])
seen_shrink = False
for _ in range(30):
responses = gen.next()
for resp in responses:
if resp.finish_reason is not None:
seen_shrink = True
continue
if not hasattr(resp, "_topk_indices"):
continue
selected_fb, items_fb = extract_top_logprobs(
resp.logprobs, tok, top_logprobs=5, selected_token=resp.token
)
selected_pc, _ = extract_top_logprobs(
resp.logprobs,
tok,
top_logprobs=5,
selected_token=resp.token,
precomputed_indices=resp._topk_indices,
precomputed_values=resp._topk_values,
precomputed_selected=resp._selected_logprob,
)
assert selected_pc == pytest.approx(selected_fb, abs=1e-5), (
f"Mismatch after batch shrink: precomputed={selected_pc}, fallback={selected_fb}"
)
if gen.active_batch is None and not gen.unprocessed_prompts:
break
gen.close()
assert seen_shrink, "Expected at least one request to finish (batch shrink)"
+11 -18
View File
@@ -486,16 +486,7 @@ def _patch_lossy_chat_template(template: str) -> str | None:
def _needs_dsml_encoding(task_params: TextGenerationTaskParams) -> bool:
if "deepseek-v3.2" not in task_params.model.lower():
return False
# Use DSML encoding when tools are provided or tool results are in the conversation
if task_params.tools:
return True
if task_params.chat_template_messages:
return any(
msg.get("role") == "tool" for msg in task_params.chat_template_messages
)
return False
return "deepseek-v3.2" in task_params.model.lower()
def apply_chat_template(
@@ -514,8 +505,6 @@ def apply_chat_template(
if task_params.chat_template_messages is not None:
# Use pre-formatted messages that preserve tool_calls, thinking, etc.
formatted_messages = list(task_params.chat_template_messages)
for msg in formatted_messages:
_normalize_tool_calls(msg)
else:
# Add system message (instructions) if present
if task_params.instructions:
@@ -541,7 +530,10 @@ def apply_chat_template(
prompt = encode_messages(
messages=formatted_messages,
thinking_mode="thinking" if task_params.enable_thinking else "chat",
# Only use chat mode if enable thinking is explicitly Fakse.
thinking_mode="chat"
if task_params.enable_thinking is False
else "thinking",
tools=task_params.tools,
)
if partial_assistant_content:
@@ -549,6 +541,9 @@ def apply_chat_template(
logger.info(prompt)
return prompt
for msg in formatted_messages:
_normalize_tool_calls(msg)
extra_kwargs: dict[str, Any] = {}
if task_params.enable_thinking is not None:
# Qwen3 and GLM use "enable_thinking"; DeepSeek uses "thinking".
@@ -638,6 +633,7 @@ class NullKVCache(KVCache):
@property
def state(self) -> tuple[mx.array, mx.array]:
# matches what mx.save_safetensors / mx.eval expect
assert self.keys is not None and self.values is not None
return self.keys, self.values
@state.setter
@@ -739,12 +735,9 @@ def _parse_kimi_tool_calls(text: str):
if func_args_match is None:
raise ValueError("No tool call arguments found.")
func_args = func_args_match.group(1)
try:
arg_dct = json.loads(func_args) # pyright: ignore[reportAny]
except Exception:
arg_dct = None
arg_dct = json.loads(func_args) # pyright: ignore[reportAny]
return dict(id=tool_call_id, name=func_name, arguments=arg_dct)
return dict(id=tool_call_id, name=func_name, arguments=arg_dct) # pyright: ignore[reportAny]
tool_matches = _tool_call_split_regex.findall(text)
if tool_matches:
+11 -2
View File
@@ -5,10 +5,10 @@ import anyio
from anyio import fail_after
from loguru import logger
from exo.api.types import ImageEditsTaskParams
from exo.download.download_utils import resolve_model_in_path
from exo.shared.apply import apply
from exo.shared.models.model_cards import ModelId
from exo.shared.types.api import ImageEditsTaskParams
from exo.shared.models.model_cards import ModelId, add_to_card_cache, delete_custom_card
from exo.shared.types.commands import (
ForwarderCommand,
ForwarderDownloadCommand,
@@ -16,6 +16,8 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, NodeId, SystemId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
IndexedEvent,
InputChunkReceived,
@@ -130,6 +132,13 @@ class Worker:
event.chunk.data
)
if isinstance(event, CustomModelCardAdded):
await event.model_card.save_to_custom_dir()
add_to_card_cache(event.model_card)
if isinstance(event, CustomModelCardDeleted):
await delete_custom_card(event.model_id)
async def plan_step(self):
while True:
await anyio.sleep(0.1)
+3 -14
View File
@@ -55,7 +55,7 @@ def plan(
# Python short circuiting OR logic should evaluate these sequentially.
return (
_cancel_tasks(runners, tasks)
or _kill_runner(runners, all_runners, instances)
or _kill_runner(runners, instances)
or _create_runner(node_id, runners, instances)
or _model_needs_download(node_id, runners, global_download_status)
or _init_distributed_backend(runners, all_runners)
@@ -67,25 +67,14 @@ def plan(
def _kill_runner(
runners: Mapping[RunnerId, RunnerSupervisor],
all_runners: Mapping[RunnerId, RunnerStatus],
instances: Mapping[InstanceId, Instance],
) -> Shutdown | None:
for runner in runners.values():
runner_id = runner.bound_instance.bound_runner_id
if (instance_id := runner.bound_instance.instance.instance_id) not in instances:
return Shutdown(instance_id=instance_id, runner_id=runner_id)
for (
global_runner_id
) in runner.bound_instance.instance.shard_assignments.node_to_runner.values():
if runner_id == global_runner_id:
continue
if isinstance(all_runners.get(global_runner_id, None), RunnerFailed):
return Shutdown(
instance_id=instance_id,
runner_id=runner_id,
)
if isinstance(runner.status, RunnerFailed):
return Shutdown(instance_id=instance_id, runner_id=runner_id)
def _create_runner(
+3
View File
@@ -8,6 +8,7 @@ from exo.shared.types.tasks import Task, TaskId
from exo.shared.types.worker.instances import BoundInstance
from exo.shared.types.worker.runners import RunnerFailed
from exo.utils.channels import ClosedResourceError, MpReceiver, MpSender
from exo.worker.engines.mlx.patches import apply_mlx_patches
logger: "loguru.Logger" = loguru.logger
@@ -45,6 +46,8 @@ def entrypoint(
else:
from exo.worker.runner.llm_inference.runner import Runner
apply_mlx_patches()
runner = Runner(
bound_instance, event_sender, task_receiver, cancel_receiver
)
+2 -5
View File
@@ -4,10 +4,10 @@ from typing import TYPE_CHECKING, Literal
import mlx.core as mx
from exo.api.types import ImageGenerationStats
from exo.shared.constants import EXO_MAX_CHUNK_SIZE, EXO_TRACING_ENABLED
from exo.shared.models.model_cards import ModelTask
from exo.shared.tracing import clear_trace_buffer, get_trace_buffer
from exo.shared.types.api import ImageGenerationStats
from exo.shared.types.chunks import ErrorChunk, ImageChunk
from exo.shared.types.common import CommandId, ModelId
from exo.shared.types.events import (
@@ -39,7 +39,6 @@ from exo.shared.types.worker.runner_response import (
from exo.shared.types.worker.runners import (
RunnerConnected,
RunnerConnecting,
RunnerFailed,
RunnerIdle,
RunnerLoaded,
RunnerLoading,
@@ -256,9 +255,7 @@ class Runner:
def handle_task(self, task: Task):
match task:
case ConnectToGroup() if isinstance(
self.current_status, (RunnerIdle, RunnerFailed)
):
case ConnectToGroup() if isinstance(self.current_status, RunnerIdle):
logger.info("runner connecting")
self.update_status(RunnerConnecting())
self.acknowledge_task(task)
@@ -195,21 +195,29 @@ class SequentialGenerator(InferenceGenerator):
assert self._active is not None
task, mlx_gen, queue, output_generator = self._active
response = None
output: list[
tuple[TaskId, GenerationResponse | ToolCallResponse | Cancelled | Finished]
] = []
try:
queue.push(next(mlx_gen))
response = next(output_generator)
response = next(mlx_gen)
queue.push(response)
# drain potentially many responses every time
while (parsed := next(output_generator, None)) is not None:
output.append((task.task_id, parsed))
except (StopIteration, PrefillCancelled):
response = Finished()
output.append((task.task_id, Finished()))
self._active = None
if self._queue:
self._start_next()
except Exception as e:
self._send_error(task, e)
self._active = None
raise
return itertools.chain(
[] if response is None else [(task.task_id, response)],
output,
map(lambda task: (task, Cancelled()), self._cancelled_tasks),
)
@@ -427,11 +435,11 @@ class BatchGenerator(InferenceGenerator):
task, queue, output_generator = self._active_tasks[uid]
queue.push(response)
parsed = next(output_generator)
if parsed is not None:
# If a generator fails to parse for some reason and returns early, we should not crash
while (parsed := next(output_generator, None)) is not None:
output.append((task.task_id, parsed))
# check if original response was terminal and append a Finished()
if response.finish_reason is not None:
output.append((task.task_id, Finished()))
del self._active_tasks[uid]
@@ -13,7 +13,7 @@ from openai_harmony import ( # pyright: ignore[reportMissingTypeStubs]
load_harmony_encoding,
)
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
from exo.shared.types.common import ModelId
from exo.shared.types.mlx import Model
from exo.shared.types.worker.runner_response import GenerationResponse, ToolCallResponse
@@ -159,11 +159,42 @@ def parse_deepseek_v32(
# Text accumulated during a tool call block
tool_call_text = ""
def _try_parse_tool_call(
text: str, response: GenerationResponse
) -> ToolCallResponse | GenerationResponse:
parsed = parse_dsml_output(text)
if parsed is not None:
return ToolCallResponse(
tool_calls=parsed, usage=response.usage, stats=response.stats
)
logger.warning(f"DSML tool call parsing failed for: {text}")
return response.model_copy(update={"text": text})
for response in responses:
if response is None:
yield None
continue
if response.finish_reason is not None:
yield from pending_buffer
pending_buffer.clear()
if in_tool_call:
tool_call_text += response.text
yield (
_try_parse_tool_call(tool_call_text, response)
if TOOL_CALLS_END in tool_call_text
else response.model_copy(update={"text": tool_call_text})
)
elif TOOL_CALLS_START in response.text and TOOL_CALLS_END in response.text:
dsml_start = response.text.index(TOOL_CALLS_START)
before = response.text[:dsml_start]
if before:
yield response.model_copy(update={"text": before})
yield _try_parse_tool_call(response.text[dsml_start:], response)
else:
yield response
break
# ── Handle thinking tags ──
if not thinking and THINKING_START in response.text:
thinking = True
@@ -191,28 +222,7 @@ def parse_deepseek_v32(
if in_tool_call:
tool_call_text += response.text
if TOOL_CALLS_END in tool_call_text:
# Parse the accumulated DSML block
parsed = parse_dsml_output(tool_call_text)
if parsed is not None:
logger.info(f"parsed DSML tool calls: {parsed}")
yield ToolCallResponse(
tool_calls=parsed,
usage=response.usage,
stats=response.stats,
)
else:
logger.warning(
f"DSML tool call parsing failed for: {tool_call_text}"
)
yield response.model_copy(update={"text": tool_call_text})
in_tool_call = False
tool_call_text = ""
continue
# EOS reached before end marker — yield buffered text as-is
if response.finish_reason is not None:
logger.info("DSML tool call parsing interrupted by EOS")
yield response.model_copy(update={"text": tool_call_text})
yield _try_parse_tool_call(tool_call_text, response)
in_tool_call = False
tool_call_text = ""
continue
@@ -228,33 +238,22 @@ def parse_deepseek_v32(
if pre_text:
# Flush pending buffer tokens that contributed text before the marker
for buf_resp in pending_buffer:
if pre_text:
chunk = buf_resp.text
if len(chunk) <= len(pre_text):
yield buf_resp
pre_text = pre_text[len(chunk) :]
else:
yield buf_resp.model_copy(update={"text": pre_text})
pre_text = ""
if not pre_text:
break
chunk = buf_resp.text
if len(chunk) <= len(pre_text):
yield buf_resp
pre_text = pre_text[len(chunk) :]
else:
yield buf_resp.model_copy(update={"text": pre_text})
pre_text = ""
pending_buffer = []
tool_call_text = accumulated[start_idx:]
accumulated = ""
# Check if the end marker is already present (entire tool call in one token)
if TOOL_CALLS_END in tool_call_text:
parsed = parse_dsml_output(tool_call_text)
if parsed is not None:
logger.info(f"parsed DSML tool calls: {parsed}")
yield ToolCallResponse(
tool_calls=parsed,
usage=response.usage,
stats=response.stats,
)
else:
logger.warning(
f"DSML tool call parsing failed for: {tool_call_text}"
)
yield response.model_copy(update={"text": tool_call_text})
yield _try_parse_tool_call(tool_call_text, response)
tool_call_text = ""
else:
in_tool_call = True
@@ -267,15 +266,13 @@ def parse_deepseek_v32(
continue
# No partial match — flush all pending tokens and the current one
for buf_resp in pending_buffer:
yield buf_resp
pending_buffer = []
yield from pending_buffer
pending_buffer.clear()
accumulated = ""
yield response
# Flush any remaining pending buffer at generator end
for buf_resp in pending_buffer:
yield buf_resp
yield from pending_buffer
def _could_be_dsml_prefix(text: str) -> bool:
@@ -358,8 +355,10 @@ def parse_tool_calls(
if parsed is None:
logger.warning(f"tool call parsing failed for text {combined}")
yield response.model_copy(update={"text": combined})
continue
yield response.model_copy(
update={"text": combined, "token": 0, "finish_reason": "error"}
)
break
yield ToolCallResponse(
tool_calls=parsed, usage=response.usage, stats=response.stats
@@ -374,6 +373,7 @@ def parse_tool_calls(
update={
"text": "".join(tool_call_text_parts),
"token": 0,
"finish_reason": "error",
}
)
yield response
@@ -146,9 +146,7 @@ class Runner:
self.send_task_status(task.task_id, TaskStatus.Running)
match task:
case ConnectToGroup() if isinstance(
self.current_status, (RunnerIdle, RunnerFailed)
):
case ConnectToGroup() if isinstance(self.current_status, RunnerIdle):
assert isinstance(self.generator, Builder)
logger.info("runner connecting")
self.update_status(RunnerConnecting())
@@ -319,7 +317,9 @@ class Runner:
return ExitCode.AllTasksComplete
def send_response(
self, response: GenerationResponse | ToolCallResponse, command_id: CommandId
self,
response: GenerationResponse | ToolCallResponse,
command_id: CommandId,
):
match response:
case GenerationResponse():
@@ -3,7 +3,7 @@ import math
from dataclasses import dataclass
from typing import Any, Callable
from exo.shared.types.api import ToolCallItem
from exo.api.types import ToolCallItem
@dataclass
+39 -37
View File
@@ -24,6 +24,7 @@ from exo.shared.types.tasks import (
CANCEL_ALL_TASKS,
ImageEdits,
ImageGeneration,
Shutdown,
Task,
TaskId,
TaskStatus,
@@ -110,39 +111,45 @@ class RunnerSupervisor:
async def run(self):
self.runner_process.start()
async with self._tg as tg:
tg.start_soon(self._watch_runner)
tg.start_soon(self._forward_events)
try:
async with self._tg as tg:
tg.start_soon(self._watch_runner)
tg.start_soon(self._forward_events)
finally:
logger.info("Runner supervisor shutting down")
if not self._cancel_watch_runner.cancel_called:
self._cancel_watch_runner.cancel()
with contextlib.suppress(ClosedResourceError):
self._ev_recv.close()
with contextlib.suppress(ClosedResourceError):
self._task_sender.close()
with contextlib.suppress(ClosedResourceError):
self._event_sender.close()
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.send(CANCEL_ALL_TASKS)
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.close()
await to_thread.run_sync(self.runner_process.join, 5)
if self.runner_process.is_alive():
logger.warning(
"Runner process didn't shutdown succesfully, terminating"
)
self.runner_process.terminate()
self.runner_process.join(timeout=5)
# This is overkill but it's not technically bad, just unnecessary.
if self.runner_process.is_alive():
logger.critical("Runner process didn't respond to SIGTERM, killing")
self.runner_process.kill()
self.runner_process.join(timeout=5)
else:
logger.info("Runner process succesfully terminated")
self.runner_process.close()
def shutdown(self):
logger.info("Runner supervisor shutting down")
self._tg.cancel_tasks()
if not self._cancel_watch_runner.cancel_called:
self._cancel_watch_runner.cancel()
with contextlib.suppress(ClosedResourceError):
self._ev_recv.close()
with contextlib.suppress(ClosedResourceError):
self._task_sender.close()
with contextlib.suppress(ClosedResourceError):
self._event_sender.close()
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.send(CANCEL_ALL_TASKS)
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.close()
self.runner_process.join(5)
if not self.runner_process.is_alive():
logger.info("Runner process succesfully terminated")
return
# This is overkill but it's not technically bad, just unnecessary.
logger.warning("Runner process didn't shutdown succesfully, terminating")
self.runner_process.terminate()
self.runner_process.join(1)
if not self.runner_process.is_alive():
return
logger.critical("Runner process didn't respond to SIGTERM, killing")
self.runner_process.kill()
async def start_task(self, task: Task):
if task.task_id in self.pending:
@@ -163,7 +170,8 @@ class RunnerSupervisor:
await self._task_sender.send_async(task)
except ClosedResourceError:
self.in_progress.pop(task.task_id, None)
logger.warning(f"Task {task} dropped, runner closed communication.")
if not isinstance(task, Shutdown):
logger.warning(f"Task {task} dropped, runner closed communication.")
return
await event.wait()
@@ -218,12 +226,6 @@ class RunnerSupervisor:
for tid in self.pending:
self.pending[tid].set()
def __del__(self) -> None:
if self.runner_process.is_alive():
logger.critical("RunnerSupervisor was not stopped cleanly.")
with contextlib.suppress(ValueError):
self.runner_process.kill()
async def _watch_runner(self) -> None:
with self._cancel_watch_runner:
while True:
@@ -43,8 +43,8 @@ def run_pipeline_device(
def __call__(self, x: mx.array, *args: object, **kwargs: object) -> mx.array:
for layer in self.layers:
x = layer(x, *args, **kwargs) # pyright: ignore[reportUnknownVariableType]
return x # pyright: ignore[reportUnknownVariableType]
x = layer(x, *args, **kwargs)
return x
try:
group = mx.distributed.init(backend="ring", strict=True)
@@ -1,389 +0,0 @@
import copy
import gc
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, cast
import mlx.core as mx
import pytest
from mlx_lm.tokenizer_utils import TokenizerWrapper
from exo.shared.types.common import ModelId
from exo.shared.types.mlx import KVCacheType, Model
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
from exo.worker.engines.mlx.cache import CacheSnapshot, KVPrefixCache, cache_length
from exo.worker.engines.mlx.generator.batch_generate import ExoBatchGenerator
from exo.worker.engines.mlx.generator.generate import mlx_generate
from exo.worker.engines.mlx.utils_mlx import (
apply_chat_template,
load_tokenizer_for_model_id,
)
from .test_prefix_cache_architectures import (
ARCHITECTURES,
ArchSpec,
_arch_available, # pyright: ignore[reportPrivateUsage]
_build_model, # pyright: ignore[reportPrivateUsage]
_copy_tokenizer, # pyright: ignore[reportPrivateUsage]
_find_snapshot, # pyright: ignore[reportPrivateUsage]
_reduce_config, # pyright: ignore[reportPrivateUsage]
)
def _make_task(
content: str = "Hello, what is 2+2?",
max_tokens: int = 10,
seed: int = 42,
) -> TextGenerationTaskParams:
return TextGenerationTaskParams(
model=ModelId("test"),
input=[InputMessage(role="user", content=content)],
max_output_tokens=max_tokens,
temperature=0.7,
seed=seed,
)
# ── Helpers ──────────────────────────────────────────────────────────────── #
def _collect_mlx_generate(
model: Model,
tokenizer: TokenizerWrapper,
task: TextGenerationTaskParams,
kv_prefix_cache: KVPrefixCache | None,
) -> list[int]:
"""Run mlx_generate and collect output token IDs."""
prompt = apply_chat_template(tokenizer=tokenizer, task_params=task)
tokens: list[int] = []
for resp in mlx_generate(
model=model,
tokenizer=tokenizer,
task=task,
prompt=prompt,
kv_prefix_cache=kv_prefix_cache,
group=None,
):
tokens.append(resp.token)
if resp.finish_reason is not None:
break
return tokens
def _collect_batch_generate(
model: Model,
tokenizer: TokenizerWrapper,
task_params: TextGenerationTaskParams,
kv_prefix_cache: KVPrefixCache | None,
) -> list[int]:
"""Run ExoBatchGenerator and collect raw output token IDs"""
exo_gen = ExoBatchGenerator(
model=model,
tokenizer=tokenizer,
group=None,
kv_prefix_cache=kv_prefix_cache,
)
prompt = apply_chat_template(tokenizer=tokenizer, task_params=task_params)
exo_gen.submit(task_params=task_params, prompt=prompt)
tokens: list[int] = []
while exo_gen.has_work:
results = exo_gen.step()
for _uid, response in results:
tokens.append(response.token)
exo_gen.close()
return tokens
def _assert_state_equal(sa: object, sb: object, label: str) -> None:
"""Compare two state items, handling both plain arrays and tuples of arrays (CacheList)."""
if isinstance(sa, tuple):
assert isinstance(sb, tuple), f"{label}: type mismatch"
for k, (arr_a, arr_b) in enumerate(
zip(
cast(tuple[mx.array, ...], sa),
cast(tuple[mx.array, ...], sb),
strict=True,
)
):
a_f = mx.array(arr_a).astype(mx.float32)
b_f = mx.array(arr_b).astype(mx.float32)
if a_f.size == 0:
assert b_f.size == 0, f"{label}[{k}]: size mismatch"
continue
diff = float(mx.max(mx.abs(a_f - b_f)).item())
assert diff == 0.0, f"{label}[{k}]: max diff {diff}"
else:
sa_f = mx.array(cast(mx.array, sa)).astype(mx.float32)
sb_f = mx.array(cast(mx.array, sb)).astype(mx.float32)
if sa_f.size == 0:
assert sb_f.size == 0, f"{label}: size mismatch"
return
diff = float(mx.max(mx.abs(sa_f - sb_f)).item())
assert diff == 0.0, f"{label}: max diff {diff}"
def _compare_cache_arrays(
cache_a: KVCacheType,
cache_b: KVCacheType,
label: str = "",
) -> None:
"""Assert two KV caches have identical array values."""
assert len(cache_a) == len(cache_b), (
f"{label}Cache layer count: {len(cache_a)} vs {len(cache_b)}"
)
for i, (a, b) in enumerate(zip(cache_a, cache_b, strict=True)):
assert type(a) is type(b), (
f"{label}Layer {i}: type {type(a).__name__} vs {type(b).__name__}"
)
states_a = a.state
states_b = b.state
assert len(states_a) == len(states_b), (
f"{label}Layer {i}: state count {len(states_a)} vs {len(states_b)}"
)
for j, (sa, sb) in enumerate(zip(states_a, states_b, strict=True)):
if sa is None and sb is None:
continue
assert sa is not None and sb is not None, (
f"{label}Layer {i}, state {j}: one is None"
)
_assert_state_equal(sa, sb, f"{label}Layer {i}, state {j}")
def _safe_state(cache: object) -> list[object]:
"""Safely access .state on a cache object. Returns [] if uninitialized."""
# RotatingKVCache.state crashes when keys is None (uninitialized)
if getattr(cache, "keys", _SENTINEL) is None:
return []
try:
return list(cache.state) # type: ignore[union-attr]
except (AttributeError, TypeError):
return []
_SENTINEL = object()
def _compare_snapshots(
snaps_a: list[CacheSnapshot] | None,
snaps_b: list[CacheSnapshot] | None,
label: str = "",
) -> None:
"""Assert two snapshot lists are identical."""
if snaps_a is None:
assert snaps_b is None, f"{label}One side has snapshots, other doesn't"
return
assert snaps_b is not None, f"{label}One side has snapshots, other doesn't"
assert len(snaps_a) == len(snaps_b), (
f"{label}Snapshot count: {len(snaps_a)} vs {len(snaps_b)}"
)
for k, (sa, sb) in enumerate(zip(snaps_a, snaps_b, strict=True)):
assert sa.token_count == sb.token_count, (
f"{label}Snapshot {k} token_count: {sa.token_count} vs {sb.token_count}"
)
for layer_i, (s1, s2) in enumerate(zip(sa.states, sb.states, strict=True)):
if s1 is None and s2 is None:
continue
assert s1 is not None and s2 is not None, (
f"{label}Snapshot {k}, layer {layer_i}: one state is None"
)
state_a = _safe_state(s1)
state_b = _safe_state(s2)
if not state_a and not state_b:
continue
assert len(state_a) == len(state_b), (
f"{label}Snapshot {k}, layer {layer_i}: state length mismatch"
)
for st_j, (arr_a, arr_b) in enumerate(zip(state_a, state_b, strict=True)):
if arr_a is None and arr_b is None:
continue
assert arr_a is not None and arr_b is not None
_assert_state_equal(
arr_a,
arr_b,
f"{label}Snapshot {k}, layer {layer_i}, state {st_j}",
)
# ── Test class ────────────────────────────────────────────────────────────── #
@pytest.mark.slow
class TestBatchVsGenerate:
"""Verify BatchGenerator matches mlx_generate for output tokens and prefix cache."""
@pytest.fixture(autouse=True)
def _cleanup(self):
yield
mx.clear_cache()
gc.collect()
@pytest.mark.parametrize(
"spec",
ARCHITECTURES,
ids=[a.name for a in ARCHITECTURES],
)
def test_same_output_and_cache(self, spec: ArchSpec) -> None:
if not _arch_available(spec):
pytest.skip(f"Model {spec.hub_name} not cached locally")
snapshot = _find_snapshot(spec.hub_name)
assert snapshot is not None
tmpdir = Path(tempfile.mkdtemp(prefix=f"exo_batchtest_{spec.name}_"))
try:
# Build reduced config
with open(snapshot / "config.json") as f:
cfg = cast(dict[str, Any], json.load(f))
reduced = _reduce_config(copy.deepcopy(cfg))
(tmpdir / "config.json").write_text(json.dumps(reduced))
# Copy tokenizer
tok_src = snapshot
if spec.tokenizer_hub is not None:
alt = _find_snapshot(spec.tokenizer_hub)
if alt is not None:
tok_src = alt
_copy_tokenizer(tok_src, tmpdir)
# Load tokenizer, build model with random weights
model_id = ModelId(f"mlx-community/{spec.hub_name}")
tokenizer = load_tokenizer_for_model_id(model_id, tmpdir)
mx.random.seed(0)
model = _build_model(spec.module, reduced)
task = _make_task()
# ── Run mlx_generate path ──
# Seed is set inside mlx_generate/ExoBatchGenerator.submit from task.seed
kv_mlx = KVPrefixCache(None)
mlx_tokens = _collect_mlx_generate(model, tokenizer, task, kv_mlx)
# ── Run batch generator path ──
kv_batch = KVPrefixCache(None)
batch_tokens = _collect_batch_generate(model, tokenizer, task, kv_batch)
# ── Compare output tokens ──
assert len(mlx_tokens) > 0, "mlx_generate produced no tokens"
assert len(batch_tokens) > 0, "BatchGenerator produced no tokens"
assert mlx_tokens == batch_tokens, (
f"[{spec.name}] Token mismatch:\n"
f" mlx_generate: {mlx_tokens}\n"
f" BatchGenerator: {batch_tokens}"
)
# ── Compare prefix cache KV arrays ──
assert len(kv_mlx.caches) == 1, "mlx_generate didn't save to prefix cache"
assert len(kv_batch.caches) == 1, (
"BatchGenerator didn't save to prefix cache"
)
_compare_cache_arrays(
kv_mlx.caches[0],
kv_batch.caches[0],
label=f"[{spec.name}] ",
)
# ── Compare cache lengths ──
mlx_len = cache_length(kv_mlx.caches[0])
batch_len = cache_length(kv_batch.caches[0])
assert mlx_len == batch_len, (
f"[{spec.name}] Cache length: mlx={mlx_len} vs batch={batch_len}"
)
# ── Compare snapshots ──
_compare_snapshots(
kv_mlx._snapshots[0], # pyright: ignore[reportPrivateUsage]
kv_batch._snapshots[0], # pyright: ignore[reportPrivateUsage]
label=f"[{spec.name}] ",
)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@pytest.mark.parametrize(
"spec",
ARCHITECTURES,
ids=[a.name for a in ARCHITECTURES],
)
def test_concurrent_batch_completes(self, spec: ArchSpec) -> None:
"""Two requests processed concurrently must both complete without
crashing and produce non-empty output.
Note: batch decode logits are NOT bit-exact with sequential because
Metal's matmul kernel picks different reduction tiling for B=1 vs B=2
when L=1 (decode step). This introduces sub-ULP float16 diffs in
gate_proj/down_proj/lm_head which swiglu amplifies by |up_values|.
With random weights these accumulate into argmax flips; with trained
weights the diffs are absorbed and output matches exactly (verified
with real Llama-3.2-1B-Instruct-4bit weights).
"""
if not _arch_available(spec):
pytest.skip(f"Model {spec.hub_name} not cached locally")
snapshot = _find_snapshot(spec.hub_name)
assert snapshot is not None
tmpdir = Path(tempfile.mkdtemp(prefix=f"exo_concurrent_{spec.name}_"))
try:
with open(snapshot / "config.json") as f:
cfg = cast(dict[str, Any], json.load(f))
reduced = _reduce_config(copy.deepcopy(cfg))
(tmpdir / "config.json").write_text(json.dumps(reduced))
tok_src = snapshot
if spec.tokenizer_hub is not None:
alt = _find_snapshot(spec.tokenizer_hub)
if alt is not None:
tok_src = alt
_copy_tokenizer(tok_src, tmpdir)
model_id = ModelId(f"mlx-community/{spec.hub_name}")
tokenizer = load_tokenizer_for_model_id(model_id, tmpdir)
mx.random.seed(0)
model = _build_model(spec.module, reduced)
# Two different prompts → different prompt lengths.
task_a = _make_task(content="Hello, what is 2+2?", seed=42)
task_a = task_a.model_copy(update={"temperature": 0.0})
task_b = _make_task(
content="Write a short poem about the ocean and the sky.",
seed=99,
)
task_b = task_b.model_copy(update={"temperature": 0.0})
# ── Concurrent: submit both to one ExoBatchGenerator ──
exo_gen = ExoBatchGenerator(
model=model,
tokenizer=tokenizer,
group=None,
kv_prefix_cache=None,
)
prompt_a = apply_chat_template(tokenizer=tokenizer, task_params=task_a)
prompt_b = apply_chat_template(tokenizer=tokenizer, task_params=task_b)
uid_a = exo_gen.submit(task_params=task_a, prompt=prompt_a)
uid_b = exo_gen.submit(task_params=task_b, prompt=prompt_b)
batch_tokens: dict[int, list[int]] = {uid_a: [], uid_b: []}
finished: set[int] = set()
while exo_gen.has_work:
results = exo_gen.step()
for uid, response in results:
batch_tokens[uid].append(response.token)
if response.finish_reason is not None:
finished.add(uid)
exo_gen.close()
# ── Verify both completed ──
assert len(batch_tokens[uid_a]) > 0, "No tokens for task A"
assert len(batch_tokens[uid_b]) > 0, "No tokens for task B"
assert uid_a in finished, "Task A never finished"
assert uid_b in finished, "Task B never finished"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@@ -190,7 +190,7 @@ ARCHITECTURES: list[ArchSpec] = [
def _arch_available(spec: ArchSpec) -> bool:
snap = _find_snapshot(spec.hub_name)
if snap is None:
if snap is None or not (snap / "config.json").exists():
return False
if spec.tokenizer_hub is not None:
return _find_snapshot(spec.tokenizer_hub) is not None
@@ -2,6 +2,7 @@ import json
from collections.abc import Generator
from typing import Any
from exo.shared.types.common import ModelId
from exo.shared.types.worker.runner_response import (
GenerationResponse,
ToolCallResponse,
@@ -965,3 +966,70 @@ class TestE2EFullRoundTrip:
assert "sunny" in final_text.lower()
assert "5°C" in final_text
assert "12°C" in final_text
class TestMultiTurnThinkingPrompt:
def test_no_orphan_think_end_in_multiturn(self):
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hi!"},
{"role": "assistant", "content": "Hello! How can I help you today?"},
{"role": "user", "content": "Tell me about Paris."},
]
prompt = encode_messages(messages, thinking_mode="thinking")
assistant_token = "<\uff5cAssistant\uff5c>"
parts = prompt.split(assistant_token)
for part in parts[1:]:
assert not part.startswith(THINKING_END), (
f"Orphan </think> without <think> after <Assistant>: ...{assistant_token}{part[:50]}"
)
class TestApplyChatTemplateWithToolCalls:
def test_dsml_encoding_with_tool_calls_in_history(self):
from exo.shared.types.text_generation import (
InputMessage,
TextGenerationTaskParams,
)
from exo.worker.engines.mlx.utils_mlx import apply_chat_template
chat_template_messages: list[dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Tokyo"}',
},
}
],
},
{"role": "tool", "content": "Sunny, 25°C"},
{"role": "user", "content": "Thanks!"},
]
from unittest.mock import MagicMock
tokenizer = MagicMock()
tokenizer.has_thinking = True
tokenizer.think_start = "<think>"
tokenizer.think_end = "</think>"
params = TextGenerationTaskParams(
model=ModelId("mlx-community/DeepSeek-V3.2-8bit"),
input=[InputMessage(role="user", content="Thanks!")],
instructions="You are a helpful assistant.",
enable_thinking=True,
chat_template_messages=chat_template_messages,
tools=_WEATHER_TOOLS,
)
prompt = apply_chat_template(tokenizer, params)
assert "get_weather" in prompt
assert "Tokyo" in prompt
assert "Sunny" in prompt
@@ -0,0 +1,332 @@
from collections.abc import Generator
from typing import Any
from exo.shared.types.worker.runner_response import (
FinishReason,
GenerationResponse,
ToolCallResponse,
)
from exo.worker.engines.mlx.dsml_encoding import (
DSML_TOKEN,
THINKING_END,
THINKING_START,
TOOL_CALLS_END,
TOOL_CALLS_START,
)
from exo.worker.runner.llm_inference.model_output_parsers import (
parse_deepseek_v32,
parse_thinking_models,
parse_tool_calls,
)
from exo.worker.runner.llm_inference.tool_parsers import make_mlx_parser
def _make_response(
text: str, token: int, finish_reason: FinishReason | None = None
) -> GenerationResponse:
return GenerationResponse(
text=text, token=token, finish_reason=finish_reason, usage=None
)
def _queue_source(
tokens: list[GenerationResponse],
) -> Generator[GenerationResponse | None]:
for token in tokens:
yield token
yield None
while True:
yield None
def _step_until_finish(
parser_gen: Generator[GenerationResponse | ToolCallResponse | None],
max_steps: int = 200,
) -> list[GenerationResponse | ToolCallResponse]:
results: list[GenerationResponse | ToolCallResponse] = []
for _ in range(max_steps):
try:
result = next(parser_gen)
except StopIteration:
break
if result is None:
continue
results.append(result)
if isinstance(result, GenerationResponse) and result.finish_reason is not None:
return results
if isinstance(result, ToolCallResponse):
return results
return results
def _got_finish(results: list[GenerationResponse | ToolCallResponse]) -> bool:
for r in results:
if isinstance(r, ToolCallResponse):
return True
if r.finish_reason is not None:
return True
return False
# ── parse_deepseek_v32 ──────────────────────────────────────────
class TestDeepSeekV32FinishReason:
def test_finish_reason_with_buffered_dsml_prefix(self):
tokens = [
_make_response("Hello! The answer is x", 0),
_make_response("<", 1),
_make_response("", 2, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
full_text = "".join(
r.text for r in results if isinstance(r, GenerationResponse)
)
assert "Hello" in full_text
assert "<" in full_text
def test_finish_reason_completes_tool_call_block(self):
tokens = [
_make_response(TOOL_CALLS_START, 0),
_make_response("\n", 1),
_make_response(f'<{DSML_TOKEN}invoke name="get_weather">\n', 2),
_make_response(
f'<{DSML_TOKEN}parameter name="city" string="true">Tokyo</{DSML_TOKEN}parameter>\n',
3,
),
_make_response(f"</{DSML_TOKEN}invoke>\n", 4),
_make_response(TOOL_CALLS_END, 5, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
assert tool_results[0].tool_calls[0].name == "get_weather"
def test_finish_reason_mid_tool_call_before_close(self):
tokens = [
_make_response(TOOL_CALLS_START, 0),
_make_response("\n", 1),
_make_response(
f'<{DSML_TOKEN}invoke name="get_weather">\n', 2, finish_reason="stop"
),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
def test_finish_reason_single_token_complete_dsml_block(self):
dsml_block = (
f"{TOOL_CALLS_START}\n"
f'<{DSML_TOKEN}invoke name="get_weather">\n'
f'<{DSML_TOKEN}parameter name="city" string="true">Tokyo</{DSML_TOKEN}parameter>\n'
f"</{DSML_TOKEN}invoke>\n"
f"{TOOL_CALLS_END}"
)
tokens = [_make_response(dsml_block, 0, finish_reason="stop")]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
assert tool_results[0].tool_calls[0].name == "get_weather"
def test_finish_reason_during_thinking(self):
tokens = [
_make_response(THINKING_START, 0),
_make_response("I need to think about this", 1),
_make_response(" carefully before responding", 2, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
def test_finish_reason_after_thinking_then_tool_call(self):
tokens = [
_make_response(THINKING_START, 0),
_make_response("Let me check the weather.", 1),
_make_response(THINKING_END, 2),
_make_response("\n\n", 3),
_make_response(TOOL_CALLS_START, 4),
_make_response("\n", 5),
_make_response(f'<{DSML_TOKEN}invoke name="get_weather">\n', 6),
_make_response(
f'<{DSML_TOKEN}parameter name="city" string="true">NYC</{DSML_TOKEN}parameter>\n',
7,
),
_make_response(f"</{DSML_TOKEN}invoke>\n", 8),
_make_response(TOOL_CALLS_END, 9, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
assert tool_results[0].tool_calls[0].name == "get_weather"
def test_finish_reason_normal_text_no_buffering(self):
tokens = [
_make_response("Hello", 0),
_make_response(" world", 1),
_make_response("!", 2, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
full_text = "".join(
r.text for r in results if isinstance(r, GenerationResponse)
)
assert full_text == "Hello world!"
def test_finish_reason_multiple_buffered_prefix_tokens(self):
tokens = [
_make_response("text ", 0),
_make_response("<", 1),
_make_response("not a tag", 2),
_make_response(" more<", 3),
_make_response("", 4, finish_reason="stop"),
]
results = _step_until_finish(parse_deepseek_v32(_queue_source(tokens)))
assert _got_finish(results)
# ── parse_thinking_models ────────────────────────────────────────
class TestThinkingModelsFinishReason:
def test_finish_reason_during_thinking(self):
tokens = [
_make_response("<think>", 0),
_make_response("reasoning here", 1),
_make_response("more reasoning", 2, finish_reason="stop"),
]
results = _step_until_finish(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=False,
)
)
assert _got_finish(results)
last_gen = [
r
for r in results
if isinstance(r, GenerationResponse) and r.finish_reason is not None
]
assert len(last_gen) == 1
assert last_gen[0].is_thinking is False
def test_finish_reason_after_thinking(self):
tokens = [
_make_response("<think>", 0),
_make_response("hmm", 1),
_make_response("</think>", 2),
_make_response("The answer is 42.", 3, finish_reason="stop"),
]
results = _step_until_finish(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=False,
)
)
assert _got_finish(results)
def test_finish_reason_starts_in_thinking(self):
tokens = [
_make_response("still thinking", 0),
_make_response("</think>", 1),
_make_response("done", 2, finish_reason="stop"),
]
results = _step_until_finish(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=True,
)
)
assert _got_finish(results)
# ── parse_tool_calls (generic) ──────────────────────────────────
def _dummy_parser_fn(text: str) -> dict[str, Any]:
return {"name": "test_fn", "arguments": {"arg": text}}
_dummy_parser = make_mlx_parser("<tool_call>", "</tool_call>", _dummy_parser_fn)
class TestGenericToolCallsFinishReason:
def test_finish_reason_after_complete_tool_call(self):
tokens = [
_make_response("<tool_call>", 0),
_make_response("body", 1),
_make_response("</tool_call>", 2),
_make_response("extra text", 3, finish_reason="stop"),
]
results = _step_until_finish(
parse_tool_calls(
_queue_source(tokens),
_dummy_parser,
tools=None,
)
)
tool_results = [r for r in results if isinstance(r, ToolCallResponse)]
assert len(tool_results) == 1
def test_finish_reason_mid_tool_call_unclosed(self):
tokens = [
_make_response("<tool_call>", 0),
_make_response("partial content", 1, finish_reason="stop"),
]
results = _step_until_finish(
parse_tool_calls(
_queue_source(tokens),
_dummy_parser,
tools=None,
)
)
assert _got_finish(results)
def test_finish_reason_no_tool_calls(self):
tokens = [
_make_response("Just", 0),
_make_response(" a", 1),
_make_response(" normal", 2),
_make_response(" response.", 3, finish_reason="stop"),
]
results = _step_until_finish(
parse_tool_calls(
_queue_source(tokens),
_dummy_parser,
tools=None,
)
)
assert _got_finish(results)
# ── Double parser chain (parse_thinking_models → parse_deepseek_v32) ──
class TestBatchGeneratorSingleNext:
def test_finish_reason_with_buffered_tokens_drain_loop(self):
from exo.worker.runner.llm_inference.batch_generator import GeneratorQueue
queue: GeneratorQueue[GenerationResponse] = GeneratorQueue()
parser = parse_deepseek_v32(queue.gen())
tokens = [
_make_response("Hello ", 0),
_make_response(" `<", 1),
_make_response("", 2, finish_reason="stop"),
]
collected: list[GenerationResponse | ToolCallResponse] = []
for token in tokens:
queue.push(token)
while (parsed := next(parser, None)) is not None:
collected.append(parsed)
if token.finish_reason is not None:
break
assert _got_finish(collected), (
f"No finish_reason in collected: {[(type(r).__name__, getattr(r, 'finish_reason', None) if isinstance(r, GenerationResponse) else 'tool') for r in collected]}"
)
@@ -1,6 +1,6 @@
from collections.abc import Generator
from exo.shared.types.api import FinishReason
from exo.api.types import FinishReason
from exo.shared.types.worker.runner_response import (
GenerationResponse,
ToolCallResponse,
@@ -87,6 +87,7 @@ class TestParseToolCalls:
assert len(results) == 1
assert isinstance(results[0], GenerationResponse)
assert results[0].text == "<tool_call>bad content</tool_call>"
assert results[0].finish_reason == "error"
def test_tool_schema_coerces_string_arguments_to_expected_types(self):
"""Tool argument values should be coerced using provided JSON schema."""
+1 -1
View File
@@ -50,6 +50,6 @@ bench_runner="${hosts[0]}"
mkdir -p "./bench/$commit"
nix run .#exo-get-all-models-on-cluster -- "$bench_runner" | while IFS= read -r model; do
echo "running bench for $model" 1>&2
ssh -Tn -o BatchMode=yes -o ServerAliveInterval=30 "$bench_runner@$bench_runner" "/nix/var/nix/profiles/default/bin/nix run github:exo-explore/exo/$commit#exo-bench -- --model $model --pp 128 4096 --tg 128 --stdout --skip-tensor-ring" >>"./bench/$commit/${model//\//--}.json"
ssh -Tn -o BatchMode=yes -o ServerAliveInterval=30 "$bench_runner@$bench_runner" "/nix/var/nix/profiles/default/bin/nix run github:exo-explore/exo/$commit#exo-bench -- --model $model --pp 128 4096 --tg 128 --concurrency 1 3 8 --stdout --skip-tensor-ring" >>"./bench/$commit/${model//\//--}.json"
echo
done
+11 -4
View File
@@ -11,10 +11,17 @@ set -euo pipefail
exit 1
}
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null) || {
echo "No upstream"
exit 1
}
commit=$(git rev-parse HEAD)
git fetch -q origin
git branch -r --contains "$commit" | grep -qE '^\s*origin/' || {
echo "Not pushed to origin"
remote=${upstream%%/*}
remote_installable=$(git remote get-url "$remote" | sed -E "s#^(git@github.com:|https://github\.com/)([^/]+)/([^/]+)(\.git)?\$#github:\2/\3/$commit#")
git fetch -q "$remote"
git branch -r --contains "$commit" | grep -qE "^[[:space:]]*$remote/" || {
echo "Not pushed to $remote"
exit 1
}
@@ -35,7 +42,7 @@ i=0
for host; do
colour=${colours[i++ % 4]}
ssh -T -o BatchMode=yes -o ServerAliveInterval=30 "$host@$host" \
"EXO_LIBP2P_NAMESPACE=$commit /nix/var/nix/profiles/default/bin/nix run github:exo-explore/exo/$commit" |&
"EXO_LIBP2P_NAMESPACE=$commit /nix/var/nix/profiles/default/bin/nix run $remote_installable" 2>&1 |
awk -v p="${colour}[${host}]${reset}" '{ print p $0; fflush() }' &
done
Generated
+31 -19
View File
@@ -213,14 +213,20 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
@@ -344,8 +350,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
@@ -353,8 +361,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
@@ -362,8 +372,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
@@ -473,7 +485,7 @@ dependencies = [
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mflux", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.6", source = { registry = "https://pypi.org/simple" }, extra = ["cpu"], marker = "sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260303+257d5692", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#257d5692fc7af6bba3b8afaeb63c549b7d1e43d5" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx", version = "0.31.2.dev20260324+e5e64331", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#e5e64331830d9b04ae9082b843073f9c1fa7705e" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx-lm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "openai-harmony", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -509,10 +521,10 @@ requires-dist = [
{ name = "huggingface-hub", specifier = ">=0.33.4" },
{ name = "hypercorn", specifier = ">=0.18.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "mflux", specifier = "==0.16.9" },
{ name = "mflux", specifier = "==0.17.2" },
{ name = "mlx", marker = "sys_platform == 'darwin'", git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks" },
{ name = "mlx", extras = ["cpu"], marker = "sys_platform == 'linux'", specifier = "==0.30.6" },
{ name = "mlx-lm", git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Feval-left-padding-in-batched-rotation" },
{ name = "mlx-lm", git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Ffix-deepseek-v32-indexer" },
{ name = "msgspec", specifier = ">=0.19.0" },
{ name = "openai-harmony", specifier = ">=0.0.8" },
{ name = "psutil", specifier = ">=7.0.0" },
@@ -1342,7 +1354,7 @@ wheels = [
[[package]]
name = "mflux"
version = "0.16.9"
version = "0.17.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -1351,12 +1363,13 @@ dependencies = [
{ name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.6", source = { registry = "https://pypi.org/simple" }, extra = ["cuda13"], marker = "sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260303+257d5692", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#257d5692fc7af6bba3b8afaeb63c549b7d1e43d5" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx", version = "0.31.2.dev20260324+e5e64331", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#e5e64331830d9b04ae9082b843073f9c1fa7705e" }, marker = "sys_platform == 'darwin'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "opencv-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "piexif", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "safetensors", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -1369,9 +1382,9 @@ dependencies = [
{ name = "twine", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/dc/73dfccae395df22623fbb6f748657700fd9372be22904a0dafb825dc42d2/mflux-0.16.9.tar.gz", hash = "sha256:ff35e9386b5f026a6b97c786b3426e5341105de3356e55ec1b0c5951ff0ac8c8", size = 764296, upload-time = "2026-03-07T11:54:45.031Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/75/65f791f54c9531b524813d18ff87a0d34866ef220a8fe2ad637437f3cb54/mflux-0.17.2.tar.gz", hash = "sha256:52dee2d27cf438a84648e5c1861b92ceb63a9ac06823d14452a78646a1d30ee7", size = 779264, upload-time = "2026-03-23T13:08:18.377Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/69/091df2d0f4a2677d5e2a8b326d568bd884d7ca37f15d3ff78e574aca2271/mflux-0.16.9-py3-none-any.whl", hash = "sha256:988e925653a024e81b46401fd5a2e423dd283428ba64949af4d9257e7df8ddd9", size = 1018804, upload-time = "2026-03-07T11:54:43.47Z" },
{ url = "https://files.pythonhosted.org/packages/68/02/f94eca4e77b7d12685060461eb793cbc8c00e96cc7fe0ce376374201aed2/mflux-0.17.2-py3-none-any.whl", hash = "sha256:be1642b04847413c0a8ed1dae82ce1ca023e155b057d82a8301eca9c3fe08339", size = 1037451, upload-time = "2026-03-23T13:08:16.747Z" },
]
[[package]]
@@ -1399,8 +1412,8 @@ cuda13 = [
[[package]]
name = "mlx"
version = "0.30.7.dev20260303+257d5692"
source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#257d5692fc7af6bba3b8afaeb63c549b7d1e43d5" }
version = "0.31.2.dev20260324+e5e64331"
source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#e5e64331830d9b04ae9082b843073f9c1fa7705e" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version < '3.14' and sys_platform == 'darwin'",
@@ -1432,11 +1445,11 @@ wheels = [
[[package]]
name = "mlx-lm"
version = "0.31.0"
source = { git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Feval-left-padding-in-batched-rotation#5e0c484cfb5c68d281a71409927ee1bb75adaae2" }
version = "0.31.2"
source = { git = "https://github.com/rltakashige/mlx-lm?branch=leo%2Ffix-deepseek-v32-indexer#d388ff77858fec3b5d2e3b1d9502a7e2878b8109" }
dependencies = [
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260303+257d5692", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#257d5692fc7af6bba3b8afaeb63c549b7d1e43d5" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx", version = "0.31.2.dev20260324+e5e64331", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#e5e64331830d9b04ae9082b843073f9c1fa7705e" }, marker = "sys_platform == 'darwin'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -2091,15 +2104,14 @@ wheels = [
[[package]]
name = "protobuf"
version = "6.33.3"
version = "5.29.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/5c/f912bdebdd4af4160da6a2c2b1b3aaa1b8c578d0243ba8f694f93c7095f0/protobuf-6.33.3.tar.gz", hash = "sha256:c8794debeb402963fddff41a595e1f649bcd76616ba56c835645cab4539e810e", size = 444318, upload-time = "2026-01-09T23:05:02.79Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/5d/0ef28dded98973a26443a6a7bc49bff6206be8c57dc1d1e28e6c1147b879/protobuf-6.33.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:648b7b0144222eb06cf529a3d7b01333c5f30b4196773b682d388f04db373759", size = 427594, upload-time = "2026-01-09T23:04:53.358Z" },
{ url = "https://files.pythonhosted.org/packages/c5/46/551c69b6ff1957bd703654342bfb776bb97db400bc80afc56fbb64e7c11d/protobuf-6.33.3-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:08a6ca12f60ba99097dd3625ef4275280f99c9037990e47ce9368826b159b890", size = 324469, upload-time = "2026-01-09T23:04:54.332Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6d/ade1cca06c64a421ee9745e082671465ead28164c809efaf2c15bc93f9a0/protobuf-6.33.3-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:642fce7187526c98683c79a3ad68e5d646a5ef5eb004582fe123fc9a33a9456b", size = 339242, upload-time = "2026-01-09T23:04:55.347Z" },
{ url = "https://files.pythonhosted.org/packages/38/8c/6522b8e543ece46f645911c3cebe361d8460134c0fee02ddcf70ebf32999/protobuf-6.33.3-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:6fa9b5f4baa12257542273e5e6f3c3d3867b30bc2770c14ad9ac8315264bf986", size = 323298, upload-time = "2026-01-09T23:04:56.866Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b9/067b8a843569d5605ba6f7c039b9319720a974f82216cd623e13186d3078/protobuf-6.33.3-py3-none-any.whl", hash = "sha256:c2bf221076b0d463551efa2e1319f08d4cffcc5f0d864614ccd3d0e77a637794", size = 170518, upload-time = "2026-01-09T23:05:01.227Z" },
{ url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
]
[[package]]