Compare commits

..

37 Commits

Author SHA1 Message Date
ciaranbor 68a4cf728d Fix rebase 2026-04-01 19:14:11 +01:00
ciaranbor e6bee75d91 Use slider for storage limit 2026-04-01 19:14:11 +01:00
ciaranbor 70dbc03b37 Remove ModelEvicted status 2026-04-01 19:14:11 +01:00
ciaranbor b54cd7415d Rename model download statuses 2026-04-01 19:14:11 +01:00
ciaranbor fdd6d94fcc Add storage config IO tests 2026-04-01 19:14:11 +01:00
ciaranbor 26aacd55b7 Batch update storage configs 2026-04-01 19:14:11 +01:00
ciaranbor fee0d3fc48 Better separation of concerns 2026-04-01 19:14:11 +01:00
ciaranbor 0a951737ee Move rejection-triggered instance cleanup from _plan to _event_processor 2026-04-01 19:14:11 +01:00
ciaranbor a0fad061e2 Include ongoing downloads in calculate_used_storage 2026-04-01 19:14:11 +01:00
ciaranbor 9832f161e7 Evict directly instead of through DownloadRejected 2026-04-01 19:14:11 +01:00
ciaranbor e1ddaf97bd Pydantic <-> toml 2026-04-01 19:14:11 +01:00
ciaranbor fa29d0fd00 Deduplicate by model ID 2026-04-01 19:14:11 +01:00
ciaranbor 60f6d8e6fc Use DownloadEvicted event 2026-04-01 19:14:11 +01:00
ciaranbor f87d705b9c Implement storage management 2026-04-01 19:14:11 +01:00
ciaranbor 94a7264cbf Add cli arguments to set max storage and eviction policy 2026-04-01 19:14:11 +01:00
ciaranbor bf35249d9a Add StorageConfig to state, with max storage and storage policy settings 2026-04-01 19:14:11 +01:00
ciaranbor 9d5242a25e Add StorageConfigUpdated event 2026-04-01 19:14:11 +01:00
ciaranbor f88aea5bbe Add SetStorageConfig command 2026-04-01 19:14:11 +01:00
ciaranbor 922e00ddc8 Add DownloadRejected type 2026-04-01 19:14:11 +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
179 changed files with 7779 additions and 1060 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
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: ...
+8 -4
View File
@@ -295,8 +295,9 @@ exo supports several environment variables for configuration:
| Variable | Description | Default |
|----------|-------------|---------|
| `EXO_MODELS_PATH` | Colon-separated paths to search for pre-downloaded models (e.g., on NFS mounts or shared storage) | None |
| `EXO_MODELS_DIR` | Directory where exo downloads and stores models | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_DEFAULT_MODELS_DIR` | Default directory for model downloads and caches. Always first in the writable dirs list. | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
| `EXO_MODELS_DIRS` | Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
| `EXO_MODELS_READ_ONLY_DIRS` | Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
| `EXO_OFFLINE` | Run without internet connection (uses only local models) | `false` |
| `EXO_ENABLE_IMAGE_MODELS` | Enable image model support | `false` |
| `EXO_LIBP2P_NAMESPACE` | Custom namespace for cluster isolation | None |
@@ -306,8 +307,11 @@ exo supports several environment variables for configuration:
**Example usage:**
```bash
# Use pre-downloaded models from NFS mount
EXO_MODELS_PATH=/mnt/nfs/models:/opt/ai-models uv run exo
# Use pre-downloaded models from NFS mount (read-only)
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
# Download models to an external SSD (falls back to default dir if full)
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
# Run in offline mode
EXO_OFFLINE=true uv run exo
+1 -1
View File
@@ -264,7 +264,7 @@ struct NodeDownloadStatus {
init?(statusKey: String, payload: NodeDownloadPayload) {
guard let nodeId = payload.nodeId else { return nil }
self.nodeId = nodeId
self.progress = statusKey == "DownloadOngoing" ? payload.downloadProgress : nil
self.progress = statusKey == "ModelDownloading" ? payload.downloadProgress : nil
}
}
+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)
+143 -4
View File
@@ -22,6 +22,7 @@ import contextlib
import itertools
import json
import sys
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -33,7 +34,9 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
node_ids_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
run_planning_phase,
@@ -131,6 +134,91 @@ def format_peak_memory(b: float) -> str:
raise ValueError("You're using petabytes of memory. Something went wrong...")
_SAMPLER_METRICS = ("gpuUsage", "temp", "sysPower", "pcpuUsage", "ecpuUsage")
class SystemMetricsSampler:
def __init__(self, client: ExoClient, node_ids: list[str], interval_s: float = 1.0):
self._client = client
self._node_ids = node_ids
self._interval_s = interval_s
self._samples: dict[str, list[tuple[float, dict[str, float]]]] = {
nid: [] for nid in node_ids
}
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
self._stop.clear()
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
def _poll_loop(self) -> None:
while not self._stop.is_set():
t = time.monotonic()
for nid in self._node_ids:
try:
data = self._client.get_node_system(nid)
if data:
self._samples[nid].append(
(t, {k: data.get(k, 0.0) for k in _SAMPLER_METRICS})
)
except Exception:
pass
self._stop.wait(self._interval_s)
def energy_between(self, t0: float, t1: float) -> float:
total_joules = 0.0
for _nid, samples in self._samples.items():
window = [(t, s["sysPower"]) for t, s in samples if t0 <= t <= t1]
if len(window) >= 2:
for i in range(1, len(window)):
dt = window[i][0] - window[i - 1][0]
avg_power = (window[i][1] + window[i - 1][1]) / 2
total_joules += avg_power * dt
elif len(window) == 1:
total_joules += window[0][1] * (t1 - t0)
return total_joules
def summarize(self) -> dict[str, dict[str, dict[str, float]]]:
result: dict[str, dict[str, dict[str, float]]] = {}
for nid, samples in self._samples.items():
if not samples:
continue
metrics: dict[str, dict[str, float]] = {}
for key in _SAMPLER_METRICS:
values = [s[key] for t, s in samples]
metrics[key] = {
"min": round(min(values), 2),
"max": round(max(values), 2),
"mean": round(mean(values), 2),
"samples": len(values),
}
result[nid] = metrics
return result
def print_summary(self, placement_label: str) -> None:
summary = self.summarize()
if not summary:
return
logger.info(f"--- System Metrics ({placement_label}) ---")
for nid, metrics in summary.items():
gpu = metrics.get("gpuUsage", {})
temp = metrics.get("temp", {})
power = metrics.get("sysPower", {})
logger.info(
f" {nid}: "
f"GPU {gpu.get('mean', 0) * 100:.0f}% avg ({gpu.get('min', 0) * 100:.0f}{gpu.get('max', 0) * 100:.0f}%) | "
f"{temp.get('mean', 0):.1f}°C avg | "
f"{power.get('mean', 0):.1f}W avg"
)
def parse_int_list(values: list[str]) -> list[int]:
items: list[int] = []
for v in values:
@@ -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)
@@ -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
@@ -522,13 +638,30 @@ def main() -> int:
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
)
logger.info(
summary = (
f"prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
f"prompt_tokens={ptok} gen_tokens={gtok} "
f"peak_memory={format_peak_memory(peak)}\n"
f"peak_memory={format_peak_memory(peak)}"
)
if sampler and inference_windows:
joules = sum(
sampler.energy_between(t0, t1)
for t0, t1 in inference_windows
)
inf_seconds = sum(t1 - t0 for t0, t1 in inference_windows)
avg_watts = joules / inf_seconds if inf_seconds > 0 else 0
summary += f" energy={joules:.1f}J ({avg_watts:.1f}W avg over {inf_seconds:.1f}s inference)"
logger.info(f"{summary}\n")
time.sleep(2)
finally:
if sampler:
sampler.stop()
placement_label = f"{sharding}/{instance_meta}/{n_nodes} nodes"
sampler.print_summary(placement_label)
placement_metrics = sampler.summarize()
if placement_metrics:
all_system_metrics.update(placement_metrics)
try:
client.request_json("DELETE", f"/instance/{instance_id}")
except ExoHttpError as e:
@@ -539,11 +672,17 @@ def main() -> int:
time.sleep(5)
output: dict[str, Any] = {"runs": all_rows}
if cluster_snapshot:
output["cluster"] = cluster_snapshot
if all_system_metrics:
output["system_metrics"] = all_system_metrics
if args.stdout:
json.dump(all_rows, sys.stdout, indent=2, ensure_ascii=False)
json.dump(output, sys.stdout, indent=2, ensure_ascii=False)
elif args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(all_rows, f, indent=2, ensure_ascii=False)
json.dump(output, f, indent=2, ensure_ascii=False)
logger.debug(f"\nWrote results JSON: {args.json_out}")
return 0
+8 -1
View File
@@ -46,6 +46,7 @@ from harness import (
ExoClient,
ExoHttpError,
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
nodes_used_in_instance,
resolve_model_short_id,
@@ -1027,16 +1028,18 @@ def save_results(
concurrency: int,
results: list[QuestionResult],
scores: dict[str, Any],
cluster: dict[str, Any] | None = None,
) -> Path:
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark_name
out_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = out_dir / f"c{concurrency}_{ts}.json"
data = {
data: dict[str, Any] = {
"benchmark": benchmark_name,
"model": model,
"concurrency": concurrency,
**({"cluster": cluster} if cluster else {}),
"scores": scores,
"results": [
{
@@ -1231,8 +1234,10 @@ def main() -> int:
client.request_json("DELETE", f"/instance/{instance_id}")
return 1
time.sleep(1)
cluster_snapshot = capture_cluster_snapshot(client)
else:
full_model_id = args.model
cluster_snapshot = None
# Auto-detect reasoning from model config
model_config = load_model_config(full_model_id)
@@ -1328,6 +1333,7 @@ def main() -> int:
c,
results,
scores,
cluster=cluster_snapshot,
)
results_by_c[c] = results
if len(results_by_c) >= 2:
@@ -1358,6 +1364,7 @@ def main() -> int:
args.num_concurrent,
results,
scores,
cluster=cluster_snapshot,
)
finally:
if instance_id is not None:
+110 -43
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,19 +383,14 @@ 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"][
"ModelReady" in p
and unwrap_instance(p["ModelReady"]["shardMetadata"])["modelCard"][
"modelId"
]
== full_model_id
@@ -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,17 +426,15 @@ 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"][
unwrap_instance(p["ModelReady"]["shardMetadata"])["modelCard"][
"modelId"
],
p["DownloadCompleted"]["total"]["inBytes"],
p["ModelReady"]["total"]["inBytes"],
)
for p in node_downloads
if "DownloadCompleted" in p
and not p["DownloadCompleted"].get("readOnly", False)
if "ModelReady" in p and not p["ModelReady"].get("readOnly", False)
]
for del_model, size in sorted(completed, key=lambda x: x[1]):
logger.info(f"Deleting {del_model} from {node_id} ({size // (1024**2)}MB)")
@@ -417,31 +464,51 @@ def run_planning_phase(
# Wait for downloads
start = time.time()
while time.time() - start < timeout:
state = client.request_json("GET", "/state")
downloads = state.get("downloads", {})
all_done = True
for node_id in node_ids:
node_downloads = client.get_node_downloads(node_id) or []
done = any(
"DownloadCompleted" in p
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
for p in downloads.get(node_id, [])
)
failed = [
p["DownloadFailed"]["errorMessage"]
for p in downloads.get(node_id, [])
if "DownloadFailed" in p
and unwrap_instance(p["DownloadFailed"]["shardMetadata"])["modelCard"][
"ModelReady" in p
and unwrap_instance(p["ModelReady"]["shardMetadata"])["modelCard"][
"modelId"
]
== full_model_id
for p in node_downloads
)
failed = [
p["ModelDownloadFailed"]["errorMessage"]
for p in node_downloads
if "ModelDownloadFailed" in p
and unwrap_instance(p["ModelDownloadFailed"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
]
if failed:
raise RuntimeError(f"Download failed on {node_id}: {failed[0]}")
if not done:
all_done = False
ongoing = [
p
for p in node_downloads
if "DownloadOngoing" in p
and unwrap_instance(p["DownloadOngoing"]["shardMetadata"])[
"modelCard"
]["modelId"]
== full_model_id
]
if ongoing:
prog = ongoing[0]["DownloadOngoing"]["downloadProgress"]
speed_mb = prog.get("speed", 0) / (1024 * 1024)
eta_s = prog.get("etaMs", 0) / 1000
dl_bytes = prog.get("downloaded", {}).get("inBytes", 0)
total_bytes = prog.get("total", {}).get("inBytes", 0)
pct = (dl_bytes / total_bytes * 100) if total_bytes else 0
logger.info(
f"Downloading on {node_id}: {pct:.1f}% @ {speed_mb:.1f} MB/s, "
f"ETA {eta_s:.0f}s "
f"({prog.get('completedFiles', 0)}/{prog.get('totalFiles', 0)} files)"
)
if all_done:
if download_t0 is not None:
return time.perf_counter() - download_t0
+272 -1
View File
@@ -11,7 +11,8 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0"
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@@ -518,6 +519,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2635,6 +2886,26 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -31,6 +31,7 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0"
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
}
}
@@ -139,6 +139,8 @@
return "🖼";
case "text":
return "📄";
case "pdf":
return "📑";
default:
return "📎";
}
@@ -42,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
>
@@ -90,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
>
@@ -115,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
>
@@ -139,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
>
@@ -165,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>
@@ -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"
+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";
+172 -18
View File
@@ -254,14 +254,22 @@ interface RawStateResponse {
string,
{ total: { inBytes: number }; available: { inBytes: number } }
>;
nodeStorageConfig?: Record<
string,
{
maxStorage: { inBytes: number } | null;
storagePolicy: "manual" | "auto-evict";
}
>;
}
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 {
@@ -567,6 +575,15 @@ class AppStore {
>
>({});
nodeRdmaCtl = $state<Record<string, { enabled: boolean }>>({});
nodeStorageConfig = $state<
Record<
string,
{
maxStorage: { inBytes: number } | null;
storagePolicy: "manual" | "auto-evict";
}
>
>({});
nodeThunderboltBridge = $state<
Record<
string,
@@ -1325,6 +1342,7 @@ class AppStore {
this.thunderboltBridgeCycles = data.thunderboltBridgeCycles ?? [];
// Thunderbolt bridge status per node
this.nodeThunderboltBridge = data.nodeThunderboltBridge ?? {};
this.nodeStorageConfig = data.nodeStorageConfig ?? {};
this.lastUpdate = Date.now();
// Connection recovered
if (!this.isConnected) {
@@ -2242,6 +2260,7 @@ class AppStore {
type: string;
textContent?: string;
preview?: string;
pageImages?: string[];
}[],
enableThinking?: boolean | null,
): Promise<void> {
@@ -2277,6 +2296,20 @@ class AppStore {
preview: file.preview,
mimeType: file.type,
});
} else if (
file.pageImages ||
(file.textContent && file.type === "application/pdf")
) {
attachments.push({
type: "pdf",
name: file.name,
content: file.textContent,
pageImages: file.pageImages,
mimeType: file.type,
});
if (file.textContent) {
fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
}
} else if (file.textContent) {
attachments.push({
type: "text",
@@ -2344,13 +2377,70 @@ class AppStore {
const apiMessages = [
systemPrompt,
...targetConversation.messages.slice(0, -1).map((m) => {
// Build content including any text file attachments
// Check if this message has image or PDF attachments
const visualAttachments = m.attachments?.filter(
(a) =>
(a.type === "image" && a.preview) ||
(a.type === "pdf" && a.pageImages?.length),
);
if (visualAttachments && visualAttachments.length > 0) {
// Build multimodal content array (OpenAI vision format)
const contentParts: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = [];
// Add image parts first
for (const att of visualAttachments) {
if (att.type === "image" && att.preview) {
contentParts.push({
type: "image_url",
image_url: { url: att.preview },
});
} else if (att.type === "pdf" && att.pageImages) {
for (const pageImg of att.pageImages) {
contentParts.push({
type: "image_url",
image_url: { url: pageImg },
});
}
}
}
// Build text content including any text/pdf file attachments
let textContent = m.content;
if (m.attachments) {
for (const attachment of m.attachments) {
if (
(attachment.type === "text" || attachment.type === "pdf") &&
attachment.content
) {
textContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
}
}
if (textContent) {
contentParts.push({ type: "text", text: textContent });
}
return {
role: m.role,
content: contentParts,
};
}
// Text-only message (original path)
let msgContent = m.content;
// Add text attachments as context
// Add text/pdf attachments as context
if (m.attachments) {
for (const attachment of m.attachments) {
if (attachment.type === "text" && attachment.content) {
if (
(attachment.type === "text" || attachment.type === "pdf") &&
attachment.content
) {
msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
}
@@ -2650,6 +2740,9 @@ class AppStore {
this.syncActiveMessagesIfNeeded(targetConversationId);
this.saveConversationsToStorage();
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2704,6 +2797,7 @@ class AppStore {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: abortController.signal,
});
if (!response.ok) {
@@ -2843,14 +2937,27 @@ class AppStore {
);
}
} catch (error) {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
if (abortController.signal.aborted) {
this.updateConversationMessage(
targetConversationId,
assistantMessage.id,
(msg) => {
msg.content = "Cancelled";
msg.attachments = [];
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
} else {
console.error("Error generating image:", error);
this.handleStreamingError(
error,
targetConversationId,
assistantMessage.id,
"Failed to generate image",
);
}
} finally {
this.currentAbortController = null;
this.isLoading = false;
this.saveConversationsToStorage();
}
@@ -2914,6 +3021,9 @@ class AppStore {
// Clear editing state
this.editingImage = null;
const abortController = new AbortController();
this.currentAbortController = abortController;
try {
// Determine the model to use
const model = this.getModelForRequest(modelId);
@@ -2975,6 +3085,7 @@ class AppStore {
const apiResponse = await fetch("/v1/images/edits", {
method: "POST",
body: formData,
signal: abortController.signal,
});
if (!apiResponse.ok) {
@@ -3075,14 +3186,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();
}
@@ -3172,6 +3296,29 @@ class AppStore {
}
}
async setStorageConfig(
nodeIds: string[] | null,
maxStorageGb: number | null,
storagePolicy: "manual" | "auto-evict",
): Promise<void> {
try {
const response = await fetch("/storage", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nodeIds, maxStorageGb, storagePolicy }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to set storage config: ${response.status} - ${errorText}`,
);
}
} catch (error) {
console.error("Error setting storage config:", error);
throw error;
}
}
/**
* List all available traces
*/
@@ -3249,6 +3396,7 @@ export const instances = () => appStore.instances;
export const runners = () => appStore.runners;
export const downloads = () => appStore.downloads;
export const nodeDisk = () => appStore.nodeDisk;
export const nodeStorageConfig = () => appStore.nodeStorageConfig;
export const placementPreviews = () => appStore.placementPreviews;
export const selectedPreviewModelId = () => appStore.selectedPreviewModelId;
export const isLoadingPreviews = () => appStore.isLoadingPreviews;
@@ -3271,6 +3419,7 @@ export const sendMessage = (
type: string;
textContent?: string;
preview?: string;
pageImages?: string[];
}[],
enableThinking?: boolean | null,
) => appStore.sendMessage(content, files, enableThinking);
@@ -3371,6 +3520,11 @@ export const startDownload = (nodeId: string, shardMetadata: object) =>
appStore.startDownload(nodeId, shardMetadata);
export const deleteDownload = (nodeId: string, modelId: string) =>
appStore.deleteDownload(nodeId, modelId);
export const setStorageConfig = (
nodeIds: string[] | null,
maxStorageGb: number | null,
storagePolicy: "manual" | "auto-evict",
) => appStore.setStorageConfig(nodeIds, maxStorageGb, storagePolicy);
// Trace actions
export const listTraces = () => appStore.listTraces();
+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 });
+9 -9
View File
@@ -5,7 +5,7 @@
* Record<NodeId, Array<TaggedDownloadEntry>>
*
* Each entry is a tagged union object like:
* { "DownloadCompleted": { shard_metadata: { "PipelineShardMetadata": { model_card: { model_id: "..." }, ... } }, ... } }
* { "ModelReady": { shard_metadata: { "PipelineShardMetadata": { model_card: { model_id: "..." }, ... } }, ... } }
*/
/** Unwrap one level of tagged-union envelope, returning [tag, payload]. */
@@ -49,7 +49,7 @@ export function extractShardMetadata(
return shardMetadata as Record<string, unknown>;
}
/** Get the download tag (DownloadCompleted, DownloadOngoing, etc.) from a wrapped entry. */
/** Get the download tag (ModelReady, ModelDownloading, etc.) from a wrapped entry. */
export function getDownloadTag(
entry: unknown,
): [string, Record<string, unknown>] | null {
@@ -73,7 +73,7 @@ function* iterNodeDownloads(
}
}
/** Check if a specific model is fully downloaded (DownloadCompleted) on a specific node. */
/** Check if a specific model is fully downloaded (ModelReady) on a specific node. */
export function isModelDownloadedOnNode(
downloadsData: Record<string, unknown[]>,
nodeId: string,
@@ -83,12 +83,12 @@ export function isModelDownloadedOnNode(
if (!Array.isArray(nodeDownloads)) return false;
for (const [tag, , entryModelId] of iterNodeDownloads(nodeDownloads)) {
if (tag === "DownloadCompleted" && entryModelId === modelId) return true;
if (tag === "ModelReady" && entryModelId === modelId) return true;
}
return false;
}
/** Get all node IDs where a model is fully downloaded (DownloadCompleted). */
/** Get all node IDs where a model is fully downloaded (ModelReady). */
export function getNodesWithModelDownloaded(
downloadsData: Record<string, unknown[]>,
modelId: string,
@@ -122,7 +122,7 @@ export function getShardMetadataForModel(
const shard = extractShardMetadata(payload);
if (!shard) continue;
if (tag === "DownloadCompleted") return shard;
if (tag === "ModelReady") return shard;
if (!fallback) fallback = shard;
}
}
@@ -131,7 +131,7 @@ export function getShardMetadataForModel(
/**
* Get the download status tag for a specific model on a specific node.
* Returns the "best" status: DownloadCompleted > DownloadOngoing > others.
* Returns the "best" status: ModelReady > ModelDownloading > others.
*/
export function getModelDownloadStatus(
downloadsData: Record<string, unknown[]>,
@@ -144,8 +144,8 @@ export function getModelDownloadStatus(
let best: string | null = null;
for (const [tag, , entryModelId] of iterNodeDownloads(nodeDownloads)) {
if (entryModelId !== modelId) continue;
if (tag === "DownloadCompleted") return tag;
if (tag === "DownloadOngoing") best = tag;
if (tag === "ModelReady") return tag;
if (tag === "ModelDownloading") best = tag;
else if (!best) best = tag;
}
return best;
+76 -7
View File
@@ -265,6 +265,7 @@
let mounted = $state(false);
let localNodeId = $state<string | null>(null);
let pendingFirefoxQuery = $state<string | null>(null); // ?q= param deferred until state loads
// ── Onboarding wizard state ──
const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
@@ -1309,6 +1310,20 @@
return;
}
// Firefox AI sidebar integration: handle ?q= query parameter
// Firefox's built-in AI sidebar (about:config: browser.ml.chat.enabled) sends
// the user's prompt as ?q=<URL-encoded prompt> to the configured provider URL.
// See: https://support.mozilla.org/en-US/kb/ai-chatbot
const queryParam = params.get("q");
if (queryParam) {
// Clean up the URL to prevent re-submission on page refresh
window.history.replaceState({}, "", window.location.pathname);
// Defer the auto-send until cluster state is loaded (topologyData,
// instances, availableMemory) so that model auto-selection works
// correctly. The $effect below will pick this up once data is ready.
pendingFirefoxQuery = queryParam;
}
// Check server-side onboarding state (persisted in ~/.exo)
try {
const res = await fetch("/onboarding");
@@ -1329,6 +1344,18 @@
}
});
// Deferred Firefox AI sidebar auto-send: wait for cluster state and model
// list before submitting. Both data (from /state polling) and models (from
// the async /models fetch in onMount) must be loaded for handleAutoSend to
// correctly auto-select a model.
$effect(() => {
if (pendingFirefoxQuery && data && models.length > 0) {
const query = pendingFirefoxQuery;
pendingFirefoxQuery = null;
handleChatSend(query);
}
});
async function fetchModels() {
try {
const response = await fetch("/models");
@@ -1554,12 +1581,14 @@
progress: DownloadProgress | null;
perNode: NodeDownloadStatus[];
failedError: string | null;
rejectedError: string | null;
} {
const empty = {
isDownloading: false,
progress: null,
perNode: [] as NodeDownloadStatus[],
failedError: null,
rejectedError: null,
};
if (!downloadsData || Object.keys(downloadsData).length === 0) {
@@ -1591,8 +1620,8 @@
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (!downloadModelId || downloadModelId !== modelId) continue;
// DownloadFailed — return with any data collected so far
if (downloadKind === "DownloadFailed") {
// ModelDownloadFailed — return with any data collected so far
if (downloadKind === "ModelDownloadFailed") {
return {
isDownloading: false,
progress: null,
@@ -1601,12 +1630,25 @@
(downloadPayload.errorMessage as string) ||
(downloadPayload.error_message as string) ||
"Download failed",
rejectedError: null,
};
}
// ModelRejected — storage limit exceeded
if (downloadKind === "ModelRejected") {
return {
isDownloading: false,
progress: null,
perNode: Array.from(perNodeMap.values()),
failedError: null,
rejectedError:
(downloadPayload.reason as string) || "Storage limit exceeded",
};
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending" &&
downloadKind !== "ModelDownloading" &&
downloadKind !== "ModelNotDownloading" &&
downloadKind !== "DownloadCompleted"
)
continue;
@@ -1625,7 +1667,7 @@
continue;
}
if (downloadKind === "DownloadPending") {
if (downloadKind === "ModelNotDownloading") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
downloadPayload.downloaded_bytes ??
@@ -1649,7 +1691,7 @@
continue;
}
// DownloadOngoing
// ModelDownloading
const progress = parseDownloadProgress(downloadPayload);
if (
!progress ||
@@ -1695,6 +1737,7 @@
progress: null,
perNode,
failedError: null,
rejectedError: null,
};
}
@@ -1715,6 +1758,7 @@
},
perNode,
failedError: null,
rejectedError: null,
};
}
@@ -1799,6 +1843,17 @@
};
}
if (result.rejectedError) {
return {
isDownloading: false,
isFailed: true,
errorMessage: result.rejectedError,
progress: null,
statusText: "REJECTED",
perNode: [],
};
}
if (!result.isDownloading) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
@@ -2448,7 +2503,13 @@
if (Object.keys(prev).length > 0) {
for (const [id, currentStatus] of Object.entries(currentStatuses)) {
const prevStatus = prev[id];
if (!prevStatus || prevStatus === currentStatus) continue;
if (prevStatus === currentStatus) continue;
if (
!prevStatus &&
currentStatus !== "REJECTED" &&
currentStatus !== "FAILED"
)
continue;
const modelId = getInstanceModelId(instanceData[id]);
const shortName = modelId
@@ -2482,6 +2543,14 @@
addToast({ type: "error", message: `Model failed: ${shortName}` });
}
if (prevStatus !== "REJECTED" && currentStatus === "REJECTED") {
addToast({
type: "warning",
message: `Storage limit exceeded: ${shortName}`,
duration: 8000,
});
}
// Any -> Shutdown
if (prevStatus !== "SHUTDOWN" && currentStatus === "SHUTDOWN") {
addToast({ type: "info", message: `Model shut down: ${shortName}` });
+391 -15
View File
@@ -6,16 +6,19 @@
topologyData,
downloads,
nodeDisk,
nodeStorageConfig,
refreshState,
lastUpdate as lastUpdateStore,
startDownload,
deleteDownload,
setStorageConfig,
} from "$lib/stores/app.svelte";
import {
getDownloadTag,
extractModelIdFromDownload,
extractShardMetadata,
} from "$lib/utils/downloads";
import { addToast } from "$lib/stores/toast.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
type CellStatus =
@@ -36,6 +39,14 @@
modelDirectory?: string;
}
| { kind: "failed"; modelDirectory?: string }
| {
kind: "rejected";
reason: string;
requiredBytes: number;
availableBytes: number;
limitBytes: number;
modelDirectory?: string;
}
| { kind: "not_present" };
type ModelCardInfo = {
@@ -61,11 +72,14 @@
label: string;
diskAvailable?: number;
diskTotal?: number;
storageLimit?: number;
storagePolicy?: "manual" | "auto-evict";
};
const data = $derived(topologyData());
const downloadsData = $derived(downloads());
const nodeDiskData = $derived(nodeDisk());
const storageConfigData = $derived(nodeStorageConfig());
function getNodeLabel(nodeId: string): string {
const node = data?.nodes?.[nodeId];
@@ -122,10 +136,37 @@
return Math.min(100, Math.max(0, value as number));
}
function getNodeUsedStorage(nodeId: string): number {
const nodeDownloads = downloadsData?.[nodeId];
if (!nodeDownloads || !Array.isArray(nodeDownloads)) return 0;
let total = 0;
for (const entry of nodeDownloads) {
const tagged = getDownloadTag(entry);
if (!tagged) continue;
const [tag, payload] = tagged;
if (tag === "ModelReady") {
total += getBytes(payload.total);
} else if (tag === "ModelDownloading") {
const prog = (payload.download_progress ?? payload.downloadProgress) as
| Record<string, unknown>
| undefined;
if (prog) total += getBytes(prog.downloaded);
}
}
return total;
}
function storageBarColor(percent: number): string {
if (percent >= 90) return "bg-red-500";
if (percent >= 70) return "bg-yellow-500";
return "bg-green-500";
}
const CELL_PRIORITY: Record<CellStatus["kind"], number> = {
completed: 4,
downloading: 3,
pending: 2,
completed: 5,
downloading: 4,
pending: 3,
rejected: 2,
failed: 1,
not_present: 0,
};
@@ -178,6 +219,50 @@
let nodeColumns = $state<NodeColumn[]>([]);
let infoRow = $state<ModelRow | null>(null);
let storageConfigNode = $state<NodeColumn | null>(null);
let configMaxGb = $state<number | null>(null);
let configNoLimit = $state(true);
let configPolicy = $state<"manual" | "auto-evict">("manual");
let configSaving = $state(false);
let configApplyAll = $state(false);
let configDiskTotalGb = $derived(
storageConfigNode
? Math.round((storageConfigNode.diskTotal ?? 0) / 1024 ** 3)
: 0,
);
function openStorageConfig(col: NodeColumn) {
storageConfigNode = col;
if (col.storageLimit != null) {
configNoLimit = false;
configMaxGb = Math.round(col.storageLimit / 1024 ** 3);
} else {
configNoLimit = true;
configMaxGb = null;
}
configPolicy = col.storagePolicy ?? "manual";
configApplyAll = false;
}
async function saveStorageConfig() {
if (!storageConfigNode) return;
configSaving = true;
try {
const maxGb = configNoLimit ? null : configMaxGb;
const nodeIds = configApplyAll ? null : [storageConfigNode.nodeId];
await setStorageConfig(nodeIds, maxGb, configPolicy);
storageConfigNode = null;
refreshState();
} catch (error) {
addToast({
type: "error",
message: `Failed to save storage config: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
configSaving = false;
}
}
$effect(() => {
try {
if (!downloadsData || Object.keys(downloadsData).length === 0) {
@@ -189,11 +274,14 @@
const allNodeIds = Object.keys(downloadsData);
const columns: NodeColumn[] = allNodeIds.map((nodeId) => {
const diskInfo = nodeDiskData?.[nodeId];
const storageConfig = storageConfigData?.[nodeId];
return {
nodeId,
label: getNodeLabel(nodeId),
diskAvailable: diskInfo?.available?.inBytes,
diskTotal: diskInfo?.total?.inBytes,
storageLimit: storageConfig?.maxStorage?.inBytes ?? undefined,
storagePolicy: storageConfig?.storagePolicy,
};
});
@@ -234,10 +322,14 @@
((payload.model_directory ?? payload.modelDirectory) as string) ||
undefined;
let cell: CellStatus;
if (tag === "DownloadCompleted") {
if (tag === "ModelReady") {
const totalBytes = getBytes(payload.total);
cell = { kind: "completed", totalBytes, modelDirectory };
} else if (tag === "DownloadOngoing") {
cell = {
kind: "completed",
totalBytes,
modelDirectory,
};
} else if (tag === "ModelDownloading") {
const rawProgress =
payload.download_progress ?? payload.downloadProgress ?? {};
const prog = rawProgress as Record<string, unknown>;
@@ -257,7 +349,16 @@
etaMs,
modelDirectory,
};
} else if (tag === "DownloadFailed") {
} else if (tag === "ModelRejected") {
cell = {
kind: "rejected",
reason: (payload.reason as string) ?? "Storage limit exceeded",
requiredBytes: getBytes(payload.required),
availableBytes: getBytes(payload.available),
limitBytes: getBytes(payload.limit),
modelDirectory,
};
} else if (tag === "ModelDownloadFailed") {
cell = { kind: "failed", modelDirectory };
} else {
const downloaded = getBytes(
@@ -406,15 +507,52 @@
Model
</th>
{#each nodeColumns as col}
{@const usedStorage = getNodeUsedStorage(col.nodeId)}
{@const storageMax = col.storageLimit ?? col.diskTotal ?? 0}
{@const storagePercent =
storageMax > 0
? Math.min(100, (usedStorage / storageMax) * 100)
: 0}
<th
class="px-4 py-3 text-[11px] uppercase tracking-wider text-exo-light-gray font-medium text-center whitespace-nowrap min-w-[120px]"
>
<div>{col.label}</div>
{#if col.diskAvailable != null}
<div
class="text-[9px] text-white/70 normal-case tracking-normal mt-0.5"
<div class="flex items-center justify-center gap-1">
<span>{col.label}</span>
<button
type="button"
class="p-0.5 rounded hover:bg-white/10 transition-colors"
onclick={() => openStorageConfig(col)}
title="Storage settings"
aria-label="Storage settings for {col.label}"
>
{formatBytes(col.diskAvailable)} free
<svg
class="w-3.5 h-3.5 text-white/40 hover:text-exo-yellow transition-colors"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{#if storageMax > 0}
<div class="text-[9px] normal-case tracking-normal mt-1">
<div
class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full rounded-full transition-all duration-300 {storageBarColor(
storagePercent,
)}"
style="width: {storagePercent.toFixed(1)}%"
></div>
</div>
<div class="text-white/60 mt-0.5">
{formatBytes(usedStorage)} / {formatBytes(storageMax)}
</div>
</div>
{/if}
</th>
@@ -610,6 +748,52 @@
<span class="text-white/40 text-sm">...</span>
{/if}
</div>
{:else if cell.kind === "rejected"}
<div
class="flex flex-col items-center gap-1"
title={cell.reason}
>
<svg
class="w-7 h-7 text-orange-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
<span class="text-[10px] text-orange-400/80"
>Need {formatBytes(cell.requiredBytes)}</span
>
<span class="text-[10px] text-white/50"
>{formatBytes(cell.availableBytes)} avail</span
>
{#if row.shardMetadata}
<button
type="button"
class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() =>
startDownload(col.nodeId, row.shardMetadata!)}
title="Retry download on this node"
>
<svg
class="w-5 h-5"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</button>
{/if}
</div>
{:else if cell.kind === "failed"}
<div
class="flex flex-col items-center gap-1"
@@ -812,9 +996,11 @@
? 'bg-green-500/10 text-green-400/80 border border-green-500/20'
: cellStatus.kind === 'downloading'
? 'bg-exo-yellow/10 text-exo-yellow/80 border border-exo-yellow/20'
: cellStatus.kind === 'failed'
? 'bg-red-500/10 text-red-400/80 border border-red-500/20'
: 'bg-white/5 text-white/50 border border-white/10'}"
: cellStatus.kind === 'rejected'
? 'bg-orange-500/10 text-orange-400/80 border border-orange-500/20'
: cellStatus.kind === 'failed'
? 'bg-red-500/10 text-red-400/80 border border-red-500/20'
: 'bg-white/5 text-white/50 border border-white/10'}"
>
{col.label}
{#if cellStatus.kind === "downloading" && "percentage" in cellStatus}
@@ -839,8 +1025,198 @@
</div>
{/if}
<!-- Storage config modal -->
{#if storageConfigNode}
<div
class="fixed inset-0 z-[60] bg-black/60"
transition:fade={{ duration: 150 }}
onclick={() => (storageConfigNode = null)}
role="presentation"
></div>
<div
class="fixed z-[60] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(80vw,360px)] bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-2xl p-4"
transition:fly={{ y: 10, duration: 200, easing: cubicOut }}
role="dialog"
aria-modal="true"
onkeydown={(e) => {
if (e.key === "Escape") storageConfigNode = null;
}}
>
<div class="flex items-start justify-between mb-4">
<h3 class="font-mono text-sm text-white">
Storage — {configApplyAll ? "All nodes" : storageConfigNode.label}
</h3>
<button
type="button"
class="p-1 rounded hover:bg-white/10 transition-colors text-white/50"
onclick={() => (storageConfigNode = null)}
aria-label="Close storage settings"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
/>
</svg>
</button>
</div>
<div class="space-y-4">
<!-- Apply to all nodes -->
{#if nodeColumns.length > 1}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={configApplyAll}
class="accent-exo-yellow w-4 h-4"
/>
<span class="text-xs font-mono text-white/80">Apply to all nodes</span
>
</label>
{/if}
<!-- No limit checkbox -->
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={configNoLimit}
onchange={() => {
if (!configNoLimit && configMaxGb == null) {
configMaxGb = configDiskTotalGb || 50;
}
}}
class="accent-exo-yellow w-4 h-4"
/>
<span class="text-xs font-mono text-white/80">Unlimited storage</span>
</label>
<!-- Max storage slider -->
<div class="space-y-1.5">
<div class="flex items-baseline justify-between">
<label
class="text-[11px] font-mono text-white/50 uppercase tracking-wider"
for="storage-max-gb"
>
Max storage
</label>
<span
class="text-xs font-mono tabular-nums transition-opacity {configNoLimit
? 'opacity-30'
: 'text-white'}"
>
{configMaxGb ?? 0} GB
</span>
</div>
<input
id="storage-max-gb"
type="range"
min="1"
max={Math.max(configDiskTotalGb, configMaxGb ?? 1)}
step="1"
bind:value={configMaxGb}
disabled={configNoLimit}
class="slider w-full h-1.5 rounded-full appearance-none cursor-pointer
disabled:opacity-30 disabled:cursor-not-allowed"
/>
<div
class="flex justify-between text-[10px] font-mono text-white/30 transition-opacity {configNoLimit
? 'opacity-30'
: ''}"
>
<span>1 GB</span>
<span>{Math.max(configDiskTotalGb, configMaxGb ?? 1)} GB</span>
</div>
</div>
<!-- Policy selector -->
<div class="space-y-1.5">
<div
class="text-[11px] font-mono text-white/50 uppercase tracking-wider"
>
Eviction policy
</div>
<div class="flex gap-1">
<button
type="button"
class="flex-1 px-3 py-1.5 rounded text-xs font-mono transition-colors
{configPolicy === 'manual'
? 'bg-exo-yellow/20 text-exo-yellow border border-exo-yellow/40'
: 'bg-exo-black/40 text-white/50 border border-exo-medium-gray/30 hover:text-white/70'}"
onclick={() => (configPolicy = "manual")}
>
Manual
</button>
<button
type="button"
class="flex-1 px-3 py-1.5 rounded text-xs font-mono transition-colors
{configPolicy === 'auto-evict'
? 'bg-exo-yellow/20 text-exo-yellow border border-exo-yellow/40'
: 'bg-exo-black/40 text-white/50 border border-exo-medium-gray/30 hover:text-white/70'}"
onclick={() => (configPolicy = "auto-evict")}
>
Auto-evict
</button>
</div>
<p class="text-[10px] text-white/40 font-mono">
{#if configPolicy === "manual"}
Downloads that exceed the limit are rejected. Delete models
manually.
{:else}
Oldest unused models are automatically removed to make room.
{/if}
</p>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 mt-5">
<button
type="button"
class="px-3 py-1.5 rounded text-xs font-mono text-white/50 hover:text-white/70 transition-colors"
onclick={() => (storageConfigNode = null)}
>
Cancel
</button>
<button
type="button"
class="px-3 py-1.5 rounded text-xs font-mono bg-exo-yellow/20 text-exo-yellow border border-exo-yellow/40 hover:bg-exo-yellow/30 transition-colors disabled:opacity-50"
onclick={saveStorageConfig}
disabled={configSaving ||
(!configNoLimit && (configMaxGb == null || configMaxGb <= 0))}
>
{configSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
{/if}
<style>
table {
min-width: max-content;
}
.slider {
background: rgba(255, 255, 255, 0.1);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #f5c518;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
border: none;
background: #f5c518;
cursor: pointer;
}
.slider:disabled::-webkit-slider-thumb {
cursor: not-allowed;
}
.slider:disabled::-moz-range-thumb {
cursor: not-allowed;
}
</style>
@@ -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": {
+6 -3
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
@@ -29,6 +29,8 @@ dependencies = [
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"mlx-vlm>=0.3.11",
"transformers>=5.0.0,<5.4.0",
]
[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/fix-deepseek-v32-indexer" }
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
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "DeepSeek V3.2"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 131072
[storage_size]
in_bytes = 378086226621
@@ -9,5 +9,7 @@ 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
@@ -9,5 +9,7 @@ quantization = "6bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 68899327465
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 89357758772
@@ -9,5 +9,7 @@ quantization = "bf16"
base_model = "Qwen3 Coder Next"
capabilities = ["text", "code"]
context_length = 262144
[storage_size]
in_bytes = 157548627945
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 46976204800
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text"]
context_length = 262144
[storage_size]
in_bytes = 88814387200
@@ -9,5 +9,7 @@ quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 47080074240
@@ -9,5 +9,7 @@ quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking", "thinking_toggle"]
context_length = 262144
[storage_size]
in_bytes = 88814387200
@@ -0,0 +1,14 @@
model_id = "mlx-community/Qwen3-VL-4B-Instruct-4bit"
n_layers = 36
hidden_size = 2560
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3-VL 4B"
capabilities = ["text", "thinking", "vision"]
context_length = 262144
[storage_size]
in_bytes = 3340000000
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 69593314272
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 100120675296
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 130648036320
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "bf16"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 245125640160
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 16054266848
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 29500943328
@@ -7,7 +7,9 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 2B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 2662787264

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