Compare commits

...
Author SHA1 Message Date
dmcc73 00993ada81 exo_bench: set temperature=0.0 for argmax sampling (drops decode overhead) 2026-05-05 00:36:32 +01:00
dmcc73 c8f3a12063 Disable patch_tensor_model for A/B test 2026-05-05 00:14:00 +01:00
dmcc73 e39e1cc26a exo_bench: log per-repeat numbers in addition to average 2026-05-05 00:05:42 +01:00
Sam BradburyandSam Bradbury 9c6ff4ce95 feat: update rdma_ctl instructions (#1977)
## Motivation

The RDMA setup instructions were missing a step: after booting to
Recovery mode, users need to open Terminal from the Utilities menu
before they can run the `rdma_ctl` command. Without this step, users
following the instructions wouldn't know how to access a terminal in
Recovery mode. This step was already in the README just not in the UI
notifications.

## Changes

Added a missing instruction step — "Open Terminal from the Utilities
menu" — to three instances of the RDMA setup flow in
`dashboard/src/routes/+page.svelte`.

## Why It Works

N/A copy change only. 

## Test Plan

### Manual Testing
Hardware: MacBook Pro M4 Max 48GB

### Automated Testing
No automated tests affected; this is a UI copy change only.

Co-authored-by: Sam Bradbury <sam@consultbradbury.com>
2026-05-01 11:18:57 +00:00
ecohash-coandJordan Miller b26268dfaf fix(macos-app): disable URL response caching for cluster-state polling (#2005)
Fixes #2004.

`ClusterStateService` polls `/state` at 2 Hz via `URLSession.shared`,
which keeps an on-disk `URLCache` attached by default. Every polled
response body gets persisted under `~/Library/Caches/exolabs.EXO/`,
sustaining ~500–620 KB/sec of file-backed memory dirtied — far above
macOS's ~25 KB/sec per-process daily-average baseline. Six
microstackshot reports observed on a single Mac Studio M3 Ultra over
eight days, with one 15-hour run accumulating 34.36 GB of cache writes.

Heaviest stack on every diagnostic report (96–98% of samples):

```
_dispatch_workloop_worker_thread → _dispatch_block_async_invoke2 →
  __CFURLCache::CreateAndStoreCacheNode → write
```

Full diagnostic data and analysis in #2004.

## What changed

`ClusterStateService` now defaults to an ephemeral, non-caching
`URLSession` instead of `URLSession.shared`. Cluster-state responses are
time-sensitive and small; nothing benefits from being cached on disk.

```swift
private static func makeNonCachingSession() -> URLSession {
    let config = URLSessionConfiguration.ephemeral
    config.urlCache = nil
    config.requestCachePolicy = .reloadIgnoringLocalCacheData
    return URLSession(configuration: config)
}
```

The existing per-request `request.cachePolicy =
.reloadIgnoringLocalCacheData` calls are kept as defense in depth — they
only affect read behavior, but harmless to leave alongside the
session-level config.

## Scope

- **Behavioral**: none. Polled requests still go out at the same
cadence; responses still parse the same; no semantic change to any API
surface.
- **Test injection**: the `session:` parameter remains in `init`, so
tests can still inject a custom mock session unchanged.
- **`BugReportService` and other `URLSession.shared` callers**:
untouched. If maintainers prefer an app-wide URLCache disable instead,
happy to switch the approach (issue body has the alternative spelled
out).

## Verification

Verified locally that compiling EXO with this change produces a working
menubar app and `ClusterStateService` continues to fetch state
correctly. After ~30 min of idle polling, no new entries in
`/Library/Logs/DiagnosticReports/EXO_*.diag` and no growth in
`~/Library/Caches/exolabs.EXO/`.

## Test plan
- [ ] Build EXO from this branch on macOS 26.4
- [ ] Launch, let cluster state polling run for 30+ min
- [ ] Confirm no new microstackshot diagnostic reports
- [ ] Confirm `~/Library/Caches/exolabs.EXO/Cache.db*` does not grow

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

Co-authored-by: Jordan Miller <jordan.d.miller@gmail.com>
2026-05-01 10:41:10 +00:00
ciaranbor 8dae3ecb9a A few targeted tweaks to address HF rate limits (#2009)
## Motivation

- exo bursts ~200 HF Hub-API requests on every cold start, blowing past
the anonymous 500-req/5-min budget.
- The existing retry loop catches 429 generically and gives up in ~3s —
well before HF's reset window.
- `file_meta` and `_download_file` had no 429 handling at all (became
`AssertionError`).
- Disk file-list cache was bypassed on every process restart.

## Changes

All in `src/exo/download/download_utils.py` + tests.

- Parse `t=` from HF's `RateLimit` header on 429; sleep `min(t, 300s) +
jitter`.
- Handle 429 at all three call sites (`_fetch_file_list`, `file_meta`,
`_download_file`).
- `n_attempts`: 3 → 5.
- Disk cache now primary across restarts (24h mtime TTL).
- `?recursive=true` instead of N+1 subdir walks.

## Why It Works

`t=<seconds>` is HF's "wait this long and you'll be unblocked" —
sleeping that long lets the window reset. Disk-cache-as-primary plus
recursive listing cuts cold-start Hub-API traffic by ~10×.

## Test Plan

### Manual Testing

MacBook Pro M1 Max. Tripped the real HF 429. Pre-fix: failed in 3.4s.
Post-fix: slept (HF returned `t=158`) and recovered.

### Automated Testing

- New `test_rate_limit_handling.py` (19 tests) — header parsing,
retry-loop behaviour, plus HTTP-level coverage that mocks aiohttp to
return a 429 and asserts each call site raises
`HuggingFaceRateLimitError(retry_after=52.0)`.
- New `TestFileListCacheTTL` in `test_offline_mode.py` — fresh cache
hits, stale cache refetches.
- 421 tests pass; basedpyright / ruff / nix fmt clean.
2026-04-30 18:06:15 +00:00
Alex CheemaandClaude Opus 4.7 fb12b403ea fix(app): tighten Share Bug Report prompt layout (#2008)
## Summary

Follow-ups to #2003 based on feedback that the Share Bug Report window
felt visually weighty: too much padding above and below, and a
description editor that invited an essay rather than a one-liner.

## Changes (one file)

`app/EXO/EXO/Views/BugReportWindowController.swift`:

- **Auto-size the window to its content.** Switched from `NSHostingView`
+ fixed `contentRect: 480x380` + SwiftUI `frame(minHeight: 320)` to
`NSHostingController` with `sizingOptions = [.preferredContentSize,
.minSize]`. The fixed-min combo was centering the form in dead vertical
space.
- **Smaller, lower-pressure editor.** Field is now labeled `Description
(optional)` with a placeholder hint (`What were you doing when it
broke?`) inside the editor. Editor height fixed at 72pt (was 120pt min).
Replaced the long lead-in paragraph and headline with a single one-line
caption between field and buttons: `Diagnostic logs will be uploaded
with your report.`
- **Tighter spacing.** Outer padding 20 -> 16, root spacing 16 -> 12,
prompting-section spacing 12 -> 8.
- **Remove em dash from copy.**

`BugReportService` and the menu wiring are unchanged.

## Test plan

- [ ] Click `Share Bug Report...` from the menu bar.
- [ ] The window opens centered and sized to its content (no big empty
bands top/bottom).
- [ ] Description editor is visibly compact, with the placeholder hint
showing when empty.
- [ ] The optional-ness is conveyed by the field label (no separate help
paragraph).
- [ ] Caption `Diagnostic logs will be uploaded with your report.`
appears in `.caption` style under the editor, above the buttons.
- [ ] Resize the window: persists across re-opens (frame autosave still
works).
- [ ] Send/Cancel/Try Again/Done flows behave the same as before.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:10:26 +01:00
Alex CheemaandClaude Opus 4.7 1606e63816 feat(app): open Share Bug Report in a dedicated window (#2003)
## Summary

- Adds a top-level **Share Bug Report…** menu item to the macOS popover
(between *Check for Updates* and *Quit*) with SF Symbol `ladybug`.
- Clicking it opens a dedicated resizable `NSWindow` ("Send a Bug
Report") that hosts the prompting / sending / success / failure flow.
- Removes the description-less duplicate from Settings → Debug Info, and
the dead `debugSection` it nominally lived behind.

## Why

PR #1959 added a user-description prompt to the bug-report flow, but its
trigger lived inside `ContentView.debugSection` — a view that's defined
but never rendered in the body. The path users actually hit was
`SettingsView.sendBugReportButton`, which called
`BugReportService.sendReport(isManual: true)` without ever passing
`userDescription`. So the description prompt was unreachable in the
built app.

## Approach

Per Apple HIG, an action that requires further input before completing
should open a dialog, not transform the menu inline. So:

- Add a top-level menu entry that ends in `…` (HIG: ellipsis indicates
"further input required").
- Move the prompting/sending/success/failure state machine into a
standalone `BugReportWindowController` modeled after the existing
`SettingsWindowController`.
- Single-instance window with frame-autosave name, sensible
`contentMinSize`, resizable, native button layout (`.cancelAction` /
`.defaultAction` keyboard shortcuts), light/dark-mode-correct
`.textBackgroundColor` and `.separatorColor`.
- Auto-focus the description field on open. `Try Again` from failure,
`Open GitHub Issue` + `Done` from success.

## Files

- `app/EXO/EXO/Views/BugReportWindowController.swift` (new) — controller
+ view.
- `app/EXO/EXO/EXOApp.swift` — wire `BugReportWindowController` as a
`@StateObject` and inject as environment object.
- `app/EXO/EXO/ContentView.swift` — replace inline state machine with
menu item that calls `bugReportWindowController.open()`. Remove
now-unused state, helpers, and dead `debugSection`.
- `app/EXO/EXO/Views/SettingsView.swift` — remove duplicate
`sendBugReportButton`, `sendBugReport()`, and related `@State`. Section
"Debug Info" keeps Thunderbolt / interface / RDMA info.

`BugReportService` is unchanged.

## Test plan

- [ ] Open the menu-bar popover → confirm **Share Bug Report…** appears
between *Check for Updates* and *Quit*, with a ladybug icon.
- [ ] Click it → a window titled "Send a Bug Report" appears, centered,
with the description editor focused.
- [ ] Resize the window → size persists across re-opens (frame
autosave).
- [ ] Type a description, press Return → upload succeeds, success card
with **Open GitHub Issue** + **Done** appears.
- [ ] Click **Open GitHub Issue** → browser opens with the description
pre-filled into the issue template.
- [ ] Send with empty description → upload still succeeds.
- [ ] Press Esc from the prompting state → window closes.
- [ ] On failure (e.g., offline) → error card with **Try Again** +
**Close** appears; Try Again returns to the editor with the description
preserved.
- [ ] Open the Settings window → Debug Info section is unchanged except
the Send Bug Report button is gone.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:05:07 +01:00
667a3bb0e5 feat: keep-models option when uninstalling EXO (#1997)
## Summary

- Adds a **Keep downloaded models (~/.exo/models)** checkbox to the
macOS uninstall confirmation dialog (Settings → Advanced → Danger Zone).
The full `~/.exo` directory is now removed on uninstall by default; if
the checkbox is checked, `~/.exo/models` is preserved.
- The standalone `app/EXO/uninstall-exo.sh` gains a matching
`--keep-models` flag and the same `~/.exo` cleanup so GUI and CLI flows
stay in sync. Resolves the user home via `$SUDO_USER` since the script
runs under `sudo`.

Previously, "Uninstall EXO" only cleaned up system-level components
(LaunchDaemon, network location, logs, app bundle) and left the entire
`~/.exo` directory behind. Now uninstalling actually removes EXO's user
data, with a one-click opt-out for the (potentially many GB) of
downloaded models.

![Uninstall dialog with new
checkbox](https://raw.githubusercontent.com/exo-explore/exo/703b7fbbf13441217ad2903bb199f07e92af4490/uninstall-dialog.png)

> Note: the rendered icon in the screenshot above is the generic system
folder icon because it was captured from a small standalone Swift binary
(no app bundle / icon resource). When triggered from the actual EXO.app,
the EXO app icon is shown.

## Test plan

- [ ] Build EXO.app locally; open Settings → Advanced → Danger Zone →
Uninstall EXO; confirm the new "Keep downloaded models (~/.exo/models)"
checkbox is present and unchecked by default.
- [ ] Uninstall with the checkbox **checked** → `~/.exo/models/`
survives, all other entries under `~/.exo` are gone, system components
removed, app moved to Trash.
- [ ] Uninstall with the checkbox **unchecked** → `~/.exo` is fully
removed.
- [ ] `sudo app/EXO/uninstall-exo.sh --keep-models` → `~/.exo/models/`
is preserved, the rest of `~/.exo` is removed.
- [ ] `sudo app/EXO/uninstall-exo.sh` (no flag) → `~/.exo` is fully
removed.
- [ ] `app/EXO/uninstall-exo.sh --help` prints usage and exits 0;
unknown args exit 2 with a usage hint.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan <evanev7@gmail.com>
2026-04-28 01:06:02 +00:00
Evan Quiney c80b10c013 implement engine abstraction for mlx and mflux (#2000)
refactor for future versions.
2026-04-28 00:58:17 +00:00
Alex CheemaandClaude Opus 4.7 18ffe1df23 fix: uninstall-exo.sh removes both current and legacy bridge scripts (#1998)
## Summary

The standalone `app/EXO/uninstall-exo.sh` only knew about the legacy
filename `disable_bridge_enable_dhcp.sh`. On machines installed with
newer EXO versions, the current `/Library/Application
Support/EXO/disable_bridge.sh` was left behind, and the script then
reported `EXO support directory not empty, leaving in place`.

This PR makes the script try both filenames, removing whichever ones
exist. Tolerates **either**, **both**, or **neither** being present
without erroring.

The Swift `NetworkSetupHelper.makeUninstallScript()` already handles
both paths correctly, so the GUI uninstall flow is unaffected — this is
a script-only fix.

Caught while running an end-to-end uninstall on a real machine for
#1997.

## Test plan

Verified the new block in isolation against all four states:

- [x] both `disable_bridge.sh` and `disable_bridge_enable_dhcp.sh`
present → both removed
- [x] only `disable_bridge.sh` present → removed cleanly
- [x] only `disable_bridge_enable_dhcp.sh` present → removed cleanly
(legacy install)
- [x] neither present → prints the existing "already removed?" warning,
exits 0

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:28:12 +00:00
rltakashige f0d1371d89 MLX P/D (#1993)
## Motivation

MLX only prefill server for Apple Silicon
2026-04-28 00:12:42 +00:00
5d10188d3a fix: route by in-flight tasks only — completed tasks were skewing load balance (#1989)
The load balancer counted ALL tasks (Complete, Cancelled, TimedOut,
Failed) instead of only Pending/Running ones. With 138 accumulated tasks
and only 7 active, routing decisions were based on historical
distribution, causing one node to appear permanently 'busier' and
starving the other of work.

Co-authored-by: Adam Durham <adam@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:03:12 +00:00
ciaranbor f2a0db4e23 Extend bench/eval tooling (#1905)
## Motivation

Extend bench/eval tooling with robustness features, streaming support,
and align model configs with vllm eval for reproducible comparisons.

## Changes

- **exo_eval**: Checkpoint/resume (JSONL), instance health monitoring +
early abort, `top_k`/`min_p`/`enable_thinking` params, LCB
`--release-version`/`--offset`
- **exo_bench**: Streaming SSE (`--stream`), Kimi tokenizer fix for
transformers 5.x
- **Both tools**: Auto-detect running instances instead of requiring
`--skip-instance-setup`; `--fresh-instance` to override
- **harness**: SSE streaming client, `find_existing_instance()` shared
helper, removed download timeout, settle-timeout default 0→7200s
- **models.toml**: Added `enable_thinking`, aligned `max_tokens`/temps
with vllm, added new models
- **API**: Streaming SSE for `/bench/chat/completions`

## Why It Works

- Checkpoint/resume uses append-only JSONL + skip-on-load so interrupted
evals resume without re-running completed questions
- Health monitoring races an `asyncio.Event` against API calls for fast
abort when the instance dies
- Auto-detection queries `/state` for existing instances matching the
model ID before attempting placement
- Streaming reuses the existing `generate_chat_stream` infrastructure
from the regular chat endpoint
2026-04-27 16:53:43 +01:00
rltakashigeandEvan 37f6f4f6c2 Add DeepSeek V4 Flash/Pro (#1978)
Wait for upstream merge.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-27 15:20:50 +01:00
Adam DurhamandAdam Durham 48a922fd5c fix: map presence_penalty and frequency_penalty from ChatCompletionRequest (#1991)
Upstream PR #1947 added `presence_penalty` and `frequency_penalty` to
`TextGenerationTaskParams` and the mlx-lm generator call sites, but
missed wiring them up in the API adapter so they were silently dropped
from incoming requests. This fixes the API mapping.

Co-authored-by: Adam Durham <adam@example.com>
2026-04-27 08:58:59 +00:00
rltakashige fd707de30b Add more model cards (#1970) 2026-04-23 15:28:40 +01:00
Alex CheemaandClaude Opus 4.7 45248c5c85 chore(app): hardcode bug report presigned-URL endpoint (#1971)
## Motivation

The bug-report presigned-URL endpoint
(`https://reports.exolabs.net/presigned-urls`) was injected at build
time from the `EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` GitHub Actions
secret into `Info.plist`, then read at runtime by `BugReportService`. It
isn't actually a secret — the POST body is just `{"keys":[...]}` with no
credential (see `app/EXO/EXO/Services/BugReportService.swift:136-142`),
abuse prevention lives server-side on the lambda, and the URL is already
visible in every publicly-distributed DMG's `Info.plist`. Treating it as
a repo secret added plumbing with no security benefit and broke local
dev builds — hitting **Send Bug Report** on an uncustomised `just
build-app` raised "Bug report endpoint is invalid".

## Changes

- `app/EXO/EXO/Info.plist`: replace
`$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)` with the literal URL.
- `.github/workflows/build-app.yml`: drop the
`EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` job-level env var and the
xcodebuild build-setting passthrough. No other workflow changes.

Swift code is unchanged — `BugReportService` still reads from
`Info.plist`, which leaves an escape hatch if anyone ever needs to
override via `xcodebuild EXOBugReportPresignedUrlEndpoint=...` without
recompiling.

Follow-up: the `EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` repo secret can
now be deleted in the GitHub Actions settings UI.

## Why It Works

`Info.plist` variable substitution turns `$(FOO)` into whatever build
setting `FOO` resolves to. CI was setting `FOO` via xcodebuild; local
dev wasn't, so the key resolved to an empty string, which
`BugReportService.fetchPresignedUploadUrls` rejects via the
`!trimmedEndpointString.isEmpty` guard at `BugReportService.swift:131`.
Hardcoding the literal string removes the substitution entirely, so
every build — local or CI — gets the right value.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro (macOS app build via Xcode) -->
- `just build-app` with no extra env vars (reproduces the failure path
on `main`).
- `/usr/libexec/PlistBuddy -c "Print :EXOBugReportPresignedUrlEndpoint"
app/EXO/build/Build/Products/Debug/EXO.app/Contents/Info.plist` →
returns `https://reports.exolabs.net/presigned-urls` (was empty before
this change).
- `open app/EXO/build/Build/Products/Debug/EXO.app` → menubar → **Debug
Info** → **Send Bug Report** → type a description → **Send** → upload
succeeds and the **Create GitHub Issue** button appears (was failing
with "Bug report endpoint is invalid" before).
- Cross-check on the Slack side that the uploaded `report.json` lands
under `reports/YYYY/MM/DD/<ts>/` as before.

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
- No new tests. This is a single-string change to `Info.plist` plus a
workflow cleanup. `nix flake check` in CI verifies formatting/lint for
the rest of the tree.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:16 +00:00
rltakashige 290e3fd927 Keep image cache fresh (temporary fix) (#1961)
## Motivation

When a new node joins, it might not have the cache.



Caveat: 
This is potentially fallible if a new node joins and updates real
topology, but the API topology hasn't caught up with this fact and the
user queues up a new text generation. In practice, there is only a split
second where this is the case, and this is only for users of the
dashboard interface. We should fix this properly after the release.
2026-04-23 11:39:36 +01:00
rltakashige 3894cf134e Fix Gemma 4 E2B TP + DeepSeek V32 thinking parsing (#1967) 2026-04-23 01:50:39 +00:00
Alex CheemaandClaude Opus 4.7 8993ccaf09 feat(app): add friendly context message to bug report prompt (#1959)
## Motivation

When a user clicks **Send Bug Report** in the macOS app, we already give
them the option to add more context via an optional text field. But the
current prompt is just a terse label — `"What's the issue? (optional)"`
— which doesn't tell the user why bothering to fill it in matters. A
friendly one-line explanation increases the chance they'll describe what
went wrong, which is the single most useful signal when we triage the
resulting diagnostic bundle.

## Changes

- `app/EXO/EXO/ContentView.swift`: In the `.prompting` phase of
`sendBugReportButton`, replace the single label with a two-line
hierarchy:
  - Primary: `Tell us what went wrong (optional)`
- Helper: `A quick description of what you were doing and what happened
helps us track down the bug for you.`
- The helper uses `.caption2` + `.secondary` + `.opacity(0.8)` +
`.fixedSize(horizontal: false, vertical: true)` so it stays visually
subordinate and wraps cleanly inside the 340pt popover.

No changes to `BugReportService`, the `user_description` payload, or any
other flow.

## Why It Works

The optional description is already plumbed end-to-end (text editor →
`bugReportUserDescription` state → `BugReportService.sendReport(...,
userDescription:)` → `report.json`'s `user_description` field → GitHub
issue pre-fill). The only gap was user-facing motivation, so this is
purely a copy/layout tweak inside the existing `.prompting` case — no
new state, bindings, or service changes.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro (macOS app build via Xcode) -->
- Build the macOS app in Xcode (`app/EXO/EXO.xcodeproj`) and launch it.
- Open the menubar popover → expand **Debug Info** → click **Send Bug
Report**.
- Verify the new primary label and helper sentence both appear above the
text editor and wrap cleanly within the popover width.
- Leave the field empty → click **Send** → upload should succeed (no
`user_description` in payload, same as before).
- Fill in a description → click **Send** → upload succeeds and the
success card with **Create GitHub Issue** appears; clicking it opens
GitHub with the description pre-filled.
- Click **Cancel** from the prompting state → returns to idle.

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
- No new automated tests. This is a SwiftUI copy/layout change; existing
`EXOTests` are smoke-level and don't cover `ContentView` view bodies,
and UI snapshot tests aren't worth adding for a two-line copy tweak.
- `nix fmt` reports 0 files changed after the edit; `nix flake check` in
CI will verify formatting/lint for the rest of the tree.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:39:36 +00:00
Nadeem Hilal Wani 4939fbe995 feat(dashboard): add Pi integration tab (#1925)
## Summary
Adds a new **Pi** tab to the Integrations page (`/#/integrations`)
alongside the existing Claude Code, OpenCode, Codex, OpenClaw, Open
WebUI, n8n, and Firefox tabs.

[pi](https://pi.dev) (`@mariozechner/pi-coding-agent`) is a terminal
coding agent that supports custom OpenAI-compatible providers via
`~/.pi/agent/models.json`.
This tab gives users a copy-pasteable config to wire pi up to their exo
cluster.

## What's in the tab
- **Model selector** (shown when multiple models are running) — picks
the default model for the generated shell command.
- **Models Config card** — generates `~/.pi/agent/models.json`
registering `exo` as a custom provider:
     - `baseUrl` → `<apiUrl>/v1`
     - `api` → `openai-completions`
     - `apiKey` → `"exo"` (placeholder; exo ignores it)
- `compat.supportsDeveloperRole: false` and
`compat.supportsReasoningEffort: false`, per pi docs recommendation for
local OpenAI-compatible servers
- Auto-populates every running model with `id`, `contextWindow` (from
`/v1/models`), and `input: ["text", "image"]` for vision-capable models
- **Shell Command card** — `pi --provider exo --model <model>` for quick
launch.

The tab gracefully falls back to `your-model-id` when no models are
running, matching the behavior of the other tabs.

   ## Usage

   1. `npm install -g @mariozechner/pi-coding-agent`
   2. Paste the generated config into `~/.pi/agent/models.json`
3. Run `pi` and pick an exo model via `/model` — or run the shell
command directly

   ## Changes

- `dashboard/src/routes/integrations/+page.svelte` — adds `"Pi"` to the
`tabs` tuple, `piModel` state, `piModelsJson` + `piShellCommand`
derivations, and the tab content block.

   Single-file, scoped change — no backend or type changes.

   ## Testing

   - `cd dashboard && npm run build` —  builds cleanly
   - `svelte-check` on the edited file — no new errors
- Manually verified the tab renders, the model selector updates the
generated JSON, and the config reflects `/v1/models` capabilities
(vision → `input: ["text","image"]`,
 `context_length` → `contextWindow`).

   ## Screenshots

<img width="1545" height="1236" alt="pi-tab"
src="https://github.com/user-attachments/assets/38aa179f-4ed9-4a1e-9783-d3baa7738263"
/>
2026-04-22 17:29:47 +00:00
rltakashige 73782ecc65 Fix event mutation causing indexed vs event mismatch (#1964)
Fixes small issue with #1957
2026-04-22 16:12:24 +00:00
rltakashige f6e418ed23 Cleanup on #1952 (#1960) 2026-04-22 17:05:49 +01:00
rltakashigeandEvan 7a312a177b Misc fixes: upstream JACCL all_sum, API, etc. + Add Kimi K2.6 (#1952)
## Motivation

This fixes a bunch of observed model quality issues introduced upstream
in JACCL, as well as API issues and prefix cache calculation.


## Test Plan

### Manual Testing
Tested a bunch

### Automated Testing
Added a test, automated eval tool calls on Kimi K2.6, Minimax M2.7, GPT
OSS and Qwen3.6 models.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-22 15:43:27 +00:00
Evan Quiney 0a549f8846 remove layer loading callback (#1890)
first part of modularising the backend is simplifying some of the
control flow. more tbd.
2026-04-22 14:03:31 +01:00
178 changed files with 10531 additions and 2908 deletions

No files matched your search

-2
View File
@@ -32,7 +32,6 @@ jobs:
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
SPARKLE_S3_BUCKET: ${{ secrets.SPARKLE_S3_BUCKET }}
SPARKLE_S3_PREFIX: ${{ secrets.SPARKLE_S3_PREFIX }}
EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT: ${{ secrets.EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT }}
AWS_REGION: ${{ secrets.AWS_REGION }}
EXO_BUILD_NUMBER: ${{ github.run_number }}
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
@@ -347,7 +346,6 @@ jobs:
EXO_BUILD_COMMIT="$GITHUB_SHA" \
SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \
SPARKLE_ED25519_PUBLIC="$SPARKLE_ED25519_PUBLIC" \
EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT="$EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT" \
CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES
mkdir -p ../../output
+6 -5
View File
@@ -1767,12 +1767,12 @@ def clip(
array: The clipped array.
"""
def compile(
fun: Callable,
def compile[F: Callable[..., object]](
fun: F,
inputs: object | None = ...,
outputs: object | None = ...,
shapeless: bool = ...,
) -> Callable:
) -> F:
"""
Returns a compiled function which produces the same output as ``fun``.
@@ -2915,8 +2915,8 @@ def gather_mm(
a: array,
b: array,
/,
lhs_indices: array,
rhs_indices: array,
lhs_indices: array | None = ...,
rhs_indices: array | None = ...,
*,
sorted_indices: bool = ...,
stream: Stream | Device | None = ...,
@@ -4707,6 +4707,7 @@ def softmax(
/,
axis: int | Sequence[int] | None = ...,
*,
precise: bool = ...,
stream: Stream | Device | None = ...,
) -> array:
"""
+4
View File
@@ -57,6 +57,10 @@ class Module(dict):
def __init__(self) -> None:
"""Should be called by the subclasses of ``Module``."""
def __getitem__(self, key: str) -> mx.array | Module: ...
def get(
self, key: str, default: mx.array | Module | None = ...
) -> mx.array | Module | None: ...
@property
def training(self): # -> bool:
"""Boolean indicating if the model is in training mode."""
+5 -4
View File
@@ -383,11 +383,12 @@ class GenerationBatch:
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]
_current_logprobs: mx.array | List[mx.array]
_next_tokens: Optional[mx.array]
_next_logprobs: mx.array | List[mx.array]
_token_context: List[Any]
_num_tokens: List[int]
_matcher_states: List[Any]
def __init__(
self,
+5 -5
View File
@@ -3,7 +3,7 @@ This type stub file was generated by pyright.
"""
from dataclasses import dataclass
from typing import Optional
from typing import Any, Optional
import mlx.core as mx
@@ -37,10 +37,10 @@ def quantized_scaled_dot_product_attention(
bits: int = ...,
) -> mx.array: ...
def scaled_dot_product_attention(
queries,
keys,
values,
cache,
queries: mx.array,
keys: mx.array,
values: mx.array,
cache: Optional[Any],
scale: float,
mask: Optional[mx.array],
sinks: Optional[mx.array] = ...,
+280
View File
@@ -0,0 +1,280 @@
"""Type stubs for mlx_lm.models.deepseek_v4"""
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 ArraysCache, RotatingKVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_size: int
intermediate_size: int
moe_intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
n_shared_experts: Optional[int]
n_routed_experts: int
num_experts_per_tok: int
head_dim: int
qk_rope_head_dim: int
q_lora_rank: int
o_lora_rank: int
o_groups: int
sliding_window: int
hc_mult: int
hc_sinkhorn_iters: int
hc_eps: float
compress_ratios: Optional[List[int]]
compress_rope_theta: float
rope_theta: float
rope_scaling: Optional[Dict[str, Any]]
rms_norm_eps: float
swiglu_limit: float
attention_bias: bool
max_position_embeddings: int
class DeepseekV4RoPE(nn.Module):
dims: int
freqs: mx.array
def __init__(
self,
dims: int,
base: float,
scaling_config: Optional[Dict[str, Any]] = None,
) -> None: ...
def __call__(
self,
x: mx.array,
offset: int = 0,
inverse: bool = False,
) -> mx.array: ...
class HyperConnection(nn.Module):
dim: int
hc_mult: int
norm_eps: float
def __init__(
self,
dim: int,
hc_mult: int,
norm_eps: float,
sinkhorn_iters: int,
hc_eps: float,
) -> None: ...
class HyperHead(nn.Module):
dim: int
hc_mult: int
def __init__(
self,
dim: int,
hc_mult: int,
norm_eps: float,
hc_eps: float,
) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class Compressor(nn.Module):
dim: int
head_dim: int
rope_head_dim: int
compress_ratio: int
overlap: bool
wkv_gate: nn.Linear
ape: mx.array
norm: nn.RMSNorm
rope: DeepseekV4RoPE
def __init__(
self,
dim: int,
compress_ratio: int,
head_dim: int,
rope_head_dim: int,
rms_norm_eps: float,
rope: DeepseekV4RoPE,
) -> None: ...
def __call__(
self,
x: mx.array,
cache: "DeepseekV4Cache",
offset: Any,
key: str = ...,
) -> mx.array: ...
class Indexer(nn.Module):
def __init__(
self,
args: ModelArgs,
compress_ratio: int,
rope: DeepseekV4RoPE,
) -> None: ...
class _CompressorBranch:
buffer_kv: Optional[mx.array]
buffer_gate: Optional[mx.array]
prev_kv: Optional[mx.array]
prev_gate: Optional[mx.array]
pool: Optional[mx.array]
buffer_lengths: Optional[List[int]]
pool_lengths: Optional[List[int]]
buffer_count: int
_new_pool_lengths: Optional[List[int]]
def __init__(self) -> None: ...
class DeepseekV4Cache:
local: RotatingKVCache
offset: int
keys: Optional[mx.array]
values: Optional[mx.array]
state: Any
meta_state: Any
nbytes: int
_branches: Dict[str, _CompressorBranch]
_pending_lengths: Optional[List[int]]
def __init__(self, sliding_window: int) -> None: ...
def update_and_fetch(
self, keys: mx.array, values: mx.array
) -> tuple[mx.array, mx.array]: ...
def is_trimmable(self) -> bool: ...
def trim(self, n: int) -> int: ...
def empty(self) -> bool: ...
def size(self) -> int: ...
def prepare(
self,
*,
left_padding: Optional[List[int]] = None,
lengths: Optional[List[int]] = None,
right_padding: Optional[List[int]] = None,
) -> None: ...
def finalize(self) -> None: ...
def filter(self, batch_indices: mx.array) -> None: ...
def extend(self, other: "DeepseekV4Cache") -> None: ...
def extract(self, idx: int) -> "DeepseekV4Cache": ...
@classmethod
def merge(cls, caches: List["DeepseekV4Cache"]) -> "DeepseekV4Cache": ...
class V4Attention(nn.Module):
args: ModelArgs
layer_id: int
dim: int
n_heads: int
head_dim: int
rope_head_dim: int
nope_head_dim: int
n_groups: int
q_lora_rank: int
o_lora_rank: int
window: int
eps: float
scale: float
compress_ratio: int
wqkv_a: nn.Linear
q_norm: nn.RMSNorm
wq_b: nn.Linear
kv_norm: nn.RMSNorm
attn_sink: mx.array
wo_a: nn.Linear
wo_b: nn.Linear
rope: DeepseekV4RoPE
compressor: Compressor
indexer: Indexer
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class DeepseekV4MLP(nn.Module):
gate_proj: nn.Linear
up_proj: nn.Linear
down_proj: nn.Linear
def __init__(
self,
hidden_size: int,
intermediate_size: int,
swiglu_limit: float = 0.0,
) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class MoEGate(nn.Module):
weight: mx.array
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(
self, x: mx.array, input_ids: mx.array
) -> tuple[mx.array, mx.array]: ...
class DeepseekV4MoE(nn.Module):
num_experts_per_tok: int
switch_mlp: SwitchGLU
gate: MoEGate
shared_experts: DeepseekV4MLP
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(self, x: mx.array, input_ids: mx.array) -> mx.array: ...
class DeepseekV4Block(nn.Module):
attn_norm: nn.RMSNorm
attn: V4Attention
hc_attn: HyperConnection
ffn_norm: nn.RMSNorm
ffn: DeepseekV4MoE
hc_ffn: HyperConnection
def __init__(self, args: ModelArgs, layer_id: int) -> None: ...
def __call__(
self,
h: mx.array,
cache: Optional[Any],
input_ids: mx.array,
) -> mx.array: ...
class DeepseekV4Model(nn.Module):
args: ModelArgs
vocab_size: int
embed_tokens: nn.Embedding
layers: list[DeepseekV4Block]
norm: nn.RMSNorm
hc_head: HyperHead
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[List[Any]] = None,
) -> mx.array: ...
class Model(nn.Module):
args: ModelArgs
model_type: str
model: DeepseekV4Model
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[List[Any]] = None,
) -> mx.array: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
def make_cache(self) -> list[RotatingKVCache | DeepseekV4Cache]: ...
@property
def layers(self) -> list[DeepseekV4Block]: ...
+103
View File
@@ -0,0 +1,103 @@
"""Type stubs for mlx_lm.models.gpt_oss"""
from dataclasses import dataclass
from typing import Any, List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .cache import KVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
num_local_experts: int
num_experts_per_tok: int
vocab_size: int
rms_norm_eps: float
sliding_window: int
layer_types: Optional[List[str]]
def mlx_topk(a: mx.array, k: int, axis: int = -1) -> tuple[mx.array, mx.array]: ...
class AttentionBlock(nn.Module):
head_dim: int
num_attention_heads: int
num_key_value_heads: int
num_key_value_groups: int
sinks: mx.array
q_proj: nn.Linear
k_proj: nn.Linear
v_proj: nn.Linear
o_proj: nn.Linear
sm_scale: float
rope: nn.Module
def __init__(self, config: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class TransformerBlock(nn.Module):
self_attn: AttentionBlock
mlp: MLPBlock
def __init__(self, config: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class MLPBlock(nn.Module):
hidden_size: int
num_local_experts: int
num_experts_per_tok: int
experts: SwitchGLU
router: nn.Linear
sharding_group: Optional[mx.distributed.Group]
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class GptOssMoeModel(nn.Module):
embed_tokens: nn.Embedding
norm: nn.RMSNorm
layer_types: List[str]
layers: list[TransformerBlock]
window_size: int
swa_idx: int
ga_idx: int
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
class Model(nn.Module):
model_type: str
model: GptOssMoeModel
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
@property
def layers(self) -> list[nn.Module]: ...
def make_cache(self) -> list[KVCache]: ...
+94
View File
@@ -0,0 +1,94 @@
"""Type stubs for mlx_lm.models.minimax"""
from dataclasses import dataclass
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
num_local_experts: int
num_experts_per_tok: int
max_position_embeddings: int
class MiniMaxAttention(nn.Module):
num_heads: int
num_attention_heads: int
num_key_value_heads: int
head_dim: int
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
rope: nn.Module
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class MiniMaxSparseMoeBlock(nn.Module):
num_experts_per_tok: int
gate: nn.Linear
switch_mlp: SwitchGLU
e_score_correction_bias: mx.array
sharding_group: Optional[mx.distributed.Group]
def __init__(self, args: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
class MiniMaxDecoderLayer(nn.Module):
self_attn: MiniMaxAttention
block_sparse_moe: MiniMaxSparseMoeBlock
input_layernorm: nn.RMSNorm
post_attention_layernorm: nn.RMSNorm
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class MiniMaxModel(nn.Module):
embed_tokens: nn.Embedding
layers: list[MiniMaxDecoderLayer]
norm: nn.RMSNorm
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
class Model(nn.Module):
model_type: str
model: MiniMaxModel
lm_head: nn.Linear
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array: ...
@property
def layers(self) -> list[MiniMaxDecoderLayer]: ...
+14
View File
@@ -92,6 +92,15 @@ class NemotronHAttention(nn.Module):
cache: Optional[KVCache] = None,
) -> mx.array: ...
class MoEGate(nn.Module):
config: ModelArgs
top_k: int
norm_topk_prob: bool
weight: mx.array
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> tuple[mx.array, mx.array]: ...
class NemotronHMLP(nn.Module):
up_proj: nn.Linear
down_proj: nn.Linear
@@ -102,9 +111,14 @@ class NemotronHMLP(nn.Module):
def __call__(self, x: mx.array) -> mx.array: ...
class NemotronHMoE(nn.Module):
config: ModelArgs
num_experts_per_tok: int
moe_latent_size: Optional[int]
switch_mlp: SwitchMLP
gate: MoEGate
shared_experts: NemotronHMLP
fc1_latent_proj: nn.Linear
fc2_latent_proj: nn.Linear
def __init__(self, config: ModelArgs) -> None: ...
def __call__(self, x: mx.array) -> mx.array: ...
@@ -71,6 +71,7 @@ class Qwen3NextAttention(nn.Module):
class Qwen3NextSparseMoeBlock(nn.Module):
norm_topk_prob: bool
num_experts: int
num_experts_per_tok: int
top_k: int
gate: nn.Linear
switch_mlp: SwitchGLU
+8 -218
View File
@@ -16,22 +16,13 @@ struct ContentView: View {
@EnvironmentObject private var updater: SparkleUpdater
@EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
@EnvironmentObject private var settingsWindowController: SettingsWindowController
@EnvironmentObject private var bugReportWindowController: BugReportWindowController
@State private var focusedNode: NodeViewModel?
@State private var deletingInstanceIDs: Set<String> = []
@State private var showAllNodes = false
@State private var showAllInstances = false
@State private var baseURLCopied = false
@State private var showAdvanced = false
@State private var showDebugInfo = false
private enum BugReportPhase: Equatable {
case idle
case prompting
case sending(String)
case success(String)
case failure(String)
}
@State private var bugReportPhase: BugReportPhase = .idle
@State private var bugReportUserDescription: String = ""
@State private var uninstallInProgress = false
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@@ -294,6 +285,13 @@ struct ContentView: View {
) {
updater.checkForUpdates()
}
HoverButton(
title: "Share Bug Report…",
tint: .primary,
trailingSystemImage: "ladybug"
) {
bugReportWindowController.open()
}
.padding(.bottom, 8)
HoverButton(title: "Quit", tint: .secondary) {
controller.stop()
@@ -477,40 +475,6 @@ struct ContentView: View {
}
}
private var debugSection: some View {
VStack(alignment: .leading, spacing: 4) {
HoverButton(
title: "Debug Info",
tint: .primary,
trailingSystemImage: showDebugInfo ? "chevron.up" : "chevron.down",
small: true
) {
showDebugInfo.toggle()
}
if showDebugInfo {
VStack(alignment: .leading, spacing: 4) {
Text("Version: \(buildTag)")
.font(.caption2)
.foregroundColor(.secondary)
Text("Commit: \(buildCommit)")
.font(.caption2)
.foregroundColor(.secondary)
Text(thunderboltStatusText)
.font(.caption2)
.foregroundColor(thunderboltStatusColor)
clusterThunderboltBridgeView
interfaceIpList
rdmaStatusView
sendBugReportButton
.padding(.top, 6)
}
.padding(.leading, 8)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: showDebugInfo)
}
private var rdmaStatusView: some View {
let rdmaStatuses = stateService.latestSnapshot?.nodeRdmaCtl ?? [:]
let localNodeId = stateService.localNodeId
@@ -559,118 +523,6 @@ struct ContentView: View {
}
}
private var sendBugReportButton: some View {
VStack(alignment: .leading, spacing: 6) {
switch bugReportPhase {
case .idle:
Button {
bugReportPhase = .prompting
bugReportUserDescription = ""
} label: {
HStack {
Text("Send Bug Report")
.font(.caption)
.fontWeight(.semibold)
Spacer()
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.12))
)
}
.buttonStyle(.plain)
case .prompting:
VStack(alignment: .leading, spacing: 6) {
Text("What's the issue? (optional)")
.font(.caption2)
.foregroundColor(.secondary)
TextEditor(text: $bugReportUserDescription)
.font(.caption2)
.frame(height: 60)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
HStack(spacing: 8) {
Button("Send") {
Task {
await sendBugReport()
}
}
.font(.caption2)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Cancel") {
bugReportPhase = .idle
}
.font(.caption2)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.06))
)
case .sending(let message):
HStack(spacing: 6) {
ProgressView()
.scaleEffect(0.6)
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
}
case .success(let message):
VStack(alignment: .leading, spacing: 6) {
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
openGitHubIssue()
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.right.square")
.imageScale(.small)
Text("Create GitHub Issue")
.font(.caption2)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
Button("Done") {
bugReportPhase = .idle
bugReportUserDescription = ""
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
case .failure(let message):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.caption2)
.foregroundColor(.red)
.fixedSize(horizontal: false, vertical: true)
Button("Dismiss") {
bugReportPhase = .idle
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
}
}
.animation(.easeInOut(duration: 0.2), value: bugReportPhase)
}
private var processToggleBinding: Binding<Bool> {
Binding(
get: {
@@ -711,61 +563,6 @@ struct ContentView: View {
)
}
private func sendBugReport() async {
bugReportPhase = .sending("Collecting logs...")
let service = BugReportService()
let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let outcome = try await service.sendReport(
isManual: true,
userDescription: description.isEmpty ? nil : description
)
if outcome.success {
bugReportPhase = .success(outcome.message)
} else {
bugReportPhase = .failure(outcome.message)
}
} catch {
bugReportPhase = .failure(error.localizedDescription)
}
}
private func openGitHubIssue() {
let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
var bodyParts: [String] = []
bodyParts.append("## Describe the bug")
bodyParts.append("")
if !description.isEmpty {
bodyParts.append(description)
} else {
bodyParts.append("A clear and concise description of what the bug is.")
}
bodyParts.append("")
bodyParts.append("## Environment")
bodyParts.append("")
bodyParts.append("- macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
bodyParts.append("- EXO Version: \(buildTag) (\(buildCommit))")
bodyParts.append("")
bodyParts.append("## Additional context")
bodyParts.append("")
bodyParts.append("A bug report with diagnostic logs was submitted via the app.")
let body = bodyParts.joined(separator: "\n")
var components = URLComponents(string: "https://github.com/exo-explore/exo/issues/new")!
components.queryItems = [
URLQueryItem(name: "template", value: "bug_report.md"),
URLQueryItem(name: "title", value: "[BUG] "),
URLQueryItem(name: "body", value: body),
URLQueryItem(name: "labels", value: "bug"),
]
if let url = components.url {
NSWorkspace.shared.open(url)
}
}
private func showUninstallConfirmationAlert() {
let alert = NSAlert()
alert.messageText = "Uninstall EXO"
@@ -848,13 +645,6 @@ struct ContentView: View {
}
}
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
private var buildCommit: String {
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
}
}
private struct HoverButton: View {
+3
View File
@@ -22,6 +22,7 @@ struct EXOApp: App {
@StateObject private var updater: SparkleUpdater
@StateObject private var thunderboltBridgeService: ThunderboltBridgeService
@StateObject private var settingsWindowController: SettingsWindowController
@StateObject private var bugReportWindowController: BugReportWindowController
private let terminationObserver: TerminationObserver
private let firstLaunchPopout = FirstLaunchPopout()
private let ciContext = CIContext(options: nil)
@@ -46,6 +47,7 @@ struct EXOApp: App {
let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service)
_thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge)
_settingsWindowController = StateObject(wrappedValue: SettingsWindowController())
_bugReportWindowController = StateObject(wrappedValue: BugReportWindowController())
enableLaunchAtLoginIfNeeded()
// Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops)
NetworkSetupHelper.promptAndInstallIfNeeded()
@@ -66,6 +68,7 @@ struct EXOApp: App {
.environmentObject(updater)
.environmentObject(thunderboltBridgeService)
.environmentObject(settingsWindowController)
.environmentObject(bugReportWindowController)
} label: {
menuBarIcon
.onReceive(controller.$isFirstLaunchReady) { ready in
+1 -1
View File
@@ -9,7 +9,7 @@
<key>EXOBuildCommit</key>
<string>$(EXO_BUILD_COMMIT)</string>
<key>EXOBugReportPresignedUrlEndpoint</key>
<string>$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)</string>
<string>https://reports.exolabs.net/presigned-urls</string>
<key>NSLocalNetworkUsageDescription</key>
<string>EXO needs local network access to discover and connect to other devices in your cluster for distributed AI inference.</string>
<key>NSBonjourServices</key>
+18 -1
View File
@@ -17,7 +17,7 @@ final class ClusterStateService: ObservableObject {
init(
baseURL: URL = URL(string: "http://127.0.0.1:52415")!,
session: URLSession = .shared
session: URLSession = ClusterStateService.makeNonCachingSession()
) {
self.baseURL = baseURL
self.endpoint = baseURL.appendingPathComponent("state")
@@ -27,6 +27,23 @@ final class ClusterStateService: ObservableObject {
self.decoder = decoder
}
/// `URLSession.shared` carries an on-disk `URLCache` that persists every
/// response body under `~/Library/Caches/exolabs.EXO/`. We poll `/state`
/// at 2 Hz from `startPolling`, so leaving the shared cache attached
/// dirties ~500620 KB/sec of file-backed memory and trips macOS's
/// per-process `disk writes` resource limit (microstackshot reports
/// observed on M3 Ultra producing GBs of cached responses per hour).
/// Cluster-state polling responses are time-sensitive and small; they
/// gain nothing from being cached on disk. Use an ephemeral session
/// with `urlCache = nil` so neither response bodies nor metadata
/// touch disk.
private static func makeNonCachingSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
return URLSession(configuration: config)
}
func startPolling(interval: TimeInterval = 0.5) {
stopPolling()
Task {
@@ -0,0 +1,242 @@
import AppKit
import SwiftUI
/// Manages a standalone window for the bug-report flow.
/// Ensures only one instance exists and brings it to front on repeated opens.
@MainActor
final class BugReportWindowController: ObservableObject {
private var window: NSWindow?
func open() {
if let existing = window, existing.isVisible {
existing.makeKeyAndOrderFront(nil)
NSApp.activate()
return
}
let view = BugReportView(onDismiss: { [weak self] in
self?.window?.close()
})
let hostingController = NSHostingController(rootView: view)
hostingController.sizingOptions = [.preferredContentSize, .minSize]
let newWindow = NSWindow(contentViewController: hostingController)
newWindow.styleMask = [.titled, .closable, .resizable]
newWindow.title = "Send a Bug Report"
newWindow.center()
newWindow.setFrameAutosaveName("ExoBugReportWindow")
newWindow.isReleasedWhenClosed = false
newWindow.makeKeyAndOrderFront(nil)
NSApp.activate()
window = newWindow
}
}
private struct BugReportView: View {
fileprivate enum Phase: Equatable {
case prompting
case sending(String)
case success(String)
case failure(String)
}
let onDismiss: () -> Void
@State private var phase: Phase = .prompting
@State private var userDescription: String = ""
@FocusState private var descriptionFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
switch phase {
case .prompting:
promptingView
case .sending(let message):
sendingView(message: message)
case .success(let message):
successView(message: message)
case .failure(let message):
failureView(message: message)
}
}
.padding(16)
.frame(minWidth: 380)
.animation(.easeInOut(duration: 0.2), value: phase)
.onAppear { descriptionFocused = true }
}
private var promptingView: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Description (optional)")
.font(.subheadline)
.foregroundColor(.secondary)
ZStack(alignment: .topLeading) {
if userDescription.isEmpty {
Text("What were you doing when it broke?")
.font(.body)
.foregroundColor(Color(nsColor: .placeholderTextColor))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.allowsHitTesting(false)
}
TextEditor(text: $userDescription)
.font(.body)
.scrollContentBackground(.hidden)
.padding(4)
.frame(height: 72)
.focused($descriptionFocused)
}
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .textBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1)
)
Text("Diagnostic logs will be uploaded with your report.")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") { onDismiss() }
.keyboardShortcut(.cancelAction)
Button("Send") {
Task { await send() }
}
.keyboardShortcut(.defaultAction)
}
.padding(.top, 4)
}
}
private func sendingView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(message)
.foregroundColor(.secondary)
}
HStack {
Spacer()
Button("Cancel") { onDismiss() }
.keyboardShortcut(.cancelAction)
.disabled(true)
Button("Send") {}
.disabled(true)
}
}
}
private func successView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title2)
Text(message)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button {
openGitHubIssue()
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.right.square")
Text("Open GitHub Issue")
}
}
Spacer()
Button("Done") { onDismiss() }
.keyboardShortcut(.defaultAction)
}
}
}
private func failureView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.title2)
Text(message)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Try Again") {
phase = .prompting
}
Button("Close") { onDismiss() }
.keyboardShortcut(.defaultAction)
}
}
}
private func send() async {
phase = .sending("Collecting logs and uploading…")
let service = BugReportService()
let description = userDescription.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let outcome = try await service.sendReport(
isManual: true,
userDescription: description.isEmpty ? nil : description
)
if outcome.success {
phase = .success(outcome.message)
} else {
phase = .failure(outcome.message)
}
} catch {
phase = .failure(error.localizedDescription)
}
}
private func openGitHubIssue() {
let description = userDescription.trimmingCharacters(in: .whitespacesAndNewlines)
var bodyParts: [String] = []
bodyParts.append("## Describe the bug")
bodyParts.append("")
if !description.isEmpty {
bodyParts.append(description)
} else {
bodyParts.append("A clear and concise description of what the bug is.")
}
bodyParts.append("")
bodyParts.append("## Environment")
bodyParts.append("")
bodyParts.append("- macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
bodyParts.append("- EXO Version: \(buildTag) (\(buildCommit))")
bodyParts.append("")
bodyParts.append("## Additional context")
bodyParts.append("")
bodyParts.append("A bug report with diagnostic logs was submitted via the app.")
let body = bodyParts.joined(separator: "\n")
var components = URLComponents(string: "https://github.com/exo-explore/exo/issues/new")!
components.queryItems = [
URLQueryItem(name: "template", value: "bug_report.md"),
URLQueryItem(name: "title", value: "[BUG] "),
URLQueryItem(name: "body", value: body),
URLQueryItem(name: "labels", value: "bug"),
]
if let url = components.url {
NSWorkspace.shared.open(url)
}
}
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
private var buildCommit: String {
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
}
}
+30 -49
View File
@@ -21,8 +21,6 @@ struct SettingsView: View {
@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?
@State private var uninstallInProgress = false
var body: some View {
@@ -202,8 +200,6 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 2) {
rdmaStatusView
}
sendBugReportButton
}
Section("Danger Zone") {
@@ -504,63 +500,30 @@ struct SettingsView: View {
}
}
private var sendBugReportButton: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
Task {
await sendBugReport()
}
} label: {
HStack {
if bugReportInFlight {
ProgressView()
.scaleEffect(0.6)
}
Text("Send Bug Report")
.font(.caption)
.fontWeight(.semibold)
Spacer()
}
}
.disabled(bugReportInFlight)
if let message = bugReportMessage {
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Actions
private func sendBugReport() async {
bugReportInFlight = true
bugReportMessage = "Collecting logs..."
let service = BugReportService()
do {
let outcome = try await service.sendReport(isManual: true)
bugReportMessage = outcome.message
} catch {
bugReportMessage = error.localizedDescription
}
bugReportInFlight = false
}
private func showUninstallConfirmationAlert() {
let alert = NSAlert()
alert.messageText = "Uninstall EXO"
alert.informativeText = """
This will remove EXO and all its system components:
This will remove EXO and all its components:
• Network configuration daemon
• Launch at login registration
• EXO network location
• EXO data directory (~/.exo)
The app will be moved to Trash.
"""
alert.alertStyle = .warning
let checkbox = NSButton(
checkboxWithTitle: "Keep downloaded models (~/.exo/models)",
target: nil, action: nil)
checkbox.state = .off
checkbox.sizeToFit()
alert.accessoryView = checkbox
alert.addButton(withTitle: "Uninstall")
alert.addButton(withTitle: "Cancel")
@@ -570,11 +533,11 @@ struct SettingsView: View {
let response = alert.runModal()
if response == .alertFirstButtonReturn {
performUninstall()
performUninstall(keepModels: checkbox.state == .on)
}
}
private func performUninstall() {
private func performUninstall(keepModels: Bool) {
uninstallInProgress = true
controller.cancelPendingLaunch()
@@ -584,6 +547,7 @@ struct SettingsView: View {
DispatchQueue.global(qos: .utility).async {
do {
try NetworkSetupHelper.uninstall()
try Self.removeExoDirectory(keepModels: keepModels)
DispatchQueue.main.async {
LaunchAtLoginHelper.disable()
@@ -607,6 +571,23 @@ struct SettingsView: View {
}
}
private static func removeExoDirectory(keepModels: Bool) throws {
let fm = FileManager.default
let exoDir = ExoProcessController.exoDirectoryURL
guard fm.fileExists(atPath: exoDir.path) else { return }
if !keepModels {
try fm.removeItem(at: exoDir)
return
}
let contents = try fm.contentsOfDirectory(
at: exoDir, includingPropertiesForKeys: nil, options: [])
for entry in contents where entry.lastPathComponent != "models" {
try? fm.removeItem(at: entry)
}
}
private func moveAppToTrash() {
guard let appURL = Bundle.main.bundleURL as URL? else { return }
do {
+63 -7
View File
@@ -3,25 +3,55 @@
# EXO Uninstaller Script
#
# This script removes all EXO system components that persist after deleting the app.
# Run with: sudo ./uninstall-exo.sh
# Run with: sudo ./uninstall-exo.sh [--keep-models]
#
# Options:
# --keep-models Preserve ~/.exo/models when removing the EXO data directory.
#
# Components removed:
# - LaunchDaemon: /Library/LaunchDaemons/io.exo.networksetup.plist
# - Network script: /Library/Application Support/EXO/
# - Log files: /var/log/io.exo.networksetup.*
# - Network location: "exo"
# - EXO data directory: ~/.exo (or all of ~/.exo except models/ when --keep-models is set)
# - Launch at login registration
#
set -euo pipefail
KEEP_MODELS=0
for arg in "$@"; do
case "$arg" in
--keep-models)
KEEP_MODELS=1
;;
-h | --help)
echo "Usage: sudo ./uninstall-exo.sh [--keep-models]"
echo " --keep-models Preserve ~/.exo/models when removing the EXO data directory."
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: sudo ./uninstall-exo.sh [--keep-models]" >&2
exit 2
;;
esac
done
LABEL="io.exo.networksetup"
SCRIPT_DEST="/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh"
# Current script path. Older installs used a different filename; keep the
# legacy path here so a fresh uninstall still cleans up upgraded machines.
CURRENT_SCRIPT_DEST="/Library/Application Support/EXO/disable_bridge.sh"
LEGACY_SCRIPT_DEST="/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh"
PLIST_DEST="/Library/LaunchDaemons/io.exo.networksetup.plist"
LOG_OUT="/var/log/${LABEL}.log"
LOG_ERR="/var/log/${LABEL}.err.log"
APP_BUNDLE_ID="io.exo.EXO"
# Resolve the invoking user's home, even when run via sudo.
USER_HOME="$(eval echo "~${SUDO_USER:-$USER}")"
EXO_DIR="$USER_HOME/.exo"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -69,11 +99,17 @@ else
echo_warn "LaunchDaemon plist not found (already removed?)"
fi
# Remove the script and parent directory
if [[ -f $SCRIPT_DEST ]]; then
rm -f "$SCRIPT_DEST"
echo_info "Removed network setup script"
else
# Remove the script (current and legacy filenames) — backwards-compatible:
# tolerate either, both, or neither being present.
removed_any_script=0
for script in "$CURRENT_SCRIPT_DEST" "$LEGACY_SCRIPT_DEST"; do
if [[ -f $script ]]; then
rm -f "$script"
echo_info "Removed network setup script: $script"
removed_any_script=1
fi
done
if [[ $removed_any_script -eq 0 ]]; then
echo_warn "Network setup script not found (already removed?)"
fi
@@ -115,6 +151,22 @@ if networksetup -listnetworkservices 2>/dev/null | grep -q "Thunderbolt Bridge";
echo_info "Re-enabled Thunderbolt Bridge"
fi
# Remove EXO data directory (~/.exo)
EXO_DIR_REMOVED=""
if [[ -d $EXO_DIR ]]; then
if [[ $KEEP_MODELS == "1" && -d "$EXO_DIR/models" ]]; then
find "$EXO_DIR" -mindepth 1 -maxdepth 1 ! -name models -exec rm -rf {} +
EXO_DIR_REMOVED="kept_models"
echo_info "Removed ~/.exo (preserved models/)"
else
rm -rf "$EXO_DIR"
EXO_DIR_REMOVED="full"
echo_info "Removed ~/.exo"
fi
else
echo_warn "~/.exo not found (already removed?)"
fi
# Note about launch at login registration
# SMAppService-based login items cannot be removed from a shell script.
# They can only be unregistered from within the app itself or manually via System Settings.
@@ -144,6 +196,10 @@ echo " • Network setup LaunchDaemon"
echo " • Network configuration script"
echo " • Log files"
echo " • 'exo' network location"
case "$EXO_DIR_REMOVED" in
full) echo " • EXO data directory (~/.exo)" ;;
kept_models) echo " • EXO data directory (~/.exo, models preserved)" ;;
esac
echo ""
echo "Your network has been restored to use the 'Automatic' location."
echo "Thunderbolt Bridge has been re-enabled (if present)."
+77 -22
View File
@@ -7,7 +7,7 @@
# name, patterns, reasoning
#
# Optional per-model overrides (CLI flags take priority over these):
# temperature, top_p, max_tokens, reasoning_effort
# temperature, top_p, max_tokens, reasoning_effort, enable_thinking
#
# Fallback defaults (when no per-model config):
# reasoning: temperature=1.0, max_tokens=131072, reasoning_effort="high"
@@ -18,10 +18,9 @@
# ─── Qwen3.5 (Feb 2026) ─────────────────────────────────────────────
# Source: HuggingFace model cards (Qwen/Qwen3.5-*)
# 35B-A3B thinking general: temp=1.0, top_p=0.95, top_k=20
# 397B thinking: temp=0.6, top_p=0.95, top_k=20
# Non-thinking: temp=0.7, top_p=0.8, top_k=20
# max_tokens: 32768 general, 81920 for complex math/code
# Model card recommends: temp=0.6, top_p=0.95, top_k=20
# We omit top_k to match vllm eval (which doesn't set it).
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin).
[[model]]
name = "Qwen3.5 2B"
@@ -29,7 +28,8 @@ patterns = ["Qwen3.5-2B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 9B"
@@ -37,7 +37,8 @@ patterns = ["Qwen3.5-9B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 27B"
@@ -45,15 +46,17 @@ patterns = ["Qwen3.5-27B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 35B A3B"
patterns = ["Qwen3.5-35B-A3B"]
reasoning = true
temperature = 1.0
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 122B A10B"
@@ -61,7 +64,8 @@ patterns = ["Qwen3.5-122B-A10B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Qwen3.5 397B A17B"
@@ -69,12 +73,14 @@ patterns = ["Qwen3.5-397B-A17B"]
reasoning = true
temperature = 0.6
top_p = 0.95
max_tokens = 81920
enable_thinking = true
max_tokens = 121072
# ─── Qwen3 (Apr 2025) ───────────────────────────────────────────────
# Source: HuggingFace model cards (Qwen/Qwen3-*)
# Thinking: temp=0.6, top_p=0.95, top_k=20
# Non-thinking: temp=0.7, top_p=0.8, top_k=20
# Model card recommends: temp=0.6, top_p=0.95, top_k=20
# We omit top_k to match vllm eval (which doesn't set it).
# Non-thinking: temp=0.7, top_p=0.8
# max_tokens: 32768 general, 38912 for complex math/code
[[model]]
@@ -83,6 +89,7 @@ patterns = ["Qwen3-0.6B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -91,6 +98,7 @@ patterns = ["Qwen3-30B-A3B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -99,6 +107,7 @@ patterns = ["Qwen3-235B-A22B"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -107,6 +116,7 @@ patterns = ["Qwen3-Next-80B-A3B-Thinking"]
reasoning = true
temperature = 0.6
top_p = 0.95
enable_thinking = true
max_tokens = 38912
[[model]]
@@ -129,9 +139,9 @@ max_tokens = 16384
name = "Qwen3 Coder Next"
patterns = ["Qwen3-Coder-Next"]
reasoning = false
temperature = 0.7
top_p = 0.8
max_tokens = 16384
temperature = 1.0
top_p = 0.95
max_tokens = 121072
# ─── GPT-OSS (OpenAI) ───────────────────────────────────────────────
# Source: OpenAI GitHub README + HuggingFace discussion #21
@@ -165,10 +175,38 @@ patterns = ["DeepSeek-V3.1"]
reasoning = true
temperature = 0.0
[[model]]
name = "DeepSeek V3.2"
patterns = ["DeepSeek-V3.2"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
# ─── NVIDIA Nemotron ───────────────────────────────────────────────────
# Source: HuggingFace model cards
# All variants: temp=1.0, top_p=0.95, enable_thinking=true
[[model]]
name = "Nemotron Cascade 2 30B A3B"
patterns = ["Nemotron-Cascade-2-30B-A3B"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
[[model]]
name = "Nemotron 3 Super 120B A12B"
patterns = ["Nemotron-3-Super-120B-A12B", "NVIDIA-Nemotron-3-Super-120B-A12B"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
# ─── GLM (ZhipuAI / THUDM) ──────────────────────────────────────────
# Source: HuggingFace model cards + generation_config.json + docs.z.ai
# GLM 4.5+: temp=1.0, top_p=0.95
# Reasoning tasks: 131072 max_tokens; coding/SWE tasks: temp=0.7
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin)
[[model]]
name = "GLM-5"
@@ -176,7 +214,8 @@ patterns = ["GLM-5"]
reasoning = true
temperature = 1.0
top_p = 0.95
max_tokens = 131072
enable_thinking = true
max_tokens = 121072
[[model]]
name = "GLM 4.5 Air"
@@ -191,7 +230,8 @@ patterns = ["GLM-4.7-"]
reasoning = true
temperature = 1.0
top_p = 0.95
max_tokens = 131072
enable_thinking = true
max_tokens = 121072
# Note: matches both GLM-4.7 and GLM-4.7-Flash
# ─── Kimi (Moonshot AI) ─────────────────────────────────────────────
@@ -213,7 +253,8 @@ patterns = ["Kimi-K2.5"]
reasoning = true
temperature = 1.0
top_p = 0.95
max_tokens = 131072
enable_thinking = true
max_tokens = 121072
[[model]]
name = "Kimi K2 Instruct"
@@ -223,7 +264,17 @@ temperature = 0.6
# ─── MiniMax ─────────────────────────────────────────────────────────
# Source: HuggingFace model cards + generation_config.json
# All models: temp=1.0, top_p=0.95, top_k=40
# All models: temp=1.0, top_p=0.95
# max_tokens=90000 to match vllm eval (100000 context - 10000 safety margin)
[[model]]
name = "MiniMax M2.7"
patterns = ["MiniMax-M2.7"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 90000
[[model]]
name = "MiniMax M2.5"
@@ -231,6 +282,8 @@ patterns = ["MiniMax-M2.5"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 90000
[[model]]
name = "MiniMax M2.1"
@@ -251,6 +304,8 @@ patterns = ["Step-3.5-Flash"]
reasoning = true
temperature = 1.0
top_p = 0.95
enable_thinking = true
max_tokens = 121072
# ─── Llama (Meta) ───────────────────────────────────────────────────
# Source: generation_config.json + meta-llama/llama-models generation.py
+82 -37
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import sys
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
@@ -209,7 +211,7 @@ def _openai_build_request(
"model": model,
"messages": messages,
"tools": tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
return "/v1/chat/completions", body
@@ -276,7 +278,7 @@ def _openai_build_followup(
"model": model,
"messages": followup_messages,
"tools": tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
return "/v1/chat/completions", body
@@ -379,7 +381,7 @@ def _claude_build_request(
"model": model,
"messages": claude_messages,
"tools": claude_tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
if system_content is not None:
@@ -489,7 +491,7 @@ def _claude_build_followup(
"model": model,
"messages": claude_messages,
"tools": claude_tools,
"max_tokens": 16384,
"max_tokens": 4096,
"temperature": 0.0,
}
if system_content is not None:
@@ -913,6 +915,12 @@ Examples:
default=1,
help="Repeat each scenario N times (default: 1)",
)
parser.add_argument(
"--concurrency",
type=int,
default=1,
help="Run up to N scenarios in parallel against the same instance (default: 1)",
)
parser.add_argument(
"--scenarios",
nargs="*",
@@ -935,6 +943,13 @@ Examples:
)
args = parser.parse_args()
if args.concurrency < 1:
print(
f"--concurrency must be >= 1 (got {args.concurrency})",
file=sys.stderr,
)
sys.exit(2)
all_scenarios = load_scenarios(SCENARIOS_PATH)
if args.scenarios:
scenarios = [s for s in all_scenarios if s.name in args.scenarios]
@@ -1010,42 +1025,72 @@ Examples:
cluster_snapshot = capture_cluster_snapshot(exo)
all_results: list[ScenarioResult] = []
tasks: list[tuple[int, Scenario, ApiName]] = [
(run_idx, scenario, api_name)
for run_idx in range(args.repeat)
for scenario in scenarios
for api_name in api_names
]
def _run_one(
http_client: httpx.Client,
task: tuple[int, Scenario, ApiName],
) -> tuple[tuple[int, Scenario, ApiName], list[ScenarioResult], str]:
run_idx, scenario, api_name = task
buf = io.StringIO()
run_tag = f"[run {run_idx + 1}/{args.repeat}]" if args.repeat > 1 else ""
print(
f"\n {run_tag}[{api_name:>9}] {scenario.name}: {scenario.description}",
file=buf,
)
scenario_results = run_scenario(
http_client,
args.host,
args.port,
full_model_id,
scenario,
api_name,
args.timeout,
args.verbose,
)
for r in scenario_results:
status = "PASS" if r.passed else "FAIL"
print(
f" [{r.phase:>10}] {status} ({r.latency_ms:.0f}ms)",
file=buf,
)
for check_name, check_ok in r.checks.items():
mark = "+" if check_ok else "-"
print(f" {mark} {check_name}", file=buf)
if r.error:
print(f" ! {r.error}", file=buf)
return task, scenario_results, buf.getvalue()
try:
with httpx.Client() as http_client:
for run_idx in range(args.repeat):
if args.repeat > 1:
print(f"\n--- Run {run_idx + 1}/{args.repeat} ---", file=log)
for scenario in scenarios:
for api_name in api_names:
print(
f"\n [{api_name:>9}] {scenario.name}: {scenario.description}",
file=log,
)
scenario_results = run_scenario(
http_client,
args.host,
args.port,
full_model_id,
scenario,
api_name,
args.timeout,
args.verbose,
)
if args.concurrency == 1:
current_run = -1
for task in tasks:
run_idx = task[0]
if args.repeat > 1 and run_idx != current_run:
print(f"\n--- Run {run_idx + 1}/{args.repeat} ---", file=log)
current_run = run_idx
_, scenario_results, buffered = _run_one(http_client, task)
all_results.extend(scenario_results)
log.write(buffered)
log.flush()
else:
print(
f"Running {len(tasks)} tasks with concurrency={args.concurrency}",
file=log,
)
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futures = [pool.submit(_run_one, http_client, t) for t in tasks]
for fut in as_completed(futures):
_, scenario_results, buffered = fut.result()
all_results.extend(scenario_results)
for r in scenario_results:
status = "PASS" if r.passed else "FAIL"
print(
f" [{r.phase:>10}] {status} ({r.latency_ms:.0f}ms)",
file=log,
)
for check_name, check_ok in r.checks.items():
mark = "+" if check_ok else "-"
print(f" {mark} {check_name}", file=log)
if r.error:
print(f" ! {r.error}", file=log)
log.write(buffered)
log.flush()
finally:
try:
exo.request_json("DELETE", f"/instance/{instance_id}")
+259 -101
View File
@@ -35,6 +35,7 @@ from harness import (
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
@@ -79,7 +80,7 @@ def load_tokenizer_for_bench(model_id: str) -> Any:
model_path = Path(
snapshot_download(
model_id,
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model"],
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model", "*.jinja"],
)
)
@@ -122,8 +123,48 @@ def load_tokenizer_for_bench(model_id: str) -> Any:
return hf_tokenizer
# Default: use AutoTokenizer
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
# TODO: Change back to using only transformers
try:
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
except (AttributeError, ValueError):
from huggingface_hub import snapshot_download
from transformers import PretrainedConfig
model_path = Path(
snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
"*.jinja",
],
)
)
stub_kwargs: dict[str, Any] = {}
config_file = model_path / "config.json"
if config_file.exists():
with open(config_file) as f:
raw = json.load(f)
for key in (
"model_type",
"max_position_embeddings",
"vocab_size",
"bos_token_id",
"eos_token_id",
"pad_token_id",
):
if key in raw:
stub_kwargs[key] = raw[key]
return AutoTokenizer.from_pretrained(
str(model_path),
config=PretrainedConfig(**stub_kwargs),
trust_remote_code=True,
)
def format_peak_memory(b: float) -> str:
@@ -237,28 +278,76 @@ def run_one_completion(
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: bool = False,
) -> tuple[dict[str, Any], int]:
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
# Argmax sampling for deterministic, faster decode (matches
# mlx_lm.benchmark default). Avoids per-token softmax + categorical
# sample over the full vocab.
"temperature": 0.0,
}
t0 = time.perf_counter()
out = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
if not stream:
payload["stream"] = False
t0 = time.perf_counter()
out = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
stats = out.get("generation_stats")
choices = out.get("choices") or [{}]
message = choices[0].get("message", {}) if choices else {}
content = message.get("content") or ""
preview = content[:200] if content else ""
else:
tokens = 0
first_token_time = None
t0 = time.perf_counter()
text_parts: list[str] = []
stats = None
# Extract preview, handling None content (common for thinking models)
choices = out.get("choices") or [{}]
message = choices[0].get("message", {}) if choices else {}
content = message.get("content") or ""
preview = content[:200] if content else ""
for raw_line in client.stream_bench_chat_completions(payload):
line = raw_line.strip()
if line.startswith(": generation_stats "):
with contextlib.suppress(json.JSONDecodeError):
stats = json.loads(line[len(": generation_stats ") :])
continue
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
if delta.get("content"):
if first_token_time is None:
first_token_time = time.perf_counter()
tokens += 1
text_parts.append(delta["content"])
except json.JSONDecodeError:
pass
elapsed = time.perf_counter() - t0
preview = "".join(text_parts)[:200]
if not stats:
ttft = (first_token_time - t0) if first_token_time else elapsed
gen_time = elapsed - ttft if tokens > 1 else elapsed
gen_tps = (tokens - 1) / gen_time if tokens > 1 and gen_time > 0 else 0.0
prompt_tps = pp_tokens / ttft if ttft > 0 else 0.0
stats = {
"prompt_tokens": pp_tokens,
"generation_tokens": tokens,
"prompt_tps": round(prompt_tps, 2),
"generation_tps": round(gen_tps, 2),
"peak_memory_usage": {"inBytes": 0},
}
return {
"elapsed_s": elapsed,
@@ -278,9 +367,19 @@ class PromptSizer:
def _make_counter(tokenizer: Any) -> Callable[[str], int]:
def count_fn(user_content: str) -> int:
messages = [{"role": "user", "content": user_content}]
ids = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
try:
ids = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
except ValueError:
# Models without a Jinja chat template (e.g. DeepSeek V4 which
# ships its own Python encoder). Use the exo-side V4 encoder.
from exo.worker.engines.mlx.deepseek_v4_encoding import (
encode_messages as encode_v4,
)
prompt = encode_v4(messages, thinking_mode="thinking")
ids = tokenizer.encode(prompt, add_special_tokens=False)
# Fix for transformers 5.x
if hasattr(ids, "input_ids"):
ids = ids.input_ids
@@ -375,6 +474,11 @@ def main() -> int:
action="store_true",
help="Force all pp×tg combinations (cartesian product) even when lists have equal length.",
)
ap.add_argument(
"--stream",
action="store_true",
help="Use /bench/chat/completions with streaming SSE response (bench=True still applies: no EOS detection, no KV cache).",
)
ap.add_argument(
"--no-system-metrics",
action="store_true",
@@ -440,81 +544,124 @@ def main() -> int:
logger.error("[exo-bench] tokenizer usable but prompt sizing failed")
raise
selected = settle_and_fetch_placements(
client, full_model_id, args, settle_timeout=args.settle_timeout
)
# Optionally reuse a running instance for this model
reused_instance_id: str | None = None
if args.reuse_instance:
existing = find_existing_instance(client, full_model_id)
if existing:
reused_instance_id = existing
logger.info(f"Reusing existing instance {reused_instance_id}")
else:
logger.warning(
"--reuse-instance: no existing instance found, creating a new one"
)
if not selected:
logger.error("No valid placements matched your filters.")
return 1
selected.sort(
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
-nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
logger.debug(f"exo-bench model: short_id={short_id} full_id={full_model_id}")
logger.info(f"placements: {len(selected)}")
for p in selected:
logger.info(
f" - {p['sharding']} / {p['instance_meta']} / nodes={nodes_used_in_instance(p['instance'])}"
if reused_instance_id is not None:
# Use the existing instance directly — skip placement iteration
selected = []
download_duration_s = None
else:
selected = settle_and_fetch_placements(
client, full_model_id, args, settle_timeout=args.settle_timeout
)
if args.dry_run:
return 0
if not selected:
logger.error("No valid placements matched your filters.")
return 1
settle_deadline = (
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
)
selected.sort(
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
logger.info("Planning phase: checking downloads...")
download_duration_s = run_planning_phase(
client,
full_model_id,
selected[0],
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if download_duration_s is not None:
logger.info(f"Download: {download_duration_s:.1f}s (freshly downloaded)")
else:
logger.info("Download: model already cached")
logger.debug(f"exo-bench model: short_id={short_id} full_id={full_model_id}")
logger.info(f"placements: {len(selected)}")
for p in selected:
logger.info(
f" - {p['sharding']} / {p['instance_meta']} / nodes={nodes_used_in_instance(p['instance'])}"
)
if args.dry_run:
return 0
settle_deadline = (
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
)
logger.info("Planning phase: checking downloads...")
download_duration_s = run_planning_phase(
client,
full_model_id,
selected[0],
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if download_duration_s is not None:
logger.info(f"Download: {download_duration_s:.1f}s (freshly downloaded)")
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]]] = {}
# If reusing an existing instance, run a single benchmark pass against it
if reused_instance_id is not None:
selected = [None]
for preview in selected:
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
created_instance = False
if preview is not None:
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
sharding = str(preview["sharding"])
instance_meta = str(preview["instance_meta"])
n_nodes = nodes_used_in_instance(instance)
sharding = str(preview["sharding"])
instance_meta = str(preview["instance_meta"])
n_nodes = nodes_used_in_instance(instance)
logger.info("=" * 80)
logger.info(
f"PLACEMENT: {sharding} / {instance_meta} / nodes={n_nodes} / instance_id={instance_id}"
)
logger.info("=" * 80)
logger.info(
f"PLACEMENT: {sharding} / {instance_meta} / nodes={n_nodes} / instance_id={instance_id}"
)
client.request_json("POST", "/instance", body={"instance": instance})
try:
wait_for_instance_ready(client, instance_id)
except (RuntimeError, TimeoutError) as e:
logger.error(f"Failed to initialize placement: {e}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
continue
# Delete any existing instances to free resources before placing
try:
state = client.request_json("GET", "/state")
for old_id in list(state.get("instances", {}).keys()):
logger.info(f"Deleting stale instance {old_id}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{old_id}")
if state.get("instances"):
time.sleep(2)
except Exception as e:
logger.warning(f"Failed to clean up stale instances: {e}")
time.sleep(1)
client.request_json("POST", "/instance", body={"instance": instance})
try:
wait_for_instance_ready(client, instance_id)
except (RuntimeError, TimeoutError) as e:
logger.error(f"Failed to initialize placement: {e}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
continue
time.sleep(1)
created_instance = True
else:
instance_id = reused_instance_id
sharding = "reused"
instance_meta = "reused"
n_nodes = 0
logger.info("=" * 80)
logger.info(f"Using existing instance {instance_id}")
sampler: SystemMetricsSampler | None = None
if not args.no_system_metrics:
if not args.no_system_metrics and preview is not None:
nids = node_ids_from_instance(instance)
sampler = SystemMetricsSampler(
ExoClient(args.host, args.port, timeout_s=30),
@@ -523,16 +670,20 @@ def main() -> int:
)
sampler.start()
def _do_one(c: ExoClient, pp: int, tg: int) -> tuple[dict[str, Any], int]:
return run_one_completion(
c,
full_model_id,
pp,
tg,
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
stream=args.stream,
)
try:
for i in range(args.warmup):
run_one_completion(
client,
full_model_id,
pp_list[0],
tg_list[0],
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
_do_one(client, pp_list[0], tg_list[0])
logger.debug(f" warmup {i + 1}/{args.warmup} done")
# If pp and tg lists have same length, run in tandem (zip)
@@ -554,14 +705,7 @@ def main() -> int:
# Sequential: single request
try:
inf_t0 = time.monotonic()
row, actual_pp_tokens = run_one_completion(
client,
full_model_id,
pp,
tg,
prompt_sizer,
use_prefix_cache=args.use_prefix_cache,
)
row, actual_pp_tokens = _do_one(client, pp, tg)
inference_windows.append((inf_t0, time.monotonic()))
except Exception as e:
logger.error(e)
@@ -587,6 +731,15 @@ def main() -> int:
)
runs.append(row)
all_rows.append(row)
# Per-repeat trial log so individual numbers are visible
# alongside the final averaged summary. Useful for
# spotting outliers and trial-to-trial variance.
_s = row.get("stats") or {}
logger.info(
f" repeat {r + 1}/{args.repeat}: "
f"prompt_tps={_s.get('prompt_tps', 0):.2f} "
f"gen_tps={_s.get('generation_tps', 0):.2f}"
)
else:
# Concurrent: fire N requests in parallel
# Pre-build prompt once, barrier ensures simultaneous dispatch
@@ -598,6 +751,8 @@ def main() -> int:
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": args.use_prefix_cache,
# Argmax sampling — matches mlx_lm.benchmark default
"temperature": 0.0,
}
barrier = threading.Barrier(concurrency)
batch_start = threading.Event()
@@ -710,10 +865,12 @@ def main() -> int:
gen_tps = per_req_tps * concurrency
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
peak = mean(
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
)
def _peak_bytes(s: dict[str, Any]) -> float:
pm = s["peak_memory_usage"]
return pm.get("inBytes") or pm.get("in_bytes", 0)
peak = mean(_peak_bytes(x["stats"]) for x in runs)
summary = (
f"prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
@@ -738,15 +895,16 @@ def main() -> int:
if placement_metrics:
all_system_metrics.update(placement_metrics)
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
wait_for_instance_gone(client, instance_id)
logger.debug(f"Deleted instance {instance_id}")
if created_instance and instance_id is not None:
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
wait_for_instance_gone(client, instance_id)
logger.debug(f"Deleted instance {instance_id}")
time.sleep(5)
time.sleep(5)
output: dict[str, Any] = {"runs": all_rows}
if cluster_snapshot:
+427 -56
View File
@@ -47,6 +47,7 @@ from harness import (
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -62,6 +63,15 @@ from loguru import logger
# ---------------------------------------------------------------------------
MAX_RETRIES = 30
INSTANCE_HEALTH_CHECK_AFTER = (
3 # Check instance health after this many consecutive failures
)
class InstanceFailedError(RuntimeError):
"""Raised when the exo instance is detected as failed/gone."""
DEFAULT_MAX_TOKENS = 16_384
REASONING_MAX_TOKENS = 131_072
TEMPERATURE_NON_REASONING = 0.0
@@ -271,7 +281,7 @@ def run_humaneval_test(
@dataclass
class QuestionResult:
question_id: int
question_id: int | str
prompt: str
response: str
extracted_answer: str | None
@@ -281,7 +291,11 @@ class QuestionResult:
prompt_tokens: int = 0
completion_tokens: int = 0
reasoning_tokens: int = 0
reasoning_content: str = ""
finish_reason: str = ""
elapsed_s: float = 0.0
power_watts: float = 0.0
energy_joules: float = 0.0
@dataclass
@@ -517,6 +531,10 @@ class ApiResult:
prompt_tokens: int
completion_tokens: int
reasoning_tokens: int
reasoning_content: str = ""
finish_reason: str = ""
power_watts: float = 0.0
energy_joules: float = 0.0
async def _call_api(
@@ -530,6 +548,9 @@ async def _call_api(
system_message: str | None = None,
reasoning_effort: str | None = None,
top_p: float | None = None,
top_k: int | None = None,
min_p: float | None = None,
enable_thinking: bool | None = None,
) -> ApiResult:
messages = []
if system_message:
@@ -546,6 +567,12 @@ async def _call_api(
body["reasoning_effort"] = reasoning_effort
if top_p is not None:
body["top_p"] = top_p
if top_k is not None:
body["top_k"] = top_k
if min_p is not None:
body["min_p"] = min_p
if enable_thinking is not None:
body["enable_thinking"] = enable_thinking
resp = await client.post(
f"{base_url}/v1/chat/completions",
@@ -554,19 +581,40 @@ async def _call_api(
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
if not content or not content.strip():
choice = data["choices"][0]
message = choice["message"]
content = message.get("content") or ""
reasoning_content = message.get("reasoning_content") or ""
finish_reason = choice.get("finish_reason") or ""
# For thinking models, empty content is expected when finish_reason is "length"
if not content.strip() and finish_reason != "length" and not reasoning_content:
raise ValueError("Empty response from model")
usage = data.get("usage", {})
details = usage.get("completion_tokens_details", {})
power = data.get("power_usage") or {}
return ApiResult(
content=content,
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
reasoning_tokens=details.get("reasoning_tokens", 0) if details else 0,
reasoning_content=reasoning_content,
finish_reason=finish_reason,
power_watts=power.get("total_avg_sys_power_watts", 0.0),
energy_joules=power.get("total_energy_joules", 0.0),
)
async def _check_instance_health(base_url: str) -> bool:
"""Return True if the exo instance is still reachable."""
try:
async with httpx.AsyncClient() as c:
resp = await c.get(f"{base_url}/models", timeout=5.0)
return resp.status_code == 200
except Exception:
return False
async def call_with_retries(
client: httpx.AsyncClient,
base_url: str,
@@ -578,8 +626,14 @@ async def call_with_retries(
system_message: str | None = None,
reasoning_effort: str | None = None,
top_p: float | None = None,
top_k: int | None = None,
min_p: float | None = None,
enable_thinking: bool | None = None,
instance_failed: asyncio.Event | None = None,
) -> ApiResult | None:
for attempt in range(MAX_RETRIES):
if instance_failed and instance_failed.is_set():
raise InstanceFailedError("Instance already marked as failed")
try:
return await _call_api(
client,
@@ -592,8 +646,30 @@ async def call_with_retries(
system_message,
reasoning_effort,
top_p,
top_k,
min_p,
enable_thinking,
)
except Exception as e:
is_conn_error = isinstance(
e,
(
httpx.ConnectError,
httpx.RemoteProtocolError,
ConnectionRefusedError,
OSError,
),
)
if (
is_conn_error
and attempt >= INSTANCE_HEALTH_CHECK_AFTER
and not await _check_instance_health(base_url)
):
if instance_failed:
instance_failed.set()
raise InstanceFailedError(
f"Instance is down after {attempt + 1} failures: {e}"
) from e
if attempt < MAX_RETRIES - 1:
wait = min(2**attempt, 60)
logger.warning(
@@ -618,10 +694,16 @@ async def evaluate_benchmark(
max_tokens: int,
concurrency: int = 1,
limit: int | None = None,
offset: int = 0,
timeout: float | None = None,
reasoning_effort: str | None = None,
top_p: float | None = None,
top_k: int | None = None,
min_p: float | None = None,
enable_thinking: bool | None = None,
difficulty: str | None = None,
checkpoint_path: Path | None = None,
release_version: str | None = None,
) -> list[QuestionResult]:
"""Run a benchmark. Returns per-question results."""
import datasets
@@ -652,7 +734,21 @@ async def evaluate_benchmark(
ds = ds.filter(lambda x: x["difficulty"] == difficulty)
logger.info(f"Filtered to {len(ds)} {difficulty} problems")
if release_version and "release_version" in ds.column_names:
ds = ds.filter(lambda x: x["release_version"] == release_version)
logger.info(
f"Filtered to {len(ds)} problems with release_version={release_version}"
)
# Sort by question_id to match LCB runner ordering (scenario_router.py:60).
# This ensures [offset:offset+limit] slices select the same problems as vllm.
if "question_id" in ds.column_names:
ds = ds.sort("question_id")
total = len(ds)
if offset > 0:
ds = ds.select(range(min(offset, total), total))
total = len(ds)
if limit and limit < total:
ds = ds.select(range(limit))
total = limit
@@ -660,6 +756,13 @@ async def evaluate_benchmark(
logger.info(
f"Evaluating {benchmark_name}: {total} questions, concurrency={concurrency}, "
f"temperature={temperature}, max_tokens={max_tokens}"
+ (f", top_k={top_k}" if top_k is not None else "")
+ (f", min_p={min_p}" if min_p is not None else "")
+ (
f", enable_thinking={enable_thinking}"
if enable_thinking is not None
else ""
)
)
if config.kind == "code":
@@ -667,16 +770,64 @@ async def evaluate_benchmark(
"Code benchmarks execute model-generated code. Use a sandboxed environment."
)
# Load checkpoint for resume
checkpoint_data: dict[str | int, dict[str, Any]] = {}
if checkpoint_path and checkpoint_path.exists():
with open(checkpoint_path) as f:
for line in f:
entry = json.loads(line)
checkpoint_data[entry["question_id"]] = entry
logger.info(f"Loaded {len(checkpoint_data)} checkpointed results")
semaphore = asyncio.Semaphore(concurrency)
instance_failed = asyncio.Event()
results: list[QuestionResult | None] = [None] * total
completed = 0
lock = asyncio.Lock()
def _get_question_id(idx: int, doc: dict) -> str | int:
"""Get a stable question ID for checkpointing."""
if benchmark_name == "livecodebench":
return doc.get("question_id", idx)
elif benchmark_name == "humaneval":
return doc.get("task_id", idx)
return idx
async def process_question(
idx: int, doc: dict, http_client: httpx.AsyncClient
) -> None:
nonlocal completed
system_msg = None
question_id = _get_question_id(idx, doc)
# Bail out early if instance is already dead
if instance_failed.is_set():
return
# Check checkpoint
if question_id in checkpoint_data:
cached = checkpoint_data[question_id]
results[idx] = QuestionResult(
question_id=question_id,
prompt=cached.get("prompt", ""),
response=cached.get("response", ""),
extracted_answer=cached.get("extracted_answer"),
gold_answer=cached.get("gold_answer", ""),
correct=cached.get("correct", False),
error=cached.get("error"),
prompt_tokens=cached.get("prompt_tokens", 0),
completion_tokens=cached.get("completion_tokens", 0),
reasoning_tokens=cached.get("reasoning_tokens", 0),
reasoning_content=cached.get("reasoning_content", ""),
finish_reason=cached.get("finish_reason", ""),
elapsed_s=cached.get("elapsed_s", 0.0),
power_watts=cached.get("power_watts", 0.0),
energy_joules=cached.get("energy_joules", 0.0),
)
async with lock:
completed += 1
logger.info(f" [{completed}/{total}] {question_id} (cached)")
return
if benchmark_name == "gpqa_diamond":
prompt, gold = format_gpqa_question(doc, idx)
@@ -697,24 +848,50 @@ async def evaluate_benchmark(
raise ValueError(f"Unknown benchmark: {benchmark_name}")
async with semaphore:
if instance_failed.is_set():
return
t0 = time.monotonic()
api_result = await call_with_retries(
http_client,
base_url,
model,
prompt,
temperature,
max_tokens,
timeout,
system_message=system_msg,
reasoning_effort=reasoning_effort,
top_p=top_p,
)
try:
# Race the API call against the instance_failed event
api_task = asyncio.create_task(
call_with_retries(
http_client,
base_url,
model,
prompt,
temperature,
max_tokens,
timeout,
system_message=system_msg,
reasoning_effort=reasoning_effort,
top_p=top_p,
top_k=top_k,
min_p=min_p,
enable_thinking=enable_thinking,
instance_failed=instance_failed,
)
)
failed_waiter = asyncio.create_task(instance_failed.wait())
done, pending = await asyncio.wait(
[api_task, failed_waiter],
return_when=asyncio.FIRST_COMPLETED,
)
for p in pending:
p.cancel()
with contextlib.suppress(asyncio.CancelledError):
await p
if instance_failed.is_set() and api_task not in done:
logger.error(f"Instance failed, aborting {question_id}")
return
api_result = api_task.result()
except InstanceFailedError:
logger.error(f"Instance failed, skipping {question_id}")
return
elapsed = time.monotonic() - t0
if api_result is None:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response="",
extracted_answer=None,
@@ -729,13 +906,17 @@ async def evaluate_benchmark(
"prompt_tokens": api_result.prompt_tokens,
"completion_tokens": api_result.completion_tokens,
"reasoning_tokens": api_result.reasoning_tokens,
"reasoning_content": api_result.reasoning_content,
"finish_reason": api_result.finish_reason,
"elapsed_s": elapsed,
"power_watts": api_result.power_watts,
"energy_joules": api_result.energy_joules,
}
if config.kind == "mc":
extracted = extract_mc_answer(response, valid_letters)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=extracted,
@@ -749,7 +930,7 @@ async def evaluate_benchmark(
check_aime_answer(extracted, int(gold)) if extracted else False
)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=extracted,
@@ -763,7 +944,7 @@ async def evaluate_benchmark(
code = extract_code_block(response, preserve_indent=keep_indent)
if code is None:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -778,7 +959,7 @@ async def evaluate_benchmark(
code,
)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer="pass" if passed else "fail",
@@ -793,7 +974,7 @@ async def evaluate_benchmark(
exec_meta["sample"],
)
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer="pass" if passed else "fail",
@@ -804,7 +985,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -815,7 +996,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -827,24 +1008,82 @@ async def evaluate_benchmark(
results[idx] = result
# Write checkpoint (skip infra failures so they get retried on resume,
# but keep wrong answers — they are legitimate results)
if checkpoint_path is not None and result.response:
_write_checkpoint(checkpoint_path, result)
async with lock:
completed += 1
n = completed
if n % max(1, total // 20) == 0 or n == total:
correct_so_far = sum(1 for r in results if r is not None and r.correct)
answered = sum(1 for r in results if r is not None)
logger.info(
f" [{n}/{total}] {correct_so_far}/{answered} correct "
f"({correct_so_far / max(answered, 1):.1%})"
)
# Log progress
thinking_info = ""
if result.reasoning_content:
thinking_info = f", {len(result.reasoning_content)} chars thinking"
logger.info(
f" [{n}/{total}] {question_id}: {len(result.response)} chars{thinking_info}, "
f"tokens: {result.prompt_tokens}+{result.completion_tokens} "
f"[{result.finish_reason}]"
+ (f" {result.extracted_answer}" if result.extracted_answer else "")
)
async def _health_monitor() -> None:
"""Periodically check if the instance is still alive."""
# Wait a bit before first check to let things start
await asyncio.sleep(10)
while not instance_failed.is_set():
if not await _check_instance_health(base_url):
# Double-check to avoid false positives
await asyncio.sleep(2)
if not await _check_instance_health(base_url):
logger.error("Health monitor: instance is down!")
instance_failed.set()
return
await asyncio.sleep(5)
async with httpx.AsyncClient() as http_client:
monitor = asyncio.create_task(_health_monitor())
tasks = [process_question(i, doc, http_client) for i, doc in enumerate(ds)]
await asyncio.gather(*tasks)
monitor.cancel()
with contextlib.suppress(asyncio.CancelledError):
await monitor
if instance_failed.is_set():
completed_count = sum(1 for r in results if r is not None)
logger.error(
f"Instance failed! Completed {completed_count}/{total} problems. "
f"Checkpoint saved — restart to resume remaining problems."
)
raise InstanceFailedError("Instance failed during evaluation")
return [r for r in results if r is not None]
def _write_checkpoint(path: Path, result: QuestionResult) -> None:
"""Append a single result to the JSONL checkpoint file."""
entry = {
"question_id": result.question_id,
"prompt": result.prompt,
"response": result.response,
"extracted_answer": result.extracted_answer,
"gold_answer": result.gold_answer,
"correct": result.correct,
"error": result.error,
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
"reasoning_tokens": result.reasoning_tokens,
"reasoning_content": result.reasoning_content,
"finish_reason": result.finish_reason,
"elapsed_s": round(result.elapsed_s, 2),
"power_watts": round(result.power_watts, 2),
"energy_joules": round(result.energy_joules, 2),
}
with open(path, "a") as f:
f.write(json.dumps(entry) + "\n")
# ---------------------------------------------------------------------------
# Results display
# ---------------------------------------------------------------------------
@@ -867,6 +1106,8 @@ def print_results(
total_elapsed = sum(r.elapsed_s for r in results)
wall_clock = max(r.elapsed_s for r in results) if results else 0.0
avg_gen_tps = total_completion_tokens / total_elapsed if total_elapsed > 0 else 0.0
total_energy = sum(r.energy_joules for r in results)
avg_power = sum(r.power_watts for r in results) / max(total, 1)
label = f"[c={concurrency}] " if concurrency is not None else ""
print(f"\n{label}{benchmark_name}: {correct}/{total} ({accuracy:.1%})")
@@ -878,6 +1119,10 @@ def print_results(
f" | total time: {total_elapsed:.1f}s wall clock: {wall_clock:.1f}s"
)
print(tok_line)
if total_energy > 0:
print(
f" power: avg {avg_power:.1f}W | total energy: {total_energy:.1f}J ({total_energy / 3600:.2f}Wh)"
)
if errors:
print(f" API errors: {errors}")
if no_extract:
@@ -896,6 +1141,8 @@ def print_results(
"total_elapsed_s": total_elapsed,
"wall_clock_s": wall_clock,
"avg_gen_tps": avg_gen_tps,
"avg_power_watts": avg_power,
"total_energy_joules": total_energy,
}
@@ -1053,7 +1300,11 @@ def save_results(
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"reasoning_tokens": r.reasoning_tokens,
"reasoning_content": r.reasoning_content,
"finish_reason": r.finish_reason,
"elapsed_s": round(r.elapsed_s, 2),
"power_watts": round(r.power_watts, 2),
"energy_joules": round(r.energy_joules, 2),
}
for r in results
],
@@ -1069,6 +1320,15 @@ def save_results(
# ---------------------------------------------------------------------------
def _checkpoint_path(
results_dir: str, benchmark: str, model: str, concurrency: int
) -> Path:
"""Return the JSONL checkpoint path for a benchmark run."""
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark
out_dir.mkdir(parents=True, exist_ok=True)
return out_dir / f"c{concurrency}.checkpoint.jsonl"
def parse_int_list(values: list[str]) -> list[int]:
items: list[int] = []
for v in values:
@@ -1096,6 +1356,12 @@ def main() -> int:
default=None,
help="Max questions per benchmark (for fast iteration).",
)
ap.add_argument(
"--offset",
type=int,
default=0,
help="Skip first N questions (0-based).",
)
reasoning_group = ap.add_mutually_exclusive_group()
reasoning_group.add_argument(
@@ -1115,6 +1381,8 @@ def main() -> int:
"--temperature", type=float, default=None, help="Override temperature."
)
ap.add_argument("--top-p", type=float, default=None, help="Override top_p.")
ap.add_argument("--top-k", type=int, default=None, help="Override top_k.")
ap.add_argument("--min-p", type=float, default=None, help="Override min_p.")
ap.add_argument(
"--max-tokens", type=int, default=None, help="Override max output tokens."
)
@@ -1148,15 +1416,31 @@ def main() -> int:
choices=["easy", "medium", "hard"],
help="Filter by difficulty (livecodebench only). E.g. --difficulty hard",
)
ap.add_argument(
"--release-version",
default=None,
help="LCB dataset release version (livecodebench only). E.g. release_v5",
)
ap.add_argument(
"--results-dir",
default="eval_results",
help="Directory for result JSON files (default: eval_results).",
)
ap.add_argument(
"--skip-instance-setup",
"--enable-thinking",
type=lambda v: v.lower() in ("true", "1", "yes"),
default=None,
help="Enable thinking mode for models that support it.",
)
ap.add_argument(
"--force",
action="store_true",
help="Skip exo instance management (assumes model is already running).",
help="Discard any existing checkpoint and run from scratch.",
)
ap.add_argument(
"--keep-instance",
action="store_true",
help="Skip deleting the instance after eval (for chaining runs).",
)
args, _ = ap.parse_known_args()
@@ -1177,13 +1461,26 @@ def main() -> int:
# Instance management
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
instance_id: str | None = None
created_instance = False
if not args.skip_instance_setup:
short_id, full_model_id = resolve_model_short_id(
client,
args.model,
force_download=args.force_download,
)
_short_id, full_model_id = resolve_model_short_id(
client,
args.model,
force_download=args.force_download,
)
# Optionally reuse a running instance for this model
if args.reuse_instance:
existing = find_existing_instance(client, full_model_id)
if existing:
instance_id = existing
logger.info(f"Reusing existing instance {instance_id}")
else:
logger.warning(
"--reuse-instance: no existing instance found, creating a new one"
)
if instance_id is None:
selected = settle_and_fetch_placements(
client,
full_model_id,
@@ -1198,7 +1495,7 @@ def main() -> int:
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
-nodes_used_in_instance(p["instance"]),
nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
@@ -1225,6 +1522,18 @@ def main() -> int:
if download_duration is not None:
logger.info(f"Download: {download_duration:.1f}s")
# Delete any existing instances to free resources before placing
try:
state = client.request_json("GET", "/state")
for old_id in list(state.get("instances", {}).keys()):
logger.info(f"Deleting stale instance {old_id}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{old_id}")
if state.get("instances"):
time.sleep(2)
except Exception as e:
logger.warning(f"Failed to clean up stale instances: {e}")
client.request_json("POST", "/instance", body={"instance": instance})
try:
wait_for_instance_ready(client, instance_id)
@@ -1234,10 +1543,9 @@ 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
created_instance = True
cluster_snapshot = capture_cluster_snapshot(client)
# Auto-detect reasoning from model config
model_config = load_model_config(full_model_id)
@@ -1291,16 +1599,57 @@ def main() -> int:
reasoning_effort = str(cfg["reasoning_effort"])
else:
reasoning_effort = "high" if is_reasoning else None
if args.top_k is not None:
top_k: int | None = args.top_k
elif "top_k" in cfg:
top_k = int(cfg["top_k"])
else:
top_k = None
if args.min_p is not None:
min_p: float | None = args.min_p
elif "min_p" in cfg:
min_p = float(cfg["min_p"])
else:
min_p = None
if args.enable_thinking is not None:
enable_thinking: bool | None = args.enable_thinking
elif "enable_thinking" in cfg:
enable_thinking = bool(cfg["enable_thinking"])
else:
enable_thinking = None
base_url = f"http://{args.host}:{args.port}"
logger.info(f"Model: {full_model_id}")
logger.info(
f"Settings: temperature={temperature}, max_tokens={max_tokens}, "
+ (f"top_p={top_p}, " if top_p is not None else "")
+ (f"top_k={top_k}, " if top_k is not None else "")
+ (f"min_p={min_p}, " if min_p is not None else "")
+ f"reasoning={'yes' if is_reasoning else 'no'}"
+ (f", reasoning_effort={reasoning_effort}" if reasoning_effort else "")
+ (
f", enable_thinking={enable_thinking}"
if enable_thinking is not None
else ""
)
)
# Common kwargs for evaluate_benchmark
eval_kwargs: dict[str, Any] = {
"reasoning_effort": reasoning_effort,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"enable_thinking": enable_thinking,
"difficulty": args.difficulty,
"offset": args.offset,
"release_version": args.release_version,
}
try:
if args.compare_concurrency:
concurrency_levels = parse_int_list(args.compare_concurrency)
@@ -1309,6 +1658,11 @@ def main() -> int:
for c in concurrency_levels:
logger.info(f"\n{'=' * 50}")
logger.info(f"Running {task_name} at concurrency={c}")
checkpoint_path = _checkpoint_path(
args.results_dir, task_name, full_model_id, c
)
if args.force and checkpoint_path.exists():
checkpoint_path.unlink()
results = asyncio.run(
evaluate_benchmark(
task_name,
@@ -1319,9 +1673,8 @@ def main() -> int:
concurrency=c,
limit=args.limit,
timeout=args.request_timeout,
reasoning_effort=reasoning_effort,
top_p=top_p,
difficulty=args.difficulty,
checkpoint_path=checkpoint_path,
**eval_kwargs,
)
)
if results:
@@ -1336,10 +1689,18 @@ def main() -> int:
cluster=cluster_snapshot,
)
results_by_c[c] = results
# Clean up checkpoint on success
if checkpoint_path.exists():
checkpoint_path.unlink()
if len(results_by_c) >= 2:
print_comparison(task_name, results_by_c)
else:
for task_name in task_names:
checkpoint_path = _checkpoint_path(
args.results_dir, task_name, full_model_id, args.num_concurrent
)
if args.force and checkpoint_path.exists():
checkpoint_path.unlink()
results = asyncio.run(
evaluate_benchmark(
task_name,
@@ -1350,9 +1711,8 @@ def main() -> int:
concurrency=args.num_concurrent,
limit=args.limit,
timeout=args.request_timeout,
reasoning_effort=reasoning_effort,
top_p=top_p,
difficulty=args.difficulty,
checkpoint_path=checkpoint_path,
**eval_kwargs,
)
)
if results:
@@ -1366,14 +1726,25 @@ def main() -> int:
scores,
cluster=cluster_snapshot,
)
# Clean up checkpoint on success
if checkpoint_path.exists():
checkpoint_path.unlink()
finally:
if instance_id is not None:
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
wait_for_instance_gone(client, instance_id)
if created_instance and instance_id is not None:
if args.keep_instance:
logger.info(f"Keeping instance {instance_id} (--keep-instance)")
else:
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
if e.status != 404:
raise
try:
wait_for_instance_gone(client, instance_id)
except TimeoutError:
logger.warning(
f"Timed out waiting for instance {instance_id} to be deleted"
)
return 0
+63 -12
View File
@@ -6,6 +6,7 @@ import http.client
import json
import os
import time
from collections.abc import Iterator
from typing import Any
from urllib.parse import urlencode
@@ -69,6 +70,30 @@ 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 stream_bench_chat_completions(self, payload: dict[str, Any]) -> Iterator[str]:
"""POST /bench/chat/completions with stream=True, yielding raw SSE lines."""
payload = {**payload, "stream": True}
data = json.dumps(payload).encode("utf-8")
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
try:
conn.request(
"POST",
"/bench/chat/completions",
body=data,
headers={
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
)
resp = conn.getresponse()
if resp.status >= 400:
raw = resp.read().decode("utf-8", errors="replace")
raise ExoHttpError(resp.status, resp.reason, raw[:300])
for line in resp:
yield line.decode("utf-8", errors="replace")
finally:
conn.close()
def get_state_path(self, path: str) -> Any:
try:
return self.request_json("GET", f"/state/{path}")
@@ -268,11 +293,15 @@ def sharding_filter(sharding: str, wanted: str) -> bool:
def fetch_and_filter_placements(
client: ExoClient, full_model_id: str, args: argparse.Namespace
client: ExoClient,
full_model_id: str,
args: argparse.Namespace,
node_id: str | None = None,
) -> list[dict[str, Any]]:
previews_resp = client.request_json(
"GET", "/instance/previews", params={"model_id": full_model_id}
)
params: dict[str, str] = {"model_id": full_model_id}
if node_id is not None:
params["node_ids"] = node_id
previews_resp = client.request_json("GET", "/instance/previews", params=params)
previews = previews_resp.get("previews") or []
selected: list[dict[str, Any]] = []
@@ -332,8 +361,9 @@ def settle_and_fetch_placements(
full_model_id: str,
args: argparse.Namespace,
settle_timeout: float = 0,
node_id: str | None = None,
) -> list[dict[str, Any]]:
selected = fetch_and_filter_placements(client, full_model_id, args)
selected = fetch_and_filter_placements(client, full_model_id, args, node_id=node_id)
if not selected and settle_timeout > 0:
backoff = _SETTLE_INITIAL_BACKOFF_S
@@ -346,7 +376,9 @@ def settle_and_fetch_placements(
)
time.sleep(min(backoff, remaining))
backoff = min(backoff * _SETTLE_BACKOFF_MULTIPLIER, _SETTLE_MAX_BACKOFF_S)
selected = fetch_and_filter_placements(client, full_model_id, args)
selected = fetch_and_filter_placements(
client, full_model_id, args, node_id=node_id
)
return selected
@@ -462,9 +494,8 @@ def run_planning_phase(
)
logger.info(f"Started download on {node_id}")
# Wait for downloads
start = time.time()
while time.time() - start < timeout:
# Wait for downloads (no timeout — poll until complete or failed)
while True:
all_done = True
for node_id in node_ids:
node_downloads = client.get_node_downloads(node_id) or []
@@ -514,9 +545,24 @@ def run_planning_phase(
if download_t0 is not None:
return time.perf_counter() - download_t0
return None
time.sleep(1)
time.sleep(10)
raise TimeoutError("Downloads did not complete in time")
def find_existing_instance(client: ExoClient, model_id: str) -> str | None:
"""Find an existing running instance for the given model."""
try:
state = client.request_json("GET", "/state")
except Exception:
return None
for inst_id, inst in state.get("instances", {}).items():
# Instance structure is nested: {"MlxJacclInstance": {"shardAssignments": {"modelId": ...}}}
for _inst_type, inner in inst.items():
if not isinstance(inner, dict):
continue
sa = inner.get("shardAssignments", {})
if sa.get("modelId") == model_id:
return inst_id
return None
def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
@@ -564,7 +610,7 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
ap.add_argument(
"--settle-timeout",
type=float,
default=0,
default=60.0,
help="Max seconds to wait for the cluster to produce valid placements (0 = try once).",
)
ap.add_argument(
@@ -572,3 +618,8 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
action="store_true",
help="Delete existing models from smallest to largest to make room for benchmark model.",
)
ap.add_argument(
"--reuse-instance",
action="store_true",
help="Reuse an existing running instance for this model instead of creating a new one.",
)
+36
View File
@@ -0,0 +1,36 @@
# Prefill/Decode disaggregation benchmark config.
#
# Top-level keys are bench-wide. [prefill] and [decode] sections set per-side
# placement filters and (optionally) per-side model.
#
# Example:
# uv run python bench/prefill_decode_bench.py --config bench/prefill-decode.toml
host = "james"
port = 52415
timeout = 7200.0
settle_timeout = 60.0
# Workload
pp = [4096]
tg = [512]
repeat = 1
warmup = 0
json_out = "bench/prefill_decode_results.json"
[prefill]
model = "mlx-community/gpt-oss-20b-MXFP4-Q8"
node = "mike"
instance_meta = "ring"
sharding = "pipeline"
min_nodes = 1
max_nodes = 1
[decode]
model = "mlx-community/gpt-oss-20b-MXFP4-Q8"
node = "james"
instance_meta = "ring"
sharding = "pipeline"
min_nodes = 1
max_nodes = 1
+785
View File
@@ -0,0 +1,785 @@
# type: ignore
#!/usr/bin/env python3
"""Disaggregated prefill-decode benchmark for exo (MLX → MLX).
Spins up two MLX instances on the cluster, marks one as Prefill source and
the other as Decode target via /v1/instance-links, then sends chat
completions to the API. The master routes the request to the decode
instance and stamps `prefill_endpoint` pointing at the prefill instance
the worker decides per-request whether to ship prefill remotely
(uncached_count > REMOTE_PREFILL_MIN_TOKENS).
Usage:
uv run python bench/prefill_decode_bench.py --model <id> --pp 2048,8192 --tg 128
uv run python bench/prefill_decode_bench.py --model <id> --pp 4096 --tg 128 --repeat 3
uv run python bench/prefill_decode_bench.py --model <id> --pp 2048 --tg 128 --dry-run
"""
from __future__ import annotations
import argparse
import contextlib
import copy
import itertools
import json
import sys
import time
import tomllib
from pathlib import Path
from statistics import mean
from typing import Any
from exo_bench import (
PromptSizer,
format_peak_memory,
load_tokenizer_for_bench,
parse_int_list,
)
from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
run_planning_phase,
settle_and_fetch_placements,
unwrap_instance,
wait_for_instance_gone,
wait_for_instance_ready,
)
from loguru import logger
def _node_id_to_friendly(client: ExoClient) -> dict[str, str]:
identities = client.get_node_identities() or {}
out: dict[str, str] = {}
for node_id, identity in identities.items():
if isinstance(identity, dict):
name = identity.get("friendlyName") or identity.get("friendly_name")
if isinstance(name, str):
out[str(node_id)] = name
return out
def _placement_node_friendly_names(
placement: dict[str, Any], id_to_friendly: dict[str, str]
) -> list[str]:
instance = placement["instance"]
return [id_to_friendly.get(nid, nid) for nid in node_ids_from_instance(instance)]
def _filter_by_node(
placements: list[dict[str, Any]],
friendly_name: str,
id_to_friendly: dict[str, str],
) -> list[dict[str, Any]]:
target = friendly_name.lower()
matched: list[dict[str, Any]] = []
for p in placements:
names = [n.lower() for n in _placement_node_friendly_names(p, id_to_friendly)]
if any(target == n or target in n for n in names):
matched.append(p)
return matched
def _node_id_by_friendly(id_to_friendly: dict[str, str], target: str) -> str | None:
target_lc = target.lower()
for nid, name in id_to_friendly.items():
if target_lc == name.lower() or target_lc in name.lower():
return nid
return None
def _load_toml(path: str) -> dict[str, Any]:
with Path(path).open("rb") as f:
return tomllib.load(f)
_TOP_LEVEL_TOML_KEYS = {
"host",
"port",
"timeout",
"settle_timeout",
"model",
"pp",
"tg",
"repeat",
"warmup",
"json_out",
"instance_meta",
"sharding",
"min_nodes",
"max_nodes",
"force_download",
"danger_delete_downloads",
"all_combinations",
}
def _inject_toml_into_argv() -> None:
"""If --config X is in sys.argv, pre-load it and inject required CLI args
(--model, --pp, --tg) so argparse's required=True checks pass."""
argv = sys.argv
if "--config" not in argv:
return
idx = argv.index("--config")
if idx + 1 >= len(argv):
return
cfg_path = argv[idx + 1]
cfg = _load_toml(cfg_path)
decode = cfg.get("decode", {})
def _has(flag: str) -> bool:
return any(a == flag or a.startswith(flag + "=") for a in argv)
# --model: prefer top-level, then [decode].model
if not _has("--model"):
model = cfg.get("model") or decode.get("model")
if model:
argv += ["--model", str(model)]
if not _has("--pp"):
pp = cfg.get("pp")
if pp:
argv += (
["--pp", *(str(x) for x in pp)]
if isinstance(pp, list)
else [
"--pp",
str(pp),
]
)
if not _has("--tg"):
tg = cfg.get("tg")
if tg:
argv += (
["--tg", *(str(x) for x in tg)]
if isinstance(tg, list)
else [
"--tg",
str(tg),
]
)
def _merge_toml_into_args(args: argparse.Namespace, cfg: dict[str, Any]) -> None:
"""Apply top-level toml keys onto args namespace where args has a default."""
for key, value in cfg.items():
if key in {"prefill", "decode"}:
continue
if key not in _TOP_LEVEL_TOML_KEYS:
continue
attr = key
current = getattr(args, attr, None)
if current in (None, [], False):
setattr(args, attr, value)
def _side_args(
base: argparse.Namespace, overrides: dict[str, Any]
) -> argparse.Namespace:
out = copy.copy(base)
for k in (
"instance_meta",
"sharding",
"min_nodes",
"max_nodes",
"skip_pipeline_jaccl",
"skip_tensor_ring",
):
if k in overrides:
setattr(out, k, overrides[k])
return out
def _pick_two_distinct_placements(
placements: list[dict[str, Any]],
) -> tuple[dict[str, Any], dict[str, Any]] | None:
if len(placements) < 2:
return None
seen_nodes: set[tuple[str, ...]] = set()
chosen: list[dict[str, Any]] = []
for p in placements:
nodes = tuple(sorted(str(n) for n in p.get("nodes", [])))
if nodes in seen_nodes:
continue
seen_nodes.add(nodes)
chosen.append(p)
if len(chosen) == 2:
return chosen[0], chosen[1]
return None
def _create_instance_link(
client: ExoClient,
prefill_instance_id: str,
decode_instance_id: str,
) -> str:
out = client.request_json(
"POST",
"/v1/instance-links",
body={
"prefill_instances": [prefill_instance_id],
"decode_instances": [decode_instance_id],
},
)
return str(out.get("commandId", ""))
def _list_instance_links(client: ExoClient) -> list[dict[str, Any]]:
out = client.request_json("GET", "/v1/instance-links")
return out if isinstance(out, list) else []
def _delete_instance_link(client: ExoClient, link_id: str) -> None:
client.request_json("DELETE", f"/v1/instance-links/{link_id}")
def run_one(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
) -> tuple[dict[str, Any], int]:
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"max_tokens": tg,
}
t0 = time.perf_counter()
out = client.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 ""
preview = text[:200] if text else ""
return {
"elapsed_s": elapsed,
"output_text_preview": preview,
"stats": stats,
}, pp_tokens
def _run_phase(
*,
client: ExoClient,
label: str,
pp_tg_pairs: list[tuple[int, int]],
model_id: str,
prompt_sizer: PromptSizer,
warmup: int,
repeat: int,
common_meta: dict[str, Any],
) -> list[dict[str, Any]]:
logger.info(f"=== phase: {label} (model={model_id}) ===")
rows: list[dict[str, Any]] = []
for i in range(warmup):
run_one(client, model_id, pp_tg_pairs[0][0], pp_tg_pairs[0][1], prompt_sizer)
logger.debug(f" warmup {i + 1}/{warmup} done")
for pp, tg in pp_tg_pairs:
logger.info(f"--- {label}: pp={pp} tg={tg} ---")
runs: list[dict[str, Any]] = []
for r in range(repeat):
time.sleep(2)
try:
row, actual_pp_tokens = run_one(client, model_id, pp, tg, prompt_sizer)
except Exception as e:
logger.error(e)
continue
row.update(common_meta)
row.update(
{
"phase": label,
"phase_model_id": model_id,
"pp_tokens": actual_pp_tokens,
"tg": tg,
"repeat_index": r,
}
)
runs.append(row)
rows.append(row)
if runs:
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
gen_tps = mean(x["stats"]["generation_tps"] for x in runs)
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
peak = mean(x["stats"]["peak_memory_usage"]["inBytes"] for x in runs)
avg_elapsed = mean(x["elapsed_s"] for x in runs)
logger.info(
f"[{label}] prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
f"peak_memory={format_peak_memory(peak)} "
f"avg_elapsed={avg_elapsed:.2f}s"
)
time.sleep(2)
return rows
def _summarise(rows: list[dict[str, Any]]) -> dict[tuple[int, int], dict[str, float]]:
grouped: dict[tuple[int, int], list[dict[str, Any]]] = {}
for r in rows:
key = (int(r["pp_tokens"]), int(r["tg"]))
grouped.setdefault(key, []).append(r)
out: dict[tuple[int, int], dict[str, float]] = {}
for key, runs in grouped.items():
out[key] = {
"prompt_tps": mean(x["stats"]["prompt_tps"] for x in runs),
"gen_tps": mean(x["stats"]["generation_tps"] for x in runs),
"elapsed_s": mean(x["elapsed_s"] for x in runs),
}
return out
def _print_diff(
disagg_rows: list[dict[str, Any]],
decode_alone_rows: list[dict[str, Any]],
prefill_alone_rows: list[dict[str, Any]],
) -> None:
disagg = _summarise(disagg_rows)
decode_alone = _summarise(decode_alone_rows)
prefill_alone = _summarise(prefill_alone_rows)
keys = set(disagg.keys()) | set(decode_alone.keys()) | set(prefill_alone.keys())
width = 64
for key in sorted(keys):
pp, tg = key
logger.info("" * width)
logger.info(f" pp={pp} tg={tg}")
logger.info("" * width)
logger.info(
f" {'phase':<16} {'elapsed':>10} {'prompt_tps':>11} {'gen_tps':>9}"
)
for label, summary in (
("disaggregated", disagg.get(key)),
("decode_alone", decode_alone.get(key)),
("prefill_alone", prefill_alone.get(key)),
):
if summary is None:
logger.info(f" {label:<16} {'':>10} {'':>11} {'':>9}")
continue
logger.info(
f" {label:<16} "
f"{summary['elapsed_s']:>9.2f}s "
f"{summary['prompt_tps']:>11.1f} "
f"{summary['gen_tps']:>9.2f}"
)
d = disagg.get(key)
da = decode_alone.get(key)
pa = prefill_alone.get(key)
if d and da and d["elapsed_s"] > 0:
logger.info(
f" speedup vs decode_alone: {da['elapsed_s'] / d['elapsed_s']:.2f}x"
)
if d and pa and d["elapsed_s"] > 0:
logger.info(
f" speedup vs prefill_alone: {pa['elapsed_s'] / d['elapsed_s']:.2f}x"
)
logger.info("" * width)
def main() -> int:
_inject_toml_into_argv()
ap = argparse.ArgumentParser(
prog="prefill-decode-bench",
description="Benchmark MLX-MLX disaggregated prefill/decode via instance links.",
)
add_common_instance_args(ap)
ap.add_argument(
"--pp",
nargs="+",
required=True,
help="Prompt-size hints (ints, must be >1000). Accepts commas.",
)
ap.add_argument(
"--tg",
nargs="+",
required=True,
help="Generation lengths (ints). Accepts commas.",
)
ap.add_argument(
"--repeat", type=int, default=1, help="Repetitions per (pp,tg) pair."
)
ap.add_argument(
"--warmup",
type=int,
default=0,
help="Warmup runs (uses first pp/tg).",
)
ap.add_argument(
"--json-out",
default="bench/prefill_decode_results.json",
help="Write raw per-run results JSON to this path.",
)
ap.add_argument("--stdout", action="store_true", help="Write results to stdout")
ap.add_argument(
"--dry-run", action="store_true", help="List selected placements and exit."
)
ap.add_argument(
"--all-combinations",
action="store_true",
help="Force all pp×tg combinations even when lists have equal length.",
)
ap.add_argument(
"--prefill-model",
default=None,
help="Model id for the prefill instance. Defaults to --model.",
)
ap.add_argument(
"--prefill-node",
default=None,
help="friendly_name of the node hosting the prefill instance.",
)
ap.add_argument(
"--decode-node",
default=None,
help="friendly_name of the node hosting the decode instance.",
)
ap.add_argument(
"--config",
default=None,
help="TOML config file. CLI flags override toml values.",
)
ap.add_argument(
"--compare-baseline",
action="store_true",
help="Also run each (pp,tg) pair without the prefill/decode link "
"(decode instance does its own prefill) and report the diff.",
)
args = ap.parse_args()
cfg = _load_toml(args.config) if args.config else {}
_merge_toml_into_args(args, cfg)
prefill_overrides = cfg.get("prefill", {}) if cfg else {}
decode_overrides = cfg.get("decode", {}) if cfg else {}
if args.prefill_model is None and "model" in prefill_overrides:
args.prefill_model = prefill_overrides["model"]
if args.prefill_node is None and "node" in prefill_overrides:
args.prefill_node = prefill_overrides["node"]
if args.decode_node is None and "node" in decode_overrides:
args.decode_node = decode_overrides["node"]
if "model" in decode_overrides and not args.model:
args.model = decode_overrides["model"]
pp_list = parse_int_list(args.pp)
tg_list = parse_int_list(args.tg)
if not pp_list or not tg_list:
logger.error("pp and tg lists must be non-empty")
return 2
for pp in pp_list:
if pp <= 1000:
logger.error(
f"pp={pp} must be >1000 (remote prefill triggers when uncached >1000)"
)
return 2
if args.repeat <= 0:
logger.error("--repeat must be >= 1")
return 2
use_combinations = args.all_combinations or len(pp_list) != len(tg_list)
if use_combinations:
logger.info(
f"pp/tg mode: combinations (product) — {len(pp_list) * len(tg_list)} pairs"
)
else:
logger.info(f"pp/tg mode: tandem (zip) — {len(pp_list)} pairs")
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
decode_short_id, decode_full_id = resolve_model_short_id(
client, args.model, force_download=args.force_download
)
if args.prefill_model:
prefill_short_id, prefill_full_id = resolve_model_short_id(
client, args.prefill_model, force_download=args.force_download
)
else:
prefill_short_id, prefill_full_id = decode_short_id, decode_full_id
tokenizer = load_tokenizer_for_bench(decode_full_id)
if tokenizer is None:
raise RuntimeError("[prefill-decode-bench] decode tokenizer load failed")
try:
decode_prompt_sizer = PromptSizer(tokenizer)
except Exception:
logger.error("[prefill-decode-bench] decode prompt sizing failed")
raise
if prefill_full_id == decode_full_id:
prefill_prompt_sizer = decode_prompt_sizer
else:
prefill_tokenizer = load_tokenizer_for_bench(prefill_full_id)
if prefill_tokenizer is None:
raise RuntimeError("[prefill-decode-bench] prefill tokenizer load failed")
prefill_prompt_sizer = PromptSizer(prefill_tokenizer)
id_to_friendly = _node_id_to_friendly(client)
prefill_args = _side_args(args, prefill_overrides)
decode_args = _side_args(args, decode_overrides)
if prefill_full_id == decode_full_id and prefill_overrides == decode_overrides:
placements = settle_and_fetch_placements(
client, decode_full_id, args, settle_timeout=args.settle_timeout
)
prefill_candidates = (
_filter_by_node(placements, args.prefill_node, id_to_friendly)
if args.prefill_node
else placements
)
decode_candidates = (
_filter_by_node(placements, args.decode_node, id_to_friendly)
if args.decode_node
else placements
)
if args.prefill_node and not prefill_candidates:
logger.error(f"No placement on prefill node {args.prefill_node!r}.")
return 1
if args.decode_node and not decode_candidates:
logger.error(f"No placement on decode node {args.decode_node!r}.")
return 1
if args.prefill_node and args.decode_node:
prefill_p = prefill_candidates[0]
decode_p = decode_candidates[0]
else:
pair = _pick_two_distinct_placements(placements)
if pair is None:
logger.error(
"Need at least two distinct-node MLX placements for the same model."
)
return 1
prefill_p, decode_p = pair
if args.prefill_node:
prefill_p = prefill_candidates[0]
if args.decode_node:
decode_p = decode_candidates[0]
else:
prefill_node_id = (
_node_id_by_friendly(id_to_friendly, args.prefill_node)
if args.prefill_node
else None
)
decode_node_id = (
_node_id_by_friendly(id_to_friendly, args.decode_node)
if args.decode_node
else None
)
if args.prefill_node and prefill_node_id is None:
logger.error(f"Unknown node {args.prefill_node!r}.")
return 1
if args.decode_node and decode_node_id is None:
logger.error(f"Unknown node {args.decode_node!r}.")
return 1
prefill_placements = settle_and_fetch_placements(
client,
prefill_full_id,
prefill_args,
settle_timeout=args.settle_timeout,
node_id=prefill_node_id,
)
decode_placements = settle_and_fetch_placements(
client,
decode_full_id,
decode_args,
settle_timeout=args.settle_timeout,
node_id=decode_node_id,
)
if not prefill_placements:
logger.error(
f"No placement found for prefill model {prefill_full_id}"
f"{f' on node {args.prefill_node!r}' if args.prefill_node else ''}."
)
return 1
if not decode_placements:
logger.error(
f"No placement found for decode model {decode_full_id}"
f"{f' on node {args.decode_node!r}' if args.decode_node else ''}."
)
return 1
prefill_p = prefill_placements[0]
decode_p = decode_placements[0]
prefill_node_names = _placement_node_friendly_names(prefill_p, id_to_friendly)
decode_node_names = _placement_node_friendly_names(decode_p, id_to_friendly)
_ = unwrap_instance
prefill_instance = prefill_p["instance"]
decode_instance = decode_p["instance"]
prefill_id = instance_id_from_instance(prefill_instance)
decode_id = instance_id_from_instance(decode_instance)
prefill_meta = str(prefill_p.get("instance_meta", ""))
decode_meta = str(decode_p.get("instance_meta", ""))
prefill_nodes = nodes_used_in_instance(prefill_instance)
decode_nodes = nodes_used_in_instance(decode_instance)
logger.info("=" * 80)
logger.info(
f"PREFILL: {prefill_meta} / nodes={prefill_nodes} ({','.join(prefill_node_names)}) "
f"/ {prefill_short_id} ({prefill_full_id}) / instance_id={prefill_id}"
)
logger.info(
f"DECODE: {decode_meta} / nodes={decode_nodes} ({','.join(decode_node_names)}) "
f"/ {decode_short_id} ({decode_full_id}) / instance_id={decode_id}"
)
if args.dry_run:
return 0
settle_deadline = (
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
)
logger.info("Planning phase: prefill...")
run_planning_phase(
client,
prefill_full_id,
prefill_p,
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
logger.info("Planning phase: decode...")
run_planning_phase(
client,
decode_full_id,
decode_p,
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if use_combinations:
pp_tg_pairs = list(itertools.product(pp_list, tg_list))
else:
pp_tg_pairs = list(zip(pp_list, tg_list, strict=True))
common_meta = {
"decode_model_short_id": decode_short_id,
"decode_model_id": decode_full_id,
"prefill_model_short_id": prefill_short_id,
"prefill_model_id": prefill_full_id,
"prefill_instance_id": prefill_id,
"prefill_instance_meta": prefill_meta,
"prefill_nodes": prefill_nodes,
"decode_instance_id": decode_id,
"decode_instance_meta": decode_meta,
"decode_nodes": decode_nodes,
}
all_rows: list[dict[str, Any]] = []
disagg_rows: list[dict[str, Any]] = []
decode_alone_rows: list[dict[str, Any]] = []
prefill_alone_rows: list[dict[str, Any]] = []
link_id = ""
prefill_alive = False
decode_alive = False
try:
logger.info("Creating prefill instance...")
client.request_json("POST", "/instance", body={"instance": prefill_instance})
wait_for_instance_ready(client, prefill_id)
prefill_alive = True
logger.info("Prefill instance ready")
if args.compare_baseline:
time.sleep(2)
prefill_alone_rows = _run_phase(
client=client,
label="prefill_alone",
pp_tg_pairs=pp_tg_pairs,
model_id=prefill_full_id,
prompt_sizer=prefill_prompt_sizer,
warmup=args.warmup,
repeat=args.repeat,
common_meta=common_meta,
)
all_rows.extend(prefill_alone_rows)
logger.info("Creating decode instance...")
client.request_json("POST", "/instance", body={"instance": decode_instance})
wait_for_instance_ready(client, decode_id)
decode_alive = True
logger.info("Decode instance ready")
logger.info("Linking instances (prefill → decode)...")
_create_instance_link(client, prefill_id, decode_id)
time.sleep(1)
links = _list_instance_links(client)
if not links:
logger.error("Link did not appear in state.")
return 1
link_id = str(links[-1].get("linkId") or links[-1].get("link_id") or "")
logger.info(f"Link created: {link_id}")
time.sleep(2)
disagg_rows = _run_phase(
client=client,
label="disaggregated",
pp_tg_pairs=pp_tg_pairs,
model_id=decode_full_id,
prompt_sizer=decode_prompt_sizer,
warmup=args.warmup,
repeat=args.repeat,
common_meta=common_meta,
)
all_rows.extend(disagg_rows)
if args.compare_baseline:
logger.info("Removing link and prefill instance to isolate decode_alone.")
with contextlib.suppress(ExoHttpError):
if link_id:
_delete_instance_link(client, link_id)
link_id = ""
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{prefill_id}")
wait_for_instance_gone(client, prefill_id)
prefill_alive = False
time.sleep(2)
decode_alone_rows = _run_phase(
client=client,
label="decode_alone",
pp_tg_pairs=pp_tg_pairs,
model_id=decode_full_id,
prompt_sizer=decode_prompt_sizer,
warmup=args.warmup,
repeat=args.repeat,
common_meta=common_meta,
)
all_rows.extend(decode_alone_rows)
_print_diff(disagg_rows, decode_alone_rows, prefill_alone_rows)
finally:
with contextlib.suppress(ExoHttpError):
if link_id:
_delete_instance_link(client, link_id)
if decode_alive:
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{decode_id}")
wait_for_instance_gone(client, decode_id)
if prefill_alive:
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{prefill_id}")
wait_for_instance_gone(client, prefill_id)
logger.debug("Deleted both instances")
if args.stdout:
json.dump(all_rows, 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)
logger.debug(f"\nWrote results JSON: {args.json_out}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,5 +1,8 @@
<script lang="ts">
import { browser } from "$app/environment";
import { featureFlags } from "$lib/stores/app.svelte";
const showAdvanced = $derived(featureFlags()["disaggregation"] === true);
interface Props {
showHome?: boolean;
@@ -297,5 +300,28 @@
</svg>
<span class="hidden sm:inline">Integrations</span>
</a>
{#if showAdvanced}
<a
href="/#/advanced"
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="Advanced cluster settings"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span class="hidden sm:inline">Advanced</span>
</a>
{/if}
</nav>
</header>
@@ -0,0 +1,565 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import FamilyLogos from "$lib/components/FamilyLogos.svelte";
import {
instances,
instanceLinks,
nodeIdentities,
refreshState,
createInstanceLink,
updateInstanceLink,
deleteInstanceLink,
type Instance,
} from "$lib/stores/app.svelte";
import { deriveBaseModel, deriveFamily } from "$lib/utils/model_family";
type InstanceWrapper = {
MlxRingInstance?: Instance;
MlxJacclInstance?: Instance;
VllmInstance?: Instance;
};
let interval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
refreshState();
interval = setInterval(refreshState, 3000);
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
type InstanceRow = {
id: string;
modelId: string;
family: string;
baseModel: string;
nodeNames: string[];
nodeCount: number;
};
const instanceRows = $derived.by<InstanceRow[]>(() => {
const rows: InstanceRow[] = [];
const ids = nodeIdentities();
for (const [id, raw] of Object.entries(instances())) {
const wrapper = raw as InstanceWrapper;
const inst =
wrapper.MlxRingInstance ??
wrapper.MlxJacclInstance ??
wrapper.VllmInstance;
const modelId = inst?.shardAssignments?.modelId ?? "";
const nodeToRunner = inst?.shardAssignments?.nodeToRunner ?? {};
const nodeIds = Object.keys(nodeToRunner);
const nodeNames = nodeIds
.map((nodeId) => ids[nodeId]?.friendlyName ?? nodeId.slice(0, 6))
.filter((name) => !!name);
rows.push({
id,
modelId,
family: deriveFamily(modelId),
baseModel: deriveBaseModel(modelId),
nodeNames,
nodeCount: nodeIds.length,
});
}
rows.sort((a, b) => a.modelId.localeCompare(b.modelId));
return rows;
});
const instanceById = $derived(
Object.fromEntries(instanceRows.map((r) => [r.id, r])),
);
type LinkRow = {
linkId: string;
prefill: string[];
decode: string[];
families: string[];
multiNode: boolean;
};
const linkRows = $derived.by<LinkRow[]>(() => {
const rows: LinkRow[] = [];
for (const [, link] of Object.entries(instanceLinks())) {
const fams = new Set<string>();
let multiNode = false;
for (const id of [...link.prefillInstances, ...link.decodeInstances]) {
const r = instanceById[id];
if (r && r.baseModel) fams.add(r.baseModel.toLowerCase());
if (r && r.nodeCount > 1) multiNode = true;
}
rows.push({
linkId: link.linkId,
prefill: link.prefillInstances,
decode: link.decodeInstances,
families: Array.from(fams),
multiNode,
});
}
return rows;
});
let editingLinkId = $state<string | null>(null);
let editingPrefill = $state<Set<string>>(new Set());
let editingDecode = $state<Set<string>>(new Set());
let saving = $state(false);
let errorMessage = $state<string | null>(null);
function startCreate() {
editingLinkId = "new";
editingPrefill = new Set();
editingDecode = new Set();
errorMessage = null;
}
function startEdit(row: LinkRow) {
editingLinkId = row.linkId;
editingPrefill = new Set(row.prefill);
editingDecode = new Set(row.decode);
errorMessage = null;
}
function cancelEdit() {
editingLinkId = null;
editingPrefill = new Set();
editingDecode = new Set();
errorMessage = null;
}
type Role = "prefill" | "decode" | "none";
function roleOf(id: string): Role {
if (editingPrefill.has(id)) return "prefill";
if (editingDecode.has(id)) return "decode";
return "none";
}
function setRole(id: string, role: Role) {
const p = new Set(editingPrefill);
const d = new Set(editingDecode);
p.delete(id);
d.delete(id);
if (role === "prefill") p.add(id);
if (role === "decode") d.add(id);
editingPrefill = p;
editingDecode = d;
}
const editingFamilies = $derived.by<string[]>(() => {
const fams = new Set<string>();
for (const id of [...editingPrefill, ...editingDecode]) {
const r = instanceById[id];
if (r && r.baseModel) fams.add(r.baseModel.toLowerCase());
}
return Array.from(fams);
});
const editingMultiNode = $derived.by<string[]>(() => {
const names: string[] = [];
for (const id of [...editingPrefill, ...editingDecode]) {
const r = instanceById[id];
if (r && r.nodeCount > 1) {
names.push(r.baseModel || r.modelId);
}
}
return names;
});
const editingMismatch = $derived(editingFamilies.length > 1);
const canSave = $derived(
editingLinkId !== null &&
editingPrefill.size > 0 &&
editingDecode.size > 0 &&
!saving,
);
async function save() {
if (editingLinkId === null) return;
saving = true;
errorMessage = null;
try {
const prefill = Array.from(editingPrefill);
const decode = Array.from(editingDecode);
if (editingLinkId === "new") {
await createInstanceLink(prefill, decode);
} else {
await updateInstanceLink(editingLinkId, prefill, decode);
}
cancelEdit();
await refreshState();
} catch (err) {
errorMessage = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
async function remove(linkId: string) {
if (!confirm("Remove this routing?")) return;
try {
await deleteInstanceLink(linkId);
if (editingLinkId === linkId) cancelEdit();
await refreshState();
} catch (err) {
errorMessage = err instanceof Error ? err.message : String(err);
}
}
</script>
<div class="font-mono text-foreground">
<div class="mb-6 space-y-4">
<details open class="group [&_summary::-webkit-details-marker]:hidden">
<summary
class="cursor-pointer list-none text-exo-yellow text-xs font-mono tracking-widest uppercase flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span
class="inline-block transition-transform group-open:rotate-90 text-exo-light-gray"
>▶</span
>
Prefill vs Decode
</summary>
<div class="mt-2 text-white/80 text-sm leading-relaxed">
Prefill is the compute-heavy pass that consumes the entire prompt and
builds a KV cache. Decode is the memory-bandwidth-bound loop that emits
tokens sequentially from that cache. The two phases have very different
bottlenecks, so running them on different hardware can be substantially
faster than doing both on one node.
</div>
</details>
<details class="group [&_summary::-webkit-details-marker]:hidden">
<summary
class="cursor-pointer list-none text-exo-yellow text-xs font-mono tracking-widest uppercase flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span
class="inline-block transition-transform group-open:rotate-90 text-exo-light-gray"
>▶</span
>
Linking Instances
</summary>
<div class="mt-2 text-white/80 text-sm leading-relaxed space-y-2">
<p>
A linked route here tells the cluster: when a request is sent to a
model in that cluster, the decode node (or the least active one if
there are multiple) will handle it. If it decides it must do a lot of
prefill not already cached in the prefix cache, it routes the request
to the prefill node over TCP IP. The prefill node streams the KV cache
back to the decode node which picks up from there.
</p>
<p>
Linked instances must be running the same model family — KV layouts
differ across architectures. More on the <a
class="text-exo-yellow underline underline-offset-2 hover:text-exo-yellow-darker transition-colors"
href="https://blog.exolabs.net/nvidia-dgx-spark/"
target="_blank"
rel="noreferrer noopener">blog</a
>.
</p>
</div>
</details>
</div>
{#if errorMessage}
<div
class="mb-4 px-4 py-3 bg-red-500/10 border border-red-500/40 text-red-300 text-sm"
>
{errorMessage}
</div>
{/if}
<section class="mt-12">
<h2
class="text-exo-yellow text-xs font-mono tracking-widest uppercase m-0 mb-3"
>
Existing routes
</h2>
{#if linkRows.length === 0}
{#if editingLinkId === null}
<div class="flex items-center justify-between">
<p class="text-exo-light-gray italic text-sm m-0">
No routes yet. Create one to enable remote prefill.
</p>
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-yellow/15 border border-exo-yellow/50 text-exo-yellow hover:bg-exo-yellow/25 hover:border-exo-yellow/80 transition-colors"
onclick={startCreate}
>
+ New route
</button>
</div>
{/if}
{:else}
{#if editingLinkId === null}
<div class="flex justify-end mb-3">
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-yellow/15 border border-exo-yellow/50 text-exo-yellow hover:bg-exo-yellow/25 hover:border-exo-yellow/80 transition-colors"
onclick={startCreate}
>
+ New route
</button>
</div>
{/if}
<div
class="bg-exo-dark-gray/60 border border-exo-medium-gray/40 flex flex-col"
>
{#each linkRows as row (row.linkId)}
{#if editingLinkId !== row.linkId}
<article
class="p-4 border-b border-exo-light-gray/25 last:border-b-0"
>
{#if row.multiNode}
<div
class="mb-3 px-3 py-2 bg-red-500/10 border border-red-500/40 text-red-300 text-xs tracking-wide"
>
⚠ Multi-node instance detected. Remote prefill currently only
works on single-node (rank-0) instances. This route will not
function until that's supported.
</div>
{/if}
{#if row.families.length > 1}
<div
class="mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/40 text-amber-300 text-xs tracking-wide"
>
⚠ Mixed model families: {row.families.join(", ")}
</div>
{/if}
<div
class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-x-3 gap-y-2"
>
<span
class="inline-block justify-self-start text-[10px] font-mono tracking-widest uppercase px-2 py-0.5 bg-exo-yellow/15 border border-exo-yellow/40 text-exo-yellow"
>Prefill</span
>
<span></span>
<span
class="inline-block justify-self-start text-[10px] font-mono tracking-widest uppercase px-2 py-0.5 bg-exo-medium-gray/40 border border-exo-medium-gray/60 text-foreground"
>Decode</span
>
<span></span>
<div class="min-w-0">
<ul class="list-none p-0 m-0 flex flex-col gap-2">
{#each row.prefill as id (id)}
{@const r = instanceById[id]}
{#if r}
<li
class="flex items-center gap-2 px-2.5 py-2 bg-exo-medium-gray/20 border border-exo-medium-gray/40"
>
<FamilyLogos family={r.family} />
<div class="min-w-0 flex-1">
<div
class="text-exo-yellow text-xs font-mono truncate"
>
{r.baseModel || r.modelId}
</div>
<div
class="text-exo-light-gray text-[11px] truncate"
>
{r.nodeNames.join(", ") || "?"}{r.nodeCount > 1
? ` (${r.nodeCount} nodes)`
: ""}
</div>
<div
class="text-exo-light-gray/40 text-[10px] font-mono truncate"
title={r.id}
>
{r.id.slice(0, 8)}
</div>
</div>
</li>
{/if}
{/each}
</ul>
</div>
<div class="text-exo-yellow/60 text-xl px-2" aria-hidden="true">
</div>
<div class="min-w-0">
<ul class="list-none p-0 m-0 flex flex-col gap-2">
{#each row.decode as id (id)}
{@const r = instanceById[id]}
{#if r}
<li
class="flex items-center gap-2 px-2.5 py-2 bg-exo-medium-gray/20 border border-exo-medium-gray/40"
>
<FamilyLogos family={r.family} />
<div class="min-w-0 flex-1">
<div
class="text-exo-yellow text-xs font-mono truncate"
>
{r.baseModel || r.modelId}
</div>
<div
class="text-exo-light-gray text-[11px] truncate"
>
{r.nodeNames.join(", ") || "?"}{r.nodeCount > 1
? ` (${r.nodeCount} nodes)`
: ""}
</div>
<div
class="text-exo-light-gray/40 text-[10px] font-mono truncate"
title={r.id}
>
{r.id.slice(0, 8)}
</div>
</div>
</li>
{/if}
{/each}
</ul>
</div>
<div class="flex gap-2 pl-3">
<button
class="px-2 py-0.5 text-[11px] font-mono tracking-wider uppercase bg-exo-medium-gray/30 border border-exo-medium-gray/60 rounded text-foreground hover:border-exo-yellow/60 hover:text-exo-yellow disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
onclick={() => startEdit(row)}
disabled={editingLinkId !== null}
>
Edit
</button>
<button
class="px-2 py-0.5 text-[11px] font-mono tracking-wider uppercase bg-red-500/15 border border-red-500/40 rounded text-red-300 hover:bg-red-500/25 transition-colors"
onclick={() => remove(row.linkId)}
>
Remove
</button>
</div>
</div>
</article>
{/if}
{/each}
</div>
{/if}
</section>
{#if editingLinkId !== null && instanceRows.length === 0}
<section
class="mt-6 bg-exo-dark-gray/60 border border-exo-yellow/30 px-4 py-2.5 flex items-center justify-between gap-3"
>
<span class="text-exo-light-gray italic text-sm font-mono"
>No instances available.</span
>
<button
class="px-3 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 border border-exo-medium-gray/60 rounded text-foreground hover:border-exo-yellow/60 transition-colors"
onclick={cancelEdit}
>
Cancel
</button>
</section>
{:else if editingLinkId !== null}
<section class="mt-6 bg-exo-dark-gray/60 border border-exo-yellow/30 p-5">
<h2
class="text-exo-yellow text-xs font-mono tracking-widest uppercase m-0 mb-3"
>
{editingLinkId === "new" ? "New route" : "Edit route"}
</h2>
{#if editingMismatch}
<div
class="mb-3 px-3 py-2 bg-amber-500/10 border border-amber-500/40 text-amber-300 text-xs tracking-wide"
>
⚠ Selected instances span multiple model families: <strong
>{editingFamilies.join(", ")}</strong
>. Linking across families produces a corrupt KV cache.
</div>
{/if}
{#if editingMultiNode.length > 0}
<div
class="mb-3 px-3 py-2 bg-red-500/10 border border-red-500/40 text-red-300 text-xs tracking-wide"
>
⚠ Multi-node instance(s) selected: <strong
>{editingMultiNode.join(", ")}</strong
>. Remote prefill currently only works on single-node instances. This
route will not function until multi-node support lands.
</div>
{/if}
<p class="text-exo-light-gray text-xs mb-4">
Pick a role for each instance:
<span class="text-exo-yellow">Prefill</span>
serves KV cache,
<span class="text-foreground">Decode</span> consumes it.
</p>
<div
class="grid gap-2.5"
style="grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));"
>
{#each instanceRows as row (row.id)}
{@const role = roleOf(row.id)}
<div
class="border p-3 flex flex-col gap-2.5 transition-colors {role ===
'prefill'
? 'border-exo-yellow/60 bg-exo-dark-gray/60'
: role === 'decode'
? 'border-exo-light-gray/60 bg-exo-dark-gray/60'
: 'border-exo-medium-gray/40 bg-exo-dark-gray/40'}"
>
<div class="flex items-center gap-2">
<FamilyLogos family={row.family} />
<div class="min-w-0 flex-1">
<div class="text-exo-yellow text-xs font-mono truncate">
{row.baseModel || row.modelId}
</div>
<div class="text-exo-light-gray text-[11px] truncate">
{row.nodeNames.join(", ") || "?"}{row.nodeCount > 1
? ` (${row.nodeCount} nodes)`
: ""}
</div>
<div
class="text-exo-light-gray/40 text-[10px] font-mono truncate"
title={row.id}
>
{row.id.slice(0, 8)}
</div>
</div>
{#if row.nodeCount > 1}
<span
class="text-[9px] font-mono tracking-widest uppercase px-1.5 py-0.5 bg-red-500/15 border border-red-500/40 text-red-300"
title="Multi-node instances are not supported by remote prefill yet."
>Unsupported</span
>
{/if}
</div>
<div
class="flex rounded-md overflow-hidden border border-exo-light-gray/40 divide-x divide-exo-light-gray/40"
>
<button
class="flex-1 px-2 py-1 text-[11px] font-mono tracking-wider uppercase transition-colors {role ===
'prefill'
? 'bg-exo-yellow/20 text-exo-yellow'
: 'bg-transparent text-white/80 hover:text-exo-yellow'}"
onclick={() =>
setRole(row.id, role === "prefill" ? "none" : "prefill")}
>Prefill</button
>
<button
class="flex-1 px-2 py-1 text-[11px] font-mono tracking-wider uppercase transition-colors {role ===
'decode'
? 'bg-exo-medium-gray/50 text-foreground'
: 'bg-transparent text-white/80 hover:text-foreground'}"
onclick={() =>
setRole(row.id, role === "decode" ? "none" : "decode")}
>Decode</button
>
</div>
</div>
{/each}
</div>
<div class="flex gap-2 mt-5 justify-end">
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-yellow/15 border border-exo-yellow/50 text-exo-yellow hover:bg-exo-yellow/25 hover:border-exo-yellow/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
onclick={save}
disabled={!canSave}
>
{saving ? "Saving..." : "Save route"}
</button>
<button
class="px-3 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 border border-exo-medium-gray/60 text-foreground hover:border-exo-yellow/60 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
onclick={cancelEdit}
disabled={saving}
>
Cancel
</button>
</div>
</section>
{/if}
</div>
+128 -10
View File
@@ -74,6 +74,12 @@ export interface Instance {
};
}
export interface RawInstanceLink {
linkId: string;
prefillInstances: string[];
decodeInstances: string[];
}
// Granular node state types from the new state structure
interface RawNodeIdentity {
modelId?: string;
@@ -223,6 +229,7 @@ interface RawStateResponse {
}
>;
runners?: Record<string, unknown>;
instanceLinks?: Record<string, RawInstanceLink>;
downloads?: Record<string, unknown[]>;
// New granular node state fields
nodeIdentities?: Record<string, RawNodeIdentity>;
@@ -541,6 +548,8 @@ class AppStore {
topologyData = $state<TopologyData | null>(null);
instances = $state<Record<string, unknown>>({});
runners = $state<Record<string, unknown>>({});
instanceLinks = $state<Record<string, RawInstanceLink>>({});
featureFlags = $state<Record<string, boolean>>({});
downloads = $state<Record<string, unknown[]>>({});
nodeDisk = $state<
Record<
@@ -1274,6 +1283,7 @@ class AppStore {
startPolling() {
this.fetchState();
this.fetchFeatureFlags();
this.fetchInterval = setInterval(() => this.fetchState(), 1000);
}
@@ -1285,6 +1295,16 @@ class AppStore {
this.stopPreviewsPolling();
}
async fetchFeatureFlags() {
try {
const response = await fetch("/v1/feature-flags");
if (!response.ok) return;
this.featureFlags = await response.json();
} catch {
// Silently ignore — defaults to all-disabled.
}
}
async fetchState() {
try {
const response = await fetch("/state");
@@ -1310,6 +1330,11 @@ class AppStore {
if (data.runners) {
this.runners = data.runners;
}
if (data.instanceLinks) {
this.instanceLinks = data.instanceLinks;
} else {
this.instanceLinks = {};
}
if (data.downloads) {
this.downloads = data.downloads;
}
@@ -1670,7 +1695,15 @@ class AppStore {
}
}
}
return { role: m.role, content: msgContent };
const out: {
role: string;
content: string;
reasoning_content?: string;
} = { role: m.role, content: msgContent };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}),
];
@@ -1877,7 +1910,15 @@ class AppStore {
const apiMessages = [
systemPrompt,
...targetConversation.messages.slice(0, -1).map((m) => {
return { role: m.role, content: m.content };
const out: {
role: string;
content: string;
reasoning_content?: string;
} = { role: m.role, content: m.content };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}),
];
@@ -2408,10 +2449,15 @@ class AppStore {
contentParts.push({ type: "text", text: textContent });
}
return {
role: m.role,
content: contentParts,
};
const out: {
role: string;
content: typeof contentParts;
reasoning_content?: string;
} = { role: m.role, content: contentParts };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}
// Text-only message (original path)
@@ -2429,10 +2475,15 @@ class AppStore {
}
}
return {
role: m.role,
content: msgContent,
};
const out: {
role: string;
content: string;
reasoning_content?: string;
} = { role: m.role, content: msgContent };
if (m.role === "assistant" && m.thinking) {
out.reasoning_content = m.thinking;
}
return out;
}),
];
@@ -3281,6 +3332,60 @@ class AppStore {
}
}
async createInstanceLink(
prefillInstances: string[],
decodeInstances: string[],
): Promise<void> {
const response = await fetch("/v1/instance-links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prefill_instances: prefillInstances,
decode_instances: decodeInstances,
}),
});
if (!response.ok) {
throw new Error(
`Failed to create instance link: ${response.status} ${await response.text()}`,
);
}
}
async updateInstanceLink(
linkId: string,
prefillInstances: string[],
decodeInstances: string[],
): Promise<void> {
const response = await fetch(
`/v1/instance-links/${encodeURIComponent(linkId)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prefill_instances: prefillInstances,
decode_instances: decodeInstances,
}),
},
);
if (!response.ok) {
throw new Error(
`Failed to update instance link: ${response.status} ${await response.text()}`,
);
}
}
async deleteInstanceLink(linkId: string): Promise<void> {
const response = await fetch(
`/v1/instance-links/${encodeURIComponent(linkId)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error(
`Failed to delete instance link: ${response.status} ${await response.text()}`,
);
}
}
/**
* Delete a downloaded model from a specific node
*/
@@ -3379,6 +3484,19 @@ export const prefillProgress = () => appStore.prefillProgress;
export const topologyData = () => appStore.topologyData;
export const instances = () => appStore.instances;
export const runners = () => appStore.runners;
export const instanceLinks = () => appStore.instanceLinks;
export const featureFlags = () => appStore.featureFlags;
export const createInstanceLink = (
prefillInstances: string[],
decodeInstances: string[],
) => appStore.createInstanceLink(prefillInstances, decodeInstances);
export const updateInstanceLink = (
linkId: string,
prefillInstances: string[],
decodeInstances: string[],
) => appStore.updateInstanceLink(linkId, prefillInstances, decodeInstances);
export const deleteInstanceLink = (linkId: string) =>
appStore.deleteInstanceLink(linkId);
export const downloads = () => appStore.downloads;
export const nodeDisk = () => appStore.nodeDisk;
export const placementPreviews = () => appStore.placementPreviews;
+44
View File
@@ -0,0 +1,44 @@
// Mirrors src/exo/shared/models/model_cards.py:derive_base_model
const QUANT_SUFFIXES = new RegExp(
"[-_ ](?:MLX|MXFP[0-9]+|NVFP[0-9]+|GPTQ|AWQ|GGUF|fp16|bf16|fp8|int[0-9]+|[0-9]+(?:\\.[0-9]+)?bit|Q[0-9]+(?:_[A-Z0-9]+)?|gs[0-9]+)" +
"(?:[-_ ](?:MLX|Q[0-9]+|Int[0-9]+|[A-Z0-9]+|gs[0-9]+))*$",
"i",
);
function normalize(s: string): string {
return s
.replaceAll("-", " ")
.replaceAll("_", " ")
.replaceAll(" ", " ")
.trim();
}
export function deriveBaseModel(modelId: string): string {
const short = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
const stripped = short.replace(QUANT_SUFFIXES, "");
return normalize(stripped);
}
export function baseModelsCompatible(a: string, b: string): boolean {
return deriveBaseModel(a).toLowerCase() === deriveBaseModel(b).toLowerCase();
}
// Mirrors src/exo/shared/models/model_cards.py:derive_family
export function deriveFamily(modelId: string): string {
const short = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
const stripped = short
.replace(QUANT_SUFFIXES, "")
.toLowerCase()
.replaceAll("_", "-");
const parts = stripped.split(/[-.]/);
const familyParts: string[] = [];
for (const p of parts) {
if (/^\d+$/.test(p) || /^\d+[bm]?$/i.test(p)) break;
familyParts.push(p);
}
return familyParts.length > 0 ? familyParts.join("-") : stripped;
}
+3
View File
@@ -3435,6 +3435,7 @@
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
@@ -4822,6 +4823,7 @@
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
@@ -4968,6 +4970,7 @@
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code
@@ -0,0 +1,81 @@
<script lang="ts">
import { browser } from "$app/environment";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import PrefillDecodeDisaggregation from "$lib/components/PrefillDecodeDisaggregation.svelte";
import { featureFlags, refreshState } from "$lib/stores/app.svelte";
import { onMount } from "svelte";
type TabId = "prefill-decode";
const tabs: { id: TabId; label: string }[] = [
{ id: "prefill-decode", label: "Prefill / Decode" },
];
let activeTab = $state<TabId>(tabs[0].id);
let flagsLoaded = $state(false);
onMount(() => {
refreshState().finally(() => {
flagsLoaded = true;
});
});
const flags = $derived(featureFlags());
const enabled = $derived(flags["disaggregation"] === true);
$effect(() => {
if (browser && flagsLoaded && !enabled) {
// No advanced features enabled — bounce home.
window.location.hash = "/";
}
});
</script>
<div class="min-h-screen bg-exo-dark-gray flex flex-col">
<HeaderNav />
<main class="flex-1 max-w-[1100px] mx-auto w-full px-4 md:px-6 py-8">
{#if !flagsLoaded}
<div class="text-exo-light-gray/60 text-sm">Loading…</div>
{:else if !enabled}
<div class="text-exo-light-gray/60 text-sm">
No advanced features enabled. Set <code
class="text-exo-yellow font-mono">ENABLE_DISAGGREGATION=true</code
> on the cluster to access prefill/decode disaggregation.
</div>
{:else}
<div class="mb-4">
<h1
class="text-white text-xl md:text-2xl font-semibold tracking-wide mb-2"
>
Advanced
</h1>
<p class="text-exo-light-gray/60 text-sm">
Cluster-level configuration. Most users don't need anything here.
</p>
</div>
<div
class="flex flex-wrap gap-2 mb-6 border-b border-exo-light-gray/10 pb-3"
>
{#each tabs as tab (tab.id)}
<button
onclick={() => (activeTab = tab.id)}
class="px-3 py-1.5 text-xs rounded-md transition-all cursor-pointer
{activeTab === tab.id
? '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.label}
</button>
{/each}
</div>
<div class="space-y-4">
{#if activeTab === "prefill-decode"}
<PrefillDecodeDisaggregation />
{/if}
</div>
{/if}
</main>
</div>
+112 -1
View File
@@ -14,6 +14,7 @@
let modelCapabilities = $state<Record<string, string[]>>({});
let modelContextLengths = $state<Record<string, number>>({});
let modelReasoningDialects = $state<Record<string, string>>({});
const runningModels = $derived.by(() => {
const models: string[] = [];
@@ -88,10 +89,12 @@
let codexModel = $state("");
let codexMcpPath = $state("/Users/username");
let openClawModel = $state("");
let piModel = $state("");
$effect(() => {
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
codexModel = def;
openClawModel = def;
piModel = def;
});
const claudeShellCommand = $derived(
@@ -130,6 +133,7 @@
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const dialect = modelReasoningDialects[modelId];
const entry: Record<string, unknown> = { name: modelId };
if (ctxLen > 0) {
entry.limit = { context: ctxLen, output: Math.min(ctxLen, 16384) };
@@ -137,6 +141,27 @@
if (caps.includes("vision")) {
entry.modalities = { input: ["text", "image"], output: ["text"] };
}
// Reasoning round-trip: opencode's `interleaved` field tells the
// openai-compatible adapter to send the assistant's prior
// reasoning_content back in subsequent turns. Emit it for dialects
// whose chat templates use prior reasoning:
// - `tool_conditional` (DeepSeek V3.2 / V4): wrapper preserves all
// reasoning when tools are present.
// - `post_last_user` (Qwen3-Thinking, GLM 4.5+, MiniMax M2.x):
// Jinja template reads reasoning_content for assistant turns since
// the last user message — exactly the tool-chain window.
// - `channel` (gpt-oss / Harmony): the model's Jinja template reads
// `message.thinking` rather than `message.reasoning_content`, but
// the server bridges `reasoning_content` → `thinking` before
// rendering, so the round-trip works through the standard field.
// `suffix` (Kimi): reasoning lives in content; no separate field path.
if (
dialect === "tool_conditional" ||
dialect === "post_last_user" ||
dialect === "channel"
) {
entry.interleaved = { field: "reasoning_content" };
}
models[modelId] = entry;
}
if (Object.keys(models).length === 0) {
@@ -218,6 +243,55 @@
),
);
const piModelsJson = $derived.by(() => {
const models: Record<string, unknown>[] = [];
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const entry: Record<string, unknown> = { id: modelId };
if (caps.includes("vision")) {
entry.input = ["text", "image"];
}
// Mark thinking-capable models so pi surfaces its thinking-level selector
// for them. exo capability strings: "thinking" (model emits reasoning
// content) and "thinking_toggle" (user can turn it on/off).
if (caps.includes("thinking") || caps.includes("thinking_toggle")) {
entry.reasoning = true;
}
if (ctxLen > 0) {
entry.contextWindow = ctxLen;
}
models.push(entry);
}
if (models.length === 0) {
models.push({ id: "your-model-id" });
}
return JSON.stringify(
{
providers: {
exo: {
baseUrl: `${apiUrl}/v1`,
api: "openai-completions",
apiKey: "exo",
compat: {
supportsDeveloperRole: false,
// exo's OpenAI surface takes a boolean `enable_thinking` toggle,
// not graded effort levels, so disable pi's `reasoning_effort`
// parameter and use the matching top-level-boolean format.
supportsReasoningEffort: false,
thinkingFormat: "qwen",
},
models,
},
},
},
null,
2,
);
});
const piShellCommand = $derived(`pi --provider exo --model ${piModel}`);
const ollamaCommand = $derived(
`OLLAMA_HOST=${apiUrl}/ollama ollama run ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"}`,
);
@@ -277,6 +351,7 @@
"OpenCode",
"Codex",
"OpenClaw",
"Pi",
"Open WebUI",
"n8n",
"Firefox",
@@ -298,16 +373,25 @@
try {
const resp = await fetch("/v1/models");
const data = (await resp.json()) as {
data: { id: string; capabilities: string[]; context_length: number }[];
data: {
id: string;
capabilities: string[];
context_length: number;
reasoning_dialect?: string;
}[];
};
const caps: Record<string, string[]> = {};
const ctxs: Record<string, number> = {};
const dialects: Record<string, string> = {};
for (const model of data.data) {
caps[model.id] = model.capabilities || [];
if (model.context_length > 0) ctxs[model.id] = model.context_length;
if (model.reasoning_dialect)
dialects[model.id] = model.reasoning_dialect;
}
modelCapabilities = caps;
modelContextLengths = ctxs;
modelReasoningDialects = dialects;
} catch {
/* ignore */
}
@@ -515,6 +599,33 @@
config={`openclaw doctor --fix${(modelCapabilities[openClawModel] || []).includes("vision") ? `\nopenclaw models set-image exo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
language="bash"
/>
{:else if activeTab === "Pi"}
{#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={piModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<IntegrationCard
title="Models Config"
subtitle="~/.pi/agent/models.json"
description="Register exo as a custom provider in pi. Create or edit this file, then run pi and pick an exo model via /model. Install pi with: npm install -g @mariozechner/pi-coding-agent"
config={piModelsJson}
/>
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch pi directly with the exo provider and model selected."
config={piShellCommand}
language="bash"
/>
{:else if activeTab === "Open WebUI"}
<IntegrationCard
title="1. Start Open WebUI"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "exo",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+23 -10
View File
@@ -3,7 +3,7 @@ name = "exo"
version = "0.3.70"
description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
requires-python = "==3.13.*"
dependencies = [
"aiofiles>=24.1.0",
"aiohttp>=3.12.14",
@@ -15,11 +15,11 @@ dependencies = [
"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==0.31.1; sys_platform == 'darwin'",
"mlx-lm",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"mlx==0.31.2; sys_platform == 'darwin'",
"mlx-lm; sys_platform=='darwin'",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
@@ -28,8 +28,8 @@ dependencies = [
"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",
"mlx-vlm>=0.3.11; sys_platform == 'darwin'",
"transformers>=5.6.2",
]
[project.scripts]
@@ -49,15 +49,24 @@ dev = [
[project.optional-dependencies]
build = ["nanobind"]
cpu = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cpu==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"mlx-vlm>=0.3.11; sys_platform== 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
cuda12 = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cuda-12==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"mlx-vlm>=0.3.11; sys_platform== 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
cuda13 = [
"mlx==0.31.1; sys_platform == 'linux'",
"mlx-cuda-13==0.31.1; sys_platform == 'linux'",
"mlx-lm; sys_platform == 'linux'",
"mlx-vlm>=0.3.11; sys_platform== 'linux'",
"torch>=2.10.0; sys_platform == 'linux'",
]
@@ -71,11 +80,11 @@ members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo-pyo3-bindings = { workspace = true }
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-arrayscache-leak" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/deepseek-v4" }
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'" },
{ index = "pytorch-cpu", marker = "(extra != 'cuda12' and extra != 'cuda13' and sys_platform == 'linux') or sys_platform == 'darwin'" },
]
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
@@ -148,7 +157,11 @@ 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"]
constraint-dependencies = ["transformers>=5.6.2"]
override-dependencies = [
"mlx==0.31.1; sys_platform=='linux'",
"mlx; sys_platform=='darwin'",
]
[tool.uv.extra-build-dependencies]
miniaudio = ["setuptools", "cffi", "pycparser"]
+62 -68
View File
@@ -5,14 +5,13 @@ let
workspaceRoot = ../.;
};
mkPythonSet = { pkgs, lib, self' }:
mkPythonSet = { pkgs, lib, self', members }:
let
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
@@ -51,7 +50,7 @@ let
'';
};
};
buildSystemsOverlay = final: prev: { } //
buildSystemsOverlay = final: prev:
lib.optionalAttrs isDarwin
{
mlx = prev.mlx.overrideAttrs (old:
@@ -81,7 +80,7 @@ let
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 ];
++ [ 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;
@@ -113,42 +112,42 @@ let
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/"
'';
});
} // lib.optionalAttrs cudaSupport {
"${libmlx_source}" = prev."${libmlx_source}".overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
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" ];
});
};
mlx = prev.mlx.overrideAttrs (old: {
buildInputs = old.buildInputs ++ lib.optionals cudaSupport cudaLibs;
autoPatchelfIgnoreMissingDeps = lib.optionals cudaSupport [ "libcuda.so.1" ];
postInstall = ''
cp -r "${final.${libmlx_source}}/${final.python.sitePackages}/mlx" "$out/${final.python.sitePackages}/mlx/"
'';
});
} // lib.optionalAttrs cudaSupport {
"${libmlx_source}" = prev."${libmlx_source}".overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
buildInputs = old.buildInputs ++ [ pkgs.rdma-core ];
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
buildInputs = old.buildInputs ++ cudaLibs;
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
});
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" ];
});
};
pyprojectOverlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
dependencies = { exo = [ uv_extra ]; exo-bench = [ ]; };
dependencies = members;
};
editableOverlay = workspace.mkEditablePyprojectOverlay {
# Use environment variable pointing to editable root directory
@@ -165,8 +164,8 @@ let
buildSystemsOverlay
]
);
mkApp = cmd: name: members: pkgs.writeShellApplication {
venv = name: (pythonSet.mkVirtualEnv "${name}-env" members).overrideAttrs (_: { venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" ]; });
mkApp = cmd: name: pkgs.writeShellApplication {
inherit name;
runtimeEnv = {
EXO_DASHBOARD_DIR = self'.packages.dashboard;
@@ -174,17 +173,17 @@ let
};
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/*" ]; }))
(venv name)
]
++ lib.optionals isDarwin [ pkgs.macmon ];
text = "exec " + lib.optionalString cudaSupport "${lib.getExe pkgs.nix-gl-host} " + cmd;
};
in
{
inherit pythonSet;
inherit venv;
editablePythonSet = pythonSet.overrideScope editableOverlay;
mkPythonScript = members: name: path: mkApp ''python ${path} "$@"'' name members;
mkExo = name: members: mkApp ''exo "$@"'' name members;
mkPythonScript = path: mkApp ''python ${path} "$@"'';
mkExo = mkApp ''exo "$@"'';
};
in
{
@@ -192,16 +191,21 @@ in
{ 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" ]; };
inherit (mkPythonSet { inherit self' pkgs lib; members = { exo = [ "cpu" ]; }; }) editablePythonSet mkExo;
# 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
testVenv = (mkPythonSet {
inherit self' pkgs lib; members = {
exo = [ "dev" "cpu" ]; # Include pytest, pytest-asyncio, pytest-env
};
}).venv "exo-test";
mkBenchScript = mkPythonScript { exo-bench = [ ]; };
mkBenchScript = (mkPythonSet {
inherit self' pkgs lib; members = {
exo = [ "cpu" ];
exo-bench = [ ]; # Include pytest, pytest-asyncio, pytest-env
};
}).mkPythonScript;
mkSimplePythonScript = name: path: pkgs.writeShellApplication {
inherit name;
@@ -212,9 +216,7 @@ in
in
{
packages = {
exo = mkExo "exo" { exo = lib.optionals isLinux [ "cpu" ]; };
# for devShell
exo-venv = exoVenv;
exo = mkExo "exo";
editableVenv = editablePythonSet.mkVirtualEnv "exo-dev-env" { exo = [ "dev" ]; };
# for running tests in ci
exo-test-env = testVenv;
@@ -224,8 +226,8 @@ in
# 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" ]; };
exo-cuda-12 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_12) pkgs; members = { exo = [ "cuda12" ]; }; }).mkExo "exo-cuda-12";
exo-cuda-13 = (mkPythonSet { inherit self' lib; inherit (unfreePkgs.pkgsCuda.cudaPackages_13) pkgs; members = { exo = [ "cuda13" ]; }; }).mkExo "exo-cuda-13";
};
checks = {
@@ -235,19 +237,11 @@ in
touch $out
'';
typecheck = pkgs.runCommand "typecheck"
{
nativeBuildInputs = [
testVenv
pkgs.basedpyright
];
}
''
cd ${inputs.self}
export HOME=$TMPDIR
basedpyright --pythonpath ${testVenv}/bin/python --project ${inputs.self}/pyproject.toml
touch $out
'';
typecheck = pkgs.runCommand "typecheck" { nativeBuildInputs = [ testVenv ]; } ''
cd ${inputs.self}
basedpyright
touch $out
'';
};
};
}
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 131072
@@ -8,6 +8,7 @@ family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 131072
@@ -0,0 +1,21 @@
model_id = "mlx-community/DeepSeek-V4-Flash"
n_layers = 43
hidden_size = 4096
num_key_value_heads = 1
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V4 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 1048576
[storage_size]
in_bytes = 155095760030
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash
[sampling_defaults]
temperature = 1.0
top_p = 1.0
@@ -0,0 +1,21 @@
model_id = "mlx-community/DeepSeek-V4-Pro"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 1
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V4 Pro"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "tool_conditional"
context_length = 1048576
[storage_size]
in_bytes = 849681803879
# Source: https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro
[sampling_defaults]
temperature = 1.0
top_p = 1.0
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "bf16"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 131072
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "4bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "6bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "4bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "5bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "6bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "8bit"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "MXFP4-Q8"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -8,7 +8,7 @@ family = "glm"
quantization = "bf16"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
@@ -0,0 +1,21 @@
model_id = "mlx-community/GLM-5.1-DQ4plus-q8"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "8bit"
base_model = "GLM-5.1"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
in_bytes = 465173655552
# Source: https://huggingface.co/zai-org/GLM-5.1
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -0,0 +1,21 @@
model_id = "mlx-community/GLM-5.1-MXFP4-Q8"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "MXFP4-Q8"
base_model = "GLM-5.1"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
in_bytes = 405480321024
# Source: https://huggingface.co/zai-org/GLM-5.1
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -0,0 +1,21 @@
model_id = "mlx-community/GLM-5.1"
n_layers = 78
hidden_size = 6144
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "bf16"
base_model = "GLM-5.1"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 202752
[storage_size]
in_bytes = 1487822475264
# Source: https://huggingface.co/zai-org/GLM-5.1
# Source: https://docs.z.ai/api-reference/llm/chat-completion
[sampling_defaults]
temperature = 1.0
top_p = 0.95
@@ -8,7 +8,7 @@ family = "kimi"
quantization = ""
base_model = "Kimi K2"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "suffix"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "kimi"
quantization = ""
base_model = "Kimi K2.5"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "suffix"
context_length = 262144
[storage_size]
@@ -0,0 +1,33 @@
model_id = "mlx-community/Kimi-K2.6-mlx-DQ3_K_M-q8"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "kimi"
quantization = "3bit"
base_model = "Kimi K2.6"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "suffix"
context_length = 262144
[storage_size]
in_bytes = 470628683776
[vision]
image_token_id = 163605
model_type = "kimi_vl"
weights_repo = "exolabs/Kimi-K2.6-vision"
processor_repo = "moonshotai/Kimi-K2.6"
# Source: https://huggingface.co/moonshotai/Kimi-K2.6
[sampling_defaults]
temperature = 1.0
top_p = 0.95
min_p = 0.01
# Source: https://huggingface.co/moonshotai/Kimi-K2.6
[sampling_defaults.non_thinking]
temperature = 0.6
top_p = 0.95
min_p = 0.01
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "3bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "4bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "6bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "4bit-mxfp4"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "4bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "5bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "6bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "minimax"
quantization = "bf16"
base_model = "MiniMax M2.7"
capabilities = ["text", "thinking"]
reasoning_dialect = "post_last_user"
context_length = 196608
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "bf16"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 2B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 9B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 9B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -0,0 +1,35 @@
model_id = "mlx-community/Qwen3.6-27B-4bit"
n_layers = 64
hidden_size = 5120
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.6 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
in_bytes = 16054262240
# Source: https://huggingface.co/Qwen/Qwen3.6-27B#best-practices
# Source: https://unsloth.ai/docs/models/qwen3.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 20
min_p = 0.0
repetition_penalty = 1.0
presence_penalty = 1.5
# Source: https://huggingface.co/Qwen/Qwen3.6-27B#best-practices
# Source: https://unsloth.ai/docs/models/qwen3.5
[sampling_defaults.non_thinking]
temperature = 0.7
top_p = 0.8
top_k = 20
min_p = 0.0
repetition_penalty = 1.0
presence_penalty = 1.5
@@ -0,0 +1,35 @@
model_id = "mlx-community/Qwen3.6-27B-8bit"
n_layers = 64
hidden_size = 5120
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.6 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
in_bytes = 29500938720
# Source: https://huggingface.co/Qwen/Qwen3.6-27B#best-practices
# Source: https://unsloth.ai/docs/models/qwen3.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 20
min_p = 0.0
repetition_penalty = 1.0
presence_penalty = 1.5
# Source: https://huggingface.co/Qwen/Qwen3.6-27B#best-practices
# Source: https://unsloth.ai/docs/models/qwen3.5
[sampling_defaults.non_thinking]
temperature = 0.7
top_p = 0.8
top_k = 20
min_p = 0.0
repetition_penalty = 1.0
presence_penalty = 1.5
@@ -0,0 +1,35 @@
model_id = "mlx-community/Qwen3.6-27B-bf16"
n_layers = 64
hidden_size = 5120
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "bf16"
base_model = "Qwen3.6 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
in_bytes = 54713457120
# Source: https://huggingface.co/Qwen/Qwen3.6-27B#best-practices
# Source: https://unsloth.ai/docs/models/qwen3.5
[sampling_defaults]
temperature = 1.0
top_p = 0.95
top_k = 20
min_p = 0.0
repetition_penalty = 1.0
presence_penalty = 1.5
# Source: https://huggingface.co/Qwen/Qwen3.6-27B#best-practices
# Source: https://unsloth.ai/docs/models/qwen3.5
[sampling_defaults.non_thinking]
temperature = 0.7
top_p = 0.8
top_k = 20
min_p = 0.0
repetition_penalty = 1.0
presence_penalty = 1.5
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "4bit"
base_model = "Qwen3.6 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "5bit"
base_model = "Qwen3.6 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "8bit"
base_model = "Qwen3.6 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "qwen"
quantization = "bf16"
base_model = "Qwen3.6 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
reasoning_dialect = "post_last_user"
context_length = 262144
[storage_size]
@@ -8,7 +8,7 @@ family = "gpt-oss"
quantization = "MXFP4-Q8"
base_model = "GPT-OSS 120B"
capabilities = ["text", "thinking"]
reasoning_dialect = "channel"
context_length = 131072
[storage_size]
@@ -8,7 +8,7 @@ family = "gpt-oss"
quantization = "MXFP4-Q8"
base_model = "GPT-OSS 20B"
capabilities = ["text", "thinking"]
reasoning_dialect = "channel"
context_length = 131072
[storage_size]
@@ -0,0 +1,33 @@
model_id = "moonshotai/Kimi-K2.6"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 64
supports_tensor = true
tasks = ["TextGeneration"]
family = "kimi"
quantization = ""
base_model = "Kimi K2.6"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 595148192736
[vision]
image_token_id = 163605
model_type = "kimi_vl"
weights_repo = "exolabs/Kimi-K2.6-vision"
processor_repo = "moonshotai/Kimi-K2.6"
# Source: https://huggingface.co/moonshotai/Kimi-K2.6
[sampling_defaults]
temperature = 1.0
top_p = 0.95
min_p = 0.01
# Source: https://huggingface.co/moonshotai/Kimi-K2.6
[sampling_defaults.non_thinking]
temperature = 0.6
top_p = 0.95
min_p = 0.01
+9 -3
View File
@@ -131,9 +131,13 @@ async def chat_request_to_text_generation(
multimodal_content.append({"type": "text", "text": part.text})
else:
multimodal_content.append({"type": "image"})
chat_template_messages.append(
{"role": msg.role, "content": multimodal_content}
)
multimodal_msg: dict[str, Any] = {
"role": msg.role,
"content": multimodal_content,
}
if msg.reasoning_content is not None:
multimodal_msg["reasoning_content"] = msg.reasoning_content
chat_template_messages.append(multimodal_msg)
continue
msg_copy = msg.model_copy(update={"content": content})
@@ -168,6 +172,8 @@ async def chat_request_to_text_generation(
min_p=request.min_p,
repetition_penalty=request.repetition_penalty,
repetition_context_size=request.repetition_context_size,
presence_penalty=request.presence_penalty,
frequency_penalty=request.frequency_penalty,
images=images,
)
+62 -70
View File
@@ -113,6 +113,23 @@ def _extract_content(content: str | list[ResponseContentPart]) -> str:
)
def _append_tool_call(
chat_template_messages: list[dict[str, Any]], tool_call: dict[str, Any]
) -> None:
if chat_template_messages:
prev = chat_template_messages[-1]
if prev.get("role") == "assistant" and isinstance(prev.get("content"), str):
existing: list[dict[str, Any]] | None = prev.get("tool_calls")
if existing is None:
prev["tool_calls"] = [tool_call]
else:
existing.append(tool_call)
return
chat_template_messages.append(
{"role": "assistant", "content": "", "tool_calls": [tool_call]}
)
async def responses_request_to_text_generation(
request: ResponsesRequest,
) -> TextGenerationTaskParams:
@@ -182,59 +199,44 @@ async def responses_request_to_text_generation(
| McpCallInputItem()
| CustomToolCallInputItem()
):
chat_template_messages.append(
_append_tool_call(
chat_template_messages,
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
}
],
}
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
},
)
case (
LocalShellCallInputItem()
| ShellCallInputItem()
| ComputerCallInputItem()
):
chat_template_messages.append(
_append_tool_call(
chat_template_messages,
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.type,
"arguments": json.dumps(item.action),
},
}
],
}
"id": item.call_id,
"type": "function",
"function": {
"name": item.type,
"arguments": json.dumps(item.action),
},
},
)
case ApplyPatchCallInputItem():
chat_template_messages.append(
_append_tool_call(
chat_template_messages,
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": "apply_patch",
"arguments": json.dumps({"patch": item.patch}),
},
}
],
}
"id": item.call_id,
"type": "function",
"function": {
"name": "apply_patch",
"arguments": json.dumps({"patch": item.patch}),
},
},
)
case (
WebSearchCallInputItem()
@@ -254,21 +256,16 @@ async def responses_request_to_text_generation(
args = {"prompt": item.prompt}
else:
args = {"query": item.query}
chat_template_messages.append(
_append_tool_call(
chat_template_messages,
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.type,
"arguments": json.dumps(args),
},
}
],
}
"id": item.call_id,
"type": "function",
"function": {
"name": item.type,
"arguments": json.dumps(args),
},
},
)
case (
FunctionCallOutputInputItem()
@@ -320,21 +317,16 @@ async def responses_request_to_text_generation(
}
)
case McpApprovalRequestInputItem():
chat_template_messages.append(
_append_tool_call(
chat_template_messages,
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
}
],
}
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
},
)
case McpApprovalResponseInputItem():
chat_template_messages.append(
+98 -25
View File
@@ -79,6 +79,8 @@ from exo.api.types import (
ImageListItem,
ImageListResponse,
ImageSize,
InstanceLinkBody,
InstanceLinkResponse,
ModelList,
ModelListModel,
PlaceInstanceParams,
@@ -122,6 +124,7 @@ from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
from exo.shared.constants import (
DASHBOARD_DIR,
ENABLE_DISAGGREGATION,
EXO_CACHE_HOME,
EXO_EVENT_LOG_DIR,
EXO_IMAGE_CACHE_DIR,
@@ -154,6 +157,7 @@ from exo.shared.types.commands import (
DeleteCustomModelCard,
DeleteDownload,
DeleteInstance,
DeleteInstanceLink,
DownloadCommand,
ForwarderCommand,
ForwarderDownloadCommand,
@@ -161,6 +165,7 @@ from exo.shared.types.commands import (
ImageGeneration,
PlaceInstance,
SendInputChunk,
SetInstanceLink,
StartDownload,
TaskCancelled,
TaskFinished,
@@ -174,6 +179,7 @@ from exo.shared.types.events import (
InstanceDeleted,
TracesMerged,
)
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.memory import Memory
from exo.shared.types.state import State
from exo.shared.types.tasks import (
@@ -185,7 +191,10 @@ from exo.shared.types.tasks import (
from exo.shared.types.tasks import (
TextGeneration as TextGenerationTask,
)
from exo.shared.types.text_generation import Base64Image, TextGenerationTaskParams
from exo.shared.types.text_generation import (
Base64ImageHash,
TextGenerationTaskParams,
)
from exo.shared.types.worker.downloads import DownloadCompleted
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding
@@ -212,6 +221,17 @@ def _ensure_seed(params: AdvancedImageParams | None) -> AdvancedImageParams:
return params
def _require_disaggregation_enabled() -> None:
if not ENABLE_DISAGGREGATION:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=(
"Prefill/decode disaggregation is disabled. "
"Set ENABLE_DISAGGREGATION=true to enable."
),
)
class API:
def __init__(
self,
@@ -234,6 +254,7 @@ class API:
self.node_id: NodeId = node_id
self.last_completed_election: int = 0
self.port = port
self._sent_image_hashes: set[str] = set()
self.paused: bool = False
self.paused_ev: anyio.Event = anyio.Event()
@@ -283,6 +304,7 @@ class API:
self.event_receiver.close()
self.event_receiver = event_receiver
self._tg.start_soon(self._apply_state)
self._sent_image_hashes = set()
def unpause(self, result_clock: int):
logger.info("Unpausing API")
@@ -323,6 +345,11 @@ class API:
self.app.get("/instance/previews")(self.get_placement_previews)
self.app.get("/instance/{instance_id}")(self.get_instance)
self.app.delete("/instance/{instance_id}")(self.delete_instance)
self.app.get("/v1/instance-links")(self.list_instance_links)
self.app.post("/v1/instance-links")(self.create_instance_link)
self.app.put("/v1/instance-links/{link_id}")(self.update_instance_link)
self.app.delete("/v1/instance-links/{link_id}")(self.delete_instance_link)
self.app.get("/v1/feature-flags")(self.get_feature_flags)
self.app.get("/models")(self.get_models)
self.app.get("/v1/models")(self.get_models)
self.app.post("/models/add")(self.add_custom_model)
@@ -331,7 +358,9 @@ class API:
self.app.post("/v1/chat/completions", response_model=None)(
self.chat_completions
)
self.app.post("/bench/chat/completions")(self.bench_chat_completions)
self.app.post("/bench/chat/completions", response_model=None)(
self.bench_chat_completions
)
self.app.post("/v1/images/generations", response_model=None)(
self.image_generations
)
@@ -610,6 +639,49 @@ class API:
instance_id=instance_id,
)
async def get_feature_flags(self) -> dict[str, bool]:
return {"disaggregation": ENABLE_DISAGGREGATION}
async def list_instance_links(self) -> list[InstanceLink]:
if not ENABLE_DISAGGREGATION:
return []
return list(self.state.instance_links.values())
async def create_instance_link(
self, body: InstanceLinkBody
) -> InstanceLinkResponse:
_require_disaggregation_enabled()
return await self._set_instance_link(InstanceLinkId(), body)
async def update_instance_link(
self, link_id: InstanceLinkId, body: InstanceLinkBody
) -> InstanceLinkResponse:
_require_disaggregation_enabled()
return await self._set_instance_link(link_id, body)
async def _set_instance_link(
self, link_id: InstanceLinkId, body: InstanceLinkBody
) -> InstanceLinkResponse:
command = SetInstanceLink(
link_id=link_id,
prefill_instances=list(body.prefill_instances),
decode_instances=list(body.decode_instances),
)
await self._send(command)
return InstanceLinkResponse(
message="Command received.", command_id=command.command_id
)
async def delete_instance_link(
self, link_id: InstanceLinkId
) -> InstanceLinkResponse:
_require_disaggregation_enabled()
command = DeleteInstanceLink(link_id=link_id)
await self._send(command)
return InstanceLinkResponse(
message="Command received.", command_id=command.command_id
)
async def cancel_command(self, command_id: CommandId) -> CancelCommandResponse:
"""Cancel an active command by closing its stream and notifying workers."""
sender = self._text_generation_queues.get(
@@ -737,8 +809,6 @@ class API:
"TODO: we should send a notification to the user to download the model"
)
_sent_image_hashes: set[str] = set()
async def _send_text_generation_with_images(
self, task_params: TextGenerationTaskParams
) -> TextGeneration:
@@ -750,23 +820,19 @@ class API:
return command
hashes = [hashlib.sha256(img.encode("ascii")).hexdigest() for img in images]
all_hashes = {idx: Base64ImageHash(h) for idx, h in enumerate(hashes)}
task_params = task_params.model_copy(
update={"images": [], "image_hashes": all_hashes}
)
command = TextGeneration(task_params=task_params)
cached_hashes: dict[int, str] = {}
new_images: list[tuple[int, str]] = []
for idx, (img, h) in enumerate(zip(images, hashes, strict=True)):
if h in self._sent_image_hashes:
cached_hashes[idx] = h
else:
if h not in self._sent_image_hashes:
self._sent_image_hashes.add(h)
new_images.append((idx, img))
wrapped_hashes = {idx: Base64Image(h) for idx, h in cached_hashes.items()}
if not new_images:
task_params = task_params.model_copy(
update={"images": [], "image_hashes": wrapped_hashes}
)
command = TextGeneration(task_params=task_params)
await self._send(command)
return command
@@ -775,16 +841,6 @@ class API:
for i in range(0, len(img_data), EXO_MAX_CHUNK_SIZE):
all_chunks.append((img_idx, img_data[i : i + EXO_MAX_CHUNK_SIZE]))
task_params = task_params.model_copy(
update={
"images": [],
"image_hashes": wrapped_hashes,
"total_input_chunks": len(all_chunks),
"image_count": len(new_images),
}
)
command = TextGeneration(task_params=task_params)
for global_idx, (img_idx, chunk_data) in enumerate(all_chunks):
await self._send(
SendInputChunk(
@@ -840,7 +896,7 @@ class API:
async def bench_chat_completions(
self, payload: BenchChatCompletionRequest
) -> BenchChatCompletionResponse:
) -> BenchChatCompletionResponse | StreamingResponse:
task_params = await chat_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(
ModelId(task_params.model)
@@ -857,6 +913,22 @@ class API:
command = await self._send_text_generation_with_images(task_params)
if payload.stream:
return StreamingResponse(
with_sse_keepalive(
generate_chat_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
),
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
return await self._collect_text_generation_with_stats(command.command_id)
async def _resolve_and_validate_text_model(self, model_id: ModelId) -> ModelId:
@@ -1674,6 +1746,7 @@ class API:
quantization=card.quantization,
base_model=card.base_model,
capabilities=card.capabilities,
reasoning_dialect=card.reasoning_dialect,
context_length=card.context_length,
)
for card in cards
+2
View File
@@ -34,6 +34,8 @@ from .api import ImageGenerationTaskParams as ImageGenerationTaskParams
from .api import ImageListItem as ImageListItem
from .api import ImageListResponse as ImageListResponse
from .api import ImageSize as ImageSize
from .api import InstanceLinkBody as InstanceLinkBody
from .api import InstanceLinkResponse as InstanceLinkResponse
from .api import Logprobs as Logprobs
from .api import LogprobsContentItem as LogprobsContentItem
from .api import ModelList as ModelList
+12 -1
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, field_validator
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.common import CommandId, NodeId
from exo.shared.types.memory import Memory
from exo.shared.types.text_generation import ReasoningEffort
from exo.shared.types.text_generation import ReasoningDialect, ReasoningEffort
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding, ShardMetadata
from exo.utils.pydantic_ext import FrozenModel
@@ -48,6 +48,7 @@ class ModelListModel(BaseModel):
quantization: str = Field(default="")
base_model: str = Field(default="")
capabilities: list[str] = Field(default_factory=list)
reasoning_dialect: ReasoningDialect = "none"
class ModelList(BaseModel):
@@ -295,6 +296,16 @@ class CancelCommandResponse(BaseModel):
command_id: CommandId
class InstanceLinkBody(BaseModel):
prefill_instances: list[InstanceId]
decode_instances: list[InstanceId]
class InstanceLinkResponse(BaseModel):
message: str
command_id: CommandId
ImageSize = Literal[
"auto",
"512x512",
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more