Compare commits

...

45 Commits

Author SHA1 Message Date
ciaranbor f5115827ce Monitor instance health
Don't checkpoint failed evals
Auto-delete stale instances before placement
2026-04-01 19:57:14 +01:00
ciaranbor 8290afa642 Don't timout for downloads 2026-04-01 19:57:14 +01:00
ciaranbor 19adb4a763 Add models 2026-04-01 19:57:14 +01:00
ciaranbor b7b89f6e0f Remove power_usage from response in favour of existing energy polling in exo bench 2026-04-01 19:57:14 +01:00
ciaranbor 7339b6426d Fix Kimi-K2.5 tokenization 2026-04-01 19:57:14 +01:00
ciaranbor 2555b0e649 Resolve higher-node placements first 2026-04-01 19:57:14 +01:00
ciaranbor a4101918bf Add power/energy measurements to exo_bench 2026-04-01 19:57:14 +01:00
ciaranbor 58f5046de1 Extend eval harness:
- top_k, min_p, enable_thinking params
- capture reasoning_content, finish_reason, power/energy
- checkpoint/resume support
- instance resuse
- LCB release_version param
2026-04-01 19:57:14 +01:00
ciaranbor 4f623f4090 Align models.toml configs with vllm run 2026-04-01 19:57:14 +01:00
rltakashige 4688adb5d2 Support PDFs in dashboard (#1822)
Like ChatGPT does, we now send both the extracted text and the image of
each PDF page.
2026-03-31 18:25:40 +01:00
rltakashige d9ed943034 Fix Nemotron cache leak upstream (#1819)
## Motivation
Nemotron Cascade and Nano failing at long decodes.

## Changes

Fixed upstream, just change pyproject and uv lock here.


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

Addresses #1816 

## Changes

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

## Test Plan

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

Images!

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

## Test Plan

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

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

(And batching also works)

---------

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

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

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

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

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

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

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

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

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

## Changes

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

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

## Why It Works

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

## Test Plan

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

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

---------

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

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

## Changes

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

## Why It Works

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

## Test Plan

### Manual Testing

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

### Automated Testing

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

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

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

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

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

---------

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

### the issue

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

### testing

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

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

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

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

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

## Changes

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

## Why It Works

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

## Test Plan

### Automated Testing

New tests in src/exo/worker/tests/unittests/test_image
2026-03-25 16:56:04 +00:00
vskiwi fc1ae90111 fix: DeepSeek V3.2 warmup crash and tool calling + add catalog cards (#1769)
## Summary

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

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

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

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

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

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

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

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

### Catalog cards

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

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

## Notes

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

## Test plan

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

---------

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

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

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

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

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

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

## Changes
Adds two new CLI flags:

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

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

## Why It Works

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

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

Docker Compose example:

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

## Test Plan

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

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

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

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

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

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

### Automated Testing

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

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

All 4 pass. The connection test takes ~6s

---------

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

---------

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

Minor regression from #1746 

## Why It Works

Pass thinking properly

## Test Plan

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

---------

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

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

## Test Plan

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

## Test Plan

### Manual Testing

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

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

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

## Changes

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

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

## Test Plan

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

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

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


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



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


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


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

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

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

## Changes

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

## Why It Works

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

## Test Plan

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

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

## Changes

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

## Why It Works

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

## Test Plan

### Manual Testing

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

### Automated Testing

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

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

## Changes

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

## Why It Works

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

## Test Plan

### Automated Testing

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

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

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

## Motivation

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

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

## Test plan

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

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

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

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

## Changes

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

## Why It Works

- Deduplicating by model_id in apply.py ensures each node has exactly
one download entry per model
- The perNodeMap in the frontend keeps only the latest event per node,
preventing duplicate bars
- Handling DownloadCompleted allows the UI to show finished downloads
instead of dropping them
- Scoping instance previews to assigned nodes avoids showing irrelevant
download progress
2026-03-19 14:47:45 +00:00
222 changed files with 8414 additions and 2158 deletions
+9 -1
View File
@@ -159,7 +159,7 @@ jobs:
fi
- name: Install Homebrew packages
run: brew install just awscli macmon
run: brew install just awscli
- name: Install UV
uses: astral-sh/setup-uv@v6
@@ -243,6 +243,14 @@ jobs:
# Build the bundle
# ============================================================
- name: Add pinned macmon to PATH
run: |
MACMON_DIR=$(nix develop --command sh -c 'dirname $(which macmon)')
echo "Using macmon from: $MACMON_DIR"
echo "$MACMON_DIR" >> $GITHUB_PATH
# Remove any Homebrew macmon so PyInstaller can't accidentally pick it up
brew uninstall macmon 2>/dev/null || true
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
+1 -1
View File
@@ -2396,7 +2396,7 @@ def degrees(a: array, /, *, stream: Stream | Device | None = ...) -> array:
array: The angles in degrees.
"""
def depends(inputs: array | Sequence[array], dependencies: array | Sequence[array]):
def depends[T](inputs: T, dependencies: array | Sequence[array]) -> T:
"""
Insert dependencies between arrays in the graph. The outputs are
identical to ``inputs`` but with dependencies on ``dependencies``.
+2 -6
View File
@@ -1,9 +1,5 @@
"""
This type stub file was generated by pyright.
"""
from layers import *
from utils import *
from .layers import *
from .utils import *
from . import init as init
from . import losses as losses
+16 -20
View File
@@ -1,20 +1,16 @@
"""
This type stub file was generated by pyright.
"""
from activations import *
from base import *
from containers import *
from convolution import *
from convolution_transpose import *
from distributed import *
from dropout import *
from embedding import *
from linear import *
from normalization import *
from pooling import *
from positional_encoding import *
from quantized import *
from recurrent import *
from transformer import *
from upsample import *
from .activations import *
from .base import *
from .containers import *
from .convolution import *
from .convolution_transpose import *
from .distributed import *
from .dropout import *
from .embedding import *
from .linear import *
from .normalization import *
from .pooling import *
from .positional_encoding import *
from .quantized import *
from .recurrent import *
from .transformer import *
from .upsample import *
+1 -1
View File
@@ -53,7 +53,7 @@ class Module(dict):
mx.eval(model.parameters())
"""
__call__: Callable
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
def __init__(self) -> None:
"""Should be called by the subclasses of ``Module``."""
+10 -5
View File
@@ -30,7 +30,7 @@ def str2bool(string): # -> bool:
def setup_arg_parser(): # -> ArgumentParser:
"""Set up and return the argument parser."""
generation_stream = ...
generation_stream: mx.Stream
@contextlib.contextmanager
def wired_limit(
@@ -266,12 +266,12 @@ def _merge_caches(caches: Any) -> List[Any]: ...
class Batch:
uids: List[int]
y: mx.array
logprobs: mx.array
logprobs: List[mx.array] | mx.array
max_tokens: List[int]
num_tokens: List[int]
cache: List[Any]
samplers: List[Any]
logits_processors: List[Any]
samplers: List[Callable[[mx.array], mx.array] | None]
logits_processors: List[List[Callable[[mx.array, mx.array], mx.array]]]
tokens: List[mx.array]
def __len__(self) -> int: ...
def filter(self, keep_idx: List[int]) -> None: ...
@@ -279,13 +279,18 @@ class Batch:
def extract_cache(self, idx: int) -> List[Any]: ...
class BatchGenerator:
model: Any
model: nn.Module
sampler: Callable[[mx.array], mx.array]
stop_tokens: set[int]
max_kv_size: Optional[int]
prefill_step_size: int
completion_batch_size: int
prefill_batch_size: int
unprocessed_prompts: List[Any]
active_batch: Optional[Batch]
prompt_progress_callback: Callable[[List[Tuple[int, int, int]]], None]
_stats: BatchStats
_next_count: int
@dataclass
class Response:
+25 -31
View File
@@ -88,8 +88,8 @@ def create_attention_mask(
) -> array | Literal["causal"] | None: ...
class _BaseCache(Cache):
keys: mx.array
values: mx.array
keys: mx.array | None
values: mx.array | None
offset: int
@property
def state(self) -> tuple[mx.array | None, mx.array | None]: ...
@@ -268,29 +268,14 @@ class CacheList(_BaseCache):
"""
class BatchKVCache(_BaseCache):
step = ...
def __init__(self, left_padding: List[int]) -> None:
"""
The BatchKV cache expects inputs to be left-padded.
E.g. the following prompts:
[1, 3, 5]
[7]
[2, 6, 8, 9]
Should be padded like so:
[0, 1, 3, 5]
[0, 0, 0, 7]
[2, 6, 8, 9]
And ``left_padding`` specifies the amount of padding for each.
In this case, ``left_padding = [1, 3, 0]``.
"""
def update_and_fetch(self, keys, values): # -> tuple[array | Any, array | Any]:
...
step: int
keys: array | None
values: array | None
offset: array
left_padding: array
_idx: int
def __init__(self, left_padding: List[int]) -> None: ...
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
@property
def state(
self,
@@ -316,12 +301,21 @@ class BatchKVCache(_BaseCache):
"""
class BatchRotatingKVCache(_BaseCache):
step = ...
def __init__(self, max_size, left_padding: List[int]) -> None: ...
def update_and_fetch(
self, keys, values
): # -> tuple[array | Any, array | Any] | tuple[array | Any, array | Any | None]:
...
step: int
keys: array | None
values: array | None
offset: array
left_padding: array
max_size: int
_idx: int
_offset: int
rotated: bool
_lengths: array | None
def __init__(self, max_size: int, left_padding: List[int]) -> None: ...
def _trim(self, trim_size: int, v: array, append: array | None = ...) -> array: ...
def _update_in_place(self, keys: array, values: array) -> tuple[array, array]: ...
def _update_concat(self, keys: array, values: array) -> tuple[array, array]: ...
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
@property
def state(
self,
@@ -0,0 +1,35 @@
from typing import Optional
import mlx.core as mx
def compute_g(A_log: mx.array, a: mx.array, dt_bias: mx.array) -> mx.array: ...
def gated_delta_update(
q: mx.array,
k: mx.array,
v: mx.array,
a: mx.array,
b: mx.array,
A_log: mx.array,
dt_bias: mx.array,
state: Optional[mx.array] = ...,
mask: Optional[mx.array] = ...,
use_kernel: bool = ...,
) -> tuple[mx.array, mx.array]: ...
def gated_delta_ops(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: Optional[mx.array] = ...,
mask: Optional[mx.array] = ...,
) -> tuple[mx.array, mx.array]: ...
def gated_delta_kernel(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = ...,
) -> tuple[mx.array, mx.array]: ...
+51
View File
@@ -0,0 +1,51 @@
from typing import Any, Optional
import mlx.nn as nn
class YarnRoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
scaling_factor: float = ...,
original_max_position_embeddings: int = ...,
beta_fast: float = ...,
beta_slow: float = ...,
mscale: float = ...,
mscale_all_dim: float = ...,
) -> None: ...
class Llama3RoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
scaling_factor: float = ...,
original_max_position_embeddings: int = ...,
low_freq_factor: float = ...,
high_freq_factor: float = ...,
) -> None: ...
class SuScaledRoPE(nn.Module):
def __init__(
self,
dims: int,
traditional: bool = ...,
max_position_embeddings: int = ...,
base: float = ...,
short_factor: Any = ...,
long_factor: Any = ...,
original_max_position_embeddings: int = ...,
) -> None: ...
def initialize_rope(
dims: int,
base: float = ...,
traditional: bool = ...,
scaling_config: Optional[dict[str, Any]] = ...,
max_position_embeddings: Optional[int] = ...,
) -> nn.Module: ...
View File
+12
View File
@@ -0,0 +1,12 @@
from typing import Any
def get_message_json(
model_name: str,
prompt: str,
role: str = "user",
skip_image_token: bool = False,
skip_audio_token: bool = False,
num_images: int = 0,
num_audios: int = 0,
**kwargs: Any,
) -> dict[str, Any]: ...
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
from typing import Any
class ImageProcessor:
def preprocess(
self, images: list[dict[str, Any]], **kwargs: Any
) -> dict[str, Any]: ...
def __call__(self, **kwargs: Any) -> dict[str, Any]: ...
def load_image_processor(
model_path: str | Path, **kwargs: Any
) -> ImageProcessor | None: ...
def load_processor(
model_path: str | Path, add_detokenizer: bool = ..., **kwargs: Any
) -> ImageProcessor: ...
+8
View File
@@ -0,0 +1,8 @@
from typing import Any, Self
class safe_open:
def __init__(self, filename: str, framework: str = "pt") -> None: ...
def __enter__(self) -> Self: ...
def __exit__(self, *args: Any) -> None: ...
def keys(self) -> list[str]: ...
def get_tensor(self, name: str) -> Any: ...
+11 -2
View File
@@ -11,9 +11,18 @@ To run EXO from source:
```bash
brew install uv
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
```bash
brew install macmon
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup toolchain install nightly
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
Use the pinned fork revision used by this repo instead of Homebrew `macmon`.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
macmon \
--force
```
```bash
+20 -6
View File
@@ -95,11 +95,10 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
- [uv](https://github.com/astral-sh/uv) (for Python dependency management)
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
- [node](https://github.com/nodejs/node) (for building the dashboard)
```bash
brew install uv macmon node
brew install uv node
```
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
@@ -107,6 +106,17 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup toolchain install nightly
```
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
Install the pinned fork revision used by this repo instead of Homebrew `macmon`.
Homebrew `macmon 0.6.1` still crashes on Apple M5.
```bash
cargo install --git https://github.com/swiftraccoon/macmon \
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
macmon \
--force
```
Clone the repo, build the dashboard, and run exo:
@@ -285,8 +295,9 @@ exo supports several environment variables for configuration:
| Variable | Description | Default |
|----------|-------------|---------|
| `EXO_MODELS_PATH` | Colon-separated paths to search for pre-downloaded models (e.g., on NFS mounts or shared storage) | None |
| `EXO_MODELS_DIR` | Directory where exo downloads and stores models | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_DEFAULT_MODELS_DIR` | Default directory for model downloads and caches. Always first in the writable dirs list. | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_MODELS_DIRS` | Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
| `EXO_MODELS_READ_ONLY_DIRS` | Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
| `EXO_OFFLINE` | Run without internet connection (uses only local models) | `false` |
| `EXO_ENABLE_IMAGE_MODELS` | Enable image model support | `false` |
| `EXO_LIBP2P_NAMESPACE` | Custom namespace for cluster isolation | None |
@@ -296,8 +307,11 @@ exo supports several environment variables for configuration:
**Example usage:**
```bash
# Use pre-downloaded models from NFS mount
EXO_MODELS_PATH=/mnt/nfs/models:/opt/ai-models uv run exo
# Use pre-downloaded models from NFS mount (read-only)
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
# Download models to an external SSD (falls back to default dir if full)
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
# Run in offline mode
EXO_OFFLINE=true uv run exo
+12
View File
@@ -4,6 +4,7 @@ import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let hfEndpointKey = "EXOHFEndpoint"
private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
@@ -53,6 +54,14 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
}
}
@Published var hfEndpoint: String = {
return UserDefaults.standard.string(forKey: hfEndpointKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(hfEndpoint, forKey: hfEndpointKey)
}
}
@Published var enableImageModels: Bool = {
return UserDefaults.standard.bool(forKey: enableImageModelsKey)
}()
@@ -273,6 +282,9 @@ final class ExoProcessController: ObservableObject {
if !hfToken.isEmpty {
environment["HF_TOKEN"] = hfToken
}
if !hfEndpoint.isEmpty {
environment["HF_ENDPOINT"] = hfEndpoint
}
if enableImageModels {
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
}
+15
View File
@@ -12,6 +12,7 @@ struct SettingsView: View {
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@State private var pendingHFEndpoint: String = ""
@State private var pendingEnableImageModels = false
@State private var pendingOfflineMode = false
@State private var needsRestart = false
@@ -42,6 +43,7 @@ struct SettingsView: View {
.onAppear {
pendingNamespace = controller.customNamespace
pendingHFToken = controller.hfToken
pendingHFEndpoint = controller.hfEndpoint
pendingEnableImageModels = controller.enableImageModels
pendingOfflineMode = controller.offlineMode
needsRestart = false
@@ -74,6 +76,17 @@ struct SettingsView: View {
.foregroundColor(.secondary)
}
Section {
LabeledContent("HuggingFace Endpoint") {
TextField("default", text: $pendingHFEndpoint)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
Toggle("Offline Mode", isOn: $pendingOfflineMode)
Text("Skip internet checks and use only locally available models.")
@@ -454,6 +467,7 @@ struct SettingsView: View {
private var hasGeneralChanges: Bool {
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
|| pendingHFEndpoint != controller.hfEndpoint
|| pendingOfflineMode != controller.offlineMode
}
@@ -464,6 +478,7 @@ struct SettingsView: View {
private func applyGeneralSettings() {
controller.customNamespace = pendingNamespace
controller.hfToken = pendingHFToken
controller.hfEndpoint = pendingHFEndpoint
controller.offlineMode = pendingOfflineMode
restartIfRunning()
}
+68 -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,8 @@ 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.5"
@@ -231,6 +273,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 +295,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
+7 -2
View File
@@ -17,6 +17,7 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -1006,6 +1007,7 @@ Examples:
sys.exit(1)
time.sleep(1)
cluster_snapshot = capture_cluster_snapshot(exo)
all_results: list[ScenarioResult] = []
try:
@@ -1084,16 +1086,19 @@ Examples:
print(f" - {r.name} [{r.api}/{r.phase}]: {r.error}", file=log)
json_results = [result_to_dict(r) for r in all_results]
output: dict[str, Any] = {"results": json_results}
if cluster_snapshot:
output["cluster"] = cluster_snapshot
if args.stdout:
print(json.dumps(json_results, indent=2))
print(json.dumps(output, indent=2))
else:
json_path = args.json_out
parent = os.path.dirname(json_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(json_path, "w") as f:
json.dump(json_results, f, indent=2)
json.dump(output, f, indent=2)
f.write("\n")
print(f"\nJSON results written to {json_path}", file=log)
+150 -14
View File
@@ -22,6 +22,7 @@ import contextlib
import itertools
import json
import sys
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -33,7 +34,9 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
run_planning_phase,
@@ -76,7 +79,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"],
)
)
@@ -131,6 +134,91 @@ def format_peak_memory(b: float) -> str:
raise ValueError("You're using petabytes of memory. Something went wrong...")
_SAMPLER_METRICS = ("gpuUsage", "temp", "sysPower", "pcpuUsage", "ecpuUsage")
class SystemMetricsSampler:
def __init__(self, client: ExoClient, node_ids: list[str], interval_s: float = 1.0):
self._client = client
self._node_ids = node_ids
self._interval_s = interval_s
self._samples: dict[str, list[tuple[float, dict[str, float]]]] = {
nid: [] for nid in node_ids
}
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
self._stop.clear()
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
def _poll_loop(self) -> None:
while not self._stop.is_set():
t = time.monotonic()
for nid in self._node_ids:
try:
data = self._client.get_node_system(nid)
if data:
self._samples[nid].append(
(t, {k: data.get(k, 0.0) for k in _SAMPLER_METRICS})
)
except Exception:
pass
self._stop.wait(self._interval_s)
def energy_between(self, t0: float, t1: float) -> float:
total_joules = 0.0
for _nid, samples in self._samples.items():
window = [(t, s["sysPower"]) for t, s in samples if t0 <= t <= t1]
if len(window) >= 2:
for i in range(1, len(window)):
dt = window[i][0] - window[i - 1][0]
avg_power = (window[i][1] + window[i - 1][1]) / 2
total_joules += avg_power * dt
elif len(window) == 1:
total_joules += window[0][1] * (t1 - t0)
return total_joules
def summarize(self) -> dict[str, dict[str, dict[str, float]]]:
result: dict[str, dict[str, dict[str, float]]] = {}
for nid, samples in self._samples.items():
if not samples:
continue
metrics: dict[str, dict[str, float]] = {}
for key in _SAMPLER_METRICS:
values = [s[key] for t, s in samples]
metrics[key] = {
"min": round(min(values), 2),
"max": round(max(values), 2),
"mean": round(mean(values), 2),
"samples": len(values),
}
result[nid] = metrics
return result
def print_summary(self, placement_label: str) -> None:
summary = self.summarize()
if not summary:
return
logger.info(f"--- System Metrics ({placement_label}) ---")
for nid, metrics in summary.items():
gpu = metrics.get("gpuUsage", {})
temp = metrics.get("temp", {})
power = metrics.get("sysPower", {})
logger.info(
f" {nid}: "
f"GPU {gpu.get('mean', 0) * 100:.0f}% avg ({gpu.get('min', 0) * 100:.0f}{gpu.get('max', 0) * 100:.0f}%) | "
f"{temp.get('mean', 0):.1f}°C avg | "
f"{power.get('mean', 0):.1f}W avg"
)
def parse_int_list(values: list[str]) -> list[int]:
items: list[int] = []
for v in values:
@@ -279,6 +367,17 @@ def main() -> int:
action="store_true",
help="Force all pp×tg combinations (cartesian product) even when lists have equal length.",
)
ap.add_argument(
"--no-system-metrics",
action="store_true",
help="Disable GPU utilization, temperature, and power collection during inference.",
)
ap.add_argument(
"--metrics-interval",
type=float,
default=1.0,
help="System metrics polling interval in seconds (default: 1.0).",
)
args = ap.parse_args()
pp_list = parse_int_list(args.pp)
@@ -331,7 +430,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,
)
@@ -364,7 +463,9 @@ def main() -> int:
else:
logger.info("Download: model already cached")
cluster_snapshot = capture_cluster_snapshot(client)
all_rows: list[dict[str, Any]] = []
all_system_metrics: dict[str, dict[str, dict[str, float]]] = {}
for preview in selected:
instance = preview["instance"]
@@ -390,6 +491,16 @@ def main() -> int:
time.sleep(1)
sampler: SystemMetricsSampler | None = None
if not args.no_system_metrics:
nids = node_ids_from_instance(instance)
sampler = SystemMetricsSampler(
ExoClient(args.host, args.port, timeout_s=30),
nids,
interval_s=args.metrics_interval,
)
sampler.start()
try:
for i in range(args.warmup):
run_one_completion(
@@ -408,15 +519,18 @@ def main() -> int:
for concurrency in concurrency_list:
logger.info(f"--- pp={pp} tg={tg} concurrency={concurrency} ---")
runs: list[dict[str, Any]] = []
inference_windows: list[tuple[float, float]] = []
for r in range(args.repeat):
time.sleep(3)
if concurrency <= 1:
# Sequential: single request
try:
inf_t0 = time.monotonic()
row, actual_pp_tokens = run_one_completion(
client, full_model_id, pp, tg, prompt_sizer
)
inference_windows.append((inf_t0, time.monotonic()))
except Exception as e:
logger.error(e)
continue
@@ -457,6 +571,7 @@ def main() -> int:
c, full_model_id, _pp, _tg, prompt_sizer
)
inf_t0 = time.monotonic()
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {
pool.submit(_run_concurrent, i): i
@@ -468,6 +583,7 @@ def main() -> int:
except Exception as e:
logger.error(f"Concurrent request failed: {e}")
batch_errors += 1
inference_windows.append((inf_t0, time.monotonic()))
for idx, (row, actual_pp_tokens) in enumerate(
batch_results
@@ -501,36 +617,50 @@ def main() -> int:
for x, _ in batch_results
if x["stats"]["generation_tps"] > 0
]
agg_gen_tps = (
per_req_tps = (
mean(valid_gen_tps) if valid_gen_tps else 0.0
)
gen_tps = agg_gen_tps / concurrency
agg_gen_tps = per_req_tps * concurrency
logger.info(
f"[concurrent {concurrency}x] "
f"agg_gen_tps={agg_gen_tps:.2f} "
f"gen_tps={gen_tps:.2f} "
f"per_req_tps={per_req_tps:.2f} "
f"errors={batch_errors}"
)
if runs:
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
gen_tps = mean(
x["stats"]["generation_tps"] / x["concurrency"]
for x in runs
)
per_req_tps = mean(x["stats"]["generation_tps"] for x in runs)
gen_tps = per_req_tps * concurrency
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
peak = mean(
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
)
logger.info(
summary = (
f"prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
f"peak_memory={format_peak_memory(peak)}\n"
f"peak_memory={format_peak_memory(peak)}"
)
if sampler and inference_windows:
joules = sum(
sampler.energy_between(t0, t1)
for t0, t1 in inference_windows
)
inf_seconds = sum(t1 - t0 for t0, t1 in inference_windows)
avg_watts = joules / inf_seconds if inf_seconds > 0 else 0
summary += f" energy={joules:.1f}J ({avg_watts:.1f}W avg over {inf_seconds:.1f}s inference)"
logger.info(f"{summary}\n")
time.sleep(2)
finally:
if sampler:
sampler.stop()
placement_label = f"{sharding}/{instance_meta}/{n_nodes} nodes"
sampler.print_summary(placement_label)
placement_metrics = sampler.summarize()
if placement_metrics:
all_system_metrics.update(placement_metrics)
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
@@ -541,11 +671,17 @@ def main() -> int:
time.sleep(5)
output: dict[str, Any] = {"runs": all_rows}
if cluster_snapshot:
output["cluster"] = cluster_snapshot
if all_system_metrics:
output["system_metrics"] = all_system_metrics
if args.stdout:
json.dump(all_rows, sys.stdout, indent=2, ensure_ascii=False)
json.dump(output, sys.stdout, indent=2, ensure_ascii=False)
elif args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(all_rows, f, indent=2, ensure_ascii=False)
json.dump(output, f, indent=2, ensure_ascii=False)
logger.debug(f"\nWrote results JSON: {args.json_out}")
return 0
+473 -90
View File
@@ -46,6 +46,7 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -61,6 +62,12 @@ 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."""
pass
DEFAULT_MAX_TOKENS = 16_384
REASONING_MAX_TOKENS = 131_072
TEMPERATURE_NON_REASONING = 0.0
@@ -270,7 +277,7 @@ def run_humaneval_test(
@dataclass
class QuestionResult:
question_id: int
question_id: int | str
prompt: str
response: str
extracted_answer: str | None
@@ -280,7 +287,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
@@ -516,6 +527,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(
@@ -529,6 +544,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:
@@ -545,6 +563,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",
@@ -553,19 +577,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,
@@ -577,8 +622,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,
@@ -591,8 +642,17 @@ 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:
if not await _check_instance_health(base_url):
if instance_failed:
instance_failed.set()
raise InstanceFailedError(f"Instance is down after {attempt + 1} failures: {e}")
if attempt < MAX_RETRIES - 1:
wait = min(2**attempt, 60)
logger.warning(
@@ -617,10 +677,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
@@ -651,7 +717,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
@@ -659,6 +739,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":
@@ -666,16 +753,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)
@@ -696,24 +831,48 @@ 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,
@@ -728,13 +887,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,
@@ -748,7 +911,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,
@@ -762,7 +925,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,
@@ -777,7 +940,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",
@@ -792,7 +955,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",
@@ -803,7 +966,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -814,7 +977,7 @@ async def evaluate_benchmark(
)
else:
result = QuestionResult(
question_id=idx,
question_id=question_id,
prompt=prompt,
response=response,
extracted_answer=None,
@@ -826,24 +989,80 @@ async def evaluate_benchmark(
results[idx] = result
# Write checkpoint (skip failures so they get retried on resume)
if checkpoint_path is not None and not result.error 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,
"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
# ---------------------------------------------------------------------------
@@ -866,6 +1085,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%})")
@@ -877,6 +1098,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:
@@ -895,6 +1120,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,
}
@@ -1027,16 +1254,18 @@ def save_results(
concurrency: int,
results: list[QuestionResult],
scores: dict[str, Any],
cluster: dict[str, Any] | None = None,
) -> Path:
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark_name
out_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = out_dir / f"c{concurrency}_{ts}.json"
data = {
data: dict[str, Any] = {
"benchmark": benchmark_name,
"model": model,
"concurrency": concurrency,
**({"cluster": cluster} if cluster else {}),
"scores": scores,
"results": [
{
@@ -1050,7 +1279,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
],
@@ -1066,6 +1299,32 @@ def save_results(
# ---------------------------------------------------------------------------
def _find_existing_instance(client: ExoClient, model_id: str) -> str | None:
"""Find an existing 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 _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:
@@ -1093,6 +1352,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(
@@ -1112,6 +1377,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."
)
@@ -1145,16 +1412,42 @@ 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(
"--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(
"--skip-instance-setup",
action="store_true",
help="Skip exo instance management (assumes model is already running).",
)
ap.add_argument(
"--force",
action="store_true",
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).",
)
ap.add_argument(
"--reuse-instance",
action="store_true",
help="Reuse an existing ready instance instead of creating a new one.",
)
args, _ = ap.parse_known_args()
@@ -1176,63 +1469,88 @@ def main() -> int:
instance_id: str | None = None
if not args.skip_instance_setup:
short_id, full_model_id = resolve_model_short_id(
_short_id, full_model_id = resolve_model_short_id(
client,
args.model,
force_download=args.force_download,
)
selected = settle_and_fetch_placements(
client,
full_model_id,
args,
settle_timeout=args.settle_timeout,
)
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,
)
preview = selected[0]
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
# Try to reuse an existing instance if --reuse-instance is set
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}")
logger.info(
f"PLACEMENT: {preview['sharding']} / {preview['instance_meta']} / "
f"nodes={nodes_used_in_instance(instance)}"
)
if instance_id is None:
selected = settle_and_fetch_placements(
client,
full_model_id,
args,
settle_timeout=args.settle_timeout,
)
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
)
download_duration = run_planning_phase(
client,
full_model_id,
preview,
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
if download_duration is not None:
logger.info(f"Download: {download_duration:.1f}s")
selected.sort(
key=lambda p: (
str(p.get("instance_meta", "")),
str(p.get("sharding", "")),
nodes_used_in_instance(p["instance"]),
),
reverse=True,
)
preview = selected[0]
instance = preview["instance"]
instance_id = instance_id_from_instance(instance)
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: {e}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
return 1
time.sleep(1)
logger.info(
f"PLACEMENT: {preview['sharding']} / {preview['instance_meta']} / "
f"nodes={nodes_used_in_instance(instance)}"
)
settle_deadline = (
time.monotonic() + args.settle_timeout
if args.settle_timeout > 0
else None
)
download_duration = run_planning_phase(
client,
full_model_id,
preview,
args.danger_delete_downloads,
args.timeout,
settle_deadline,
)
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:
pass
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: {e}")
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
return 1
time.sleep(1)
cluster_snapshot = capture_cluster_snapshot(client)
else:
full_model_id = args.model
cluster_snapshot = None
# Auto-detect reasoning from model config
model_config = load_model_config(full_model_id)
@@ -1286,16 +1604,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)
@@ -1304,6 +1663,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,
@@ -1314,9 +1678,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:
@@ -1328,12 +1691,21 @@ def main() -> int:
c,
results,
scores,
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,
@@ -1344,9 +1716,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:
@@ -1358,15 +1729,27 @@ def main() -> int:
args.num_concurrent,
results,
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 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
+100 -35
View File
@@ -69,6 +69,35 @@ class ExoClient:
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.request_json("POST", "/bench/chat/completions", body=payload)
def get_state_path(self, path: str) -> Any:
try:
return self.request_json("GET", f"/state/{path}")
except ExoHttpError as e:
if e.status == 404:
return None
raise
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"instances/{instance_id}")
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"runners/{runner_id}")
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
return self.get_state_path(f"downloads/{node_id}")
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeDisk/{node_id}")
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeSystem/{node_id}")
def get_node_identities(self) -> dict[str, Any] | None:
return self.get_state_path("nodeIdentities")
def get_topology(self) -> dict[str, Any] | None:
return self.get_state_path("topology")
def unwrap_instance(instance: dict[str, Any]) -> dict[str, Any]:
if len(instance) != 1:
@@ -97,6 +126,11 @@ def runner_ids_from_instance(instance: dict[str, Any]) -> list[str]:
return list(runner_to_shard.keys())
def node_ids_from_instance(instance: dict[str, Any]) -> list[str]:
inner = unwrap_instance(instance)
return list(inner["shardAssignments"]["nodeToRunner"].keys())
def runner_ready(runner: dict[str, Any]) -> bool:
return "RunnerReady" in runner
@@ -116,13 +150,12 @@ def wait_for_instance_ready(
) -> None:
start_time = time.time()
instance_existed = False
last_loaded: dict[str, int] = {}
while time.time() - start_time < timeout:
state = client.request_json("GET", "/state")
instances = state.get("instances", {})
instance = client.get_instance(instance_id)
if instance_id not in instances:
if instance is None:
if instance_existed:
# Instance was deleted after being created - likely due to runner failure
raise RuntimeError(
f"Instance {instance_id} was deleted (runner may have failed)"
)
@@ -130,18 +163,25 @@ def wait_for_instance_ready(
continue
instance_existed = True
instance = instances[instance_id]
runner_ids = runner_ids_from_instance(instance)
runners = state.get("runners", {})
rids = runner_ids_from_instance(instance)
# Check for failed runners first
for rid in runner_ids:
runner = runners.get(rid, {})
all_ready = True
for rid in rids:
runner = client.get_runner(rid) or {}
if runner_failed(runner):
error_msg = get_runner_failed_message(runner) or "Unknown error"
raise RuntimeError(f"Runner {rid} failed: {error_msg}")
if "RunnerLoading" in runner:
loading = runner["RunnerLoading"]
loaded = loading.get("layersLoaded", 0)
total = loading.get("totalLayers", 0)
if total > 0 and last_loaded.get(rid) != loaded:
last_loaded[rid] = loaded
logger.debug(f"Runner {rid}: loading layers {loaded}/{total}")
if not runner_ready(runner):
all_ready = False
if all(runner_ready(runners.get(rid, {})) for rid in runner_ids):
if all_ready:
return
time.sleep(0.1)
@@ -165,6 +205,23 @@ def wait_for_instance_gone(
raise TimeoutError(f"Instance {instance_id} did not get deleted within {timeout=}")
def capture_cluster_snapshot(client: ExoClient) -> dict[str, Any]:
snapshot: dict[str, Any] = {}
identities = client.get_node_identities()
if identities:
snapshot["nodeIdentities"] = identities
topology = client.get_topology()
if topology:
snapshot["topology"] = topology
node_memory = client.get_state_path("nodeMemory")
if node_memory:
snapshot["nodeMemory"] = node_memory
node_system = client.get_state_path("nodeSystem")
if node_system:
snapshot["nodeSystem"] = node_system
return snapshot
def resolve_model_short_id(
client: ExoClient, model_arg: str, *, force_download: bool = False
) -> tuple[str, str]:
@@ -326,16 +383,11 @@ def run_planning_phase(
node_ids = list(inner["shardAssignments"]["nodeToRunner"].keys())
runner_to_shard = inner["shardAssignments"]["runnerToShard"]
state = client.request_json("GET", "/state")
downloads = state.get("downloads", {})
node_disk = state.get("nodeDisk", {})
needs_download = False
for node_id in node_ids:
node_downloads = downloads.get(node_id, [])
node_downloads = client.get_node_downloads(node_id) or []
# Check if model already downloaded on this node
already_downloaded = any(
"DownloadCompleted" in p
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
@@ -349,8 +401,7 @@ def run_planning_phase(
needs_download = True
# Wait for disk info if settle_deadline is set
disk_info = node_disk.get(node_id, {})
disk_info = client.get_node_disk(node_id) or {}
backoff = _SETTLE_INITIAL_BACKOFF_S
while not disk_info and settle_deadline and time.monotonic() < settle_deadline:
remaining = settle_deadline - time.monotonic()
@@ -359,9 +410,7 @@ def run_planning_phase(
)
time.sleep(min(backoff, remaining))
backoff = min(backoff * _SETTLE_BACKOFF_MULTIPLIER, _SETTLE_MAX_BACKOFF_S)
state = client.request_json("GET", "/state")
node_disk = state.get("nodeDisk", {})
disk_info = node_disk.get(node_id, {})
disk_info = client.get_node_disk(node_id) or {}
if not disk_info:
logger.warning(f"No disk info for {node_id}, skipping space check")
@@ -377,7 +426,6 @@ def run_planning_phase(
f"have {avail // (1024**3)}GB. Use --danger-delete-downloads to free space."
)
# Delete from smallest to largest (skip read-only models from EXO_MODELS_PATH)
completed = [
(
unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
@@ -414,24 +462,22 @@ def run_planning_phase(
)
logger.info(f"Started download on {node_id}")
# Wait for downloads
start = time.time()
while time.time() - start < timeout:
state = client.request_json("GET", "/state")
downloads = state.get("downloads", {})
# 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 []
done = any(
"DownloadCompleted" in p
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
for p in downloads.get(node_id, [])
for p in node_downloads
)
failed = [
p["DownloadFailed"]["errorMessage"]
for p in downloads.get(node_id, [])
for p in node_downloads
if "DownloadFailed" in p
and unwrap_instance(p["DownloadFailed"]["shardMetadata"])["modelCard"][
"modelId"
@@ -442,13 +488,32 @@ def run_planning_phase(
raise RuntimeError(f"Download failed on {node_id}: {failed[0]}")
if not done:
all_done = False
ongoing = [
p
for p in node_downloads
if "DownloadOngoing" in p
and unwrap_instance(p["DownloadOngoing"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
]
if ongoing:
prog = ongoing[0]["DownloadOngoing"]["downloadProgress"]
speed_mb = prog.get("speed", 0) / (1024 * 1024)
eta_s = prog.get("etaMs", 0) / 1000
dl_bytes = prog.get("downloaded", {}).get("inBytes", 0)
total_bytes = prog.get("total", {}).get("inBytes", 0)
pct = (dl_bytes / total_bytes * 100) if total_bytes else 0
logger.info(
f"Downloading on {node_id}: {pct:.1f}% @ {speed_mb:.1f} MB/s, "
f"ETA {eta_s:.0f}s "
f"({prog.get('completedFiles', 0)}/{prog.get('totalFiles', 0)} files)"
)
if all_done:
if download_t0 is not None:
return time.perf_counter() - download_t0
return None
time.sleep(1)
raise TimeoutError("Downloads did not complete in time")
time.sleep(10)
def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
@@ -496,8 +561,8 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
ap.add_argument(
"--settle-timeout",
type=float,
default=0,
help="Max seconds to wait for the cluster to produce valid placements (0 = try once).",
default=7200,
help="Max seconds to wait for the cluster to produce valid placements (default: 7200).",
)
ap.add_argument(
"--danger-delete-downloads",
+272 -1
View File
@@ -11,7 +11,8 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0"
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@@ -518,6 +519,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2635,6 +2886,26 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -31,6 +31,7 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0"
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
}
}
@@ -139,6 +139,8 @@
return "🖼";
case "text":
return "📄";
case "pdf":
return "📑";
default:
return "📎";
}
@@ -88,6 +88,12 @@
d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z"
/>
</svg>
{:else if family === "nemotron"}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"
/>
</svg>
{:else}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -31,6 +31,7 @@
kimi: "Kimi",
flux: "FLUX",
"qwen-image": "Qwen Img",
nemotron: "NVIDIA",
};
function getFamilyName(family: string): string {
@@ -41,31 +42,20 @@
</script>
<div
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[72px] sm:min-w-[64px] overflow-y-auto scrollbar-hide"
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[80px] sm:min-w-[72px] overflow-y-auto scrollbar-hide"
>
<!-- All models (no filter) -->
<button
type="button"
onclick={() => onSelect(null)}
class="group flex flex-col items-center justify-center p-2 sm:p-2 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
class="group flex items-center justify-center px-3 py-2.5 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
null
? 'bg-exo-yellow/20 border-l-2 border-exo-yellow'
: 'hover:bg-white/5 border-l-2 border-transparent'}"
title="All models"
>
<svg
class="w-5 h-5 {selectedFamily === null
? 'text-exo-yellow'
: 'text-white/50 group-hover:text-white/70'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
/>
</svg>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === null
class="text-[12px] font-mono font-medium {selectedFamily === null
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}">All</span
>
@@ -89,7 +79,7 @@
: "text-white/50 group-hover:text-amber-400/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'favorites'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'favorites'
? 'text-amber-400'
: 'text-white/40 group-hover:text-white/60'}">Faves</span
>
@@ -114,7 +104,7 @@
: "text-white/50 group-hover:text-white/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'recents'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'recents'
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}">Recent</span
>
@@ -138,7 +128,7 @@
: "text-white/50 group-hover:text-orange-400/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'huggingface'
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'huggingface'
? 'text-orange-400'
: 'text-white/40 group-hover:text-white/60'}">Hub</span
>
@@ -164,7 +154,7 @@
: "text-white/50 group-hover:text-white/70"}
/>
<span
class="text-[9px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
class="text-[11px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
family
? 'text-exo-yellow'
: 'text-white/40 group-hover:text-white/60'}"
+53 -15
View File
@@ -1,21 +1,38 @@
<script lang="ts">
import { browser } from "$app/environment";
export let showHome = true;
export let onHome: (() => void) | null = null;
export let showSidebarToggle = false;
export let sidebarVisible = true;
export let onToggleSidebar: (() => void) | null = null;
export let showMobileMenuToggle = false;
export let mobileMenuOpen = false;
export let onToggleMobileMenu: (() => void) | null = null;
export let showMobileRightToggle = false;
export let mobileRightOpen = false;
export let onToggleMobileRight: (() => void) | null = null;
export let downloadProgress: {
count: number;
percentage: number;
} | null = null;
interface Props {
showHome?: boolean;
onHome?: (() => void) | null;
showSidebarToggle?: boolean;
sidebarVisible?: boolean;
onToggleSidebar?: (() => void) | null;
showMobileMenuToggle?: boolean;
mobileMenuOpen?: boolean;
onToggleMobileMenu?: (() => void) | null;
showMobileRightToggle?: boolean;
mobileRightOpen?: boolean;
onToggleMobileRight?: (() => void) | null;
downloadProgress?: {
count: number;
percentage: number;
} | null;
}
let {
showHome = true,
onHome = null,
showSidebarToggle = false,
sidebarVisible = true,
onToggleSidebar = null,
showMobileMenuToggle = false,
mobileMenuOpen = false,
onToggleMobileMenu = null,
showMobileRightToggle = false,
mobileRightOpen = false,
onToggleMobileRight = null,
downloadProgress = null,
}: Props = $props();
function handleHome(): void {
if (onHome) {
@@ -259,5 +276,26 @@
{/if}
<span class="hidden sm:inline">Downloads</span>
</a>
<a
href="/#/integrations"
class="text-xs md:text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-1.5 md:gap-2 cursor-pointer"
title="Integration configs for external tools"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
/>
</svg>
<span class="hidden sm:inline">Integrations</span>
</a>
</nav>
</header>
@@ -0,0 +1,52 @@
<script lang="ts">
interface Props {
title: string;
subtitle: string;
config: string;
description?: string;
language?: "json" | "bash";
}
let {
title,
subtitle,
config,
description = "",
language = "json",
}: Props = $props();
let copied = $state(false);
async function copyToClipboard() {
await navigator.clipboard.writeText(config);
copied = true;
setTimeout(() => (copied = false), 2000);
}
</script>
<div
class="border border-exo-light-gray/20 rounded-lg bg-exo-medium-gray/20 overflow-hidden"
>
<div class="flex items-center justify-between px-5 py-4">
<div>
<h3 class="text-white text-sm font-semibold tracking-wide">{title}</h3>
<p class="text-exo-light-gray/60 text-xs mt-0.5 font-mono">{subtitle}</p>
</div>
<button
onclick={copyToClipboard}
class="px-3 py-1.5 text-xs rounded border transition-all duration-200 cursor-pointer
{copied
? 'border-green-500/50 text-green-400 bg-green-500/10'
: 'border-exo-light-gray/30 text-exo-light-gray hover:border-exo-yellow/50 hover:text-exo-yellow'}"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
{#if description}
<p class="text-exo-light-gray/70 text-xs px-5 pb-3">{description}</p>
{/if}
<div class="bg-black/30 border-t border-exo-light-gray/10">
<pre
class="text-xs text-exo-light-gray/90 font-mono p-4 overflow-x-auto whitespace-pre">{config}</pre>
</div>
</div>
+52 -30
View File
@@ -16,7 +16,9 @@
perNode?: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
}>;
} | null;
nodes?: Record<string, NodeInfo>;
@@ -145,10 +147,7 @@
return `${s}s`;
}
const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
const progress = $derived(downloadStatus?.progress);
const percentage = $derived(progress?.percentage ?? 0);
let expandedNodes = $state<Set<string>>(new Set());
const perNode = $derived(downloadStatus?.perNode ?? []);
function toggleNodeDetails(nodeId: string): void {
const next = new Set(expandedNodes);
@@ -587,23 +586,49 @@
</span>
</div>
<!-- Download Status -->
{#if isDownloading && progress}
<!-- Download Status (per-node) -->
{#if perNode.length > 0}
<div class="mb-2 space-y-1">
<div class="flex items-center justify-between text-xs font-mono">
<span class="text-blue-400 tracking-wider uppercase">Downloading</span
>
<span class="text-white/60"
>{percentage.toFixed(1)}% &middot; {formatSpeed(progress.speed)}
&middot; {formatEta(progress.etaMs)}</span
>
</div>
<div class="h-1 bg-exo-medium-gray/30 rounded overflow-hidden">
<div
class="h-full bg-blue-500/70 transition-all duration-300"
style="width: {percentage}%"
></div>
<div
class="text-[10px] font-mono text-white/20 tracking-widest uppercase"
>
Download progress
</div>
{#each perNode as node}
<div class="flex items-center gap-2 text-xs font-mono">
<span class="text-white/40 w-20 truncate" title={node.nodeId}
>{node.nodeName}</span
>
<div
class="flex-1 h-1 bg-exo-medium-gray/30 rounded overflow-hidden"
>
<div
class="h-full transition-all duration-300 {node.status ===
'downloading'
? 'bg-blue-500/70'
: node.status === 'completed'
? 'bg-exo-yellow/40'
: 'bg-white/20'}"
style="width: {node.percentage}%"
></div>
</div>
<span
class="text-right {node.status === 'completed'
? 'text-exo-yellow/60'
: node.status === 'downloading'
? 'text-blue-400/60'
: 'text-white/30'}"
>
{#if node.status === "downloading" && node.progress}
{Math.round(node.percentage)}% {formatSpeed(
node.progress.speed,
)}
{:else}
{node.percentage > 0 ? `${Math.round(node.percentage)}%` : "0%"}
{/if}
</span>
</div>
{/each}
</div>
{/if}
@@ -662,15 +687,7 @@
{@const allConnections =
isDebugMode && usedNodes.length > 1
? (() => {
const conns: Array<{
ip: string;
iface: string | null;
from: string;
to: string;
midX: number;
midY: number;
arrow: string;
}> = [];
const conns: Array = [];
for (let i = 0; i < usedNodes.length; i++) {
for (let j = i + 1; j < usedNodes.length; j++) {
const n1 = usedNodes[i];
@@ -682,7 +699,12 @@
const toPos = nodePositions[c.to];
const arrow =
fromPos && toPos ? getArrow(fromPos, toPos) : "→";
conns.push({ ...c, midX, midY, arrow });
conns.push({
...c,
midX,
midY,
arrow,
});
}
}
}
@@ -73,7 +73,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-10"
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-20"
transition:fly={{ y: -10, duration: 200, easing: cubicOut }}
onclick={(e) => e.stopPropagation()}
role="dialog"
@@ -459,6 +459,7 @@
"llama",
"flux",
"qwen-image",
"nemotron",
];
return Array.from(families).sort((a, b) => {
const aIdx = familyOrder.indexOf(a);
+1
View File
@@ -13,3 +13,4 @@ export { default as ModelFilterPopover } from "./ModelFilterPopover.svelte";
export { default as ModelPickerGroup } from "./ModelPickerGroup.svelte";
export { default as ModelPickerModal } from "./ModelPickerModal.svelte";
export { default as ChatModelSelector } from "./ChatModelSelector.svelte";
export { default as IntegrationCard } from "./IntegrationCard.svelte";
+154 -23
View File
@@ -257,11 +257,12 @@ interface RawStateResponse {
}
export interface MessageAttachment {
type: "image" | "text" | "file" | "generated-image";
type: "image" | "text" | "file" | "generated-image" | "pdf";
name: string;
content?: string;
preview?: string;
mimeType?: string;
pageImages?: string[];
}
export interface TopLogprob {
@@ -1793,6 +1794,14 @@ class AppStore {
this.persistConversation(targetConversationId);
}
},
{
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
}
},
},
);
// Final update
@@ -1990,6 +1999,14 @@ class AppStore {
this.persistConversation(targetConversationId);
}
},
{
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
}
},
},
);
// Final cleanup of the message (if conversation still exists)
@@ -2226,6 +2243,7 @@ class AppStore {
type: string;
textContent?: string;
preview?: string;
pageImages?: string[];
}[],
enableThinking?: boolean | null,
): Promise<void> {
@@ -2261,6 +2279,20 @@ class AppStore {
preview: file.preview,
mimeType: file.type,
});
} else if (
file.pageImages ||
(file.textContent && file.type === "application/pdf")
) {
attachments.push({
type: "pdf",
name: file.name,
content: file.textContent,
pageImages: file.pageImages,
mimeType: file.type,
});
if (file.textContent) {
fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
}
} else if (file.textContent) {
attachments.push({
type: "text",
@@ -2328,13 +2360,70 @@ class AppStore {
const apiMessages = [
systemPrompt,
...targetConversation.messages.slice(0, -1).map((m) => {
// Build content including any text file attachments
// Check if this message has image or PDF attachments
const visualAttachments = m.attachments?.filter(
(a) =>
(a.type === "image" && a.preview) ||
(a.type === "pdf" && a.pageImages?.length),
);
if (visualAttachments && visualAttachments.length > 0) {
// Build multimodal content array (OpenAI vision format)
const contentParts: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = [];
// Add image parts first
for (const att of visualAttachments) {
if (att.type === "image" && att.preview) {
contentParts.push({
type: "image_url",
image_url: { url: att.preview },
});
} else if (att.type === "pdf" && att.pageImages) {
for (const pageImg of att.pageImages) {
contentParts.push({
type: "image_url",
image_url: { url: pageImg },
});
}
}
}
// Build text content including any text/pdf file attachments
let textContent = m.content;
if (m.attachments) {
for (const attachment of m.attachments) {
if (
(attachment.type === "text" || attachment.type === "pdf") &&
attachment.content
) {
textContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
}
}
if (textContent) {
contentParts.push({ type: "text", text: textContent });
}
return {
role: m.role,
content: contentParts,
};
}
// Text-only message (original path)
let msgContent = m.content;
// Add text attachments as context
// Add text/pdf attachments as context
if (m.attachments) {
for (const attachment of m.attachments) {
if (attachment.type === "text" && attachment.content) {
if (
(attachment.type === "text" || attachment.type === "pdf") &&
attachment.content
) {
msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
}
@@ -2397,7 +2486,7 @@ class AppStore {
let streamedContent = "";
let streamedThinking = "";
let serverTpsReceived = false;
interface ChatCompletionChunk {
choices?: Array<{
delta?: { content?: string; reasoning_content?: string };
@@ -2462,7 +2551,6 @@ class AppStore {
tokenCount += 1;
this.totalTokens = tokenCount;
// Update real-time TPS during streaming
if (firstTokenTime !== null && tokenCount > 1) {
const elapsed = performance.now() - firstTokenTime;
this.tps = (tokenCount / elapsed) * 1000;
@@ -2513,16 +2601,24 @@ class AppStore {
startedAt: this.prefillProgress?.startedAt ?? performance.now(),
};
},
generation_stats: (data) => {
const stats = data as { generation_tps: number };
if (stats.generation_tps > 0) {
this.tps = stats.generation_tps;
serverTpsReceived = true;
}
},
},
);
// Clear prefill progress after stream ends
this.prefillProgress = null;
// Calculate final TPS
if (firstTokenTime !== null && tokenCount > 1) {
// Use server-side TPS if available, otherwise fall back to client-side
if (!serverTpsReceived && firstTokenTime !== null && tokenCount > 1) {
const totalGenerationTime = performance.now() - firstTokenTime;
this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
this.tps = (tokenCount / totalGenerationTime) * 1000;
}
// Final cleanup of the message (if conversation still exists)
@@ -2627,6 +2723,9 @@ class AppStore {
this.syncActiveMessagesIfNeeded(targetConversationId);
this.saveConversationsToStorage();
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2681,6 +2780,7 @@ class AppStore {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: abortController.signal,
});
if (!response.ok) {
@@ -2820,14 +2920,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
@@ -2891,6 +3004,9 @@ class AppStore {
// Clear editing state
this.editingImage = null;
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2952,6 +3068,7 @@ class AppStore {
const apiResponse = await fetch("/v1/images/edits", {
method: "POST",
body: formData,
signal: abortController.signal,
});
if (!apiResponse.ok) {
@@ -3052,14 +3169,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error editing image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to edit image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error editing image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to edit image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
@@ -3248,6 +3378,7 @@ export const sendMessage = (
type: string;
textContent?: string;
preview?: string;
pageImages?: string[];
}[],
enableThinking?: boolean | null,
) => appStore.sendMessage(content, files, enableThinking);
+68 -1
View File
@@ -2,6 +2,15 @@
* File attachment types for the chat interface
*/
import { getDocument, GlobalWorkerOptions, version } from "pdfjs-dist";
import type { DocumentInitParameters } from "pdfjs-dist/types/src/display/api";
GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.mjs`;
const PDF_PAGE_SCALE = 2.0;
const PDF_MAX_PAGES = 20;
const PDF_MAX_TEXT_CHARS = 100_000;
export interface ChatUploadedFile {
id: string;
name: string;
@@ -10,6 +19,7 @@ export interface ChatUploadedFile {
file: File;
preview?: string;
textContent?: string;
pageImages?: string[];
}
export interface ChatAttachment {
@@ -194,6 +204,58 @@ export function readFileAsText(file: File): Promise<string> {
});
}
async function extractPdfContent(
file: File,
): Promise<{ text: string; pageImages: string[] }> {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({
data: new Uint8Array(arrayBuffer),
useSystemFonts: true,
} as DocumentInitParameters).promise;
const numPages = Math.min(pdf.numPages, PDF_MAX_PAGES);
const pageTexts: string[] = [];
const pageImages: string[] = [];
for (let i = 1; i <= numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
const strings = content.items
.filter((item: any) => "str" in item)
.map((item: any) => item.str as string);
pageTexts.push(strings.join(" "));
const viewport = page.getViewport({ scale: PDF_PAGE_SCALE });
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx as any, viewport }).promise;
const blob = await canvas.convertToBlob({
type: "image/jpeg",
quality: 0.8,
});
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
pageImages.push(dataUrl);
}
}
let text = pageTexts.join("\n\n").trim();
if (text.length > PDF_MAX_TEXT_CHARS) {
text = text.slice(0, PDF_MAX_TEXT_CHARS) + "\n\n[truncated]";
}
if (pdf.numPages > PDF_MAX_PAGES) {
text += `\n\n[showing ${PDF_MAX_PAGES} of ${pdf.numPages} pages]`;
}
return { text, pageImages };
}
/**
* Process uploaded files into ChatUploadedFile format
*/
@@ -223,7 +285,12 @@ export async function processUploadedFiles(
const textContent = await readFileAsText(file);
results.push({ ...base, textContent });
} else if (category === "pdf") {
results.push(base);
const { text, pageImages } = await extractPdfContent(file);
results.push({
...base,
textContent: text || undefined,
pageImages: pageImages.length > 0 ? pageImages : undefined,
});
} else if (category === "audio") {
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
+225 -247
View File
@@ -42,6 +42,7 @@
setSelectedChatModel,
selectedChatModel,
sendMessage,
thinkingEnabled,
generateImage,
editImage,
editingImage,
@@ -264,6 +265,7 @@
let mounted = $state(false);
let localNodeId = $state<string | null>(null);
let pendingFirefoxQuery = $state<string | null>(null); // ?q= param deferred until state loads
// ── Onboarding wizard state ──
const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
@@ -852,7 +854,7 @@
) {
const model = selectedChatModel();
if (!model) {
sendMessage(content, files, null);
sendMessage(content, files, thinkingEnabled());
return;
}
@@ -880,7 +882,7 @@
}
// Default: text chat
sendMessage(content, files, null);
sendMessage(content, files, thinkingEnabled());
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
@@ -1308,6 +1310,20 @@
return;
}
// Firefox AI sidebar integration: handle ?q= query parameter
// Firefox's built-in AI sidebar (about:config: browser.ml.chat.enabled) sends
// the user's prompt as ?q=<URL-encoded prompt> to the configured provider URL.
// See: https://support.mozilla.org/en-US/kb/ai-chatbot
const queryParam = params.get("q");
if (queryParam) {
// Clean up the URL to prevent re-submission on page refresh
window.history.replaceState({}, "", window.location.pathname);
// Defer the auto-send until cluster state is loaded (topologyData,
// instances, availableMemory) so that model auto-selection works
// correctly. The $effect below will pick this up once data is ready.
pendingFirefoxQuery = queryParam;
}
// Check server-side onboarding state (persisted in ~/.exo)
try {
const res = await fetch("/onboarding");
@@ -1328,6 +1344,18 @@
}
});
// Deferred Firefox AI sidebar auto-send: wait for cluster state and model
// list before submitting. Both data (from /state polling) and models (from
// the async /models fetch in onMount) must be loaded for handleAutoSend to
// correctly auto-select a model.
$effect(() => {
if (pendingFirefoxQuery && data && models.length > 0) {
const query = pendingFirefoxQuery;
pendingFirefoxQuery = null;
handleChatSend(query);
}
});
async function fetchModels() {
try {
const response = await fetch("/models");
@@ -1535,34 +1563,44 @@
}
// Helper to get download status for a model (checks all downloads for matching model ID)
function getModelDownloadStatus(modelId: string): {
type NodeDownloadStatus = {
nodeId: string;
nodeName: string;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
};
// Shared helper: collect per-node download status for a model across a set of nodes.
// Handles deduplication, entry parsing, and aggregation in one place.
function collectDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}>;
perNode: NodeDownloadStatus[];
failedError: string | null;
} {
const empty = {
isDownloading: false,
progress: null,
perNode: [] as NodeDownloadStatus[],
failedError: null,
};
if (!downloadsData || Object.keys(downloadsData).length === 0) {
return { isDownloading: false, progress: null, perNode: [] };
return empty;
}
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
const perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}> = [];
// Deduplicate by nodeId — a node can have multiple entries for the same model
// (e.g. PipelineShardMetadata + TensorShardMetadata). Keep the last entry,
// which is the most recently applied event.
const perNodeMap = new Map<string, NodeDownloadStatus>();
// Check all nodes for downloads matching this model
const nodeIdSet = nodeIds ? new Set(nodeIds) : null;
for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
if (nodeIdSet && !nodeIdSet.has(nodeId)) continue;
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
@@ -1575,29 +1613,45 @@
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending"
)
continue;
if (!downloadPayload) continue;
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (!downloadModelId || downloadModelId !== modelId) continue;
// Match if the model ID contains or equals the requested model
// (handles cases like "mlx-community/Meta-Llama..." matching)
if (
!downloadModelId ||
!downloadModelId.includes(modelId.split("/").pop() || modelId)
) {
// Try exact match or partial match
if (downloadModelId !== modelId) continue;
// DownloadFailed — return with any data collected so far
if (downloadKind === "DownloadFailed") {
return {
isDownloading: false,
progress: null,
perNode: Array.from(perNodeMap.values()),
failedError:
(downloadPayload.errorMessage as string) ||
(downloadPayload.error_message as string) ||
"Download failed",
};
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending" &&
downloadKind !== "DownloadCompleted"
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
if (downloadKind === "DownloadCompleted") {
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "completed",
percentage: 100,
progress: null,
});
continue;
}
// For DownloadPending with partial bytes (paused/resumed downloads),
// synthesize a progress object from the top-level downloaded/total fields
let progress: DownloadProgress | null;
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
@@ -1610,44 +1664,67 @@
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
isDownloading = true;
progress = {
totalBytes: pendingTotal,
downloadedBytes: pendingDownloaded,
speed: 0,
etaMs: 0,
percentage:
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
completedFiles: 0,
totalFiles: 0,
files: [],
};
} else {
isDownloading = true;
progress = parseDownloadProgress(downloadPayload);
const pct =
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: pendingDownloaded > 0 ? "partial" : "pending",
percentage: pct,
progress: null,
});
continue;
}
if (progress) {
// Sum all values across nodes - each node downloads independently
totalBytes += progress.totalBytes;
downloadedBytes += progress.downloadedBytes;
totalSpeed += progress.speed;
completedFiles += progress.completedFiles;
totalFiles += progress.totalFiles;
allFiles.push(...progress.files);
// DownloadOngoing
const progress = parseDownloadProgress(downloadPayload);
if (
!progress ||
(progress.downloadedBytes <= 0 && progress.totalBytes <= 0)
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
perNode.push({ nodeId, nodeName, progress });
}
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "downloading",
percentage: progress.percentage,
progress,
});
}
}
// Aggregate from deduplicated per-node entries
const perNode = Array.from(perNodeMap.values());
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
for (const node of perNode) {
if (node.status === "downloading" && node.progress) {
isDownloading = true;
totalBytes += node.progress.totalBytes;
downloadedBytes += node.progress.downloadedBytes;
totalSpeed += node.progress.speed;
completedFiles += node.progress.completedFiles;
totalFiles += node.progress.totalFiles;
allFiles.push(...node.progress.files);
}
}
if (!isDownloading) {
return { isDownloading: false, progress: null, perNode: [] };
return {
isDownloading: false,
progress: null,
perNode,
failedError: null,
};
}
// ETA = total remaining bytes / total speed across all nodes
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
@@ -1664,9 +1741,21 @@
files: allFiles,
},
perNode,
failedError: null,
};
}
function getModelDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: NodeDownloadStatus[];
} {
return collectDownloadStatus(modelId, nodeIds);
}
// Helper to get download status for an instance
function getInstanceDownloadStatus(
instanceId: string,
@@ -1677,26 +1766,9 @@
errorMessage: string | null;
progress: DownloadProgress | null;
statusText: string;
perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}>;
perNode: NodeDownloadStatus[];
} {
if (!downloadsData || Object.keys(downloadsData).length === 0) {
// No download data yet — defer to runner status instead of assuming RUNNING
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: false,
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: [],
};
}
// Unwrap the instance
// Unwrap the instance to get shard assignments
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") {
return {
@@ -1716,132 +1788,9 @@
modelId?: string;
};
};
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const instanceModelId = inst.shardAssignments?.modelId;
// Build reverse mapping: runnerId -> nodeId
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
const perNode: Array<{
nodeId: string;
nodeName: string;
progress: DownloadProgress;
}> = [];
// Check downloads for nodes that are part of this instance
for (const runnerId of Object.keys(runnerToShard)) {
const nodeId = runnerToNode[runnerId];
if (!nodeId) continue;
const nodeDownloads = downloadsData[nodeId];
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
const keys = Object.keys(downloadWrapped as Record<string, unknown>);
if (keys.length !== 1) continue;
const downloadKind = keys[0];
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
// Handle DownloadFailed - return immediately with error info
if (downloadKind === "DownloadFailed") {
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
return {
isDownloading: false,
isFailed: true,
errorMessage:
(downloadPayload.errorMessage as string) || "Download failed",
progress: null,
statusText: "FAILED",
perNode: [],
};
}
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending"
)
continue;
if (!downloadPayload) continue;
// Check if this download is for this instance's model
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (
instanceModelId &&
downloadModelId &&
downloadModelId === instanceModelId
) {
// For DownloadPending with partial bytes, synthesize progress
let progress: DownloadProgress | null;
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
downloadPayload.downloaded_bytes ??
downloadPayload.downloadedBytes,
);
const pendingTotal = getBytes(
downloadPayload.total ??
downloadPayload.total_bytes ??
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
isDownloading = true;
progress = {
totalBytes: pendingTotal,
downloadedBytes: pendingDownloaded,
speed: 0,
etaMs: 0,
percentage:
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
completedFiles: 0,
totalFiles: 0,
files: [],
};
} else {
isDownloading = true;
progress = parseDownloadProgress(downloadPayload);
}
if (progress) {
// Sum all values across nodes - each node downloads independently
totalBytes += progress.totalBytes;
downloadedBytes += progress.downloadedBytes;
totalSpeed += progress.speed;
completedFiles += progress.completedFiles;
totalFiles += progress.totalFiles;
allFiles.push(...progress.files);
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
perNode.push({ nodeId, nodeName, progress });
}
}
}
}
if (!isDownloading) {
// Check runner status for other states
if (!instanceModelId) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
@@ -1853,26 +1802,49 @@
};
}
// ETA = total remaining bytes / total speed across all nodes
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
// Get node IDs assigned to this instance
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
const instanceNodeIds = Object.keys(runnerToShard)
.map((runnerId) => runnerToNode[runnerId])
.filter(Boolean);
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
if (result.failedError) {
return {
isDownloading: false,
isFailed: true,
errorMessage: result.failedError,
progress: null,
statusText: "FAILED",
perNode: [],
};
}
if (!result.isDownloading) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: statusInfo.statusText === "FAILED",
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: result.perNode,
};
}
return {
isDownloading: true,
isFailed: false,
errorMessage: null,
progress: {
totalBytes,
downloadedBytes,
speed: totalSpeed,
etaMs,
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
completedFiles,
totalFiles,
files: allFiles,
},
progress: result.progress,
statusText: "DOWNLOADING",
perNode,
perNode: result.perNode,
};
}
@@ -4630,7 +4602,7 @@
type="button"
onclick={() => {
completeOnboarding();
sendMessage(chip);
sendMessage(chip, undefined, thinkingEnabled());
}}
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
>
@@ -5369,10 +5341,10 @@
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode as nodeProg}
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.progress.percentage),
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
@@ -5428,15 +5400,17 @@
>
<span
>{formatBytes(
nodeProg.progress.downloadedBytes,
nodeProg.progress?.downloadedBytes ??
0,
)} / {formatBytes(
nodeProg.progress.totalBytes,
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(nodeProg.progress.speed)}
ETA {formatEta(
nodeProg.progress.etaMs,
>{formatSpeed(
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
@@ -5444,14 +5418,14 @@
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress.files.length === 0}
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress.files as f}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
@@ -5927,12 +5901,15 @@
)}
{@const allPreviews = filteredPreviews()}
{#if selectedModel && allPreviews.length > 0}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
)}
{@const tags = modelTags()[selectedModel.id] || []}
<div class="space-y-3">
{#each allPreviews as apiPreview, i}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
apiPreview.memory_delta_by_node
? Object.keys(apiPreview.memory_delta_by_node)
: undefined,
)}
<div
role="group"
onmouseenter={() => {
@@ -6120,7 +6097,7 @@
onclick={() => {
chatLaunchState = "idle";
selectedChatCategory = null;
sendMessage(prompt);
sendMessage(prompt, undefined, thinkingEnabled());
}}
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
>
@@ -6503,10 +6480,10 @@
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode as nodeProg}
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.progress.percentage),
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
@@ -6565,16 +6542,17 @@
>
<span
>{formatBytes(
nodeProg.progress.downloadedBytes,
nodeProg.progress
?.downloadedBytes ?? 0,
)} / {formatBytes(
nodeProg.progress.totalBytes,
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(
nodeProg.progress.speed,
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress.etaMs,
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
@@ -6582,14 +6560,14 @@
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress.files.length === 0}
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress.files as f}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
@@ -0,0 +1,577 @@
<script lang="ts">
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import IntegrationCard from "$lib/components/IntegrationCard.svelte";
import { instances, refreshState } from "$lib/stores/app.svelte";
import { onMount } from "svelte";
const apiUrl = browser
? window.location.origin.replace("localhost", "127.0.0.1")
: "http://127.0.0.1:52415";
const instancesData = $derived(instances());
let modelCapabilities = $state<Record<string, string[]>>({});
let modelContextLengths = $state<Record<string, number>>({});
const runningModels = $derived.by(() => {
const models: string[] = [];
for (const [, wrapper] of Object.entries(instancesData)) {
if (wrapper && typeof wrapper === "object") {
const values = Object.values(wrapper as Record<string, unknown>);
if (values.length > 0) {
const instance = values[0];
if (instance && typeof instance === "object") {
const inst = instance as {
shardAssignments?: { modelId?: string };
};
const modelId = inst.shardAssignments?.modelId;
if (modelId && !models.includes(modelId)) {
models.push(modelId);
}
}
}
}
}
return models;
});
function estimateParamSize(modelId: string): number {
const match = modelId.match(/(\d+(?:\.\d+)?)[Bb]/);
return match ? parseFloat(match[1]) : 0;
}
const modelsBySize = $derived(
[...runningModels].sort(
(a, b) => estimateParamSize(b) - estimateParamSize(a),
),
);
const defaultTiers = $derived.by(() => {
const n = modelsBySize.length;
if (n === 0)
return {
opus: "your-model-id",
sonnet: "your-model-id",
haiku: "your-model-id",
};
if (n === 1)
return {
opus: modelsBySize[0],
sonnet: modelsBySize[0],
haiku: modelsBySize[0],
};
if (n === 2)
return {
opus: modelsBySize[0],
sonnet: modelsBySize[1],
haiku: modelsBySize[1],
};
return {
opus: modelsBySize[0],
sonnet: modelsBySize[Math.floor(n / 2)],
haiku: modelsBySize[n - 1],
};
});
let opusModel = $state("");
let sonnetModel = $state("");
let haikuModel = $state("");
$effect(() => {
opusModel = defaultTiers.opus;
sonnetModel = defaultTiers.sonnet;
haikuModel = defaultTiers.haiku;
});
let codexModel = $state("");
let codexMcpPath = $state("/Users/username");
let openClawModel = $state("");
$effect(() => {
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
codexModel = def;
openClawModel = def;
});
const claudeShellCommand = $derived(
[
`ANTHROPIC_BASE_URL=${apiUrl} \\`,
`ANTHROPIC_API_KEY=x \\`,
`ANTHROPIC_DEFAULT_OPUS_MODEL=${opusModel} \\`,
`ANTHROPIC_DEFAULT_SONNET_MODEL=${sonnetModel} \\`,
`ANTHROPIC_DEFAULT_HAIKU_MODEL=${haikuModel} \\`,
`API_TIMEOUT_MS=3000000 \\`,
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \\`,
`claude`,
].join("\n"),
);
const claudeSettingsJson = $derived(
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: apiUrl,
ANTHROPIC_API_KEY: "x",
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
},
},
null,
2,
),
);
const openCodeConfig = $derived.by(() => {
const models: Record<string, Record<string, unknown>> = {};
for (const modelId of runningModels) {
const caps = modelCapabilities[modelId] || [];
const ctxLen = modelContextLengths[modelId] || 0;
const entry: Record<string, unknown> = { name: modelId };
if (ctxLen > 0) {
entry.limit = { context: ctxLen, output: Math.min(ctxLen, 16384) };
}
if (caps.includes("vision")) {
entry.modalities = { input: ["text", "image"], output: ["text"] };
}
models[modelId] = entry;
}
if (Object.keys(models).length === 0) {
models["your-model-id"] = { name: "your-model-name" };
}
const firstModel =
runningModels.length > 0 ? runningModels[0] : "your-model-id";
return JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
provider: {
exo: {
npm: "@ai-sdk/openai-compatible",
name: "exo",
options: {
baseURL: `${apiUrl}/v1`,
apiKey: "x",
},
models,
},
},
model: `exo/${firstModel}`,
},
null,
2,
);
});
const codexShellCommand = $derived(`EXO_API_KEY=x npx @openai/codex`);
const codexConfig = $derived(
[
`model = "${codexModel}"`,
`model_provider = "exo"`,
``,
`[model_providers.exo]`,
`name = "exo"`,
`base_url = "${apiUrl}/v1"`,
`env_key = "EXO_API_KEY"`,
``,
`[mcp_servers.filesystem]`,
`command = "npx"`,
`args = ["-y", "@modelcontextprotocol/server-filesystem", "${codexMcpPath}"]`,
].join("\n"),
);
const openClawConfig = $derived(
JSON.stringify(
{
gateway: { mode: "local" },
models: {
providers: {
exo: {
baseUrl: `${apiUrl}/v1`,
apiKey: "x",
api: "openai-completions",
models: [
{
id: openClawModel,
name: "exo local",
input: (modelCapabilities[openClawModel] || []).includes(
"vision",
)
? ["text", "image"]
: ["text"],
},
],
},
},
},
agents: {
defaults: {
model: `exo/${openClawModel}`,
},
},
},
null,
2,
),
);
const ollamaCommand = $derived(
`OLLAMA_HOST=${apiUrl}/ollama ollama run ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"}`,
);
const openWebUiCommand = $derived(
[
`docker run -d -p 3000:8080 \\`,
` -e OLLAMA_BASE_URL=${apiUrl.replace("localhost", "host.docker.internal")}/ollama \\`,
` -v open-webui:/app/backend/data \\`,
` --name open-webui \\`,
` ghcr.io/open-webui/open-webui:main`,
].join("\n"),
);
const n8nDockerCommand = $derived(
[
`docker run -d -p 5678:5678 \\`,
` -v n8n_data:/home/node/.n8n \\`,
` --name n8n \\`,
` docker.n8n.io/n8nio/n8n`,
].join("\n"),
);
const n8nCredentialSteps = $derived(
[
`1. Go to Credentials → Add Credential → search "OpenAI API"`,
`2. Set API Key to: x`,
`3. Set Base URL to: ${apiUrl.replace("127.0.0.1", "host.docker.internal").replace("localhost", "host.docker.internal")}/v1`,
`4. Save the credential`,
].join("\n"),
);
const n8nWorkflowSteps = $derived(
[
`1. Create a new workflow → "Start from Scratch"`,
`2. Add an "AI Agent" or "Basic LLM Chain" node`,
`3. Inside it, add an "OpenAI Chat Model" sub-node`,
`4. Select the OpenAI credential you just created`,
`5. Set Model to "From list" and pick your model (e.g. ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"})`,
`6. Optionally toggle "Use Responses API", add Built-in Tools, or click "Add Option" for sampling settings`,
`7. Connect a "Chat Trigger" node for interactive chat`,
`8. On the Chat Trigger, enable "Allow File Uploads" for vision`,
].join("\n"),
);
const firefoxConfig = $derived(
[
`1. Open about:config in Firefox`,
`2. Set browser.ml.chat.enabled to true`,
`3. Set browser.ml.chat.hideLocalhost to false`,
`4. Set browser.ml.chat.provider to: ${apiUrl}/`,
].join("\n"),
);
const tabs = [
"Claude Code",
"OpenCode",
"Codex",
"OpenClaw",
"Open WebUI",
"n8n",
"Firefox",
] as const;
type Tab = (typeof tabs)[number];
const stored = browser ? localStorage.getItem("exo-integrations-tab") : null;
let activeTab = $state<Tab>(
stored && tabs.includes(stored as Tab) ? (stored as Tab) : "Claude Code",
);
$effect(() => {
if (browser) localStorage.setItem("exo-integrations-tab", activeTab);
});
const selectClass =
"bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none appearance-none cursor-pointer";
onMount(async () => {
refreshState();
try {
const resp = await fetch("/v1/models");
const data = (await resp.json()) as {
data: { id: string; capabilities: string[]; context_length: number }[];
};
const caps: Record<string, string[]> = {};
const ctxs: Record<string, number> = {};
for (const model of data.data) {
caps[model.id] = model.capabilities || [];
if (model.context_length > 0) ctxs[model.id] = model.context_length;
}
modelCapabilities = caps;
modelContextLengths = ctxs;
} catch {
/* ignore */
}
});
</script>
<div class="min-h-screen bg-exo-dark-gray flex flex-col">
<HeaderNav showHome={true} />
<main
class="flex-1 max-w-3xl mx-auto w-full px-4 md:px-6 py-8"
in:fade={{ duration: 200 }}
>
<div class="mb-8">
<h1
class="text-white text-xl md:text-2xl font-semibold tracking-wide mb-2"
>
Integrations
</h1>
<p class="text-exo-light-gray/60 text-sm">
Connect external tools to your exo cluster.
</p>
</div>
<!-- Status -->
<div class="mb-8">
<span class="text-exo-light-gray/70 text-xs uppercase tracking-wider"
>API Endpoint</span
>
<span class="text-white font-mono text-sm ml-2">{apiUrl}</span>
{#if runningModels.length > 0}
<div class="text-exo-light-gray/50 text-xs mt-2">
Running model{runningModels.length > 1 ? "s" : ""}:
<ul class="mt-1 space-y-0.5 list-none">
{#each runningModels as model}
<li class="text-exo-yellow font-mono">{model}</li>
{/each}
</ul>
</div>
{:else}
<p class="text-exo-light-gray/40 text-xs mt-2 italic">
No models currently running
</p>
{/if}
</div>
<!-- API Endpoints -->
<div class="mb-8">
<div
class="flex flex-col sm:flex-row gap-3 text-xs font-mono text-exo-light-gray/70"
>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>OpenAI-compatible</span
>
<span class="text-white/80">{apiUrl}/v1</span>
</div>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>Claude-compatible</span
>
<span class="text-white/80">{apiUrl}</span>
</div>
<div
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
>
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
>Ollama-compatible</span
>
<span class="text-white/80">{apiUrl}/ollama</span>
</div>
</div>
</div>
<!-- Tabs -->
<div
class="flex flex-wrap gap-2 mb-6 border-b border-exo-light-gray/10 pb-3"
>
{#each tabs as tab}
<button
onclick={() => (activeTab = tab)}
class="px-3 py-1.5 text-xs rounded-md transition-all cursor-pointer
{activeTab === tab
? 'bg-exo-yellow/15 text-exo-yellow border border-exo-yellow/30'
: 'text-exo-light-gray/60 hover:text-white/80 border border-transparent hover:border-exo-light-gray/20'}"
>
{tab}
</button>
{/each}
</div>
<!-- Tab Content -->
<div class="space-y-4">
{#if activeTab === "Claude Code"}
{#if runningModels.length > 1}
<div class="grid grid-cols-3 gap-3 text-xs">
{#each [{ label: "Opus", bind: () => opusModel, set: (v: string) => (opusModel = v) }, { label: "Sonnet", bind: () => sonnetModel, set: (v: string) => (sonnetModel = v) }, { label: "Haiku", bind: () => haikuModel, set: (v: string) => (haikuModel = v) }] as tier}
<div>
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>{tier.label}</span
>
<select
value={tier.bind()}
onchange={(e) =>
tier.set((e.target as HTMLSelectElement).value)}
class="w-full {selectClass}"
>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/each}
</div>
{/if}
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch Claude Code with exo as the backend. Paste this into your terminal."
config={claudeShellCommand}
language="bash"
/>
<IntegrationCard
title="Settings File"
subtitle="~/.claude/settings.json"
description="Or add this to your Claude Code settings for persistent configuration."
config={claudeSettingsJson}
/>
{:else if activeTab === "OpenCode"}
<IntegrationCard
title="Config File"
subtitle="opencode.json"
description="Add this to your project root or ~/.config/opencode/opencode.json for global config. Vision models automatically get image input modality."
config={openCodeConfig}
/>
{:else if activeTab === "Codex"}
<div class="flex gap-3 text-xs">
{#if runningModels.length > 1}
<div>
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={codexModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<div class="flex-1">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>MCP Filesystem Path</span
>
<input
type="text"
bind:value={codexMcpPath}
class="w-full bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none"
/>
</div>
</div>
<IntegrationCard
title="Config File"
subtitle="~/.codex/config.toml"
description="Add this to your Codex CLI config so the model and provider persist."
config={codexConfig}
/>
<IntegrationCard
title="Shell Command"
subtitle="Run in terminal"
description="Launch Codex with exo as the backend."
config={codexShellCommand}
language="bash"
/>
{:else if activeTab === "OpenClaw"}
{#if runningModels.length > 1}
<div class="text-xs">
<span
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
>Model</span
>
<select bind:value={openClawModel} class={selectClass}>
{#each runningModels as model}
<option value={model}>{model.split("/").pop()}</option>
{/each}
</select>
</div>
{/if}
<IntegrationCard
title="Config File"
subtitle="~/.openclaw/openclaw.json"
description="Add this to your OpenClaw config. If you haven't installed OpenClaw yet, run: npm install -g openclaw@latest"
config={openClawConfig}
/>
<IntegrationCard
title="Setup Commands"
subtitle="Run in terminal"
description="After saving the config, run these commands to fix metadata and start the gateway."
config={`openclaw doctor --fix${(modelCapabilities[openClawModel] || []).includes("vision") ? `\nopenclaw models set-image exo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
language="bash"
/>
{:else if activeTab === "Open WebUI"}
<IntegrationCard
title="1. Start Open WebUI"
subtitle="Run in terminal"
description="Run this to start Open WebUI."
config={openWebUiCommand}
language="bash"
/>
<IntegrationCard
title="2. Open & Select Model"
subtitle="http://localhost:3000"
description={`Open http://localhost:3000 in your browser. Select the running model from the dropdown at the top: ${runningModels.length > 0 ? runningModels.join(", ") : "no models running"}`}
config={"open http://localhost:3000"}
language="bash"
/>
<IntegrationCard
title="Ollama CLI"
subtitle="Run in terminal"
description="Or use the Ollama CLI directly."
config={ollamaCommand}
language="bash"
/>
{:else if activeTab === "n8n"}
<IntegrationCard
title="1. Start n8n"
subtitle="Run in terminal"
description="Start n8n with Docker. If you already have n8n running, skip this step."
config={n8nDockerCommand}
language="bash"
/>
<IntegrationCard
title="2. Open n8n"
subtitle="http://localhost:5678"
description="Open n8n in your browser. If this is your first time, complete the setup and select 'Start from Scratch' when prompted."
config={"open http://localhost:5678"}
language="bash"
/>
<IntegrationCard
title="3. Add OpenAI Credential"
subtitle="n8n UI → Credentials"
description="Create an OpenAI credential pointing at your exo cluster."
config={n8nCredentialSteps}
/>
<IntegrationCard
title="4. Build a Workflow"
subtitle="n8n UI → Workflows"
description="Create a workflow that uses your exo-powered model."
config={n8nWorkflowSteps}
/>
{:else if activeTab === "Firefox"}
<IntegrationCard
title="Firefox AI Chatbot"
subtitle="about:config"
description="Use the exo dashboard as Firefox's built-in AI chatbot. Requires Firefox 130+."
config={firefoxConfig}
/>
{/if}
</div>
</main>
</div>
Generated
+9 -9
View File
@@ -164,11 +164,11 @@
]
},
"locked": {
"lastModified": 1763662255,
"narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=",
"lastModified": 1773870109,
"narHash": "sha256-ZoTdqZP03DcdoyxvpFHCAek4bkPUTUPUF3oCCgc3dP4=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "042904167604c681a090c07eb6967b4dd4dae88c",
"rev": "b6e74f433b02fa4b8a7965ee24680f4867e2926f",
"type": "github"
},
"original": {
@@ -184,11 +184,11 @@
]
},
"locked": {
"lastModified": 1764134915,
"narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=",
"lastModified": 1774498001,
"narHash": "sha256-wTfdyzzrmpuqt4TQQNqilF91v0m5Mh1stNy9h7a/WK4=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "2c8df1383b32e5443c921f61224b198a2282a657",
"rev": "794afa6eb588b498344f2eaa36ab1ceb7e6b0b09",
"type": "github"
},
"original": {
@@ -280,11 +280,11 @@
]
},
"locked": {
"lastModified": 1767701098,
"narHash": "sha256-CJhKZnWb3gumR9oTRjFvCg/6lYTGbZRU7xtvcyWIRwU=",
"lastModified": 1774490495,
"narHash": "sha256-a9WmQWj8fF7BctZGCoyzpUjP6GJw8H+lxl+zxpGnETk=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "9d357f0d2ce6f5f35ec7959d7e704452352eb4da",
"rev": "18ae62fc5e389e3069854a7c66455c22e31708fc",
"type": "github"
},
"original": {
+12 -1
View File
@@ -72,7 +72,7 @@
];
perSystem =
{ config, self', inputs', pkgs, lib, system, ... }:
{ config, self', pkgs, lib, system, ... }:
let
# Use pinned nixpkgs for swift-format (swift is broken on x86_64-linux in newer nixpkgs)
pkgsSwift = import inputs.nixpkgs-swift { inherit system; };
@@ -84,6 +84,17 @@
config.allowUnfreePredicate = pkg: (pkg.pname or "") == "metal-toolchain";
overlays = [
(import ./nix/apple-sdk-overlay.nix)
(final: prev: {
macmon = prev.macmon.overrideAttrs (_: {
version = "git";
src = final.fetchFromGitHub {
owner = "swiftraccoon";
repo = "macmon";
rev = "9154d234f763fbeffdcb4135d0bbbaf80609699b";
hash = "sha256-CwhilKNbs5XL9/tF5DMwyPBlE/hpmjGNTuxQ36sM50M=";
};
});
})
];
};
treefmt = {
+7
View File
@@ -1,5 +1,8 @@
export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
default: lint fmt
all: lint fmt check
fmt:
treefmt || nix fmt
@@ -31,6 +34,10 @@ build-dashboard:
package:
uv run pyinstaller packaging/pyinstaller/exo.spec
build-app: package
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
clean:
rm -rf **/__pycache__
rm -rf target/
+3 -2
View File
@@ -71,7 +71,9 @@ MACMON_PATH = shutil.which("macmon")
if MACMON_PATH is None:
raise SystemExit(
"macmon binary not found in PATH. "
"Install it via: brew install macmon"
"Install the pinned fork used by exo via: "
"cargo install --git https://github.com/swiftraccoon/macmon "
"--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b macmon --force"
)
BINARIES: list[tuple[str, str]] = [
@@ -120,4 +122,3 @@ coll = COLLECT(
upx_exclude=[],
name="exo",
)
+7 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "exo"
version = "0.3.68"
version = "0.3.69"
description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.116.1",
"filelock>=3.18.0",
"rustworkx>=0.17.1",
"huggingface-hub>=0.33.4",
"huggingface-hub>=1.8.0",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo_pyo3_bindings", # rust bindings
@@ -25,10 +25,12 @@ dependencies = [
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"mflux==0.16.9",
"mflux==0.17.2",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"mlx-vlm>=0.3.11",
"transformers>=5.0.0,<5.4.0",
]
[project.scripts]
@@ -61,7 +63,7 @@ members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo_pyo3_bindings = { workspace = true }
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/eval-left-padding-in-batched-rotation" }
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-arrayscache-leak" }
# Uncomment to use local mlx/mlx-lm development versions:
# mlx = { path = "/Users/Shared/mlx", editable=true }
# mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true }
@@ -114,6 +116,7 @@ root = "src"
required-version = ">=0.8.6"
prerelease = "allow"
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
extra-build-dependencies = { "miniaudio" = ["setuptools", "cffi", "pycparser"] }
###
# ruff configuration
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 405874409472
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 765577920512
@@ -0,0 +1,15 @@
model_id = "mlx-community/DeepSeek-V3.2-4bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 378086226621
@@ -0,0 +1,15 @@
model_id = "mlx-community/DeepSeek-V3.2-8bit"
n_layers = 61
hidden_size = 7168
num_key_value_heads = 128
supports_tensor = true
tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 755957120916
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 122406567936
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 229780750336
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 198556925568
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 286737579648
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 396963397248
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 19327352832
@@ -9,5 +9,7 @@ quantization = "5bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 22548578304
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 26843545600
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 202752
[storage_size]
in_bytes = 34359738368
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 790517400864
@@ -9,5 +9,7 @@ quantization = "MXFP4-Q8"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 405478939008
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
context_length = 202752
[storage_size]
in_bytes = 1487822475264
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Kimi K2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 620622774272
@@ -9,5 +9,7 @@ quantization = ""
base_model = "Kimi K2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 706522120192
@@ -7,7 +7,15 @@ tasks = ["TextGeneration"]
family = "kimi"
quantization = ""
base_model = "Kimi K2.5"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 662498705408
[vision]
image_token_id = 163605
model_type = "kimi_vl"
weights_repo = "davehind/Kimi-K2.5-vision"
processor_repo = "moonshotai/Kimi-K2.5"
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 39688355840
@@ -8,5 +8,7 @@ quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 74964549632
@@ -8,5 +8,7 @@ quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 141107412992
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 2538706944
@@ -8,5 +8,7 @@ quantization = "8bit"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 4794980352
@@ -8,5 +8,7 @@ quantization = "bf16"
base_model = "NVIDIA Llama-3.1-Nemotron-Nano-4B-v1.1"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 9025492992
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.2 1B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 729808896
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.2 3B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 1863319552
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Llama 3.2 3B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 3501195264
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 40652242944
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Llama 3.3 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 76799803392
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.1 70B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 40652242944
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 4637851648
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 8954839040
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "Llama 3.1 8B"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 16882073600
@@ -9,5 +9,7 @@ quantization = "3bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 196608
[storage_size]
in_bytes = 100086644736
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 196608
[storage_size]
in_bytes = 242986745856
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 128666664960
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 185826705408
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "MiniMax M2.5"
capabilities = ["text", "thinking"]
context_length = 196608
[storage_size]
in_bytes = 242986745856
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 17775342336
@@ -8,5 +8,7 @@ quantization = "5bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 21721476864
@@ -8,5 +8,7 @@ quantization = "6bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 25667611392
@@ -8,5 +8,7 @@ quantization = "8bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 33559880448
@@ -8,5 +8,7 @@ quantization = "bf16"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 63155889408
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 16788808704
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-3-Nano-30B-A3B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 19323906944
@@ -8,5 +8,7 @@ quantization = "4bit"
base_model = "NVIDIA Nemotron-Nano-9B-v2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 5002791936
@@ -8,5 +8,7 @@ quantization = "6bit"
base_model = "NVIDIA Nemotron-Nano-9B-v2"
capabilities = ["text"]
context_length = 131072
[storage_size]
in_bytes = 7224298496
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 0.6B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 342884352
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 0.6B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 698351616
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 235B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 141733920768
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 235B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 268435456000
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 30B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 17612931072
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 30B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 32768
[storage_size]
in_bytes = 33279705088
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Coder 480B"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 289910292480
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Coder 480B"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 579820584960
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 45644286500
@@ -9,5 +9,7 @@ quantization = "5bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 57657697020

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