MTPPredictor._load_weights expects keys with the 'mtp.' prefix preserved
(see mtp_module.py:202+), e.g. 'mtp.fc.weight', 'mtp.layers.0.self_attn.q_proj.weight'.
Both the original code (stripping 'model.mtp.' to leave 'fc.weight') and
my recent change (stripping 'mtp.') produced cached files MTPPredictor
couldn't read — manifesting as KeyError 'mtp.fc.weight' at MTPPredictor
init. The try/except in _resolve_mtp_weights swallowed it and silently
fell back to non-speculative.
Fix: only strip the optional 'model.' wrapper; keep 'mtp.' intact. Bump
cache filename to mtp_v2_<hash>.safetensors so any existing wrong cache
gets re-extracted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-tier fallback:
1. Read index, byte-range fetch only the MTP tensors out of their shards
via HfFileSystem. ~500 MB for Qwen/Qwen3.5-27B (vs 55 GB full repo or
20 GB shard-level).
2. Shard-level fallback if byte-range read fails.
3. Full safetensors fallback if no index (single-shard repo).
The MTP head is small (~13 tensors) but scattered across 4 different shards
because HF packs by file size, not logical grouping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes to _extract_mtp_from_hf:
1. Read model.safetensors.index.json first to find shards containing MTP
tensors, download only those. For Qwen/Qwen3.5-27B the MTP head spans
4 of 11 shards (~20 GB instead of ~55 GB full repo). Falls back to
pulling all safetensors if the index is absent.
2. Accept both 'model.mtp.' (older convention) and 'mtp.' (Qwen/Qwen3.5-27B
actual prefix) tensor keys. Previously the code only matched 'model.mtp.'
so on Qwen/Qwen3.5-27B it found zero tensors, raised ValueError, and the
caller silently fell back to non-speculative — meaning MTP has likely
been disabled on this repo since the auto-detect logic was added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the [DFlash] n_accepted print so MTP runs can be diffed against
DFlash runs by greppable log lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MLX nn.RMSNorm defaults eps to 1e-5 but the drafter's HF config specifies
1e-6. Applied to q/k_norm, input/post_attention layernorms, hidden_norm,
and norm — 6 RMSNorms per layer x 8 layers plus 2 top-level. Small per-op
drift that compounds through the drafter forward, likely contributing to
lower-than-expected acceptance on open-ended prompts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the split path's print. Same format so logs look comparable
between single-node and split runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For 35B-A3B bring-up diagnostics. Each stage now evals local work and
gathered with explicit perf_counter brackets (graph build outside timer).
Role label per rank so attn vs MoE time can be compared side-by-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LpB covers both qwen3_5 and qwen3_5_moe; fused MoE kernels are now
opt-in via EXO_FUSED_KERNELS=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously EXO_FUSED_KERNELS=0 skipped all patching; single-node MoE runs
got zero projection patches. Now under EXO_FUSED_KERNELS=0 we apply
apply_lpb_patches to both qwen3_5 (dense) and qwen3_5_moe targets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Back to the minimal state from commit 312229a4 (post-27B debug):
stage 0: eval(gathered)
B even-S: eval(my_out) on ATTN only
A even-S: no eval
drain: eval(gathered)
Keep the pipeline begin print at top. Odd-S paths keep their existing evals.
Remove unused `import time`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Qwen3.5 and Qwen3.5-MoE keep lm_head on the TextModel wrapper
(model.language_model.lm_head). The old holder loop only checked
model and inner (Qwen3_5TextModel), silently missing lm_head. Affected
both attn/moe split runs and the single-node single-device path.
Bumps 35B-A3B target LpB count 330 -> 331 and analogous +1 on 27B.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ATTN_RANK always drops mlp + post_attention_layernorm (never runs them).
MOE_RANK drops self_attn (never runs attention). Under speculative, MOE_RANK
keeps linear_attn + input_layernorm because _pipelined_dflash_forward's
post-loop conv_input reconstruction calls them on MOE_RANK.
Saves the biggest chunk on 35B-A3B: MLP has 256 experts x 3 projections
x 40 layers dead on ATTN_RANK previously.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For 35B-A3B bring-up diagnostics. Each stage now evals local work + gathered
with explicit perf_counter brackets (graph build outside timer). Prints role
label per rank so attn vs MoE time can be compared side-by-side. Strip later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reaches Qwen3NextSparseMoeBlock.{gate, shared_expert_gate, shared_expert.*}
so Qwen3.5-35B-A3B picks up the dynamic LpB kernels on MOE_RANK. Routed
experts (switch_mlp / SwitchLinear) stay on stock. Silent no-op on dense
27B via getattr fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cache offset on MOE_RANK stays at 0 (no attention runs there), while
ATTN_RANK's grows normally with prefill. _first_step_capture (stock)
reads cache offset for _draft_position[uid], so MOE_RANK's value is
always wrong (0). Since we sync drafts and take MOE_RANK's as truth,
the drafter was running with start=0 (wrong positional encoding) and
producing garbage drafts → n_accepted=0/7 consistently.
Fix: at the top of _split_speculative_next, all_gather the local
_draft_position and use ATTN_RANK's value on both ranks. Subsequent
updates (start + n_accepted + 1) stay synced naturally since both
ranks compute the same n_accepted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
One line per verify step from MOE_RANK showing how many of the V
drafts were accepted. Useful for tracking acceptance rate during
tuning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stock dflash_speculative_forward captures each GDN layer's input during
the layer loop, then post-loop computes `conv_input = concat([pre_conv,
in_proj_qkv(input_layernorm(layer_input))])` and assigns it to the
SpeculativeArraysCache for conv state rollback. Our earlier patch
skipped this because pipelined_layer_loop doesn't preserve per-layer
inputs.
Fix: extend the capture set passed to pipelined_layer_loop to include
`L-1` for every GDN layer L (layer L-1's output IS layer L's input).
Layer 0's input is the initial embedding, saved explicitly.
Post-loop now computes qkv and conv_input for each GDN layer exactly
matching stock. SpeculativeArraysCache.rollback will now correctly
restore both cache[0] (conv state) and cache[1] (recurrent state).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Our cross-layer pipeline calls GDN.linear_attn TWICE per GDN layer
(H0 then H1) during the verify forward. The monkey-patched
gated_delta_update appends per-step states on each invocation, so
spec_all_states ends up with 2*N_gdn entries instead of N_gdn.
The post-loop assigns `spec_cache.all_states = spec_all_states[gdn_idx]`
one-per-layer — so under the split, layer 0 gets H0-of-layer-0's states,
layer 1 gets H1-of-layer-0's states (wrong!), etc. Rollback then
restores GDN recurrent state to the wrong value, corrupting subsequent
verifies.
Fix: if S>1 and we see 2*N entries, concatenate consecutive pairs along
the step dim to get one (B, S, ...) entry per GDN layer, matching what
stock dflash_speculative_forward would have produced with a single
per-layer call.
Also reverted the previous _idx rollback patch — stock DFlash works fine
with stock exo (user confirmed), so the rollback .offset-only path isn't
the bug. The bug is our 2-call-per-layer capture mismatch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BatchKVCache.update_and_fetch writes at [_idx : _idx + new_S] and
returns keys[:_idx]. Stock DFlash's rollback only decrements .offset,
leaving _idx at the post-verify value. Result: stale rejected-draft
keys stay in the cache, and the next verify's attention attends to
them, corrupting the output.
Fix: decrement _idx by the same amount as offset. Now
update_and_fetch overwrites stale entries cleanly.
This matches observed behavior: first speculative verify produces
fine output, second+ verifies produce gibberish due to stale cache
contamination.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Print which branch _split_speculative_next takes (fallback vs
speculative cycle) and whether target_hidden is set. Helps diagnose
why output is gibberish — lets us see if the speculative path is
ever entered or if every token is coming from super()._next() fallback.
Note: _CapturingLayer in stock DFlashBatchGenerator never fires during
our pipelined_layer_loop (which calls layer.self_attn/linear_attn/mlp
directly, bypassing layer.__call__). So _last_target_hidden is never
populated and every _speculative_next call falls back to super()._next().
That SHOULD produce correct tokens; if it doesn't, something else is off.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BatchKVCache.update_and_fetch returns keys[:, :, :_idx, :] — the actual
K buffer length. BatchKVCache.make_mask also uses _idx as the offset to
create_causal_mask. Our pipelined mask slicer was using .offset (which
is _idx - left_padding for positional encodings), producing a mask too
short by left_padding.
Fix: prefer _idx if the cache has it (BatchKVCache), else fall back to
.offset (plain KVCache, where they're equal).
Shape error was "(1,1,4,65) vs (1,24,4,72)" — 72 = actual _idx + mid,
65 = our offset + mid with offset=61. Difference 7 = left_padding of
one of the batch entries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BatchKVCache stores offset as an mx.array (1,). Slice indices require
Python ints, so take max().item() on the array case. KVCache's offset
is already a Python int (passthrough).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Applies the dynamic bf16/int8 loop-per-batch projection patches to the
target model's attention, MLP, and lm_head. Kernels are picked at call
time based on M = batch*seq; for M<=16 uses the LpB fast path. Benefits:
- decode S=1 (M=1)
- DFlash verify S=V+1=6 (M=6)
Prefill with long prompts (M>16) falls back to stock projections.
Most impactful on dense Qwen3.5-27B (patches MLP too). On MoE variants
only attn + lm_head get patched (MLP structure differs).
Gated by EXO_LPB_PATCHES env var (default "1").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 2b: enable DFlash speculative decoding through the pipelined
2N+1 stage schedule. Both ranks load the drafter and draft
independently; drafts sync via all_gather (MOE_RANK's drafts win).
Both ranks then run the pipelined verify forward; acceptance is
deterministic at temp=0 on both ranks, or broadcast from MOE_RANK
at temp>0.
Changes:
- pipelined_layer_loop: accept capture_layers set; return
(final, captured_dict) so DFlash target_hidden can be assembled
from selected layer outputs. Captures at the end of each A stage
(when layer T-1's full output is known as concat(x_H0, x_H1))
and after drain for layer N-1.
- model_forward.make_pipelined_dflash_speculative_forward: wraps
pipelined_layer_loop with DFlash pre/post-loop (cache wrap, GDN
kernel swap, target_hidden assembly).
- dflash_split.make_split_speculative_next: rank-aware
DFlashBatchGenerator._speculative_next — drafts independently,
syncs via all_gather, runs pipelined verify, shared acceptance.
- apply.py: patches dflash_speculative_forward and
DFlashBatchGenerator._speculative_next on top of existing
attn/MoE split patches.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace verbose step-by-step prints with one summary per stage showing
tensor means of the gathered results. Same format as the decoder.py
prints — same mean on both ranks = correct, divergence = bug.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each layer prints h.mean() after gather-1 and result.mean() after
gather-2. Forces mx.eval before print so both ranks must sync at each
gather point. Same mean on both ranks = ok, divergence = bug.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prints begin / pre-gather-1 / post-gather-1 / pre-gather-2 / done for
each layer call. Note post-gather-1 is before mx.eval — gather is still
lazy at that point; actual blocking is in the next step's compute or
gather.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add mx.eval(my_contribution) before every all_gather in the pipelined
loop. Forces each rank to materialize its contribution to the GPU
before hitting the collective, so JACCL doesn't block waiting on a
lazy graph node to complete.
Covers: Stage 0, all main-loop stages (even-S and odd-S paths), and
Stage 2N drain.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
create_attention_mask(return_array=True) -> create_causal_mask returns
a 2D (S, offset+S) mask in the exo mlx-lm fork, not 4D. Update
slice_fa_mask to slice by the last two dims regardless of ndim.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Even S (H0.size == H1.size == S/2): one all_gather per stage — each
rank contributes its real output, both get both (2N+1 collectives total).
Odd S (H0.size != H1.size): two all_gathers per stage — each rank
contributes a zero placeholder of the other side's shape (4N collectives
total).
Spec verify uses even S (DFlash V+1=6 by default) and gets the fast path.
Prefill with odd-length prompts still works correctly via the slower
two-gather path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of requiring even S for the one-all_gather optimization,
use two all_gathers per stage (one per shape) so H0 and H1 can have
different sizes. Handles any S including odd (e.g. γ+1=3 for MTP
default).
Each rank contributes:
- attn_side: real attn output (ATTN) or zeros of same shape (MOE)
- moe_side: real moe output (MOE) or zeros of same shape (ATTN)
Two collectives per stage, total 4N+2 collectives per forward (vs
2N+1 for the even-S single-gather approach). Still much better than
serial 2*N per-layer gathers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The pipelined 2N+1 schedule does one all_gather per stage where both
ranks contribute tensors. For odd S, mid=S//2 means H0 and H1 have
different sizes (mid vs S-mid), so the two ranks would contribute
tensors of different shapes to the all_gather — shape mismatch.
Simplest fix: fall back to the stock serial layer loop when S is odd.
Even-S prompts still use the pipelined path. Future work: pad to even
length (requires mask-out of padding positions in the output).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move S>1 pipelining out of DecoderLayer.__call__ into a new
model_forward.py that replaces Qwen3_5TextModel.__call__ and
mtp_module.speculative_forward.
New approach:
- decoder.py: serial split for S==1 only (clean rewrite, no state)
- model_forward.py:
- pipelined_layer_loop(): 2N+1 stage pipeline with loop-local state
(no closures carrying cross-layer state, no correctness bugs)
- make_pipelined_model_call(): replaces Qwen3_5TextModel.__call__
- make_pipelined_speculative_forward(): replaces speculative_forward
- apply.py: patches both class-level __call__ and the speculative_forward
function; skips _drop_unused_weights when EXO_SPECULATIVE=1
Pipeline schedule:
Stage 0 : ATTN attn_0(H0). MOE idle.
Stage 2T+1 : ATTN attn_T(H1). MOE moe_T(h_T_H0).
Stage 2T(≥1): ATTN attn_T(H0). MOE moe_{T-1}(h_{T-1}_H1).
Stage 2N : ATTN idle. MOE moe_{N-1}(h_{N-1}_H1).
One all_gather per stage (both ranks contribute their real output, both
receive both — same shape, single collective instead of two).
Mask slicing uses create_attention_mask(return_array=True) to force a
real tensor so H1 queries get correct columns (mid..S) instead of the
"causal" sentinel which would mis-align.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Qwen3.5-27B is mostly GDN layers. create_ssm_mask returns None or
a 2D (B, S) bool mask from ArraysCache.make_mask. create_attention_mask
often returns the "causal" string sentinel, not a 4D tensor.
Branch get_masks on type/dimension:
- None / str: pass through unchanged to both halves
- 4D tensor: slice rows + columns (needs cache.offset)
- 2D tensor: slice sequence dim only
Cache key changed to (cache id, S) so different layers with different
caches don't collide.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both ranks must participate in the Stage A all_gather. ATTN_RANK
contributes pending_h_H1 (already-gathered tensor) as a placeholder;
the all_gather returns MOE_RANK's real out_H1_prev. Fixes crash
where ATTN_RANK reached concatenate with out_H1 = None.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract attention(), moe(), gather_from(), get_masks() helpers.
Split serial_decode() (S==1) and pipelined_forward() (S>1) into
separate functions. Closure state dict groups related fields:
layer_idx, mask_cache, prev_layer, pending_h_H1.
No behavior change — just readability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compute mask_H0 and mask_H1 on first layer call, cache in closure
dict keyed by S. Subsequent layers reuse cached slices. Clear cache
on S==1 (decode) transitions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For S>1 (prefill/verify), split sequence into H0 and H1:
1. ATTN does H0 attention → all_gather
2. ATTN does H1 attention || MOE does H0 MoE (OVERLAP) → all_gather
3. MOE does H1 MoE → all_gather
H0 mask sliced to (mid, offset+mid) columns. H1 mask uses full width
since H0's KV is already cached. GDN layers process H0 then H1
sequentially (recurrent state carries over in cache).
S==1 decode path unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass n_layers into make_split_decoder_call so the first-layer eval
fires on every forward pass, not just the very first one. Detected
from the model's layer count.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 1 eval on MOE_RANK when layer % 4 == 0.
Step 2 eval on ATTN_RANK when layer % 4 == 2.
Interleaved sync points every 2 layers but alternating between steps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eval on both ranks' h every 2nd layer (before step 1 all_gather).
Step 2 has no eval. 50 dummy layernorms per step.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MOE_RANK uses post_attention_layernorm (has it), ATTN_RANK uses
input_layernorm (has it). Each rank uses the norm it didn't drop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 1: ATTN does attention, MOE does input_layernorm(x) as dummy.
Step 2: MOE does MoE, ATTN does post_attention_layernorm(h) as dummy.
all_gather picks the correct rank's output. No evals — dummy layernorm
keeps graph non-trivial on both sides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Back to all_sum pattern. MOE_RANK evals its x-x in step 1 (the
known-working config). Step 2 has no eval.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only MOE_RANK has evals: eval(h) after recv, async_eval(sent) after send.
ATTN_RANK has zero evals — send + recv are fully lazy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ATTN_RANK sets mlp + post_attention_layernorm to None on each layer.
MOE_RANK sets self_attn + linear_attn + input_layernorm to None.
Frees ~50% memory on each machine. embed_tokens, norm, and lm_head
stay on both ranks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore proper two-step send/recv split: ATTN does attention → send h
→ recv out, MOE recv h → MoE → send out. async_eval after sends,
blocking eval after recvs. Revert opt_batch_gen.py back to async_eval.
Works on both Qwen3.5-35B-A3B (MoE) and Qwen3.5-27B (dense).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both ranks run identical attention-only forward. No MoE, no all_sum,
no rank branching. Pure attention speed baseline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment out MoE and all_sum to measure pure attention-only decode
speed as a ceiling reference.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single step per layer: ATTN does attention on x, MOE does MoE on x,
all_sum * 0.5. Only ATTN_RANK evals its result (the heavier compute).
Gibberish output but tests parallel compute with minimal sync.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
De-indented async_eval so both ranks submit their graph before the
all_sum. ATTN_RANK submits the attention graph, MOE_RANK submits
the x-x graph. Both enter all_sum with their work already queued.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single eval per layer: MOE_RANK evals its zeros before step 1 all_sum.
Step 2 has no eval on either side.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Back to asymmetric split with x-x zeros. The rank doing trivial
work (x-x) gets mx.eval to force materialization before the all_sum.
The rank doing real compute (attention/MoE) stays lazy. Tests whether
evaling the fast side prevents it from racing ahead into the all_sum.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each rank does its own specialty on x simultaneously: ATTN does
attention, MOE does MoE. Both eval their result, then all_sum * 0.5.
Single all_sum per layer. Output is gibberish but tests whether
parallel compute + single collective works without hanging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Temporary test: use mx.eval in _fast_next instead of mx.async_eval
to see if blocking eval at the end of each decode step prevents the
all_sum hang/queue overflow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ATTN_RANK evals h after attention, MOE_RANK evals out after MoE.
Each prints its layer index + sum. No eval on the x-x zero side.
Tests whether evaling just the computing rank's result (not the
all_sum) is enough to keep things flowing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Debug: mx.eval(x) and print x.sum at the start of each layer call
to trace where the hang happens and whether ranks stay in sync.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plain asymmetric split with no evals, no streams. Baseline for
further experiments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Place all_sum calls on mx.cpu stream via mx.stream context manager.
Back to asymmetric split (real compute vs x-x zeros). CPU stream
serializes the collectives naturally and may avoid the JACCL queue
overflow (MAX_SEND_WR=32) that causes the -12 ENOMEM crash.
No per-layer evals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MOE_RANK runs MoE twice to increase graph weight on the MoE side.
Tests whether more compute on the non-attention side prevents hang.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
One step per layer: ATTN_RANK does attention on x, MOE_RANK does MoE
on x, both in parallel. Single all_sum combines results. No evals.
Output is gibberish (attn + moe of same input) but tests whether a
single all_sum per layer with comparable work on both sides avoids
the hang.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch back to all_gather with correct slicing, keep eval every 4th
layer. Tests whether all_gather works with periodic sync points and
cross-specialty dummy work.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mx.eval(out) after every 2nd layer call. Tests whether reducing
sync points from 48 to 24 per forward still prevents the hang.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same layout but all_sum forces both sides' values to be consumed
(no slice = no dead code elimination). Output will be numerically
wrong (sum of both ranks) but tests whether the hang is caused by
all_gather pruning the unused rank's contribution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 1: ATTN_RANK does real attention, MOE_RANK does MoE on x (dummy).
Step 2: both ranks run MoE, ATTN_RANK skips residual. No cache
corruption risk since attention only runs in step 1 on ATTN_RANK.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 1: ATTN_RANK does real attention, MOE_RANK runs MoE on x (wrong
input but comparable graph weight). Step 2: MOE_RANK does real MoE,
ATTN_RANK runs attention on h (dummy). all_gather picks the correct
rank's output. Each side does work proportional to its real task so
the graphs have comparable weight. No evals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Non-owning rank just skips the residual add: h = r (not x + r),
out = _moe_out (not h + _moe_out). Same real compute on both sides,
minimal branching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both ranks execute the real attention and real MoE to keep graph
structure nearly identical. The non-owning rank discards its result
(doesn't add the residual) and substitutes a lightweight op.
all_gather picks only the correct rank's output. No evals.
Tests whether matched graph weight prevents the hang.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use h = layernorm(h) + h so each iteration produces a different value
and MLX can't constant-fold the chain.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Non-computing rank runs 100 chained layernorms instead of 1, making
the dummy graph much heavier. Tests whether the hang is caused by the
non-computing rank's graph being too lightweight relative to the
computing rank (JACCL timing out waiting for the fast side).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Back to rank-specific compute: ATTN_RANK runs attention, MOE_RANK runs
MoE. Non-computing rank runs a dummy op (input_layernorm /
post_attention_layernorm) to keep the graph non-trivial on both sides.
all_gather + slice picks only the real rank's output. No evals.
Tests whether all_gather handles asymmetric but non-trivial graphs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same symmetric approach (both ranks compute everything) but use
all_gather + slice instead of all_sum * 0.5. Both ranks get identical
results by taking the last rank's copy ([-1:]). No evals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both ranks run the full decoder (attention + MoE) identically. all_sum
doubles the result (same value from each rank), multiply by 0.5 to
correct. Zero branching, zero per-layer evals, identical graph on both
ranks — exactly like tensor parallel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both ranks execute the full decoder layer (attention + MoE) to keep
identical graph structures. The non-owning rank zeros its result with
x-x before the all_sum so only the owning rank's value propagates.
No per-layer evals — fully lazy like tensor parallel. Tests whether
graph symmetry is what makes TP's all_sums work without evals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use all_gather + slice to broadcast each step's result. No per-layer
eval or async_eval at all — relies on the end-of-step eval in
opt_batch_gen.py to drive everything. all_gather may handle the
asymmetric graph differently from all_sum.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use x-x and h-h to create zeros with a data dependency on the input
tensor, so the non-computing rank's all_sum graph node depends on the
same tensor as the computing rank. This should prevent MLX from
scheduling the zero side's all_sum before the compute side is ready.
Only the second async_eval remains.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only one mx.async_eval remains — after the second all_sum (MoE result).
The first all_sum (attention result) flows lazily into step 2.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace blocking mx.eval with mx.async_eval after each all_sum.
Lets the graph pipeline without blocking Python at each layer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace send/recv_like with the all_sum pattern from
run_split_pure_lazy.py: computing rank contributes real tensor,
other rank contributes zeros, all_sum broadcasts to both. Two
all_sum + two mx.eval per layer. Avoids send/recv entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Back to the known-working minimal config: blocking mx.eval after
each recv_like, nothing else. No warmup detection, no async_eval.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
During warmup (first ~600 layer calls ≈ 12 forward passes): blocking
mx.eval after each recv to keep ranks in lockstep. After warmup: only
async_eval on sends, no blocking evals — lets the lazy graph pipeline
across layers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace all evals with mx.async_eval after each send. No blocking
evals — Python races ahead while the GPU processes the lazy graph.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keep only mx.eval(out) after rank 0's recv_like and mx.eval(h) after
rank 1's recv_like. Post-send evals removed. Testing minimal eval set.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add post-recv evals back: mx.eval(out) after rank 0's recv_like,
mx.eval(h) after rank 1's recv_like. Now 4 evals: post-send and
post-recv on each rank.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keep only mx.eval(h) after rank 0's send and mx.eval(sent) after
rank 1's send. All other evals removed. Testing whether the two
post-send evals alone are sufficient.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop mx.eval after each send — the recv eval on the other rank
pulls the send through the graph. Remaining 5 evals: pre-send +
post-recv on rank 0, pre-recv + post-recv + pre-send on rank 1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Back to the full-eval version that worked on both TCP and RDMA:
6 evals per layer (pre-send, post-send, post-recv on each rank).
Will remove one at a time from here.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two evals per layer: mx.eval(out) after rank 0's recv_like,
mx.eval(h) after rank 1's recv_like. Sends are left lazy —
the recv eval on the other side forces the graph including the
matching send.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two evals total per layer: mx.eval(h) after rank 0's send, mx.eval(sent)
after rank 1's send. No eval on recv side — let the recv stay lazy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop mx.eval at lines 45 (post-send on rank 0), 47 (post-recv on
rank 0), and 50 (pre-recv on rank 1). Lets the send→recv chain stay
in the lazy graph with only the final evals remaining: post-recv on
rank 0 (line 46) and post-send on rank 1 (line 53).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop the mx.eval before each mx.distributed.send — lets the send
consume the lazy graph directly instead of forcing a GPU sync first.
Keeps the post-send and post-recv evals so the distributed op still
gets its own command buffer boundary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Continuous batching on exo calls cache.extract / filter / extend to
slice per-sample state. Rank 1 never populates caches, so each method
hits None subscripts. Patch ArraysCache / BatchKVCache /
BatchRotatingKVCache extract/filter/extend on rank 1 to short-circuit
when the cache is still uninitialised.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The exo runtime path for real requests uses BatchGenerator which builds
BatchKVCache instances, not the KVCache used by warmup. Both crash at
`k.shape[2]` when keys is None (never populated on rank 1, the MoE
half). Patch BatchKVCache.state and BatchRotatingKVCache.state on rank 1
with the same zero-length placeholder idiom already used for KVCache and
ArraysCache.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rank 1 (MoE) never runs attention, so its per-layer KVCache.keys and
ArraysCache entries stay as their initial None sentinels. mlx_lm.generate
calls mx.eval([c.state for c in prompt_cache]) once per step, which
invokes KVCache.state and crashes on `self.keys.shape[2]`
(AttributeError: 'NoneType' object has no attribute 'shape'). Patch the
state property on rank 1 only to return zero-length placeholder arrays
whose eval is a no-op.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cuts each Qwen3.5 MoE decoder layer across two ranks: rank 0 runs
input_layernorm + attention + first residual, rank 1 runs
post_attention_layernorm + MoE + second residual, with one cross-rank
send/recv pair per layer. Mirrors auto_parallel's PipelineFirstLayer /
PipelineLastLayer mx.eval idiom around every distributed op so the
send/recv stays on its own Metal command buffer and avoids GPU timeout.
No mx.depends cache anchoring in v1.
Plumbing:
- Sharding.AttnMoeSplit enum + AttnMoeSplitShardMetadata class
- get_shard_assignments_for_attn_moe_split (both ranks own [0, n_layers))
- Validator in master/placement.py: requires Qwen3.5 model + 2-node cycle,
rejects single-node downgrade
- New patches/qwen3_5_moe_split/{apply,decoder}.py replaces
DecoderLayer.__call__ at class level. Invoked from
attn_moe_split_auto_parallel in auto_parallel.py, dispatched from
utils_mlx.shard_and_load.
- Preview enumeration in api/main.py:477 now includes AttnMoeSplit
- Dashboard: PlacementPreview.sharding union extended; "Attn/MoE Split"
button added to Advanced Options sharding picker
Phase 1a only: vanilla decoder split (no fused GDN / batched MoE under
this mode). maybe_apply_patches is only called in single-device mode so
the fused qwen3_5_moe patches do not collide with the split.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Old warmup ran 3 draft+verify cycles at one (BS, V) combo, but only
hit S_ctx ∈ {1, V+1} — missing every intermediate value. That left
drafter fc / k_proj / v_proj uncompiled for S_ctx ∈ {2..V}, so their
kernels JIT-compiled during the first few real generation cycles and
inflated draft time by 15–25%.
New warmup explicitly sweeps S_ctx = 1..V+1 against the drafter with
a fresh draft KV cache per iteration, then runs one target verify at
M=V+1 for the target-side projections. Measured on bf16 27B with a
thinking prompt: draft ms/cycle drops 16.7→12.8 at BS=16 V=13 and
11.0→9.1 at BS=6 V=5.
Warmup is structured around a `modes` list so dynamic (BS, V)
switching at runtime won't hit a compilation stall on mode flip —
add the extra modes to `modes` and every kernel they need is
compiled up front.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enables launching the bf16 target from the dashboard so the
thinking/no-thinking toggle is exposed for DFlash benchmarking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ports the matmul kernel suite and dynamic picker from mlx_bench into exo
so both the DFlash drafter and the Qwen3.5-27B target can select the
fastest correct kernel at call time based on the actual M seen in the
forward pass.
- New matmul/ package: bf16 (lpb, lpb_twice, bm8, splitk_steel) and
int8 (lpb, bm8, bm16, splitk) kernels plus Steel headers bundle
- kernel_picker.{pick_bf16,pick_int8}_kernel memoizes per projection and
force-routes N>50000 (lm_head) away from the broken sk_steel path
- speculative/bf16_lpb_patch and patches/qwen3_5/lpb_patch rewritten to
auto-detect nn.Linear vs nn.QuantizedLinear so the same target patch
covers both bf16 and 8-bit Qwen3.5-27B; target patch now also wraps
lm_head
- warmup_dflash runs three full draft+verify cycles at the real
(block_size, verify_len) so every M the runtime hits is compiled once
at startup instead of during the first generation step
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DFlash drafts all tokens in one parallel pass through a 5-layer
bidirectional transformer, conditioned on target model hidden states.
60 TPS in mlx_bench (2.6x over baseline).
New files:
- speculative/dflash_module.py: DFlashDrafter (loads z-lab/Qwen3.5-27B-DFlash)
- speculative/dflash_speculative.py: dflash_speculative_forward with GDN rollback
- speculative/dflash_batch_generator.py: DFlashBatchGenerator (BS=1 speculative)
- speculative/bf16_lpb_patch.py: LpB patches for draft model projections
- patches/qwen3_5/custom_bf16_*.py: bf16 Loop-over-B GEMV kernels
Env vars:
EXO_SPECULATIVE_MODE=dflash Select DFlash (default: mtp)
EXO_DFLASH_MODEL=... DFlash model path (default: z-lab/Qwen3.5-27B-DFlash)
EXO_DFLASH_VERIFY=5 Draft tokens to verify per cycle
EXO_DFLASH_BLOCK_SIZE=6 Draft model block size
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The opt_batch_gen patch replaces BatchGenerator.next() with _fast_next()
which bypasses _next() entirely. Since MTPBatchGenerator overrides _next()
for speculative decoding, the fast path causes speculative to never fire.
Skip the patch when EXO_SPECULATIVE=1. The speculative path doesn't need
the fast_next optimization (it has its own optimized decode loop), and
EXO_DISABLE_LOGPROBS=1 already handles skipping logprobs extraction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## 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>
## 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
## 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
## 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 -->
<!-- - -->
## 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 -->
<!-- - -->
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
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+worldhttp://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"
/>
## 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>
## 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
## 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>
## 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 -->
<!-- - -->
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.
## 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>
## 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
## Summary
DeepSeek V3.2 (`DeepseekV32ForCausalLM`) is already supported by exo's
inference engine (architecture whitelisted in `model_cards.py`, DSML
encoding added in #1548), but **doesn't work out of the box** due to two
bugs:
### Bug 1: `warmup_inference` passes empty model ID
`warmup_inference()` in `generate.py` accepts `model_id: ModelId` as a
parameter but creates `TextGenerationTaskParams(model=ModelId(""), ...)`
instead of using it. Since `_needs_dsml_encoding()` checks
`"deepseek-v3.2" in task_params.model.lower()`, the empty string never
matches → falls back to `tokenizer.apply_chat_template()` →
**ValueError** because V3.2 has no Jinja chat template.
**Fix:** `model=ModelId("")` → `model=model_id` (one line).
### Bug 2: `_needs_dsml_encoding` limited to tool calling
`_needs_dsml_encoding()` returns `True` only when `task_params.tools` is
present or tool messages exist in `chat_template_messages`. For warmup
and regular chat requests without tools → `return False` → Jinja
fallback → **ValueError**.
Unlike V3.1 (which has a `.jinja` chat template file that transformers
picks up automatically), V3.2 **has no Jinja template at all** — it uses
Python-based DSML encoding for all message types.
**Fix:** For V3.2, always return `True` — DSML encoding handles all
message types.
### Catalog cards
Added inference model cards for:
- `mlx-community/DeepSeek-V3.2-8bit`
- `mlx-community/DeepSeek-V3.2-4bit`
Parameters taken from model `config.json` on HuggingFace, storage sizes
from HF API. Capabilities include `thinking_toggle` (related: #1456).
## Notes
- The model ID string matching approach (`"deepseek-v3.2" in
model.lower()`) is acknowledged tech debt — see #1371 for the planned
architecture-based approach.
## Test plan
- [x] Start exo with DeepSeek V3.2 model → warmup should complete
without crash
- [x] Send a regular chat message (no tools) → should get a response
- [x] Send a chat message with tools → should work as before
- [x] V3.2 cards should appear in the dashboard model catalog
---------
Co-authored-by: user <user@m1.note>
Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
Co-authored-by: Evan <evanev7@gmail.com>
## Motivation
Warmup occasionally had issues; e.g. #1748 and #1793 because we were
using a standard stream_generate, all of which are issues that are
resolved in the wrapper function mlx_generate.
**Enabling peers to be discovered in environments where mDNS is
unavailable (SSH sessions, headless servers, Docker).**
## Motivation
Exo discovers peers exclusively via mDNS, which works great on a local
network but breaks once you move beyond a single L2 broadcast domain:
- SSH sessions on macOS — TCC blocks mDNS multicast from non-GUI
sessions (#1488)
- Headless servers/rack machines — #1682 ("DGX Spark does not find other
nodes")
- Docker Compose — mDNS is often unavailable across container networks;
e.g. #1462 (E2E test framework) needs an alternative
Related works:
#1488 (working implementation made by @AlexCheema and closed because SSH
had a GUI workaround),
#1023 (Headscale WAN then closed due to merge conflicts),
#1656 (discovery cleanup, open).
This PR introduces an optional bootstrap mechanism for peer discovery
while leaving the existing mDNS behavior unchanged.
## Changes
Adds two new CLI flags:
- `--bootstrap-peers` (env: `EXO_BOOTSTRAP_PEERS`) — comma-separated
libp2p multiaddrs to dial on startup and retry periodically
- `--libp2p-port` — fixed TCP port for libp2p to listen on (default:
OS-assigned). Required when bootstrap peers, so other nodes know which
port to dial.
8 files:
- `rust/networking/src/discovery.rs`: Store bootstrap addrs, dial in
existing retry loop
- `rust/networking/src/swarm.rs`: Thread `bootstrap_peers` parameter to
`Behaviour`
- `rust/networking/examples/chatroom.rs`: Updated call site for new
create_swarm signature
- `rust/networking/tests/bootstrap_peers.rs`: Integration tests
- `rust/exo_pyo3_bindings/src/networking.rs`: Accept optional
`bootstrap_peers` in PyO3 constructor
- `rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi` : Update type stub
- `src/exo/routing/router.py`: Pass peers to `NetworkingHandle`
- `src/exo/main.py` : `--bootstrap-peers` CLI arg +
`EXO_BOOTSTRAP_PEERS` env var
## Why It Works
Bootstrap peers are dialed in the existing retry loop — the same path
taken by peers when mDNS-discovered. The swarm handles connection, Noise
handshake, and gossipsub mesh joining from there.
PeerId is intentionally not required in the multiaddr, the Noise
handshake discovers it.
Docker Compose example:
```yaml
services:
exo-1:
environment:
EXO_BOOTSTRAP_PEERS: "/ip4/exo-2/tcp/30000"
exo-2:
environment:
EXO_BOOTSTRAP_PEERS: "/ip4/exo-1/tcp/30000"
```
## Test Plan
### Manual Testing
<details>
<summary>Docker Compose config</summary>
```
services:
exo-node1:
build:
context: .
dockerfile: Dockerfile.bootstrap-test
container_name: exo-bootstrap-node1
hostname: exo-node1
command: ["-q", "--libp2p-port", "30000", "--bootstrap-peers", "/ip4/172.30.20.3/tcp/30000"]
environment:
- EXO_LIBP2P_NAMESPACE=bootstrap-test
ports:
- "52415:52415"
networks:
bootstrap-net:
ipv4_address: 172.30.20.2
deploy:
resources:
limits:
memory: 4g
exo-node2:
build:
context: .
dockerfile: Dockerfile.bootstrap-test
container_name: exo-bootstrap-node2
hostname: exo-node2
command: ["-q", "--libp2p-port", "30000", "--bootstrap-peers", "/ip4/172.30.20.2/tcp/30000"]
environment:
- EXO_LIBP2P_NAMESPACE=bootstrap-test
ports:
- "52416:52415"
networks:
bootstrap-net:
ipv4_address: 172.30.20.3
deploy:
resources:
limits:
memory: 4g
networks:
bootstrap-net:
driver: bridge
ipam:
config:
- subnet: 172.30.20.0/24
```
</details>
Two containers on a bridge network (`172.30.20.0/24`), fixed IPs,
`--libp2p-port 30000`, cross-referencing `--bootstrap-peers`.
Both nodes found each other and established a connection then ran the
election protocol.
### Automated Testing
4 Rust integration tests in `rust/networking/tests/bootstrap_peers.rs`
(`cargo test -p networking`):
| Test | What it verifies | Result |
|------|-----------------|--------|
| `two_nodes_connect_via_bootstrap_peers` | Node B discovers Node A via
bootstrap addr (real TCP connection) | PASS |
| `create_swarm_with_empty_bootstrap_peers` | Backward compatibility —
no bootstrap peers works | PASS |
| `create_swarm_ignores_invalid_bootstrap_addrs` | Invalid multiaddrs
silently filtered | PASS |
| `create_swarm_with_fixed_port` | `listen_port` parameter works | PASS
|
All 4 pass. The connection test takes ~6s
---------
Signed-off-by: DeepZima <deepzima@outlook.com>
Co-authored-by: Evan <evanev7@gmail.com>
## Motivation
The Nemotron model family in the model picker sidebar was displaying as
"Nemotron" with a generic checkmark icon. Since these models are
NVIDIA's Nemotron models, the category should be branded as "NVIDIA"
with the official NVIDIA logo, consistent with how other families are
branded (e.g., "llama" → "Meta", "gpt-oss" → "OpenAI").
## Changes
- **FamilySidebar.svelte**: Added `nemotron: "NVIDIA"` to the
`familyNames` mapping so the sidebar displays "NVIDIA" instead of
"Nemotron"
- **FamilyLogos.svelte**: Added the NVIDIA "eye" logo as an inline SVG
for the `nemotron` family, matching the `viewBox="0 0 24 24"` /
`fill="currentColor"` pattern used by all other brand logos
- **ModelPickerModal.svelte**: Added `"nemotron"` to the `familyOrder`
array so NVIDIA appears in a consistent position in the sidebar
## Why It Works
The model picker derives categories from the `family` field in TOML
model cards. Nemotron models already have `family = "nemotron"`, but the
three UI components (display name, logo, sort order) lacked explicit
entries for it, causing fallback behavior (auto-capitalized name,
checkmark icon, alphabetical sorting). Adding explicit entries for all
three aligns NVIDIA with the existing brand pattern.
## Test Plan
### Manual Testing
<!-- Hardware: N/A - dashboard UI change only -->
- Built dashboard successfully (`npm run build`)
- Verified the NVIDIA logo renders in the sidebar alongside existing
brand logos
### Automated Testing
- No test changes needed — this is a purely cosmetic dashboard change
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
updates macmon to an upstream fork that fixes m5 max issues.
might see if the upstream version gets merged before we release.
---------
Co-authored-by: Alex Cheema <alexcheema123@gmail.com>
## Motivation
Move back to an MLX LM to improve the Qwen 3.5 experience.
## Test Plan
### Manual Testing
Seems to loop less from testing, no speed regressions.
## Motivation
Some users (primarily in China) are unable to access HuggingFace.co. HF
supports the HF_ENDPOINT env variable, and we also support it, but there
is no way to easily do that from the app currently.
## Test Plan
### Manual Testing
hf-mirror works
empty field works
google.com endpoint fails
a fix from pr #1643 which is now stale - should make prefill more
consistent on very slow machines
## testing
qwen-3.5-35b-a3b loads normally
gpt-oss-120b-mxfp4-q8 loads normally
## Motivation
Batch generation reports incorrect statistics, as mlx lm never clears
the original stats, meaning they get polluted over time.
The dashboard also seems considerably slower than bench statistics.
We also have a large discrepancy between B=1 batch generating and
mlx_generate.
Extracting logprobs is massively expensive, causing up to a 25% slowdown
compared to pure batching.
```
[ 12:02:01.1240AM | INFO ] step overhead: 3.49ms (next=12.49ms total=15.99ms)
[ 12:02:02.1600AM | INFO ] step overhead: 3.23ms (next=13.01ms total=16.24ms)
[ 12:02:03.2228AM | INFO ] step overhead: 3.28ms (next=13.38ms total=16.66ms)
[ 12:02:04.2798AM | INFO ] step overhead: 3.25ms (next=12.84ms total=16.10ms)
[ 12:02:05.3152AM | INFO ] step overhead: 3.18ms (next=12.61ms total=15.79ms)
[ 12:02:06.3522AM | INFO ] step overhead: 3.41ms (next=12.83ms total=16.25ms)
[ 12:02:07.3987AM | INFO ] step overhead: 3.38ms (next=13.14ms total=16.52ms)
[ 12:02:08.4537AM | INFO ] step overhead: 1.84ms (next=19.44ms total=21.28ms)
```
## Changes
1. Report stats ourselves instead of using mlx lm's stats for batch
generation (they use perf_counter anyway).
2. Adjust exo bench to match
3. Improve logprobs extraction speed by 10x, improving tps for dashboard
& any requests for logprobs
4. Use an SSE comment to align the speed to the real numbers at the end
of generation
5. Patch mlx for several optimizations given our assumptions and use
cases (e.g. use vllm style RoPE).
6. Switch MLX LM version to latest main, including support for Nemotron
Super and some Qwen3.5 fixes.
## Why It Works
1. Exo bench no longer reports polluted stats
2. Exo bench now handles the reported per-request stats rather than the
aggregate stats
3. The decode speed now jumps back to a real number at the end of the
generation
4. Large batch speedup for rotating KV cache models + 1:1 matching cache
with vllm
## Test Plan
### Manual Testing
Needs testing on OpenCode and CC
Needs eval testing
### Automated Testing
Only going to show the performance optimization difference after the
accurate reporting:
**GPT OSS 20B MXFP4 Q8 (large change)**
Before:
<img width="2466" height="1534" alt="image"
src="https://github.com/user-attachments/assets/88b50637-fca2-4db4-9413-b9eee6e2057e"
/>
<img width="2410" height="1240" alt="image"
src="https://github.com/user-attachments/assets/21e5c76a-2f5f-44d2-8953-121b3ebdbd68"
/>
After:
<img width="2476" height="1472" alt="image"
src="https://github.com/user-attachments/assets/fec5cfbd-fff8-430a-b12e-a329410107a2"
/>
<img width="2454" height="1236" alt="image"
src="https://github.com/user-attachments/assets/0400344b-a4a6-42c0-a9dd-4ee91ade714a"
/>
**Qwen 3.5 35B A3B 8bit (No change)**
Before:
<img width="2414" height="1396" alt="image"
src="https://github.com/user-attachments/assets/e75f0b38-df5d-49fd-ab90-bc1667d981b3"
/>
After:
<img width="2346" height="1234" alt="image"
src="https://github.com/user-attachments/assets/eabfb59c-851f-4d88-b927-e1e699a75cc6"
/>
**Llama 3.2 1B Instruct 4bit (small change)**
Before:
<img width="2516" height="1220" alt="image"
src="https://github.com/user-attachments/assets/c2873655-acff-4536-8263-fb8aea33db80"
/>
After:
<img width="2566" height="1370" alt="image"
src="https://github.com/user-attachments/assets/15f95c75-1c2f-4474-85a2-88c4d0a32543"
/>
## Motivation
Running exo in offline mode on a machine where a model has never been
downloaded causes a crash
## Changes
- `download_shard` now catches `FileNotFoundError` from
`fetch_file_list_with_cache` and returns a `not_started` progress
instead of propagating the exception
## Why It Works
A status query should never crash its caller. By returning
`not_started`, the coordinator's existing offline guard (`if
self.offline:` at line 198) is reached and emits a graceful
`DownloadFailed` event. This also eliminates the warning spam on startup
where the status iterator catches the same exception for every
predefined model.
## Test Plan
### Manual Testing
- Start exo with `--offline` on a machine with no cached models, request
a model via the API — should get a graceful failure instead of a crash
## Motivation
Custom model cards were only saved locally on the node that handled the
API request.
## Changes
- Added AddCustomModelCard and DeleteCustomModelCard commands
- Added CustomModelCardAdded and CustomModelCardDeleted events
- Added custom_model_cards field to cluster State
- Master handles new commands by emitting corresponding events
- Workers persist model cards to disk and update the in-memory cache on
event receipt
- Separated fetch_from_hf (pure fetch) from disk persistence (now
handled by event-sourcing layer)
- Exposed add_to_card_cache() helper for the worker to update the cache
## Why It Works
- Follows the existing event-sourcing pattern: API → Command → Master →
Event → all Workers
- Every node applies the same events, so custom model cards are
consistent across the cluster
## Test Plan
### Manual Testing
Add/delete a custom model via the API on one node, verify it
appears/disappears on all nodes
### Automated Testing
Existing tests cover apply() logic; new event types follow the same
discriminated-union pattern
## Motivation
When placing a model instance across the cluster, the master previously
only considered available RAM. This meant it could pick a node that
hasn't downloaded the model yet, even when another node already has it
(or is further along in downloading it).
## Changes
- Added download_status parameter to place_instance() in placement.py
- Added _get_node_download_fraction() to compute 0.0–1.0 download
progress per node/model
- Added _cycle_download_score() to sum download fractions across a
cycle's nodes
- Cycle selection now uses a (download_score, available_ram) tuple key —
download progress is the primary sort, RAM is the tiebreaker
- Passed self.state.downloads into place_instance() from master/main.py
## Why It Works
Python's tuple comparison gives download progress strict priority over
RAM, so a node with the model already downloaded will always be
preferred over one with more free RAM but no download.
## Test Plan
### Automated Testing
3 new tests cover: completed download preferred, higher partial progress
preferred, failed download not preferred over no-download node
## Summary
- **Add ISO date to verbose stderr format**: when running with `-v`, the
stderr timestamp changes from `HH:mm:ss.SSS` to `YYYY-MM-DD
HH:mm:ss.SSS`, matching the file log format. This makes it possible to
correlate entries across days when stderr is captured by launchd,
systemd, Docker, or file redirection.
- **File log respects verbosity**: `exo.log` now uses DEBUG level when
`-v` is passed, so the persistent log has the same detail as stderr.
Previously it was always INFO regardless of verbosity.
- **Startup banner with PID**: adds a visual separator and process ID to
the startup message, making it easy to identify session boundaries in
long-running logs (e.g. `grep "Starting EXO"`).
The non-verbose (default) stderr format is unchanged — end-user terminal
experience is not affected.
## Motivation
When exo runs as a service (launchd, systemd, Docker), stderr is
typically captured to a file. Without calendar dates in the timestamp,
it is impossible to tell which day a log entry belongs to. This caused
misidentification of log entries during a multi-day RDMA debugging
session on a 4-node cluster.
The file log (`exo.log`) already had dates and rotation, but only
captured INFO level — missing the DEBUG output needed for postmortem
analysis.
## Test plan
- [x] `basedpyright` — 0 errors, 0 warnings, 0 notes
- [x] `ruff check` — all checks passed
- [x] `pytest` — 249 passed, 1 skipped
Co-authored-by: user <user@m1.note>
## Motivation
Download progress in the dashboard was broken: mainly treating all
download statuses as ongoing
## Changes
- Backend (apply.py): Deduplicate download progress events by model_id
instead of full shard_metadata, preventing duplicate entries per node
- Dashboard (+page.svelte): Extract shared collectDownloadStatus()
helper that both getModelDownloadStatus and getInstanceDownloadStatus
use, eliminating ~100 lines of duplicated logic. Adds proper handling
for
DownloadCompleted/DownloadFailed events, uses a Map to deduplicate
per-node entries, and introduces a typed NodeDownloadStatus with
explicit status states (downloading/completed/partial/pending)
- ModelCard: Replace single aggregate progress bar with per-node
download bars, each color-coded by status. Instance preview now scopes
download status to participating nodes only
## Why It Works
- Deduplicating by model_id in apply.py ensures each node has exactly
one download entry per model
- The perNodeMap in the frontend keeps only the latest event per node,
preventing duplicate bars
- Handling DownloadCompleted allows the UI to show finished downloads
instead of dropping them
- Scoping instance previews to assigned nodes avoids showing irrelevant
download progress
2026-03-19 14:47:45 +00:00
288 changed files with 20254 additions and 2070 deletions
@@ -285,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` |
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}
/>
{:elseifactiveTab==="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."
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")?`\nopenclawmodelsset-imageexo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
language="bash"
/>
{:elseifactiveTab==="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"
/>
{:elseifactiveTab==="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}
/>
{:elseifactiveTab==="Firefox"}
<IntegrationCard
title="Firefox AI Chatbot"
subtitle="about:config"
description="Use the exo dashboard as Firefox's built-in AI chatbot. Requires Firefox 130+."
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.