Compare commits

..
Author SHA1 Message Date
Evan efa0da0912 wah 2026-04-22 12:51:10 +01:00
Evan Quiney df332035ef swap camelcasemodels for frozenmodels globally (#1957) 2026-04-22 11:49:25 +00:00
ciaranbor af673845d3 Ignore HF remote repo changes (temporary fix) (#1958)
## Motivation

Fixes #1918. Downloaded model status reverts from "completed" to
"pending" during each download scan. Reproduced with `zai-org/GLM-5.1`.

## Changes

- `coordinator.py`: In the periodic rescan, don't downgrade
already-completed models; fall back to `resolve_existing_model()`
(safetensors weight check) when per-file size check reports incomplete
- New `test_download_status_not_lost.py`: 3 regression tests

## Why It Works

The rescan compares local file sizes against HF's `main` revision. When
HF updates text files (README, jinja, etc.), remote sizes change but
local files still match the old revision — causing a false "incomplete".
The fix uses the safetensors weight check as ground truth instead.

Long-term: pin the downloaded revision SHA rather than always checking
against `main`.

## Test Plan

### Manual Testing

- Mac Studio M3 Ultra with GLM-5.1 downloaded (natural reproduction of
the issue)
- Confirmed GLM-5.1 stays `DownloadCompleted` through multiple rescan
cycles

### Automated Testing

- 3 new tests: completed-not-downgraded, fallback-to-resolve,
genuinely-incomplete-stays-pending
2026-04-22 11:11:01 +01:00
ciaranbor 49670c8624 Handle missing total_size in safetensors index files (#1956)
## Motivation

Image models fail to load after a mid-download instance deletion and
recreation. The system skips the download and crashes with
`FileNotFoundError: No safetensors files found in .../vae`.

## Changes

- Make `ModelSafetensorsIndexMetadata.total_size` optional (`PositiveInt
| None = None`)
- Add null guard in `fetch_safetensors_size`
- Add regression test

## Why It Works

Exolabs quantized image models have safetensors index files with mflux
metadata (`quantization_level`, `mflux_version`) but no `total_size`.
The required `PositiveInt` field caused Pydantic validation to fail,
which was silently swallowed by `except Exception: continue` in
`_scan_model_directory`. This skipped all weight map checks, making
incomplete models appear complete.

## Test Plan

### Manual Testing

- Hardware: Mac Studio
- Before: `CreateRunner → LoadModel` (crash). After: `CreateRunner →
DownloadModel` (correct).

### Automated Testing

- `test_safetensors_index.py`: 3 cases covering missing, valid, and null
metadata
2026-04-21 16:39:14 +01:00
rltakashige fcc3718efb Add sampling defaults (#1947)
## Motivation

Model quality issues

### Manual Testing
TODO
2026-04-21 06:45:33 +00:00
rltakashige 8ccfd7fcb6 Fix some misc build issues (#1948)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-21 07:41:07 +01:00
Evan Quiney 7b416155de bump uv lock for linux builds (#1942)
followup from a rebase issue in #1874
2026-04-20 11:41:40 +00:00
Evan Quiney 93a24748e6 mlx cuda 13 (dgx spark) support (#1874) 2026-04-20 11:49:12 +01:00
Evan Quiney e32829e51d chore: bump versions in line with release (#1941) 2026-04-20 09:39:28 +00:00
rltakashige 09e894dd52 Fix vision models on M5 Pro/Max MacBooks (#1927)
## Motivation

Vision models don't understand images on M5 series MacBooks. The
upstream NAX addmm fix (https://github.com/ml-explore/mlx/pull/3422)
fixes this.

## Why It Works
Same conclusion I came to when I was debugging the issue on an M5 Max.
It works after this fix.

## Test Plan

### Manual Testing
Works for Qwen3.5 27B
2026-04-19 12:55:20 +01:00
rltakashige bf8aacfd41 Improve build CI (#1920)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-17 20:55:15 +00:00
af9e847edb fix: force gc + clear_cache after KV prefix cache eviction (#1832)
## Summary
- After `KVPrefixCache` evicts LRU entries, the MLX Metal buffers stay
allocated until Python's GC runs
- This leaks ~3-4 GB between long-context requests, reducing the
effective context ceiling for back-to-back requests
- Adding `gc.collect()` + `mx.clear_cache()` after eviction frees Metal
buffers promptly

## Test plan
- [x] Measured on 2-node PP cluster with Qwen3.5-397B-A17B-4bit at 63K
context
- [x] Before: 108.88 GB retained after eviction (3.78 GB above baseline)
- [x] After: 105.48 GB retained after eviction (0.38 GB above baseline —
draft model KV + minor overhead)
- [x] `gc.collect()` adds ~2-3ms latency, runs once per eviction cycle
(not per token)
- [ ] Verify with `uv run pytest`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Adam Durham <adam@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: rltakashige <rl.takashige@gmail.com>
2026-04-17 17:57:32 +00:00
mlpy0 01598960bd Add model card for Qwen3.6-35B-A3B-8bit (#1917)
Adds the 8bit variant missing from #1907 — the safetensors index is now
live on HF.

- `mlx-community/Qwen3.6-35B-A3B-8bit` (~35 GB)

Architectural fields match the existing 4bit/5bit/bf16 cards.
`storage_size.in_bytes` is taken from `metadata.total_size` of the
upstream `model.safetensors.index.json`.
2026-04-17 10:06:23 +00:00
63b8e64715 Add model cards for Qwen3.6-35B-A3B variants (#1907)
## Motivation

`mlx-community` has just published the new **Qwen3.6-35B-A3B**
multimodal MoE family on HuggingFace. Without static model cards exo
doesn't surface these models in the dashboard picker or match its
placement / prefill logic, so users can't one-click launch them. This PR
adds cards for the three quants whose safetensors indexes are already
live on HF (4bit / 5bit / bf16).

## Changes

Three new TOML files in `resources/inference_model_cards/`:

- `mlx-community--Qwen3.6-35B-A3B-4bit.toml` (~19 GB)
- `mlx-community--Qwen3.6-35B-A3B-5bit.toml` (~23 GB)
- `mlx-community--Qwen3.6-35B-A3B-bf16.toml` (~65 GB)

All three share the same architectural fields (`n_layers = 40`,
`hidden_size = 2048`, `num_key_value_heads = 2`, `context_length =
262144`, capabilities `text, thinking, thinking_toggle, vision`,
`base_model = "Qwen3.6 35B A3B"`) — only `model_id`, `quantization`, and
`storage_size.in_bytes` differ between variants.

## Why It Works

- Qwen3.6-35B-A3B reuses the `qwen3_5_moe` architecture
(`Qwen3_5MoeForConditionalGeneration`) — the same one already wired into
exo's MLX runner at `src/exo/worker/engines/mlx/auto_parallel.py:47` via
`Qwen3_5MoeModel`. The architectural fields are taken verbatim from the
HF `config.json.text_config` and match the existing `Qwen3.5-35B-A3B-*`
cards.
- Storage sizes are the exact `metadata.total_size` read from each
variant's `model.safetensors.index.json` on HF, so download progress and
cluster-memory-fit checks are accurate.
- Vision support is flagged in `capabilities`; the `[vision]` block is
auto-detected by `ModelCard._autodetect_vision` from the upstream
`config.json`, so no hand-written vision config is required.
- The card loader (`_refresh_card_cache` in
`src/exo/shared/models/model_cards.py`) globs every `.toml` in
`resources/inference_model_cards/` on startup, so nothing else needs to
change — the `/models` endpoint and the dashboard picker pick them up
automatically.

The `mxfp4` / `mxfp8` / `nvfp4` variants are still uploading upstream
(index JSONs currently 404) and can be added in a follow-up PR once HF
completes.

## Test Plan

### Manual Testing

Hardware: MacBook Pro M4 Max, 48 GB unified memory.

- Built the dashboard, ran `uv run exo`, waited for the API to come up
on `http://localhost:52415`.
- `curl -s http://localhost:52415/models` returns the three new model
ids (`mlx-community/Qwen3.6-35B-A3B-{4bit,5bit,bf16}`) alongside
existing models.
- Opened the dashboard, clicked SELECT MODEL, typed "Qwen3.6" into the
search box. A single **"Qwen3.6 35B A3B"** group appears showing `3
variants (19GB-65GB)`. Expanding it lists the `4bit` / `5bit` / `bf16`
quants with sizes `19GB` / `23GB` / `65GB`, exactly as expected:

![Qwen3.6 35B A3B in model
picker](https://gist.githubusercontent.com/AlexCheema/68c2c02da9450b44968e6b0e0b1d255e/raw/127119f70382353c65a847188e5a2c9013db68d2/qwen36-picker.png)

- Programmatically loaded each TOML via `ModelCard.load_from_path(...)`
and confirmed the parsed fields (layers / hidden / KV heads / context /
quant / base_model / caps / bytes) match what's written in the files.

### Automated Testing

No code paths were touched — these are pure TOML data files that plug
into the existing model-card loader. The existing pytest suite covers
TOML parsing and card serving; adding new TOMLs doesn't require new test
scaffolding. `uv run ruff check` and `nix fmt` are clean.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ryuichi Leo Takashige <rl.takashige@gmail.com>
2026-04-16 23:25:26 +01:00
rltakashige 28c797846a Update mlx and mlx lm to latest (#1906)
Just bumping to the very latest upstream versions.
2026-04-16 10:59:33 +00:00
rltakashige 058bb08261 Allow copying on dashboard even on HTTP (#1902)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-15 23:30:12 +01:00
rltakashige 3eead80238 Better environment variables in MacOS app (#1901)
## Motivation

Closes #1858 

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-15 20:14:52 +00:00
rltakashige 87329c80ef Add usage stats to tool calls and handle multiple tool calls correctly (#1899)
## Motivation

Tool calls are usually not end tokens, so they didn't have usage stats.
2026-04-15 19:40:00 +01:00
rltakashige 8cdc833892 Drain tokens silently skipped in thinking parsing (#1898)
## Motivation
Closes #1882
2026-04-15 14:23:07 +00:00
rltakashige 2cd66ae4cf Fix out of order event idx causing fatal crashes (#1894)
## Motivation

<img width="828" height="373" alt="Screenshot 2026-04-14 at 22 56 52"
src="https://github.com/user-attachments/assets/f8f48c1d-68c5-4acc-a6de-9d180672da9d"
/>

if is_new_master=True, _elect_loop creates a new EventRouter before the
worker has receivers. Then, event router runs _run_ext_in and
buf.drain_indexed() will pick off events, even though
self.internal_outbound is not populated fully.

Finally, when the worker does try requesting events, the next event it
receives is not the first event, meaning the worker crashes.

## Changes

Start the event router after all the receivers are registered

## Why It Works

self.internal_outbound is populated before the loop begins.

## Test Plan

### Manual Testing
No more crashes observed in testing (it's actually quite easy to
reproduce the issue if you have one node with this fix but the other
node on main).

I'm convinced this is a fix, at least.
2026-04-15 08:46:02 +00:00
rltakashige 2ecefa0cfe Fix Qwen3-VL and autodetect vision config (#1893)
## Motivation

Qwen3 VL TP doesn't work atm, and vision is not behaving.

## Test Plan

### Manual Testing
Works now.
2026-04-14 23:05:55 +01:00
rltakashige b8eaf707a8 Add gemma 4 tensor parallelism (#1891) 2026-04-14 20:31:59 +01:00
rltakashige 8d81811b89 Try harder to clean up processes nicely (#1889)
## Motivation

Model loading is actually quite reliable now. No need to kill if you
have a slow SSD or it's a massive model; the user can shut the instance
down if necessary.

This was a major cause of signal=9 issues although not the only one (can
happen during inference too?).
The reason signal=9 is so bad is that RDMA will no longer work until
restart if this ever happens.

## Changes

- no more model load timeout
- no more crazy sigkills
- try harder to clean up processes on model shutdown

## Test Plan

### Manual Testing
Tested with some RDMA instances
2026-04-14 16:37:49 +01:00
rltakashige f2709dcde6 Add prefix cache flag to exo bench (#1888)
## Motivation
For using Exo-Bench extensively, there are many cases that we could use
prefix caching to speed up the benchmarks, especially when the focus is
on the token generation.

At the same time, it's very clear that prefix caching decode tokens is
not very useful in most current scenarios. Surprisingly, even for
non-thinking models, the chat template means that a continued
conversation will be formatted such that the existing cache is not
effective.

We already (slightly accidentally) do this for the batch generator - we
should do it for the sequential generator too.

## Changes

We can now speed up exo bench by having a use prefix caching flag. Of
course, for most accurate pp results, it is better to not have it, but
this speeds up tg and large benchmarking significantly.
Updated methodology to match

## Test Plan

### Manual Testing
Tested on many configurations that the difference in results is
negligible, even with multiple --pp options.
2026-04-14 11:12:58 +01:00
ciaranbor 77ffe039b3 Complete responses api usage response field (#1885)
## Motivation

The Responses API usage response was missing `input_tokens_details` and
`output_tokens_details`. The chat completions API already reports these.

## Changes

- Added `InputTokensDetails` (`cached_tokens`) and `OutputTokensDetails`
(`reasoning_tokens`) to `ResponseUsage`
- Extracted shared `_build_response_usage()` helper for both streaming
and non-streaming paths

## Test Plan

### Manual Testing

4-node cluster, `Qwen3-30B-A3B-4bit` — verified both detail objects
present with correct values in streaming and non-streaming responses.

### Automated Testing

13 tests in `test_openai_responses_api.py`.
2026-04-13 17:38:33 +00:00
rltakashige 3f0df404a5 Reduce memory consumption by adding Flash Attention to Qwen3.5 and Gemma 4, and fix RotatingKVCache prefix cache memory leak (#1886)
## Motivation

Part 1 of many memory improvements.

## Changes
As written in the title

## Test Plan

### Manual Testing
Gemma 4 26B cache reduced from 54GB -> 10GB per 100k tokens, Qwen3.5 35B
A3B cache reduced from 21GB every 100000 tokens to 7GB.
2026-04-13 18:32:17 +01:00
Evan Quiney 9b381f7bfe bump and simplify flake (#1866)
seems like stablepkgs swiftfmt works now! also bump macmon to 0.7
2026-04-13 15:45:17 +00:00
Alex Cheema d2f67b5d10 dashboard: group Gemma under Google with proper logo (#1883)
## Motivation

In the dashboard model picker sidebar, the Gemma 4 models were showing
up under a "Gemma" family with the generic fallback tick/checkmark icon
(the default case in `FamilyLogos.svelte`), since no dedicated logo
branch existed for `family === "gemma"`. Every other vendor (Meta,
NVIDIA, OpenAI, DeepSeek, Qwen, …) has its own brand mark.

Gemma is Google's model family, so it should live under a **Google**
bucket that future Google-authored models can join, and it should render
with a proper Google logo in the same style as its neighbors.

## Changes

- `dashboard/src/lib/components/FamilyLogos.svelte`: added a `family ===
"google"` branch rendering a monochrome Google "G" as a single `<path>`
inside the shared `24×24` viewBox with `fill="currentColor"`, matching
the other vendor logos.
- `dashboard/src/lib/components/FamilySidebar.svelte`: added `google:
"Google"` to the `familyNames` display map.
- `dashboard/src/lib/components/ModelPickerModal.svelte`: inserted
`"google"` into the `familyOrder` array (next to `"llama"`) so the
vendor has a deterministic sort position.
- `resources/inference_model_cards/mlx-community--gemma-4-*.toml` (16
files): changed `family = "gemma"` → `family = "google"`. `base_model =
"Gemma 4 …"` is unchanged, so the model titles still read "Gemma".

## Why It Works

The sidebar builds its family list from whatever values appear in
`model.family` across the loaded model cards (`ModelPickerModal.svelte`
`uniqueFamilies`). Renaming the family string on the 16 Gemma cards from
`"gemma"` to `"google"` collapses them into a single "Google" bucket,
and the new logo branch + display-name map entry gives that bucket a
real brand mark and label. All other logos share the same `w-6 h-6 /
viewBox="0 0 24 24" / fill="currentColor"` shape, so inheriting
`text-exo-yellow` / `text-white/50` just works.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro M3 Max -->
- `cd dashboard && npm install && npm run build` — dashboard builds
cleanly.
- `uv run exo`, opened `http://localhost:52415`, clicked **SELECT
MODEL**:
- sidebar shows a **Google** entry with a monochrome Google "G" logo in
the same style as Meta / NVIDIA / etc.
  - old "Gemma" entry with the generic tick is gone.
- clicking **Google** filters to the Gemma 4 variants (e2b / e4b / 26B
A4B / 31B).
- hover/selected color states switch between `text-white/50` and
`text-exo-yellow` correctly.

### Automated Testing
- No new tests — this is a cosmetic grouping/logo change. Existing
dashboard build verifies the Svelte + TS compiles.
2026-04-13 14:08:15 +00:00
8973503322 fix: use configured api_port for IP connectivity probes (#1877)
## Motivation

Fixes #1861

When `--api-port` is set to a non-default value (e.g., `--api-port
55555`), the IP connectivity discovery system still probes peers on the
hardcoded default port 52415. Since the API is not listening on 52415,
all reachability checks fail, the topology reports zero reachable nodes,
and the dashboard shows "No valid configurations for current settings."

## Changes

Thread the configured `api_port` from `Args` through `Worker` into the
reachability probe functions:

- `net_profile.py`: `check_reachability()` and `check_reachable()`
accept an `api_port` parameter (default 52415 for backward
compatibility)
- `worker/main.py`: `Worker` stores `api_port` and passes it to
`check_reachable()`, uses it in `Multiaddr` construction and the mDNS
connection filter
- `main.py`: passes `args.api_port` to the `Worker` constructor

## Why It Works

The `/node_id` endpoint used by reachability probes is served by the
FastAPI app, which binds to `args.api_port`. The probes must use the
same port the API is actually listening on. Before this fix, the port
was hardcoded in three places in `net_profile.py` and `worker/main.py`;
now it uses the value from the CLI flag.

## Test Plan

### Manual Testing
<!-- Hardware: not available for multi-node testing -->
- Verified ruff passes on all changed files
- Code inspection: traced `api_port` flow from `Args.parse()` →
`Node.create()` → `Worker.__init__()` → `_poll_connection_updates()` →
`check_reachable()` → `check_reachability()` → HTTP probe URL

### Automated Testing
- No existing automated tests cover the reachability probe code path
- The new `api_port` parameter defaults to `52415`, so all existing
behavior is preserved when `--api-port` is not specified

---------

Co-authored-by: lawrence3699 <lawrence3699@users.noreply.github.com>
Co-authored-by: Evan <evanev7@gmail.com>
2026-04-13 14:01:42 +00:00
Alex CheemaandClaude Opus 4.6 eb9228615f models: add MiniMax M2.7 cards (#1884)
## Motivation

The mlx-community [MiniMax-M2.7
collection](https://huggingface.co/collections/mlx-community/minimax-m27)
landed but exo didn't have model cards for any of the variants yet, so
they weren't selectable from the dashboard model picker. Adding cards
also makes them discoverable under the existing MiniMax family entry.

## Changes

Added 6 new model cards in `resources/inference_model_cards/`, one per
quant of MiniMax M2.7:

- `mlx-community--MiniMax-M2.7.toml` (bf16, full precision — 457 GB)
- `mlx-community--MiniMax-M2.7-4bit.toml` (128 GB)
- `mlx-community--MiniMax-M2.7-4bit-mxfp4.toml` (121 GB)
- `mlx-community--MiniMax-M2.7-5bit.toml` (157 GB)
- `mlx-community--MiniMax-M2.7-6bit.toml` (185 GB)
- `mlx-community--MiniMax-M2.7-8bit.toml` (243 GB)

All six use `family = "minimax"` and share `base_model = "MiniMax M2.7"`
so they collapse into a single group in the picker with the existing
MiniMax logo. Architecture fields (`n_layers = 62`, `hidden_size =
3072`, `num_key_value_heads = 8`, `context_length = 196608`) were read
from each repo's `config.json`; `storage_size.in_bytes` was summed from
the HF tree API per repo.

`capabilities = ["text", "thinking"]` follows the existing MiniMax M2.5
cards — the chat template always emits `<think>` tags (no toggle),
matching M2.5 behavior.

## Why It Works

Model cards in `resources/inference_model_cards/` are auto-loaded by
`src/exo/shared/models/model_cards.py::get_model_cards`. The dashboard
picker groups by `base_model` and filters by `family`, so sharing both
across all six variants gives a single "MiniMax M2.7" group under the
MiniMax sidebar entry, with the quant variants exposed as selectable
sub-options.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro M3 Max -->
- Ran `uv run python -c "…await get_model_cards()…"` and confirmed all 6
new cards load with `family=minimax`, `base_model="MiniMax M2.7"`, and
correct quant + byte sizes.
- `cd dashboard && npm run build` then `uv run exo`, opened the model
picker → **MiniMax** family → **MiniMax M2.7** group shows all six quant
variants.

### Automated Testing
- No new automated tests — these are data files validated by the
existing Pydantic `ModelCard` schema at load time.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:47:09 +01:00
MikkoParkkola 4b13735ea3 build: remove pyinstaller temp artifacts (#1868)
removes PyInstaller `build/` leftovers after `just package`
2026-04-11 11:46:03 +00:00
rltakashigeandEvan 196543ce69 Add Gemma 4 + VLM fixes + thinking parsing updates (#1851)
## Motivation
Add support for Gemma 4, including VLM!

## Changes

- Add auto parallel strategies and model cards for Gemma 4
- Normalise Gemma 4's special Vision Transformer handling to be in line
with the rest of our vision processors.
- Also adds reprs to messages and b64 hashes to prevent log spam.

## Test Plan

### Manual Testing
Tested manually on 4bit E2B and 8bit 26B

### Automated Testing
Model onboarding shows small logit diffs.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-11 12:29:33 +01:00
ciaranbor 6172617b00 add env override to macos app (#1869)
## Motivation

- Let users pass/override arbitrary exo env vars from the macOS app
without a code change.

## Changes

- `ExoProcessController.swift`: `CustomEnvironmentVariable` struct +
`@Published` list persisted to `UserDefaults`, injected into the child
process after built-ins.
- `SettingsView.swift`: new **Environment** tab with add/remove rows,
trim + dedup on save, and a POSIX name validator with a warning badge.

## Why It Works

- Custom vars applied last in `makeEnvironment`, so overriding a
built-in works with no special-casing.

## Test Plan

### Manual Testing

- Set `EXO_LIBP2P_NAMESPACE` via the new UI; confirmed override in
`~/.exo/exo_log/exo.log`.
2026-04-10 17:39:55 +01:00
ciaranbor 93a980a61e just package first builds dashboard (#1867)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-10 16:25:30 +00:00
ciaranbor 2962ebee60 Fix pdf inputs on Safari (#1865)
## Motivation

PDF attachments weren't working on Safari

## Changes

Create async readable stream if none exists

## Why It Works
pdfjs-dist requires an async readable stream internally

## Test Plan

### Manual Testing
pdf attachments now work on Safari, still work on Firefox
2026-04-10 14:59:34 +00:00
rltakashigeandEvan abd75ae06c Truncate long logs with repr (#1854)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-10 15:53:48 +01:00
kaiisfree ee2e505b3c fix: handle BrokenResourceError in download progress callback (#1846)
## Summary
- Wraps progress callback `send()` in try/except to gracefully handle
`BrokenResourceError` when the memory stream is closed
- Prevents unhandled `ExceptionGroup` from crashing the process when the
download consumer disconnects during transfer

## Root Cause
The download progress callback sends updates through an anyio memory
object stream. When the receiving end closes (e.g., client disconnect,
timeout, or task cancellation), `send()` raises `BrokenResourceError`.
Inside an anyio `TaskGroup`, this unhandled exception becomes an
`ExceptionGroup` that propagates up and crashes the coordinator.

## Fix
Catch `BrokenResourceError` (and `ClosedResourceError` for completeness)
in the progress callback and handle gracefully — the download continues
but progress updates are silently dropped for disconnected consumers.

Fixes #1844

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-10 11:43:42 +00:00
Evan Quiney f2e6b1ef76 prevent some crash loops (#1827)
extension to #1763 that prevents crash looping in some common scenarios.
2026-04-09 11:34:35 +00:00
ciaranbor e2e17eafb7 Fix reasoning_tokens counting for multi-token thinking tag models (#1848)
## Motivation

`reasoning_tokens` is always 0 in usage stats, even when thinking
content streams correctly via `reasoning_content` SSE deltas. The MLX
generators had their own thinking detection comparing individual
detokenized tokens against think tags — this never fires for models
where tags span multiple tokens (e.g. gpt-oss-120b) or are already in
the prompt.

## Changes

- Removed broken per-token thinking detection from `batch_generate.py`
and `generate.py`
- Added `_count_reasoning_tokens` wrapper in `model_output_parsers.py`
that counts `is_thinking=True` responses and patches the total into
Usage on the final response
- Wired it as the outermost stage of `apply_all_parsers`, so it works
regardless of which parser sets `is_thinking`
- Added 3 tests covering `parse_thinking_models` and `parse_gpt_oss`
paths

## Why It Works

The parser pipeline already correctly sets `is_thinking` on each
response. Counting at the output of `apply_all_parsers` means one
counting point that works for all model types, replacing the duplicate
broken logic in two generators.

## Test Plan

### Manual Testing

- 4-node cluster, `mlx-community/gpt-oss-120b-MXFP4-Q8`
- Main branch: `reasoning_tokens: 0` — fix branch: `reasoning_tokens:
25`

### Automated Testing

- 3 new tests: explicit think tags, `starts_in_thinking=True`, and
gpt-oss Harmony analysis channel
2026-04-09 12:29:35 +01:00
ciaranbor b12cd1b186 Cancel SSE keep-alive when instance is deleted (#1828)
## Motivation

When a model instance is deleted (e.g. node disconnect, manual
teardown), any in-flight SSE streaming connections for that instance
hang indefinitely. The API never closes the response stream, so clients
block forever waiting for more chunks.

## Changes

- Listen for `InstanceDeleted` events in the API event loop
- Add `_close_streams_for_instance()` to find and close any active
text/image generation queues tied to tasks on the deleted instance
- Add unit tests covering text gen, image gen, and
unrelated-instance-not-closed scenarios

## Why It Works

When an instance is deleted, we iterate `state.tasks` to find commands
running on that instance, then close and remove their send-side queue
handles. This causes the SSE generator to terminate, unblocking the
client.

## Test Plan

### Manual Testing
- This was causing issues for me on another branch (integration tests).
Including this fix solved the issue

### Automated Testing
- `test_instance_deleted_stream_cleanup.py`: 3 tests covering text gen
cleanup, image gen cleanup, and ensuring unrelated streams are not
affected
2026-04-08 16:14:28 +01:00
mlpy0 62570227ff Catch ClosedResourceError when forwarding chunks to client queues (#1856)
While stress-testing inference with rapid client cancels mid-stream, I
hit a reproducible crash where the entire exo process exits.

When a client cancels a streaming chat completion partway through, its
receive stream gets closed cleanly via its context manager. The producer
in `API._apply_state` then calls `queue.send(event.chunk)`, which raises
`anyio.ClosedResourceError` rather than `BrokenResourceError`. The
existing handler only catches `BrokenResourceError`, so the exception
propagates through the API task group, kills the Node task group, and
the process exits with `EXO Shutdown complete`.

Trace from one of the crashes:

```
File "exo/api/main.py", line 1818, in _apply_state
    await queue.send(event.chunk)
File "anyio/streams/memory.py", line 212, in send_nowait
    raise ClosedResourceError
anyio.ClosedResourceError
```

The fix is to catch `ClosedResourceError` alongside
`BrokenResourceError` in both queue handlers (text and image), so the
dead queue gets dropped and `_apply_state` keeps running for other
in-flight requests.
2026-04-08 08:50:04 +00:00
Alex CheemaandClaude Opus 4.6 645bc20950 Add Fast Synch Enabled toggle to macOS app settings (#1852)
## Motivation

The exo backend already supports `--fast-synch` / `--no-fast-synch` CLI
flags and the `EXO_FAST_SYNCH` environment variable, but there was no
way to toggle this from the macOS app UI. Users who want fast CPU-to-GPU
synchronization for RDMA with Tensor Parallelism had to use CLI flags.

## Changes

- **ExoProcessController.swift**: Added `fastSynchEnabled`
UserDefaults-backed property and pass `EXO_FAST_SYNCH=on` to the exo
process environment when enabled.
- **SettingsView.swift**: Added a "Performance" section to the Advanced
tab with a "Fast Synch Enabled" toggle, an info icon (ⓘ) tooltip
explaining the feature and trade-offs, and a "Save & Restart" button.

## Why It Works

Follows the exact same pattern as the existing `offlineMode` and
`enableImageModels` settings — UserDefaults persistence, `@Published`
property with `didSet`, environment variable passthrough in
`makeEnvironment()`, and pending state with Save & Restart in the
settings UI. The `EXO_FAST_SYNCH=on` value matches what the Python
backend already reads in `main.py`.

## Test Plan

### Manual Testing
<!-- Hardware: macOS app -->
- Open Settings → Advanced tab → verify "Performance" section with "Fast
Synch Enabled" toggle appears
- Hover the ⓘ icon → verify tooltip explains the feature and GPU lock
trade-off
- Toggle on → click "Save & Restart" → verify process restarts with
`EXO_FAST_SYNCH=on` in env
- Close and reopen Settings → verify the toggle state persists
- Verify "Save & Restart" button is disabled when no changes are pending

### Automated Testing
- Existing settings patterns are well-established; no new automated
tests needed for this UI toggle

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:04:42 +00:00
rltakashige 5757c27dd5 Add download utility script (#1855)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-08 00:58:39 +00:00
Andrei Cravtov fd5b23281c Workspace tweaks (#1849)
## Changes

Mostly chore changes around vscode and jetbrains workspace settings, and
some basedpyright settings tweaks, to allow direnv to work and nixd
autocomplete with flake parts to work
2026-04-07 17:26:29 +00:00
rltakashige 43b3df45fb Fix BatchGenerator in line with upstream refactor (and prevent Qwen3.5 memory leak) (#1835)
## Motivation

MLX LM has had a massive refactor to their BatchGenerator recently.
Since we'd like new features from MLX LM such as Gemma 4, we need to
update the code to handle this.

Additionally this fixes a significant memory leak in GatedDeltaNet (the
difference is quite substantial, up to 1GB every 1000 tokens, explaining
several memory issues users were facing with Qwen3.5 models)

## Testing
Before
<img width="3146" height="884" alt="image"
src="https://github.com/user-attachments/assets/5af0f55a-393c-4a32-9eed-ae43f1611af4"
/>


After (no memory leak, as one of the changes upstream)
<img width="3190" height="892" alt="image"
src="https://github.com/user-attachments/assets/f0bd128d-fd48-40d4-9bbd-50a564beab14"
/>
2026-04-07 11:50:12 +00:00
mlpy0 24420eb10a Fix reasoning_tokens always reported as 0 for thinking models (#1836)
When `enable_thinking` is set, chat templates (Qwen3, DeepSeek, etc.)
append `<think>` to the prompt. The model starts generating thinking
content directly without emitting a `<think>` token in the output
stream.

Both generators initialized `in_thinking = False` and only set it to
`True` on seeing a `<think>` token in output. Since that token was part
of the prompt, the flag never flipped and `reasoning_tokens` stayed at 0
in the usage response.

Fix: initialize `in_thinking` from `detect_thinking_prompt_suffix()`,
which already exists and is used by `model_output_parsers` for routing
thinking content correctly.
2026-04-05 00:05:18 +00:00
rltakashige 59669c1168 Tighten EXO bench concurrency numbers and explain methodology (#1811)
## Motivation

The timings in the batch generator are a little optimistic; a minor
change is needed to make them more correct.

## Changes

Include the time spent in the API in the generation tps and make sure to
send all requests simultaneously
2026-04-05 00:57:52 +01:00
ciaranbor 1d2ce464dc Allow pausing and deleting active downloads (#1829)
## Motivation

No way to pause active downloads or delete partial/failed downloads from
the dashboard.

## Changes

- **Backend:** Added `POST /download/cancel` endpoint with
`CancelDownloadParams`/`CancelDownloadResponse` types. Wires into the
existing `CancelDownload` command + coordinator handler.
- **Dashboard store:** Added `cancelDownload(nodeId, modelId)` function.
- **Dashboard UI:**
  - Pause + delete buttons on active (downloading) cells
  - Delete button on paused/pending and failed cells
- Extracted duplicated SVG icons into `{#snippet}` blocks (`trashIcon`,
`downloadIcon`, `pauseIcon`, `deleteButton`)
- **Tests:** 3 coordinator-level tests for cancel: active download →
pending, nonexistent → no-op, cancel then resume.

## Why It Works

`CancelDownload` command and coordinator handler already existed — just
needed an HTTP endpoint and dashboard wiring. Delete endpoint already
supported all download states.

## Test Plan

### Manual Testing

Started a model download, paused it. Deleted some paused downloads.
Deleted some ongoing downloads.

### Automated Testing

- `test_cancel_active_download_transitions_to_pending` — cancels
in-progress download, asserts `DownloadPending` event and cleanup
- `test_cancel_nonexistent_download_is_noop` — no events emitted
- `test_cancel_then_resume_download` — restart after cancel works
2026-04-02 15:56:33 +01:00
ciaranborandEvan eb6ae9fd3c Prevent failed instance retries (#1763)
## Motivation

Currently, when a runner fails, the master retries the instance. Most of
the time, this causes a loop over failure. Retries need backoff and a
cap.

## Changes

- src/exo/worker/main.py: Before creating a runner, check an exponential
backoff timer per instance. After EXO_MAX_INSTANCE_RETRIES failures,
send DeleteInstance to permanently remove the instance. Record attempts
on Shutdown; reset on InstanceDeleted.
- src/exo/utils/keyed_backoff.py: Add attempts() method to query retry
count
- src/exo/shared/constants.py: Add EXO_MAX_INSTANCE_RETRIES = 3.

## Why It Works

The worker gates CreateRunner tasks behind a KeyedBackoff, adding
exponential delay (2s base, 30s cap) between retries. After 3 failures
the worker sends DeleteInstance, stopping retries entirely. The backoff
resets when the instance is deleted, so a fresh placement starts clean.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-01 21:03:34 +01:00
rltakashige 4688adb5d2 Support PDFs in dashboard (#1822)
Like ChatGPT does, we now send both the extracted text and the image of
each PDF page.
2026-03-31 18:25:40 +01:00
rltakashige d9ed943034 Fix Nemotron cache leak upstream (#1819)
## Motivation
Nemotron Cascade and Nano failing at long decodes.

## Changes

Fixed upstream, just change pyproject and uv lock here.


## Test Plan
### Automated Testing
Tested with a reproduce script upstream
2026-03-30 16:53:21 +00:00
rltakashige c6815bfdce Only update KV prefix cache on a good cache hit (#1817)
## Motivation

Addresses #1816 

## Changes

Update on min prefix cache > min_prefix_hit_length **and** hit ratio >
_MIN_PREFIX_HIT_RATIO_TO_UPDATE
min_prefix_hit_length = max(1000, system prompt length) -> system
prompts must match exactly.

## Test Plan

### Manual Testing
Test on OpenCode and Claude Code
2026-03-30 15:04:38 +01:00
rltakashige 39c39e8199 Integrations helpers (#1810)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-03-30 14:28:41 +01:00
rltakashige e5cb7b80d0 Add SSE-keepalive to not time out on long prefill on clients (#1803)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-03-30 12:18:38 +01:00
rltakashigeandDavid Hind 635801d515 Add multimodality! (#1802)
## Motivation

Images!

TODO (in a future PR): Add audio and video support.

## Test Plan

### Manual Testing
<img width="2652" height="1900" alt="image"
src="https://github.com/user-attachments/assets/7d3a7137-542f-4f94-9193-2c73b7c4a5ec"
/>

<img width="2770" height="1956" alt="image"
src="https://github.com/user-attachments/assets/e3c3a096-8029-4409-97a6-aca31a9a3f24"
/>
<img width="2738" height="1768" alt="image"
src="https://github.com/user-attachments/assets/d70ea37f-cd1d-4a4c-ad08-3beb9fafa380"
/>

(And batching also works)

---------

Co-authored-by: David Hind <davehind@yahoo.co.uk>
2026-03-30 11:52:19 +01:00
rltakashige 2efbb8ab4f Improve exo harness with path state (#1815)
<img width="3224" height="1476" alt="image"
src="https://github.com/user-attachments/assets/d90a7d8a-9fe5-43a1-a715-1ef7ecc15422"
/>
2026-03-30 11:20:46 +01:00
Evan Quiney c6c5a3e73c feat: /state/paths (#1796)
adds a path option to the /state endpoint, allowing you to query
subfields of state without grabbing the whole blob

## test plan
poking around in the api
2026-03-30 10:10:00 +00:00
ArvidSU 10ef7ec9e8 feat: add Firefox AI sidebar (?q=) support to dashboard (#1814)
This PR builds on https://github.com/exo-explore/exo/pull/1677 to enable
custom prompts sent from Firefox `browser.ml.chat` to EXO dashboard
using URL parameters in sidebar for summary and other browser
interactions. See "Summarize page" example below.

## Summary
- Parse `?q=<encoded prompt>` URL parameter on page load and auto-submit
it as a chat message
- Clean up the URL with `history.replaceState` to prevent re-submission
on refresh
- Defer auto-send until both cluster state and model list are loaded so
model auto-selection works correctly

## Context
Firefox's built-in AI sidebar (`about:config: browser.ml.chat.enabled`)
integrates with chat providers by appending the user's prompt as
`?q=<URL-encoded prompt>`. Previously the exo dashboard ignored this
parameter. Users can now configure `http://localhost:52415` as a Firefox
AI chatbot provider.

See: https://support.mozilla.org/en-US/kb/ai-chatbot

## Technical notes
- Frontend-only change in `dashboard/src/routes/+page.svelte`
- Uses a Svelte `$effect` that reacts to `pendingFirefoxQuery`, `data`
(cluster state), and `models.length` — fires exactly once when all three
are ready
- If no model is selected, `handleAutoSend` auto-picks the best
available model; if no model fits memory, a toast is shown
- If a model is selected but not running, the message is queued until
the model loads

## Testing
```
http://localhost:52415/?q=Hello+world
http://localhost:52415/?q=Summarize+this+page%3A+%5Bpage+title%5D+%5Bpage+url%5D
```

<img width="2056" height="1329" alt="image"
src="https://github.com/user-attachments/assets/74463eb4-ca1a-400d-806a-c19ba93147b9"
/>
2026-03-30 11:02:35 +01:00
Evan Quiney 1e51dc89b0 chore: bump exo-version with release version (#1807)
our pyproject.toml version was 0.3.68 - update to .69 in line with
release!!
2026-03-27 11:47:13 +00:00
Alex CheemaandClaude Opus 4.6 5327bdde84 Fix custom model add requiring two attempts + enlarge sidebar buttons (#1805)
## Motivation

Adding a custom model from the Hub tab shows "Added" toast but the model
doesn't appear in the All tab. You have to add it a second time for it
to work. Also, the "All" button in the model picker sidebar is too small
to read comfortably.

## Changes

**Race condition fix (`src/exo/api/main.py`):**
- Call `add_to_card_cache(card)` directly in `add_custom_model()` after
sending the `ForwarderCommand`, before the API response returns

**Sidebar sizing
(`dashboard/src/lib/components/FamilySidebar.svelte`):**
- Increased sidebar min-width from 72/64px to 80/72px
- Increased "All" icon from `w-5 h-5` to `w-6 h-6`
- Increased all sidebar labels from 9px to 11px

## Why It Works

`POST /models/add` sends a `ForwarderCommand(AddCustomModelCard)` and
returns immediately. The frontend then calls `GET /models` which reads
from `_card_cache`. But the cache was only updated by the worker event
handler after the event round-trips through the master — a race the
frontend almost always loses. By updating the cache directly in the API
handler, `GET /models` immediately reflects the new model. The worker's
later `add_to_card_cache` call is idempotent (dict key assignment).

## Test Plan

### Manual Testing
<!-- Hardware: any Mac -->
- Open model picker → Hub tab → add a custom model → verify it appears
in All tab on the first attempt
- Verify sidebar "All" button and other labels are visually larger and
readable

### Automated Testing
- `uv run basedpyright` passes with 0 errors
- `uv run ruff check` passes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:57:00 -07:00
ciaranbor 15f1b61f4c Rework model storage directory management (for external storage) (#1765)
## Motivation

Replace confusing EXO_MODELS_DIR/EXO_MODELS_PATH with clearer
multi-directory support, enabling automatic download spillover across
volumes.

## Changes

- EXO_MODELS_DIRS: colon-separated writable dirs (default always
prepended, first with enough space wins)
- EXO_MODELS_READ_ONLY_DIRS: colon-separated read-only dirs (protected
from deletion)
- select_download_dir(): picks writable dir by free space
- resolve_existing_model(): unified lookup across all dirs
- is_read_only_model_dir(): path-based read-only detection instead of
hardcoded flag
- Updated coordinator, worker, model cards, tests

## Why It Works

Default dir always included so zero-config behavior is unchanged. Disk
space checked at download time for automatic spillover. Read-only status
derived from path, not hardcoded.

## Test Plan

### Manual Testing

- No env vars set → identical behavior
- EXO_MODELS_DIRS=/Volumes/SSD/models → downloads to external storage
- EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs → models found, deletion blocked

### Automated Testing

- 4 new tests in test_xdg_paths.py (prepend, default-only, overlap,
empty read-only)
- Existing tests updated to patch new constants
2026-03-26 17:46:46 +00:00
Michael HarriganandEvan 9034300163 [Fix] Node hang on reelection (#1801)
## Motivation

During master reelection, `_elect_loop` called `worker.shutdown()` (fire
& forget) then immediately created and started a new Worker.

This caused the old runner subprocess's Metal/GPU teardown to race with
the new worker's startup, resulting in `IOConnectUnmapMemory failed:
kr=0xe00002bc` errors and a full node hang requiring `^C`. Same issue
existed for `DownloadCoordinator`.

## Changes

- Added `anyio.Event`-based `_stopped` signal to `Worker` and
`DownloadCoordinator`, set at the end of their `run()` finally blocks
- Added `wait_stopped()` async method to both classes
- Updated `_elect_loop` to `await wait_stopped()` after calling
`shutdown()` on the old Worker and DownloadCoordinator before creating
replacements

## Why It Works

The old Worker's task group contains the RunnerSupervisor tasks, whose
finally blocks join the runner subprocess (with 5s timeout + SIGTERM +
SIGKILL escalation). By awaiting `wait_stopped()`, we guarantee the old
runner process has fully exited — including GPU memory cleanup — before
a new Worker can start and potentially access the GPU. This eliminates
the race without changing the shutdown mechanics themselves.

## Test Plan

### Manual Testing
Hardware: M4 Pro Mac Mini 24GB + M3 Ultra Mac Studio 96GB, connected via
Thunderbolt

**Repro steps:**
1. Start exo on two nodes with a model sharded across both (e.g.
`Josiefied-Qwen3-14B-abliterated-v3-4bit`)
2. Wait for "runner ready" on both
3. `kill -9` the master node
4. Observe the surviving node's re-election behavior

**Before fix (original crash):**
```
[ 11:02:39.0896AM ] Runner supervisor shutting down
[ 11:02:39.0905AM ] bye from the runner
[ 11:02:39.1052AM ] Stopping Worker
IOConnectUnmapMemory failed: kr=0xe00002bc
IOConnectUnmapMemory failed: kr=0xe00002bc
IOConnectUnmapMemory failed: kr=0xe00002bc
IOConnectUnmapMemory failed: kr=0xe00002bc
^C[ 11:03:45 ] ← hung for over a minute, required manual kill
```

**After fix (clean re-election):**
```
[ 12:15:22.4703PM ] runner loaded
[ 12:15:24.1672PM ] runner ready
[ 12:15:33.5393PM ] Waiting for other campaign to finish
[ 12:15:36.5409PM ] Node elected Master
[ 12:15:36.5413PM ] Unpausing API
```
No `IOConnectUnmapMemory` errors, no hang, no `^C` needed.

### Automated Testing
- No existing tests cover the `_elect_loop` re-election path; this is an
integration-level flow requiring a live router/election/worker stack
- All existing tests pass (307/308, 1 pre-existing Rust binding failure)
- basedpyright: 0 errors, ruff: all checks passed

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-03-26 17:28:47 +00:00
rltakashige 1d1dfaa1f3 Don't download original/ and metal/ folders from HF (#1800)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-03-26 13:36:07 +00:00
Evan Quiney 7625213df0 fix: enable macmon if preflight fails (#1799)
missed in #1747, issue #1798.

### the issue

we didn't set the memory poll rate after failling the macmon preflight,
only after failing the followups - as we never ran macmon if preflight
failed, we never hit the followup errors etc.

### testing

requires testing on an m5 pro, but the core issue is solved.
2026-03-26 11:35:22 +00:00
Alex CheemaandClaude Opus 4.6 f318f9ea14 Fix macOS build bundling wrong macmon binary (#1797)
## Motivation

PR #1747 fixed macmon support for M5 Pro/Max by pinning the
`swiftraccoon/macmon` fork in `flake.nix`. This works when running from
source (via Nix) but the distributed macOS `.app` build was still broken
on M5 Pro/Max because it was bundling the wrong macmon.

The error on M5 Pro/Max:
```
macmon preflight failed with return code -6: thread 'main' panicked at src/sources.rs:394:41
```

## Changes

- Removed `macmon` from `brew install` in `build-app.yml` — this was
installing the upstream `vladkens/macmon` which doesn't support M5
Pro/Max
- Added a new step that resolves the pinned macmon fork from the Nix dev
shell (same `swiftraccoon/macmon` at rev `9154d23` already defined in
`flake.nix`) and adds it to `$GITHUB_PATH`
- Added a safety `brew uninstall macmon` to ensure no Homebrew macmon
can shadow the pinned version

## Why It Works

PyInstaller bundles macmon via `shutil.which("macmon")`. Previously this
found the Homebrew (upstream) binary. Now it finds the Nix-overlayed
fork that has M5 Pro/Max support, because `$GITHUB_PATH` prepends the
Nix store path before the PyInstaller step runs.

## Test Plan

### Manual Testing
<!-- Hardware: M5 Pro -->
- Trigger a macOS build and verify the bundled macmon is the pinned fork
- Run the built `.app` on M5 Pro/Max and confirm macmon preflight
succeeds

### Automated Testing
- Existing CI build workflow will validate that the macmon binary is
found and bundled correctly

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:47:48 +00:00
ciaranbor 30fd5aa1cc Prefer higher % downloaded nodes for API placement previews (#1795)
Follow up to https://github.com/exo-explore/exo/pull/1767

Same thing for placement previews through API
2026-03-25 17:26:31 +00:00
ciaranbor 6de14cfedb Support image generation cancellation (#1774)
## Motivation

Support cancelling image generation, similar to existing support for
cancelling text generation

## Changes

- Dashboard (app.svelte.ts): Wire up AbortController for both
generateImage and editImage API calls. On abort, show "Cancelled"
instead of an error. Clean up the controller in finally.
- Pipeline runner (pipeline/runner.py): Introduce a cancel_checker
callback and NaN-sentinel cancellation protocol for distributed
diffusion:
  - _check_cancellation() - only rank 0 polls the cancel callback
- _send() - replaces data with NaN sentinels when cancelling, so
downstream ranks detect cancellation via _recv_and_check()
  - _recv() / _recv_like() wrappers that eval and check for NaN sentinel
  - After cancellation, drains any pending ring recv to prevent deadlock
  - Skips partial image yields and final decode when cancelled
- Image runner (runner/image_models/runner.py): Deduplicate the
ImageGeneration and ImageEdits match arms into a shared
_run_image_task() method. Thread a cancel_checker closure (backed by the
existing cancel_receiver + cancelled_tasks set) into generate_image().
- Plumbing (distributed_model.py, generate.py): Pass cancel_checker
through the call chain.

## Why It Works

- Rank 0 is the only node that knows about task-level cancellation. When
it detects cancellation, it sends NaN tensors instead of real data.
Higher-order ranks detect the NaN sentinel on recv, set their own
_cancelling flag, and propagate NaN forward
- A drain step after the loop prevents the deadlock case where the last
rank already sent patches that the first would never consume.
- For single-node mode, the loop simply breaks immediately on
cancellation.

## Test Plan

### Automated Testing

New tests in src/exo/worker/tests/unittests/test_image
2026-03-25 16:56:04 +00:00
268 changed files with 12390 additions and 2880 deletions

No files matched your search

+7
View File
@@ -1 +1,8 @@
use flake
# creates .venv if doesn't exist and loads its environment
export VIRTUAL_ENV=".venv"
if ! [ -d "./$VIRTUAL_ENV" ]; then
uv venv
fi
layout python
+124 -2
View File
@@ -159,7 +159,7 @@ jobs:
fi
- name: Install Homebrew packages
run: brew install just awscli macmon
run: brew install just awscli
- name: Install UV
uses: astral-sh/setup-uv@v6
@@ -239,10 +239,92 @@ jobs:
# Export keychain path for other steps
echo "BUILD_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV
# ============================================================
# Pre-flight credential / profile validation
# Runs BEFORE the ~16 min build so auth/expiry failures surface in <1 min.
# ============================================================
- name: Validate Apple notarization credentials
env:
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
run: |
# All-or-nothing: either all three creds are set, or none are.
CRED_COUNT=0
for v in "$APPLE_NOTARIZATION_USERNAME" "$APPLE_NOTARIZATION_PASSWORD" "$APPLE_NOTARIZATION_TEAM"; do
[[ -n "$v" ]] && CRED_COUNT=$((CRED_COUNT + 1))
done
if [[ "$CRED_COUNT" -eq 0 ]]; then
echo "No notarization credentials configured — skipping notarization for this build."
exit 0
fi
if [[ "$CRED_COUNT" -ne 3 ]]; then
echo "ERROR: partial notarization credentials set ($CRED_COUNT/3). Aborting before build."
exit 1
fi
# Cheap, ~5s, auth-only call. Fails instantly with a clear message if
# the app-specific password is stale, wrong team-id, etc.
echo "Verifying Apple notarization credentials via notarytool history..."
if ! xcrun notarytool history \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" >/dev/null; then
echo "ERROR: notarytool rejected the provided credentials. Fix before rerunning."
echo "Common causes: app-specific password expired/revoked, wrong team-id,"
echo "Apple ID not on the team, or 2FA not configured for this Apple ID."
exit 1
fi
echo "Apple notarization credentials OK."
- name: Validate provisioning profile expiry
run: |
PROFILE="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/EXO.provisionprofile"
if [[ ! -f "$PROFILE" ]]; then
echo "ERROR: provisioning profile not found at $PROFILE"
exit 1
fi
EXPIRY=$(security cms -D -i "$PROFILE" | plutil -extract ExpirationDate raw -o - - 2>/dev/null || true)
if [[ -z "$EXPIRY" ]]; then
echo "WARNING: could not read ExpirationDate from provisioning profile; skipping expiry check."
exit 0
fi
# Try a couple of known plutil date formats. If none parse, skip the check rather
# than risk a false-positive "expired" block on a format we didn't anticipate.
EXPIRY_EPOCH=""
for fmt in "%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%d %H:%M:%S %z" "%Y-%m-%d %H:%M:%S +0000"; do
if parsed=$(date -j -f "$fmt" "$EXPIRY" +%s 2>/dev/null); then
EXPIRY_EPOCH="$parsed"
break
fi
done
if [[ -z "$EXPIRY_EPOCH" ]]; then
echo "WARNING: could not parse ExpirationDate '$EXPIRY'; skipping expiry check."
exit 0
fi
NOW_EPOCH=$(date +%s)
if [[ "$EXPIRY_EPOCH" -le "$NOW_EPOCH" ]]; then
echo "ERROR: provisioning profile expired on $EXPIRY. Regenerate it before rerunning."
exit 1
fi
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
echo "Provisioning profile valid until $EXPIRY ($DAYS_LEFT days remaining)."
if [[ "$DAYS_LEFT" -lt 14 ]]; then
echo "WARNING: profile expires in under 14 days — regenerate soon."
fi
# ============================================================
# Build the bundle
# ============================================================
- name: Add pinned macmon to PATH
run: |
MACMON_DIR=$(nix develop --command sh -c 'dirname $(which macmon)')
echo "Using macmon from: $MACMON_DIR"
echo "$MACMON_DIR" >> $GITHUB_PATH
# Remove any Homebrew macmon so PyInstaller can't accidentally pick it up
brew uninstall macmon 2>/dev/null || true
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
@@ -298,11 +380,41 @@ jobs:
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
run: |
set -o pipefail
cd output
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
# Fail fast if notarization creds are partial. All-or-nothing.
CRED_COUNT=0
for v in "$APPLE_NOTARIZATION_USERNAME" "$APPLE_NOTARIZATION_PASSWORD" "$APPLE_NOTARIZATION_TEAM"; do
[[ -n "$v" ]] && CRED_COUNT=$((CRED_COUNT + 1))
done
if [[ "$CRED_COUNT" -ne 0 && "$CRED_COUNT" -ne 3 ]]; then
echo "ERROR: partial Apple notarization credentials set ($CRED_COUNT/3). Aborting."
exit 1
fi
/usr/bin/codesign --deep --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" EXO.app
# Pre-flight: verify the signed app BEFORE building DMG and submitting to Apple.
# If this fails, notarization will fail too — cheap way to fail in seconds, not 15 minutes.
echo "===== codesign --verify EXO.app ====="
if ! /usr/bin/codesign --verify --deep --strict --verbose=2 EXO.app; then
echo "ERROR: EXO.app failed codesign verification. Dumping signing status of every executable:"
find EXO.app -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" -o -name "*.framework" \) -print0 |
while IFS= read -r -d '' f; do
printf -- '--- %s\n' "$f"
/usr/bin/codesign -dv --verbose=2 "$f" 2>&1 | sed 's/^/ /' || true
done
exit 1
fi
# Gatekeeper assessment. A failure here strongly predicts notarization rejection.
echo "===== spctl assessment (predicts notarization outcome) ====="
/usr/bin/spctl -a -vvv -t install EXO.app || echo "WARNING: spctl assessment failed — notarization is likely to fail too."
mkdir -p dmg-root
cp -R EXO.app dmg-root/
ln -s /Applications dmg-root/Applications
@@ -310,12 +422,22 @@ jobs:
hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
/usr/bin/codesign --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$DMG_NAME"
echo "===== codesign --verify DMG ====="
if ! /usr/bin/codesign --verify --verbose=2 "$DMG_NAME"; then
echo "ERROR: DMG failed codesign verification."
exit 1
fi
if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then
echo "===== notarytool submit ====="
# `|| true` so set -e doesn't abort before we can echo output / fetch the log.
# We rely on the parsed STATUS below to decide pass/fail.
SUBMISSION_OUTPUT=$(xcrun notarytool submit "$DMG_NAME" \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" \
--wait --timeout 15m 2>&1)
--wait --timeout 15m 2>&1) || true
echo "$SUBMISSION_OUTPUT"
SUBMISSION_ID=$(echo "$SUBMISSION_OUTPUT" | awk 'tolower($1)=="id:" && $2 ~ /^[0-9a-fA-F-]+$/ {print $2; exit}')
-3
View File
@@ -91,9 +91,6 @@ jobs:
nix build .#metal-toolchain
fi
# Build mlx (depends on metal-toolchain)
nix build .#mlx
- name: Build all Nix outputs
run: |
nix flake show --json | jq -r '
+2
View File
@@ -38,3 +38,5 @@ bench/**/*.json
# tmp
tmp/models
/build/exo
/.claude/skills
-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="FacetManager">
<facet type="Python" name="Python facet">
<configuration sdkName="Python 3.13 virtualenv at ~/Desktop/exo/.venv" />
</facet>
</component>
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/scripts/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/exo_pyo3_bindings/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/exo_pyo3_bindings/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/rust/util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/networking/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/networking/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/networking/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/rust/system_custodian/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/.direnv" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/dist" />
<excludeFolder url="file://$MODULE_DIR$/.go_cache" />
<excludeFolder url="file://$MODULE_DIR$/rust/target" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (exo)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Python 3.13 virtualenv at ~/Desktop/exo/.venv interpreter library" level="application" />
</component>
</module>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalDependencies">
<plugin id="systems.fehn.intellijdirenv" />
<plugin id="al.aoli.intellijdirenv" />
</component>
</project>
-14
View File
@@ -1,14 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ourVersions">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="3.14" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>
-3
View File
@@ -4,7 +4,4 @@
<option name="sdkName" value="Python 3.13 (exo)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (exo)" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>
+5 -3
View File
@@ -2,7 +2,7 @@
This type stub file was generated by pyright.
"""
from typing import Callable, Optional, Union
from typing import Any, Callable, Optional, Union
import mlx.core as mx
from base import Module
@@ -13,8 +13,10 @@ def quantize(
bits: int = ...,
*,
mode: str = ...,
class_predicate: Optional[Callable[[str, Module], Union[bool, dict]]] = ...,
): # -> None:
class_predicate: Optional[
Callable[[str, Module], Union[bool, dict[str, Any]]]
] = ...,
) -> None:
"""Quantize the sub-modules of a module according to a predicate.
By default all layers that define a ``to_quantized(group_size, bits)``
+5
View File
@@ -0,0 +1,5 @@
"""
This type stub file was generated by pyright.
"""
__version__ = ...
+281 -95
View File
@@ -3,13 +3,12 @@ This type stub file was generated by pyright.
"""
import contextlib
from dataclasses import dataclass
from typing import Any, Callable, Generator, List, Optional, Tuple, Union
import mlx.core as mx
import mlx.nn as nn
from dataclasses import dataclass
from collections import deque
from typing import Any, Callable, Generator, List, Optional, Sequence, Tuple, Union
from transformers import PreTrainedTokenizer
from .tokenizer_utils import TokenizerWrapper
DEFAULT_PROMPT = ...
@@ -29,6 +28,7 @@ def str2bool(string): # -> bool:
...
def setup_arg_parser(): # -> ArgumentParser:
"""Set up and return the argument parser."""
...
generation_stream: mx.Stream
@@ -43,6 +43,7 @@ def wired_limit(
async eval could be running pass in the streams to synchronize with prior
to exiting the context manager.
"""
...
@dataclass
class GenerationResponse:
"""
@@ -91,7 +92,7 @@ def generate_step(
kv_bits: Optional[int] = ...,
kv_group_size: int = ...,
quantized_kv_start: int = ...,
prompt_progress_callback: Optional[Callable[[int], int]] = ...,
prompt_progress_callback: Optional[Callable[[int, int], None]] = ...,
input_embeddings: Optional[mx.array] = ...,
) -> Generator[Tuple[mx.array, mx.array], None, None]:
"""
@@ -117,7 +118,7 @@ def generate_step(
kv_group_size (int): Group size for KV cache quantization. Default: ``64``.
quantized_kv_start (int): Step to begin using a quantized KV cache.
when ``kv_bits`` is non-None. Default: ``0``.
prompt_progress_callback (Callable[[int], int]): A call-back which takes the
prompt_progress_callback (Callable[[int, int], None]): A call-back which takes the
prompt tokens processed so far and the total number of prompt tokens.
input_embeddings (mx.array, optional): Input embeddings to use instead of or in
conjunction with prompt tokens. Default: ``None``.
@@ -125,6 +126,7 @@ def generate_step(
Yields:
Tuple[mx.array, mx.array]: One token and a vector of log probabilities.
"""
...
def speculative_generate_step(
prompt: mx.array,
@@ -170,6 +172,7 @@ def speculative_generate_step(
Tuple[mx.array, mx.array, bool]: One token, a vector of log probabilities,
and a bool indicating if the token was generated by the draft model
"""
...
def stream_generate(
model: nn.Module,
@@ -177,7 +180,7 @@ def stream_generate(
prompt: Union[str, mx.array, List[int]],
max_tokens: int = ...,
draft_model: Optional[nn.Module] = ...,
**kwargs: object,
**kwargs: Any,
) -> Generator[GenerationResponse, None, None]:
"""
A generator producing text based on the given prompt from the model.
@@ -199,6 +202,7 @@ def stream_generate(
GenerationResponse: An instance containing the generated text segment and
associated metadata. See :class:`GenerationResponse` for details.
"""
...
def generate(
model: nn.Module,
@@ -219,6 +223,9 @@ def generate(
kwargs: The remaining options get passed to :func:`stream_generate`.
See :func:`stream_generate` for more details.
"""
...
def _merge_caches(caches: List[List[Any]]) -> List[Any]: ...
@dataclass
class BatchStats:
"""
@@ -242,10 +249,262 @@ class BatchStats:
generation_time: float = ...
peak_memory: float = ...
class SequenceStateMachine:
"""A state machine that uses one Aho-Corasick trie per state to efficiently
track state across a generated sequence.
The transitions are provided as state -> [(sequence, new_state)].
Example:
sm = SequenceStateMachine(
transitions={
"normal": [
(think_start_tokens, "reasoning"),
(tool_start_tokens, "tool"),
(eos, None),
],
"reasoning": [
(think_end_tokens, "normal"),
(eos, None),
],
"tool": [
(tool_end_tokens, None),
(eos, None)
],
},
initial="normal"
)
"""
def __init__(self, transitions=..., initial=...) -> None: ...
def __deepcopy__(self, memo): # -> SequenceStateMachine:
...
def make_state(self): # -> tuple[str, Any, dict[Any, Any]]:
...
@staticmethod
def match(state, x): # -> tuple[tuple[Any, Any | None, Any], Any | None, Any]:
...
class PromptProcessingBatch:
"""
A batch processor for prompt tokens with support for incremental processing.
This class handles batched prompt processing, managing KV caches and preparing
tokens for generation. It supports extending, filtering, and splitting batches.
"""
@dataclass
class Response:
uid: int
progress: tuple
end_of_segment: bool
end_of_prompt: bool
...
def __init__(
self,
model: nn.Module,
uids: List[int],
caches: List[List[Any]],
tokens: Optional[List[List[int]]] = ...,
prefill_step_size: int = ...,
samplers: Optional[List[Callable[[mx.array], mx.array]]] = ...,
fallback_sampler: Optional[Callable[[mx.array], mx.array]] = ...,
logits_processors: Optional[
List[List[Callable[[mx.array, mx.array], mx.array]]]
] = ...,
state_machines: Optional[List[SequenceStateMachine]] = ...,
max_tokens: Optional[List[int]] = ...,
) -> None: ...
def __len__(self): # -> int:
...
def extract_cache(self, idx: int) -> List[Any]: ...
def extend(self, batch): # -> None:
...
def split(self, indices: List[int]): # -> Self:
...
def filter(self, keep: List[int]): # -> None:
...
def prompt(self, tokens: List[List[int]]): # -> None:
"""
Process prompt tokens through the model.
Args:
tokens: List of token sequences to process.
"""
...
def generate(self, tokens: List[List[int]]): # -> GenerationBatch:
"""
Transition from prompt processing to generation.
Args:
tokens: Final tokens for each sequence to start generation.
Returns:
A GenerationBatch ready for token generation.
"""
...
@classmethod
def empty(
cls,
model: nn.Module,
fallback_sampler: Callable[[mx.array], mx.array],
prefill_step_size: int = ...,
): # -> Self:
...
class GenerationBatch:
"""
A batched token generator that manages multiple sequences in parallel.
This class handles the generation phase after prompt processing, managing
KV caches, sampling, and stop sequence detection for multiple sequences.
"""
@dataclass
class Response:
uid: int
token: int
logprobs: mx.array
finish_reason: Optional[str]
current_state: Optional[str]
match_sequence: Optional[List[int]]
prompt_cache: Optional[List[Any]]
all_tokens: Optional[List[int]]
...
model: nn.Module
uids: List[int]
prompt_cache: List[Any]
tokens: List[List[int]]
samplers: Optional[List[Callable[[mx.array], mx.array]]]
fallback_sampler: Callable[[mx.array], mx.array]
logits_processors: Optional[List[List[Callable[[mx.array, mx.array], mx.array]]]]
state_machines: List[SequenceStateMachine]
max_tokens: List[int]
_current_tokens: Optional[mx.array]
_current_logprobs: List[mx.array]
_next_tokens: mx.array
_next_logprobs: List[mx.array]
_token_context: List[mx.array]
_num_tokens: List[int]
def __init__(
self,
model: nn.Module,
uids: List[int],
inputs: mx.array,
prompt_cache: List[Any],
tokens: List[List[int]],
samplers: Optional[List[Callable[[mx.array], mx.array]]],
fallback_sampler: Callable[[mx.array], mx.array],
logits_processors: Optional[
List[List[Callable[[mx.array, mx.array], mx.array]]]
],
state_machines: List[SequenceStateMachine],
max_tokens: List[int],
) -> None: ...
def __len__(self) -> int: ...
def extend(self, batch: GenerationBatch) -> None: ...
def extract_cache(self, idx: int) -> List[Any]: ...
def filter(self, keep: List[int]) -> None: ...
def _step(self) -> Tuple[List[int], List[mx.array]]: ...
def next(self) -> List[Response]:
"""
Generate the next batch of tokens.
Returns:
List of Response objects for each sequence in the batch.
"""
...
@classmethod
def empty(
cls, model: nn.Module, fallback_sampler: Callable[[mx.array], mx.array]
): # -> Self:
...
class BatchGenerator:
"""
A batch generator implements continuous batching.
This class provides automatic management of prompt processing and generation
batches, handling the transition between the two.
It also allows for segmented prompt processing which guarantees that the
generator will stop at these boundaries when processing an input.
"""
def __init__(
self,
model: nn.Module,
max_tokens: int = ...,
stop_tokens: Optional[Sequence[Sequence[int]]] = ...,
sampler: Optional[Callable[[mx.array], mx.array]] = ...,
logits_processors: Optional[
List[Callable[[mx.array, mx.array], mx.array]]
] = ...,
completion_batch_size: int = ...,
prefill_batch_size: int = ...,
prefill_step_size: int = ...,
) -> None: ...
def close(self) -> None: ...
def __del__(self): # -> None:
...
@contextlib.contextmanager
def stats(self, stats=...): # -> Generator[Any | BatchStats, Any, None]:
...
_unprocessed_sequences: deque[tuple[Any, ...]]
_prompt_batch: PromptProcessingBatch
_generation_batch: GenerationBatch
_currently_processing: list[Any]
_gen_tokens_counter: int
_steps_counter: int
def _next(
self,
) -> tuple[
List[PromptProcessingBatch.Response], List[GenerationBatch.Response]
]: ...
def insert(
self,
prompts: List[List[int]],
max_tokens: Optional[List[int]] = ...,
caches: Optional[List[List[Any]]] = ...,
all_tokens: Optional[List[List[int]]] = ...,
samplers: Optional[List[Callable[[mx.array], mx.array]]] = ...,
logits_processors: Optional[
List[List[Callable[[mx.array, mx.array], mx.array]]]
] = ...,
state_machines: Optional[List[SequenceStateMachine]] = ...,
) -> List[int]: ...
def insert_segments(
self,
segments: List[List[List[int]]],
max_tokens: Optional[List[int]] = ...,
caches: Optional[List[List[Any]]] = ...,
all_tokens: Optional[List[List[int]]] = ...,
samplers: Optional[List[Callable[[mx.array], mx.array]]] = ...,
logits_processors: Optional[
List[List[Callable[[mx.array, mx.array], mx.array]]]
] = ...,
state_machines: Optional[List[SequenceStateMachine]] = ...,
) -> List[int]: ...
def extract_cache(self, uids: List[int]) -> dict[int, Any]: ...
def remove(
self, uids: List[int], return_prompt_caches: bool = ...
) -> dict[int, Any]: ...
@property
def prompt_cache_nbytes(self) -> int: ...
def next(
self,
) -> tuple[
List[PromptProcessingBatch.Response], List[GenerationBatch.Response]
]: ...
def next_generated(self) -> List[GenerationBatch.Response]: ...
@dataclass
class BatchResponse:
"""
An data object to hold a batch generation response.
A data object to hold a batch generation response.
Args:
texts: (List[str]): The generated text for each prompt.
@@ -255,98 +514,17 @@ class BatchResponse:
texts: List[str]
stats: BatchStats
caches: Optional[List[List[Any]]]
def _left_pad_prompts(prompts: Any, max_length: Optional[int] = ...) -> mx.array: ...
def _right_pad_prompts(prompts: Any, max_length: Optional[int] = ...) -> mx.array: ...
def _make_cache(
model: Any, left_padding: Any, max_kv_size: Optional[int]
) -> List[Any]: ...
def _merge_caches(caches: Any) -> List[Any]: ...
@dataclass
class Batch:
uids: List[int]
y: mx.array
logprobs: List[mx.array] | mx.array
max_tokens: List[int]
num_tokens: List[int]
cache: 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: ...
def extend(self, other: "Batch") -> None: ...
def extract_cache(self, idx: int) -> List[Any]: ...
class BatchGenerator:
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:
uid: int
token: int
logprobs: mx.array
finish_reason: Optional[str]
prompt_cache: Any
def __init__(
self,
model: Any,
max_tokens: int = ...,
stop_tokens: Optional[set[int]] = ...,
sampler: Optional[Callable[[mx.array], mx.array]] = ...,
logits_processors: Optional[
List[Callable[[mx.array, mx.array], mx.array]]
] = ...,
completion_batch_size: int = ...,
prefill_batch_size: int = ...,
prefill_step_size: int = ...,
prompt_progress_callback: Optional[
Callable[[List[Tuple[int, int, int]]], None]
] = ...,
max_kv_size: Optional[int] = ...,
) -> None: ...
def close(self) -> None: ...
def insert(
self,
prompts: Any,
max_tokens: Union[List[int], int, None] = ...,
caches: Any = ...,
samplers: Optional[List[Any]] = ...,
logits_processors: Optional[List[Any]] = ...,
) -> List[int]: ...
def remove(
self, uids: List[int], return_prompt_caches: bool = ...
) -> Optional[dict[int, List[Any]]]: ...
def stats(self) -> BatchStats: ...
def next(self) -> List[Response]: ...
def _process_prompts(self, prompts: List[Any]) -> Batch: ...
def _step(
self,
input_tokens: mx.array,
prompt_cache: List[Any],
samplers: Optional[List[Any]],
logits_processors: Optional[List[Any]],
tokens: List[mx.array],
) -> Tuple[mx.array, List[mx.array]]: ...
...
def batch_generate(
model,
tokenizer,
prompts: List[int],
prompts: List[List[int]],
prompt_caches: Optional[List[List[Any]]] = ...,
max_tokens: Union[int, List[int]] = ...,
verbose: bool = ...,
return_prompt_caches: bool = ...,
logits_processors: Optional[List[Callable[[mx.array, mx.array], mx.array]]] = ...,
**kwargs,
) -> BatchResponse:
"""
@@ -355,14 +533,22 @@ def batch_generate(
Args:
model (nn.Module): The language model.
tokenizer (PreTrainedTokenizer): The tokenizer.
prompt (List[List[int]]): The input prompts.
prompts (List[List[int]]): The input prompts.
prompt_caches (List[List[Any]], optional): Pre-computed prompt-caches
for each input prompt. Note, unlike ``generate_step``, the caches
won't be updated in-place.
verbose (bool): If ``True``, print tokens and timing information.
Default: ``False``.
max_tokens (Union[int, List[int]): Maximum number of output tokens. This
can be per prompt if a list is provided.
return_prompt_caches (bool): Return the prompt caches in the batch
responses. Default: ``False``.
logits_processors (List[Callable[[mx.array, mx.array], mx.array]], optional):
A list of functions that take tokens and logits and return the processed logits. Default: ``None``.
kwargs: The remaining options get passed to :obj:`BatchGenerator`.
See :obj:`BatchGenerator` for more details.
"""
...
def main(): # -> None:
...
@@ -0,0 +1,19 @@
"""
This type stub file was generated by pyright.
"""
import mlx.core as mx
import mlx.nn as nn
from functools import partial
@partial(mx.compile, shapeless=True)
def swiglu(gate, x): ...
@partial(mx.compile, shapeless=True)
def xielu(x, alpha_p, alpha_n, beta, eps): # -> array:
...
class XieLU(nn.Module):
def __init__(
self, alpha_p_init=..., alpha_n_init=..., beta=..., eps=...
) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
+31
View File
@@ -0,0 +1,31 @@
from dataclasses import dataclass
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from . import gemma4_text
from .base import BaseModelArgs
from .cache import KVCache, RotatingKVCache
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: Optional[dict[str, Any]]
vocab_size: int
def __post_init__(self) -> None: ...
class Model(nn.Module):
args: ModelArgs
model_type: str
language_model: gemma4_text.Model
def __init__(self, args: ModelArgs) -> None: ...
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@property
def layers(self) -> list[gemma4_text.DecoderLayer]: ...
@property
def quant_predicate(self) -> Any: ...
def make_cache(self) -> list[KVCache | RotatingKVCache]: ...
+179
View File
@@ -0,0 +1,179 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .cache import KVCache, RotatingKVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
num_hidden_layers: int
intermediate_size: int
num_attention_heads: int
head_dim: int
global_head_dim: int
global_partial_rotary_factor: float
rms_norm_eps: float
vocab_size: int
vocab_size_per_layer_input: int
num_key_value_heads: int
num_global_key_value_heads: Optional[int]
num_kv_shared_layers: int
pad_token_id: int
hidden_size_per_layer_input: int
rope_traditional: bool
partial_rotary_factor: float
rope_parameters: Optional[Dict[str, Any]]
sliding_window: int
sliding_window_pattern: int
max_position_embeddings: int
attention_k_eq_v: bool
final_logit_softcapping: float
use_double_wide_mlp: bool
enable_moe_block: bool
num_experts: Optional[int]
top_k_experts: Optional[int]
moe_intermediate_size: Optional[int]
layer_types: Optional[List[str]]
tie_word_embeddings: bool
def __post_init__(self) -> None: ...
class MLP(nn.Module):
gate_proj: nn.Linear
down_proj: nn.Linear
up_proj: nn.Linear
def __init__(self, config: ModelArgs, layer_idx: int = 0) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class Router(nn.Module):
proj: nn.Linear
scale: mx.array
per_expert_scale: mx.array
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> tuple[mx.array, mx.array]: ...
class Experts(nn.Module):
switch_glu: SwitchGLU
def __init__(self, config: ModelArgs) -> None: ...
def __call__(
self, x: mx.array, top_k_indices: mx.array, top_k_weights: mx.array
) -> mx.array: ...
class Attention(nn.Module):
layer_idx: int
layer_type: str
is_sliding: bool
head_dim: int
n_heads: int
n_kv_heads: int
use_k_eq_v: bool
scale: float
q_proj: nn.Linear
k_proj: nn.Linear
v_proj: nn.Linear
o_proj: nn.Linear
q_norm: nn.Module
k_norm: nn.Module
v_norm: nn.Module
rope: nn.Module
def __init__(self, config: ModelArgs, layer_idx: int) -> None: ...
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
class DecoderLayer(nn.Module):
layer_idx: int
layer_type: str
self_attn: Attention
mlp: MLP
enable_moe: bool
router: Router
experts: Experts
input_layernorm: nn.Module
post_attention_layernorm: nn.Module
pre_feedforward_layernorm: nn.Module
post_feedforward_layernorm: nn.Module
post_feedforward_layernorm_1: nn.Module
post_feedforward_layernorm_2: nn.Module
pre_feedforward_layernorm_2: nn.Module
hidden_size_per_layer_input: int
per_layer_input_gate: Optional[nn.Linear]
per_layer_projection: Optional[nn.Linear]
post_per_layer_input_norm: Optional[nn.Module]
layer_scalar: mx.array
def __init__(self, config: ModelArgs, layer_idx: int) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = ...,
cache: Optional[Any] = ...,
per_layer_input: Optional[mx.array] = ...,
shared_kv: Optional[tuple[mx.array, mx.array]] = ...,
offset: Optional[mx.array] = ...,
) -> tuple[mx.array, tuple[mx.array, mx.array], mx.array]: ...
class Gemma4TextModel(nn.Module):
config: ModelArgs
vocab_size: int
window_size: int
sliding_window_pattern: int
num_hidden_layers: int
embed_tokens: nn.Embedding
embed_scale: float
layers: list[DecoderLayer]
norm: nn.Module
hidden_size_per_layer_input: int
embed_tokens_per_layer: Optional[nn.Embedding]
per_layer_model_projection: Optional[nn.Linear]
per_layer_projection_norm: Optional[nn.Module]
previous_kvs: list[int]
def __init__(self, config: ModelArgs) -> None: ...
def __call__(
self,
inputs: Optional[mx.array] = ...,
cache: Optional[list[Any]] = ...,
input_embeddings: Optional[mx.array] = ...,
per_layer_inputs: Optional[mx.array] = ...,
) -> mx.array: ...
def _get_per_layer_inputs(
self,
input_ids: Optional[mx.array],
input_embeddings: Optional[mx.array] = ...,
) -> mx.array: ...
def _project_per_layer_inputs(
self,
input_embeddings: mx.array,
per_layer_inputs: Optional[mx.array] = ...,
) -> mx.array: ...
def _make_masks(self, h: mx.array, cache: list[Any]) -> list[Any]: ...
class Model(nn.Module):
args: ModelArgs
model_type: str
model: Gemma4TextModel
final_logit_softcapping: float
tie_word_embeddings: bool
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@property
def layers(self) -> list[DecoderLayer]: ...
@property
def head_dim(self) -> int: ...
@property
def n_kv_heads(self) -> int: ...
@property
def quant_predicate(self) -> Any: ...
def make_cache(self) -> list[KVCache | RotatingKVCache]: ...
+4
View File
@@ -48,6 +48,10 @@ def make_logits_processors(
logit_bias: Optional[Dict[int, float]] = ...,
repetition_penalty: Optional[float] = ...,
repetition_context_size: Optional[int] = ...,
presence_penalty: Optional[float] = ...,
presence_context_size: Optional[int] = ...,
frequency_penalty: Optional[float] = ...,
frequency_context_size: Optional[int] = ...,
) -> list[Callable[[mx.array, mx.array], mx.array]]:
"""
Make logits processors for use with ``generate_step``.
+2
View File
@@ -117,6 +117,8 @@ class TokenizerWrapper:
think_end: str | None
think_start_id: int | None
think_end_id: int | None
think_start_tokens: list[int] | None
think_end_tokens: list[int] | None
def __init__(
self,
+50
View File
@@ -0,0 +1,50 @@
"""
This type stub file was generated by pyright.
"""
import mlx.nn as nn
class DoRALinear(nn.Module):
@staticmethod
def from_base(
linear: nn.Linear, r: int = ..., dropout: float = ..., scale: float = ...
): # -> DoRALinear:
...
def fuse(self, dequantize: bool = ...): # -> QuantizedLinear | Linear:
...
def __init__(
self,
input_dims: int,
output_dims: int,
r: int = ...,
dropout: float = ...,
scale: float = ...,
bias: bool = ...,
) -> None: ...
def set_linear(self, linear): # -> None:
"""
Set the self.linear layer and recompute self.m.
"""
...
def __call__(self, x): ...
class DoRAEmbedding(nn.Module):
def from_base(
embedding: nn.Embedding, r: int = ..., dropout: float = ..., scale: float = ...
): # -> DoRAEmbedding:
...
def fuse(self, dequantize: bool = ...): # -> Embedding:
...
def __init__(
self,
num_embeddings: int,
dims: int,
r: int = ...,
dropout: float = ...,
scale: float = ...,
) -> None: ...
def set_embedding(self, embedding: nn.Module): # -> None:
...
def __call__(self, x): ...
def as_linear(self, x): ...
+66
View File
@@ -0,0 +1,66 @@
"""
This type stub file was generated by pyright.
"""
import mlx.nn as nn
class LoRALinear(nn.Module):
@staticmethod
def from_base(
linear: nn.Linear, r: int = ..., dropout: float = ..., scale: float = ...
): # -> LoRALinear:
...
def fuse(self, dequantize: bool = ...): # -> QuantizedLinear | Linear:
...
def __init__(
self,
input_dims: int,
output_dims: int,
r: int = ...,
dropout: float = ...,
scale: float = ...,
bias: bool = ...,
) -> None: ...
def __call__(self, x): # -> array:
...
class LoRASwitchLinear(nn.Module):
@staticmethod
def from_base(
linear: nn.Module, r: int = ..., dropout: float = ..., scale: float = ...
): # -> LoRASwitchLinear:
...
def fuse(self, dequantize: bool = ...): # -> QuantizedSwitchLinear | SwitchLinear:
...
def __init__(
self,
input_dims: int,
output_dims: int,
num_experts: int,
r: int = ...,
dropout: float = ...,
scale: float = ...,
bias: bool = ...,
) -> None: ...
def __call__(self, x, indices, sorted_indices=...): ...
class LoRAEmbedding(nn.Module):
@staticmethod
def from_base(
embedding: nn.Embedding, r: int = ..., dropout: float = ..., scale: float = ...
): # -> LoRAEmbedding:
...
def fuse(self, dequantize: bool = ...): # -> QuantizedEmbedding | Embedding:
...
def __init__(
self,
num_embeddings: int,
dims: int,
r: int = ...,
dropout: float = ...,
scale: float = ...,
) -> None: ...
def __call__(self, x): # -> array:
...
def as_linear(self, x): # -> array:
...
+57
View File
@@ -0,0 +1,57 @@
"""
This type stub file was generated by pyright.
"""
import mlx.nn as nn
from typing import Dict
def build_schedule(schedule_config: Dict): # -> Any:
"""
Build a learning rate schedule from the given config.
"""
...
def linear_to_lora_layers(
model: nn.Module, num_layers: int, config: Dict, use_dora: bool = ...
): # -> None:
"""
Convert some of the models linear layers to lora layers.
Args:
model (nn.Module): The neural network model.
num_layers (int): The number of blocks to convert to lora layers
starting from the last layer.
config (dict): More configuration parameters for LoRA, including the
rank, scale, and optional layer keys.
use_dora (bool): If True, uses DoRA instead of LoRA.
Default: ``False``
"""
...
def load_adapters(model: nn.Module, adapter_path: str) -> nn.Module:
"""
Load any fine-tuned adapters / layers.
Args:
model (nn.Module): The neural network model.
adapter_path (str): Path to the adapter configuration file.
Returns:
nn.Module: The updated model with LoRA layers applied.
"""
...
def remove_lora_layers(model: nn.Module) -> nn.Module:
"""
Remove the LoRA layers from the model.
Args:
model (nn.Module): The model with LoRA layers.
Returns:
nn.Module: The model without LoRA layers.
"""
...
def print_trainable_parameters(model): # -> None:
...
View File
Whitespace-only changes.
+12
View File
@@ -0,0 +1,12 @@
from typing import Any
def get_message_json(
model_name: str,
prompt: str,
role: str = "user",
skip_image_token: bool = False,
skip_audio_token: bool = False,
num_images: int = 0,
num_audios: int = 0,
**kwargs: Any,
) -> dict[str, Any]: ...
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
from typing import Any
class ImageProcessor:
def preprocess(
self, images: list[dict[str, Any]], **kwargs: Any
) -> dict[str, Any]: ...
def __call__(self, **kwargs: Any) -> dict[str, Any]: ...
def load_image_processor(
model_path: str | Path, **kwargs: Any
) -> ImageProcessor | None: ...
def load_processor(
model_path: str | Path, add_detokenizer: bool = ..., **kwargs: Any
) -> ImageProcessor: ...
+8
View File
@@ -0,0 +1,8 @@
from typing import Any, Self
class safe_open:
def __init__(self, filename: str, framework: str = "pt") -> None: ...
def __enter__(self) -> Self: ...
def __exit__(self, *args: Any) -> None: ...
def keys(self) -> list[str]: ...
def get_tensor(self, name: str) -> Any: ...
+2 -1
View File
@@ -1,7 +1,8 @@
{
"recommendations": [
"detachhead.basedpyright",
"ms-python.python"
"ms-python.python",
"jnoortheen.nix-ide"
],
"unwantedRecommendations": [
"ms-python.vscode-pylance",
+30 -1
View File
@@ -1,3 +1,32 @@
{
"basedpyright.importStrategy": "fromEnvironment"
"files.associations": {
"*.nix": "nix",
},
"nix.enableLanguageServer": true,
"nix.serverPath": "nixd",
"nix.serverSettings": {
"nixd": {
"formatting": {
"command": ["nixpkgs-fmt"]
},
"nixpkgs": {
"expr": "(builtins.getFlake \"path:${workspaceFolder}\").currentSystem.config._module.args.pkgs"
},
"options": {
"flake-parts": {
"expr": "(builtins.getFlake \"path:${workspaceFolder}\").debug.options"
},
"flake-parts-perSystem": {
"expr": "(builtins.getFlake \"path:${workspaceFolder}\").currentSystem.options"
}
}
}
},
"[nix]": {
"editor.defaultFormatter": "jnoortheen.nix-ide"
},
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"basedpyright.analysis.configFilePath": "${workspaceFolder}/pyproject.toml",
"basedpyright.importStrategy": "fromEnvironment",
}
+2 -2
View File
@@ -19,8 +19,8 @@ To run EXO from source:
- [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 \
cargo install --git https://github.com/vladkens/macmon \
--rev a1cd06b6cc0d5e61db24fd8832e74cd992097a7d \
macmon \
--force
```
+10 -6
View File
@@ -112,8 +112,8 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
Homebrew `macmon 0.6.1` still crashes on Apple M5.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
cargo install --git https://github.com/vladkens/macmon \
--rev a1cd06b6cc0d5e61db24fd8832e74cd992097a7d \
macmon \
--force
```
@@ -295,8 +295,9 @@ exo supports several environment variables for configuration:
| Variable | Description | Default |
|----------|-------------|---------|
| `EXO_MODELS_PATH` | Colon-separated paths to search for pre-downloaded models (e.g., on NFS mounts or shared storage) | None |
| `EXO_MODELS_DIR` | Directory where exo downloads and stores models | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_DEFAULT_MODELS_DIR` | Default directory for model downloads and caches. Always first in the writable dirs list. | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_MODELS_DIRS` | Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
| `EXO_MODELS_READ_ONLY_DIRS` | Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
| `EXO_OFFLINE` | Run without internet connection (uses only local models) | `false` |
| `EXO_ENABLE_IMAGE_MODELS` | Enable image model support | `false` |
| `EXO_LIBP2P_NAMESPACE` | Custom namespace for cluster isolation | None |
@@ -306,8 +307,11 @@ exo supports several environment variables for configuration:
**Example usage:**
```bash
# Use pre-downloaded models from NFS mount
EXO_MODELS_PATH=/mnt/nfs/models:/opt/ai-models uv run exo
# Use pre-downloaded models from NFS mount (read-only)
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
# Download models to an external SSD (falls back to default dir if full)
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
# Run in offline mode
EXO_OFFLINE=true uv run exo
+98
View File
@@ -7,7 +7,27 @@ private let hfTokenKey = "EXOHFToken"
private let hfEndpointKey = "EXOHFEndpoint"
private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let fastSynchEnabledKey = "EXOFastSynchEnabled"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
private let defaultModelsDirKey = "EXODefaultModelsDir"
private let additionalModelsDirsKey = "EXOAdditionalModelsDirs"
private let readOnlyModelsDirsKey = "EXOReadOnlyModelsDirs"
private let customEnvironmentVariablesKey = "EXOCustomEnvironmentVariables"
/// A user-defined environment variable that is injected into the exo child
/// process at launch. Used as an escape hatch for env vars that don't have
/// first-class typed UI in Settings.
struct CustomEnvironmentVariable: Codable, Identifiable, Equatable {
var id: UUID
var key: String
var value: String
init(id: UUID = UUID(), key: String = "", value: String = "") {
self.id = id
self.key = key
self.value = value
}
}
@MainActor
final class ExoProcessController: ObservableObject {
@@ -78,6 +98,60 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(offlineMode, forKey: offlineModeKey)
}
}
@Published var fastSynchEnabled: Bool = {
if UserDefaults.standard.object(forKey: fastSynchEnabledKey) == nil {
return true
}
return UserDefaults.standard.bool(forKey: fastSynchEnabledKey)
}()
{
didSet {
UserDefaults.standard.set(fastSynchEnabled, forKey: fastSynchEnabledKey)
}
}
@Published var defaultModelsDir: String = {
return UserDefaults.standard.string(forKey: defaultModelsDirKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(defaultModelsDir, forKey: defaultModelsDirKey)
}
}
@Published var additionalModelsDirs: String = {
return UserDefaults.standard.string(forKey: additionalModelsDirsKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(additionalModelsDirs, forKey: additionalModelsDirsKey)
}
}
@Published var readOnlyModelsDirs: String = {
return UserDefaults.standard.string(forKey: readOnlyModelsDirsKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(readOnlyModelsDirs, forKey: readOnlyModelsDirsKey)
}
}
@Published var customEnvironmentVariables: [CustomEnvironmentVariable] = {
guard
let data = UserDefaults.standard.data(forKey: customEnvironmentVariablesKey),
let decoded = try? JSONDecoder().decode(
[CustomEnvironmentVariable].self, from: data
)
else {
return []
}
return decoded
}()
{
didSet {
guard let data = try? JSONEncoder().encode(customEnvironmentVariables) else {
return
}
UserDefaults.standard.set(data, forKey: customEnvironmentVariablesKey)
}
}
/// Fires once when EXO transitions to `.running` for the very first time (fresh install).
@Published private(set) var isFirstLaunchReady = false
@@ -291,6 +365,7 @@ final class ExoProcessController: ObservableObject {
if offlineMode {
environment["EXO_OFFLINE"] = "true"
}
environment["EXO_FAST_SYNCH"] = fastSynchEnabled ? "true" : "false"
var paths: [String] = []
if let existing = environment["PATH"], !existing.isEmpty {
@@ -315,6 +390,29 @@ final class ExoProcessController: ObservableObject {
}
environment["PATH"] = paths.joined(separator: ":")
let trimmedDefaultModelsDir = defaultModelsDir.trimmingCharacters(in: .whitespaces)
if !trimmedDefaultModelsDir.isEmpty {
environment["EXO_DEFAULT_MODELS_DIR"] = trimmedDefaultModelsDir
}
let trimmedAdditionalModelsDirs = additionalModelsDirs.trimmingCharacters(in: .whitespaces)
if !trimmedAdditionalModelsDirs.isEmpty {
environment["EXO_MODELS_DIRS"] = trimmedAdditionalModelsDirs
}
let trimmedReadOnlyModelsDirs = readOnlyModelsDirs.trimmingCharacters(in: .whitespaces)
if !trimmedReadOnlyModelsDirs.isEmpty {
environment["EXO_MODELS_READ_ONLY_DIRS"] = trimmedReadOnlyModelsDirs
}
// Apply user-defined arbitrary environment variables last so that
// power users can override any of the typed fields above when
// necessary. Empty keys are ignored.
for variable in customEnvironmentVariables {
let trimmedKey = variable.key.trimmingCharacters(in: .whitespaces)
guard !trimmedKey.isEmpty else { continue }
environment[trimmedKey] = variable.value
}
return environment
}
+240 -7
View File
@@ -15,6 +15,11 @@ struct SettingsView: View {
@State private var pendingHFEndpoint: String = ""
@State private var pendingEnableImageModels = false
@State private var pendingOfflineMode = false
@State private var pendingFastSynchEnabled = false
@State private var pendingDefaultModelsDir: String = ""
@State private var pendingAdditionalModelsDirs: String = ""
@State private var pendingReadOnlyModelsDirs: String = ""
@State private var pendingCustomEnvironmentVariables: [CustomEnvironmentVariable] = []
@State private var needsRestart = false
@State private var bugReportInFlight = false
@State private var bugReportMessage: String?
@@ -34,18 +39,27 @@ struct SettingsView: View {
.tabItem {
Label("Advanced", systemImage: "wrench.and.screwdriver")
}
environmentTab
.tabItem {
Label("Environment", systemImage: "terminal")
}
aboutTab
.tabItem {
Label("About", systemImage: "info.circle")
}
}
.frame(width: 450, height: 400)
.frame(width: 640, height: 560)
.onAppear {
pendingNamespace = controller.customNamespace
pendingHFToken = controller.hfToken
pendingHFEndpoint = controller.hfEndpoint
pendingEnableImageModels = controller.enableImageModels
pendingOfflineMode = controller.offlineMode
pendingFastSynchEnabled = controller.fastSynchEnabled
pendingDefaultModelsDir = controller.defaultModelsDir
pendingAdditionalModelsDirs = controller.additionalModelsDirs
pendingReadOnlyModelsDirs = controller.readOnlyModelsDirs
pendingCustomEnvironmentVariables = controller.customEnvironmentVariables
needsRestart = false
}
}
@@ -56,9 +70,9 @@ struct SettingsView: View {
Form {
Section {
LabeledContent("Cluster Namespace") {
TextField("default", text: $pendingNamespace)
TextField("", text: $pendingNamespace, prompt: Text("default"))
.textFieldStyle(.roundedBorder)
.frame(width: 200)
.frame(width: 260)
}
Text("Nodes with the same namespace form a cluster. Leave empty for default.")
.font(.caption)
@@ -67,9 +81,9 @@ struct SettingsView: View {
Section {
LabeledContent("HuggingFace Token") {
SecureField("optional", text: $pendingHFToken)
SecureField("", text: $pendingHFToken, prompt: Text("optional"))
.textFieldStyle(.roundedBorder)
.frame(width: 200)
.frame(width: 260)
}
Text("Required for gated models. Get yours at huggingface.co/settings/tokens")
.font(.caption)
@@ -78,9 +92,9 @@ struct SettingsView: View {
Section {
LabeledContent("HuggingFace Endpoint") {
TextField("default", text: $pendingHFEndpoint)
TextField("", text: $pendingHFEndpoint, prompt: Text("default"))
.textFieldStyle(.roundedBorder)
.frame(width: 200)
.frame(width: 260)
}
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
.font(.caption)
@@ -137,6 +151,23 @@ struct SettingsView: View {
private var advancedTab: some View {
Form {
Section("Performance") {
Toggle("Fast Synch Enabled", isOn: $pendingFastSynchEnabled)
Text(
"Experimental: enables fast CPU to GPU synchronization. Can sometimes cause a \"GPU lock\" where inference hangs for ~10 seconds before starting. Necessary for low latency with RDMA and Tensor Parallelism."
)
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Save & Restart") {
applyAdvancedSettings()
}
.disabled(!hasAdvancedChanges)
}
}
Section("Onboarding") {
HStack {
VStack(alignment: .leading) {
@@ -193,6 +224,128 @@ struct SettingsView: View {
.padding()
}
// MARK: - Environment Tab
private var environmentTab: some View {
Form {
Section("Models Directories") {
LabeledContent("Default Models Directory") {
TextField(
"",
text: $pendingDefaultModelsDir,
prompt: Text("~/.exo/models")
)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.frame(width: 260)
}
Text("Sets EXO_DEFAULT_MODELS_DIR. Where models are downloaded.")
.font(.caption)
.foregroundColor(.secondary)
LabeledContent("Additional Directories") {
TextField(
"",
text: $pendingAdditionalModelsDirs,
prompt: Text("optional, colon-separated")
)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.frame(width: 260)
}
Text("Sets EXO_MODELS_DIRS. Extra writable model directories.")
.font(.caption)
.foregroundColor(.secondary)
LabeledContent("Read-Only Directories") {
TextField(
"",
text: $pendingReadOnlyModelsDirs,
prompt: Text("optional, colon-separated")
)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.frame(width: 260)
}
Text("Sets EXO_MODELS_READ_ONLY_DIRS. Never written to.")
.font(.caption)
.foregroundColor(.secondary)
}
Section("Custom Environment Variables") {
Text(
"Escape hatch for env vars that don't have typed fields above. "
+ "Values here override the typed fields on conflict."
)
.font(.caption)
.foregroundColor(.secondary)
if pendingCustomEnvironmentVariables.isEmpty {
Text("No custom variables.")
.font(.caption)
.foregroundColor(.secondary)
} else {
ForEach($pendingCustomEnvironmentVariables) { $variable in
HStack(alignment: .center, spacing: 8) {
VStack(spacing: 4) {
TextField("key", text: $variable.key)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
TextField("value", text: $variable.value)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
VStack(spacing: 4) {
Button {
pendingCustomEnvironmentVariables.removeAll {
$0.id == variable.id
}
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
.help("Remove variable")
if !isValidEnvironmentVariableName(variable.key) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.help(
"Invalid environment variable name. "
+ "Must match [A-Za-z_][A-Za-z0-9_]*."
)
}
}
}
}
}
HStack {
Button {
pendingCustomEnvironmentVariables.append(
CustomEnvironmentVariable()
)
} label: {
Label("Add Variable", systemImage: "plus")
}
Spacer()
}
}
Section {
HStack {
Spacer()
Button("Save & Restart") {
applyEnvironmentSettings()
}
.disabled(!hasEnvironmentChanges)
}
}
}
.formStyle(.grouped)
.padding()
}
// MARK: - About Tab
private var aboutTab: some View {
@@ -475,6 +628,17 @@ struct SettingsView: View {
pendingEnableImageModels != controller.enableImageModels
}
private var hasAdvancedChanges: Bool {
pendingFastSynchEnabled != controller.fastSynchEnabled
}
private var hasEnvironmentChanges: Bool {
pendingDefaultModelsDir != controller.defaultModelsDir
|| pendingAdditionalModelsDirs != controller.additionalModelsDirs
|| pendingReadOnlyModelsDirs != controller.readOnlyModelsDirs
|| pendingCustomEnvironmentVariables != controller.customEnvironmentVariables
}
private func applyGeneralSettings() {
controller.customNamespace = pendingNamespace
controller.hfToken = pendingHFToken
@@ -488,6 +652,75 @@ struct SettingsView: View {
restartIfRunning()
}
private func applyAdvancedSettings() {
controller.fastSynchEnabled = pendingFastSynchEnabled
restartIfRunning()
}
private func applyEnvironmentSettings() {
controller.defaultModelsDir = pendingDefaultModelsDir.trimmingCharacters(
in: .whitespaces)
controller.additionalModelsDirs = pendingAdditionalModelsDirs.trimmingCharacters(
in: .whitespaces)
controller.readOnlyModelsDirs = pendingReadOnlyModelsDirs.trimmingCharacters(
in: .whitespaces)
pendingDefaultModelsDir = controller.defaultModelsDir
pendingAdditionalModelsDirs = controller.additionalModelsDirs
pendingReadOnlyModelsDirs = controller.readOnlyModelsDirs
// Trim whitespace from keys and drop empty ones so that the stored
// form matches what is actually injected into the child process and
// hasEnvironmentChanges doesn't show a stale diff after save.
let trimmed: [CustomEnvironmentVariable] =
pendingCustomEnvironmentVariables.compactMap { variable in
let key = variable.key.trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { return nil }
return CustomEnvironmentVariable(
id: variable.id, key: key, value: variable.value
)
}
// De-duplicate keys, keeping the last occurrence. This matches the
// effective semantics of the dictionary assignment in
// ExoProcessController.makeEnvironment and avoids silently losing
// visible rows after save.
var seenKeys = Set<String>()
var deduplicatedReversed: [CustomEnvironmentVariable] = []
for variable in trimmed.reversed() {
if seenKeys.insert(variable.key).inserted {
deduplicatedReversed.append(variable)
}
}
let sanitized = Array(deduplicatedReversed.reversed())
pendingCustomEnvironmentVariables = sanitized
controller.customEnvironmentVariables = sanitized
restartIfRunning()
}
/// Validates a POSIX-style environment variable name:
/// `[A-Za-z_][A-Za-z0-9_]*`. Uses an ASCII-only charset so that
/// Unicode letters (e.g. `ñ`, Cyrillic) are rejected in line with what
/// the help tooltip advertises. Empty strings are treated as valid
/// here so that a freshly added blank row does not immediately look
/// broken; the save step filters empty keys out instead.
private func isValidEnvironmentVariableName(_ key: String) -> Bool {
if key.isEmpty { return true }
let headAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"
)
let tailAllowed = headAllowed.union(CharacterSet(charactersIn: "0123456789"))
guard let first = key.unicodeScalars.first, headAllowed.contains(first) else {
return false
}
for scalar in key.unicodeScalars.dropFirst() {
if !tailAllowed.contains(scalar) { return false }
}
return true
}
private func restartIfRunning() {
if controller.status == .running || controller.status == .starting {
controller.restart()
@@ -30,7 +30,7 @@ final class SettingsWindowController: ObservableObject {
let hostingView = NSHostingView(rootView: settingsView)
let newWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 450, height: 400),
contentRect: NSRect(x: 0, y: 0, width: 640, height: 560),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
+160
View File
@@ -0,0 +1,160 @@
# Exo-Bench — Methodology
exo bench measures inference throughput and resource consumption of an exo cluster under controlled conditions. It sends prompts to the `/bench/chat/completions` endpoint, collects server-reported timing statistics, and records system-level metrics (power, GPU utilisation, temperature) throughout each run.
The goal is to have accurate, transparent and reproducible numbers to compare speed and scaling across different models and different setups, and to be able to track these results as optimizations and features are added to EXO.
Below is a technical summary of how Exo-Bench works. While the methodology and benchmark may change over time, this document will be kept up to date whenever this happens. If you find an issue with the methodology, or would like a feature to be added, please open a GitHub issue!
---
## Prompt Construction
Benchmarks need prompts of an exact token length. Unfortunately, we do not have direct access to the model but just the chat completion endpoint. To get around this fact, we create a request that will tokenise to a certain prompt length.
This is achieved by:
1. Tokenising a sample message through the model's `apply_chat_template()` to measure overhead (system tokens, special tokens, chat formatting).
2. Binary-searching over a repeated atom string (default `"a "`) to find the content length that produces exactly the target number of tokens after template expansion.
3. Returning both the content string and the verified token count.
The actual token count is recorded in every result row as `pp_tokens`, so downstream analysis can confirm the prompt hit its target.
Chat template formatting means that it may be impossible to attain very small pp benchmarks. e.g. pp=32 may not work. This tradeoff was made because the result of such a small prompt does not seem very interesting or useful for any real-world use cases.
---
## Bench Endpoint
When a request reaches the server via the `/bench/chat/completions` endpoint, three things change compared to a normal chat completion:
- **KV prefix cache is disabled by default**. Every request starts from a cold cache, ensuring prefill timing is not affected by prior requests. See [Prefix Cache Mode](#prefix-cache-mode) for the `--use-prefix-cache` option.
- **EOS tokens are banned**. A logits processor suppresses all end-of-sequence tokens, forcing the model to generate exactly `max_tokens` tokens. This guarantees consistent generation length for fair TPS comparison — the model cannot short-circuit a run by stopping early.
- **No model output parsing**. The bench collection path concatenates raw token text without any model-specific post-processing (thinking tag extraction, structured output handling, etc.). This is to avoid model outputs such as tool parsing or any structural mistakes from breaking the benchmark - we are testing for speed; see Exo-Eval for performance metrics.
---
## Timing
### Prefill TPS
Measured server-side per task.
```
prefill_tps = num_prompt_tokens / prefill_wall_seconds
```
### Generation TPS
Measured server-side per task. Each task records wall-clock timestamps as tokens arrive:
- First generated token: timestamp recorded
- Every subsequent token: timestamp updated
When generation completes:
```
gen_span = last_token_time - first_token_time
generation_tps = (completion_tokens - 1) / gen_span
```
The first token is excluded from the numerator because the rate measures inter-token throughput — the time between the first and last token divided by the number of intervals.
This does mean that tg=1 will not work.
---
## Concurrency
### Single Request
The client records wall-clock `elapsed_s` around the HTTP round-trip (network latency + server prefill + generation + response serialisation). This is a convenience metric for end-to-end latency. The authoritative TPS numbers come from the server-side per-task timing in the `generation_stats` response.
### Concurrent Requests
When `--concurrency N` is set with N > 1, all N requests must hit the server at the same instant. The mechanism:
1. The prompt is built once and shared across all threads.
2. Each thread gets its own HTTP connection.
3. A thread barrier blocks all threads until every thread is ready.
4. The first thread past the barrier records the batch start time and signals the others.
5. All threads use the same start time as their reference, then fire their HTTP request.
6. Each thread's `elapsed_s` is measured from the shared start time to its own response completion.
**Batch wall time** is the maximum `elapsed_s` across all N requests — the time until the last request finishes.
### Aggregate TPS
```
per_req_tps = max(generation_tps across N concurrent requests)
agg_gen_tps = per_req_tps * concurrency
```
`max` is used instead of `mean` because all requests run in parallel against the same model. The fastest request's generation rate represents the system's per-stream throughput capacity; multiplying by concurrency gives aggregate throughput.
---
## Prefix Cache Mode
When `--use-prefix-cache` is passed, the KV prefix cache remains active during benchmarking. This speeds up repeated runs by skipping redundant prefill work, which is useful when prompt processing is not the focus of the benchmark (e.g. when measuring generation throughput or power consumption across many configurations).
Each response includes a `prefix_cache_hit` field (`"none"`, `"partial"`, or `"exact"`):
- **none**: Cold prefill — no cached KV state was available. The reported `prompt_tps` is the real prefill throughput.
- **partial**: A prefix of the prompt was found in cache. Only the remaining tokens were prefilled. The reported `prompt_tps` reflects the real throughput on the uncached portion. This occurs when multiple ascending `--pp` values share a common prefix (e.g. `--pp 1000,5000` — the 5000-token prompt reuses the 1000-token cache entry and prefills the remaining 4000 tokens).
- **exact**: The entire prompt was found in cache (e.g. same `--pp` value on a `--repeat`). No prefill work was done. The reported `prompt_tps` is the TPS from when the cache entry was originally created, not a new measurement.
**Prompt TPS is approximate in this mode.** Exact-hit runs report the stored TPS from the original cold/partial prefill rather than a freshly measured value. For accurate cold prefill numbers, run without `--use-prefix-cache`.
Ascending `--pp` order (e.g. `--pp 1000,5000,10000`) gives the most useful data: each size gets a meaningful partial hit except the first which is cold. Descending order produces exact hits with approximate TPS from a longer prompt's original run.
---
## Warmup
Before measurement begins, `--warmup N` (default: 0) discarded requests are sent using the first pp/tg pair. Warmup results are not included in the output.
---
## System Metrics
A background thread polls each node at 1 Hz, collecting:
- GPU utilisation (%)
- Temperature (C)
- System power draw (W)
- CPU cluster usage (performance and efficiency cores)
**Energy** is computed via trapezoidal integration of the power samples over each inference window (the wall-clock span of each benchmark request or concurrent batch). Average power is `total_joules / total_inference_seconds`.
---
## Output Format
Results are written as JSON with three top-level keys:
- **`runs`**: Array of per-request result objects, each containing:
- `elapsed_s`, `output_text_preview` (first 200 chars)
- `stats`: `{ prompt_tps, generation_tps, prompt_tokens, generation_tokens, peak_memory_usage }`
- Placement metadata: `model_id`, `placement_sharding`, `placement_instance_meta`, `placement_nodes`
- Run metadata: `pp_tokens`, `tg`, `repeat_index`, `concurrency`, `concurrent_index`
- `download_duration_s` (if model was freshly downloaded)
- **`cluster`**: Cluster state snapshot at time of benchmarking.
- **`system_metrics`**: Per-node time-series samples (GPU, power, temperature).
---
## Reproducing Results
```bash
cd bench && uv run python exo_bench.py \
--model "mlx-community/Qwen3.5-27B-4bit" \
--instance-meta jaccl \
--sharding tensor \
--min-nodes 2 --max-nodes 2 \
--pp 512 4096 --tg 128 \
--repeat 3 \
--warmup 1
```
Run --help for all the available flags.
+7 -2
View File
@@ -17,6 +17,7 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -1006,6 +1007,7 @@ Examples:
sys.exit(1)
time.sleep(1)
cluster_snapshot = capture_cluster_snapshot(exo)
all_results: list[ScenarioResult] = []
try:
@@ -1084,16 +1086,19 @@ Examples:
print(f" - {r.name} [{r.api}/{r.phase}]: {r.error}", file=log)
json_results = [result_to_dict(r) for r in all_results]
output: dict[str, Any] = {"results": json_results}
if cluster_snapshot:
output["cluster"] = cluster_snapshot
if args.stdout:
print(json.dumps(json_results, indent=2))
print(json.dumps(output, indent=2))
else:
json_path = args.json_out
parent = os.path.dirname(json_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(json_path, "w") as f:
json.dump(json_results, f, indent=2)
json.dump(output, f, indent=2)
f.write("\n")
print(f"\nJSON results written to {json_path}", file=log)
+228 -13
View File
@@ -22,6 +22,7 @@ import contextlib
import itertools
import json
import sys
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -33,7 +34,9 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
run_planning_phase,
@@ -131,6 +134,91 @@ def format_peak_memory(b: float) -> str:
raise ValueError("You're using petabytes of memory. Something went wrong...")
_SAMPLER_METRICS = ("gpuUsage", "temp", "sysPower", "pcpuUsage", "ecpuUsage")
class SystemMetricsSampler:
def __init__(self, client: ExoClient, node_ids: list[str], interval_s: float = 1.0):
self._client = client
self._node_ids = node_ids
self._interval_s = interval_s
self._samples: dict[str, list[tuple[float, dict[str, float]]]] = {
nid: [] for nid in node_ids
}
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
self._stop.clear()
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
def _poll_loop(self) -> None:
while not self._stop.is_set():
t = time.monotonic()
for nid in self._node_ids:
try:
data = self._client.get_node_system(nid)
if data:
self._samples[nid].append(
(t, {k: data.get(k, 0.0) for k in _SAMPLER_METRICS})
)
except Exception:
pass
self._stop.wait(self._interval_s)
def energy_between(self, t0: float, t1: float) -> float:
total_joules = 0.0
for _nid, samples in self._samples.items():
window = [(t, s["sysPower"]) for t, s in samples if t0 <= t <= t1]
if len(window) >= 2:
for i in range(1, len(window)):
dt = window[i][0] - window[i - 1][0]
avg_power = (window[i][1] + window[i - 1][1]) / 2
total_joules += avg_power * dt
elif len(window) == 1:
total_joules += window[0][1] * (t1 - t0)
return total_joules
def summarize(self) -> dict[str, dict[str, dict[str, float]]]:
result: dict[str, dict[str, dict[str, float]]] = {}
for nid, samples in self._samples.items():
if not samples:
continue
metrics: dict[str, dict[str, float]] = {}
for key in _SAMPLER_METRICS:
values = [s[key] for t, s in samples]
metrics[key] = {
"min": round(min(values), 2),
"max": round(max(values), 2),
"mean": round(mean(values), 2),
"samples": len(values),
}
result[nid] = metrics
return result
def print_summary(self, placement_label: str) -> None:
summary = self.summarize()
if not summary:
return
logger.info(f"--- System Metrics ({placement_label}) ---")
for nid, metrics in summary.items():
gpu = metrics.get("gpuUsage", {})
temp = metrics.get("temp", {})
power = metrics.get("sysPower", {})
logger.info(
f" {nid}: "
f"GPU {gpu.get('mean', 0) * 100:.0f}% avg ({gpu.get('min', 0) * 100:.0f}{gpu.get('max', 0) * 100:.0f}%) | "
f"{temp.get('mean', 0):.1f}°C avg | "
f"{power.get('mean', 0):.1f}W avg"
)
def parse_int_list(values: list[str]) -> list[int]:
items: list[int] = []
for v in values:
@@ -142,7 +230,13 @@ def parse_int_list(values: list[str]) -> list[int]:
def run_one_completion(
client: ExoClient, model_id: str, pp_hint: int, tg: int, prompt_sizer: PromptSizer
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
) -> tuple[dict[str, Any], int]:
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
@@ -150,6 +244,8 @@ def run_one_completion(
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
}
t0 = time.perf_counter()
@@ -279,6 +375,22 @@ def main() -> int:
action="store_true",
help="Force all pp×tg combinations (cartesian product) even when lists have equal length.",
)
ap.add_argument(
"--no-system-metrics",
action="store_true",
help="Disable GPU utilization, temperature, and power collection during inference.",
)
ap.add_argument(
"--metrics-interval",
type=float,
default=1.0,
help="System metrics polling interval in seconds (default: 1.0).",
)
ap.add_argument(
"--use-prefix-cache",
action="store_true",
help="Enable KV prefix cache during bench (default: disabled for cold-cache measurements).",
)
args = ap.parse_args()
pp_list = parse_int_list(args.pp)
@@ -294,6 +406,15 @@ def main() -> int:
logger.error("--concurrency values must be >= 1")
return 2
if args.use_prefix_cache:
logger.warning(
"--use-prefix-cache: prompt TPS will be approximate. See METHODOLOGY.md for details."
)
if pp_list != sorted(pp_list):
logger.warning(
"--pp values are not in ascending order: prompt TPS will be less accurate. Use ascending --pp for best results."
)
# Log pairing mode
use_combinations = args.all_combinations or len(pp_list) != len(tg_list)
if use_combinations:
@@ -364,7 +485,9 @@ def main() -> int:
else:
logger.info("Download: model already cached")
cluster_snapshot = capture_cluster_snapshot(client)
all_rows: list[dict[str, Any]] = []
all_system_metrics: dict[str, dict[str, dict[str, float]]] = {}
for preview in selected:
instance = preview["instance"]
@@ -390,10 +513,25 @@ def main() -> int:
time.sleep(1)
sampler: SystemMetricsSampler | None = None
if not args.no_system_metrics:
nids = node_ids_from_instance(instance)
sampler = SystemMetricsSampler(
ExoClient(args.host, args.port, timeout_s=30),
nids,
interval_s=args.metrics_interval,
)
sampler.start()
try:
for i in range(args.warmup):
run_one_completion(
client, full_model_id, pp_list[0], tg_list[0], prompt_sizer
client,
full_model_id,
pp_list[0],
tg_list[0],
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
logger.debug(f" warmup {i + 1}/{args.warmup} done")
@@ -408,15 +546,23 @@ def main() -> int:
for concurrency in concurrency_list:
logger.info(f"--- pp={pp} tg={tg} concurrency={concurrency} ---")
runs: list[dict[str, Any]] = []
inference_windows: list[tuple[float, float]] = []
for r in range(args.repeat):
time.sleep(3)
if concurrency <= 1:
# Sequential: single request
try:
inf_t0 = time.monotonic()
row, actual_pp_tokens = run_one_completion(
client, full_model_id, pp, tg, prompt_sizer
client,
full_model_id,
pp,
tg,
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
inference_windows.append((inf_t0, time.monotonic()))
except Exception as e:
logger.error(e)
continue
@@ -443,20 +589,54 @@ def main() -> int:
all_rows.append(row)
else:
# Concurrent: fire N requests in parallel
# Each thread gets its own ExoClient (separate HTTP connection)
# Pre-build prompt once, barrier ensures simultaneous dispatch
content, actual_pp = prompt_sizer.build(pp)
pre_built_payload: dict[str, Any] = {
"model": full_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": args.use_prefix_cache,
}
barrier = threading.Barrier(concurrency)
batch_start = threading.Event()
batch_t0: float = 0.0
batch_results: list[tuple[dict[str, Any], int]] = []
batch_errors = 0
def _run_concurrent(
idx: int, *, _pp: int = pp, _tg: int = tg
idx: int,
_barrier: threading.Barrier = barrier,
_batch_start: threading.Event = batch_start,
_payload: dict[str, Any] = pre_built_payload,
_actual_pp: int = actual_pp,
) -> tuple[dict[str, Any], int]:
nonlocal batch_t0
c = ExoClient(
args.host, args.port, timeout_s=args.timeout
)
return run_one_completion(
c, full_model_id, _pp, _tg, prompt_sizer
if _barrier.wait() == 0:
batch_t0 = time.perf_counter()
_batch_start.set()
else:
_batch_start.wait()
t0 = batch_t0
out = c.post_bench_chat_completions(_payload)
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
choices = out.get("choices") or [{}]
message = (
choices[0].get("message", {}) if choices else {}
)
text = message.get("content") or ""
return {
"elapsed_s": elapsed,
"output_text_preview": text[:200],
"stats": stats,
}, _actual_pp
inf_t0 = time.monotonic()
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {
pool.submit(_run_concurrent, i): i
@@ -468,6 +648,12 @@ def main() -> int:
except Exception as e:
logger.error(f"Concurrent request failed: {e}")
batch_errors += 1
batch_wall_s = (
max(x["elapsed_s"] for x, _ in batch_results)
if batch_results
else time.perf_counter() - batch_t0
)
inference_windows.append((inf_t0, time.monotonic()))
for idx, (row, actual_pp_tokens) in enumerate(
batch_results
@@ -502,19 +688,25 @@ def main() -> int:
if x["stats"]["generation_tps"] > 0
]
per_req_tps = (
mean(valid_gen_tps) if valid_gen_tps else 0.0
max(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"wall_s={batch_wall_s:.2f} "
f"errors={batch_errors}"
)
if runs:
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
per_req_tps = mean(x["stats"]["generation_tps"] for x in runs)
valid_gen = [
x["stats"]["generation_tps"]
for x in runs
if x["stats"]["generation_tps"] > 0
]
per_req_tps = max(valid_gen) if valid_gen else 0.0
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)
@@ -522,13 +714,30 @@ def main() -> int:
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
)
logger.info(
summary = (
f"prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
f"peak_memory={format_peak_memory(peak)}\n"
f"peak_memory={format_peak_memory(peak)}"
)
if sampler and inference_windows:
joules = sum(
sampler.energy_between(t0, t1)
for t0, t1 in inference_windows
)
inf_seconds = sum(t1 - t0 for t0, t1 in inference_windows)
avg_watts = joules / inf_seconds if inf_seconds > 0 else 0
summary += f" energy={joules:.1f}J ({avg_watts:.1f}W avg over {inf_seconds:.1f}s inference)"
logger.info(f"{summary}\n")
time.sleep(2)
finally:
if sampler:
sampler.stop()
placement_label = f"{sharding}/{instance_meta}/{n_nodes} nodes"
sampler.print_summary(placement_label)
placement_metrics = sampler.summarize()
if placement_metrics:
all_system_metrics.update(placement_metrics)
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
@@ -539,11 +748,17 @@ def main() -> int:
time.sleep(5)
output: dict[str, Any] = {"runs": all_rows}
if cluster_snapshot:
output["cluster"] = cluster_snapshot
if all_system_metrics:
output["system_metrics"] = all_system_metrics
if args.stdout:
json.dump(all_rows, sys.stdout, indent=2, ensure_ascii=False)
json.dump(output, sys.stdout, indent=2, ensure_ascii=False)
elif args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(all_rows, f, indent=2, ensure_ascii=False)
json.dump(output, f, indent=2, ensure_ascii=False)
logger.debug(f"\nWrote results JSON: {args.json_out}")
return 0
+8 -1
View File
@@ -46,6 +46,7 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -1027,16 +1028,18 @@ def save_results(
concurrency: int,
results: list[QuestionResult],
scores: dict[str, Any],
cluster: dict[str, Any] | None = None,
) -> Path:
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark_name
out_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = out_dir / f"c{concurrency}_{ts}.json"
data = {
data: dict[str, Any] = {
"benchmark": benchmark_name,
"model": model,
"concurrency": concurrency,
**({"cluster": cluster} if cluster else {}),
"scores": scores,
"results": [
{
@@ -1231,8 +1234,10 @@ def main() -> int:
client.request_json("DELETE", f"/instance/{instance_id}")
return 1
time.sleep(1)
cluster_snapshot = capture_cluster_snapshot(client)
else:
full_model_id = args.model
cluster_snapshot = None
# Auto-detect reasoning from model config
model_config = load_model_config(full_model_id)
@@ -1328,6 +1333,7 @@ def main() -> int:
c,
results,
scores,
cluster=cluster_snapshot,
)
results_by_c[c] = results
if len(results_by_c) >= 2:
@@ -1358,6 +1364,7 @@ def main() -> int:
args.num_concurrent,
results,
scores,
cluster=cluster_snapshot,
)
finally:
if instance_id is not None:
+95 -27
View File
@@ -69,6 +69,35 @@ class ExoClient:
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.request_json("POST", "/bench/chat/completions", body=payload)
def get_state_path(self, path: str) -> Any:
try:
return self.request_json("GET", f"/state/{path}")
except ExoHttpError as e:
if e.status == 404:
return None
raise
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"instances/{instance_id}")
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"runners/{runner_id}")
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
return self.get_state_path(f"downloads/{node_id}")
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeDisk/{node_id}")
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeSystem/{node_id}")
def get_node_identities(self) -> dict[str, Any] | None:
return self.get_state_path("nodeIdentities")
def get_topology(self) -> dict[str, Any] | None:
return self.get_state_path("topology")
def unwrap_instance(instance: dict[str, Any]) -> dict[str, Any]:
if len(instance) != 1:
@@ -97,6 +126,11 @@ def runner_ids_from_instance(instance: dict[str, Any]) -> list[str]:
return list(runner_to_shard.keys())
def node_ids_from_instance(instance: dict[str, Any]) -> list[str]:
inner = unwrap_instance(instance)
return list(inner["shardAssignments"]["nodeToRunner"].keys())
def runner_ready(runner: dict[str, Any]) -> bool:
return "RunnerReady" in runner
@@ -116,13 +150,12 @@ def wait_for_instance_ready(
) -> None:
start_time = time.time()
instance_existed = False
last_loaded: dict[str, int] = {}
while time.time() - start_time < timeout:
state = client.request_json("GET", "/state")
instances = state.get("instances", {})
instance = client.get_instance(instance_id)
if instance_id not in instances:
if instance is None:
if instance_existed:
# Instance was deleted after being created - likely due to runner failure
raise RuntimeError(
f"Instance {instance_id} was deleted (runner may have failed)"
)
@@ -130,18 +163,25 @@ def wait_for_instance_ready(
continue
instance_existed = True
instance = instances[instance_id]
runner_ids = runner_ids_from_instance(instance)
runners = state.get("runners", {})
rids = runner_ids_from_instance(instance)
# Check for failed runners first
for rid in runner_ids:
runner = runners.get(rid, {})
all_ready = True
for rid in rids:
runner = client.get_runner(rid) or {}
if runner_failed(runner):
error_msg = get_runner_failed_message(runner) or "Unknown error"
raise RuntimeError(f"Runner {rid} failed: {error_msg}")
if "RunnerLoading" in runner:
loading = runner["RunnerLoading"]
loaded = loading.get("layersLoaded", 0)
total = loading.get("totalLayers", 0)
if total > 0 and last_loaded.get(rid) != loaded:
last_loaded[rid] = loaded
logger.debug(f"Runner {rid}: loading layers {loaded}/{total}")
if not runner_ready(runner):
all_ready = False
if all(runner_ready(runners.get(rid, {})) for rid in runner_ids):
if all_ready:
return
time.sleep(0.1)
@@ -165,6 +205,23 @@ def wait_for_instance_gone(
raise TimeoutError(f"Instance {instance_id} did not get deleted within {timeout=}")
def capture_cluster_snapshot(client: ExoClient) -> dict[str, Any]:
snapshot: dict[str, Any] = {}
identities = client.get_node_identities()
if identities:
snapshot["nodeIdentities"] = identities
topology = client.get_topology()
if topology:
snapshot["topology"] = topology
node_memory = client.get_state_path("nodeMemory")
if node_memory:
snapshot["nodeMemory"] = node_memory
node_system = client.get_state_path("nodeSystem")
if node_system:
snapshot["nodeSystem"] = node_system
return snapshot
def resolve_model_short_id(
client: ExoClient, model_arg: str, *, force_download: bool = False
) -> tuple[str, str]:
@@ -326,16 +383,11 @@ def run_planning_phase(
node_ids = list(inner["shardAssignments"]["nodeToRunner"].keys())
runner_to_shard = inner["shardAssignments"]["runnerToShard"]
state = client.request_json("GET", "/state")
downloads = state.get("downloads", {})
node_disk = state.get("nodeDisk", {})
needs_download = False
for node_id in node_ids:
node_downloads = downloads.get(node_id, [])
node_downloads = client.get_node_downloads(node_id) or []
# Check if model already downloaded on this node
already_downloaded = any(
"DownloadCompleted" in p
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
@@ -349,8 +401,7 @@ def run_planning_phase(
needs_download = True
# Wait for disk info if settle_deadline is set
disk_info = node_disk.get(node_id, {})
disk_info = client.get_node_disk(node_id) or {}
backoff = _SETTLE_INITIAL_BACKOFF_S
while not disk_info and settle_deadline and time.monotonic() < settle_deadline:
remaining = settle_deadline - time.monotonic()
@@ -359,9 +410,7 @@ def run_planning_phase(
)
time.sleep(min(backoff, remaining))
backoff = min(backoff * _SETTLE_BACKOFF_MULTIPLIER, _SETTLE_MAX_BACKOFF_S)
state = client.request_json("GET", "/state")
node_disk = state.get("nodeDisk", {})
disk_info = node_disk.get(node_id, {})
disk_info = client.get_node_disk(node_id) or {}
if not disk_info:
logger.warning(f"No disk info for {node_id}, skipping space check")
@@ -377,7 +426,6 @@ def run_planning_phase(
f"have {avail // (1024**3)}GB. Use --danger-delete-downloads to free space."
)
# Delete from smallest to largest (skip read-only models from EXO_MODELS_PATH)
completed = [
(
unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
@@ -417,21 +465,20 @@ def run_planning_phase(
# Wait for downloads
start = time.time()
while time.time() - start < timeout:
state = client.request_json("GET", "/state")
downloads = state.get("downloads", {})
all_done = True
for node_id in node_ids:
node_downloads = client.get_node_downloads(node_id) or []
done = any(
"DownloadCompleted" in p
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
for p in downloads.get(node_id, [])
for p in node_downloads
)
failed = [
p["DownloadFailed"]["errorMessage"]
for p in downloads.get(node_id, [])
for p in node_downloads
if "DownloadFailed" in p
and unwrap_instance(p["DownloadFailed"]["shardMetadata"])["modelCard"][
"modelId"
@@ -442,6 +489,27 @@ def run_planning_phase(
raise RuntimeError(f"Download failed on {node_id}: {failed[0]}")
if not done:
all_done = False
ongoing = [
p
for p in node_downloads
if "DownloadOngoing" in p
and unwrap_instance(p["DownloadOngoing"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
]
if ongoing:
prog = ongoing[0]["DownloadOngoing"]["downloadProgress"]
speed_mb = prog.get("speed", 0) / (1024 * 1024)
eta_s = prog.get("etaMs", 0) / 1000
dl_bytes = prog.get("downloaded", {}).get("inBytes", 0)
total_bytes = prog.get("total", {}).get("inBytes", 0)
pct = (dl_bytes / total_bytes * 100) if total_bytes else 0
logger.info(
f"Downloading on {node_id}: {pct:.1f}% @ {speed_mb:.1f} MB/s, "
f"ETA {eta_s:.0f}s "
f"({prog.get('completedFiles', 0)}/{prog.get('totalFiles', 0)} files)"
)
if all_done:
if download_t0 is not None:
return time.perf_counter() - download_t0
+272 -1
View File
@@ -11,7 +11,8 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0"
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@@ -518,6 +519,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2635,6 +2886,26 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -31,6 +31,7 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0"
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
}
}
@@ -139,6 +139,8 @@
return "🖼";
case "text":
return "📄";
case "pdf":
return "📑";
default:
return "📎";
}
@@ -94,6 +94,12 @@
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 if family === "gemma"}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
{:else}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -32,6 +32,7 @@
flux: "FLUX",
"qwen-image": "Qwen Img",
nemotron: "NVIDIA",
gemma: "Google",
};
function getFamilyName(family: string): string {
@@ -42,31 +43,20 @@
</script>
<div
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[72px] sm:min-w-[64px] overflow-y-auto scrollbar-hide"
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[80px] sm:min-w-[72px] overflow-y-auto scrollbar-hide"
>
<!-- All models (no filter) -->
<button
type="button"
onclick={() => onSelect(null)}
class="group flex flex-col items-center justify-center p-2 sm:p-2 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
class="group flex items-center justify-center px-3 py-2.5 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
null
? 'bg-exo-yellow/20 border-l-2 border-exo-yellow'
: 'hover:bg-white/5 border-l-2 border-transparent'}"
title="All models"
>
<svg
class="w-5 h-5 {selectedFamily === null
? 'text-exo-yellow'
: 'text-white/50 group-hover:text-white/70'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
/>
</svg>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === null
class="text-[12px] font-mono font-medium {selectedFamily === null
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}">All</span
>
@@ -90,7 +80,7 @@
: "text-white/50 group-hover:text-amber-400/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'favorites'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'favorites'
? 'text-amber-400'
: 'text-white/40 group-hover:text-white/60'}">Faves</span
>
@@ -115,7 +105,7 @@
: "text-white/50 group-hover:text-white/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'recents'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'recents'
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}">Recent</span
>
@@ -139,7 +129,7 @@
: "text-white/50 group-hover:text-orange-400/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'huggingface'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'huggingface'
? 'text-orange-400'
: 'text-white/40 group-hover:text-white/60'}">Hub</span
>
@@ -165,7 +155,7 @@
: "text-white/50 group-hover:text-white/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
class="text-[11px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
family
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}"
+53 -15
View File
@@ -1,21 +1,38 @@
<script lang="ts">
import { browser } from "$app/environment";
export let showHome = true;
export let onHome: (() => void) | null = null;
export let showSidebarToggle = false;
export let sidebarVisible = true;
export let onToggleSidebar: (() => void) | null = null;
export let showMobileMenuToggle = false;
export let mobileMenuOpen = false;
export let onToggleMobileMenu: (() => void) | null = null;
export let showMobileRightToggle = false;
export let mobileRightOpen = false;
export let onToggleMobileRight: (() => void) | null = null;
export let downloadProgress: {
count: number;
percentage: number;
} | null = null;
interface Props {
showHome?: boolean;
onHome?: (() => void) | null;
showSidebarToggle?: boolean;
sidebarVisible?: boolean;
onToggleSidebar?: (() => void) | null;
showMobileMenuToggle?: boolean;
mobileMenuOpen?: boolean;
onToggleMobileMenu?: (() => void) | null;
showMobileRightToggle?: boolean;
mobileRightOpen?: boolean;
onToggleMobileRight?: (() => void) | null;
downloadProgress?: {
count: number;
percentage: number;
} | null;
}
let {
showHome = true,
onHome = null,
showSidebarToggle = false,
sidebarVisible = true,
onToggleSidebar = null,
showMobileMenuToggle = false,
mobileMenuOpen = false,
onToggleMobileMenu = null,
showMobileRightToggle = false,
mobileRightOpen = false,
onToggleMobileRight = null,
downloadProgress = null,
}: Props = $props();
function handleHome(): void {
if (onHome) {
@@ -259,5 +276,26 @@
{/if}
<span class="hidden sm:inline">Downloads</span>
</a>
<a
href="/#/integrations"
class="text-xs md:text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-1.5 md:gap-2 cursor-pointer"
title="Integration configs for external tools"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
/>
</svg>
<span class="hidden sm:inline">Integrations</span>
</a>
</nav>
</header>
@@ -0,0 +1,62 @@
<script lang="ts">
import { copyText } from "$lib/utils/clipboard";
interface Props {
title: string;
subtitle: string;
config: string;
description?: string;
language?: "json" | "bash";
}
let {
title,
subtitle,
config,
description = "",
language = "json",
}: Props = $props();
let copied = $state(false);
let failed = $state(false);
async function copyToClipboard() {
const ok = await copyText(config);
if (ok) {
copied = true;
setTimeout(() => (copied = false), 2000);
} else {
failed = true;
setTimeout(() => (failed = false), 2000);
}
}
</script>
<div
class="border border-exo-light-gray/20 rounded-lg bg-exo-medium-gray/20 overflow-hidden"
>
<div class="flex items-center justify-between px-5 py-4">
<div>
<h3 class="text-white text-sm font-semibold tracking-wide">{title}</h3>
<p class="text-exo-light-gray/60 text-xs mt-0.5 font-mono">{subtitle}</p>
</div>
<button
onclick={copyToClipboard}
class="px-3 py-1.5 text-xs rounded border transition-all duration-200 cursor-pointer
{copied
? 'border-green-500/50 text-green-400 bg-green-500/10'
: failed
? 'border-red-500/50 text-red-400 bg-red-500/10'
: 'border-exo-light-gray/30 text-exo-light-gray hover:border-exo-yellow/50 hover:text-exo-yellow'}"
>
{copied ? "Copied!" : failed ? "Copy failed" : "Copy"}
</button>
</div>
{#if description}
<p class="text-exo-light-gray/70 text-xs px-5 pb-3">{description}</p>
{/if}
<div class="bg-black/30 border-t border-exo-light-gray/10">
<pre
class="text-xs text-exo-light-gray/90 font-mono p-4 overflow-x-auto whitespace-pre">{config}</pre>
</div>
</div>
@@ -73,7 +73,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-10"
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-20"
transition:fly={{ y: -10, duration: 200, easing: cubicOut }}
onclick={(e) => e.stopPropagation()}
role="dialog"
@@ -457,6 +457,7 @@
"deepseek",
"gpt-oss",
"llama",
"gemma",
"flux",
"qwen-image",
"nemotron",
+1
View File
@@ -13,3 +13,4 @@ export { default as ModelFilterPopover } from "./ModelFilterPopover.svelte";
export { default as ModelPickerGroup } from "./ModelPickerGroup.svelte";
export { default as ModelPickerModal } from "./ModelPickerModal.svelte";
export { default as ChatModelSelector } from "./ChatModelSelector.svelte";
export { default as IntegrationCard } from "./IntegrationCard.svelte";
+153 -18
View File
@@ -257,11 +257,12 @@ interface RawStateResponse {
}
export interface MessageAttachment {
type: "image" | "text" | "file" | "generated-image";
type: "image" | "text" | "file" | "generated-image" | "pdf";
name: string;
content?: string;
preview?: string;
mimeType?: string;
pageImages?: string[];
}
export interface TopLogprob {
@@ -2242,6 +2243,7 @@ class AppStore {
type: string;
textContent?: string;
preview?: string;
pageImages?: string[];
}[],
enableThinking?: boolean | null,
): Promise<void> {
@@ -2277,6 +2279,20 @@ class AppStore {
preview: file.preview,
mimeType: file.type,
});
} else if (
file.pageImages ||
(file.textContent && file.type === "application/pdf")
) {
attachments.push({
type: "pdf",
name: file.name,
content: file.textContent,
pageImages: file.pageImages,
mimeType: file.type,
});
if (file.textContent) {
fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
}
} else if (file.textContent) {
attachments.push({
type: "text",
@@ -2344,13 +2360,70 @@ class AppStore {
const apiMessages = [
systemPrompt,
...targetConversation.messages.slice(0, -1).map((m) => {
// Build content including any text file attachments
// Check if this message has image or PDF attachments
const visualAttachments = m.attachments?.filter(
(a) =>
(a.type === "image" && a.preview) ||
(a.type === "pdf" && a.pageImages?.length),
);
if (visualAttachments && visualAttachments.length > 0) {
// Build multimodal content array (OpenAI vision format)
const contentParts: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = [];
// Add image parts first
for (const att of visualAttachments) {
if (att.type === "image" && att.preview) {
contentParts.push({
type: "image_url",
image_url: { url: att.preview },
});
} else if (att.type === "pdf" && att.pageImages) {
for (const pageImg of att.pageImages) {
contentParts.push({
type: "image_url",
image_url: { url: pageImg },
});
}
}
}
// Build text content including any text/pdf file attachments
let textContent = m.content;
if (m.attachments) {
for (const attachment of m.attachments) {
if (
(attachment.type === "text" || attachment.type === "pdf") &&
attachment.content
) {
textContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
}
}
if (textContent) {
contentParts.push({ type: "text", text: textContent });
}
return {
role: m.role,
content: contentParts,
};
}
// Text-only message (original path)
let msgContent = m.content;
// Add text attachments as context
// Add text/pdf attachments as context
if (m.attachments) {
for (const attachment of m.attachments) {
if (attachment.type === "text" && attachment.content) {
if (
(attachment.type === "text" || attachment.type === "pdf") &&
attachment.content
) {
msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
}
@@ -2650,6 +2723,9 @@ class AppStore {
this.syncActiveMessagesIfNeeded(targetConversationId);
this.saveConversationsToStorage();
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2704,6 +2780,7 @@ class AppStore {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: abortController.signal,
});
if (!response.ok) {
@@ -2843,14 +2920,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
@@ -2914,6 +3004,9 @@ class AppStore {
// Clear editing state
this.editingImage = null;
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2975,6 +3068,7 @@ class AppStore {
const apiResponse = await fetch("/v1/images/edits", {
method: "POST",
body: formData,
signal: abortController.signal,
});
if (!apiResponse.ok) {
@@ -3075,14 +3169,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error editing image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to edit image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error editing image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to edit image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
@@ -3149,6 +3256,31 @@ class AppStore {
}
}
/**
* Cancel/pause an active download on a specific node
*/
async cancelDownload(nodeId: string, modelId: string): Promise<void> {
try {
const response = await fetch("/download/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: nodeId,
modelId: modelId,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to cancel download: ${response.status} - ${errorText}`,
);
}
} catch (error) {
console.error("Error cancelling download:", error);
throw error;
}
}
/**
* Delete a downloaded model from a specific node
*/
@@ -3271,6 +3403,7 @@ export const sendMessage = (
type: string;
textContent?: string;
preview?: string;
pageImages?: string[];
}[],
enableThinking?: boolean | null,
) => appStore.sendMessage(content, files, enableThinking);
@@ -3369,6 +3502,8 @@ export const resetImageGenerationParams = () =>
// Download actions
export const startDownload = (nodeId: string, shardMetadata: object) =>
appStore.startDownload(nodeId, shardMetadata);
export const cancelDownload = (nodeId: string, modelId: string) =>
appStore.cancelDownload(nodeId, modelId);
export const deleteDownload = (nodeId: string, modelId: string) =>
appStore.deleteDownload(nodeId, modelId);
+94 -1
View File
@@ -2,6 +2,41 @@
* File attachment types for the chat interface
*/
import { getDocument, GlobalWorkerOptions, version } from "pdfjs-dist";
import type { DocumentInitParameters } from "pdfjs-dist/types/src/display/api";
// Safari (through at least 18/26) does not implement
// ReadableStream.prototype[Symbol.asyncIterator], which pdfjs-dist uses
// internally in getTextContent(). Without this polyfill, `for await (const n
// of stream)` throws "undefined is not a function" and PDF processing fails.
if (
typeof ReadableStream !== "undefined" &&
!(ReadableStream.prototype as unknown as Record<symbol, unknown>)[
Symbol.asyncIterator
]
) {
(ReadableStream.prototype as unknown as Record<symbol, unknown>)[
Symbol.asyncIterator
] = async function* (this: ReadableStream<unknown>) {
const reader = this.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
yield value;
}
} finally {
reader.releaseLock();
}
};
}
GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.mjs`;
const PDF_PAGE_SCALE = 2.0;
const PDF_MAX_PAGES = 20;
const PDF_MAX_TEXT_CHARS = 100_000;
export interface ChatUploadedFile {
id: string;
name: string;
@@ -10,6 +45,7 @@ export interface ChatUploadedFile {
file: File;
preview?: string;
textContent?: string;
pageImages?: string[];
}
export interface ChatAttachment {
@@ -194,6 +230,58 @@ export function readFileAsText(file: File): Promise<string> {
});
}
async function extractPdfContent(
file: File,
): Promise<{ text: string; pageImages: string[] }> {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({
data: new Uint8Array(arrayBuffer),
useSystemFonts: true,
} as DocumentInitParameters).promise;
const numPages = Math.min(pdf.numPages, PDF_MAX_PAGES);
const pageTexts: string[] = [];
const pageImages: string[] = [];
for (let i = 1; i <= numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
const strings = content.items
.filter((item: any) => "str" in item)
.map((item: any) => item.str as string);
pageTexts.push(strings.join(" "));
const viewport = page.getViewport({ scale: PDF_PAGE_SCALE });
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx as any, viewport }).promise;
const blob = await canvas.convertToBlob({
type: "image/jpeg",
quality: 0.8,
});
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
pageImages.push(dataUrl);
}
}
let text = pageTexts.join("\n\n").trim();
if (text.length > PDF_MAX_TEXT_CHARS) {
text = text.slice(0, PDF_MAX_TEXT_CHARS) + "\n\n[truncated]";
}
if (pdf.numPages > PDF_MAX_PAGES) {
text += `\n\n[showing ${PDF_MAX_PAGES} of ${pdf.numPages} pages]`;
}
return { text, pageImages };
}
/**
* Process uploaded files into ChatUploadedFile format
*/
@@ -223,7 +311,12 @@ export async function processUploadedFiles(
const textContent = await readFileAsText(file);
results.push({ ...base, textContent });
} else if (category === "pdf") {
results.push(base);
const { text, pageImages } = await extractPdfContent(file);
results.push({
...base,
textContent: text || undefined,
pageImages: pageImages.length > 0 ? pageImages : undefined,
});
} else if (category === "audio") {
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
+55
View File
@@ -0,0 +1,55 @@
export async function copyText(text: string): Promise<boolean> {
if (
typeof window !== "undefined" &&
window.isSecureContext &&
navigator.clipboard?.writeText
) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to execCommand fallback
}
}
if (typeof document === "undefined") {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.padding = "0";
textarea.style.border = "none";
textarea.style.outline = "none";
textarea.style.boxShadow = "none";
textarea.style.background = "transparent";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
const previousSelection = document.getSelection();
const previousRange =
previousSelection && previousSelection.rangeCount > 0
? previousSelection.getRangeAt(0)
: null;
try {
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length);
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
if (previousRange && previousSelection) {
previousSelection.removeAllRanges();
previousSelection.addRange(previousRange);
}
}
}
+27
View File
@@ -265,6 +265,7 @@
let mounted = $state(false);
let localNodeId = $state<string | null>(null);
let pendingFirefoxQuery = $state<string | null>(null); // ?q= param deferred until state loads
// ── Onboarding wizard state ──
const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
@@ -1309,6 +1310,20 @@
return;
}
// Firefox AI sidebar integration: handle ?q= query parameter
// Firefox's built-in AI sidebar (about:config: browser.ml.chat.enabled) sends
// the user's prompt as ?q=<URL-encoded prompt> to the configured provider URL.
// See: https://support.mozilla.org/en-US/kb/ai-chatbot
const queryParam = params.get("q");
if (queryParam) {
// Clean up the URL to prevent re-submission on page refresh
window.history.replaceState({}, "", window.location.pathname);
// Defer the auto-send until cluster state is loaded (topologyData,
// instances, availableMemory) so that model auto-selection works
// correctly. The $effect below will pick this up once data is ready.
pendingFirefoxQuery = queryParam;
}
// Check server-side onboarding state (persisted in ~/.exo)
try {
const res = await fetch("/onboarding");
@@ -1329,6 +1344,18 @@
}
});
// Deferred Firefox AI sidebar auto-send: wait for cluster state and model
// list before submitting. Both data (from /state polling) and models (from
// the async /models fetch in onMount) must be loaded for handleAutoSend to
// correctly auto-select a model.
$effect(() => {
if (pendingFirefoxQuery && data && models.length > 0) {
const query = pendingFirefoxQuery;
pendingFirefoxQuery = null;
handleChatSend(query);
}
});
async function fetchModels() {
try {
const response = await fetch("/models");
+99 -94
View File
@@ -9,6 +9,7 @@
refreshState,
lastUpdate as lastUpdateStore,
startDownload,
cancelDownload,
deleteDownload,
} from "$lib/stores/app.svelte";
import {
@@ -349,6 +350,59 @@
});
</script>
{#snippet trashIcon()}
<svg
class="w-5 h-5"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M4 6h12M8 6V4h4v2m1 0v10a1 1 0 01-1 1H8a1 1 0 01-1-1V6h6"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
{/snippet}
{#snippet downloadIcon(size?: string)}
<svg
class={size ?? "w-5 h-5"}
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
{/snippet}
{#snippet pauseIcon()}
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M6 4h2v12H6V4zm6 0h2v12h-2V4z"
clip-rule="evenodd"
></path>
</svg>
{/snippet}
{#snippet deleteButton(nodeId: string, modelId: string)}
<button
type="button"
class="text-white/50 hover:text-red-400 transition-colors cursor-pointer"
onclick={() => deleteDownload(nodeId, modelId)}
title="Delete from this node"
>
{@render trashIcon()}
</button>
{/snippet}
<div class="min-h-screen bg-exo-dark-gray text-white">
<HeaderNav showHome={true} />
<div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
@@ -486,27 +540,7 @@
<span class="text-xs text-white/70"
>{formatBytes(cell.totalBytes)}</span
>
<button
type="button"
class="text-white/50 hover:text-red-400 transition-colors mt-0.5 cursor-pointer"
onclick={() =>
deleteDownload(col.nodeId, row.modelId)}
title="Delete from this node"
>
<svg
class="w-5 h-5"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M4 6h12M8 6V4h4v2m1 0v10a1 1 0 01-1 1H8a1 1 0 01-1-1V6h6"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</button>
{@render deleteButton(col.nodeId, row.modelId)}
</div>
{:else if cell.kind === "downloading"}
<div
@@ -533,6 +567,18 @@
<span class="text-[10px] text-white/70"
>{formatSpeed(cell.speed)}</span
>
<div class="flex gap-1 mt-0.5">
<button
type="button"
class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() =>
cancelDownload(col.nodeId, row.modelId)}
title="Pause download"
>
{@render pauseIcon()}
</button>
{@render deleteButton(col.nodeId, row.modelId)}
</div>
</div>
{:else if cell.kind === "pending"}
<div
@@ -558,32 +604,24 @@
).toFixed(1)}%"
></div>
</div>
{#if row.shardMetadata}
<button
type="button"
class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() =>
startDownload(col.nodeId, row.shardMetadata!)}
title="Resume download on this node"
>
<svg
class="w-5 h-5"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex gap-1">
{#if row.shardMetadata}
<button
type="button"
class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() =>
startDownload(col.nodeId, row.shardMetadata!)}
title="Resume download on this node"
>
<path
d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</button>
{:else}
<span class="text-white/50 text-[10px]">paused</span
>
{/if}
{@render downloadIcon()}
</button>
{:else}
<span class="text-white/50 text-[10px]"
>paused</span
>
{/if}
{@render deleteButton(col.nodeId, row.modelId)}
</div>
{:else if row.shardMetadata}
<button
type="button"
@@ -592,19 +630,7 @@
startDownload(col.nodeId, row.shardMetadata!)}
title="Start download on this node"
>
<svg
class="w-6 h-6"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
{@render downloadIcon("w-6 h-6")}
</button>
{:else}
<span class="text-white/40 text-sm">...</span>
@@ -626,29 +652,20 @@
clip-rule="evenodd"
></path>
</svg>
{#if row.shardMetadata}
<button
type="button"
class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() =>
startDownload(col.nodeId, row.shardMetadata!)}
title="Retry download on this node"
>
<svg
class="w-5 h-5"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex gap-1">
{#if row.shardMetadata}
<button
type="button"
class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() =>
startDownload(col.nodeId, row.shardMetadata!)}
title="Retry download on this node"
>
<path
d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</button>
{/if}
{@render downloadIcon()}
</button>
{/if}
{@render deleteButton(col.nodeId, row.modelId)}
</div>
</div>
{:else}
<div
@@ -666,19 +683,7 @@
startDownload(col.nodeId, row.shardMetadata!)}
title="Download to this node"
>
<svg
class="w-5 h-5"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
{@render downloadIcon()}
</button>
{/if}
</div>
@@ -0,0 +1,577 @@
<script lang="ts">
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import IntegrationCard from "$lib/components/IntegrationCard.svelte";
import { instances, refreshState } from "$lib/stores/app.svelte";
import { onMount } from "svelte";
const apiUrl = browser
? window.location.origin.replace("localhost", "127.0.0.1")
: "http://127.0.0.1:52415";
const instancesData = $derived(instances());
let modelCapabilities = $state<Record<string, string[]>>({});
let modelContextLengths = $state<Record<string, number>>({});
const runningModels = $derived.by(() => {
const models: string[] = [];
for (const [, wrapper] of Object.entries(instancesData)) {
if (wrapper && typeof wrapper === "object") {
const values = Object.values(wrapper as Record<string, unknown>);
if (values.length > 0) {
const instance = values[0];
if (instance && typeof instance === "object") {
const inst = instance as {
shardAssignments?: { modelId?: string };
};
const modelId = inst.shardAssignments?.modelId;
if (modelId && !models.includes(modelId)) {
models.push(modelId);
}
}
}
}
}
return models;
});
function estimateParamSize(modelId: string): number {
const match = modelId.match(/(\d+(?:\.\d+)?)[Bb]/);
return match ? parseFloat(match[1]) : 0;
}
const modelsBySize = $derived(
[...runningModels].sort(
(a, b) => estimateParamSize(b) - estimateParamSize(a),
),
);
const defaultTiers = $derived.by(() => {
const n = modelsBySize.length;
if (n === 0)
return {
opus: "your-model-id",
sonnet: "your-model-id",
haiku: "your-model-id",
};
if (n === 1)
return {
opus: modelsBySize[0],
sonnet: modelsBySize[0],
haiku: modelsBySize[0],
};
if (n === 2)
return {
opus: modelsBySize[0],
sonnet: modelsBySize[1],
haiku: modelsBySize[1],
};
return {
opus: modelsBySize[0],
sonnet: modelsBySize[Math.floor(n / 2)],
haiku: modelsBySize[n - 1],
};
});
let opusModel = $state("");
let sonnetModel = $state("");
let haikuModel = $state("");
$effect(() => {
opusModel = defaultTiers.opus;
sonnetModel = defaultTiers.sonnet;
haikuModel = defaultTiers.haiku;
});
let codexModel = $state("");
let codexMcpPath = $state("/Users/username");
let openClawModel = $state("");
$effect(() => {
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
codexModel = def;
openClawModel = def;
});
const claudeShellCommand = $derived(
[
`ANTHROPIC_BASE_URL=${apiUrl} \\`,
`ANTHROPIC_API_KEY=x \\`,
`ANTHROPIC_DEFAULT_OPUS_MODEL=${opusModel} \\`,
`ANTHROPIC_DEFAULT_SONNET_MODEL=${sonnetModel} \\`,
`ANTHROPIC_DEFAULT_HAIKU_MODEL=${haikuModel} \\`,
`API_TIMEOUT_MS=3000000 \\`,
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \\`,
`claude`,
].join("\n"),
);
const claudeSettingsJson = $derived(
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: apiUrl,
ANTHROPIC_API_KEY: "x",
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
},
},
null,
2,
),
);
const openCodeConfig = $derived.by(() => {
const models: Record<string, Record<string, unknown>> = {};
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const entry: Record<string, unknown> = { name: modelId };
if (ctxLen > 0) {
entry.limit = { context: ctxLen, output: Math.min(ctxLen, 16384) };
}
if (caps.includes("vision")) {
entry.modalities = { input: ["text", "image"], output: ["text"] };
}
models[modelId] = entry;
}
if (Object.keys(models).length === 0) {
models["your-model-id"] = { name: "your-model-name" };
}
const firstModel =
runningModels.length > 0 ? runningModels[0] : "your-model-id";
return JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
provider: {
exo: {
npm: "@ai-sdk/openai-compatible",
name: "exo",
options: {
baseURL: `${apiUrl}/v1`,
apiKey: "x",
},
models,
},
},
model: `exo/${firstModel}`,
},
null,
2,
);
});
const codexShellCommand = $derived(`EXO_API_KEY=x npx @openai/codex`);
const codexConfig = $derived(
[
`model = "${codexModel}"`,
`model_provider = "exo"`,
``,
`[model_providers.exo]`,
`name = "exo"`,
`base_url = "${apiUrl}/v1"`,
`env_key = "EXO_API_KEY"`,
``,
`[mcp_servers.filesystem]`,
`command = "npx"`,
`args = ["-y", "@modelcontextprotocol/server-filesystem", "${codexMcpPath}"]`,
].join("\n"),
);
const openClawConfig = $derived(
JSON.stringify(
{
gateway: { mode: "local" },
models: {
providers: {
exo: {
baseUrl: `${apiUrl}/v1`,
apiKey: "x",
api: "openai-completions",
models: [
{
id: openClawModel,
name: "exo local",
input: (modelCapabilities[openClawModel] || []).includes(
"vision",
)
? ["text", "image"]
: ["text"],
},
],
},
},
},
agents: {
defaults: {
model: `exo/${openClawModel}`,
},
},
},
null,
2,
),
);
const ollamaCommand = $derived(
`OLLAMA_HOST=${apiUrl}/ollama ollama run ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"}`,
);
const openWebUiCommand = $derived(
[
`docker run -d -p 3000:8080 \\`,
` -e OLLAMA_BASE_URL=${apiUrl.replace("localhost", "host.docker.internal")}/ollama \\`,
` -v open-webui:/app/backend/data \\`,
` --name open-webui \\`,
` ghcr.io/open-webui/open-webui:main`,
].join("\n"),
);
const n8nDockerCommand = $derived(
[
`docker run -d -p 5678:5678 \\`,
` -v n8n_data:/home/node/.n8n \\`,
` --name n8n \\`,
` docker.n8n.io/n8nio/n8n`,
].join("\n"),
);
const n8nCredentialSteps = $derived(
[
`1. Go to Credentials → Add Credential → search "OpenAI API"`,
`2. Set API Key to: x`,
`3. Set Base URL to: ${apiUrl.replace("127.0.0.1", "host.docker.internal").replace("localhost", "host.docker.internal")}/v1`,
`4. Save the credential`,
].join("\n"),
);
const n8nWorkflowSteps = $derived(
[
`1. Create a new workflow → "Start from Scratch"`,
`2. Add an "AI Agent" or "Basic LLM Chain" node`,
`3. Inside it, add an "OpenAI Chat Model" sub-node`,
`4. Select the OpenAI credential you just created`,
`5. Set Model to "From list" and pick your model (e.g. ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"})`,
`6. Optionally toggle "Use Responses API", add Built-in Tools, or click "Add Option" for sampling settings`,
`7. Connect a "Chat Trigger" node for interactive chat`,
`8. On the Chat Trigger, enable "Allow File Uploads" for vision`,
].join("\n"),
);
const firefoxConfig = $derived(
[
`1. Open about:config in Firefox`,
`2. Set browser.ml.chat.enabled to true`,
`3. Set browser.ml.chat.hideLocalhost to false`,
`4. Set browser.ml.chat.provider to: ${apiUrl}/`,
].join("\n"),
);
const tabs = [
"Claude Code",
"OpenCode",
"Codex",
"OpenClaw",
"Open WebUI",
"n8n",
"Firefox",
] as const;
type Tab = (typeof tabs)[number];
const stored = browser ? localStorage.getItem("exo-integrations-tab") : null;
let activeTab = $state<Tab>(
stored && tabs.includes(stored as Tab) ? (stored as Tab) : "Claude Code",
);
$effect(() => {
if (browser) localStorage.setItem("exo-integrations-tab", activeTab);
});
const selectClass =
"bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none appearance-none cursor-pointer";
onMount(async () => {
refreshState();
try {
const resp = await fetch("/v1/models");
const data = (await resp.json()) as {
data: { id: string; capabilities: string[]; context_length: number }[];
};
const caps: Record<string, string[]> = {};
const ctxs: Record<string, number> = {};
for (const model of data.data) {
caps[model.id] = model.capabilities || [];
if (model.context_length > 0) ctxs[model.id] = model.context_length;
}
modelCapabilities = caps;
modelContextLengths = ctxs;
} catch {
/* ignore */
}
});
</script>
<div class="min-h-screen bg-exo-dark-gray flex flex-col">
<HeaderNav showHome={true} />
<main
class="flex-1 max-w-3xl mx-auto w-full px-4 md:px-6 py-8"
in:fade={{ duration: 200 }}
>
<div class="mb-8">
<h1
class="text-white text-xl md:text-2xl font-semibold tracking-wide mb-2"
>
Integrations
</h1>
<p class="text-exo-light-gray/60 text-sm">
Connect external tools to your exo cluster.
</p>
</div>
<!-- Status -->
<div class="mb-8">
<span class="text-exo-light-gray/70 text-xs uppercase tracking-wider"
>API Endpoint</span
>
<span class="text-white font-mono text-sm ml-2">{apiUrl}</span>
{#if runningModels.length > 0}
<div class="text-exo-light-gray/50 text-xs mt-2">
Running model{runningModels.length > 1 ? "s" : ""}:
<ul class="mt-1 space-y-0.5 list-none">
{#each runningModels as model}
<li class="text-exo-yellow font-mono">{model}</li>
{/each}
</ul>
</div>
{:else}
<p class="text-exo-light-gray/40 text-xs mt-2 italic">
No models currently running
</p>
{/if}
</div>
<!-- API Endpoints -->
<div class="mb-8">
<div
class="flex flex-col sm:flex-row gap-3 text-xs font-mono text-exo-light-gray/70"
>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>OpenAI-compatible</span
>
<span class="text-white/80">{apiUrl}/v1</span>
</div>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>Claude-compatible</span
>
<span class="text-white/80">{apiUrl}</span>
</div>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>Ollama-compatible</span
>
<span class="text-white/80">{apiUrl}/ollama</span>
</div>
</div>
</div>
<!-- Tabs -->
<div
class="flex flex-wrap gap-2 mb-6 border-b border-exo-light-gray/10 pb-3"
>
{#each tabs as tab}
<button
onclick={() => (activeTab = tab)}
class="px-3 py-1.5 text-xs rounded-md transition-all cursor-pointer
{activeTab === tab
? 'bg-exo-yellow/15 text-exo-yellow border border-exo-yellow/30'
: 'text-exo-light-gray/60 hover:text-white/80 border border-transparent hover:border-exo-light-gray/20'}"
>
{tab}
</button>
{/each}
</div>
<!-- Tab Content -->
<div class="space-y-4">
{#if activeTab === "Claude Code"}
{#if runningModels.length > 1}
<div class="grid grid-cols-3 gap-3 text-xs">
{#each [{ label: "Opus", bind: () => opusModel, set: (v: string) => (opusModel = v) }, { label: "Sonnet", bind: () => sonnetModel, set: (v: string) => (sonnetModel = v) }, { label: "Haiku", bind: () => haikuModel, set: (v: string) => (haikuModel = v) }] as tier}
<div>
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>{tier.label}</span
>
<select
value={tier.bind()}
onchange={(e) =>
tier.set((e.target as HTMLSelectElement).value)}
class="w-full {selectClass}"
>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/each}
</div>
{/if}
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch Claude Code with exo as the backend. Paste this into your terminal."
config={claudeShellCommand}
language="bash"
/>
<IntegrationCard
title="Settings File"
subtitle="~/.claude/settings.json"
description="Or add this to your Claude Code settings for persistent configuration."
config={claudeSettingsJson}
/>
{:else if activeTab === "OpenCode"}
<IntegrationCard
title="Config File"
subtitle="opencode.json"
description="Add this to your project root or ~/.config/opencode/opencode.json for global config. Vision models automatically get image input modality."
config={openCodeConfig}
/>
{:else if activeTab === "Codex"}
<div class="flex gap-3 text-xs">
{#if runningModels.length > 1}
<div>
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={codexModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<div class="flex-1">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>MCP Filesystem Path</span
>
<input
type="text"
bind:value={codexMcpPath}
class="w-full bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none"
/>
</div>
</div>
<IntegrationCard
title="Config File"
subtitle="~/.codex/config.toml"
description="Add this to your Codex CLI config so the model and provider persist."
config={codexConfig}
/>
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch Codex with exo as the backend."
config={codexShellCommand}
language="bash"
/>
{:else if activeTab === "OpenClaw"}
{#if runningModels.length > 1}
<div class="text-xs">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={openClawModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<IntegrationCard
title="Config File"
subtitle="~/.openclaw/openclaw.json"
description="Add this to your OpenClaw config. If you haven't installed OpenClaw yet, run: npm install -g openclaw@latest"
config={openClawConfig}
/>
<IntegrationCard
title="Setup Commands"
subtitle="Run in terminal"
description="After saving the config, run these commands to fix metadata and start the gateway."
config={`openclaw doctor --fix${(modelCapabilities[openClawModel] || []).includes("vision") ? `\nopenclaw models set-image exo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
language="bash"
/>
{:else if activeTab === "Open WebUI"}
<IntegrationCard
title="1. Start Open WebUI"
subtitle="Run in terminal"
description="Run this to start Open WebUI."
config={openWebUiCommand}
language="bash"
/>
<IntegrationCard
title="2. Open & Select Model"
subtitle="http://localhost:3000"
description={`Open http://localhost:3000 in your browser. Select the running model from the dropdown at the top: ${runningModels.length > 0 ? runningModels.join(", ") : "no models running"}`}
config={"open http://localhost:3000"}
language="bash"
/>
<IntegrationCard
title="Ollama CLI"
subtitle="Run in terminal"
description="Or use the Ollama CLI directly."
config={ollamaCommand}
language="bash"
/>
{:else if activeTab === "n8n"}
<IntegrationCard
title="1. Start n8n"
subtitle="Run in terminal"
description="Start n8n with Docker. If you already have n8n running, skip this step."
config={n8nDockerCommand}
language="bash"
/>
<IntegrationCard
title="2. Open n8n"
subtitle="http://localhost:5678"
description="Open n8n in your browser. If this is your first time, complete the setup and select 'Start from Scratch' when prompted."
config={"open http://localhost:5678"}
language="bash"
/>
<IntegrationCard
title="3. Add OpenAI Credential"
subtitle="n8n UI → Credentials"
description="Create an OpenAI credential pointing at your exo cluster."
config={n8nCredentialSteps}
/>
<IntegrationCard
title="4. Build a Workflow"
subtitle="n8n UI → Workflows"
description="Create a workflow that uses your exo-powered model."
config={n8nWorkflowSteps}
/>
{:else if activeTab === "Firefox"}
<IntegrationCard
title="Firefox AI Chatbot"
subtitle="about:config"
description="Use the exo dashboard as Firefox's built-in AI chatbot. Requires Firefox 130+."
config={firefoxConfig}
/>
{/if}
</div>
</main>
</div>
+1 -1
View File
@@ -81,4 +81,4 @@ Whenever a device produces side effects, it captures those side effects in an `E
## Purity
A significant goal of the current design is to make data flow explicit. Classes should either represent simple data (`CamelCaseModel`s typically, and `TaggedModel`s for unions) or active `System`s (Erlang `Actor`s), with all transformations of that data being "referentially transparent" - destructure and construct new data, don't mutate in place. We have had varying degrees of success with this, and are still exploring where purity makes sense.
A significant goal of the current design is to make data flow explicit. Classes should either represent simple data (`FrozenModel`s typically, and `TaggedModel`s for unions) or active `System`s (Erlang `Actor`s), with all transformations of that data being "referentially transparent" - destructure and construct new data, don't mutate in place. We have had varying degrees of success with this, and are still exploring where purity makes sense.
Generated
+49 -45
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1767744144,
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
"lastModified": 1775790182,
"narHash": "sha256-pG2RWVQY0Pe+rmmXJx+Jpyi+JcgjWzS18m7fcD1B64Q=",
"owner": "ipetkov",
"repo": "crane",
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
"rev": "534982f1c41834b101e381b07b1121a4f065a374",
"type": "github"
},
"original": {
@@ -47,11 +47,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1768287139,
"narHash": "sha256-nsXFt0OzUi6K7dUzzJD5/v9e0Ic+fvclfIW936/43ZM=",
"lastModified": 1775807984,
"narHash": "sha256-Redoe3D9zGN5I9QPHWL9vfMVQBehY1fKsMiRXQ83X3w=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a4a3aa956931f90f35453cb519e4545e9ad7f773",
"rev": "fcf90c0c4d368b2ca917a7afa6d08e98a397e5fd",
"type": "github"
},
"original": {
@@ -83,11 +83,11 @@
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"type": "github"
},
"original": {
@@ -96,38 +96,42 @@
"type": "github"
}
},
"nixglhost": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1732211616,
"narHash": "sha256-QZCKJoypcwgS3tDNSWMjlxEBZtOYPW3eXV24rMzKsac=",
"owner": "numtide",
"repo": "nix-gl-host",
"rev": "5269b233f83880a0b433eafe026f0bc0d8f1a4a9",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "nix-gl-host",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768127708,
"narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=",
"lastModified": 1775595990,
"narHash": "sha256-OEf7YqhF9IjJFYZJyuhAypgU+VsRB5lD4DuiMws5Ltc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38",
"rev": "4e92bbcdb030f3b4782be4751dc08e6b6cb6ccf2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-swift": {
"locked": {
"lastModified": 1761672384,
"narHash": "sha256-o9KF3DJL7g7iYMZq9SWgfS1BFlNbsm6xplRjVlOCkXI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "08dacfca559e1d7da38f3cf05f1f45ee9bfd213c",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "08dacfca559e1d7da38f3cf05f1f45ee9bfd213c",
"type": "github"
}
},
"purescript-overlay": {
"inputs": {
"flake-compat": "flake-compat",
@@ -164,11 +168,11 @@
]
},
"locked": {
"lastModified": 1763662255,
"narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=",
"lastModified": 1773870109,
"narHash": "sha256-ZoTdqZP03DcdoyxvpFHCAek4bkPUTUPUF3oCCgc3dP4=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "042904167604c681a090c07eb6967b4dd4dae88c",
"rev": "b6e74f433b02fa4b8a7965ee24680f4867e2926f",
"type": "github"
},
"original": {
@@ -184,11 +188,11 @@
]
},
"locked": {
"lastModified": 1764134915,
"narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=",
"lastModified": 1775439158,
"narHash": "sha256-NHY9SJNU019n+8NCabBDtmuzRFeE2gZlYKHowp9bV24=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "2c8df1383b32e5443c921f61224b198a2282a657",
"rev": "fb6b728260f3f32761367e9fd1e1a25b4245bcd0",
"type": "github"
},
"original": {
@@ -203,8 +207,8 @@
"dream2nix": "dream2nix",
"fenix": "fenix",
"flake-parts": "flake-parts",
"nixglhost": "nixglhost",
"nixpkgs": "nixpkgs",
"nixpkgs-swift": "nixpkgs-swift",
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix",
"treefmt-nix": "treefmt-nix",
@@ -214,11 +218,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1768224240,
"narHash": "sha256-Pp1dDrXKPBUJReZnnDElFyHYn67XTd48zRhToheLjtk=",
"lastModified": 1775745684,
"narHash": "sha256-8MbfLwd60FNa8dRFkjE+G3TT/x21G3Rsplm1bMBQUtU=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "725349602e525df37f377701e001fe8aab807878",
"rev": "64ddb549bc9a70d011328746fa46a8883f937b6b",
"type": "github"
},
"original": {
@@ -257,11 +261,11 @@
]
},
"locked": {
"lastModified": 1768158989,
"narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=",
"lastModified": 1775636079,
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca",
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"type": "github"
},
"original": {
@@ -280,11 +284,11 @@
]
},
"locked": {
"lastModified": 1767701098,
"narHash": "sha256-CJhKZnWb3gumR9oTRjFvCg/6lYTGbZRU7xtvcyWIRwU=",
"lastModified": 1775706324,
"narHash": "sha256-BTb4sydzX2B5/oNbvCdQFeSbk97xEnbb8bk84CiKCOs=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "9d357f0d2ce6f5f35ec7959d7e704452352eb4da",
"rev": "5707df99097375896a3dda811d492a2fabe63500",
"type": "github"
},
"original": {
+37 -44
View File
@@ -2,7 +2,7 @@
description = "The development environment for Exo";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-parts = {
url = "github:hercules-ci/flake-parts";
@@ -46,17 +46,18 @@
inputs.nixpkgs.follows = "nixpkgs";
};
# Pinned nixpkgs for swift-format (swift is broken on x86_64-linux in newer nixpkgs)
nixpkgs-swift.url = "github:NixOS/nixpkgs/08dacfca559e1d7da38f3cf05f1f45ee9bfd213c";
nixglhost = {
url = "github:numtide/nix-gl-host";
inputs.nixpkgs.follows = "nixpkgs";
};
};
nixConfig = {
extra-trusted-public-keys = "exo.cachix.org-1:okq7hl624TBeAR3kV+g39dUFSiaZgLRkLsFBCuJ2NZI=";
extra-substituters = "https://exo.cachix.org";
extra-trusted-public-keys = "exo.cachix.org-1:okq7hl624TBeAR3kV+g39dUFSiaZgLRkLsFBCuJ2NZI= cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=";
extra-substituters = "https://exo.cachix.org https://cache.nixos-cuda.org";
};
outputs =
inputs:
outputs = inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
@@ -71,32 +72,38 @@
./python/parts.nix
];
perSystem =
{ config, self', pkgs, lib, system, ... }:
debug = true; # Enable options autocompletion
perSystem = { 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; };
in
{
# Allow unfree for metal-toolchain (needed for Darwin Metal packages)
_module.args.pkgs = import inputs.nixpkgs {
pkgsArgs = {
inherit system;
config.allowUnfreePredicate = pkg: (pkg.pname or "") == "metal-toolchain";
overlays = [
inputs.nixglhost.overlays.default
(import ./nix/apple-sdk-overlay.nix)
(final: prev: {
macmon = prev.macmon.overrideAttrs (_: {
(final: _: {
macmon = final.rustPlatform.buildRustPackage {
pname = "macmon";
version = "git";
src = final.fetchFromGitHub {
owner = "swiftraccoon";
owner = "vladkens";
repo = "macmon";
rev = "9154d234f763fbeffdcb4135d0bbbaf80609699b";
hash = "sha256-CwhilKNbs5XL9/tF5DMwyPBlE/hpmjGNTuxQ36sM50M=";
rev = "a1cd06b6cc0d5e61db24fd8832e74cd992097a7d";
hash = "sha256-wcq4PUXK44XfUKOZKl32u8LpOxXpSbUUfItQGwS2Zso=";
};
});
cargoHash = "sha256-Epj3L+db1flGNK5y6yfSig8piEiXTz15lPo/FNkqlkA=";
};
})
];
};
in
{
# Allow unfree for metal-toolchain (needed for Darwin Metal packages)
_module.args = {
pkgs = import inputs.nixpkgs pkgsArgs;
unfreePkgs = import inputs.nixpkgs (pkgsArgs // { config.allowUnfree = true; });
};
treefmt = {
projectRootFile = "flake.nix";
programs = {
@@ -116,29 +123,19 @@
};
swift-format = {
enable = true;
package = pkgsSwift.swiftPackages.swift-format;
package = pkgs.swiftPackages.swift-format;
};
shfmt.enable = true;
taplo.enable = true;
};
};
packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin (
let
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 uvLockMlxRev;
};
default = self'.packages.exo;
}
);
packages = {
default = self'.packages.exo;
} //
lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
};
devShells.default = with pkgs; pkgs.mkShell {
inputsFrom = [ self'.checks.cargo-build ];
@@ -149,16 +146,15 @@
config.treefmt.build.wrapper
# PYTHON
python313
self'.packages.editableVenv
uv
ruff
basedpyright
# RUST
config.rust.toolchain
maturin
# NIX
nixd
nixpkgs-fmt
# SVELTE
@@ -168,9 +164,6 @@
just
jq
]
++ lib.optionals stdenv.isLinux [
unixtools.ifconfig
]
++ lib.optionals stdenv.isDarwin [
macmon
];
+6 -4
View File
@@ -22,19 +22,21 @@ sync-clean:
uv sync --all-packages --force-reinstall --no-cache
rust-rebuild:
cargo run --bin stub_gen
PYO3_PYTHON="$(uv run python -c 'import sys; print(sys.executable)')" cargo run --bin stub_gen
uv sync --reinstall-package exo_pyo3_bindings
build-dashboard:
#!/usr/bin/env bash
cd dashboard
pushd dashboard
npm install
npm run build
popd
package:
package: build-dashboard
uv run pyinstaller packaging/pyinstaller/exo.spec
rm -rf build
build-app: package
build-app: rust-rebuild sync-clean 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"
+1 -1
View File
@@ -59,7 +59,7 @@ index 262b0495..5c7446ad 100644
add_custom_command(
OUTPUT ${MLX_METAL_PATH}/mlx.metallib
- COMMAND xcrun -sdk macosx metallib ${KERNEL_AIR} -o
- COMMAND xcrun -sdk macosx metal ${KERNEL_AIR} -o
+ COMMAND metallib ${KERNEL_AIR} -o
${MLX_METAL_PATH}/mlx.metallib
DEPENDS ${KERNEL_AIR}
-158
View File
@@ -1,158 +0,0 @@
{ stdenv
, lib
, fetchFromGitHub
, replaceVars
, fetchzip
, cmake
, nlohmann_json
, apple-sdk_26
, metal-toolchain
, runCommand
, fmt
, python313Packages
, uvLockMlxVersion
, uvLockMlxRev
}:
assert stdenv.isDarwin;
let
python = python313Packages.python;
# Static dependencies included directly during compilation
gguf-tools = fetchFromGitHub {
owner = "antirez";
repo = "gguf-tools";
rev = "8fa6eb65236618e28fd7710a0fba565f7faa1848";
hash = "sha256-15FvyPOFqTOr5vdWQoPnZz+mYH919++EtghjozDlnSA=";
};
metal_cpp = fetchzip {
url = "https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip";
hash = "sha256-7n2eI2lw/S+Us6l7YPAATKwcIbRRpaQ8VmES7S8ZjY8=";
};
nanobind = fetchFromGitHub {
owner = "wjakob";
repo = "nanobind";
rev = "v2.10.2";
hash = "sha256-io44YhN+VpfHFWyvvLWSanRgbzA0whK8WlDNRi3hahU=";
fetchSubmodules = true;
};
mlx = stdenv.mkDerivation rec {
pname = "mlx";
version = uvLockMlxVersion;
pyproject = true;
src = fetchFromGitHub {
owner = "rltakashige";
repo = "mlx-jaccl-fix-small-recv";
rev = uvLockMlxRev;
hash = "sha256-GosFIWxIB48Egb1MqJrR3xhsUsQeWdRk5rV93USY6wQ=";
};
patches = [
(replaceVars ./darwin-build-fixes.patch {
sdkVersion = apple-sdk_26.version;
metalVersion = metal-toolchain.metalVersion;
})
];
postPatch = ''
substituteInPlace mlx/backend/cpu/jit_compiler.cpp \
--replace-fail "g++" "$CXX"
'';
dontUseCmakeConfigure = true;
enableParallelBuilding = true;
# Allows multiple cores to be used in Python builds.
postUnpack = ''
export MAKEFLAGS+="''${enableParallelBuilding:+-j$NIX_BUILD_CORES}"
'';
# Updates the wrong fetcher rev attribute
passthru.skipBulkUpdate = true;
env = {
DEV_RELEASE = 1;
CMAKE_ARGS = toString [
(lib.cmakeBool "USE_SYSTEM_FMT" true)
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_GGUFLIB" "${gguf-tools}")
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_JSON" "${nlohmann_json.src}")
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_NANOBIND" "${nanobind}")
(lib.cmakeBool "FETCHCONTENT_FULLY_DISCONNECTED" true)
(lib.cmakeBool "MLX_BUILD_CPU" true)
(lib.cmakeBool "MLX_BUILD_METAL" true)
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_METAL_CPP" "${metal_cpp}")
(lib.cmakeOptionType "string" "CMAKE_OSX_DEPLOYMENT_TARGET" "${apple-sdk_26.version}")
(lib.cmakeOptionType "filepath" "CMAKE_OSX_SYSROOT" "${apple-sdk_26.passthru.sdkroot}")
];
SDKROOT = apple-sdk_26.passthru.sdkroot;
MACOSX_DEPLOYMENT_TARGET = apple-sdk_26.version;
};
build-system = [
python313Packages.setuptools
];
nativeBuildInputs = [
cmake
metal-toolchain
python313Packages.pypaBuildHook
python313Packages.pypaInstallHook
python313Packages.setuptools
python313Packages.typing-extensions
python313Packages.wheel
python313Packages.cmake
python313Packages.ninja
];
buildInputs = [
fmt
gguf-tools
python313Packages.nanobind
python313Packages.pybind11
apple-sdk_26
];
# Tests require Metal GPU access which isn't available in the Nix sandbox.
# To run tests, build with: nix build --option sandbox false .#mlx.passthru.tests.mlxTest
doCheck = false;
pythonImportsCheck = [ "mlx" ];
passthru.tests = {
# Runs example scripts to verify MLX works. Requires --option sandbox false
# since Metal GPU access is needed.
mlxTest =
runCommand "run-mlx-examples"
{
buildInputs = [ mlx ];
nativeBuildInputs = [ python ];
}
''
cp ${src}/examples/python/logistic_regression.py .
${python.interpreter} logistic_regression.py
rm logistic_regression.py
cp ${src}/examples/python/linear_regression.py .
${python.interpreter} linear_regression.py
rm linear_regression.py
touch $out
'';
};
meta = {
homepage = "https://github.com/ml-explore/mlx";
description = "Array framework for Apple silicon";
changelog = "https://github.com/ml-explore/mlx/releases/tag/${src.tag}";
license = lib.licenses.mit;
platforms = [ "aarch64-darwin" ];
};
};
in
mlx
+12 -9
View File
@@ -1,5 +1,6 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import importlib.util
import shutil
from pathlib import Path
@@ -56,6 +57,7 @@ HIDDEN_IMPORTS = sorted(
set(
collect_submodules("mlx")
+ _safe_collect("mlx_lm")
+ _safe_collect("mlx_vlm")
+ _safe_collect("transformers")
)
)
@@ -67,18 +69,19 @@ DATAS: list[tuple[str, str]] = [
(str(EXO_SHARED_MODELS_DIR), "exo/shared/models"),
]
MACMON_PATH = shutil.which("macmon")
if MACMON_PATH is None:
raise SystemExit(
"macmon binary not found in PATH. "
"Install the pinned fork used by exo via: "
"cargo install --git https://github.com/swiftraccoon/macmon "
"--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b macmon --force"
)
if sys.platform == "darwin":
MACMON_PATH = shutil.which("macmon")
if MACMON_PATH is None:
raise SystemExit(
"macmon binary not found in PATH. "
"Install the pinned fork used by exo via: "
"cargo install --git https://github.com/vladkens/macmon "
"--rev a1cd06b6cc0d5e61db24fd8832e74cd992097a7d macmon --force"
)
BINARIES: list[tuple[str, str]] = [
(MACMON_PATH, "."),
]
] if sys.platform == "darwin" else []
a = Analysis(
[str(ENTRYPOINT)],
+91 -18
View File
@@ -1,6 +1,6 @@
[project]
name = "exo"
version = "0.3.68"
version = "0.3.70"
description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
@@ -12,23 +12,25 @@ dependencies = [
"fastapi>=0.116.1",
"filelock>=3.18.0",
"rustworkx>=0.17.1",
"huggingface-hub>=0.33.4",
"huggingface-hub>=1.8.0",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo_pyo3_bindings", # rust bindings
"exo-pyo3-bindings", # rust bindings
"anyio==4.11.0",
"mlx; sys_platform == 'darwin'",
"mlx[cpu]==0.30.6; sys_platform == 'linux'",
"mlx==0.31.1; sys_platform == 'darwin'",
"mlx-lm",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"mflux==0.17.2",
"mflux==0.17.2; sys_platform == 'darwin'",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"mlx-vlm>=0.3.11",
"transformers>=5.0.0,<5.4.0",
"pydantic-settings>=2.13.1",
]
[project.scripts]
@@ -45,11 +47,20 @@ dev = [
"ruff>=0.11.13",
]
# mlx[cuda] requires a newer version of mlx. the ideal on linux is: default to mlx[cpu] unless[cuda] specified.
[project.optional-dependencies]
# cuda = [
# "mlx[cuda]==0.26.3",
# ]
build = ["nanobind"]
cpu = [
"mlx-cpu==0.31.1; sys_platform == 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
cuda12 = [
"mlx-cuda-12==0.31.1; sys_platform == 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
cuda13 = [
"mlx-cuda-13==0.31.1; sys_platform == 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
###
# workspace configuration
@@ -59,12 +70,30 @@ dev = [
members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo_pyo3_bindings = { workspace = true }
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/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 }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-arrayscache-leak" }
torch = [
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and extra == 'cuda13' and extra != 'cpu' and extra != 'cuda12'" },
{ index = "pytorch-cu120", marker = "sys_platform == 'linux' and extra == 'cuda12' and extra != 'cpu' and extra != 'cuda13'" },
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'cpu' and extra != 'cuda12' and extra != 'cuda13'" },
]
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
[[tool.uv.index]]
name = "pytorch-cu130"
url = "https://download.pytorch.org/whl/cu130"
explicit = true
[[tool.uv.index]]
name = "pytorch-cu120"
url = "https://download.pytorch.org/whl/cu120"
explicit = true
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[build-system]
requires = ["uv_build>=0.8.9,<0.9.0"]
@@ -75,7 +104,7 @@ build-backend = "uv_build"
###
[tool.basedpyright]
include = [".venv/lib/mlx", ".venv/lib/mlx_lm", "src", "bench"]
include = ["src", "bench"]
typeCheckingMode = "strict"
failOnWarnings = true
@@ -102,9 +131,14 @@ exclude = [
]
stubPath = ".mlx_typings"
[[tool.basedpyright.executionEnvironments]]
root = "src/exo/worker/engines/image"
reportMissingModuleSource = false
[[tool.basedpyright.executionEnvironments]]
root = "src"
###
# uv configuration
###
@@ -114,6 +148,46 @@ root = "src"
required-version = ">=0.8.6"
prerelease = "allow"
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
conflicts = [[{ extra = "cuda12" }, { extra = "cuda13" }, { extra = "cpu" }]]
constraint-dependencies = ["transformers>=5.0.0,<5.4.0"]
[tool.uv.extra-build-dependencies]
miniaudio = ["setuptools", "cffi", "pycparser"]
mlx = [
"setuptools",
"typing-extensions",
"nanobind",
"pybind11",
"wheel",
"cmake",
"ninja",
]
mlx-lm = ["setuptools"]
xgrammar = [
"nanobind",
"setuptools",
"scikit-build-core",
"packaging",
"pathspec",
]
rouge-score = ["setuptools"]
sacrebleu = ["setuptools"]
sqlitedict = ["setuptools"]
word2number = ["setuptools"]
vllm = [
"setuptools",
"setuptools-scm",
"scikit-build-core",
"jinja2",
"wheel",
"markupsafe",
"typing-extensions",
"torch",
]
fastsafetensors = ["setuptools", "pybind11"]
torch = ["typing-extensions"]
torchvision = ["torch"]
torchaudio = ["torch"]
###
# ruff configuration
@@ -121,7 +195,6 @@ environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
[tool.ruff]
extend-exclude = [
"shared/protobufs/**",
"*mlx_typings/**",
"rust/exo_pyo3_bindings/**",
"bench/vendor/**",
+170 -131
View File
@@ -1,18 +1,37 @@
{ inputs, ... }:
{
perSystem =
{ config, self', pkgs, lib, system, ... }:
let
# Load workspace from uv.lock
workspace = inputs.uv2nix.lib.workspace.loadWorkspace {
workspaceRoot = ../.;
};
mkPythonSet = { pkgs, lib, self' }:
let
# Load workspace from uv.lock
workspace = inputs.uv2nix.lib.workspace.loadWorkspace {
workspaceRoot = inputs.self;
};
# Create overlay from workspace
# Use wheels from PyPI for most packages; we override mlx with our pure Nix Metal build
overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
# Override overlay to inject Nix-built components
inherit (pkgs.stdenv.hostPlatform) isLinux isDarwin isx86_64;
inherit (pkgs.config) cudaSupport;
inherit (pkgs) cudaPackages;
cuda13Support = cudaSupport && cudaPackages.cudaMajorVersion == "13";
libmlx_source = if cuda13Support then "mlx-cuda-13" else if cudaSupport then "mlx-cuda-12" else "mlx-cpu";
uv_extra = if cuda13Support then "cuda13" else if cudaSupport then "cuda12" else "cpu";
python = pkgs.python313;
cudaLibs = with cudaPackages; [
cuda_cudart
cuda_cccl
cuda_cupti
cuda_nvrtc
cuda_nvtx
cudnn
libcufile
libcublas
libcufft
libcurand
libcusolver
libcusparse
libcusparse_lt
libnvjitlink
libnvshmem
nccl
];
exoOverlay = final: prev: {
# Replace workspace exo_pyo3_bindings with Nix-built wheel.
# Preserve passthru so mkVirtualEnv can resolve dependency groups.
@@ -32,126 +51,157 @@
'';
};
};
buildSystemsOverlay = final: prev: { } //
lib.optionalAttrs isDarwin
{
mlx = prev.mlx.overrideAttrs (old:
let
# Static dependencies included directly during compilation
gguf-tools = pkgs.fetchFromGitHub {
owner = "antirez";
repo = "gguf-tools";
rev = "8fa6eb65236618e28fd7710a0fba565f7faa1848";
hash = "sha256-15FvyPOFqTOr5vdWQoPnZz+mYH919++EtghjozDlnSA=";
};
python = pkgs.python313;
metal_cpp = pkgs.fetchzip {
url = "https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip";
hash = "sha256-7n2eI2lw/S+Us6l7YPAATKwcIbRRpaQ8VmES7S8ZjY8=";
};
# Overlay to provide build systems and custom packages
buildSystemsOverlay = final: prev: {
# mlx-lm is a git dependency that needs setuptools
mlx-lm = prev.mlx-lm.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
final.setuptools
];
nanobind = pkgs.fetchFromGitHub {
owner = "wjakob";
repo = "nanobind";
rev = "v2.10.2";
hash = "sha256-io44YhN+VpfHFWyvvLWSanRgbzA0whK8WlDNRi3hahU=";
fetchSubmodules = true;
};
in
{
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.cmake self'.packages.metal-toolchain ];
# TODO: non-sdk_26 support
buildInputs = (old.buildInputs or [ ])
++ [ gguf-tools pkgs.fmt pkgs.nlohmann_json pkgs.apple-sdk_26 ];
patches = [
(pkgs.replaceVars ../nix/darwin-build-fixes.patch {
sdkVersion = pkgs.apple-sdk_26.version;
inherit (self'.packages.metal-toolchain) metalVersion;
})
];
postPatch = ''
substituteInPlace mlx/backend/cpu/jit_compiler.cpp \
--replace-fail "g++" "${lib.getExe' pkgs.stdenv.cc "c++"}"
'';
DEV_RELEASE = 1;
CMAKE_ARGS = toString ([
(lib.cmakeBool "USE_SYSTEM_FMT" true)
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_GGUFLIB" "${gguf-tools}")
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_JSON" "${pkgs.nlohmann_json.src}")
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_NANOBIND" "${nanobind}")
(lib.cmakeBool "FETCHCONTENT_FULLY_DISCONNECTED" true)
(lib.cmakeBool "MLX_BUILD_CPU" true)
(lib.cmakeBool "MLX_BUILD_METAL" true)
(lib.cmakeOptionType "string" "CMAKE_INSTALL_LIBDIR" "lib")
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_METAL_CPP" "${metal_cpp}")
(lib.cmakeOptionType "string" "CMAKE_OSX_DEPLOYMENT_TARGET" "${pkgs.apple-sdk_26.version}")
(lib.cmakeOptionType "filepath" "CMAKE_OSX_SYSROOT" "${pkgs.apple-sdk_26.passthru.sdkroot}")
] ++ lib.optionals (isDarwin && isx86_64) [
(lib.cmakeBool "MLX_ENABLE_X64_MAC" true)
]);
SDKROOT = pkgs.apple-sdk_26.passthru.sdkroot;
MACOSX_DEPLOYMENT_TARGET = pkgs.apple-sdk_26.version;
});
} // lib.optionalAttrs isLinux {
mlx = prev.mlx.overrideAttrs (old: {
buildInputs = old.buildInputs ++ lib.optionals cudaSupport cudaLibs;
autoPatchelfIgnoreMissingDeps = lib.optionals cudaSupport [ "libcuda.so.1" ];
postInstall = (old.postInstall or "") + ''
cp -r "${final.${libmlx_source}}/${final.python.sitePackages}/mlx" "$out/${final.python.sitePackages}/mlx/"
'';
});
# rouge-score and sacrebleu don't declare setuptools as a build dependency
rouge-score = prev.rouge-score.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
final.setuptools
];
} // lib.optionalAttrs cudaSupport {
"${libmlx_source}" = prev."${libmlx_source}".overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
sacrebleu = prev.sacrebleu.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
final.setuptools
];
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
sqlitedict = prev.sqlitedict.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
final.setuptools
];
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
word2number = prev.word2number.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
final.setuptools
];
nvidia-nvshmem-cu13 = prev.nvidia-nvshmem-cu13.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core pkgs.pmix pkgs.libfabric pkgs.ucx pkgs.openmpi ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusparse = prev.nvidia-cusparse.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ cudaLibs ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
torch = prev.torch.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
# Use our pure Nix-built MLX with Metal support (macOS only)
mlx = self'.packages.mlx;
};
# Additional overlay for Linux-specific fixes (type checking env).
# Native wheels have shared lib dependencies we don't need at type-check time.
linuxOverlay = final: prev:
let
ignoreMissing = drv: drv.overrideAttrs { autoPatchelfIgnoreMissingDeps = [ "*" ]; };
nvidiaPackages = lib.filterAttrs (name: _: lib.hasPrefix "nvidia-" name) prev;
in
lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux (
(lib.mapAttrs (_: ignoreMissing) nvidiaPackages) // {
mlx = ignoreMissing prev.mlx;
mlx-cuda-13 = prev.mlx-cuda-13.overrideAttrs (old: {
buildInputs = (old.buildInputs or [ ]) ++ [
final.nvidia-cublas
final.nvidia-cuda-nvrtc
final.nvidia-cudnn-cu13
final.nvidia-nccl-cu13
];
preFixup = ''
addAutoPatchelfSearchPath ${final.nvidia-cublas}
addAutoPatchelfSearchPath ${final.nvidia-cuda-nvrtc}
addAutoPatchelfSearchPath ${final.nvidia-cudnn-cu13}
addAutoPatchelfSearchPath ${final.nvidia-nccl-cu13}
'';
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
torch = ignoreMissing prev.torch;
triton = ignoreMissing prev.triton;
}
);
pyprojectOverlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
dependencies = { exo = [ uv_extra ]; exo-bench = [ ]; };
};
editableOverlay = workspace.mkEditablePyprojectOverlay {
# Use environment variable pointing to editable root directory
root = "$REPO_ROOT";
members = [ "exo" "exo-bench" ];
};
pythonSet = (pkgs.callPackage inputs.pyproject-nix.build.packages {
inherit python;
}).overrideScope (
lib.composeManyExtensions [
inputs.pyproject-build-systems.overlays.default
overlay
pyprojectOverlay
exoOverlay
buildSystemsOverlay
linuxOverlay
]
);
# mlx-cpu and mlx-cuda-13 both ship mlx/ site-packages files; keep first.
# mlx-cpu/mlx-cuda-13 and nvidia-cudnn-cu12/cu13 ship overlapping files.
venvCollisionPaths = lib.optionals pkgs.stdenv.hostPlatform.isLinux [
"lib/python3.13/site-packages/mlx*"
"lib/python3.13/site-packages/nvidia*"
];
# Exclude bench deps from main env (bench has its own benchVenv)
exoDeps = removeAttrs workspace.deps.default [ "exo-bench" ];
exoVenv = (pythonSet.mkVirtualEnv "exo-env" exoDeps).overrideAttrs {
venvIgnoreCollisions = venvCollisionPaths;
};
# Virtual environment with dev dependencies for testing
testVenv = (pythonSet.mkVirtualEnv "exo-test-env" (
exoDeps // {
exo = [ "dev" ]; # Include pytest, pytest-asyncio, pytest-env
}
)).overrideAttrs {
venvIgnoreCollisions = venvCollisionPaths;
};
mkPythonScript = name: path: pkgs.writeShellApplication {
mkApp = cmd: name: members: pkgs.writeShellApplication {
inherit name;
runtimeInputs = [ exoVenv ];
runtimeEnv = {
EXO_DASHBOARD_DIR = self'.packages.dashboard;
EXO_RESOURCES_DIR = inputs.self + /resources;
};
text = ''exec python ${path} "$@"'';
runtimeInputs = [
# mlx and mlx-cuda ship clashing cmake files - we dont need them at runtime anyway
((pythonSet.mkVirtualEnv "${name}-env" members).overrideAttrs (_: { venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" ]; }))
]
++ lib.optionals isDarwin [ pkgs.macmon ];
text = "exec " + lib.optionalString cudaSupport "${lib.getExe pkgs.nix-gl-host} " + cmd;
};
in
{
inherit pythonSet;
editablePythonSet = pythonSet.overrideScope editableOverlay;
mkPythonScript = members: name: path: mkApp ''python ${path} "$@"'' name members;
mkExo = name: members: mkApp ''exo "$@"'' name members;
};
in
{
perSystem =
{ self', pkgs, unfreePkgs, lib, ... }:
let
inherit (pkgs.stdenv.hostPlatform) isLinux;
inherit (mkPythonSet { inherit self' pkgs lib; }) pythonSet editablePythonSet mkPythonScript mkExo;
exoVenv = pythonSet.mkVirtualEnv "exo-env" { exo = lib.optionals isLinux [ "cpu" ]; };
# Virtual environment with dev dependencies for testing
testVenv = pythonSet.mkVirtualEnv "exo-test-env" {
exo = [ "dev" ] ++ lib.optionals isLinux [ "cpu" ]; # Include pytest, pytest-asyncio, pytest-env
};
benchVenv = pythonSet.mkVirtualEnv "exo-bench-env" {
exo-bench = [ ];
};
mkBenchScript = name: path: pkgs.writeShellApplication {
inherit name;
runtimeInputs = [ benchVenv ];
text = ''exec python ${path} "$@"'';
};
mkBenchScript = mkPythonScript { exo-bench = [ ]; };
mkSimplePythonScript = name: path: pkgs.writeShellApplication {
inherit name;
@@ -159,43 +209,32 @@
text = ''exec python ${path} "$@"'';
};
exoPackage = pkgs.runCommand "exo"
{
nativeBuildInputs = [ pkgs.makeWrapper ];
}
''
mkdir -p $out/bin
# Create wrapper script
makeWrapper ${exoVenv}/bin/exo $out/bin/exo \
--set EXO_DASHBOARD_DIR ${self'.packages.dashboard} \
--set EXO_RESOURCES_DIR ${inputs.self + /resources} \
${lib.optionalString pkgs.stdenv.hostPlatform.isDarwin "--prefix PATH : ${pkgs.macmon}/bin"}
'';
in
{
# Python package only available on macOS (requires MLX/Metal)
packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin
{
exo = exoPackage;
# Test environment for running pytest outside of Nix sandbox (needs GPU access)
exo-test-env = testVenv;
} // {
packages = {
exo = mkExo "exo" { exo = lib.optionals isLinux [ "cpu" ]; };
# for devShell
exo-venv = exoVenv;
editableVenv = editablePythonSet.mkVirtualEnv "exo-dev-env" { exo = [ "dev" ]; };
# for running tests in ci
exo-test-env = testVenv;
exo-bench = mkBenchScript "exo-bench" (inputs.self + /bench/exo_bench.py);
exo-eval = mkBenchScript "exo-eval" (inputs.self + /bench/exo_eval.py);
exo-eval-tool-calls = mkBenchScript "exo-eval-tool-calls" (inputs.self + /bench/eval_tool_calls.py);
# used by ./tests/run_exo_on.sh
exo-get-all-models-on-cluster = mkSimplePythonScript "exo-get-all-models-on-cluster" (inputs.self + /tests/get_all_models_on_cluster.py);
} // lib.optionalAttrs isLinux {
exo-cuda-12 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; }).mkExo "exo-cuda-12" { exo = [ "cuda12" ]; };
exo-cuda-13 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; }).mkExo "exo-cuda-13" { exo = [ "cuda13" ]; };
};
checks = {
# Ruff linting (works on all platforms)
lint = pkgs.runCommand "ruff-lint" { } ''
export RUFF_CACHE_DIR="$TMPDIR/ruff-cache"
${pkgs.ruff}/bin/ruff check ${inputs.self}
touch $out
'';
# Hermetic basedpyright type checking
typecheck = pkgs.runCommand "typecheck"
{
nativeBuildInputs = [
@@ -206,7 +245,7 @@
''
cd ${inputs.self}
export HOME=$TMPDIR
basedpyright --pythonpath ${testVenv}/bin/python
basedpyright --pythonpath ${testVenv}/bin/python --project ${inputs.self}/pyproject.toml
touch $out
'';
};
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 405874409472
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V3.1/blob/main/generation_config.json
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V3.1/discussions/19
[sampling_defaults]
temperature = 0.6
top_p = 0.95
@@ -9,5 +9,13 @@ quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 765577920512
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V3.1/blob/main/generation_config.json
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V3.1/discussions/19
[sampling_defaults]
temperature = 0.6
top_p = 0.95
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 378086226621
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V3.2/blob/main/generation_config.json
# Source: https://docs.vllm.ai/projects/recipes/en/latest/DeepSeek/DeepSeek-V3_2.html
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,13 @@ quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 755957120916
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V3.2/blob/main/generation_config.json
# Source: https://docs.vllm.ai/projects/recipes/en/latest/DeepSeek/DeepSeek-V3_2.html
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,12 @@ quantization = "8bit"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 122406567936
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 0.6
top_p = 0.95
@@ -9,5 +9,12 @@ quantization = "bf16"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 229780750336
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 0.6
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "4bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 198556925568
# Source: https://huggingface.co/zai-org/GLM-4.7
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "6bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 286737579648
# Source: https://huggingface.co/zai-org/GLM-4.7
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "8bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 396963397248
# Source: https://huggingface.co/zai-org/GLM-4.7
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "4bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 19327352832
# Source: https://huggingface.co/zai-org/GLM-4.7-Flash
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "5bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 22548578304
# Source: https://huggingface.co/zai-org/GLM-4.7-Flash
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "6bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 26843545600
# Source: https://huggingface.co/zai-org/GLM-4.7-Flash
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,14 @@ quantization = "8bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 34359738368
# Source: https://huggingface.co/zai-org/GLM-4.7-Flash
# Source: https://unsloth.ai/docs/models/glm-4.7-flash
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,13 @@ quantization = "8bit"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 790517400864
# Source: https://huggingface.co/zai-org/GLM-5
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,13 @@ quantization = "MXFP4-Q8"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 405478939008
# Source: https://huggingface.co/zai-org/GLM-5
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,13 @@ quantization = "bf16"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 1487822475264
# Source: https://huggingface.co/zai-org/GLM-5
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -9,5 +9,12 @@ quantization = "4bit"
base_model = "Kimi K2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 620622774272
# Source: https://huggingface.co/moonshotai/Kimi-K2-Instruct
# Source: https://platform.kimi.ai/docs/guide/kimi-k2-quickstart
[sampling_defaults]
temperature = 0.6
@@ -9,5 +9,12 @@ quantization = ""
base_model = "Kimi K2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 706522120192
# Source: https://huggingface.co/moonshotai/Kimi-K2-Thinking
# Source: https://platform.kimi.ai/docs/guide/use-kimi-k2-thinking-model
[sampling_defaults]
temperature = 1.0
@@ -7,7 +7,29 @@ tasks = ["TextGeneration"]
family = "kimi"
quantization = ""
base_model = "Kimi K2.5"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 662498705408
[vision]
image_token_id = 163605
model_type = "kimi_vl"
weights_repo = "davehind/Kimi-K2.5-vision"
processor_repo = "moonshotai/Kimi-K2.5"
# Source: https://deepwiki.com/MoonshotAI/Kimi-K2.5/3.7-recommended-parameters
# Source: https://unsloth.ai/docs/models/kimi-k2.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
min_p = 0.01
# Source: https://deepwiki.com/MoonshotAI/Kimi-K2.5/3.7-recommended-parameters
# Source: https://unsloth.ai/docs/models/kimi-k2.5
[sampling_defaults.non_thinking]
temperature = 0.6
top_p = 0.95
min_p = 0.01
@@ -8,5 +8,13 @@ quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 39688355840
# Source: https://huggingface.co/RedHatAI/Llama-3.1-Nemotron-70B-Instruct-HF-FP8-dynamic
# Source: https://deepinfra.com/nvidia/Llama-3.1-Nemotron-70B-Instruct/api
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -8,5 +8,13 @@ quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 74964549632
# Source: https://huggingface.co/RedHatAI/Llama-3.1-Nemotron-70B-Instruct-HF-FP8-dynamic
# Source: https://deepinfra.com/nvidia/Llama-3.1-Nemotron-70B-Instruct/api
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -8,5 +8,13 @@ quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 141107412992
# Source: https://huggingface.co/RedHatAI/Llama-3.1-Nemotron-70B-Instruct-HF-FP8-dynamic
# Source: https://deepinfra.com/nvidia/Llama-3.1-Nemotron-70B-Instruct/api
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -8,5 +8,16 @@ quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 2538706944
# Source: https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
[sampling_defaults]
temperature = 0.6
top_p = 0.95
# Source: https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
[sampling_defaults.non_thinking]
temperature = 0.0
@@ -8,5 +8,16 @@ quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 4794980352
# Source: https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
[sampling_defaults]
temperature = 0.6
top_p = 0.95
# Source: https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
[sampling_defaults.non_thinking]
temperature = 0.0
@@ -8,5 +8,16 @@ quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 9025492992
# Source: https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
[sampling_defaults]
temperature = 0.6
top_p = 0.95
# Source: https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1
[sampling_defaults.non_thinking]
temperature = 0.0
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "Llama 3.2 1B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 729808896
# Source: https://huggingface.co/meta-llama/Llama-3.2-1B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Llama-3.2-1B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "Llama 3.2 3B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 1863319552
# Source: https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Llama-3.2-3B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "8bit"
base_model = "Llama 3.2 3B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 3501195264
# Source: https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Llama-3.2-3B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 40652242944
# Source: https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Llama-3.3-70B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "8bit"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 76799803392
# Source: https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Llama-3.3-70B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "Llama 3.1 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 40652242944
# Source: https://huggingface.co/meta-llama/Meta-Llama-3.1-70B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Meta-Llama-3.1-70B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "4bit"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 4637851648
# Source: https://huggingface.co/meta-llama/Meta-Llama-3.1-8B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Meta-Llama-3.1-8B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "8bit"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 8954839040
# Source: https://huggingface.co/meta-llama/Meta-Llama-3.1-8B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Meta-Llama-3.1-8B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,13 @@ quantization = "bf16"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 16882073600
# Source: https://huggingface.co/meta-llama/Meta-Llama-3.1-8B-Instruct/blob/main/generation_config.json
# Source: https://huggingface.co/unsloth/Meta-Llama-3.1-8B-Instruct/blob/main/generation_config.json
[sampling_defaults]
temperature = 0.6
top_p = 0.9
@@ -9,5 +9,14 @@ quantization = "3bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 196608
[storage_size]
in_bytes = 100086644736
# Source: https://huggingface.co/MiniMaxAI/MiniMax-M2.1
# Source: https://github.com/MiniMax-AI/MiniMax-M2.1
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 40
@@ -9,5 +9,14 @@ quantization = "8bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 196608
[storage_size]
in_bytes = 242986745856
# Source: https://huggingface.co/MiniMaxAI/MiniMax-M2.1
# Source: https://github.com/MiniMax-AI/MiniMax-M2.1
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 40
@@ -9,5 +9,14 @@ quantization = "4bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 128666664960
# Source: https://huggingface.co/MiniMaxAI/MiniMax-M2.5
# Source: https://github.com/MiniMax-AI/MiniMax-M2.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 40
@@ -9,5 +9,14 @@ quantization = "6bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 185826705408
# Source: https://huggingface.co/MiniMaxAI/MiniMax-M2.5
# Source: https://github.com/MiniMax-AI/MiniMax-M2.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 40
@@ -9,5 +9,14 @@ quantization = "8bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 242986745856
# Source: https://huggingface.co/MiniMaxAI/MiniMax-M2.5
# Source: https://github.com/MiniMax-AI/MiniMax-M2.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 40
@@ -0,0 +1,23 @@
model_id = "mlx-community/MiniMax-M2.7-4bit-mxfp4"
n_layers = 62
hidden_size = 3072
num_key_value_heads = 8
supports_tensor = true
tasks = ["TextGeneration"]
family = "minimax"
quantization = "4bit-mxfp4"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 121537496794
# Source: https://huggingface.co/MiniMaxAI/MiniMax-M2.7
# Source: https://github.com/MiniMax-AI/MiniMax-M2.7
# Source: https://unsloth.ai/docs/models/minimax-m27
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 40
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more