File: README.md
exo connects all your devices into an AI cluster. Not only does exo enable running models larger than would fit on a single device, but with day-0 support for RDMA over Thunderbolt, makes models run faster as you add more devices.
Features
- Automatic Device Discovery: Devices running exo automatically discover each other - no manual configuration.
- RDMA over Thunderbolt: exo ships with day-0 support for RDMA over Thunderbolt 5, enabling 99% reduction in latency between devices.
- Topology-Aware Auto Parallel: exo figures out the best way to split your model across all available devices based on a realtime view of your device topology. It takes into account device resources and network latency/bandwidth between each link.
- Tensor Parallelism: exo supports sharding models, for up to 1.8x speedup on 2 devices and 3.2x speedup on 4 devices.
- MLX Support: exo uses MLX as an inference backend and MLX distributed for distributed communication.
- Multiple API Compatibility: Compatible with OpenAI Chat Completions API, Claude Messages API, OpenAI Responses API, and Ollama API - use your existing tools and clients.
- Custom Model Support: Load custom models from HuggingFace hub to expand the range of available models.
Dashboard
exo includes a built-in dashboard for managing your cluster and chatting with models.
Benchmarks
Qwen3-235B (8-bit) on 4 ร M3 Ultra Mac Studio with Tensor Parallel RDMA
**Source:** [Jeff Geerling: 15 TB VRAM on Mac Studio โ RDMA over Thunderbolt 5](https://www.jeffgeerling.com/blog/2025/15-tb-vram-on-mac-studio-rdma-over-thunderbolt-5)DeepSeek v3.1 671B (8-bit) on 4 ร M3 Ultra Mac Studio with Tensor Parallel RDMA
**Source:** [Jeff Geerling: 15 TB VRAM on Mac Studio โ RDMA over Thunderbolt 5](https://www.jeffgeerling.com/blog/2025/15-tb-vram-on-mac-studio-rdma-over-thunderbolt-5)Kimi K2 Thinking (native 4-bit) on 4 ร M3 Ultra Mac Studio with Tensor Parallel RDMA
**Source:** [Jeff Geerling: 15 TB VRAM on Mac Studio โ RDMA over Thunderbolt 5](https://www.jeffgeerling.com/blog/2025/15-tb-vram-on-mac-studio-rdma-over-thunderbolt-5)Quick Start
Devices running exo automatically discover each other, without needing any manual configuration. Each device provides an API and a dashboard for interacting with your cluster (runs at http://localhost:52415).
You can run exo with the macOS app, the easiest way on a Mac, or from source on macOS or Linux.
macOS App
exo ships a macOS app that runs in the background on your Mac.
The macOS app requires macOS Tahoe 26.2 or later.
Download the latest build here: EXO-latest.dmg.
You can also install the latest build with Homebrew:
brew install --cask exoThe app will ask for permission to modify system settings and install a new Network profile. Improvements to this are being worked on.
Custom Namespace for Cluster Isolation:
The macOS app includes a custom namespace feature that allows you to isolate your exo cluster from others on the same network. This is configured through the EXO_ZENOH_NAMESPACE setting:
Use cases:
- Running multiple separate exo clusters on the same network
- Isolating development/testing clusters from production clusters
- Preventing accidental cluster joining
Configuration: Access this setting in the app's Advanced settings (or set the
EXO_ZENOH_NAMESPACEenvironment variable when running from source)
The namespace is logged on startup for debugging purposes.
Uninstalling the macOS App
The recommended way to uninstall is through the app itself: click the menu bar icon โ Advanced โ Uninstall. This cleanly removes all system components.
If you've already deleted the app, you can run the standalone uninstaller script:
sudo ./app/EXO/uninstall-exo.shThis removes:
- Network setup LaunchDaemon
- Network configuration script
- Log files
- The "exo" network location
Note: You'll need to manually remove EXO from Login Items in System Settings โ General โ Login Items.
Run from Source (macOS)
If you have Nix installed, you can skip most of the steps below and run exo directly:
nix run .#exoNote: To accept the Cachix binary cache (and avoid the Xcode Metal ToolChain), add these lines to /etc/nix/nix.conf, replacing your-username with your macOS username (whoami prints it):
trusted-users = root your-username
experimental-features = nix-command flakesThen restart the Nix daemon: sudo launchctl kickstart -k system/org.nixos.nix-daemon
Prerequisites:
Xcode (provides the Metal ToolChain required for MLX compilation)
brew (for simple package management on macOS)
bash/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"uv (for Python dependency management)
node (for building the dashboard)
bashbrew install uv noderust (stable, to build the Rust bindings)
bashcurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shmacmon (optional, for GPU usage, power and temperature monitoring on Apple Silicon; without it exo falls back to psutil and monitors memory only)
Install the pinned fork revision used by this repo instead of Homebrew
macmon.
Homebrewmacmon 0.6.1still crashes on Apple M5.bashcargo install --git https://github.com/vladkens/macmon \ --rev a1cd06b6cc0d5e61db24fd8832e74cd992097a7d \ macmon \ --force
Clone the repo, build the dashboard, install the dependencies, and run exo:
# Clone exo
git clone https://github.com/exo-explore/exo
# Build dashboard
cd exo/dashboard && npm install && npm run build && cd ..
# Install Python dependencies, including the MLX backend
uv sync --extra mlx
# Run exo
uv run exoThis starts the exo dashboard and API at http://localhost:52415/
Please view the section on RDMA to enable this feature on MacOS >=26.2!
Run from Source (Linux)
Prerequisites:
- uv (for Python dependency management)
- node (for building the dashboard) - version 18 or higher
- rust (stable, to build the Rust bindings)
Installation methods:
Option 1: Using system package manager (Ubuntu/Debian example):
# Install Node.js and npm
sudo apt update
sudo apt install nodejs npm
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install Rust (using rustup)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shOption 2: Using Homebrew on Linux (if preferred):
# Install Homebrew on Linux
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install dependencies
brew install uv node
# Install Rust (using rustup)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shNote: The macmon package is macOS-only and not required for Linux.
Clone the repo, build the dashboard, install the dependencies, and run exo:
# Clone exo
git clone https://github.com/exo-explore/exo
# Build dashboard
cd exo/dashboard && npm install && npm run build && cd ..
# Install Python dependencies with the MLX backend for your hardware
# (NVIDIA: --extra mlx-cuda13 or --extra mlx-cuda12)
uv sync --extra mlx-cpu
# Run exo
uv run exoThis starts the exo dashboard and API at http://localhost:52415/
Important note for Linux users: Currently, exo runs on CPU on Linux. GPU support for Linux platforms is under development. If you'd like to see support for your specific Linux hardware, please search for existing feature requests or create a new one.
Configuration Options:
--no-worker: Run exo without the worker component. Useful for coordinator-only nodes that handle networking and orchestration but don't execute inference tasks. This is helpful for machines without sufficient GPU resources but with good network connectivity.bashuv run exo --no-worker--legacy-daemon: Run exo as a legacy SysV-style background daemon using double-fork daemonization. This is intended for legacy init scripts; systemd and launchd should run exo in the foreground without this flag.bashuv run exo --legacy-daemon
File Locations (Linux):
exo follows the XDG Base Directory Specification on Linux:
- Configuration files:
~/.config/exo/(or$XDG_CONFIG_HOME/exo/) - Data files:
~/.local/share/exo/(or$XDG_DATA_HOME/exo/) - Cache files:
~/.cache/exo/(or$XDG_CACHE_HOME/exo/) - Log files:
~/.cache/exo/exo_log/(with automatic log rotation) - Custom model cards:
~/.local/share/exo/custom_model_cards/
You can override these locations by setting the corresponding XDG environment variables.
Enabling RDMA on macOS
RDMA is a new capability added to macOS 26.2. It works on any Mac with Thunderbolt 5 (M4 Pro Mac Mini, M4 Max Mac Studio, M4 Max MacBook Pro, M3 Ultra Mac Studio).
Please refer to the caveats for immediate troubleshooting.
To enable RDMA on macOS, follow these steps:
- Shut down your Mac.
- Hold down the power button for 10 seconds until the boot menu appears.
- Select "Options" to enter Recovery mode.
- When the Recovery UI appears, open the Terminal from the Utilities menu.
- In the Terminal, type:and press Enter.text
rdma_ctl enable - Reboot your Mac.
After that, RDMA will be enabled in macOS and exo will take care of the rest.
Important Caveats
- Devices that wish to be part of an RDMA cluster must be connected to all other devices in the cluster.
- The cables must support TB5.
- On a Mac Studio, you cannot use the Thunderbolt 5 port next to the Ethernet port.
- If running from source, please use the script found at
tmp/set_rdma_network_config.sh, which will disable Thunderbolt Bridge and set dhcp on each RDMA port. - RDMA ports may be unable to discover each other on different versions of MacOS. Please ensure that OS versions match exactly (even beta version numbers) on all devices.
Environment Variables
exo supports several environment variables for configuration:
| Variable | Description | Default |
|---|---|---|
EXO_DEFAULT_MODELS_DIR |
Default directory for model downloads and caches. Always first in the writable dirs list. | ~/.local/share/exo/models (Linux) or ~/.exo/models (macOS) |
EXO_MODELS_DIRS |
Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
EXO_MODELS_READ_ONLY_DIRS |
Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
EXO_OFFLINE |
Run without internet connection (uses only local models) | false |
EXO_ENABLE_IMAGE_MODELS |
Enable image model support | false |
EXO_ZENOH_NAMESPACE |
Custom namespace for cluster isolation (same as --namespace) |
exo version |
EXO_FAST_SYNCH |
Control MLX_METAL_FAST_SYNCH behavior (for JACCL backend) | Auto |
EXO_TRACING_ENABLED |
Enable distributed tracing for performance analysis | false |
Example usage:
# Use pre-downloaded models from NFS mount (read-only)
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
# Download models to an external SSD (falls back to default dir if full)
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
# Run in offline mode
EXO_OFFLINE=true uv run exo
# Enable image models
EXO_ENABLE_IMAGE_MODELS=true uv run exo
# Use custom namespace for cluster isolation
EXO_ZENOH_NAMESPACE=my-dev-cluster uv run exoUsing the API
exo provides multiple API-compatible interfaces for maximum compatibility with existing tools:
- OpenAI Chat Completions API - Compatible with OpenAI clients
- Claude Messages API - Compatible with Anthropic's Claude format
- OpenAI Responses API - Compatible with OpenAI's Responses format
- Ollama API - Compatible with Ollama and tools like OpenWebUI
If you prefer to interact with exo via the API, here is an example creating an instance of a small model (mlx-community/Llama-3.2-1B-Instruct-4bit), sending a chat completions request and deleting the instance.
1. Preview instance placements
The /instance/previews endpoint will preview all valid placements for your model.
curl "http://localhost:52415/instance/previews?model_id=mlx-community/Llama-3.2-1B-Instruct-4bit"Sample response:
{
"previews": [
{
"model_id": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"sharding": "Pipeline",
"instance_meta": "MlxRing",
"instance": {...},
"memory_delta_by_node": {"local": 729808896},
"error": null
}
// ...possibly more placements...
]
}This will return all valid placements for this model. Pick a placement that you like.
To pick the first one, pipe into jq:
curl "http://localhost:52415/instance/previews?model_id=mlx-community/Llama-3.2-1B-Instruct-4bit" | jq -c '.previews[] | select(.error == null) | .instance' | head -n12. Create a model instance
Send a POST to /instance with your desired placement in the instance field (the full payload must match types as in CreateInstanceParams), which you can copy from step 1:
curl -X POST http://localhost:52415/instance \
-H 'Content-Type: application/json' \
-d '{
"instance": {...}
}'Sample response:
{
"message": "Command received.",
"command_id": "e9d1a8ab-...."
}This command is asynchronous. Before sending inference requests, wait until the
API sees the new instance for this model:
curl -N "http://localhost:52415/instance/await?model_id=mlx-community/Llama-3.2-1B-Instruct-4bit"The endpoint returns an SSE stream. A successful wait emits a message with"type": "ready" and the matching instance; a timeout emits "type": "timeout".
By default it waits indefinitely. Set timeout_seconds to a positive value to
bound the wait.
3. Send a chat completion
Now, make a POST to /v1/chat/completions (the same format as OpenAI's API):
curl -N -X POST http://localhost:52415/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"messages": [
{"role": "user", "content": "What is Llama 3.2 1B?"}
],
"stream": true
}'4. Delete the instance
When you're done, delete the instance by its ID (find it via /state or /instance endpoints):
curl -X DELETE http://localhost:52415/instance/YOUR_INSTANCE_IDClaude Messages API Compatibility
Use the Claude Messages API format with the /v1/messages endpoint:
curl -N -X POST http://localhost:52415/v1/messages \
-H 'Content-Type: application/json' \
-d '{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"messages": [
{"role": "user", "content": "Hello"}
],
"max_tokens": 1024,
"stream": true
}'OpenAI Responses API Compatibility
Use the OpenAI Responses API format with the /v1/responses endpoint:
curl -N -X POST http://localhost:52415/v1/responses \
-H 'Content-Type: application/json' \
-d '{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"input": "Hello",
"stream": true
}'Ollama API Compatibility
exo supports Ollama API endpoints for compatibility with tools like OpenWebUI:
# Ollama chat
curl -X POST http://localhost:52415/ollama/api/chat \
-H 'Content-Type: application/json' \
-d '{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"messages": [
{"role": "user", "content": "Hello"}
],
"stream": false
}'
# List models (Ollama format)
curl http://localhost:52415/ollama/api/tagsCustom Model Loading from HuggingFace
You can add custom models from the HuggingFace hub:
curl -X POST http://localhost:52415/models/add \
-H 'Content-Type: application/json' \
-d '{
"model_id": "mlx-community/my-custom-model"
}'Security Note:
Custom models requiring trust_remote_code in their configuration must be explicitly enabled (default is false) for security. Only enable this if you trust the model's remote code execution. Models are fetched from HuggingFace and stored locally as custom model cards.
Other useful API endpoints:*
- List all models:
curl http://localhost:52415/models - List downloaded models only:
curl http://localhost:52415/models?status=downloaded - Search HuggingFace:
curl "http://localhost:52415/models/search?query=llama&limit=10" - Inspect instance IDs and deployment state:
curl http://localhost:52415/state
For further details, see:
- API documentation in docs/api.md.
- API endpoints in src/exo/api/main.py, and their request and response types in src/exo/api/types/.
Benchmarking
The exo-bench tool measures model prefill and token generation speed across different placement configurations. This helps you optimize model performance and validate improvements.
Prerequisites:
- Nodes should be running with
uv run exobefore benchmarking - The tool uses the
/bench/chat/completionsendpoint
Basic usage:
uv run bench/exo_bench.py \
--model Llama-3.2-1B-Instruct-4bit \
--pp 128,256,512 \
--tg 128,256Key parameters:
--model: Model to benchmark (short ID or HuggingFace ID)--pp: Prompt size hints (comma-separated integers)--tg: Generation lengths (comma-separated integers)--max-nodes: Limit placements to N nodes (default: 4)--instance-meta: Filter byring,jaccl, orboth(default: both)--sharding: Filter bypipeline,tensor, orboth(default: both)--repeat: Number of repetitions per configuration (default: 1)--warmup: Warmup runs per placement (default: 0)--json-out: Output file for results (default: bench/results.json)
Example with filters:
uv run bench/exo_bench.py \
--model Llama-3.2-1B-Instruct-4bit \
--pp 128,512 \
--tg 128 \
--max-nodes 2 \
--sharding tensor \
--repeat 3 \
--json-out my-results.jsonThe tool outputs performance metrics including prompt tokens per second (prompt_tps), generation tokens per second (generation_tps), and peak memory usage for each configuration.
Hardware Accelerator Support
On macOS, exo uses the GPU. On Linux, exo currently runs on CPU. We are working on extending hardware accelerator support. If you'd like support for a new hardware platform, please search for an existing feature request and add a thumbs up so we know what hardware is important to the community.
Contributing
See CONTRIBUTING.md for guidelines on how to contribute to exo.
File: AGENTS.md
AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
Project Overview
exo is a distributed AI inference system that connects multiple devices into a cluster. It enables running large language models across multiple machines using MLX as the inference backend and zenoh for peer-to-peer networking.
Build & Run Commands
# Build the dashboard (required before running exo)
cd dashboard && npm install && npm run build && cd ..
# Run exo (starts both master and worker with API at http://localhost:52415)
uv run exo
# Run with verbose logging
uv run exo -v # or -vv for more verbose
# Run tests (excludes slow tests by default)
uv run pytest
# Run all tests including slow tests
uv run pytest -m ""
# Run a specific test file
uv run pytest src/exo/shared/tests/test_election.py
# Run a specific test function
uv run pytest src/exo/shared/tests/test_election.py::test_function_name
# Type checking (strict mode)
uv run basedpyright
# Linting
uv run ruff check
# Format code (using nix)
nix fmtPre-Commit Checks (REQUIRED)
IMPORTANT: Always run these checks before committing code. CI will fail if these don't pass.
# 1. Type checking - MUST pass with 0 errors
uv run basedpyright
# 2. Linting - MUST pass
uv run ruff check
# 3. Formatting - MUST be applied
nix fmt
# 4. Tests - MUST pass
uv run pytestRun all checks in sequence:
uv run basedpyright && uv run ruff check && nix fmt && uv run pytestIf nix fmt changes any files, stage them before committing. The CI runs nix flake check which verifies formatting, linting, and runs Rust tests.
Architecture
Node Composition
A single exo Node (src/exo/main.py) runs multiple components:
- Router: zenoh-based pub/sub messaging via Rust bindings (exo_rs)
- Worker: Handles inference tasks, downloads models, manages runner processes
- Master: Coordinates cluster state, places model instances across nodes
- Election: Bully algorithm for master election
- API: FastAPI server for OpenAI-compatible chat completions
Message Flow
Components communicate via typed pub/sub topics (src/exo/routing/topics.py):
GLOBAL_EVENTS: Master broadcasts indexed events to all workersLOCAL_EVENTS: Workers send events to master for indexingCOMMANDS: Workers/API send commands to masterELECTION_MESSAGES: Election protocol messagesCONNECTION_MESSAGES: zenoh connection updates
Event Sourcing
The system uses event sourcing for state management:
State(src/exo/shared/types/state.py): Immutable state objectapply()(src/exo/shared/apply.py): Pure function that applies events to state- Master indexes events and broadcasts; workers apply indexed events
- The master only keeps recent events. A node that falls further behind (e.g. a late joiner) gets a
StateSnapshotinstead of a replay, so never assume a node has seen every event since the session started
Key Type Hierarchy
src/exo/shared/types/: Pydantic models for all shared typesevents.py: Event types (discriminated union)commands.py: Command typestasks.py: Task types for worker executionstate.py: Cluster state model
Rust Components
Rust code in rust/ provides:
networking: zenoh networking (gossipsub, peer discovery)exo_rs: PyO3 bindings exposing Rust to Pythonsystem_custodian: System-level operations
Dashboard
Svelte 5 + TypeScript frontend in dashboard/. Build output goes to dashboard/build/ and is served by the API.
Code Style Requirements
From .cursorrules:
- Strict, exhaustive typing - never bypass the type-checker
- Use
Literal[...]for enum-like sets,typing.NewTypefor primitives - Pydantic models with
frozen=Trueandstrict=True - Pure functions with injectable effect handlers for side-effects
- Descriptive names - no abbreviations or 3-letter acronyms
- Catch exceptions only where you can handle them meaningfully
- Use
@finaland immutability wherever applicable
Testing
Tests use pytest-asyncio with asyncio_mode = "auto". Tests are in tests/ subdirectories alongside the code they test. The EXO_TESTS=1 env var is set during tests.
Dashboard UI Testing & Screenshots
Building and Running the Dashboard
# Build the dashboard (must be done before running exo)
cd dashboard && npm install && npm run build && cd ..
# Start exo (serves the dashboard at http://localhost:52415)
uv run exo &
sleep 8 # Wait for server to startTaking Headless Screenshots with Playwright
Use Playwright with headless Chromium for programmatic screenshots โ no manual browser interaction needed.
Setup (one-time):
npx --yes playwright install chromium
cd /tmp && npm init -y && npm install playwrightTaking screenshots:
// Run from /tmp where playwright is installed: cd /tmp && node -e "..."
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto('http://localhost:52415', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Inject test data into localStorage if needed (e.g., recent models)
await page.evaluate(() => {
localStorage.setItem('exo-recent-models', JSON.stringify([
{ modelId: 'mlx-community/Qwen3-30B-A3B-4bit', launchedAt: Date.now() },
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Interact with UI elements
await page.locator('text=SELECT MODEL').click();
await page.waitForTimeout(1000);
// Take screenshot
await page.screenshot({ path: '/tmp/screenshot.png', fullPage: false });
await browser.close();
})();Uploading Images to GitHub PRs
GitHub's API doesn't support direct image upload for PR comments. Workaround:
Commit images to the branch (temporarily):
bashcp /tmp/screenshot.png . git add screenshot.png git commit -m "temp: add screenshots for PR" git push origin <branch> COMMIT_SHA=$(git rev-parse HEAD)Post PR comment referencing the raw image URL (uses permanent commit SHA so images survive deletion):
bashgh pr comment <PR_NUMBER> --body ""Remove the images from the branch:
bashgit rm screenshot.png git commit -m "chore: remove temporary screenshot files" git push origin <branch>The images still render in the PR comment because they reference the permanent commit SHA.
File: CONTRIBUTING.md
Contributing to EXO
Thank you for your interest in contributing to EXO!
Getting Started
To run EXO from source:
Prerequisites:
- uv (for Python dependency management)bash
brew install uv - rust (to build Rust bindings, nightly for now)bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup toolchain install nightly - macmon (for hardware monitoring on Apple Silicon)
Use the pinned fork revision used by this repo instead of Homebrewmacmon.bashcargo install --git https://github.com/vladkens/macmon \ --rev a1cd06b6cc0d5e61db24fd8832e74cd992097a7d \ macmon \ --force
git clone https://github.com/exo-explore/exo.git
cd exo/dashboard
npm install && npm run build && cd ..
uv sync --extra mlx
uv run exoDevelopment
EXO is built with a mix of Rust, Python, and TypeScript (Svelte for the dashboard), and the codebase is actively evolving. Before starting work:
- Pull the latest source to ensure you're working with the most recent code
- Keep your changes focused - implement one feature or fix per pull request
- Avoid combining unrelated changes, even if they seem small
This makes reviews faster and helps us maintain code quality as the project evolves.
Code Style
Write pure functions where possible. When adding new code, prefer Rust unless there's a good reason otherwise. Leverage the type systems available to you - Rust's type system, Python type hints, and TypeScript types. Comments should explain why you're doing something, not what the code does - especially for non-obvious decisions.
Run nix fmt to auto-format your code before submitting.
Model Cards
EXO uses TOML-based model cards to define model metadata and capabilities. Model cards are stored in:
resources/inference_model_cards/for text generation modelsresources/image_model_cards/for image generation models~/.exo/custom_model_cards/for user-added custom models
Adding a Model Card
To add a new model, create a TOML file with the following structure:
model_id = "mlx-community/Llama-3.2-1B-Instruct-4bit"
n_layers = 16
hidden_size = 2048
supports_tensor = true
tasks = ["TextGeneration"]
family = "llama"
quantization = "4bit"
base_model = "Llama 3.2 1B"
capabilities = ["text"]
[storage_size]
in_bytes = 729808896Required Fields
model_id: Hugging Face model identifiern_layers: Number of transformer layershidden_size: Hidden dimension sizesupports_tensor: Whether the model supports tensor parallelismtasks: List of supported tasks (TextGeneration,TextToImage,ImageToImage)family: Model family (e.g., "llama", "deepseek", "qwen")quantization: Quantization level (e.g., "4bit", "8bit", "bf16")base_model: Human-readable base model namecapabilities: List of capabilities (e.g.,["text"],["text", "thinking"])
Optional Fields
components: For multi-component models (like image models with separate text encoders and transformers)uses_cfg: Whether the model uses classifier-free guidance (for image models)trust_remote_code: Whether to allow remote code execution (defaults tofalsefor security)
Capabilities
The capabilities field defines what the model can do:
text: Standard text generationthinking: Model supports chain-of-thought reasoningthinking_toggle: Thinking can be enabled/disabled viaenable_thinkingparameterimage_edit: Model supports image-to-image editing (FLUX.1-Kontext)
Security Note
By default, trust_remote_code is set to false for security. Only enable it if the model explicitly requires remote code execution from the Hugging Face hub.
API Adapters
EXO supports multiple API formats through an adapter pattern. Adapters convert API-specific request formats to the internal TextGenerationTaskParams format and convert internal token chunks back to API-specific responses.
Adapter Architecture
All adapters live in src/exo/master/adapters/ and follow the same pattern:
- Convert API-specific requests to
TextGenerationTaskParams - Handle both streaming and non-streaming response generation
- Convert internal
TokenChunkobjects to API-specific formats - Manage error handling and edge cases
Existing Adapters
chat_completions.py: OpenAI Chat Completions APIclaude.py: Anthropic Claude Messages APIresponses.py: OpenAI Responses APIollama.py: Ollama API (for OpenWebUI compatibility)
Adding a New API Adapter
To add support for a new API format:
- Create a new adapter file in
src/exo/master/adapters/ - Implement a request conversion function:python
def your_api_request_to_text_generation( request: YourAPIRequest, ) -> TextGenerationTaskParams: # Convert API request to internal format pass - Implement streaming response generation:python
async def generate_your_api_stream( command_id: CommandId, chunk_stream: AsyncGenerator[TokenChunk | ErrorChunk | ToolCallChunk, None], ) -> AsyncGenerator[str, None]: # Convert internal chunks to API-specific streaming format pass - Implement non-streaming response collection:python
async def collect_your_api_response( command_id: CommandId, chunk_stream: AsyncGenerator[TokenChunk | ErrorChunk | ToolCallChunk, None], ) -> AsyncGenerator[str]: # Collect all chunks and return single response pass - Register the adapter endpoints in
src/exo/master/api.py
The adapter pattern keeps API-specific logic isolated from core inference systems. Internal systems (worker, runner, event sourcing) only see TextGenerationTaskParams and TokenChunk objects - no API-specific types cross the adapter boundary.
For detailed API documentation, see docs/api.md.
Testing
EXO relies heavily on manual testing at this point in the project, but this is evolving. Before submitting a change, test both before and after to demonstrate how your change improves behavior. Do the best you can with the hardware you have available - if you need help testing, ask and we'll do our best to assist. Add automated tests where possible - we're actively working to substantially improve our automated testing story.
Submitting Changes
- Fork the repository
- Create a feature branch (
git checkout -b feature/your-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin feature/your-feature) - Open a Pull Request and follow the PR template
Reporting Issues
If you find a bug or have a feature request, please open an issue on GitHub with:
- A clear description of the problem or feature
- Steps to reproduce (for bugs)
- Expected vs actual behavior
- Your environment (macOS version, hardware, etc.)
Questions?
Join our community:
File: PLATFORMS.md
EXO Platform support (partial roadmap)
Tier 1 support - tested and maintained
Apple Silicon MacOS
- Mac Studio: M3 Ultra
- Mac Mini: M4 Pro
- Macbook Pro: M5, M4 Max
Tier 2 support - checked occasionally, should run without crashing
Tier 3 support - minimal support and testing, but no theoretical reason it shouldnt work
Planned
Tier 1
Linux CUDA Support
- Nvidia DGX Spark
Linux CPU Support
Tier 2
Linux Vulkan Support -- depends heavily on ecosystem
- Framework Desktop
Linux CUDA Support -- depends heavily on ecosystem
- Framework Desktop
Longer term!
Windows CUDA Support
Windows CPU Support
File: RULES.md
Repository Rules
- if you see any code that violates these rules, raise it with me directly rather than trying to fix.
- where applicable, file a GitHub Issue.
- adhere to these rules strictly.
General Rules
- if its possible to eliminate an extra try-catch or if-statement at runtime using type-level discipline, do it!
- name your types, functions, and classes appropriately.
- no three-letter acronyms.
- no non-standard contractions.
- each data type has a meaning, pick a name which is accurate and descriptive.
- the average layman should be able to easily understand what your function does using the function signature alone!
- sometimes, there will be exceptions. eg, when you're using specific technical terms that are well understood (saga, event, etc).
- usually, you'll think that your code is an exception to the rules, but it won't be.
State, Functions and Classes
- every function, given the same inputs, should produce the same outputs. ie, no hidden state.
- use classes to prevent fixed state from being mutated arbitrarily (unsafely); methods provide a safe way of interfacing with state.
- if your logic doesn't mutate fixed state, it probably belongs in a standalone function rather than a class.
- functions shouldn't usually produce side-effects (they should be computationally pure).
- if, for example, you're updating a state using an event (computationally pure), and you want to trigger a saga (computational side-effect), store the logic for triggering the saga into an effect handler (a function, capable of producing side-effects, that you pass into an otherwise computationally pure function, so that it may trigger side-effects safely).
Pydantic
- read the Pydantic docs.
- respect the Pydantic docs.
- pydantic is all you need.
- declare and re-use a central
ConfigDictfor your use-case, you'll usually wantfrozenandstrictto beTrue.
Unique ID (UUID) Generation
- inherit from Pydantic's
UUID4class to create your own UUID class. - use
uuid.uuid4()to initialize your class with a fresh UUID where possible. - ensure that idempotency tags are generated by taking the salted hash of persisted state.
- rationale: if a node crashes and resumes from an older state, it should not accidentally re-publish the same event twice under different idempotency tags.
- every distinct function should feature a unique salt, so that there are no accidental collisions in idempotency tags.
Type Wrappers
- reuse types that already exist in the Python standard library.
- when two distinct data types are structurally identical (for example, different IDs which are both UUIDs but shouldn't never mixed up), make sure they can't be conflated by the type system.
- if you're working with a primitive data type (
str,int, etc), useNewType(it has zero runtime overhead). - if you're working with serializable data objects, consider adding a field (type
str) that states its type.
- if you're working with a primitive data type (
Type Discipline
- do not bypass the type-checker, preserve strict typing by any means necessary.
- by default, use literal types (like
Literal['one', 'two']) where an enum seems appropriate.
pro-tip: Python's type system is quite complex and feature-rich, so reading the documentation is often advisable; Matt discovered that Python typing library allows you to check that you've implemented a match exhaustively using Literal and get_args(type) after reading the docs.
Use of `@final`, Freezing
- use wherever applicable.
Error Handling
- don't try-catch for no reason.
- make sure that you always know where and when the exceptions your code produces are meant to be handled, so that it's never a nasty surprise.
- always write the rationale for your error-handling down in the docstring!
- communicate the details to your colleagues when appropriate.
Dependencies
- don't introduce any new dependencies without asking.
- don't ask for any dependencies that aren't ubiquitous within production environments.
Commit Messages
- use the imperative mood in the subject line.
- prefix the subject line with a change type. our change types are:
documentation: documentation changes.feature: a new feature.refactor: a code change that neither fixes a bug nor adds a feature.bugfix: a bug fix.chore: routine tasks, maintenance, or tooling changes.test: adding or correcting tests.
- restrict the subject line to fifty characters or less.
- capitalize the subject line.
- do not end the subject line with a period.
- separate subject from body with a blank line.
File: TODO.md
EXO_BOOTSTRAP_PEERS is currently broken
I'd like to see profiled network latency / bandwidth.
I'd like to see how much bandwidth each link is using.
Solve the problem of in continuous batching when a new prompt comes in, it will block decode of the current batch until the prefill is complete.
We want people to be able to copy models over to a new device without ever connecting EXO to the internet. Right now EXO require internet connection once to cache some files to check if a download is complete. Instead, we should simply check if there is a non-empty model folder locally with no .partial files. This indicates it's a fully downloaded model that can be loaded.
Memory pressure instead of memory used.
Show the type of each connection (TB5, Ethernet, etc.) in the UI. Refer to old exo: https://github.com/exo-explore/exo/blob/56f783b38dc6b08ce606b07a5386dc40dae00330/exo/helpers.py#L251
Dynamically switch to higher priority connection when it becomes available. Probably bring back InstanceReplacedAtomically.
Faster model loads by streaming model from other devices in cluster.
Add support for specifying the type of network connection to use in a test. Depends on 15/16.
Log cleanup - per-module log filters and default to DEBUG log levels
Validate RDMA connections with ibv_devinfo in the info gatherer
File: app/EXO/EXO/Assets.xcassets/Contents.json
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File: app/EXO/EXO/Assets.xcassets/AccentColor.colorset/Contents.json
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
File: app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json
{
"images" : [
{
"filename" : "16-mac.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "32-mac.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "32-mac 1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "64-mac.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "128-mac.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "256-mac 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "256-mac.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "512-mac.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "512-mac 1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "1024-mac.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
File: app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json
{
"images" : [
{
"filename" : "exo-logo-hq-square-transparent-bg.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
File: app/EXO/EXO/Preview Content/Preview Assets.xcassets/Contents.json
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File: bench/METHODOLOGY.md
Exo-Bench โ Methodology
exo bench measures inference throughput and resource consumption of an exo cluster under controlled conditions. It sends prompts to the /bench/chat/completions endpoint, collects server-reported timing statistics, and records system-level metrics (power, GPU utilisation, temperature) throughout each run.
The goal is to have accurate, transparent and reproducible numbers to compare speed and scaling across different models and different setups, and to be able to track these results as optimizations and features are added to EXO.
Below is a technical summary of how Exo-Bench works. While the methodology and benchmark may change over time, this document will be kept up to date whenever this happens. If you find an issue with the methodology, or would like a feature to be added, please open a GitHub issue!
Prompt Construction
Benchmarks need prompts of an exact token length. Unfortunately, we do not have direct access to the model but just the chat completion endpoint. To get around this fact, we create a request that will tokenise to a certain prompt length.
This is achieved by:
- Tokenising a sample message through the model's
apply_chat_template()to measure overhead (system tokens, special tokens, chat formatting). - Binary-searching over a repeated atom string (default
"a ") to find the content length that produces exactly the target number of tokens after template expansion. - Returning both the content string and the verified token count.
The actual token count is recorded in every result row as pp_tokens, so downstream analysis can confirm the prompt hit its target.
Chat template formatting means that it may be impossible to attain very small pp benchmarks. e.g. pp=32 may not work. This tradeoff was made because the result of such a small prompt does not seem very interesting or useful for any real-world use cases.
Bench Endpoint
When a request reaches the server via the /bench/chat/completions endpoint, three things change compared to a normal chat completion:
- KV prefix cache is disabled by default. Every request starts from a cold cache, ensuring prefill timing is not affected by prior requests. See Prefix Cache Mode for the
--use-prefix-cacheoption. - EOS tokens are banned. A logits processor suppresses all end-of-sequence tokens, forcing the model to generate exactly
max_tokenstokens. This guarantees consistent generation length for fair TPS comparison โ the model cannot short-circuit a run by stopping early. - No model output parsing. The bench collection path concatenates raw token text without any model-specific post-processing (thinking tag extraction, structured output handling, etc.). This is to avoid model outputs such as tool parsing or any structural mistakes from breaking the benchmark - we are testing for speed; see Exo-Eval for performance metrics.
Timing
Prefill TPS
Measured server-side per task.
prefill_tps = num_prompt_tokens / prefill_wall_secondsGeneration TPS
Measured server-side per task. Each task records wall-clock timestamps as tokens arrive:
- First generated token: timestamp recorded
- Every subsequent token: timestamp updated
When generation completes:
gen_span = last_token_time - first_token_time
generation_tps = (completion_tokens - 1) / gen_spanThe first token is excluded from the numerator because the rate measures inter-token throughput โ the time between the first and last token divided by the number of intervals.
This does mean that tg=1 will not work.
Concurrency
Single Request
The client records wall-clock elapsed_s around the HTTP round-trip (network latency + server prefill + generation + response serialisation). This is a convenience metric for end-to-end latency. The authoritative TPS numbers come from the server-side per-task timing in the generation_stats response.
Concurrent Requests
When --concurrency N is set with N > 1, all N requests must hit the server at the same instant. The mechanism:
- The prompt is built once and shared across all threads.
- Each thread gets its own HTTP connection.
- A thread barrier blocks all threads until every thread is ready.
- The first thread past the barrier records the batch start time and signals the others.
- All threads use the same start time as their reference, then fire their HTTP request.
- Each thread's
elapsed_sis measured from the shared start time to its own response completion.
Batch wall time is the maximum elapsed_s across all N requests โ the time until the last request finishes.
Aggregate TPS
per_req_tps = max(generation_tps across N concurrent requests)
agg_gen_tps = per_req_tps * concurrencymax is used instead of mean because all requests run in parallel against the same model. The fastest request's generation rate represents the system's per-stream throughput capacity; multiplying by concurrency gives aggregate throughput.
Prefix Cache Mode
When --use-prefix-cache is passed, the KV prefix cache remains active during benchmarking. This speeds up repeated runs by skipping redundant prefill work, which is useful when prompt processing is not the focus of the benchmark (e.g. when measuring generation throughput or power consumption across many configurations).
Each response includes a prefix_cache_hit field ("none", "partial", or "exact"):
- none: Cold prefill โ no cached KV state was available. The reported
prompt_tpsis the real prefill throughput. - partial: A prefix of the prompt was found in cache. Only the remaining tokens were prefilled. The reported
prompt_tpsreflects the real throughput on the uncached portion. This occurs when multiple ascending--ppvalues share a common prefix (e.g.--pp 1000,5000โ the 5000-token prompt reuses the 1000-token cache entry and prefills the remaining 4000 tokens). - exact: The entire prompt was found in cache (e.g. same
--ppvalue on a--repeat). No prefill work was done. The reportedprompt_tpsis the TPS from when the cache entry was originally created, not a new measurement.
Prompt TPS is approximate in this mode. Exact-hit runs report the stored TPS from the original cold/partial prefill rather than a freshly measured value. For accurate cold prefill numbers, run without --use-prefix-cache.
Ascending --pp order (e.g. --pp 1000,5000,10000) gives the most useful data: each size gets a meaningful partial hit except the first which is cold. Descending order produces exact hits with approximate TPS from a longer prompt's original run.
Warmup
Before measurement begins, --warmup N (default: 0) discarded requests are sent using the first pp/tg pair. Warmup results are not included in the output.
System Metrics
A background thread polls each node at 1 Hz, collecting:
- GPU utilisation (%)
- Temperature (C)
- System power draw (W)
- CPU cluster usage (performance and efficiency cores)
Energy is computed via trapezoidal integration of the power samples over each inference window (the wall-clock span of each benchmark request or concurrent batch). Average power is total_joules / total_inference_seconds. The server additionally returns a power_usage block in each non-stream /bench/chat/completions response that splits energy into prefill and generation phases, with the boundary anchored to the first non-PrefillProgressChunk from the runner.
Output Format
Results are written as JSON with three top-level keys:
runs: Array of per-request result objects, each containing:elapsed_s,output_text_preview(first 200 chars)stats:{ prompt_tps, generation_tps, prompt_tokens, generation_tokens, peak_memory_usage }power_usage: server-side total + prefill/generation split, per-node breakdown (non-stream requests only)- Placement metadata:
model_id,placement_sharding,placement_instance_meta,placement_nodes - Run metadata:
pp_tokens,tg,repeat_index,concurrency,concurrent_index download_duration_s(if model was freshly downloaded)
cluster: Cluster state snapshot at time of benchmarking.system_metrics: Per-node time-series samples (GPU, power, temperature).
Reproducing Results
cd bench && uv run python exo_bench.py \
--model "mlx-community/Qwen3.5-27B-4bit" \
--instance-meta jaccl \
--sharding tensor \
--min-nodes 2 --max-nodes 2 \
--pp 512 4096 --tg 128 \
--repeat 3 \
--warmup 1Run --help for all the available flags.
File: dashboard/package.json
{
"name": "exo-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/d3": "^7.4.3",
"@types/node": "^22",
"d3": "^7.9.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"devalue": "^5.6.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"marked": "^17.0.1",
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.6.205"
}
}
File: dashboard/tsconfig.json
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
File: docs/api.md
EXO API โ Technical Reference
This document describes the REST API exposed by the EXO service, as implemented in:
src/exo/api/main.py (request and response types are in src/exo/api/types/)
The API is used to manage model instances in the cluster, inspect cluster state, and perform inference using multiple API-compatible interfaces.
Base URL example:
http://localhost:524151. General / Meta Endpoints
Get Master Node ID
GET /node_id
Returns the identifier of the current master node.
Response (example):
{
"node_id": "node-1234"
}Get Cluster State
GET /state
Returns the current state of the cluster, including nodes and active instances.
Response:
JSON object describing topology, nodes, and instances.
Get Events
GET /events
Returns the most recent internal events this node has applied, up to 10,000 of them (mainly for debugging and observability). Every generated token is an event, so under load this covers the last few seconds to minutes. A node that joined late, or fell far behind, catches up from a state snapshot, so it only has the events after that point.
Response:
Array of event objects.
2. Model Instance Management
Create Instance
POST /instance
Creates a new model instance in the cluster.
Request body (example):
{
"instance": { ... }
}The instance is a placement as returned by /instance/previews (each preview'sinstance field), for example the first preview without an error.
Response:
Command acknowledgement. Instance creation is asynchronous; clients should wait
for the model to appear through /instance/await before sending inference
requests for that model.
Delete Instance
DELETE /instance/{instance_id}
Deletes an existing instance by ID.
Path parameters:
instance_id: string, ID of the instance to delete
Response:
Status / confirmation JSON.
Get Instance
GET /instance/{instance_id}
Returns details of a specific instance.
Path parameters:
instance_id: string
Response:
JSON description of the instance.
Await Instance
GET /instance/await?model_id=...&timeout_seconds=0
Waits until API state contains an instance for the requested model. The response
is an SSE stream so clients receive keep-alive comments while waiting.
Query parameters:
model_id: string, requiredtimeout_seconds: float, optional, default0.0waits indefinitely;
positive values time out after that many seconds. Maximum positive value:300.
Stream messages:
data: {"type": "ready", "instance": {...}}
data: {"type": "timeout", "message": "No instance found for model ..."}The HTTP status is 200 for both messages because the stream starts before the
final result is known. The type field disambiguates the terminal message.
Preview Placements
GET /instance/previews?model_id=...
Returns possible placement previews for a given model.
Query parameters:
model_id: string, required
Response:
Array of placement preview objects.
Compute Placement
GET /instance/placement
Computes a placement for a potential instance without creating it.
Query parameters (typical):
model_id: stringsharding: string or configinstance_meta: JSON-encoded metadatamin_nodes: integer
Response:
JSON object describing the proposed placement / instance configuration.
Place Instance
POST /place_instance
Places an instance for a model using the server's placement logic.
Request body:
JSON describing the instance to be placed.
Response:
Command acknowledgement. The instance may not be ready immediately; wait for it
to appear through /instance/await before sending inference requests.
3. Models
List Models
GET /models
GET /v1/models (alias)
Returns the list of available models and their metadata.
Query parameters:
status: string (optional) - Filter bydownloadedto show only downloaded models
Response:
Array of model descriptors including is_custom field for custom HuggingFace models.
Add Custom Model
POST /models/add
Add a custom model from HuggingFace hub.
Request body (example):
{
"model_id": "mlx-community/my-custom-model"
}Response:
Model descriptor for the added model.
Security note:
Models with trust_remote_code enabled in their configuration require explicit opt-in (default is false) for security.
Delete Custom Model
DELETE /models/custom/{model_id}
Delete a user-added custom model card.
Path parameters:
model_id: string, ID of the custom model to delete
Response:
Confirmation JSON with deleted model ID.
Search Models
GET /models/search
Search HuggingFace Hub for mlx-community models.
Query parameters:
query: string (optional) - Search querylimit: integer (default: 20) - Maximum number of results
Response:
Array of HuggingFace model search results.
4. Inference / Chat Completions
OpenAI-Compatible Chat Completions
POST /v1/chat/completions
Executes a chat completion request using an OpenAI-compatible schema. Supports streaming and non-streaming modes.
Request body (example):
{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello" }
],
"stream": false
}Request parameters:
model: string, required - Model ID to usemessages: array, required - Conversation messagesstream: boolean (default: false) - Enable streaming responsesmax_tokens: integer (optional) - Maximum tokens to generatetemperature: float (optional) - Sampling temperaturetop_p: float (optional) - Nucleus sampling parametertop_k: integer (optional) - Top-k sampling parameterstop: string or array (optional) - Stop sequencesseed: integer (optional) - Random seed for reproducibilityenable_thinking: boolean (optional) - Enable thinking mode for capable models (DeepSeek V3.1, Qwen3, GLM-4.7)tools: array (optional) - Tool definitions for function callinglogprobs: boolean (optional) - Return log probabilitiestop_logprobs: integer (optional) - Number of top log probabilities to return
Response:
OpenAI-compatible chat completion response.
Streaming response format:
When stream=true, returns Server-Sent Events (SSE) with format:
data: {"id":"...","object":"chat.completion","created":...,"model":"...","choices":[...]}
data: [DONE]Non-streaming response includes usage statistics:
{
"id": "...",
"object": "chat.completion",
"created": 1234567890,
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 15,
"completion_tokens": 8,
"total_tokens": 23
}
}Cancellation:
You can cancel an active generation by closing the HTTP connection. The server detects the disconnection and stops processing.
Claude Messages API
POST /v1/messages
Executes a chat completion request using the Claude Messages API format. Supports streaming and non-streaming modes.
Request body (example):
{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"messages": [
{ "role": "user", "content": "Hello" }
],
"max_tokens": 1024,
"stream": false
}Streaming response format:
When stream=true, returns Server-Sent Events with Claude-specific event types:
message_start- Message generation startedcontent_block_start- Content block startedcontent_block_delta- Incremental content chunkcontent_block_stop- Content block completedmessage_delta- Message metadata updatesmessage_stop- Message generation completed
Response:
Claude-compatible messages response.
OpenAI Responses API
POST /v1/responses
Executes a chat completion request using the OpenAI Responses API format. Supports streaming and non-streaming modes.
Request body (example):
{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"input": [
{ "role": "user", "content": "Hello" }
],
"stream": false
}Request parameters:
model: string, required - Model ID to useinput: string or array, required - The prompt, or a list of input items such as messagesinstructions: string (optional) - System instructionsmax_output_tokens: integer (optional) - Maximum tokens to generate
Streaming response format:
When stream=true, returns Server-Sent Events with response-specific event types:
response.created- Response generation startedresponse.in_progress- Response is being generatedresponse.output_item.added- New output item addedresponse.output_text.delta- Incremental text chunkresponse.output_item.done- Output item completedresponse.completed- Response generation completed
Response:
OpenAI Responses API-compatible response.
Benchmarked Chat Completions
POST /bench/chat/completions
Same as /v1/chat/completions, but also returns performance and generation statistics.
Request body:
Same schema as /v1/chat/completions.
Response:
Chat completion plus benchmarking metrics including:
prompt_tps- Tokens per second during prompt processinggeneration_tps- Tokens per second during generationprompt_tokens- Number of prompt tokensgeneration_tokens- Number of generated tokenspeak_memory_usage- Peak memory used during generation
Cancel Command
POST /v1/cancel/{command_id}
Cancels an active generation command (text or image). Notifies workers and closes the stream.
Path parameters:
command_id: string, ID of the command to cancel
Response (example):
{
"message": "Command cancelled.",
"command_id": "cmd-abc-123"
}Returns 404 if the command is not found or already completed.
5. Ollama API Compatibility
EXO provides Ollama API compatibility for tools like OpenWebUI.
Ollama Chat
POST /ollama/api/chat
POST /ollama/api/api/chat (alias)
POST /ollama/api/v1/chat (alias)
Execute a chat request using Ollama API format.
Request body (example):
{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"messages": [
{ "role": "user", "content": "Hello" }
],
"stream": false
}Response:
Ollama-compatible chat response.
Ollama Generate
POST /ollama/api/generate
Execute a text generation request using Ollama API format.
Request body (example):
{
"model": "mlx-community/Llama-3.2-1B-Instruct-4bit",
"prompt": "Hello",
"stream": false
}Response:
Ollama-compatible generation response.
Ollama Tags
GET /ollama/api/tags
GET /ollama/api/api/tags (alias)
GET /ollama/api/v1/tags (alias)
Returns list of downloaded models in Ollama tags format.
Response:
Array of model tags with metadata.
Ollama Show
POST /ollama/api/show
Returns model information in Ollama show format.
Request body:
{
"name": "mlx-community/Llama-3.2-1B-Instruct-4bit"
}Response:
Model details including modelfile and family.
Ollama PS
GET /ollama/api/ps
Returns list of running models (active instances).
Response:
Array of active model instances.
Ollama Version
GET /ollama/api/version
HEAD /ollama/ (alias)
HEAD /ollama/api/version (alias)
Returns version information for Ollama API compatibility.
Response:
{
"version": "exo v1.0"
}6. Image Generation & Editing
Image Generation
POST /v1/images/generations
Executes an image generation request using an OpenAI-compatible schema with additional advanced_params. Supports both streaming and non-streaming modes.
Request body (example):
{
"prompt": "a robot playing chess",
"model": "exolabs/FLUX.1-dev",
"n": 1,
"size": "1024x1024",
"stream": false,
"response_format": "b64_json"
}Request parameters:
prompt: string, required - Text description of the imagemodel: string, required - Image model IDn: integer (default: 1) - Number of images to generatesize: string (default: "auto") - Image dimensions. Supported sizes:512x512768x7681024x768768x10241024x10241024x15361536x10241024x13651365x1024
stream: boolean (default: false) - Enable streaming for partial imagespartial_images: integer (default: 0) - Number of partial images to stream during generationresponse_format: string (default: "b64_json") - Eitherurlorb64_jsonquality: string (default: "medium") - Eitherhigh,medium, orlowoutput_format: string (default: "png") - Eitherpng,jpeg, orwebpadvanced_params: object (optional) - Advanced generation parameters
Advanced Parameters (advanced_params):
| Parameter | Type | Constraints | Description |
|---|---|---|---|
seed |
int | >= 0 | Random seed for reproducible generation |
num_inference_steps |
int | 1-100 | Number of denoising steps |
guidance |
float | 1.0-20.0 | Classifier-free guidance scale |
negative_prompt |
string | - | Text describing what to avoid in the image |
Non-streaming response:
{
"created": 1234567890,
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAA...",
"url": null
}
]
}Streaming response format:
When stream=true and partial_images > 0, returns Server-Sent Events:
data: {"type":"partial","image_index":0,"partial_index":1,"total_partials":5,"format":"png","data":{"b64_json":"..."}}
data: {"type":"final","image_index":0,"format":"png","data":{"b64_json":"..."}}
data: [DONE]Image Editing
POST /v1/images/edits
Executes an image editing request (img2img) using FLUX.1-Kontext-dev or similar models.
Request (multipart/form-data):
image: file, required - Input image to editprompt: string, required - Text description of desired changesmodel: string, required - Image editing model ID (e.g.,exolabs/FLUX.1-Kontext-dev)n: integer (default: 1) - Number of edited images to generatesize: string (optional) - Output image dimensionsresponse_format: string (default: "b64_json") - Eitherurlorb64_jsoninput_fidelity: string (default: "low") - Eitherloworhigh- Controls how closely the output follows the input imagestream: string (default: "false") - Enable streamingpartial_images: string (default: "0") - Number of partial images to streamquality: string (default: "medium") - Eitherhigh,medium, orlowoutput_format: string (default: "png") - Eitherpng,jpeg, orwebpadvanced_params: string (optional) - JSON-encoded advanced parameters
Response:
Same format as /v1/images/generations.
Benchmarked Image Generation
POST /bench/images/generations
Same as /v1/images/generations, but also returns generation statistics.
Request body:
Same schema as /v1/images/generations.
Response:
Image generation plus benchmarking metrics including:
seconds_per_step- Average time per denoising steptotal_generation_time- Total generation timenum_inference_steps- Number of inference steps usednum_images- Number of images generatedimage_width- Output image widthimage_height- Output image heightpeak_memory_usage- Peak memory used during generation
Benchmarked Image Editing
POST /bench/images/edits
Same as /v1/images/edits, but also returns generation statistics.
Request:
Same schema as /v1/images/edits.
Response:
Same format as /bench/images/generations, including generation_stats.
List Images
GET /images
List all stored images.
Response:
Array of image metadata including URLs and expiration times.
Get Image
GET /images/{image_id}
Retrieve a stored image by ID.
Path parameters:
image_id: string, ID of the image
Response:
Image file with appropriate content type.
7. Complete Endpoint Summary
# General
GET /node_id
GET /state
GET /events
# Instance Management
POST /instance
GET /instance/await
GET /instance/previews
GET /instance/placement
GET /instance/{instance_id}
DELETE /instance/{instance_id}
POST /place_instance
# Models
GET /models
GET /v1/models
POST /models/add
DELETE /models/custom/{model_id}
GET /models/search
# Text Generation (OpenAI Chat Completions)
POST /v1/chat/completions
POST /bench/chat/completions
# Text Generation (Claude Messages API)
POST /v1/messages
# Text Generation (OpenAI Responses API)
POST /v1/responses
# Text Generation (Ollama API)
POST /ollama/api/chat
POST /ollama/api/api/chat
POST /ollama/api/v1/chat
POST /ollama/api/generate
GET /ollama/api/tags
GET /ollama/api/api/tags
GET /ollama/api/v1/tags
POST /ollama/api/show
GET /ollama/api/ps
GET /ollama/api/version
HEAD /ollama/
HEAD /ollama/api/version
# Command Control
POST /v1/cancel/{command_id}
# Image Generation
POST /v1/images/generations
POST /bench/images/generations
POST /v1/images/edits
POST /bench/images/edits
GET /images
GET /images/{image_id}8. Notes
API Compatibility
EXO provides multiple API-compatible interfaces:
- OpenAI Chat Completions API - Compatible with OpenAI clients and tools
- Claude Messages API - Compatible with Anthropic's Claude API format
- OpenAI Responses API - Compatible with OpenAI's Responses API format
- Ollama API - Compatible with Ollama and tools like OpenWebUI
Existing OpenAI, Claude, or Ollama clients can be pointed to EXO by changing the base URL.
Custom Models
You can add custom models from HuggingFace using the /models/add endpoint. Custom models are identified by the is_custom field in model list responses.
Security: Models requiring trust_remote_code must be explicitly enabled (default is false) for security. Only enable this if you trust the model's remote code.
Usage Statistics
Chat completion responses include usage statistics with:
prompt_tokens- Number of tokens in the promptcompletion_tokens- Number of tokens generatedtotal_tokens- Sum of prompt and completion tokens
Request Cancellation
You can cancel active requests by:
- Closing the HTTP connection (for streaming requests)
- Calling
/v1/cancel/{command_id}(for any request)
The server detects cancellation and stops processing immediately.
Instance Placement
The instance placement endpoints allow you to plan and preview cluster allocations before creating instances. This helps optimize resource usage across nodes.
Observability
The /events and /state endpoints are primarily intended for operational visibility and debugging.
File: nix/apple-sdk/metadata/versions.json
{
"14": {
"urls": [
"https://swcdn.apple.com/content/downloads/14/48/052-59890-A_I0F5YGAY0Y/p9n40hio7892gou31o1v031ng6fnm9sb3c/CLTools_macOSNMOS_SDK.pkg",
"https://web.archive.org/web/20250211001355/https://swcdn.apple.com/content/downloads/14/48/052-59890-A_I0F5YGAY0Y/p9n40hio7892gou31o1v031ng6fnm9sb3c/CLTools_macOSNMOS_SDK.pkg"
],
"version": "14.4",
"hash": "sha256-QozDiwY0Czc0g45vPD7G4v4Ra+3DujCJbSads3fJjjM="
},
"15": {
"urls": [
"https://swcdn.apple.com/content/downloads/52/01/082-41241-A_0747ZN8FHV/dectd075r63pppkkzsb75qk61s0lfee22j/CLTools_macOSNMOS_SDK.pkg",
"https://web.archive.org/web/20250530132510/https://swcdn.apple.com/content/downloads/52/01/082-41241-A_0747ZN8FHV/dectd075r63pppkkzsb75qk61s0lfee22j/CLTools_macOSNMOS_SDK.pkg"
],
"version": "15.5",
"hash": "sha256-HBiSJuw1XBUK5R/8Sj65c3rftSEvQl/O9ZZVp/g1Amo="
},
"26": {
"urls": [
"https://swcdn.apple.com/content/downloads/60/22/089-71960-A_W8BL1RUJJ6/5zkyplomhk1cm7z6xja2ktgapnhhti6wwd/CLTools_macOSNMOS_SDK.pkg",
"https://web.archive.org/web/20250915230423/https://swcdn.apple.com/content/downloads/60/22/089-71960-A_W8BL1RUJJ6/5zkyplomhk1cm7z6xja2ktgapnhhti6wwd/CLTools_macOSNMOS_SDK.pkg"
],
"version": "26.2",
"hash": "sha256-hXRlMieVv0smna5uiWRwq87IWOaPWtAjAldbi+wQXcw="
}
}
File: rust/exo_rs/README.md
TODO: do something here....
File: tmp/prompt.txt
Summarise this Wikipedia article for me:
Transition from Republic to Empire
Augustus of Prima Porta
Rome had begun expanding shortly after the founding of the Roman Republic in the 6th century BC, though not outside the Italian Peninsula until the 3rd century BC. The Republic was not a nation-state in the modern sense, but a network of self-ruled towns (with varying degrees of independence from the Senate) and provinces administered by military commanders. It was governed by annually elected magistrates (Roman consuls above all) in conjunction with the Senate.[22] The 1st century BC was a time of political and military upheaval, which ultimately led to rule by emperors.[23][24][25] The consuls' military power rested in the Roman legal concept of imperium, meaning "command" (typically in a military sense).[26] Occasionally, successful consuls or generals were given the honorary title imperator (commander); this is the origin of the word emperor, since this title was always bestowed to the early emperors.[27][g]
Rome suffered a long series of internal conflicts, conspiracies, and civil wars from the late second century BC (see Crisis of the Roman Republic) while greatly extending its power beyond Italy. In 44 BC Julius Caesar was briefly perpetual dictator before being assassinated by a faction that opposed his concentration of power. This faction was driven from Rome and defeated at the Battle of Philippi in 42 BC by Mark Antony and Caesar's adopted son Octavian. Antony and Octavian divided the Roman world between them, but this did not last long. Octavian's forces defeated those of Mark Antony and Cleopatra at the Battle of Actium in 31 BC. In 27 BC the Senate gave him the title Augustus ("venerated") and made him princeps ("foremost") with proconsular imperium, thus beginning the Principate, the first epoch of Roman imperial history. Although the republic stood in name, Augustus had all meaningful authority.[29] During his 40-year rule, a new constitutional order emerged so that, upon his death, Tiberius would succeed him as the new de facto monarch.[30]
Pax Romana
Main article: Pax Romana
The so-called "Five Good Emperors" of 96โ180 AD
Nerva (r.โ96โ98)
Trajan (r.โ98โ117)
Hadrian (r.โ117โ138)
Antoninus Pius (r.โ138โ161)
Marcus Aurelius (r.โ161โ180)
The 200 years that began with Augustus's rule are traditionally regarded as the Pax Romana ("Roman Peace"). The cohesion of the empire was furthered by a degree of social stability and economic prosperity that Rome had never before experienced. Uprisings in the provinces were infrequent and put down "mercilessly and swiftly".[31] The success of Augustus in establishing principles of dynastic succession was limited by his outliving a number of talented potential heirs. The Julio-Claudian dynasty lasted for four more emperorsโTiberius, Caligula, Claudius, and Neroโbefore it yielded in 69 AD to the strife-torn Year of the Four Emperors, from which Vespasian emerged as the victor. Vespasian became the founder of the brief Flavian dynasty, followed by the NervaโAntonine dynasty which produced the "Five Good Emperors": Nerva, Trajan, Hadrian, Antoninus Pius, and Marcus Aurelius.[32]
Among the so-called โFive Good Emperors,โ Hadrian (r. 117โ138) is particularly noted for consolidating the empireโs frontiers and embarking on ambitious building projects throughout the provinces.[33] In Judaea, which had long been the center of Jewish national and religious life, his reign marked a decisive turning point. After earlier Jewish resistance to Roman rule, Hadrian visited the region in 129/130 CE and refounded Jerusalem as the Roman colony Aelia Capitolina, naming it after his family (Aelius) and the Capitoline Triad.[34] The refoundation overlaid the destroyed Jewish city with a new Roman urban plan, and included the construction of a Temple to Jupiter on the site of the former Jewish Temple.[35] Later tradition and archaeological evidence also indicate a Temple of Venus near the site of the Holy Sepulchre.[36]
Hadrianโs measures, combined with restrictions on Jewish practices, helped spark the Bar Kokhba Revolt (132โ135 CE). After crushing the uprising, Roman forces expelled most Jews from Jerusalem, barring their entry except on certain days, and rebuilt the city as a statement of imperial power and domination.[33] Most scholars consider Hadrianic Aelia to have been unwalled, with free-standing gate complexes (such as the northern gate beneath todayโs Damascus Gate) rather than a continuous defensive circuit.[37]
Transition from classical to late antiquity
Main articles: Later Roman Empire and Fall of the Western Roman Empire
See also: Barbarian kingdoms and Byzantine Empire
The Barbarian invasions consisted of the movement of (mainly) ancient Germanic peoples into Roman territory. Historically, this event marked the transition between classical antiquity and the Middle Ages.
In the view of contemporary Greek historian Cassius Dio, the accession of Commodus in 180 marked the descent "from a kingdom of gold to one of rust and iron",[38] a comment which has led some historians, notably Edward Gibbon, to take Commodus' reign as the beginning of the Empire's decline.[39][40]
In 212, during the reign of Caracalla, Roman citizenship was granted to all freeborn inhabitants of the empire. The Severan dynasty was tumultuous; an emperor's reign was ended routinely by his murder or execution and, following its collapse, the Empire was engulfed by the Crisis of the Third Century, a period of invasions, civil strife, economic disorder, and plague.[41] In defining historical epochs, this crisis sometimes marks the transition from Classical to Late Antiquity. Aurelian (r.โ270โ275) stabilised the empire militarily and Diocletian reorganised and restored much of it in 285.[42] Diocletian's reign brought the empire's most concerted effort against the perceived threat of Christianity, the "Great Persecution".[43]
Diocletian divided the empire into four regions, each ruled by a separate tetrarch.[44] Confident that he fixed the disorder plaguing Rome, he abdicated along with his co-emperor, but the Tetrarchy collapsed shortly after. Order was eventually restored by Constantine the Great, who became the first emperor to convert to Christianity, and who established Constantinople as the new capital of the Eastern Empire. During the decades of the Constantinian and Valentinian dynasties, the empire was divided along an eastโwest axis, with dual power centres in Constantinople and Rome. Julian, who under the influence of his adviser Mardonius attempted to restore Classical Roman and Hellenistic religion, only briefly interrupted the succession of Christian emperors. Theodosius I, the last emperor to rule over both East and West, died in 395 after making Christianity the state religion.[45]
The Roman Empire by 476, noting western and eastern divisions
The administrative divisions of the Roman Empire in 395 AD
Fall in the West and survival in the East
The Western Roman Empire began to disintegrate in the early 5th century. The Romans fought off all invaders, most famously Attila,[46] but the empire had assimilated so many Germanic peoples of dubious loyalty to Rome that the empire started to dismember itself.[47] Most chronologies place the end of the Western Roman Empire in 476, when Romulus Augustulus was forced to abdicate to the Germanic warlord Odoacer.[48][49][50]
Odoacer ended the Western Empire by declaring Zeno sole emperor and placing himself as Zeno's nominal subordinate. In reality, Italy was ruled by Odoacer alone.[48][49][51] The Eastern Roman Empire, called the Byzantine Empire by later historians, continued until the reign of Constantine XI Palaiologos, the last Roman emperor. He died in battle in 1453 against Mehmed II and his Ottoman forces during the siege of Constantinople. Mehmed II adopted the title of caesar in an attempt to claim a connection to the former Empire.[52][53] His claim was soon recognized by the Patriarchate of Constantinople, but not by European monarchs.
File: tmp/config_examples/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"model": "exo/mlx-community/gpt-oss-120b-MXFP4-Q8",
"provider": {
"exo": {
"api": "http://localhost:52415/v1",
"models": {
"mlx-community/gpt-oss-120b-MXFP4-Q8": {
"name": "GPT OSS 120B",
"limit": {
"context": 32768,
"output": 8192
}
}
}
}
}
}
File: .github/pull_request_template.md
Motivation
Changes
Why It Works
Test Plan
Manual Testing
Automated Testing
File: .github/actions/conditional-commit/action.yml
name: Commit if changed
inputs:
message:
required: true
runs:
using: composite
steps:
- name: Commit changed files
shell: bash
run: |
git diff --quiet && exit 0
git commit -am "${{ inputs.message }}"
File: .github/actions/format/action.yml
name: Format Code
runs:
using: "composite"
steps:
- name: Format code
run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just fmt
shell: bash
File: .github/actions/lint/action.yml
name: Lint Code
runs:
using: "composite"
steps:
- name: Lint code
run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just lint
shell: bash
File: .github/actions/lint-check/action.yml
name: Lint Check
runs:
using: "composite"
steps:
- name: Lint check
run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just lint-check
shell: bash
File: .github/actions/regenerate-protobufs/action.yml
name: Regenerate Protobufs
runs:
using: "composite"
steps:
- name: Regenerate protobufs
run: nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just regenerate-protobufs
shell: bash
File: .github/actions/setup-python-uv/action.yml
name: Setup Python & uv
runs:
using: "composite"
steps:
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install Python
run: uv python install
shell: bash
- name: Sync
run: uv sync --locked --all-extras --dev
shell: bashFile: .github/actions/unit-test/action.yml
name: Unit Test
runs:
using: "composite"
steps:
- name: Run unit tests
run: |
nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just sync-clean
nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just test-fast
shell: bash
File: .github/actions/verify-clean/action.yml
name: Verify Clean Working Tree
inputs:
step:
required: true
runs:
using: composite
steps:
- name: Check git diff
shell: bash
run: |
if ! git diff --quiet; then
echo "Error: ${{ inputs.step }} left working tree dirty." >&2
git --no-pager diff >&2
exit 1
fi
File: .github/ISSUE_TEMPLATE/bug_report.md
name: Bug Report about: Create a report to help us improve title: '[BUG] ' labels: bug assignees: ''
Describe the bug
A clear and concise description of what the bug is.
To Reproduce
Steps to reproduce the behavior:
1.
2.
3.
Expected behavior
A clear and concise description of what you expected to happen.
Actual behavior
A clear and concise description of what actually happened.
Environment
- macOS Version:
- EXO Version:
- Hardware:
- Device 1: (e.g., MacBook Pro M1 Max, 32GB RAM)
- Device 2: (e.g., Mac Mini M2, 16GB RAM)
- Additional devices:
- Interconnection:
- (e.g., Thunderbolt 4 cable between Device 1 and 2)
- (e.g., WiFi 6 for Device 3)
- (e.g., 10GbE Ethernet between all devices)
Additional context
Add any other context about the problem here.
File: .github/ISSUE_TEMPLATE/feature_request.md
name: Feature Request about: Suggest an idea for this project title: '' labels: enhancement assignees: ''
Describe what you'd like to see added to EXO.
File: .github/workflows/build-app.yml
name: Build EXO macOS DMG
Release workflow:
1. Create a draft GitHub Release with the tag name (e.g. v1.0.0) and write release notes in markdown
2. Push the tag: git tag v1.0.0 && git push origin v1.0.0
3. This workflow builds, signs, and notarizes the DMG
4. Release notes are embedded in appcast.xml for Sparkle (rendered as markdown)
5. DMG and appcast.xml are uploaded to S3
6. The draft GitHub Release is published with the DMG attached
For alpha releases (e.g. v1.0.0-alpha.1): draft release and notes are optional.
If no draft exists, a release is auto-created with generated notes.
on:
workflow_dispatch:
push:
tags:
- "v*"
branches:
- "test-app"
jobs:
build-macos-app:
runs-on: "macos-26"
permissions:
contents: write
env:
SPARKLE_VERSION: 2.9.0-beta.1
SPARKLE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
SPARKLE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
SPARKLE_ED25519_PUBLIC: ${{ secrets.SPARKLE_ED25519_PUBLIC }}
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
SPARKLE_S3_BUCKET: ${{ secrets.SPARKLE_S3_BUCKET }}
SPARKLE_S3_PREFIX: ${{ secrets.SPARKLE_S3_PREFIX }}
AWS_REGION: ${{ secrets.AWS_REGION }}
EXO_BUILD_NUMBER: ${{ github.run_number }}
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
steps:
# ============================================================
# Checkout and tag validation
# ============================================================
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive release version from tag
run: |
if [[ "$GITHUB_REF_NAME" == "test-app" || "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="0.0.0-alpha.0"
echo "IS_ALPHA=true" >> $GITHUB_ENV
else
VERSION="${GITHUB_REF_NAME#v}"
if [[ "$VERSION" == *-alpha* ]]; then
echo "IS_ALPHA=true" >> $GITHUB_ENV
else
echo "IS_ALPHA=false" >> $GITHUB_ENV
fi
fi
echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV
- name: Compute build version from semver
run: |
VERSION="$RELEASE_VERSION"
# Extract major.minor.patch (strip prerelease suffix)
BASE_VERSION="${VERSION%%-*}"
MAJOR=$(echo "$BASE_VERSION" | cut -d. -f1)
MINOR=$(echo "$BASE_VERSION" | cut -d. -f2)
PATCH=$(echo "$BASE_VERSION" | cut -d. -f3)
# Extract prerelease number (e.g., "alpha.2" -> 2, or 999 for releases)
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_PART="${VERSION#*-}"
PRERELEASE_NUM="${PRERELEASE_PART##*.}"
# Default to 0 if not a number
if ! [[ "$PRERELEASE_NUM" =~ ^[0-9]+$ ]]; then
PRERELEASE_NUM=0
fi
else
PRERELEASE_NUM=999
fi
# Compute: PRERELEASE + (1000 * PATCH) + (1_000_000 * MINOR) + (1_000_000_000 * MAJOR)
BUILD_VERSION=$((PRERELEASE_NUM + 1000 * PATCH + 1000000 * MINOR + 1000000000 * MAJOR))
echo "EXO_BUILD_VERSION=$BUILD_VERSION" >> $GITHUB_ENV
echo "Computed build version: $BUILD_VERSION from $VERSION"
- name: Ensure tag commit is on main
if: github.ref_type == 'tag'
run: |
git fetch origin main
# Alpha tags can be on any branch, production tags must be on main
if [[ "$IS_ALPHA" == "true" ]]; then
echo "Alpha tag detected, skipping main branch check"
elif ! git merge-base --is-ancestor origin/main HEAD; then
echo "Production tag must point to a commit on main"
exit 1
fi
- name: Fetch and validate release notes
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Find draft release by name using gh release list (more reliable with default token)
echo "Looking for draft release named '$GITHUB_REF_NAME'..."
DRAFT_EXISTS=$(gh release list --json name,isDraft --jq ".[] | select(.isDraft == true) | select(.name == \"$GITHUB_REF_NAME\") | .name" 2>/dev/null || echo "")
if [[ -z "$DRAFT_EXISTS" ]]; then
if [[ "$IS_ALPHA" == "true" ]]; then
echo "No draft release found for alpha tag $GITHUB_REF_NAME (optional for alphas)"
echo "HAS_RELEASE_NOTES=false" >> $GITHUB_ENV
exit 0
fi
echo "ERROR: No draft release found for tag $GITHUB_REF_NAME"
echo "Please create a draft release with release notes before pushing the tag."
exit 1
fi
# Fetch full release details via API to get body and ID
echo "Found draft release, fetching details..."
RELEASE_JSON=$(gh api repos/${{ github.repository }}/releases --jq ".[] | select(.draft == true) | select(.name == \"$GITHUB_REF_NAME\")" 2>/dev/null || echo "")
# Extract release notes
NOTES=$(echo "$RELEASE_JSON" | jq -r '.body // ""')
if [[ -z "$NOTES" || "$NOTES" == "null" ]]; then
if [[ "$IS_ALPHA" == "true" ]]; then
echo "Draft release has no notes (optional for alphas)"
echo "HAS_RELEASE_NOTES=false" >> $GITHUB_ENV
exit 0
fi
echo "ERROR: Draft release exists but has no release notes"
echo "Please add release notes to the draft release before pushing the tag."
exit 1
fi
# Save release ID for later publishing
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
echo "DRAFT_RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
echo "HAS_RELEASE_NOTES=true" >> $GITHUB_ENV
echo "Found draft release (ID: $RELEASE_ID), saving release notes..."
echo "$NOTES" > /tmp/release_notes.md
echo "RELEASE_NOTES_FILE=/tmp/release_notes.md" >> $GITHUB_ENV
# ============================================================
# Install dependencies
# ============================================================
- name: Select Xcode 26.2
run: |
sudo xcode-select -s /Applications/Xcode_26.2.app
if ! xcrun -f metal >/dev/null 2>&1; then
echo "Metal toolchain is not installed."
exit 1
fi
- name: Install Homebrew packages
run: brew install just awscli
- name: Install UV
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Setup Python
run: |
uv python install
uv sync --locked --extra mlx
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Configure Cachix
uses: cachix/cachix-action@v14
with:
name: exo
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build dashboard
run: |
DASHBOARD_OUT=$(nix build .#dashboard --print-build-logs --no-link --print-out-paths)
mkdir -p dashboard/build
cp -r "$DASHBOARD_OUT"/* dashboard/build/
- name: Install Sparkle CLI
run: |
CLI_URL="${SPARKLE_CLI_URL:-https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz}"
echo "Downloading Sparkle CLI from: $CLI_URL"
mkdir -p /tmp/sparkle
curl --fail --location --output /tmp/sparkle.tar.xz "$CLI_URL"
tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle --strip-components=1
echo "SPARKLE_BIN=/tmp/sparkle/bin" >> $GITHUB_ENV
- name: Prepare code-signing keychain
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
run: |
KEYCHAIN_PATH="$HOME/Library/Keychains/build.keychain-db"
# Create fresh keychain
security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
# Disable auto-lock (no timeout, no lock-on-sleep)
security set-keychain-settings "$KEYCHAIN_PATH"
# Add to search list while preserving existing keychains
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
# Set as default and unlock
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate with full access for codesign
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
rm /tmp/cert.p12
# Allow codesign to access the key without prompting
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
# Verify keychain is unlocked and identity is available
echo "Verifying signing identity..."
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# Setup provisioning profile
mkdir -p "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
echo "$PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/EXO.provisionprofile"
# Export keychain path for other steps
echo "BUILD_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV
# ============================================================
# Pre-flight credential / profile validation
# Runs BEFORE the ~16 min build so auth/expiry failures surface in <1 min.
# ============================================================
- name: Validate Apple notarization credentials
env:
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
run: |
# All-or-nothing: either all three creds are set, or none are.
CRED_COUNT=0
for v in "$APPLE_NOTARIZATION_USERNAME" "$APPLE_NOTARIZATION_PASSWORD" "$APPLE_NOTARIZATION_TEAM"; do
[[ -n "$v" ]] && CRED_COUNT=$((CRED_COUNT + 1))
done
if [[ "$CRED_COUNT" -eq 0 ]]; then
echo "No notarization credentials configured โ skipping notarization for this build."
exit 0
fi
if [[ "$CRED_COUNT" -ne 3 ]]; then
echo "ERROR: partial notarization credentials set ($CRED_COUNT/3). Aborting before build."
exit 1
fi
# Cheap, ~5s, auth-only call. Fails instantly with a clear message if
# the app-specific password is stale, wrong team-id, etc.
echo "Verifying Apple notarization credentials via notarytool history..."
if ! xcrun notarytool history \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" >/dev/null; then
echo "ERROR: notarytool rejected the provided credentials. Fix before rerunning."
echo "Common causes: app-specific password expired/revoked, wrong team-id,"
echo "Apple ID not on the team, or 2FA not configured for this Apple ID."
exit 1
fi
echo "Apple notarization credentials OK."
- name: Validate provisioning profile expiry
run: |
PROFILE="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/EXO.provisionprofile"
if [[ ! -f "$PROFILE" ]]; then
echo "ERROR: provisioning profile not found at $PROFILE"
exit 1
fi
EXPIRY=$(security cms -D -i "$PROFILE" | plutil -extract ExpirationDate raw -o - - 2>/dev/null || true)
if [[ -z "$EXPIRY" ]]; then
echo "WARNING: could not read ExpirationDate from provisioning profile; skipping expiry check."
exit 0
fi
# Try a couple of known plutil date formats. If none parse, skip the check rather
# than risk a false-positive "expired" block on a format we didn't anticipate.
EXPIRY_EPOCH=""
for fmt in "%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%d %H:%M:%S %z" "%Y-%m-%d %H:%M:%S +0000"; do
if parsed=$(date -j -f "$fmt" "$EXPIRY" +%s 2>/dev/null); then
EXPIRY_EPOCH="$parsed"
break
fi
done
if [[ -z "$EXPIRY_EPOCH" ]]; then
echo "WARNING: could not parse ExpirationDate '$EXPIRY'; skipping expiry check."
exit 0
fi
NOW_EPOCH=$(date +%s)
if [[ "$EXPIRY_EPOCH" -le "$NOW_EPOCH" ]]; then
echo "ERROR: provisioning profile expired on $EXPIRY. Regenerate it before rerunning."
exit 1
fi
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
echo "Provisioning profile valid until $EXPIRY ($DAYS_LEFT days remaining)."
if [[ "$DAYS_LEFT" -lt 14 ]]; then
echo "WARNING: profile expires in under 14 days โ regenerate soon."
fi
# ============================================================
# Build the bundle
# ============================================================
- name: Add pinned macmon to PATH
run: |
MACMON_DIR=$(nix develop --command sh -c 'dirname $(which macmon)')
echo "Using macmon from: $MACMON_DIR"
echo "$MACMON_DIR" >> $GITHUB_PATH
# Remove any Homebrew macmon so PyInstaller can't accidentally pick it up
brew uninstall macmon 2>/dev/null || true
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
- name: Build Swift app
env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
SPARKLE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
SPARKLE_ED25519_PUBLIC: ${{ secrets.SPARKLE_ED25519_PUBLIC }}
run: |
cd app/EXO
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
xcodebuild clean build \
-scheme EXO \
-configuration Release \
-derivedDataPath build \
MARKETING_VERSION="$RELEASE_VERSION" \
CURRENT_PROJECT_VERSION="$EXO_BUILD_VERSION" \
EXO_BUILD_TAG="$RELEASE_VERSION" \
EXO_BUILD_COMMIT="$GITHUB_SHA" \
SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \
SPARKLE_ED25519_PUBLIC="$SPARKLE_ED25519_PUBLIC" \
CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES
mkdir -p ../../output
cp -R build/Build/Products/Release/EXO.app ../../output/EXO.app
- name: Inject PyInstaller runtime
run: |
rm -rf output/EXO.app/Contents/Resources/exo
mkdir -p output/EXO.app/Contents/Resources
cp -R dist/exo output/EXO.app/Contents/Resources/exo
- name: Codesign PyInstaller runtime
env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
cd output
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
RUNTIME_DIR="EXO.app/Contents/Resources/exo"
find "$RUNTIME_DIR" -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" \) -print0 |
while IFS= read -r -d '' file; do
/usr/bin/codesign --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$file"
done
- name: Sign, notarize, and create DMG
env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
run: |
set -o pipefail
cd output
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
# Fail fast if notarization creds are partial. All-or-nothing.
CRED_COUNT=0
for v in "$APPLE_NOTARIZATION_USERNAME" "$APPLE_NOTARIZATION_PASSWORD" "$APPLE_NOTARIZATION_TEAM"; do
[[ -n "$v" ]] && CRED_COUNT=$((CRED_COUNT + 1))
done
if [[ "$CRED_COUNT" -ne 0 && "$CRED_COUNT" -ne 3 ]]; then
echo "ERROR: partial Apple notarization credentials set ($CRED_COUNT/3). Aborting."
exit 1
fi
/usr/bin/codesign --deep --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" EXO.app
# Pre-flight: verify the signed app BEFORE building DMG and submitting to Apple.
# If this fails, notarization will fail too โ cheap way to fail in seconds, not 15 minutes.
echo "===== codesign --verify EXO.app ====="
if ! /usr/bin/codesign --verify --deep --strict --verbose=2 EXO.app; then
echo "ERROR: EXO.app failed codesign verification. Dumping signing status of every executable:"
find EXO.app -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" -o -name "*.framework" \) -print0 |
while IFS= read -r -d '' f; do
printf -- '--- %s\n' "$f"
/usr/bin/codesign -dv --verbose=2 "$f" 2>&1 | sed 's/^/ /' || true
done
exit 1
fi
# Gatekeeper assessment. A failure here strongly predicts notarization rejection.
echo "===== spctl assessment (predicts notarization outcome) ====="
/usr/bin/spctl -a -vvv -t install EXO.app || echo "WARNING: spctl assessment failed โ notarization is likely to fail too."
mkdir -p dmg-root
cp -R EXO.app dmg-root/
ln -s /Applications dmg-root/Applications
DMG_NAME="EXO-${RELEASE_VERSION}.dmg"
hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
/usr/bin/codesign --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$DMG_NAME"
echo "===== codesign --verify DMG ====="
if ! /usr/bin/codesign --verify --verbose=2 "$DMG_NAME"; then
echo "ERROR: DMG failed codesign verification."
exit 1
fi
if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then
echo "===== notarytool submit ====="
# `|| true` so set -e doesn't abort before we can echo output / fetch the log.
# We rely on the parsed STATUS below to decide pass/fail.
SUBMISSION_OUTPUT=$(xcrun notarytool submit "$DMG_NAME" \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" \
--wait --timeout 15m 2>&1) || true
echo "$SUBMISSION_OUTPUT"
SUBMISSION_ID=$(echo "$SUBMISSION_OUTPUT" | awk 'tolower($1)=="id:" && $2 ~ /^[0-9a-fA-F-]+$/ {print $2; exit}')
STATUS=$(echo "$SUBMISSION_OUTPUT" | awk 'tolower($1)=="status:" {print $2; exit}')
if [[ -n "$SUBMISSION_ID" ]]; then
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" > notarization-log.txt || true
echo "===== Notarization Log ====="
cat notarization-log.txt
echo "============================"
fi
if [[ "$STATUS" != "Accepted" ]]; then
echo "Notarization failed with status: ${STATUS:-Unknown}"
exit 1
fi
xcrun stapler staple "$DMG_NAME"
fi
- name: Generate Sparkle appcast
env:
SPARKLE_DOWNLOAD_PREFIX: ${{ env.SPARKLE_DOWNLOAD_PREFIX }}
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
IS_ALPHA: ${{ env.IS_ALPHA }}
run: |
set -euo pipefail
cd output
DOWNLOAD_PREFIX="${SPARKLE_DOWNLOAD_PREFIX:-https://assets.exolabs.net}"
echo "$SPARKLE_ED25519_PRIVATE" > sparkle_ed25519.key
chmod 600 sparkle_ed25519.key
CHANNEL_FLAG=""
if [[ "$IS_ALPHA" == "true" ]]; then
CHANNEL_FLAG="--channel alpha"
echo "Generating appcast for alpha channel"
fi
$SPARKLE_BIN/generate_appcast \
--ed-key-file sparkle_ed25519.key \
--download-url-prefix "$DOWNLOAD_PREFIX" \
$CHANNEL_FLAG \
.
- name: Inject release notes into appcast
if: github.ref_type == 'tag' && env.HAS_RELEASE_NOTES == 'true'
env:
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
run: |
# Inject markdown release notes with sparkle:format="markdown" (Sparkle 2.9+)
export NOTES=$(cat "$RELEASE_NOTES_FILE")
# Insert description after the enclosure tag for this version
awk '
/<enclosure[^>]*>/ && index($0, ENVIRON["RELEASE_VERSION"]) {
print
print " <description sparkle:format=\"markdown\"><![CDATA["
print ENVIRON["NOTES"]
print " ]]></description>"
next
}
{ print }
' output/appcast.xml > output/appcast.xml.tmp && mv output/appcast.xml.tmp output/appcast.xml
echo "Injected markdown release notes for version $RELEASE_VERSION"
# ============================================================
# Upload artifacts
# ============================================================
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: EXO-dmg-${{ env.RELEASE_VERSION }}
path: output/EXO-${{ env.RELEASE_VERSION }}.dmg
- name: Upload to S3
if: env.SPARKLE_S3_BUCKET != ''
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ env.AWS_REGION }}
SPARKLE_S3_BUCKET: ${{ env.SPARKLE_S3_BUCKET }}
SPARKLE_S3_PREFIX: ${{ env.SPARKLE_S3_PREFIX }}
IS_ALPHA: ${{ env.IS_ALPHA }}
run: |
set -euo pipefail
cd output
PREFIX="${SPARKLE_S3_PREFIX:-}"
if [[ -n "$PREFIX" && "${PREFIX: -1}" != "/" ]]; then
PREFIX="${PREFIX}/"
fi
DMG_NAME="EXO-${RELEASE_VERSION}.dmg"
if [[ "${{ github.ref_type }}" != "tag" ]]; then
aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}EXO-${GITHUB_SHA}.dmg"
exit 0
fi
aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}${DMG_NAME}"
if [[ "$IS_ALPHA" != "true" ]]; then
aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}EXO-latest.dmg"
aws s3 cp appcast.xml "s3://${SPARKLE_S3_BUCKET}/${PREFIX}appcast.xml" --content-type application/xml --cache-control no-cache
fi
- name: Publish GitHub Release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
DMG_PATH="output/EXO-${RELEASE_VERSION}.dmg"
if [[ "$HAS_RELEASE_NOTES" == "true" ]]; then
# Update the draft release with the tag and upload DMG
gh api --method PATCH "repos/${{ github.repository }}/releases/$DRAFT_RELEASE_ID" \
-f tag_name="$GITHUB_REF_NAME" \
-F draft=false
gh release upload "$GITHUB_REF_NAME" "$DMG_PATH" --clobber
echo "Published release $GITHUB_REF_NAME with DMG attached"
else
# Alpha without draft release - create one with auto-generated notes
gh release create "$GITHUB_REF_NAME" "$DMG_PATH" \
--title "$GITHUB_REF_NAME" \
--generate-notes \
--prerelease
echo "Created alpha release $GITHUB_REF_NAME with auto-generated notes"
fiFile: .github/workflows/pipeline.yml
name: ci-pipeline
on:
push:
pull_request:
branches:
- staging
- main
jobs:
nix:
name: Build and check (${{ matrix.system }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-26
system: aarch64-darwin
- runner: ubuntu-latest
system: x86_64-linux
- runner: ubuntu-24.04-arm
system: aarch64-linux
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v14
name: Configure Cachix
with:
name: exo
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build Metal packages (macOS only)
if: runner.os == 'macOS'
run: |
# Try to build metal-toolchain first (may succeed via cachix cache hit)
if nix build .#metal-toolchain 2>/dev/null; then
echo "metal-toolchain built successfully (likely cache hit)"
else
echo "metal-toolchain build failed, extracting from Xcode..."
NAR_HASH="sha256-ayR5mXN4sZAddwKEG2OszGRF93k9ZFc7H0yi2xbylQw="
NAR_NAME="metal-toolchain-17C48.nar"
# Use RUNNER_TEMP to avoid /tmp symlink issues on macOS
WORK_DIR="${RUNNER_TEMP}/metal-work"
mkdir -p "$WORK_DIR"
# Download the Metal toolchain component
xcodebuild -downloadComponent MetalToolchain
# Find and mount the DMG
DMG_PATH=$(find /System/Library/AssetsV2/com_apple_MobileAsset_MetalToolchain -name '*.dmg' 2>/dev/null | head -1)
if [ -z "$DMG_PATH" ]; then
echo "Error: Could not find Metal toolchain DMG"
exit 1
fi
echo "Found DMG at: $DMG_PATH"
hdiutil attach "$DMG_PATH" -mountpoint "${WORK_DIR}/metal-dmg"
# Copy the toolchain
cp -R "${WORK_DIR}/metal-dmg/Metal.xctoolchain" "${WORK_DIR}/metal-export"
hdiutil detach "${WORK_DIR}/metal-dmg"
# Create NAR and add to store
nix nar pack "${WORK_DIR}/metal-export" > "${WORK_DIR}/${NAR_NAME}"
STORE_PATH=$(nix store add --mode flat "${WORK_DIR}/${NAR_NAME}")
echo "Added NAR to store: $STORE_PATH"
# Verify the hash matches
ACTUAL_HASH=$(nix hash file "${WORK_DIR}/${NAR_NAME}")
if [ "$ACTUAL_HASH" != "$NAR_HASH" ]; then
echo "Warning: NAR hash mismatch!"
echo "Expected: $NAR_HASH"
echo "Actual: $ACTUAL_HASH"
echo "The metal-toolchain.nix may need updating"
fi
# Clean up
rm -rf "$WORK_DIR"
# Retry the build now that NAR is in store
nix build .#metal-toolchain
fi
- name: Build all Nix outputs
run: |
nix flake show --json | jq -r '
[
(.packages."${{ matrix.system }}" // {} | keys[] | ".#packages.${{ matrix.system }}.\(.)"),
(.devShells."${{ matrix.system }}" // {} | keys[] | ".#devShells.${{ matrix.system }}.\(.)")
] | .[]
' | xargs nix build
- name: Run nix flake check
run: nix flake check
- name: Run pytest (macOS only)
if: runner.os == 'macOS'
run: |
# Build the test environment (requires relaxed sandbox for uv2nix on macOS)
TEST_ENV=$(nix build '.#exo-test-env' --option sandbox relaxed --print-out-paths)
# Run pytest outside sandbox (needs GPU access for MLX)
export HOME="$RUNNER_TEMP"
export EXO_TESTS=1
export EXO_DASHBOARD_DIR="$PWD/dashboard/"
export EXO_RESOURCES_DIR="$PWD/resources"
$TEST_ENV/bin/python -m pytest src -m "not slow" --import-mode=importlibFile: .zed/settings.json
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"lsp": {
"nix_python": {
"binary": {
"path": "nix",
"arguments": [
"run",
"--quiet",
"--no-warn-dirty",
"--no-allow-import-from-derivation",
"--print-build-logs",
"never",
"${projectRoot}#python-lsp",
"--",
"--stdio"
]
}
}
},
"languages": {
"Python": {
"language_servers": ["nix_python"]
}
}
}