Compare commits

...

9 Commits

Author SHA1 Message Date
Alex Cheema 99b0db0634 chore: apply nix fmt formatting to svelte files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 11:46:00 -08:00
Alex Cheema e3d91dc08a feat: add light/dark mode toggle to dashboard
Wires up the existing mode-watcher dependency (added in #1017 but never
used) to provide a one-click light/dark toggle in the header nav.

Changes:
- app.css: Add full light theme via html:not(.dark) block overriding all
  --exo-* brand color variables; add utility overrides for text-white,
  bg-black, scrollbar, command-panel, glow-text, graph links, code
  blocks, hljs syntax, KaTeX/math, and LaTeX proof/theorem blocks.
  Fix --exo-glow-yellow to be a color value (not a shadow value) so it
  works inside Tailwind arbitrary shadow classes.
- app.html: Add class="dark" to <html> for FOUC prevention.
- +layout.svelte: Add <ModeWatcher defaultMode="dark" />.
- HeaderNav.svelte: Add sun/moon toggle button using toggleMode from
  mode-watcher; use color-mix() for logo drop-shadow.
- TopologyGraph.svelte: Add getThemeColors() with MutationObserver on
  <html> class for reactive D3 color updates.
- ModelCard.svelte: Add isDark state + MutationObserver + tc derived
  color map; replace hardcoded SVG device colors with theme-aware values.
- MarkdownContent.svelte: Add scoped light mode overrides for markdown
  rendering (code blocks, tables, blockquotes, math).
- ChatForm, ChatMessages, ChatAttachments: Replace hardcoded rgba shadows
  with var(--exo-glow-yellow).
- ImageParamsPanel: Replace #ffd700 slider thumb colors with var(--exo-yellow).
- downloads/+page.svelte: Replace hardcoded hover bg with var(--exo-bg-hover).

