Compare commits

...

34 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
ciaranbor a6519ba006 Update mflux to 0.16.9 (#1751)
Prevents malformed output from Qwen-Image
2026-03-17 16:58:23 +00:00
rltakashige b713889f73 Fix exo bench again again (#1750)
Mb premature auto merge
2026-03-17 13:47:24 +00:00
rltakashige 6ee673147d Fix exo bench prefill and decode tps (#1749) 2026-03-17 13:36:44 +00:00
ciaranbor ff4d20eed9 Fix image models through dashboard (#1746)
## Motivation

Image generation/editing from the dashboard was broken. ChatForm
bypassed the parent's model-launch logic, so image requests fell through
to text chat.

## Changes

- Moved image routing logic from ChatForm.svelte to +page.svelte
(routeMessage())
- ChatForm now always delegates to parent via onAutoSend (made required)
- Fixed missing updateActiveConversation() call on message retry

## Why It Works

All sends now go through the parent's launch-then-route path, so image
models get launched before the request is dispatched to the correct
endpoint.
2026-03-17 11:22:01 +00:00
Evan Quiney 7ed4639540 use structured concurrency in download coordinator (#1722)
identical to #1721 but a little safer imo. 


## manual testing
ran a few downloads, cancelled non-master, cancelled master -- no errors
reported.
2026-03-13 14:55:37 +00:00
Mazin Sharaf 29d4165fe2 Add step logo condition to FamilyLogos component (#1676)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->
This was to fix a small issue which was that the StepFun logo was not
included in the sidebar, and I also noticed it from an open issue:
- #1662 

## Changes

<!-- Describe what you changed in detail -->
I added a condition to the FamilyLogos where if the family is "step"
then the logo will be included in the sidebar.

## Test Plan
Open exo, go choose a model, and scroll down the sidebar until you see
the step logo, which should be there.

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->
Check for the step logo in the sidebar.
2026-03-13 13:31:46 +00:00
ecohash-co 12af7c9586 fix: shield macmon cleanup from cancellation to prevent orphaned process (#1714)
## Summary

Fixes a bug where macmon metrics (GPU usage, temperature, power, RAM)
freeze permanently in the dashboard while non-macmon metrics (disk
usage) continue updating normally.

**Root cause: asyncio subprocess pipe transport flow control stall**

Observed on a 2-node Mac Studio M3 Ultra cluster (Python 3.13.12, anyio
4.11.0, macOS with kqueue) running EXO.app for ~36 hours. Diagnostics
confirmed:

1. **Only macmon monitoring is stuck** — disk metrics from the same
InfoGatherer continue updating, proving the Worker, EventRouter, and API
pipelines are healthy
2. **macmon IS producing data** — its stdout pipe is full at exactly
65536 bytes (64KB OS buffer), and macmon is blocked on write at 0% CPU
3. **The pipe read-end FD is still open** — the exo process holds it,
but asyncio isn't reading from it
4. **The stale GPU value (0.21) is wrong** — should be ~1.0 (matching
the other node under identical load)

This is consistent with asyncio's subprocess pipe transport getting
stuck in flow control: `pause_reading()` is called when the internal
buffer exceeds the high-water mark, but `resume_reading()` is never
called, permanently deregistering the FD from kqueue. The `receive()`
coroutine waits forever for data asyncio will never deliver.

```
$ lsof -p <macmon_pid>
macmon  74691  FD=1  PIPE  SIZE=65536  ->0x5ae78ecf376ccd0e  # full pipe

$ lsof -p <exo_pid> | grep <pipe_id>
exo     74689  FD=47  PIPE  SIZE=65536  ->0xd129da474c1340f7  # read end held open, not consumed
```

Note: anyio 4.11's `Process.aclose()` already uses
`CancelScope(shield=True)` for cleanup during cancellation — this is NOT
an election cleanup issue (confirmed by @ciaranbor's testing).

**Fix:** Replace the `async for` iteration with an explicit `receive()`
inside `fail_after()`. If macmon produces no output for 10× its
configured interval (minimum 30s), `TimeoutError` fires, `open_process`
cleanup kills macmon and tears down the stale transport, and the loop
restarts with a fresh subprocess and fresh asyncio transport.

## Test plan

- [x] All 246 existing tests pass
- [ ] Verify macmon restarts after simulated pipe stall (e.g., `SIGSTOP`
macmon, wait for timeout, confirm restart and metrics resume)
- [ ] Long-running soak test on multi-node cluster to confirm the fix
prevents recurrence
2026-03-13 12:54:30 +00:00
ciaranbor f28b2fd037 Extract mlx revision from uv lock (#1715)
## Motivation

The MLX version and git revision in nix/mlx.nix were hardcoded and had
to be manually kept in sync with uv.lock

## Changes

- flake.nix: Extract MLX git rev from uv.lock's source.git URL and pass
as uvLockMlxRev
- nix/mlx.nix: Use uvLockMlxVersion and uvLockMlxRev instead of
hardcoded values; remove version mismatch assertion

## Why It Works

uv.lock is already the source of truth — now Nix reads both version and
rev from it directly. The pinned fetchFromGitHub hash still guards
against unexpected changes.
2026-03-13 12:34:54 +00:00
Mustafa Alp Yılmaz ea18a62581 fix: guard against ZeroDivisionError in mlx_lm stats (#1707)
## Problem

Running short completions (like `max_tokens=1` health check probes) can
finish so fast that `mlx_lm`'s internal `generation_time` rounds to
zero. When that happens, `BatchGenerator.stats()` in
`mlx_lm/generate.py` divides `generation_tokens / generation_time` and
throws a `ZeroDivisionError`, which kills the runner process.

EXO already handles this on its side — lines 289-293 in
`batch_generate.py` guard the TPS calculation with `if
generation_elapsed > 0`. But the call to `self._exo_gen.stats()` on line
294 goes into mlx_lm's *separate* timing code, which doesn't have the
same guard. Two different timers, only one is protected.

In my case, this was triggered by health check probes (content: `"a"`,
`max_tokens=1`). The generation completed in sub-microsecond time,
`generation_time` was exactly `0`, and the runner crashed. Since the
health check command stays in the queue and retries after recovery, it
created an infinite crash loop — every ~15 seconds the runner would load
the model, get the same health check, and die again.

## Fix

Wrap `self._exo_gen.stats()` in a `try/except ZeroDivisionError`. If it
throws, set `mlx_stats` to `None` and fall back to `0.0` for
`prompt_tps`. The only fields EXO reads from `mlx_stats` are
`prompt_tps` and `prompt_time` — losing them on a sub-microsecond
generation has no practical impact.

## Traceback

```
File "batch_generate.py", line 294, in step
    mlx_stats = self._exo_gen.stats()
File "mlx_lm/generate.py", line 1224, in stats
    self._stats.generation_tokens / self._stats.generation_time
ZeroDivisionError: division by zero
```
2026-03-12 14:48:36 +00:00
Miguel Cruz 0782d90ec5 fix: show partial download progress on initial dashboard load (#1706)
## Summary

- Dashboard now shows partial download progress for models that were
partially downloaded in a previous session, instead of showing 0%
- Both `getModelDownloadStatus()` and `getInstanceDownloadStatus()` now
handle `DownloadPending` entries that carry non-zero
`downloaded`/`total` bytes

Fixes #1042

## Root cause

When exo restarts with partially downloaded models, the
`DownloadCoordinator` emits `DownloadPending` events (because
`downloaded_this_session` is 0, even though real bytes exist on disk).
The main page dashboard only checked for `DownloadOngoing` entries, so
these partially downloaded models showed as 0%.

The dedicated `/downloads` page already handled this correctly — it
renders `DownloadPending` entries with a progress bar when `downloaded >
0`. This fix brings the same behavior to the main page.

## Changes

For both `getModelDownloadStatus()` and `getInstanceDownloadStatus()` in
`+page.svelte`:
- Accept `DownloadPending` in addition to `DownloadOngoing`
- For `DownloadPending` entries with `downloaded > 0` or `total > 0`,
synthesize a `DownloadProgress` object from the top-level fields (with
`speed: 0` and `etaMs: 0` since no active download is in progress)
- Skip `DownloadPending` entries where both `downloaded` and `total` are
0 (truly pending, not yet started)

## Test plan

- [ ] Partially download a model, quit exo, relaunch — dashboard should
show partial progress instead of 0%
- [ ] Fully downloaded models still show as complete
- [ ] Active downloads still show real-time progress with speed/ETA
- [ ] Models never downloaded show as not started (not falsely showing
progress)
- [ ] Dashboard builds without errors (`cd dashboard && npm run build`)

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-03-12 10:39:34 +00:00
rltakashige f221a6c85c Normalise Responses API tool call format (#1704)
## Motivation

The responses API often does not provide tool args nested under a
"function" field. Since we follow the chat completions format of tools
in the backend (for MLX chat templates), we need to normalise to this
format.

## Test Plan

### Manual Testing
Works on n8n!
<img width="3442" height="2076" alt="image"
src="https://github.com/user-attachments/assets/9e11d679-0102-4d83-9a8e-b0a7a5898708"
/>
2026-03-11 18:10:12 +00:00
Mustafa Alp Yılmaz 2994b41089 fix: validate num_key_value_heads in tensor sharding placement (#1669)
## Problem

Models with fewer KV heads than nodes crash during tensor parallelism.
For example, Qwen3.5 MoE models have only 2 KV heads — trying to shard
across 4 nodes produces empty tensors and a reshape error at runtime.

The placement system already validates `hidden_size % num_nodes == 0`
but doesn't check KV heads, so it creates configurations that look valid
but blow up when the worker tries to split the attention heads.

Affected models include Qwen3.5-35B-A3B, Qwen3.5-122B-A10B,
Qwen3.5-397B-A17B, Qwen3-Next-80B-A3B, and Qwen3-Coder-Next (all have 2
KV heads).

## Changes

**Placement validation** (`src/exo/master/placement.py`):
- Combined KV heads divisibility check with the existing hidden_size
filter in a single pass
- Cycles where `num_key_value_heads % len(cycle) != 0` are now excluded
for tensor sharding
- Error message includes both constraints when no valid cycle is found

**Model card schema** (`src/exo/shared/models/model_cards.py`):
- Added optional `num_key_value_heads` field to `ModelCard` and
`ConfigData`
- Extracted from HuggingFace `config.json` (handles both top-level and
`text_config` nesting)
- Passed through in `fetch_from_hf()` for dynamically fetched cards

**All 68 inference model cards**
(`resources/inference_model_cards/*.toml`):
- Populated `num_key_value_heads` from each model's HuggingFace config

**Utility script** (`scripts/fetch_kv_heads.py`):
- Fetches `num_key_value_heads` from HuggingFace and updates TOML cards
- `--missing`: only fills in cards that don't have the field yet
- `--all`: re-fetches and overwrites everything
- Uses tomlkit for safe TOML editing and ThreadPoolExecutor for parallel
fetches

## Behavior

- Instance previews no longer show tensor options for models that can't
split their KV heads across the cluster size
- `place_instance()` rejects with a clear error instead of crash-looping
- Pipeline parallelism is unaffected
- 2-node tensor still works for 2-KV-head models (2 ÷ 2 = 1)
- Field is optional — existing custom cards without it continue to work
(validation is skipped when `None`)
2026-03-11 13:46:33 +00:00
Mustafa Alp Yılmaz 38f0c09175 fix: use StreamingDetokenizer in batch generator to fix emoji/UTF-8 corruption (#1691)
## Problem

Emojis and other multi-byte UTF-8 characters are rendered as `\ufffd`
(Unicode Replacement Character) in batch streaming responses.

Byte-level BPE tokenizers (like Qwen's) can split multi-byte UTF-8
characters (e.g. a 4-byte emoji) across multiple tokens. The batch
generator decodes each token independently with
`tokenizer.decode([token_id])`, which produces U+FFFD for partial byte
sequences.

The sequential generator doesn't have this problem — it uses
`stream_generate()` from mlx_lm, which internally uses
`StreamingDetokenizer` to buffer incomplete bytes.

## Fix

Use `StreamingDetokenizer` in the batch generator, matching the
sequential path:

- Each `_EngineTask` gets its own `StreamingDetokenizer` instance
- `add_token()` buffers tokens, holding back incomplete UTF-8 byte
sequences until they form valid characters
- `last_segment` returns only complete, valid text
- `finalize()` flushes any remaining buffered bytes when generation
completes

Empty text segments during buffering are harmless — they're already
handled correctly by the downstream streaming pipeline.

---------

Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
2026-03-10 16:10:22 +00:00
ciaranbor f36fd56c38 Include power usage in bench responses (#1692)
## Motivation

Add power/energy usage tracking to /bench API responses.

## Changes

- New PowerSampler class that periodically samples sys_power from each
node during inference
- New PowerUsage / NodePowerStats API types
- Integrated into both bench chat completion and bench image generation
endpoints

## Why It Works

Runs concurrently via anyio.create_task_group, reads from existing
state.node_system heartbeat data — no new plumbing needed. Energy =
avg_power × elapsed_time.

## Test Plan

### Manual Testing

Run a /bench request and verify power_usage appears in the response.

### Automated Testing

7 async tests covering single/multi-node, energy math, dynamic power
changes, empty state, and lifecycle.
2026-03-10 16:02:55 +00:00
rltakashige 82c54dd6d6 Add support for Nemotron sharding (#1693)
### Automated Testing
tested logits match
2026-03-10 15:51:07 +00:00
195 changed files with 3865 additions and 1308 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``."""
@@ -32,6 +32,7 @@ class Conv1d(Module):
"""
weight: mx.array
bias: mx.array | None
groups: int
def __init__(
self,
+4
View File
@@ -40,6 +40,10 @@ class Linear(Module):
bias (bool, optional): If set to ``False`` then the layer will
not use a bias. Default is ``True``.
"""
weight: mx.array
bias: mx.array | None
def __init__(self, input_dims: int, output_dims: int, bias: bool = ...) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
def to_quantized(
@@ -88,6 +88,9 @@ class RMSNorm(Module):
dims (int): The feature dimension of the input to normalize over
eps (float): A small additive constant for numerical stability
"""
weight: mx.array
def __init__(self, dims: int, eps: float = ...) -> None: ...
def __call__(self, x) -> mx.array: ...
+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]: ...
+154
View File
@@ -0,0 +1,154 @@
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
import mlx.core as mx
import mlx.nn as nn
from .cache import ArraysCache, KVCache
from .switch_layers import SwitchMLP
@dataclass
class ModelArgs:
model_type: str
vocab_size: int
hidden_size: int
intermediate_size: int
num_hidden_layers: int
max_position_embeddings: int
num_attention_heads: int
num_key_value_heads: int
attention_bias: bool
mamba_num_heads: int
mamba_head_dim: int
mamba_proj_bias: bool
ssm_state_size: int
conv_kernel: int
n_groups: int
mlp_bias: bool
layer_norm_epsilon: float
use_bias: bool
use_conv_bias: bool
hybrid_override_pattern: List[str]
head_dim: Optional[int]
moe_intermediate_size: Optional[int]
moe_shared_expert_intermediate_size: Optional[int]
n_group: Optional[int]
n_routed_experts: Optional[int]
n_shared_experts: Optional[int]
topk_group: Optional[int]
num_experts_per_tok: Optional[int]
norm_topk_prob: Optional[bool]
routed_scaling_factor: Optional[float]
time_step_limit: Optional[Tuple[float, float]]
time_step_min: Optional[float]
time_step_max: Optional[float]
@classmethod
def from_dict(cls, params: dict[str, Any]) -> ModelArgs: ...
def __post_init__(self) -> None: ...
class NemotronHMamba2Mixer(nn.Module):
num_heads: int
hidden_size: int
ssm_state_size: int
conv_kernel_size: int
intermediate_size: int
n_groups: int
head_dim: int
conv_dim: int
conv1d: nn.Conv1d
in_proj: nn.Linear
dt_bias: mx.array
A_log: mx.array
D: mx.array
norm: nn.RMSNorm
heads_per_group: int
out_proj: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
hidden_states: mx.array,
mask: Optional[mx.array],
cache: Optional[ArraysCache] = None,
) -> mx.array: ...
class NemotronHAttention(nn.Module):
hidden_size: int
num_heads: int
head_dim: int
num_key_value_heads: int
scale: float
q_proj: nn.Linear
k_proj: nn.Linear
v_proj: nn.Linear
o_proj: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[KVCache] = None,
) -> mx.array: ...
class NemotronHMLP(nn.Module):
up_proj: nn.Linear
down_proj: nn.Linear
def __init__(
self, args: ModelArgs, intermediate_size: Optional[int] = None
) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class NemotronHMoE(nn.Module):
num_experts_per_tok: int
switch_mlp: SwitchMLP
shared_experts: NemotronHMLP
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class NemotronHBlock(nn.Module):
block_type: str
norm: nn.RMSNorm
mixer: NemotronHMamba2Mixer | NemotronHAttention | NemotronHMLP | NemotronHMoE
def __init__(self, args: ModelArgs, block_type: str) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class NemotronHModel(nn.Module):
embeddings: nn.Embedding
layers: list[NemotronHBlock]
norm_f: nn.RMSNorm
fa_idx: int
ssm_idx: int
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
class Model(nn.Module):
args: ModelArgs
backbone: NemotronHModel
lm_head: nn.Linear
model_type: str
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
@property
def layers(self) -> list[NemotronHBlock]: ...
def make_cache(self) -> list[ArraysCache | KVCache]: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@@ -5,6 +5,7 @@ from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .cache import ArraysCache, KVCache
from .switch_layers import SwitchGLU
class Qwen3NextRMSNormGated(nn.Module):
@@ -99,6 +100,8 @@ class Qwen3NextModel(nn.Module):
embed_tokens: nn.Embedding
layers: list[Qwen3NextDecoderLayer]
norm: nn.RMSNorm
ssm_idx: int
fa_idx: int
def __init__(self, args: Any) -> None: ...
def __call__(
@@ -121,3 +124,4 @@ class Model(nn.Module):
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@property
def layers(self) -> list[Qwen3NextDecoderLayer]: ...
def make_cache(self) -> list[ArraysCache | KVCache]: ...
+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: ...
@@ -73,6 +73,9 @@ class SwitchGLU(nn.Module):
def __call__(self, x, indices) -> mx.array: ...
class SwitchMLP(nn.Module):
fc1: SwitchLinear
fc2: SwitchLinear
def __init__(
self,
input_dims: int,
+4 -4
View File
@@ -39,11 +39,11 @@ class StreamingDetokenizer:
"""
__slots__ = ...
def reset(self): ...
def add_token(self, token): ...
def finalize(self): ...
def reset(self) -> None: ...
def add_token(self, token: int) -> None: ...
def finalize(self) -> None: ...
@property
def last_segment(self):
def last_segment(self) -> str:
"""Return the last segment of readable text since last time this property was accessed."""
class NaiveStreamingDetokenizer(StreamingDetokenizer):
+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()
}
+8 -2
View File
@@ -496,20 +496,26 @@ def main() -> int:
all_rows.append(row)
if batch_results:
agg_gen_tps = sum(
valid_gen_tps = [
x["stats"]["generation_tps"]
for x, _ in batch_results
if x["stats"]["generation_tps"] > 0
]
per_req_tps = (
mean(valid_gen_tps) if valid_gen_tps else 0.0
)
agg_gen_tps = per_req_tps * concurrency
logger.info(
f"[concurrent {concurrency}x] "
f"agg_gen_tps={agg_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"] 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(
+4 -46
View File
@@ -1,9 +1,6 @@
<script lang="ts">
import {
isLoading,
sendMessage,
generateImage,
editImage,
editingImage,
clearEditingImage,
selectedChatModel,
@@ -28,7 +25,7 @@
modelTasks?: Record<string, string[]>;
modelCapabilities?: Record<string, string[]>;
onSend?: () => void;
onAutoSend?: (
onAutoSend: (
content: string,
files?: {
id: string;
@@ -216,49 +213,10 @@
uploadedFiles = [];
resetTextareaHeight();
// When onAutoSend is provided, the parent controls all send logic
// (including launching non-running models before sending)
if (onAutoSend) {
onAutoSend(content, files);
onSend?.();
setTimeout(() => textareaRef?.focus(), 10);
return;
}
// Use image editing if in edit mode
if (isEditMode && currentEditingImage && content) {
editImage(content, currentEditingImage.imageDataUrl);
}
// If user attached an image with an ImageToImage model, use edit endpoint
else if (
currentModel &&
modelSupportsImageEditing(currentModel) &&
files.length > 0 &&
content
) {
// Use the first attached image for editing
const imageFile = files[0];
if (imageFile.preview) {
editImage(content, imageFile.preview);
}
} else if (
currentModel &&
modelSupportsTextToImage(currentModel) &&
content
) {
// Use image generation for text-to-image models
generateImage(content);
} else {
sendMessage(
content,
files,
modelSupportsThinking() ? thinkingEnabled : null,
);
}
// Parent controls all send logic (including image routing,
// launching non-running models before sending, etc.)
onAutoSend(content, files);
onSend?.();
// Refocus the textarea after sending
setTimeout(() => textareaRef?.focus(), 10);
}
@@ -82,6 +82,18 @@
d="M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624"
/>
</svg>
{:else if family === "step"}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
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);
+29 -5
View File
@@ -1577,6 +1577,7 @@ class AppStore {
// Remove messages after user message (including the user message for image requests
// since generateImage/editImage will re-add it)
this.messages = this.messages.slice(0, lastUserIndex);
this.updateActiveConversation();
switch (requestType) {
case "image-generation":
@@ -1792,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
@@ -1989,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)
@@ -2396,7 +2413,7 @@ class AppStore {
let streamedContent = "";
let streamedThinking = "";
let serverTpsReceived = false;
interface ChatCompletionChunk {
choices?: Array<{
delta?: { content?: string; reasoning_content?: string };
@@ -2461,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;
@@ -2512,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)
+260 -195
View File
@@ -42,6 +42,10 @@
setSelectedChatModel,
selectedChatModel,
sendMessage,
thinkingEnabled,
generateImage,
editImage,
editingImage,
messages,
debugMode,
toggleDebugMode,
@@ -834,6 +838,52 @@
if (!model?.tasks) return false;
return model.tasks.includes("ImageToImage");
}
// Route a message to the correct endpoint based on model capabilities.
// Image models go to generateImage/editImage; text models go to sendMessage.
function routeMessage(
content: string,
files?: {
id: string;
name: string;
type: string;
textContent?: string;
preview?: string;
}[],
) {
const model = selectedChatModel();
if (!model) {
sendMessage(content, files, thinkingEnabled());
return;
}
const currentEditImage = editingImage();
// Image editing mode (explicit edit or attached image with ImageToImage model)
if (currentEditImage && content && modelSupportsImageEditing(model)) {
editImage(content, currentEditImage.imageDataUrl);
return;
}
if (
modelSupportsImageEditing(model) &&
files?.length &&
files[0].preview &&
content
) {
editImage(content, files[0].preview);
return;
}
// Text-to-image generation
if (modelSupportsImageGeneration(model) && content) {
generateImage(content);
return;
}
// Default: text chat
sendMessage(content, files, thinkingEnabled());
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
type InstanceMeta = "MlxRing" | "MlxJaccl";
@@ -1486,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) {
@@ -1526,46 +1586,118 @@
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
if (downloadKind !== "DownloadOngoing") continue;
if (!downloadPayload) continue;
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (!downloadModelId || 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",
};
}
// 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;
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;
}
isDownloading = true;
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;
const pct =
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: pendingDownloaded > 0 ? "partial" : "pending",
percentage: pct,
progress: null,
});
continue;
}
// DownloadOngoing
const 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);
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;
@@ -1582,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,
@@ -1595,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 {
@@ -1634,100 +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") continue;
if (!downloadPayload) continue;
// Check if this download is for this instance's model
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
isDownloading = true;
const 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,
@@ -1739,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,
};
}
@@ -2786,7 +2845,7 @@
// Running model is same or better tier — use it directly
setSelectedChatModel(bestRunning.id);
if (!chatStarted) createConversation();
sendMessage(content, files);
routeMessage(content, files);
return;
}
}
@@ -2803,7 +2862,7 @@
if (hasRunningInstance(autoModel.id)) {
setSelectedChatModel(autoModel.id);
if (!chatStarted) createConversation();
sendMessage(content, files);
routeMessage(content, files);
return;
}
@@ -2956,7 +3015,7 @@
if (pendingAutoMessage) {
const msg = pendingAutoMessage;
pendingAutoMessage = null;
sendMessage(msg.content, msg.files);
routeMessage(msg.content, msg.files);
}
return;
}
@@ -3035,7 +3094,7 @@
// Model is selected and running — send directly
if (model && hasRunningInstance(model)) {
chatLaunchState = "ready";
sendMessage(content, files, null);
routeMessage(content, files);
return;
}
@@ -4516,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"
>
@@ -5255,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(
@@ -5314,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>
@@ -5330,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),
@@ -5813,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={() => {
@@ -6006,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"
>
@@ -6389,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(
@@ -6451,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>
@@ -6468,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),
+14 -2
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 = {
@@ -117,12 +128,13 @@
uvLock = builtins.fromTOML (builtins.readFile ./uv.lock);
mlxPackage = builtins.head (builtins.filter (p: p.name == "mlx" && p.source ? git) uvLock.package);
uvLockMlxVersion = mlxPackage.version;
uvLockMlxRev = builtins.elemAt (builtins.split "#" mlxPackage.source.git) 2;
in
{
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
mlx = pkgs.callPackage ./nix/mlx.nix {
inherit (self'.packages) metal-toolchain;
inherit uvLockMlxVersion;
inherit uvLockMlxVersion uvLockMlxRev;
};
default = self'.packages.exo;
}
+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 -4
View File
@@ -11,6 +11,7 @@
, fmt
, python313Packages
, uvLockMlxVersion
, uvLockMlxRev
}:
assert stdenv.isDarwin;
@@ -41,15 +42,13 @@ let
mlx = stdenv.mkDerivation rec {
pname = "mlx";
version = let v = "0.30.7.dev20260225+257d5692"; in
assert v == uvLockMlxVersion || throw "MLX version mismatch: nix/mlx.nix has ${v} but uv.lock has ${uvLockMlxVersion}. Update both the version and hash in nix/mlx.nix.";
v;
version = uvLockMlxVersion;
pyproject = true;
src = fetchFromGitHub {
owner = "rltakashige";
repo = "mlx-jaccl-fix-small-recv";
rev = "257d5692fc7af6bba3b8afaeb63c549b7d1e43d5";
rev = uvLockMlxRev;
hash = "sha256-GosFIWxIB48Egb1MqJrR3xhsUsQeWdRk5rV93USY6wQ=";
};
+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 -3
View File
@@ -25,8 +25,7 @@ dependencies = [
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"pillow>=11.0,<12.0", # compatibility with mflux
"mflux==0.15.5",
"mflux==0.17.2",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
@@ -62,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 }
@@ -1,6 +1,7 @@
model_id = "mlx-community/DeepSeek-V3.1-4bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
@@ -1,6 +1,7 @@
model_id = "mlx-community/DeepSeek-V3.1-8bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
@@ -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,6 +1,7 @@
model_id = "mlx-community/GLM-4.5-Air-8bit"
n_layers = 46
hidden_size = 4096
num_key_value_heads = 8
supports_tensor = false
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.5-Air-bf16"
n_layers = 46
hidden_size = 4096
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-4bit"
n_layers = 91
hidden_size = 5120
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-6bit"
n_layers = 91
hidden_size = 5120
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-8bit-gs32"
n_layers = 91
hidden_size = 5120
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-Flash-4bit"
n_layers = 47
hidden_size = 2048
num_key_value_heads = 20
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-Flash-5bit"
n_layers = 47
hidden_size = 2048
num_key_value_heads = 20
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-Flash-6bit"
n_layers = 47
hidden_size = 2048
num_key_value_heads = 20
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-4.7-Flash-8bit"
n_layers = 47
hidden_size = 2048
num_key_value_heads = 20
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-5-8bit-MXFP8"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-5-MXFP4-Q8"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/GLM-5"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Kimi-K2-Instruct-4bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "kimi"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Kimi-K2-Thinking"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "kimi"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Kimi-K2.5"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "kimi"
@@ -0,0 +1,12 @@
model_id = "mlx-community/Llama-3.1-Nemotron-70B-Instruct-HF-4bit"
n_layers = 80
hidden_size = 8192
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
[storage_size]
in_bytes = 39688355840
@@ -0,0 +1,12 @@
model_id = "mlx-community/Llama-3.1-Nemotron-70B-Instruct-HF-8bit"
n_layers = 80
hidden_size = 8192
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
[storage_size]
in_bytes = 74964549632
@@ -0,0 +1,12 @@
model_id = "mlx-community/Llama-3.1-Nemotron-70B-Instruct-HF-bf16"
n_layers = 80
hidden_size = 8192
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
[storage_size]
in_bytes = 141107412992
@@ -0,0 +1,12 @@
model_id = "mlx-community/Llama-3.1-Nemotron-Nano-4B-v1.1-4bit"
n_layers = 32
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
[storage_size]
in_bytes = 2538706944
@@ -0,0 +1,12 @@
model_id = "mlx-community/Llama-3.1-Nemotron-Nano-4B-v1.1-8bit"
n_layers = 32
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
[storage_size]
in_bytes = 4794980352
@@ -0,0 +1,12 @@
model_id = "mlx-community/Llama-3.1-Nemotron-Nano-4B-v1.1-bf16"
n_layers = 32
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
[storage_size]
in_bytes = 9025492992
@@ -1,6 +1,7 @@
model_id = "mlx-community/Llama-3.2-1B-Instruct-4bit"
n_layers = 16
hidden_size = 2048
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Llama-3.2-3B-Instruct-4bit"
n_layers = 28
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Llama-3.2-3B-Instruct-8bit"
n_layers = 28
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Llama-3.3-70B-Instruct-4bit"
n_layers = 80
hidden_size = 8192
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Llama-3.3-70B-Instruct-8bit"
n_layers = 80
hidden_size = 8192
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Meta-Llama-3.1-70B-Instruct-4bit"
n_layers = 80
hidden_size = 8192
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
n_layers = 32
hidden_size = 4096
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Meta-Llama-3.1-8B-Instruct-8bit"
n_layers = 32
hidden_size = 4096
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Meta-Llama-3.1-8B-Instruct-bf16"
n_layers = 32
hidden_size = 4096
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
@@ -1,6 +1,7 @@
model_id = "mlx-community/MiniMax-M2.1-3bit"
n_layers = 61
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "minimax"
@@ -1,6 +1,7 @@
model_id = "mlx-community/MiniMax-M2.1-8bit"
n_layers = 61
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "minimax"
@@ -1,6 +1,7 @@
model_id = "mlx-community/MiniMax-M2.5-4bit"
n_layers = 62
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "minimax"
@@ -1,6 +1,7 @@
model_id = "mlx-community/MiniMax-M2.5-6bit"
n_layers = 62
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "minimax"
@@ -1,6 +1,7 @@
model_id = "mlx-community/MiniMax-M2.5-8bit"
n_layers = 62
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "minimax"
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-4Bit"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 17775342336
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-5Bit"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "5bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 21721476864
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-6Bit"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "6bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 25667611392
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-8Bit"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "8bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 33559880448
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-BF16"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "bf16"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 63155889408
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-MXFP4"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 16788808704
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-NVFP4"
n_layers = 52
hidden_size = 2688
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
[storage_size]
in_bytes = 19323906944
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-Nano-9B-v2-4bits"
n_layers = 56
hidden_size = 4480
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "4bit"
base_model = "NVIDIA Nemotron-Nano-9B-v2"
capabilities = ["text"]
[storage_size]
in_bytes = 5002791936
@@ -0,0 +1,12 @@
model_id = "mlx-community/NVIDIA-Nemotron-Nano-9B-v2-6bit"
n_layers = 56
hidden_size = 4480
supports_tensor = true
tasks = ["TextGeneration"]
family = "nemotron"
quantization = "6bit"
base_model = "NVIDIA Nemotron-Nano-9B-v2"
capabilities = ["text"]
[storage_size]
in_bytes = 7224298496
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-0.6B-4bit"
n_layers = 28
hidden_size = 1024
num_key_value_heads = 8
supports_tensor = false
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-0.6B-8bit"
n_layers = 28
hidden_size = 1024
num_key_value_heads = 8
supports_tensor = false
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-235B-A22B-Instruct-2507-4bit"
n_layers = 94
hidden_size = 4096
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit"
n_layers = 94
hidden_size = 4096
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-30B-A3B-4bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-30B-A3B-8bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit"
n_layers = 62
hidden_size = 6144
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-480B-A35B-Instruct-8bit"
n_layers = 62
hidden_size = 6144
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-Next-4bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-Next-5bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-Next-6bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-Next-8bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Coder-Next-bf16"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Next-80B-A3B-Instruct-4bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Next-80B-A3B-Instruct-8bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Next-80B-A3B-Thinking-4bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3-Next-80B-A3B-Thinking-8bit"
n_layers = 48
hidden_size = 2048
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-4bit"
n_layers = 48
hidden_size = 3072
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-6bit"
n_layers = 48
hidden_size = 3072
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
@@ -1,6 +1,7 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-8bit"
n_layers = 48
hidden_size = 3072
num_key_value_heads = 2
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"

Some files were not shown because too many files have changed in this diff Show More