Dark mode remains the default. Builds with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 11:04:09 -08:00
Alex Cheema 6c322ebb72 feat: only show thinking toggle for models that support it (#1497)
## Summary
- Adds `thinking_toggle` capability to 26 model cards that support
toggling thinking mode on/off
- GPT-OSS models (20b, 120b) excluded — they always think and don't
support toggling
- Dashboard UI updated to check for `thinking_toggle` capability before
showing the toggle button

## Test plan
- [x] `uv run basedpyright` — 0 errors
- [x] `uv run ruff check` — all checks passed
- [x] `nix fmt` — 0 files changed
- [x] `uv run pytest` — 188 passed, 0 failed
- [x] Security review passed (no secrets, eval/exec, innerHTML, or dep
changes)

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:05:00 +00:00
vskiwi 2ebe6216b4 feat: add explicit --offline mode for air-gapped clusters (#1525)
## Motivation

Closes #1510

There is currently no reliable way to run exo on an air-gapped or offline cluster where models are pre-staged on local disks. The two existing mechanisms — `--no-downloads` and `HF_HUB_OFFLINE=1` — each cover only a subset of the problem:

1. **`--no-downloads` blocks model loading**: When passed, `DownloadCoordinator` is not created. No `NodeDownloadProgress` events are ever emitted, so `_model_needs_download()` in `plan.py` perpetually returns `DownloadModel`, short-circuiting `_load_model()` and preventing the model from ever being loaded.

2. **`HF_HUB_OFFLINE=1` doesn't cover exo's aiohttp code**: exo's download pipeline primarily uses raw `aiohttp` for HTTP operations (file list fetching, file downloads, HEAD verification), not the `huggingface_hub` library. These calls will attempt connections and time out on air-gapped networks.

3. **`skip_internet` is not propagated to `download_file_with_retry()`**: Even when `internet_connection = False`, the `_download_file()` function still makes HTTP HEAD calls via `file_meta()` to verify local files and unconditionally attempts downloads for missing files.

## Changes

### `src/exo/main.py`
- Add `--offline` flag to `Args` with env var detection (`EXO_OFFLINE=1`, `HF_HUB_OFFLINE=1`)
- Pass `offline` to `DownloadCoordinator` at creation and re-creation (election loop)

### `src/exo/download/coordinator.py`
- Add `offline: bool = False` field
- In offline mode: set `internet_connection = False` immediately in `__post_init__`, skip `_test_internet_connection()` ping (avoids 3s timeout), skip `_check_internet_connection` periodic loop
- In `_start_download()`: if model is not fully available locally, emit `DownloadFailed` with clear message instead of starting a download task

### `src/exo/download/download_utils.py`
- Add `skip_internet: bool` parameter to `download_file_with_retry()` and `_download_file()`
- When `skip_internet=True` in `_download_file()`: return local file immediately without HTTP HEAD verification; raise `FileNotFoundError` for missing files
- Propagate `skip_internet` from `download_shard()` to `download_file_with_retry()`

### `src/exo/download/tests/test_offline_mode.py` (new)
- 8 tests covering `_download_file`, `download_file_with_retry`, and `fetch_file_list_with_cache` in offline mode

## Why It Works

Unlike `--no-downloads` which disables `DownloadCoordinator` entirely, `--offline` keeps the coordinator running in a restricted mode. The existing `_emit_existing_download_progress()` disk scanner still runs every 60 seconds, emitting `DownloadCompleted` events for pre-staged models. These events flow through the event-sourcing pipeline and populate `state.downloads`, which unblocks `_model_needs_download()` in `plan.py` — no changes to the planning logic required.

```
--offline flag
  → DownloadCoordinator (offline mode)
    → Skip 1.1.1.1 ping, internet_connection = False
    → _emit_existing_download_progress scans disk
      → Emits DownloadCompleted for pre-staged models
        → _model_needs_download sees DownloadCompleted
          → _load_model proceeds normally
```

## Test Plan

### Automated Testing
- `ruff check` — passes
- 8 new tests in `test_offline_mode.py` — all pass
- 11 existing download tests in `test_download_verification.py` — all pass (no regressions)

### Manual Testing
1. Pre-stage a model on disk (e.g., `~/.exo/models/mlx-community--Qwen3-0.6B-4bit/`)
2. Start exo with `--offline` (or `EXO_OFFLINE=1`)
3. Place an instance via API or dashboard
4. Verify: model loads into memory and inference works without any network calls

### Environment
- macOS (Apple Silicon), multi-node cluster with Thunderbolt interconnect
- Models pre-staged via rsync / NFS mount
2026-02-18 16:18:09 +00:00
ciaranbor f54c80b121 Ciaran/image edit api (#1500)
## Motivation

- Image editing previously ignored input image dimensions, always
defaulting to 1024x1024
- Size dropdown was hidden in edit mode, giving users no control over
output dimensions
- Portrait/landscape presets used non-standard aspect ratios (1024x1365
/ 1365x1024)

## Changes

- Added "auto" size option that uses input image dimensions for edits,
defaults to 1024x1024 for generation
- Introduced ImageSize Literal type and normalize_image_size() validator
(replaces raw str size fields)
  - Updated portrait/landscape presets to standard 1024x1536 / 1536x1024
  - Made size selector visible in edit mode (previously hidden)
  - Default size changed from "1024x1024" to "auto"

## Why It Works

- "auto" reads actual input image dimensions via PIL at generation time,
so edits preserve the original aspect ratio
- Pydantic field_validator on both ImageGenerationTaskParams and
ImageEditsTaskParams normalizes None → "auto", keeping the API
backward-compatible

## Test Plan

### Manual Testing

- Verify image edits output at the input image's native resolution when
size is "auto"
- Verify size dropdown appears and works in both generate and edit modes
2026-02-18 16:05:39 +00:00
rltakashige 48b8f86395 Add support for GLM 5 (#1526)
## Motivation

Add GLM 5 support in favor of #1513 

## 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-02-18 14:04:06 +00:00
Evan 5cbd6377a2 prioritize official model cards over custom model cards
our old model card search path would override official model cards with
custom model cards - our packaged model cards should always be the
default here
2026-02-18 13:20:05 +00:00
Evan Quiney 8f01523ddb remove dead code (#1496) 2026-02-18 11:43:27 +00:00
Alex Cheema 3addeadea8 Update mlx-lm to 0.30.7 (#1520)
## Summary
- Bumps `mlx-lm` from 0.30.6 to 0.30.7 in `pyproject.toml` and `uv.lock`

## Test plan
- [x] `uv lock` resolves successfully
- [x] `basedpyright` — no new errors (63 pre-existing in unrelated
`test_tool_call_tracker.py`)
- [x] `ruff check` — all checks passed
- [x] `nix fmt` — no formatting changes
- [x] `pytest` — 188 passed, 1 skipped

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:14:23 +00:00
71 changed files with 1392 additions and 708 deletions
@@ -0,0 +1,46 @@
"""Type stubs for mlx_lm.models.glm_moe_dsa"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from .base import BaseModelArgs
from .deepseek_v32 import Model as DSV32Model
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_size: int
index_head_dim: int
index_n_heads: int
index_topk: int
intermediate_size: int
moe_intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
n_shared_experts: Optional[int]
n_routed_experts: Optional[int]
routed_scaling_factor: float
kv_lora_rank: int
q_lora_rank: int
qk_rope_head_dim: int
v_head_dim: int
qk_nope_head_dim: int
topk_method: str
scoring_func: str
norm_topk_prob: bool
n_group: int
topk_group: int
num_experts_per_tok: int
moe_layer_freq: int
first_k_dense_replace: int
max_position_embeddings: int
rms_norm_eps: float
rope_parameters: Dict[str, Any]
attention_bias: bool
rope_scaling: Dict[str, Any] | None
rope_theta: float | None
class Model(DSV32Model):
def __init__(self, config: ModelArgs) -> None: ...
Generated
-123
View File
@@ -141,12 +141,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "asn1-rs"
version = "0.7.1"
@@ -304,19 +298,6 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bigdecimal"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934"
dependencies = [
"autocfg",
"libm",
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "bimap"
version = "0.6.3"
@@ -516,15 +497,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -746,29 +718,6 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.111",
"unicode-xid",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -939,22 +888,17 @@ name = "exo_pyo3_bindings"
version = "0.0.1"
dependencies = [
"delegate",
"derive_more",
"env_logger",
"extend",
"futures",
"impl-trait-for-tuples",
"libp2p",
"log",
"networking",
"once_cell",
"pin-project",
"pyo3",
"pyo3-async-runtimes",
"pyo3-log",
"pyo3-stub-gen",
"thiserror 2.0.17",
"thread_local",
"tokio",
"util",
]
@@ -1640,17 +1584,6 @@ dependencies = [
"xmltree",
]
[[package]]
name = "impl-trait-for-tuples"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "indexmap"
version = "2.12.1"
@@ -1829,12 +1762,6 @@ version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libp2p"
version = "0.56.0"
@@ -2824,16 +2751,13 @@ name = "networking"
version = "0.0.1"
dependencies = [
"delegate",
"derive_more",
"either",
"extend",
"futures",
"futures-timer",
"impl-trait-for-tuples",
"keccak-const",
"libp2p",
"log",
"thiserror 2.0.17",
"tokio",
"tracing-subscriber",
"util",
@@ -2918,17 +2842,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3279,28 +3192,14 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
dependencies = [
"bigdecimal",
"either",
"hashbrown 0.16.1",
"indexmap",
"indoc",
"inventory",
"libc",
"lock_api",
"memoffset",
"num-bigint",
"num-complex",
"num-rational",
"num-traits",
"once_cell",
"ordered-float",
"parking_lot",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"rust_decimal",
"smallvec",
"unindent",
]
@@ -3741,16 +3640,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282"
dependencies = [
"arrayvec",
"num-traits",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4615,24 +4504,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unicode_names2"
version = "1.3.0"
-28
View File
@@ -26,49 +26,21 @@ opt-level = 3
networking = { path = "rust/networking" }
util = { path = "rust/util" }
# Proc-macro authoring tools
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"
darling = "0.20"
# Macro dependecies
extend = "1.2"
delegate = "0.13"
impl-trait-for-tuples = "0.2"
clap = "4.5"
derive_more = { version = "2.0.1", features = ["display"] }
pin-project = "1"
# Utility dependencies
itertools = "0.14"
thiserror = "2"
internment = "0.8"
recursion = "0.5"
regex = "1.11"
once_cell = "1.21"
thread_local = "1.1"
bon = "3.4"
generativity = "1.1"
anyhow = "1.0"
keccak-const = "0.2"
# Functional generics/lenses frameworks
frunk_core = "0.4"
frunk = "0.4"
frunk_utils = "0.2"
frunk-enum-core = "0.3"
# Async dependencies
tokio = "1.46"
futures = "0.3"
futures-util = "0.3"
futures-timer = "3.0"
# Data structures
either = "1.15"
ordered-float = "5.0"
ahash = "0.8"
# Tracing/logging
log = "0.4"
+384 -2
View File
@@ -16,8 +16,8 @@
/* Gotham-inspired accent colors */
--exo-grid: oklch(0.25 0 0);
--exo-scanline: oklch(0.15 0 0);
--exo-glow-yellow: 0 0 20px oklch(0.85 0.18 85 / 0.3);
--exo-glow-yellow-strong: 0 0 40px oklch(0.85 0.18 85 / 0.5);
--exo-glow-yellow: oklch(0.85 0.18 85 / 0.3);
--exo-glow-yellow-strong: oklch(0.85 0.18 85 / 0.5);
/* Theme Variables */
--radius: 0.375rem;
@@ -39,6 +39,67 @@
--border: oklch(0.22 0 0);
--input: oklch(0.22 0 0);
--ring: var(--exo-yellow);
/* Adaptive text colors (used via utility classes below) */
--text-primary: oklch(0.9 0 0);
--text-secondary: oklch(0.9 0 0 / 0.9);
--text-muted: oklch(0.9 0 0 / 0.7);
--text-faint: oklch(0.9 0 0 / 0.5);
--text-ghost: oklch(0.9 0 0 / 0.4);
--text-subtle: oklch(0.9 0 0 / 0.6);
--text-dim: oklch(0.9 0 0 / 0.3);
--text-whisper: oklch(0.9 0 0 / 0.8);
--exo-bg-hover: oklch(0.18 0 0);
}
/* ============================================================
LIGHT THEME - Applied when html does NOT have .dark class
============================================================ */
html:not(.dark) {
/* Override EXO brand colors for light mode */
--exo-black: oklch(0.97 0 0);
--exo-dark-gray: oklch(0.94 0 0);
--exo-medium-gray: oklch(0.88 0 0);
--exo-light-gray: oklch(0.45 0 0);
--exo-yellow: oklch(0.20 0.02 85);
--exo-yellow-darker: oklch(0.15 0.02 85);
--exo-yellow-glow: oklch(0.30 0.03 85);
--exo-grid: oklch(0.88 0 0);
--exo-scanline: oklch(0.92 0 0);
--exo-glow-yellow: oklch(0.20 0.02 85 / 0.08);
--exo-glow-yellow-strong: oklch(0.20 0.02 85 / 0.12);
/* Theme Variables */
--background: oklch(0.97 0 0);
--foreground: oklch(0.15 0 0);
--card: oklch(0.94 0 0);
--card-foreground: oklch(0.15 0 0);
--popover: oklch(0.96 0 0);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.20 0.02 85);
--primary-foreground: oklch(0.97 0 0);
--secondary: oklch(0.90 0 0);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.92 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.90 0 0);
--accent-foreground: oklch(0.15 0 0);
--destructive: oklch(0.55 0.25 25);
--border: oklch(0.85 0 0);
--input: oklch(0.88 0 0);
--ring: oklch(0.20 0.02 85);
/* Adaptive text colors for light mode */
--text-primary: oklch(0.15 0 0);
--text-secondary: oklch(0.15 0 0 / 0.9);
--text-muted: oklch(0.15 0 0 / 0.7);
--text-faint: oklch(0.15 0 0 / 0.5);
--text-ghost: oklch(0.15 0 0 / 0.4);
--text-subtle: oklch(0.15 0 0 / 0.6);
--text-dim: oklch(0.15 0 0 / 0.3);
--text-whisper: oklch(0.15 0 0 / 0.8);
--exo-bg-hover: oklch(0.90 0 0);
}
@theme inline {
@@ -266,6 +327,327 @@ input:focus, textarea:focus {
box-shadow: none;
}
/* ============================================================
LIGHT MODE: Override hardcoded white/black text colors
Components use text-white/XX extensively - in light mode these
need to become dark text. We override at the CSS level so we
don't have to touch every single component.
============================================================ */
html:not(.dark) {
/* text-white variants → dark text */
& .text-white\/90,
& .text-white\/80,
& .text-white\/70,
& .text-white\/60 {
color: var(--foreground) !important;
}
/* Muted text-white variants → dark text with reduced alpha via color-mix */
& .text-white\/50 {
color: color-mix(in oklch, var(--foreground) 50%, transparent) !important;
}
& .text-white\/40 {
color: color-mix(in oklch, var(--foreground) 40%, transparent) !important;
}
& .text-white\/30 {
color: color-mix(in oklch, var(--foreground) 30%, transparent) !important;
}
/* bg-black variants → light bg */
& .bg-black\/40,
& .bg-black\/50,
& .bg-black\/60,
& .bg-black\/80 {
background-color: oklch(0.92 0 0 / 0.5) !important;
}
/* bg-exo-black opacity variants */
& [class*="bg-exo-black/"] {
background-color: oklch(0.92 0 0 / 0.5) !important;
}
/* shadow-black → softer shadow */
& [class*="shadow-black"] {
--tw-shadow-color: oklch(0 0 0 / 0.08) !important;
}
/* Scrollbar light mode */
& ::-webkit-scrollbar-track {
background: oklch(0.94 0 0) !important;
}
& ::-webkit-scrollbar-thumb {
background: oklch(0.78 0 0) !important;
}
& ::-webkit-scrollbar-thumb:hover {
background: oklch(0.40 0 0) !important;
}
/* Command panel light mode */
& .command-panel {
background: linear-gradient(
180deg,
oklch(0.96 0 0 / 0.95) 0%,
oklch(0.94 0 0 / 0.98) 100%
) !important;
border-color: oklch(0.85 0 0) !important;
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 0.5),
0 4px 20px oklch(0 0 0 / 0.08) !important;
}
/* Glow text - no glow in light mode, just subtle shadow */
& .glow-text {
text-shadow:
0 0 10px oklch(0 0 0 / 0.06),
0 0 20px oklch(0 0 0 / 0.03) !important;
}
/* Grid bg */
& .grid-bg {
background-image:
linear-gradient(oklch(0.85 0 0 / 0.3) 1px, transparent 1px),
linear-gradient(90deg, oklch(0.85 0 0 / 0.3) 1px, transparent 1px) !important;
}
/* Scanlines - very subtle in light mode */
& .scanlines::before {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
oklch(0 0 0 / 0.015) 2px,
oklch(0 0 0 / 0.015) 4px
) !important;
}
/* CRT screen effect */
& .crt-screen {
background: radial-gradient(
ellipse at center,
oklch(0.96 0 0) 0%,
oklch(0.94 0 0) 50%,
oklch(0.92 0 0) 100%
) !important;
box-shadow:
inset 0 0 100px oklch(0 0 0 / 0.05),
0 0 50px oklch(0 0 0 / 0.03) !important;
}
/* Graph links */
& .graph-link {
stroke: oklch(0.35 0 0 / 0.5) !important;
filter: none !important;
}
& .graph-link-active {
stroke: oklch(0.20 0 0 / 0.8) !important;
filter: none !important;
}
/* Shooting stars - hide in light mode */
& .shooting-stars {
display: none !important;
}
/* Logo: invert white→black + subtle dark shadow */
& img[alt="EXO"] {
filter: invert(1) drop-shadow(0 0 10px oklch(0 0 0 / 0.15)) !important;
}
/* Status indicator colors */
& .bg-exo-yellow.rounded-full {
box-shadow: 0 0 6px oklch(0 0 0 / 0.15);
}
/* Red/green/blue semantic colors stay mostly the same but darken slightly */
& .text-red-400 {
color: oklch(0.55 0.22 25) !important;
}
& .text-green-400 {
color: oklch(0.50 0.18 155) !important;
}
& .text-blue-200 {
color: oklch(0.50 0.16 250) !important;
}
& .text-blue-300 {
color: oklch(0.50 0.17 250) !important;
}
& .text-blue-400 {
color: oklch(0.50 0.18 250) !important;
}
/* bg-red variants */
& .bg-red-500\/10 {
background-color: oklch(0.55 0.22 25 / 0.08) !important;
}
& .bg-red-500\/20 {
background-color: oklch(0.55 0.22 25 / 0.12) !important;
}
& .bg-red-500\/30 {
background-color: oklch(0.55 0.22 25 / 0.15) !important;
}
/* Input/textarea backgrounds */
& textarea,
& input[type="text"] {
color: var(--foreground) !important;
}
& textarea::placeholder,
& input::placeholder {
color: oklch(0.45 0 0 / 0.5) !important;
}
/* KaTeX / Math colors in light mode */
& .katex,
& .katex .mord,
& .katex .minner,
& .katex .mop,
& .katex .mbin,
& .katex .mrel,
& .katex .mpunct {
color: oklch(0.15 0 0) !important;
}
& .katex .frac-line,
& .katex .overline-line,
& .katex .underline-line,
& .katex .hline,
& .katex .rule {
border-color: oklch(0.25 0 0) !important;
background: oklch(0.25 0 0) !important;
}
& .katex .sqrt-line {
border-color: oklch(0.25 0 0) !important;
}
& .katex svg {
fill: oklch(0.25 0 0) !important;
stroke: oklch(0.25 0 0) !important;
}
& .katex svg path {
stroke: oklch(0.25 0 0) !important;
}
& .katex .delimsizing,
& .katex .delim-size1,
& .katex .delim-size2,
& .katex .delim-size3,
& .katex .delim-size4,
& .katex .mopen,
& .katex .mclose {
color: oklch(0.35 0 0) !important;
}
/* Code blocks in light mode */
& .hljs {
color: oklch(0.25 0 0) !important;
}
& .code-block-wrapper,
& .math-display-wrapper {
background: oklch(0.96 0 0) !important;
border-color: oklch(0.85 0 0) !important;
}
& .code-block-header,
& .math-display-header {
background: oklch(0.93 0 0) !important;
border-color: oklch(0.87 0 0) !important;
}
/* Inline code */
& .inline-code {
background: oklch(0.90 0 0) !important;
color: oklch(0.25 0 0) !important;
}
/* Blockquote */
& blockquote {
background: oklch(0.94 0 0) !important;
}
/* Table */
& th {
background: oklch(0.92 0 0) !important;
border-color: oklch(0.82 0 0) !important;
}
& td {
border-color: oklch(0.85 0 0) !important;
}
& hr {
border-color: oklch(0.85 0 0) !important;
}
/* hljs syntax colors for light mode */
& .hljs-keyword,
& .hljs-selector-tag,
& .hljs-literal,
& .hljs-section,
& .hljs-link {
color: oklch(0.50 0.25 300) !important;
}
& .hljs-string,
& .hljs-title,
& .hljs-name,
& .hljs-type,
& .hljs-attribute,
& .hljs-symbol,
& .hljs-bullet,
& .hljs-addition,
& .hljs-variable,
& .hljs-template-tag,
& .hljs-template-variable {
color: oklch(0.45 0.12 55) !important;
}
& .hljs-comment,
& .hljs-quote,
& .hljs-deletion,
& .hljs-meta {
color: oklch(0.55 0 0) !important;
}
& .hljs-number,
& .hljs-regexp,
& .hljs-built_in {
color: oklch(0.45 0.16 160) !important;
}
& .hljs-function,
& .hljs-class .hljs-title {
color: oklch(0.45 0.18 250) !important;
}
/* LaTeX proof/theorem blocks */
& .latex-proof {
background: oklch(0.97 0 0) !important;
border-left-color: oklch(0.70 0 0) !important;
}
& .latex-proof-header {
color: oklch(0.25 0 0) !important;
}
& .latex-proof-content {
color: oklch(0.15 0 0) !important;
}
& .latex-proof-content::after {
color: oklch(0.45 0 0) !important;
}
& .latex-theorem {
background: oklch(0.95 0 0) !important;
border-color: oklch(0.82 0 0) !important;
}
& .latex-diagram-placeholder {
background: oklch(0.97 0 0) !important;
border-color: oklch(0.82 0 0) !important;
color: oklch(0.35 0 0) !important;
}
}
/* Shooting Stars Animation */
.shooting-stars {
position: fixed;
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
@@ -39,7 +39,7 @@
<div class="flex flex-wrap gap-2 mb-3 px-1">
{#each files as file (file.id)}
<div
class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]"
class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_var(--exo-glow-yellow)]"
>
<!-- File preview or icon -->
{#if file.preview && getFileCategory(file.type, file.name) === "image"}
+1 -1
View File
@@ -659,7 +659,7 @@
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
{!canSend || loading || isEditOnlyWithoutImage
? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_var(--exo-glow-yellow)]'}"
aria-label={shouldShowEditMode
? "Edit image"
: isImageModel()
@@ -271,7 +271,7 @@
<!-- Assistant message header -->
<div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
<div
class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"
class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_var(--exo-glow-yellow)]"
></div>
<span
class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium"
+62 -7
View File
@@ -1,11 +1,22 @@
<script lang="ts">
import { browser } from "$app/environment";
import { toggleMode, mode } from "mode-watcher";
export let showHome = true;
export let onHome: (() => void) | null = null;
export let showSidebarToggle = false;
export let sidebarVisible = true;
export let onToggleSidebar: (() => void) | null = null;
let {
showHome = true,
onHome = null,
showSidebarToggle = false,
sidebarVisible = true,
onToggleSidebar = null,
}: {
showHome?: boolean;
onHome?: (() => void) | null;
showSidebarToggle?: boolean;
sidebarVisible?: boolean;
onToggleSidebar?: (() => void) | null;
} = $props();
const currentMode = $derived(mode.current);
function handleHome(): void {
if (onHome) {
@@ -75,14 +86,58 @@
<img
src="/exo-logo.png"
alt="EXO"
class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]"
class="h-18 drop-shadow-[0_0_20px_color-mix(in_oklch,var(--exo-yellow)_50%,transparent)]"
/>
</button>
<!-- Right: Home + Downloads -->
<!-- Right: Theme Toggle + Home + Downloads -->
<div
class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
>
<!-- Theme Toggle Button -->
<button
onclick={() => toggleMode()}
class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
title={currentMode === "dark"
? "Switch to light mode"
: "Switch to dark mode"}
aria-label={currentMode === "dark"
? "Switch to light mode"
: "Switch to dark mode"}
>
{#if currentMode === "dark"}
<!-- Sun icon - click to go light -->
<svg
class="w-4 h-4 text-exo-light-gray hover:text-exo-yellow transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<path
stroke-linecap="round"
d="M12 1v2m0 18v2m-9-11H1m22 0h-2m-2.636-6.364l-1.414 1.414M6.05 6.05L4.636 4.636m0 14.728l1.414-1.414m11.314 1.414l1.414 1.414"
/>
</svg>
{:else}
<!-- Moon icon - click to go dark -->
<svg
class="w-4 h-4 text-exo-light-gray hover:text-exo-yellow transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"
/>
</svg>
{/if}
</button>
{#if showHome}
<button
onclick={handleHome}
@@ -669,7 +669,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffd700;
background: var(--exo-yellow);
cursor: pointer;
border: none;
}
@@ -678,7 +678,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffd700;
background: var(--exo-yellow);
cursor: pointer;
border: none;
}
@@ -1051,4 +1051,231 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ============================================
LIGHT MODE OVERRIDES
All dark-mode-specific hardcoded colors are
overridden below for light backgrounds.
============================================ */
/* --- Inline code --- */
:global(html:not(.dark)) .markdown-content :global(.inline-code) {
background: rgba(0, 0, 0, 0.06);
color: var(--exo-yellow, #b8860b);
}
/* --- Blockquote --- */
:global(html:not(.dark)) .markdown-content :global(blockquote) {
background: rgba(0, 0, 0, 0.03);
}
/* --- Tables --- */
:global(html:not(.dark)) .markdown-content :global(th) {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.12);
}
:global(html:not(.dark)) .markdown-content :global(td) {
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* --- Horizontal rule --- */
:global(html:not(.dark)) .markdown-content :global(hr) {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
/* --- Code block wrapper --- */
:global(html:not(.dark)) .markdown-content :global(.code-block-wrapper) {
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.03);
}
:global(html:not(.dark)) .markdown-content :global(.code-block-header) {
background: rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
/* --- Syntax highlighting - light theme --- */
:global(html:not(.dark)) .markdown-content :global(.hljs) {
color: #1f2937;
}
:global(html:not(.dark)) .markdown-content :global(.hljs-keyword),
:global(html:not(.dark)) .markdown-content :global(.hljs-selector-tag),
:global(html:not(.dark)) .markdown-content :global(.hljs-literal),
:global(html:not(.dark)) .markdown-content :global(.hljs-section),
:global(html:not(.dark)) .markdown-content :global(.hljs-link) {
color: #7c3aed;
}
:global(html:not(.dark)) .markdown-content :global(.hljs-string),
:global(html:not(.dark)) .markdown-content :global(.hljs-title),
:global(html:not(.dark)) .markdown-content :global(.hljs-name),
:global(html:not(.dark)) .markdown-content :global(.hljs-type),
:global(html:not(.dark)) .markdown-content :global(.hljs-attribute),
:global(html:not(.dark)) .markdown-content :global(.hljs-symbol),
:global(html:not(.dark)) .markdown-content :global(.hljs-bullet),
:global(html:not(.dark)) .markdown-content :global(.hljs-addition),
:global(html:not(.dark)) .markdown-content :global(.hljs-variable),
:global(html:not(.dark)) .markdown-content :global(.hljs-template-tag),
:global(html:not(.dark)) .markdown-content :global(.hljs-template-variable) {
color: #b45309;
}
:global(html:not(.dark)) .markdown-content :global(.hljs-comment),
:global(html:not(.dark)) .markdown-content :global(.hljs-quote),
:global(html:not(.dark)) .markdown-content :global(.hljs-deletion),
:global(html:not(.dark)) .markdown-content :global(.hljs-meta) {
color: #9ca3af;
}
:global(html:not(.dark)) .markdown-content :global(.hljs-number),
:global(html:not(.dark)) .markdown-content :global(.hljs-regexp),
:global(html:not(.dark)) .markdown-content :global(.hljs-literal),
:global(html:not(.dark)) .markdown-content :global(.hljs-built_in) {
color: #059669;
}
:global(html:not(.dark)) .markdown-content :global(.hljs-function),
:global(html:not(.dark)) .markdown-content :global(.hljs-class .hljs-title) {
color: #2563eb;
}
/* --- KaTeX math - light mode --- */
:global(html:not(.dark)) .markdown-content :global(.katex) {
color: oklch(0.2 0 0);
}
:global(html:not(.dark)) .markdown-content :global(.katex .mord),
:global(html:not(.dark)) .markdown-content :global(.katex .minner),
:global(html:not(.dark)) .markdown-content :global(.katex .mop),
:global(html:not(.dark)) .markdown-content :global(.katex .mbin),
:global(html:not(.dark)) .markdown-content :global(.katex .mrel),
:global(html:not(.dark)) .markdown-content :global(.katex .mpunct) {
color: oklch(0.2 0 0);
}
:global(html:not(.dark)) .markdown-content :global(.katex .frac-line),
:global(html:not(.dark)) .markdown-content :global(.katex .overline-line),
:global(html:not(.dark)) .markdown-content :global(.katex .underline-line),
:global(html:not(.dark)) .markdown-content :global(.katex .hline),
:global(html:not(.dark)) .markdown-content :global(.katex .rule) {
border-color: oklch(0.25 0 0) !important;
background: oklch(0.25 0 0);
}
:global(html:not(.dark)) .markdown-content :global(.katex .sqrt-line) {
border-color: oklch(0.25 0 0) !important;
}
:global(html:not(.dark)) .markdown-content :global(.katex svg) {
fill: oklch(0.25 0 0);
stroke: oklch(0.25 0 0);
}
:global(html:not(.dark)) .markdown-content :global(.katex svg path) {
stroke: oklch(0.25 0 0);
}
:global(html:not(.dark)) .markdown-content :global(.katex .delimsizing),
:global(html:not(.dark)) .markdown-content :global(.katex .delim-size1),
:global(html:not(.dark)) .markdown-content :global(.katex .delim-size2),
:global(html:not(.dark)) .markdown-content :global(.katex .delim-size3),
:global(html:not(.dark)) .markdown-content :global(.katex .delim-size4),
:global(html:not(.dark)) .markdown-content :global(.katex .mopen),
:global(html:not(.dark)) .markdown-content :global(.katex .mclose) {
color: oklch(0.35 0 0);
}
/* --- Display math wrapper --- */
:global(html:not(.dark)) .markdown-content :global(.math-display-wrapper) {
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.02);
}
:global(html:not(.dark))
.markdown-content
:global(.math-display-wrapper:hover) {
border-color: rgba(0, 0, 0, 0.18);
box-shadow: 0 0 12px rgba(0, 0, 0, 0.05);
}
:global(html:not(.dark)) .markdown-content :global(.math-display-header) {
background: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
:global(html:not(.dark)) .markdown-content :global(.math-label) {
color: rgba(0, 0, 0, 0.5);
}
/* --- Math scrollbar (light mode) --- */
:global(html:not(.dark))
.markdown-content
:global(.math-display-content::-webkit-scrollbar-track) {
background: rgba(0, 0, 0, 0.04);
}
:global(html:not(.dark))
.markdown-content
:global(.math-display-content::-webkit-scrollbar-thumb) {
background: rgba(0, 0, 0, 0.15);
}
:global(html:not(.dark))
.markdown-content
:global(.math-display-content::-webkit-scrollbar-thumb:hover) {
background: rgba(0, 0, 0, 0.25);
}
/* --- Inline math hover --- */
:global(html:not(.dark)) .markdown-content :global(.math-inline:hover) {
background: rgba(0, 0, 0, 0.04);
}
/* --- LaTeX proof environment --- */
:global(html:not(.dark)) .markdown-content :global(.latex-proof) {
background: rgba(0, 0, 0, 0.02);
border-left: 3px solid rgba(0, 0, 0, 0.25);
}
:global(html:not(.dark)) .markdown-content :global(.latex-proof-header) {
color: oklch(0.25 0 0);
}
:global(html:not(.dark)) .markdown-content :global(.latex-proof-content) {
color: oklch(0.2 0 0);
}
:global(html:not(.dark))
.markdown-content
:global(.latex-proof-content::after) {
color: oklch(0.4 0 0);
}
/* --- LaTeX theorem-like environments --- */
:global(html:not(.dark)) .markdown-content :global(.latex-theorem) {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:global(html:not(.dark)) .markdown-content :global(.latex-theorem-content) {
color: oklch(0.2 0 0);
}
/* --- LaTeX diagram/figure placeholder --- */
:global(html:not(.dark))
.markdown-content
:global(.latex-diagram-placeholder) {
background: rgba(0, 0, 0, 0.02);
border: 1px dashed rgba(0, 0, 0, 0.2);
color: rgba(0, 0, 0, 0.45);
}
/* --- Math error (light mode) --- */
:global(html:not(.dark)) .markdown-content :global(.math-error) {
color: #dc2626;
background: rgba(220, 38, 38, 0.08);
border: 1px solid rgba(220, 38, 38, 0.15);
}
</style>
+64 -33
View File
@@ -6,6 +6,42 @@
TopologyEdge,
} from "$lib/stores/app.svelte";
import { debugMode, topologyData } from "$lib/stores/app.svelte";
import { browser } from "$app/environment";
// Theme detection
let isDark = $state(true);
$effect(() => {
if (!browser) return;
const update = () => {
isDark = document.documentElement.classList.contains("dark");
};
update();
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
});
// Theme-aware colors
let tc = $derived({
accent: isDark ? "#FFD700" : "#1a1a1a",
accentDim: isDark ? "rgba(255,215,0,0.1)" : "rgba(26,26,26,0.06)",
accentGlow: isDark ? "rgba(255,215,0,0.1)" : "rgba(0,0,0,0.03)",
outlineActive: isDark ? "#FFD700" : "#374151",
outlineInactive: isDark ? "#4B5563" : "#d1d5db",
strokeActive: isDark ? "#FFD700" : "#374151",
strokeInactive: isDark ? "#4B5563" : "#d1d5db",
caseFill: isDark ? "#0a0a0a" : "#f3f4f6",
caseDetail: isDark ? "#374151" : "#d1d5db",
errorDot: isDark ? "#f87171" : "#dc2626",
statusInactive: isDark ? "#4B5563" : "#9ca3af",
connGood: isDark ? "rgba(255,255,255,0.85)" : "rgba(30,30,30,0.85)",
connBad: isDark ? "rgba(248,113,113,0.85)" : "rgba(220,38,38,0.85)",
scanlineColor: isDark ? "rgba(255,215,0,0.02)" : "rgba(0,0,0,0.01)",
});
interface Props {
model: { id: string; name?: string; storage_size_megabytes?: number };
@@ -491,7 +527,7 @@
<div
class="bg-exo-dark-gray/60 border {canFit
? 'border-exo-yellow/20 group-hover:border-exo-yellow/40'
: 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]"
: 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_var(--exo-glow-yellow)]"
>
<!-- Model Name & Memory Required -->
<div class="flex items-start justify-between gap-2 mb-2">
@@ -589,7 +625,8 @@
>
<!-- Scanline effect -->
<div
class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"
style="background: repeating-linear-gradient(0deg, transparent, transparent 2px, {tc.scanlineColor} 2px, {tc.scanlineColor} 4px)"
class="absolute inset-0 pointer-events-none"
></div>
<svg
@@ -670,7 +707,9 @@
y1={node.y}
x2={node2.x}
y2={node2.y}
stroke={node.isUsed && node2.isUsed ? "#FFD700" : "#374151"}
stroke={node.isUsed && node2.isUsed
? tc.strokeActive
: tc.strokeInactive}
stroke-width="1"
stroke-dasharray={node.isUsed && node2.isUsed ? "4,2" : "2,4"}
opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
@@ -706,9 +745,7 @@
dominant-baseline="hanging"
font-size="6"
font-family="SF Mono, Monaco, monospace"
fill={conn.iface
? "rgba(255,255,255,0.85)"
: "rgba(248,113,113,0.85)"}
fill={conn.iface ? tc.connGood : tc.connBad}
>
{conn.arrow}
{isRdma
@@ -725,9 +762,7 @@
dominant-baseline="hanging"
font-size="6"
font-family="SF Mono, Monaco, monospace"
fill={conn.iface
? "rgba(255,255,255,0.85)"
: "rgba(248,113,113,0.85)"}
fill={conn.iface ? tc.connGood : tc.connBad}
>
{conn.arrow}
{isRdma
@@ -746,9 +781,7 @@
dominant-baseline="auto"
font-size="6"
font-family="SF Mono, Monaco, monospace"
fill={conn.iface
? "rgba(255,255,255,0.85)"
: "rgba(248,113,113,0.85)"}
fill={conn.iface ? tc.connGood : tc.connBad}
>
{conn.arrow}
{isRdma
@@ -767,9 +800,7 @@
dominant-baseline="auto"
font-size="6"
font-family="SF Mono, Monaco, monospace"
fill={conn.iface
? "rgba(255,255,255,0.85)"
: "rgba(248,113,113,0.85)"}
fill={conn.iface ? tc.connGood : tc.connBad}
>
{conn.arrow}
{isRdma
@@ -801,7 +832,7 @@
height={node.iconSize * 0.65}
rx="2"
fill="none"
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
stroke={node.isUsed ? tc.strokeActive : tc.outlineInactive}
stroke-width="1.5"
/>
<!-- Screen area (memory fill container) -->
@@ -810,7 +841,7 @@
y="2"
width={node.iconSize - 8}
height={node.screenHeight}
fill="#0a0a0a"
fill={tc.caseFill}
/>
<!-- Current memory fill (gray) -->
<rect
@@ -818,7 +849,7 @@
y={2 + node.screenHeight - node.currentFillHeight}
width={node.iconSize - 8}
height={node.currentFillHeight}
fill="#374151"
fill={tc.caseDetail}
/>
<!-- New model memory fill (glowing yellow) -->
{#if node.modelUsageGB > 0 && node.isUsed}
@@ -830,7 +861,7 @@
node.modelFillHeight}
width={node.iconSize - 8}
height={node.modelFillHeight}
fill="#FFD700"
fill={tc.accent}
filter="url(#memGlow-{filterId})"
class="animate-pulse-slow"
/>
@@ -842,7 +873,7 @@
0.68} L {node.iconSize - 2} {node.iconSize *
0.78} L 2 {node.iconSize * 0.78} Z"
fill="none"
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
stroke={node.isUsed ? tc.strokeActive : tc.outlineInactive}
stroke-width="1.5"
/>
</g>
@@ -859,7 +890,7 @@
height={node.iconSize - 4}
rx="4"
fill="none"
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
stroke={node.isUsed ? tc.strokeActive : tc.outlineInactive}
stroke-width="1.5"
/>
<!-- Memory fill background -->
@@ -868,7 +899,7 @@
y="4"
width={node.iconSize - 8}
height={node.iconSize - 8}
fill="#0a0a0a"
fill={tc.caseFill}
/>
<!-- Current memory fill -->
<rect
@@ -877,7 +908,7 @@
(node.iconSize - 8) * (1 - node.currentPercent / 100)}
width={node.iconSize - 8}
height={(node.iconSize - 8) * (node.currentPercent / 100)}
fill="#374151"
fill={tc.caseDetail}
/>
<!-- New model memory fill -->
{#if node.modelUsageGB > 0 && node.isUsed}
@@ -887,7 +918,7 @@
width={node.iconSize - 8}
height={(node.iconSize - 8) *
((node.newPercent - node.currentPercent) / 100)}
fill="#FFD700"
fill={tc.accent}
filter="url(#memGlow-{filterId})"
class="animate-pulse-slow"
/>
@@ -906,7 +937,7 @@
height={node.iconSize * 0.4}
rx="3"
fill="none"
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
stroke={node.isUsed ? tc.strokeActive : tc.outlineInactive}
stroke-width="1.5"
/>
<!-- Memory fill background -->
@@ -915,7 +946,7 @@
y={node.iconSize * 0.32}
width={node.iconSize - 8}
height={node.iconSize * 0.36}
fill="#0a0a0a"
fill={tc.caseFill}
/>
<!-- Current memory fill -->
<rect
@@ -924,7 +955,7 @@
node.iconSize * 0.36 * (1 - node.currentPercent / 100)}
width={node.iconSize - 8}
height={node.iconSize * 0.36 * (node.currentPercent / 100)}
fill="#374151"
fill={tc.caseDetail}
/>
<!-- New model memory fill -->
{#if node.modelUsageGB > 0 && node.isUsed}
@@ -936,7 +967,7 @@
height={node.iconSize *
0.36 *
((node.newPercent - node.currentPercent) / 100)}
fill="#FFD700"
fill={tc.accent}
filter="url(#memGlow-{filterId})"
class="animate-pulse-slow"
/>
@@ -955,8 +986,8 @@
0.75} {node.iconSize /
2},{node.iconSize} 0,{node.iconSize *
0.75} 0,{node.iconSize * 0.25}"
fill={node.isUsed ? "rgba(255,215,0,0.1)" : "#0a0a0a"}
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
fill={node.isUsed ? tc.accentDim : tc.caseFill}
stroke={node.isUsed ? tc.strokeActive : tc.outlineInactive}
stroke-width="1.5"
/>
</g>
@@ -970,9 +1001,9 @@
font-family="SF Mono, Monaco, monospace"
fill={node.isUsed
? node.newPercent > 90
? "#f87171"
: "#FFD700"
: "#4B5563"}
? tc.errorDot
: tc.accent
: tc.statusInactive}
>
{node.newPercent.toFixed(0)}%
</text>
+127 -48
View File
@@ -36,6 +36,84 @@
const rdmaCtlData = $derived(nodeRdmaCtl());
const identitiesData = $derived(nodeIdentities());
// Theme-aware colors - read from CSS custom properties
function getThemeColors() {
if (typeof document === "undefined") return getDefaultColors();
const style = getComputedStyle(document.documentElement);
const isDark = document.documentElement.classList.contains("dark");
return {
accent:
style.getPropertyValue("--exo-yellow").trim() ||
(isDark ? "oklch(0.85 0.18 85)" : "oklch(0.20 0.02 85)"),
accentRgb: isDark ? "255,215,0" : "30,28,20",
deviceCase: isDark ? "#1a1a1a" : "#e8e8e8",
deviceCaseDark: isDark ? "#2c2c2c" : "#d4d4d4",
deviceScreen: isDark ? "#0a0a12" : "#f0f0f5",
deviceScreenFill: isDark ? "rgba(0,20,40,0.9)" : "rgba(240,242,248,0.95)",
labelWhite: isDark ? "#FFFFFF" : "#1a1a1a",
labelMuted: isDark ? "rgba(179,179,179,0.9)" : "rgba(80,80,80,0.9)",
labelDim: isDark ? "rgba(179,179,179,0.7)" : "rgba(100,100,100,0.7)",
wireDefault: isDark ? "rgba(179,179,179,0.8)" : "rgba(120,120,120,0.6)",
wireBright: isDark ? "rgba(255,255,255,0.9)" : "rgba(30,30,30,0.9)",
wireFiltered: isDark ? "rgba(140,140,140,0.6)" : "rgba(160,160,160,0.5)",
gridStroke: isDark
? "var(--exo-light-gray, #B3B3B3)"
: "var(--exo-light-gray, #888888)",
errorText: isDark ? "rgba(248,113,113,0.9)" : "rgba(220,38,38,0.9)",
normalText: isDark ? "rgba(255,255,255,0.85)" : "rgba(30,30,30,0.85)",
gpuChip: isDark ? "rgba(80, 80, 90, 0.7)" : "rgba(180, 180, 190, 0.7)",
detailOverlay: isDark ? "rgba(0,0,0,0.35)" : "rgba(0,0,0,0.08)",
deviceShadow: isDark ? "rgba(0,0,0,0.2)" : "rgba(0,0,0,0.06)",
deviceHighlight: isDark
? "rgba(255,255,255,0.08)"
: "rgba(255,255,255,0.5)",
tbActive: isDark ? "rgba(234,179,8,0.9)" : "rgba(30,28,20,0.9)",
tbInactive: isDark ? "rgba(100,100,100,0.7)" : "rgba(160,160,160,0.7)",
deviceDetail: isDark ? "#374151" : "#c0c0c0",
};
}
function getDefaultColors() {
return {
accent: "oklch(0.85 0.18 85)",
accentRgb: "255,215,0",
deviceCase: "#1a1a1a",
deviceCaseDark: "#2c2c2c",
deviceScreen: "#0a0a12",
deviceScreenFill: "rgba(0,20,40,0.9)",
labelWhite: "#FFFFFF",
labelMuted: "rgba(179,179,179,0.9)",
labelDim: "rgba(179,179,179,0.7)",
wireDefault: "rgba(179,179,179,0.8)",
wireBright: "rgba(255,255,255,0.9)",
wireFiltered: "rgba(140,140,140,0.6)",
gridStroke: "var(--exo-light-gray, #B3B3B3)",
errorText: "rgba(248,113,113,0.9)",
normalText: "rgba(255,255,255,0.85)",
gpuChip: "rgba(80, 80, 90, 0.7)",
detailOverlay: "rgba(0,0,0,0.35)",
deviceShadow: "rgba(0,0,0,0.2)",
deviceHighlight: "rgba(255,255,255,0.08)",
tbActive: "rgba(234,179,8,0.9)",
tbInactive: "rgba(100,100,100,0.7)",
deviceDetail: "#374151",
};
}
let themeColors = $state(getThemeColors());
$effect(() => {
if (typeof document === "undefined") return;
const observer = new MutationObserver(() => {
themeColors = getThemeColors();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
});
function getNodeLabel(nodeId: string): string {
const node = data?.nodes?.[nodeId];
return node?.friendly_name || nodeId.slice(0, 8);
@@ -127,7 +205,7 @@
function getTemperatureColor(temp: number): string {
// Default for N/A temp - light gray
if (isNaN(temp) || temp === null) return "rgba(179, 179, 179, 0.8)";
if (isNaN(temp) || temp === null) return themeColors.wireDefault;
const coolTemp = 45; // Temp for pure blue
const midTemp = 57.5; // Temp for pure yellow
@@ -208,7 +286,7 @@
.append("path")
.attr("d", "M 0 0 L 10 5 L 0 10")
.attr("fill", "none")
.attr("stroke", "var(--exo-light-gray, #B3B3B3)")
.attr("stroke", themeColors.gridStroke)
.attr("stroke-width", "1.6")
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
@@ -221,7 +299,7 @@
.attr("y", centerY)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "rgba(255,215,0,0.4)")
.attr("fill", `rgba(${themeColors.accentRgb},0.4)`)
.attr("font-size", isMinimized ? 10 : 12)
.attr("font-family", "SF Mono, monospace")
.attr("letter-spacing", "0.1em")
@@ -505,8 +583,8 @@
.attr(
"fill",
conn.missingIface
? "rgba(248,113,113,0.9)"
: "rgba(255,255,255,0.85)",
? themeColors.errorText
: themeColors.normalText,
)
.text(label);
currentY += isTop ? lineHeight : -lineHeight;
@@ -565,22 +643,22 @@
// Holographic wireframe colors - bright yellow for filter, subtle yellow for hover, grey for filtered out
const wireColor = isInFilter
? "rgba(255,215,0,1)" // Bright yellow for filter selection
? `rgba(${themeColors.accentRgb},1)` // Bright yellow for filter selection
: isHovered
? "rgba(255,215,0,0.7)" // Subtle yellow for hover
? `rgba(${themeColors.accentRgb},0.7)` // Subtle yellow for hover
: isHighlighted
? "rgba(255,215,0,0.9)" // Yellow for instance highlight
? `rgba(${themeColors.accentRgb},0.9)` // Yellow for instance highlight
: isFilteredOut
? "rgba(140,140,140,0.6)" // Grey for filtered out
: "rgba(179,179,179,0.8)"; // Default
const wireColorBright = "rgba(255,255,255,0.9)";
? themeColors.wireFiltered // Grey for filtered out
: themeColors.wireDefault; // Default
const wireColorBright = themeColors.wireBright;
const fillColor = isInFilter
? "rgba(255,215,0,0.25)"
? `rgba(${themeColors.accentRgb},0.25)`
: isHovered
? "rgba(255,215,0,0.12)"
? `rgba(${themeColors.accentRgb},0.12)`
: isHighlighted
? "rgba(255,215,0,0.15)"
: "rgba(255,215,0,0.08)";
? `rgba(${themeColors.accentRgb},0.15)`
: `rgba(${themeColors.accentRgb},0.08)`;
const strokeWidth = isInFilter
? 3
: isHovered
@@ -588,8 +666,8 @@
: isHighlighted
? 2.5
: 1.5;
const screenFill = "rgba(0,20,40,0.9)";
const glowColor = "rgba(255,215,0,0.3)";
const screenFill = themeColors.deviceScreenFill;
const glowColor = `rgba(${themeColors.accentRgb},0.3)`;
const nodeG = nodesGroup
.append("g")
@@ -653,7 +731,7 @@
.attr("width", iconBaseWidth)
.attr("height", iconBaseHeight)
.attr("rx", cornerRadius)
.attr("fill", "#1a1a1a")
.attr("fill", themeColors.deviceCase)
.attr("stroke", wireColor)
.attr("stroke-width", strokeWidth);
@@ -671,12 +749,12 @@
)
.attr("width", iconBaseWidth)
.attr("height", memFillActualHeight)
.attr("fill", "rgba(255,215,0,0.75)")
.attr("fill", `rgba(${themeColors.accentRgb},0.75)`)
.attr("clip-path", `url(#${studioClipId})`);
}
// Front panel details - vertical slots
const detailColor = "rgba(0,0,0,0.35)";
const detailColor = themeColors.detailOverlay;
const slotHeight = iconBaseHeight * 0.14;
const vSlotWidth = iconBaseWidth * 0.05;
const vSlotY =
@@ -736,7 +814,7 @@
.attr("width", iconBaseWidth)
.attr("height", iconBaseHeight)
.attr("rx", cornerRadius)
.attr("fill", "#1a1a1a")
.attr("fill", themeColors.deviceCase)
.attr("stroke", wireColor)
.attr("stroke-width", strokeWidth);
@@ -754,12 +832,12 @@
)
.attr("width", iconBaseWidth)
.attr("height", memFillActualHeight)
.attr("fill", "rgba(255,215,0,0.75)")
.attr("fill", `rgba(${themeColors.accentRgb},0.75)`)
.attr("clip-path", `url(#${miniClipId})`);
}
// Front panel details - vertical slots (no horizontal slot for Mini)
const detailColor = "rgba(0,0,0,0.35)";
const detailColor = themeColors.detailOverlay;
const slotHeight = iconBaseHeight * 0.2;
const vSlotWidth = iconBaseWidth * 0.045;
const vSlotY =
@@ -814,7 +892,7 @@
.attr("width", screenWidth)
.attr("height", screenHeight)
.attr("rx", 3)
.attr("fill", "#1a1a1a")
.attr("fill", themeColors.deviceCase)
.attr("stroke", wireColor)
.attr("stroke-width", strokeWidth);
@@ -826,7 +904,7 @@
.attr("width", screenWidth - screenBezel * 2)
.attr("height", screenHeight - screenBezel * 2)
.attr("rx", 2)
.attr("fill", "#0a0a12");
.attr("fill", themeColors.deviceScreen);
// Memory fill on screen (fills from bottom up - classic style)
if (ramUsagePercent > 0) {
@@ -842,7 +920,7 @@
)
.attr("width", screenWidth - screenBezel * 2)
.attr("height", memFillActualHeight)
.attr("fill", "rgba(255,215,0,0.85)")
.attr("fill", `rgba(${themeColors.accentRgb},0.85)`)
.attr("clip-path", `url(#${screenClipId})`);
}
@@ -859,7 +937,7 @@
"transform",
`translate(${logoX}, ${logoY}) scale(${logoScale})`,
)
.attr("fill", "#FFFFFF")
.attr("fill", themeColors.labelWhite)
.attr("opacity", 0.9);
// Base (keyboard) - trapezoidal
@@ -875,7 +953,7 @@
"d",
`M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`,
)
.attr("fill", "#2c2c2c")
.attr("fill", themeColors.deviceCaseDark)
.attr("stroke", wireColor)
.attr("stroke-width", 1);
@@ -890,7 +968,7 @@
.attr("y", keyboardY)
.attr("width", keyboardWidth)
.attr("height", keyboardHeight)
.attr("fill", "rgba(0,0,0,0.2)")
.attr("fill", themeColors.deviceShadow)
.attr("rx", 2);
// Trackpad
@@ -904,7 +982,7 @@
.attr("y", trackpadY)
.attr("width", trackpadWidth)
.attr("height", trackpadHeight)
.attr("fill", "rgba(255,255,255,0.08)")
.attr("fill", themeColors.deviceHighlight)
.attr("rx", 2);
} else {
// Default/Unknown - holographic hexagon
@@ -942,7 +1020,7 @@
.attr("y", gpuBarY)
.attr("width", gpuBarWidth)
.attr("height", gpuBarHeight)
.attr("fill", "rgba(80, 80, 90, 0.7)")
.attr("fill", themeColors.gpuChip)
.attr("rx", 2);
// GPU Bar Fill (from bottom up, colored by temperature)
@@ -979,7 +1057,7 @@
.attr("y", gpuTextY - lineSpacing)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#FFFFFF")
.attr("fill", themeColors.labelWhite)
.attr("font-size", gpuTextFontSize)
.attr("font-weight", "700")
.attr("font-family", "SF Mono, Monaco, monospace")
@@ -992,7 +1070,7 @@
.attr("y", gpuTextY)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#FFFFFF")
.attr("fill", themeColors.labelWhite)
.attr("font-size", gpuTextFontSize)
.attr("font-weight", "700")
.attr("font-family", "SF Mono, Monaco, monospace")
@@ -1005,7 +1083,7 @@
.attr("y", gpuTextY + lineSpacing)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#FFFFFF")
.attr("fill", themeColors.labelWhite)
.attr("font-size", gpuTextFontSize)
.attr("font-weight", "700")
.attr("font-family", "SF Mono, Monaco, monospace")
@@ -1033,7 +1111,7 @@
.attr("y", nameY)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#FFD700")
.attr("fill", themeColors.accent)
.attr("font-size", fontSize)
.attr("font-weight", 500)
.attr("font-family", "SF Mono, Monaco, monospace")
@@ -1050,15 +1128,15 @@
.attr("font-family", "SF Mono, Monaco, monospace");
memText
.append("tspan")
.attr("fill", "rgba(255,215,0,0.9)")
.attr("fill", `rgba(${themeColors.accentRgb},0.9)`)
.text(`${formatBytes(ramUsed)}`);
memText
.append("tspan")
.attr("fill", "rgba(179,179,179,0.9)")
.attr("fill", themeColors.labelMuted)
.text(`/${formatBytes(ramTotal)}`);
memText
.append("tspan")
.attr("fill", "rgba(179,179,179,0.7)")
.attr("fill", themeColors.labelDim)
.text(` (${ramUsagePercent.toFixed(0)}%)`);
} else if (showCompactLabels) {
// COMPACT MODE: Just name and basic info (4+ nodes)
@@ -1075,7 +1153,7 @@
.attr("x", nodeInfo.x)
.attr("y", nameY)
.attr("text-anchor", "middle")
.attr("fill", "#FFD700")
.attr("fill", themeColors.accent)
.attr("font-size", fontSize)
.attr("font-family", "SF Mono, Monaco, monospace")
.text(shortName);
@@ -1087,7 +1165,7 @@
.attr("x", nodeInfo.x)
.attr("y", statsY)
.attr("text-anchor", "middle")
.attr("fill", "rgba(255,215,0,0.7)")
.attr("fill", `rgba(${themeColors.accentRgb},0.7)`)
.attr("font-size", fontSize * 0.85)
.attr("font-family", "SF Mono, Monaco, monospace")
.text(
@@ -1108,7 +1186,7 @@
.attr("x", nodeInfo.x)
.attr("y", nameY)
.attr("text-anchor", "middle")
.attr("fill", "#FFD700")
.attr("fill", themeColors.accent)
.attr("font-size", fontSize)
.attr("font-weight", "500")
.attr("font-family", "SF Mono, Monaco, monospace")
@@ -1125,15 +1203,15 @@
.attr("font-family", "SF Mono, Monaco, monospace");
memTextMini
.append("tspan")
.attr("fill", "rgba(255,215,0,0.9)")
.attr("fill", `rgba(${themeColors.accentRgb},0.9)`)
.text(`${formatBytes(ramUsed)}`);
memTextMini
.append("tspan")
.attr("fill", "rgba(179,179,179,0.9)")
.attr("fill", themeColors.labelMuted)
.text(`/${formatBytes(ramTotal)}`);
memTextMini
.append("tspan")
.attr("fill", "rgba(179,179,179,0.7)")
.attr("fill", themeColors.labelDim)
.text(` (${ramUsagePercent.toFixed(0)}%)`);
}
@@ -1149,8 +1227,8 @@
const tbStatus = tbBridgeData[nodeInfo.id];
if (tbStatus) {
const tbColor = tbStatus.enabled
? "rgba(234,179,8,0.9)"
: "rgba(100,100,100,0.7)";
? themeColors.tbActive
: themeColors.tbInactive;
const tbText = tbStatus.enabled ? "TB:ON" : "TB:OFF";
nodeG
.append("text")
@@ -1168,7 +1246,7 @@
if (rdmaStatus !== undefined) {
const rdmaColor = rdmaStatus.enabled
? "rgba(74,222,128,0.9)"
: "rgba(100,100,100,0.7)";
: themeColors.tbInactive;
const rdmaText = rdmaStatus.enabled ? "RDMA:ON" : "RDMA:OFF";
nodeG
.append("text")
@@ -1189,7 +1267,7 @@
.attr("x", nodeInfo.x)
.attr("y", debugLabelY)
.attr("text-anchor", "middle")
.attr("fill", "rgba(179,179,179,0.7)")
.attr("fill", themeColors.labelDim)
.attr("font-size", debugFontSize)
.attr("font-family", "SF Mono, Monaco, monospace")
.text(
@@ -1206,6 +1284,7 @@
const _hoveredNodeId = hoveredNodeId;
const _filteredNodes = filteredNodes;
const _highlightedNodes = highlightedNodes;
const _themeColors = themeColors;
if (_data) {
renderGraph();
}
+4 -3
View File
@@ -306,13 +306,14 @@ const IMAGE_PARAMS_STORAGE_KEY = "exo-image-generation-params";
export interface ImageGenerationParams {
// Basic params
size:
| "auto"
| "512x512"
| "768x768"
| "1024x1024"
| "1024x768"
| "768x1024"
| "1024x1365"
| "1365x1024";
| "1024x1536"
| "1536x1024";
quality: "low" | "medium" | "high";
outputFormat: "png" | "jpeg";
numImages: number;
@@ -336,7 +337,7 @@ export interface EditingImage {
}
const DEFAULT_IMAGE_PARAMS: ImageGenerationParams = {
size: "1024x1024",
size: "auto",
quality: "medium",
outputFormat: "png",
numImages: 1,
+3
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import "../app.css";
import { ModeWatcher } from "mode-watcher";
let { children } = $props();
</script>
<ModeWatcher defaultMode="dark" />
<svelte:head>
<title>EXO</title>
<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
+3 -3
View File
@@ -2299,7 +2299,7 @@
<!-- Panel Header -->
<div class="flex items-center gap-2 mb-4">
<div
class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_var(--exo-glow-yellow)] animate-pulse"
></div>
<h3
class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
@@ -2948,7 +2948,7 @@
<!-- Dot -->
<span
class="rounded-full transition-all {isSelected
? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_var(--exo-glow-yellow)]'
: isValid
? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
@@ -3114,7 +3114,7 @@
<!-- Panel Header -->
<div class="flex items-center gap-2 mb-4">
<div
class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_var(--exo-glow-yellow)] animate-pulse"
></div>
<h3
class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+1 -1
View File
@@ -382,7 +382,7 @@
class="group border-b border-exo-medium-gray/20 hover:bg-exo-medium-gray/10 transition-colors"
>
<td
class="sticky left-0 z-10 bg-exo-dark-gray group-hover:bg-[oklch(0.18_0_0)] transition-colors px-4 py-3 whitespace-nowrap border-r border-exo-medium-gray/20"
class="sticky left-0 z-10 bg-exo-dark-gray group-hover:bg-[var(--exo-bg-hover)] transition-colors px-4 py-3 whitespace-nowrap border-r border-exo-medium-gray/20"
>
<div class="flex items-center gap-2">
<div class="min-w-0">
+3 -3
View File
@@ -41,7 +41,7 @@ let
mlx = stdenv.mkDerivation rec {
pname = "mlx";
version = let v = "0.30.7.dev20260217+50487b41"; in
version = let v = "0.30.7.dev20260218+14841977"; in
assert v == uvLockMlxVersion || throw "MLX version mismatch: nix/mlx.nix has ${v} but uv.lock has ${uvLockMlxVersion}. Update both the version and hash in nix/mlx.nix.";
v;
pyproject = true;
@@ -49,8 +49,8 @@ let
src = fetchFromGitHub {
owner = "rltakashige";
repo = "mlx-jaccl-fix-small-recv";
rev = "50487b4141f3c951122655db3b83df5146c1fbeb";
hash = "sha256-IL4a9vMX5nocgJU1WG4zE8hArHkHJtnh4sdYh3od5zU=";
rev = "1484197707f35186ad3bd614357c7c47fdf86ebc";
hash = "sha256-FupCMoK/SF/ldfKuvMSAKECcOP8c+ANgkQlPZttDsLk=";
};
patches = [
+1 -1
View File
@@ -19,7 +19,7 @@ dependencies = [
"anyio==4.11.0",
"mlx; sys_platform == 'darwin'",
"mlx[cpu]==0.30.6; sys_platform == 'linux'",
"mlx-lm==0.30.6",
"mlx-lm==0.30.7",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
"openai-harmony>=0.0.8",
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "deepseek"
quantization = "4bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 405874409472
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "deepseek"
quantization = "8bit"
base_model = "DeepSeek V3.1"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 765577920512
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "8bit"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 122406567936
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "bf16"
base_model = "GLM 4.5 Air"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 229780750336
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "4bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 198556925568
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "6bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 286737579648
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "8bit"
base_model = "GLM 4.7"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 396963397248
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "4bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 19327352832
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "5bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 22548578304
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "6bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 26843545600
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "glm"
quantization = "8bit"
base_model = "GLM 4.7 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 34359738368
@@ -0,0 +1,12 @@
model_id = "mlx-community/GLM-5-8bit-MXFP8"
n_layers = 78
hidden_size = 6144
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "8bit"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 790517400864
@@ -0,0 +1,12 @@
model_id = "mlx-community/GLM-5-MXFP4-Q8"
n_layers = 78
hidden_size = 6144
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "MXFP4-Q8"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 405478939008
@@ -0,0 +1,12 @@
model_id = "mlx-community/GLM-5"
n_layers = 78
hidden_size = 6144
supports_tensor = true
tasks = ["TextGeneration"]
family = "glm"
quantization = "bf16"
base_model = "GLM-5"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 1487822475264
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "kimi"
quantization = ""
base_model = "Kimi K2"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 706522120192
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "kimi"
quantization = ""
base_model = "Kimi K2.5"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 662498705408
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "minimax"
quantization = "3bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 100086644736
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "minimax"
quantization = "8bit"
base_model = "MiniMax M2.1"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 242986745856
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3 0.6B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 342884352
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3 0.6B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 698351616
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3 235B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 141733920768
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3 235B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 268435456000
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3 30B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 17612931072
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3 30B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 33279705088
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 47080074240
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3 Next 80B"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 88814387200
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "step"
quantization = "4bit"
base_model = "Step 3.5 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 114572190076
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "step"
quantization = "6bit"
base_model = "Step 3.5 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 159039627774
@@ -6,7 +6,7 @@ tasks = ["TextGeneration"]
family = "step"
quantization = "8bit"
base_model = "Step 3.5 Flash"
capabilities = ["text", "thinking"]
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 209082699847
+5 -20
View File
@@ -25,17 +25,17 @@ workspace = true
networking = { workspace = true }
# interop
pyo3 = { version = "0.27.1", features = [
# "abi3-py311", # tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.11
pyo3 = { version = "0.27.2", features = [
# "abi3-py313", # tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.13
"nightly", # enables better-supported GIL integration
"experimental-async", # async support in #[pyfunction] & #[pymethods]
#"experimental-inspect", # inspection of generated binary => easier to automate type-hint generation
#"py-clone", # adding Clone-ing of `Py<T>` without GIL (may cause panics - remove if panics happen)
"multiple-pymethods", # allows multiple #[pymethods] sections per class
# "multiple-pymethods", # allows multiple #[pymethods] sections per class
# integrations with other libraries
"arc_lock", "bigdecimal", "either", "hashbrown", "indexmap", "num-bigint", "num-complex", "num-rational",
"ordered-float", "rust_decimal", "smallvec",
# "arc_lock", "bigdecimal", "either", "hashbrown", "indexmap", "num-bigint", "num-complex", "num-rational",
# "ordered-float", "rust_decimal", "smallvec",
# "anyhow", "chrono", "chrono-local", "chrono-tz", "eyre", "jiff-02", "lock_api", "parking-lot", "time", "serde",
] }
pyo3-stub-gen = { version = "0.17.2" }
@@ -45,8 +45,6 @@ pyo3-log = "0.13.2"
# macro dependencies
extend = { workspace = true }
delegate = { workspace = true }
impl-trait-for-tuples = { workspace = true }
derive_more = { workspace = true }
pin-project = { workspace = true }
# async runtime
@@ -54,24 +52,11 @@ tokio = { workspace = true, features = ["full", "tracing"] }
futures = { workspace = true }
# utility dependencies
once_cell = "1.21.3"
thread_local = "1.1.9"
util = { workspace = true }
thiserror = { workspace = true }
#internment = { workspace = true }
#recursion = { workspace = true }
#generativity = { workspace = true }
#itertools = { workspace = true }
# Tracing
#tracing = "0.1"
#tracing-subscriber = "0.3"
#console-subscriber = "0.1.5"
#tracing-log = "0.2.0"
log = { workspace = true }
env_logger = "0.11"
# Networking
libp2p = { workspace = true, features = ["full"] }
@@ -6,7 +6,7 @@ use pyo3::marker::Ungil;
use pyo3::prelude::*;
use std::{
future::Future,
pin::{Pin, pin},
pin::Pin,
task::{Context, Poll},
};
@@ -33,8 +33,6 @@ where
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let waker = cx.waker();
Python::with_gil(|py| {
py.allow_threads(|| self.project().0.poll(&mut Context::from_waker(waker)))
})
Python::attach(|py| py.detach(|| self.project().0.poll(&mut Context::from_waker(waker))))
}
}
-240
View File
@@ -1,240 +0,0 @@
//! This module exists to hold examples of some pyo3 patterns that may be too complex to
//! re-create from scratch, but too inhomogenous to create an abstraction/wrapper around.
//!
//! Pattern examples include:
//! - Async task handles: with GC-integrated cleanup
//! - Sync/async callbacks from python: with propper eventloop handling
//!
//! Mutability pattern: https://pyo3.rs/v0.26.0/async-await.html#send--static-constraint
//! - Store mutable fields in tokio's `Mutex<T>`
//! - For async code: take `&self` and `.lock().await`
//! - For sync code: take `&mut self` and `.get_mut()`
use crate::ext::{PyResultExt as _, ResultExt as _, TokioRuntimeExt as _};
use futures::FutureExt as _;
use futures::future::BoxFuture;
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::{PyModule, PyModuleMethods as _};
use pyo3::{
Bound, Py, PyAny, PyErr, PyResult, PyTraverseError, PyVisit, Python, pyclass, pymethods,
};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TryRecvError;
fn needs_tokio_runtime() {
tokio::runtime::Handle::current();
}
type SyncCallback = Box<dyn Fn() + Send + Sync>;
type AsyncCallback = Box<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
enum AsyncTaskMessage {
SyncCallback(SyncCallback),
AsyncCallback(AsyncCallback),
}
async fn async_task(
sender: mpsc::UnboundedSender<()>,
mut receiver: mpsc::UnboundedReceiver<AsyncTaskMessage>,
) {
log::info!("RUST: async task started");
// task state
let mut interval = tokio::time::interval(Duration::from_secs(1));
let mut sync_cbs: Vec<SyncCallback> = vec![];
let mut async_cbs: Vec<AsyncCallback> = vec![];
loop {
tokio::select! {
// handle incoming messages from task-handle
message = receiver.recv() => {
// handle closed channel by exiting
let Some(message) = message else {
log::info!("RUST: channel closed");
break;
};
// dispatch incoming event
match message {
AsyncTaskMessage::SyncCallback(cb) => {
sync_cbs.push(cb);
}
AsyncTaskMessage::AsyncCallback(cb) => {
async_cbs.push(cb);
}
}
}
// handle all other events
_ = interval.tick() => {
log::info!("RUST: async task tick");
// call back all sync callbacks
for cb in &sync_cbs {
cb();
}
// call back all async callbacks
for cb in &async_cbs {
cb().await;
}
// send event on unbounded channel
sender.send(()).expect("handle receiver cannot be closed/dropped");
}
}
}
log::info!("RUST: async task stopped");
}
// #[gen_stub_pyclass]
#[pyclass(name = "AsyncTaskHandle")]
#[derive(Debug)]
struct PyAsyncTaskHandle {
sender: Option<mpsc::UnboundedSender<AsyncTaskMessage>>,
receiver: mpsc::UnboundedReceiver<()>,
}
#[allow(clippy::expect_used)]
impl PyAsyncTaskHandle {
const fn sender(&self) -> &mpsc::UnboundedSender<AsyncTaskMessage> {
self.sender
.as_ref()
.expect("The sender should only be None after de-initialization.")
}
const fn sender_mut(&mut self) -> &mpsc::UnboundedSender<AsyncTaskMessage> {
self.sender
.as_mut()
.expect("The sender should only be None after de-initialization.")
}
const fn new(
sender: mpsc::UnboundedSender<AsyncTaskMessage>,
receiver: mpsc::UnboundedReceiver<()>,
) -> Self {
Self {
sender: Some(sender),
receiver,
}
}
}
// #[gen_stub_pymethods]
#[pymethods]
impl PyAsyncTaskHandle {
#[new]
fn py_new(py: Python<'_>) -> PyResult<Self> {
use pyo3_async_runtimes::tokio::get_runtime;
// create communication channel TOWARDS our task
let (h_sender, t_receiver) = mpsc::unbounded_channel::<AsyncTaskMessage>();
// create communication channel FROM our task
let (t_sender, h_receiver) = mpsc::unbounded_channel::<()>();
// perform necessary setup within tokio context - or it crashes
let () = get_runtime().block_on(async { needs_tokio_runtime() });
// spawn tokio task with this thread's task-locals - without this, async callbacks on the new threads will not work!!
_ = get_runtime().spawn_with_scope(py, async move {
async_task(t_sender, t_receiver).await;
});
Ok(Self::new(h_sender, h_receiver))
}
/// NOTE: exceptions in callbacks are silently ignored until end of execution
fn add_sync_callback(
&self,
// #[gen_stub(override_type(
// type_repr="collections.abc.Callable[[], None]",
// imports=("collections.abc")
// ))]
callback: Py<PyAny>,
) -> PyResult<()> {
// blocking call to async method -> can do non-blocking if needed
self.sender()
.send(AsyncTaskMessage::SyncCallback(Box::new(move || {
_ = Python::with_gil(|py| callback.call0(py).write_unraisable_with(py));
})))
.pyerr()?;
Ok(())
}
/// NOTE: exceptions in callbacks are silently ignored until end of execution
fn add_async_callback(
&self,
// #[gen_stub(override_type(
// type_repr="collections.abc.Callable[[], collections.abc.Awaitable[None]]",
// imports=("collections.abc")
// ))]
callback: Py<PyAny>,
) -> PyResult<()> {
// blocking call to async method -> can do non-blocking if needed
self.sender()
.send(AsyncTaskMessage::AsyncCallback(Box::new(move || {
let c = Python::with_gil(|py| callback.clone_ref(py));
async move {
if let Some(f) = Python::with_gil(|py| {
let coroutine = c.call0(py).write_unraisable_with(py)?;
pyo3_async_runtimes::tokio::into_future(coroutine.into_bound(py))
.write_unraisable_with(py)
}) {
_ = f.await.write_unraisable();
}
}
.boxed()
})))
.pyerr()?;
Ok(())
}
async fn receive_unit(&mut self) -> PyResult<()> {
self.receiver
.recv()
.await
.ok_or(PyErr::new::<PyRuntimeError, _>(
"cannot receive unit on closed channel",
))
}
fn drain_units(&mut self) -> PyResult<i32> {
let mut cnt = 0;
loop {
match self.receiver.try_recv() {
Err(TryRecvError::Disconnected) => {
return Err(PyErr::new::<PyRuntimeError, _>(
"cannot receive unit on closed channel",
));
}
Err(TryRecvError::Empty) => return Ok(cnt),
Ok(()) => {
cnt += 1;
continue;
}
}
}
}
// #[gen_stub(skip)]
const fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
Ok(()) // This is needed purely so `__clear__` can work
}
// #[gen_stub(skip)]
fn __clear__(&mut self) {
// TODO: may or may not need to await a "kill-signal" oneshot channel message,
// to ensure that the networking task is done BEFORE exiting the clear function...
// but this may require GIL?? and it may not be safe to call GIL here??
self.sender = None; // Using Option<T> as a trick to force `sender` channel to be dropped
}
}
pub fn examples_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyAsyncTaskHandle>()?;
Ok(())
}
+2 -27
View File
@@ -17,7 +17,6 @@
extern crate core;
mod allow_threading;
mod examples;
pub(crate) mod networking;
pub(crate) mod pylibp2p;
@@ -25,7 +24,6 @@ use crate::networking::networking_submodule;
use crate::pylibp2p::ident::ident_submodule;
use crate::pylibp2p::multiaddr::multiaddr_submodule;
use pyo3::prelude::PyModule;
use pyo3::prelude::*;
use pyo3::{Bound, PyResult, pyclass, pymodule};
use pyo3_stub_gen::define_stub_info_gatherer;
@@ -36,14 +34,10 @@ pub(crate) mod r#const {
/// Namespace for all the type/trait aliases used by this crate.
pub(crate) mod alias {
use std::error::Error;
use std::marker::Tuple;
pub trait SendFn<Args: Tuple + Send + 'static, Output> =
Fn<Args, Output = Output> + Send + 'static;
pub type AnyError = Box<dyn Error + Send + Sync + 'static>;
pub type AnyResult<T> = Result<T, AnyError>;
}
/// Namespace for crate-wide extension traits/methods
@@ -51,7 +45,6 @@ pub(crate) mod ext {
use crate::allow_threading::AllowThreads;
use extend::ext;
use pyo3::exceptions::{PyConnectionError, PyRuntimeError};
use pyo3::marker::Ungil;
use pyo3::types::PyBytes;
use pyo3::{Py, PyErr, PyResult, Python};
use tokio::runtime::Runtime;
@@ -62,7 +55,7 @@ pub(crate) mod ext {
#[ext(pub, name = ByteArrayExt)]
impl [u8] {
fn pybytes(&self) -> Py<PyBytes> {
Python::with_gil(|py| PyBytes::new(py, self).unbind())
Python::attach(|py| PyBytes::new(py, self).unbind())
}
}
@@ -98,7 +91,7 @@ pub(crate) mod ext {
#[ext(pub, name = PyResultExt)]
impl<T> PyResult<T> {
fn write_unraisable(self) -> Option<T> {
Python::with_gil(|py| self.write_unraisable_with(py))
Python::attach(|py| self.write_unraisable_with(py))
}
fn write_unraisable_with(self, py: Python<'_>) -> Option<T> {
@@ -175,24 +168,6 @@ pub(crate) mod ext {
}
}
pub(crate) mod private {
use std::marker::Sized;
/// Sealed traits support
pub trait Sealed {}
impl<T: ?Sized> Sealed for T {}
}
/// A wrapper around [`Py`] that implements [`Clone`] using [`Python::with_gil`].
#[repr(transparent)]
pub(crate) struct ClonePy<T>(pub Py<T>);
impl<T> Clone for ClonePy<T> {
fn clone(&self) -> Self {
Python::with_gil(|py| Self(self.0.clone_ref(py)))
}
}
/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
+3 -4
View File
@@ -11,9 +11,9 @@ use crate::ext::{ResultExt as _, TokioMpscReceiverExt as _, TokioMpscSenderExt a
use crate::pyclass;
use crate::pylibp2p::ident::{PyKeypair, PyPeerId};
use libp2p::futures::StreamExt as _;
use libp2p::gossipsub;
use libp2p::gossipsub::{IdentTopic, Message, MessageId, PublishError};
use libp2p::swarm::SwarmEvent;
use libp2p::{gossipsub, mdns};
use networking::discovery;
use networking::swarm::create_swarm;
use pyo3::prelude::{PyModule, PyModuleMethods as _};
@@ -25,7 +25,7 @@ use tokio::sync::{Mutex, mpsc, oneshot};
mod exception {
use pyo3::types::PyTuple;
use pyo3::{PyErrArguments, exceptions::PyException, prelude::*};
use pyo3::{exceptions::PyException, prelude::*};
use pyo3_stub_gen::derive::*;
#[gen_stub_pyclass]
@@ -155,7 +155,6 @@ async fn networking_task(
) {
use SwarmEvent::*;
use ToTask::*;
use mdns::Event::*;
use networking::swarm::BehaviourEvent::*;
log::info!("RUST: networking task started");
@@ -485,7 +484,7 @@ impl PyNetworkingHandle {
let (tx, rx) = oneshot::channel();
// send off request to subscribe
let data = Python::with_gil(|py| Vec::from(data.as_bytes(py)));
let data = Python::attach(|py| Vec::from(data.as_bytes(py)));
self.to_task_tx()
.send_py(ToTask::GossipsubPublish {
topic,
+1 -8
View File
@@ -19,8 +19,6 @@ either = { workspace = true }
# macro dependencies
extend = { workspace = true }
delegate = { workspace = true }
impl-trait-for-tuples = { workspace = true }
derive_more = { workspace = true }
# async
tokio = { workspace = true, features = ["full"] }
@@ -29,11 +27,6 @@ futures-timer = { workspace = true }
# utility dependencies
util = { workspace = true }
thiserror = { workspace = true }
#internment = { workspace = true }
#recursion = { workspace = true }
#generativity = { workspace = true }
#itertools = { workspace = true }
tracing-subscriber = { version = "0.3.19", features = ["default", "env-filter"] }
keccak-const = { workspace = true }
@@ -41,4 +34,4 @@ keccak-const = { workspace = true }
log = { workspace = true }
# networking
libp2p = { workspace = true, features = ["full"] }
libp2p = { workspace = true, features = ["full"] }
+1 -1
View File
@@ -24,8 +24,8 @@ use libp2p::{
swarm::{NetworkBehaviour, SwarmEvent},
tcp, yamux,
};
use std::error::Error;
use std::time::Duration;
use std::{error::Error, hash::Hash};
use tokio::{io, io::AsyncBufReadExt, select};
use tracing_subscriber::EnvFilter;
-1
View File
@@ -1,5 +1,4 @@
use crate::ext::MultiaddrExt;
use crate::keep_alive;
use delegate::delegate;
use either::Either;
use futures::FutureExt;
-44
View File
@@ -1,44 +0,0 @@
use delegate::delegate;
use libp2p::swarm::handler::ConnectionEvent;
use libp2p::swarm::{ConnectionHandlerEvent, SubstreamProtocol, dummy, handler};
use std::task::{Context, Poll};
/// An implementation of [`ConnectionHandler`] that doesn't handle any protocols, but it keeps
/// the connection alive.
#[derive(Clone)]
#[repr(transparent)]
pub struct ConnectionHandler(dummy::ConnectionHandler);
impl ConnectionHandler {
pub fn new() -> Self {
ConnectionHandler(dummy::ConnectionHandler)
}
}
impl handler::ConnectionHandler for ConnectionHandler {
// delegate types and implementation mostly to dummy handler
type FromBehaviour = <dummy::ConnectionHandler as handler::ConnectionHandler>::FromBehaviour;
type ToBehaviour = <dummy::ConnectionHandler as handler::ConnectionHandler>::ToBehaviour;
type InboundProtocol =
<dummy::ConnectionHandler as handler::ConnectionHandler>::InboundProtocol;
type OutboundProtocol =
<dummy::ConnectionHandler as handler::ConnectionHandler>::OutboundProtocol;
type InboundOpenInfo =
<dummy::ConnectionHandler as handler::ConnectionHandler>::InboundOpenInfo;
type OutboundOpenInfo =
<dummy::ConnectionHandler as handler::ConnectionHandler>::OutboundOpenInfo;
delegate! {
to self.0 {
fn listen_protocol(&self) -> SubstreamProtocol<Self::InboundProtocol, Self::InboundOpenInfo>;
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<ConnectionHandlerEvent<Self::OutboundProtocol, Self::OutboundOpenInfo, Self::ToBehaviour>>;
fn on_behaviour_event(&mut self, event: Self::FromBehaviour);
fn on_connection_event(&mut self, event: ConnectionEvent<Self::InboundProtocol, Self::OutboundProtocol, Self::InboundOpenInfo, Self::OutboundOpenInfo>);
}
}
// specifically override this to force connection to stay alive
fn connection_keep_alive(&self) -> bool {
true
}
}
-20
View File
@@ -3,19 +3,7 @@
//! this is here as a placeholder documentation
//!
//!
// enable Rust-unstable features for convenience
#![feature(trait_alias)]
// #![feature(stmt_expr_attributes)]
// #![feature(unboxed_closures)]
// #![feature(assert_matches)]
// #![feature(async_fn_in_dyn_trait)]
// #![feature(async_for_loop)]
// #![feature(auto_traits)]
// #![feature(negative_impls)]
pub mod discovery;
pub mod keep_alive;
pub mod swarm;
/// Namespace for all the type/trait aliases used by this crate.
@@ -54,11 +42,3 @@ pub(crate) mod ext {
}
}
}
pub(crate) mod private {
#![allow(dead_code)]
/// Sealed traits support
pub trait Sealed {}
impl<T: ?Sized> Sealed for T {}
}
+24 -3
View File
@@ -47,6 +47,7 @@ class DownloadCoordinator:
download_command_receiver: Receiver[ForwarderDownloadCommand]
local_event_sender: Sender[ForwarderEvent]
event_index_counter: Iterator[int]
offline: bool = False
# Local state
download_status: dict[ModelId, DownloadProgress] = field(default_factory=dict)
@@ -62,6 +63,8 @@ class DownloadCoordinator:
def __post_init__(self) -> None:
self.event_sender, self.event_receiver = channel[Event]()
if self.offline:
self.shard_downloader.set_internet_connection(False)
self.shard_downloader.on_progress(self._download_progress_callback)
def _model_dir(self, model_id: ModelId) -> str:
@@ -107,13 +110,17 @@ class DownloadCoordinator:
self._last_progress_time[model_id] = current_time()
async def run(self) -> None:
logger.info("Starting DownloadCoordinator")
self._test_internet_connection()
logger.info(
f"Starting DownloadCoordinator{' (offline mode)' if self.offline else ''}"
)
if not self.offline:
self._test_internet_connection()
async with self._tg as tg:
tg.start_soon(self._command_processor)
tg.start_soon(self._forward_events)
tg.start_soon(self._emit_existing_download_progress)
tg.start_soon(self._check_internet_connection)
if not self.offline:
tg.start_soon(self._check_internet_connection)
def _test_internet_connection(self) -> None:
try:
@@ -202,6 +209,20 @@ class DownloadCoordinator:
)
return
if self.offline:
logger.warning(
f"Offline mode: model {model_id} is not fully available locally, cannot download"
)
failed = DownloadFailed(
shard_metadata=shard,
node_id=self.node_id,
error_message=f"Model files not found locally in offline mode: {model_id}",
model_directory=self._model_dir(model_id),
)
self.download_status[model_id] = failed
await self.event_sender.send(NodeDownloadProgress(download_progress=failed))
return
# Start actual download
self._start_download_task(shard, initial_progress)
+12 -1
View File
@@ -448,12 +448,13 @@ async def download_file_with_retry(
target_dir: Path,
on_progress: Callable[[int, int, bool], None] = lambda _, __, ___: None,
on_connection_lost: Callable[[], None] = lambda: None,
skip_internet: bool = False,
) -> Path:
n_attempts = 3
for attempt in range(n_attempts):
try:
return await _download_file(
model_id, revision, path, target_dir, on_progress
model_id, revision, path, target_dir, on_progress, skip_internet
)
except HuggingFaceAuthenticationError:
raise
@@ -487,10 +488,14 @@ async def _download_file(
path: str,
target_dir: Path,
on_progress: Callable[[int, int, bool], None] = lambda _, __, ___: None,
skip_internet: bool = False,
) -> Path:
target_path = target_dir / path
if await aios.path.exists(target_path):
if skip_internet:
return target_path
local_size = (await aios.stat(target_path)).st_size
# Try to verify against remote, but allow offline operation
@@ -510,6 +515,11 @@ async def _download_file(
)
return target_path
if skip_internet:
raise FileNotFoundError(
f"File {path} not found locally and cannot download in offline mode"
)
await aios.makedirs((target_dir / path).parent, exist_ok=True)
length, etag = await file_meta(model_id, revision, path)
remote_hash = etag[:-5] if etag.endswith("-gzip") else etag
@@ -814,6 +824,7 @@ async def download_shard(
file, curr_bytes, total_bytes, is_renamed
),
on_connection_lost=on_connection_lost,
skip_internet=skip_internet,
)
if not skip_download:
+230
View File
@@ -0,0 +1,230 @@
"""Tests for offline/air-gapped mode."""
from collections.abc import AsyncIterator
from pathlib import Path
from unittest.mock import AsyncMock, patch
import aiofiles
import aiofiles.os as aios
import pytest
from exo.download.download_utils import (
_download_file, # pyright: ignore[reportPrivateUsage]
download_file_with_retry,
fetch_file_list_with_cache,
)
from exo.shared.types.common import ModelId
from exo.shared.types.worker.downloads import FileListEntry
@pytest.fixture
def model_id() -> ModelId:
return ModelId("test-org/test-model")
@pytest.fixture
async def temp_models_dir(tmp_path: Path) -> AsyncIterator[Path]:
models_dir = tmp_path / "models"
await aios.makedirs(models_dir, exist_ok=True)
with patch("exo.download.download_utils.EXO_MODELS_DIR", models_dir):
yield models_dir
class TestDownloadFileOffline:
"""Tests for _download_file with skip_internet=True."""
async def test_returns_local_file_without_http_verification(
self, model_id: ModelId, tmp_path: Path
) -> None:
"""When skip_internet=True and file exists locally, return it immediately
without making any HTTP calls (no file_meta verification)."""
target_dir = tmp_path / "downloads"
await aios.makedirs(target_dir, exist_ok=True)
local_file = target_dir / "model.safetensors"
async with aiofiles.open(local_file, "wb") as f:
await f.write(b"model weights data")
with patch(
"exo.download.download_utils.file_meta",
new_callable=AsyncMock,
) as mock_file_meta:
result = await _download_file(
model_id,
"main",
"model.safetensors",
target_dir,
skip_internet=True,
)
assert result == local_file
mock_file_meta.assert_not_called()
async def test_raises_file_not_found_for_missing_file(
self, model_id: ModelId, tmp_path: Path
) -> None:
"""When skip_internet=True and file does NOT exist locally,
raise FileNotFoundError instead of attempting download."""
target_dir = tmp_path / "downloads"
await aios.makedirs(target_dir, exist_ok=True)
with pytest.raises(FileNotFoundError, match="offline mode"):
await _download_file(
model_id,
"main",
"missing_model.safetensors",
target_dir,
skip_internet=True,
)
async def test_returns_local_file_in_subdirectory(
self, model_id: ModelId, tmp_path: Path
) -> None:
"""When skip_internet=True and file exists in a subdirectory,
return it without HTTP calls."""
target_dir = tmp_path / "downloads"
subdir = target_dir / "transformer"
await aios.makedirs(subdir, exist_ok=True)
local_file = subdir / "diffusion_pytorch_model.safetensors"
async with aiofiles.open(local_file, "wb") as f:
await f.write(b"weights")
with patch(
"exo.download.download_utils.file_meta",
new_callable=AsyncMock,
) as mock_file_meta:
result = await _download_file(
model_id,
"main",
"transformer/diffusion_pytorch_model.safetensors",
target_dir,
skip_internet=True,
)
assert result == local_file
mock_file_meta.assert_not_called()
class TestDownloadFileWithRetryOffline:
"""Tests for download_file_with_retry with skip_internet=True."""
async def test_propagates_skip_internet_to_download_file(
self, model_id: ModelId, tmp_path: Path
) -> None:
"""Verify skip_internet is passed through to _download_file."""
target_dir = tmp_path / "downloads"
await aios.makedirs(target_dir, exist_ok=True)
local_file = target_dir / "config.json"
async with aiofiles.open(local_file, "wb") as f:
await f.write(b'{"model_type": "qwen2"}')
with patch(
"exo.download.download_utils.file_meta",
new_callable=AsyncMock,
) as mock_file_meta:
result = await download_file_with_retry(
model_id,
"main",
"config.json",
target_dir,
skip_internet=True,
)
assert result == local_file
mock_file_meta.assert_not_called()
async def test_file_not_found_does_not_retry(
self, model_id: ModelId, tmp_path: Path
) -> None:
"""FileNotFoundError from offline mode should not trigger retries."""
target_dir = tmp_path / "downloads"
await aios.makedirs(target_dir, exist_ok=True)
with pytest.raises(FileNotFoundError):
await download_file_with_retry(
model_id,
"main",
"nonexistent.safetensors",
target_dir,
skip_internet=True,
)
class TestFetchFileListOffline:
"""Tests for fetch_file_list_with_cache with skip_internet=True."""
async def test_uses_cached_file_list(
self, model_id: ModelId, temp_models_dir: Path
) -> None:
"""When skip_internet=True and cache file exists, use it without network."""
from pydantic import TypeAdapter
cache_dir = temp_models_dir / "caches" / model_id.normalize()
await aios.makedirs(cache_dir, exist_ok=True)
cached_list = [
FileListEntry(type="file", path="model.safetensors", size=1000),
FileListEntry(type="file", path="config.json", size=200),
]
cache_file = cache_dir / f"{model_id.normalize()}--main--file_list.json"
async with aiofiles.open(cache_file, "w") as f:
await f.write(
TypeAdapter(list[FileListEntry]).dump_json(cached_list).decode()
)
with patch(
"exo.download.download_utils.fetch_file_list_with_retry",
new_callable=AsyncMock,
) as mock_fetch:
result = await fetch_file_list_with_cache(
model_id, "main", skip_internet=True
)
assert result == cached_list
mock_fetch.assert_not_called()
async def test_falls_back_to_local_directory_scan(
self, model_id: ModelId, temp_models_dir: Path
) -> None:
"""When skip_internet=True and no cache but local files exist,
build file list from local directory."""
import json
model_dir = temp_models_dir / model_id.normalize()
await aios.makedirs(model_dir, exist_ok=True)
async with aiofiles.open(model_dir / "config.json", "w") as f:
await f.write('{"model_type": "qwen2"}')
index_data = {
"metadata": {},
"weight_map": {"model.layers.0.weight": "model.safetensors"},
}
async with aiofiles.open(model_dir / "model.safetensors.index.json", "w") as f:
await f.write(json.dumps(index_data))
async with aiofiles.open(model_dir / "model.safetensors", "wb") as f:
await f.write(b"x" * 500)
with patch(
"exo.download.download_utils.fetch_file_list_with_retry",
new_callable=AsyncMock,
) as mock_fetch:
result = await fetch_file_list_with_cache(
model_id, "main", skip_internet=True
)
mock_fetch.assert_not_called()
paths = {entry.path for entry in result}
assert "config.json" in paths
assert "model.safetensors" in paths
async def test_raises_when_no_cache_and_no_local_files(
self, model_id: ModelId, temp_models_dir: Path
) -> None:
"""When skip_internet=True and neither cache nor local files exist,
raise FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="No internet"):
await fetch_file_list_with_cache(model_id, "main", skip_internet=True)
+13
View File
@@ -39,6 +39,7 @@ class Node:
node_id: NodeId
event_index_counter: Iterator[int]
offline: bool
_tg: TaskGroup = field(init=False, default_factory=anyio.create_task_group)
@classmethod
@@ -68,6 +69,7 @@ class Node:
download_command_receiver=router.receiver(topics.DOWNLOAD_COMMANDS),
local_event_sender=router.sender(topics.LOCAL_EVENTS),
event_index_counter=event_index_counter,
offline=args.offline,
)
else:
download_coordinator = None
@@ -132,6 +134,7 @@ class Node:
api,
node_id,
event_index_counter,
args.offline,
)
async def run(self):
@@ -222,6 +225,7 @@ class Node:
),
local_event_sender=self.router.sender(topics.LOCAL_EVENTS),
event_index_counter=self.event_index_counter,
offline=self.offline,
)
self._tg.start_soon(self.download_coordinator.run)
if self.worker:
@@ -260,6 +264,9 @@ def main():
logger.info("Starting EXO")
logger.info(f"EXO_LIBP2P_NAMESPACE: {os.getenv('EXO_LIBP2P_NAMESPACE')}")
if args.offline:
logger.info("Running in OFFLINE mode — no internet checks, local models only")
# Set FAST_SYNCH override env var for runner subprocesses
if args.fast_synch is True:
os.environ["EXO_FAST_SYNCH"] = "on"
@@ -282,6 +289,7 @@ class Args(CamelCaseModel):
tb_only: bool = False
no_worker: bool = False
no_downloads: bool = False
offline: bool = False
fast_synch: bool | None = None # None = auto, True = force on, False = force off
@classmethod
@@ -329,6 +337,11 @@ class Args(CamelCaseModel):
action="store_true",
help="Disable the download coordinator (node won't download models)",
)
parser.add_argument(
"--offline",
action="store_true",
help="Run in offline/air-gapped mode: skip internet checks, use only pre-staged local models",
)
fast_synch_group = parser.add_mutually_exclusive_group()
fast_synch_group.add_argument(
"--fast-synch",
+17 -12
View File
@@ -85,6 +85,7 @@ from exo.shared.types.api import (
ImageGenerationTaskParams,
ImageListItem,
ImageListResponse,
ImageSize,
ModelList,
ModelListModel,
PlaceInstanceParams,
@@ -100,6 +101,7 @@ from exo.shared.types.api import (
TraceRankStats,
TraceResponse,
TraceStatsResponse,
normalize_image_size,
)
from exo.shared.types.chunks import (
ErrorChunk,
@@ -751,9 +753,11 @@ class API:
When stream=True and partial_images > 0, returns a StreamingResponse
with SSE-formatted events for partial and final images.
"""
payload.model = await self._validate_image_model(ModelId(payload.model))
payload = payload.model_copy(
update={"advanced_params": _ensure_seed(payload.advanced_params)}
update={
"model": await self._validate_image_model(ModelId(payload.model)),
"advanced_params": _ensure_seed(payload.advanced_params),
}
)
command = ImageGeneration(
@@ -1009,12 +1013,13 @@ class API:
async def bench_image_generations(
self, request: Request, payload: BenchImageGenerationTaskParams
) -> BenchImageGenerationResponse:
payload.model = await self._validate_image_model(ModelId(payload.model))
payload.stream = False
payload.partial_images = 0
payload = payload.model_copy(
update={"advanced_params": _ensure_seed(payload.advanced_params)}
update={
"model": await self._validate_image_model(ModelId(payload.model)),
"stream": False,
"partial_images": 0,
"advanced_params": _ensure_seed(payload.advanced_params),
}
)
command = ImageGeneration(
@@ -1035,7 +1040,7 @@ class API:
prompt: str,
model: ModelId,
n: int,
size: str,
size: ImageSize,
response_format: Literal["url", "b64_json"],
input_fidelity: Literal["low", "high"],
stream: bool,
@@ -1105,7 +1110,7 @@ class API:
prompt: str = Form(...),
model: str = Form(...),
n: int = Form(1),
size: str = Form("1024x1024"),
size: str | None = Form(None),
response_format: Literal["url", "b64_json"] = Form("b64_json"),
input_fidelity: Literal["low", "high"] = Form("low"),
stream: str = Form("false"),
@@ -1131,7 +1136,7 @@ class API:
prompt=prompt,
model=ModelId(model),
n=n,
size=size,
size=normalize_image_size(size),
response_format=response_format,
input_fidelity=input_fidelity,
stream=stream_bool,
@@ -1167,7 +1172,7 @@ class API:
prompt: str = Form(...),
model: str = Form(...),
n: int = Form(1),
size: str = Form("1024x1024"),
size: str | None = Form(None),
response_format: Literal["url", "b64_json"] = Form("b64_json"),
input_fidelity: Literal["low", "high"] = Form("low"),
quality: Literal["high", "medium", "low"] = Form("medium"),
@@ -1187,7 +1192,7 @@ class API:
prompt=prompt,
model=ModelId(model),
n=n,
size=size,
size=normalize_image_size(size),
response_format=response_format,
input_fidelity=input_fidelity,
stream=False,
+3 -1
View File
@@ -44,7 +44,8 @@ async def _refresh_card_cache():
async for toml_file in path.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
_card_cache[card.model_id] = card
if card.model_id not in _card_cache:
_card_cache[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
@@ -182,6 +183,7 @@ class ConfigData(BaseModel):
def supports_tensor(self) -> bool:
return self.architectures in [
["Glm4MoeLiteForCausalLM"],
["GlmMoeDsaForCausalLM"],
["DeepseekV32ForCausalLM"],
["DeepseekV3ForCausalLM"],
["Qwen3NextForCausalLM"],
+35 -4
View File
@@ -1,9 +1,9 @@
import time
from collections.abc import Generator
from typing import Annotated, Any, Literal
from typing import Annotated, Any, Literal, get_args
from uuid import uuid4
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.common import CommandId, NodeId
@@ -262,6 +262,27 @@ class DeleteInstanceResponse(BaseModel):
instance_id: InstanceId
ImageSize = Literal[
"auto",
"512x512",
"768x768",
"1024x768",
"768x1024",
"1024x1024",
"1024x1536",
"1536x1024",
]
def normalize_image_size(v: object) -> ImageSize:
"""Shared validator for ImageSize fields: maps None → "auto" and rejects invalid values."""
if v is None:
return "auto"
if v not in get_args(ImageSize):
raise ValueError(f"Invalid size: {v!r}. Must be one of {get_args(ImageSize)}")
return v # pyright: ignore[reportReturnType]
class AdvancedImageParams(BaseModel):
seed: Annotated[int, Field(ge=0)] | None = None
num_inference_steps: Annotated[int, Field(ge=1, le=100)] | None = None
@@ -281,7 +302,7 @@ class ImageGenerationTaskParams(BaseModel):
partial_images: int | None = 0
quality: Literal["high", "medium", "low"] | None = "medium"
response_format: Literal["url", "b64_json"] | None = "b64_json"
size: str | None = "1024x1024"
size: ImageSize = "auto"
stream: bool | None = False
style: str | None = "vivid"
user: str | None = None
@@ -289,6 +310,11 @@ class ImageGenerationTaskParams(BaseModel):
# Internal flag for benchmark mode - set by API, preserved through serialization
bench: bool = False
@field_validator("size", mode="before")
@classmethod
def normalize_size(cls, v: object) -> ImageSize:
return normalize_image_size(v)
class BenchImageGenerationTaskParams(ImageGenerationTaskParams):
bench: bool = True
@@ -305,13 +331,18 @@ class ImageEditsTaskParams(BaseModel):
quality: Literal["high", "medium", "low"] | None = "medium"
output_format: Literal["png", "jpeg", "webp"] = "png"
response_format: Literal["url", "b64_json"] | None = "b64_json"
size: str | None = "1024x1024"
size: ImageSize = "auto"
image_strength: float | None = 0.7
stream: bool = False
partial_images: int | None = 0
advanced_params: AdvancedImageParams | None = None
bench: bool = False
@field_validator("size", mode="before")
@classmethod
def normalize_size(cls, v: object) -> ImageSize:
return normalize_image_size(v)
def __repr_args__(self) -> Generator[tuple[str, Any], None, None]:
for name, value in super().__repr_args__(): # pyright: ignore[reportAny]
if name == "image_data":
+6 -2
View File
@@ -14,6 +14,7 @@ from exo.shared.types.api import (
ImageEditsTaskParams,
ImageGenerationStats,
ImageGenerationTaskParams,
ImageSize,
)
from exo.shared.types.memory import Memory
from exo.shared.types.worker.runner_response import (
@@ -23,9 +24,9 @@ from exo.shared.types.worker.runner_response import (
from exo.worker.engines.image.distributed_model import DistributedImageModel
def parse_size(size_str: str | None) -> tuple[int, int]:
def parse_size(size_str: ImageSize) -> tuple[int, int]:
"""Parse size parameter like '1024x1024' to (width, height) tuple."""
if not size_str:
if size_str == "auto":
return (1024, 1024)
try:
@@ -109,6 +110,9 @@ def generate_image(
# Decode base64 image data and save to temp file
image_path = Path(tmpdir) / "input.png"
image_path.write_bytes(base64.b64decode(task.image_data))
if task.size == "auto":
with Image.open(image_path) as img:
width, height = img.size
for image_num in range(num_images):
# Increment seed for each image to ensure unique results
+26 -13
View File
@@ -163,11 +163,14 @@ class PipelineLastLayer(CustomMlxLayer):
output, (self.r + 1) % self.s, group=self.group
)
if cache is not None:
cache.keys = mx.depends(cache.keys, output) # type: ignore[reportUnknownMemberType]
# CacheList (used by MLA models like DeepSeekV32, GLM MoE DSA)
# doesn't have .keys directly; access via first sub-cache.
_cache = cache[0] if hasattr(cache, "caches") else cache # type: ignore
_cache.keys = mx.depends(_cache.keys, output) # type: ignore
if self.is_prefill:
mx.eval(output)
if cache is not None:
mx.eval(cache.keys) # type: ignore
mx.eval(_cache.keys) # type: ignore
if not self.is_prefill:
output = mx.distributed.all_gather(output, group=self.group)[
@@ -307,7 +310,9 @@ def patch_pipeline_model[T](model: T, group: mx.distributed.Group) -> T:
# Add dependency to last cache entry to ensure distributed ops are evaluated
if cache is not None:
cache[-1].state = mx.depends(cache[-1].state, logits) # type: ignore
last = cache[-1] # type: ignore
dep_cache = last[0] if hasattr(last, "caches") else last # type: ignore
dep_cache.keys = mx.depends(dep_cache.keys, logits) # type: ignore
return logits
@@ -333,7 +338,9 @@ def patch_tensor_model[T](model: T) -> T:
# Add dependency to last cache entry to ensure distributed ops are evaluated
if cache is not None and len(cache) > 0: # pyright: ignore[reportAny]
cache[-1].state = mx.depends(cache[-1].state, logits) # pyright: ignore[reportAny,reportUnknownMemberType]
last = cache[-1] # pyright: ignore[reportAny]
dep_cache = last[0] if hasattr(last, "caches") else last # pyright: ignore[reportAny]
dep_cache.keys = mx.depends(dep_cache.keys, logits) # pyright: ignore[reportAny,reportUnknownMemberType]
return logits
@@ -547,10 +554,12 @@ class DeepSeekShardingStrategy(TensorParallelShardingStrategy):
on_timeout: TimeoutCallback | None,
) -> nn.Module:
model = cast(DeepseekV3Model, model)
for layer in model.layers:
eval_with_timeout(
layer.parameters(), timeout_seconds / len(model.layers), on_timeout
)
# Shard the self attention
if layer.self_attn.q_lora_rank is None:
layer.self_attn.q_proj = self.all_to_sharded_linear(
@@ -581,12 +590,18 @@ class DeepSeekShardingStrategy(TensorParallelShardingStrategy):
layer.mlp.down_proj = self.sharded_to_all_linear(layer.mlp.down_proj)
layer.mlp.up_proj = self.all_to_sharded_linear(layer.mlp.up_proj)
# Shard the MoE. Shard in place since the MoE should be responsible
# for aggregating the results.
# Shard the MoE.
else:
self.all_to_sharded_linear_in_place(layer.mlp.shared_experts.gate_proj)
self.sharded_to_all_linear_in_place(layer.mlp.shared_experts.down_proj)
self.all_to_sharded_linear_in_place(layer.mlp.shared_experts.up_proj)
if getattr(layer.mlp, "shared_experts", None) is not None:
self.all_to_sharded_linear_in_place(
layer.mlp.shared_experts.gate_proj
)
self.sharded_to_all_linear_in_place(
layer.mlp.shared_experts.down_proj
)
self.all_to_sharded_linear_in_place(
layer.mlp.shared_experts.up_proj
)
self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.gate_proj)
self.sharded_to_all_linear_in_place(layer.mlp.switch_mlp.down_proj)
self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.up_proj)
@@ -779,8 +794,7 @@ class MiniMaxShardingStrategy(TensorParallelShardingStrategy):
layer.self_attn = WrappedMiniMaxAttention(layer.self_attn, self.group) # pyright: ignore[reportAttributeAccessIssue,reportArgumentType]
# Shard the MoE. Shard in place since the MoE should be responsible
# for aggregating the results.
# Shard the MoE.
self.all_to_sharded_linear_in_place(
layer.block_sparse_moe.switch_mlp.gate_proj
)
@@ -893,8 +907,7 @@ class QwenShardingStrategy(TensorParallelShardingStrategy):
layer.self_attn.num_attention_heads //= self.N
layer.self_attn.num_key_value_heads //= self.N
# Shard the MoE. Shard in place since the MoE should be responsible
# for aggregating the results.
# Shard the MoE.
if isinstance(layer.mlp, (Qwen3MoeSparseMoeBlock, Qwen3NextSparseMoeBlock)):
self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.gate_proj)
self.sharded_to_all_linear_in_place(layer.mlp.switch_mlp.down_proj)
@@ -57,6 +57,7 @@ def prefill(
sampler: Callable[[mx.array], mx.array],
prompt_tokens: mx.array,
cache: KVCacheType,
group: mx.distributed.Group | None = None,
) -> tuple[float, int, list[CacheSnapshot]]:
"""Prefill the KV cache with prompt tokens.
@@ -86,6 +87,9 @@ def prefill(
set_pipeline_prefill(model, is_prefill=True)
mx_barrier(group)
logger.info("Starting prefill")
# Use max_tokens=1 because max_tokens=0 does not work.
# We just throw away the generated token - we only care about filling the cache
for _ in stream_generate(
@@ -305,16 +309,9 @@ def mlx_generate(
)
max_stop_len = max((len(s) for s in stop_sequences), default=0)
mx_barrier(group)
logger.info("Starting prefill")
# Prefill cache with all tokens except the last one
prefill_tps, prefill_tokens, ssm_snapshots_list = prefill(
model,
tokenizer,
sampler,
prompt_tokens[:-1],
caches,
model, tokenizer, sampler, prompt_tokens[:-1], caches, group
)
cache_snapshots: list[CacheSnapshot] | None = ssm_snapshots_list or None
@@ -331,6 +328,7 @@ def mlx_generate(
think_start = tokenizer.think_start
think_end = tokenizer.think_end
logger.info("Starting decode")
mx_barrier(group)
for completion_tokens, out in enumerate(
+3 -1
View File
@@ -285,10 +285,12 @@ def get_eos_token_ids_for_model(model_id: ModelId) -> list[int] | None:
model_id_lower = model_id.lower()
if "kimi-k2" in model_id_lower:
return [163586]
elif "glm-4.7-flash" in model_id_lower:
elif "glm-5" in model_id_lower or "glm-4.7" in model_id_lower:
# For GLM-5 and GLM-4.7
# 154820: <|endoftext|>, 154827: <|user|>, 154829: <|observation|>
return [154820, 154827, 154829]
elif "glm" in model_id_lower:
# For GLM-4.5 and older
return [151336, 151329, 151338]
return None
+1 -1
View File
@@ -191,7 +191,7 @@ class RunnerSupervisor:
logger.info("Checking runner's status")
if self.runner_process.is_alive():
logger.info("Runner was found to be alive, attempting to join process")
await to_thread.run_sync(self.runner_process.join, 1)
await to_thread.run_sync(self.runner_process.join, 5)
rc = self.runner_process.exitcode
logger.info(f"RunnerSupervisor exited with exit code {rc}")
if rc == 0:
Generated
+9 -9
View File
@@ -378,7 +378,7 @@ dependencies = [
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mflux", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.6", source = { registry = "https://pypi.org/simple" }, extra = ["cpu"], marker = "sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260217+50487b41", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#50487b4141f3c951122655db3b83df5146c1fbeb" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx", version = "0.30.7.dev20260218+14841977", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#1484197707f35186ad3bd614357c7c47fdf86ebc" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx-lm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "openai-harmony", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -418,7 +418,7 @@ requires-dist = [
{ name = "mflux", specifier = "==0.15.5" },
{ name = "mlx", marker = "sys_platform == 'darwin'", git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks" },
{ name = "mlx", extras = ["cpu"], marker = "sys_platform == 'linux'", specifier = "==0.30.6" },
{ name = "mlx-lm", specifier = "==0.30.6" },
{ name = "mlx-lm", specifier = "==0.30.7" },
{ name = "msgspec", specifier = ">=0.19.0" },
{ name = "openai-harmony", specifier = ">=0.0.8" },
{ name = "pillow", specifier = ">=11.0,<12.0" },
@@ -1021,7 +1021,7 @@ dependencies = [
{ name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.6", source = { registry = "https://pypi.org/simple" }, extra = ["cuda13"], marker = "sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260217+50487b41", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#50487b4141f3c951122655db3b83df5146c1fbeb" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx", version = "0.30.7.dev20260218+14841977", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#1484197707f35186ad3bd614357c7c47fdf86ebc" }, marker = "sys_platform == 'darwin'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "opencv-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "piexif", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -1068,8 +1068,8 @@ cuda13 = [
[[package]]
name = "mlx"
version = "0.30.7.dev20260217+50487b41"
source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#50487b4141f3c951122655db3b83df5146c1fbeb" }
version = "0.30.7.dev20260218+14841977"
source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#1484197707f35186ad3bd614357c7c47fdf86ebc" }
resolution-markers = [
"sys_platform == 'darwin'",
]
@@ -1100,20 +1100,20 @@ wheels = [
[[package]]
name = "mlx-lm"
version = "0.30.6"
version = "0.30.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260217+50487b41", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#50487b4141f3c951122655db3b83df5146c1fbeb" }, marker = "sys_platform == 'darwin'" },
{ name = "mlx", version = "0.30.7.dev20260218+14841977", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#1484197707f35186ad3bd614357c7c47fdf86ebc" }, marker = "sys_platform == 'darwin'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "sentencepiece", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/cb/815deddc8699b1f694d7e1f9cbed52934c03a8b49432c8add72932bb2f0b/mlx_lm-0.30.6.tar.gz", hash = "sha256:807e042d7040268f1b19190b7eaefd8b2efbff5590a65460974ad4225b91dda1", size = 271733, upload-time = "2026-02-04T21:27:45.741Z" }
sdist = { url = "https://files.pythonhosted.org/packages/66/0d/56542e2ae13ec6f542d3977d7cff89a205d4f6c5122e0ce23f33265f61c9/mlx_lm-0.30.7.tar.gz", hash = "sha256:e5f31ac58d9f2381f28e1ba639ff903e64f7cff1bdc245c0bc97f72264be329c", size = 275764, upload-time = "2026-02-12T18:41:11.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/5f/01d281f1fa8a1521d5936659beb4f5ab1f32b463d059263cf9d4cef969d9/mlx_lm-0.30.6-py3-none-any.whl", hash = "sha256:a7405bd581eacc4bf8209d7a6b7f23629585a0d7c6740c2a97e51fee35b3b0e1", size = 379451, upload-time = "2026-02-04T21:27:43.222Z" },
{ url = "https://files.pythonhosted.org/packages/1e/17/a41c798a3d9cbdc47f39c6db5bba4c2cd199203ead26bf911cb03b644070/mlx_lm-0.30.7-py3-none-any.whl", hash = "sha256:17442a4bf01c4c2d3bca1e647712fe44f19890c3f1eadc8589d389e57b44b9bf", size = 386591, upload-time = "2026-02-12T18:41:10.236Z" },
]
[[package]]