Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5115827ce | |||
| 8290afa642 | |||
| 19adb4a763 | |||
| b7b89f6e0f | |||
| 7339b6426d | |||
| 2555b0e649 | |||
| a4101918bf | |||
| 58f5046de1 | |||
| 4f623f4090 | |||
| 4688adb5d2 | |||
| d9ed943034 | |||
| c6815bfdce | |||
| 39c39e8199 | |||
| e5cb7b80d0 | |||
| 635801d515 | |||
| 2efbb8ab4f | |||
| c6c5a3e73c | |||
| 10ef7ec9e8 | |||
| 1e51dc89b0 | |||
| 5327bdde84 | |||
| 15f1b61f4c | |||
| 9034300163 | |||
| 1d1dfaa1f3 | |||
| 7625213df0 | |||
| f318f9ea14 | |||
| 30fd5aa1cc | |||
| 6de14cfedb | |||
| fc1ae90111 | |||
| 565ed41c13 | |||
| 2da740c387 | |||
| 7117d748ec | |||
| 178c617bbb | |||
| 7277c90389 | |||
| 7ee88c1f05 | |||
| 509533d49e | |||
| b6240a97e8 | |||
| 6cdfbb7e8b | |||
| fac6832e5f | |||
| 7df3774ca2 | |||
| 248919c2a8 | |||
| 49951e1b1a | |||
| e06e70a835 | |||
| e9fdd8d4af | |||
| 07598a3af1 | |||
| 63f57fc193 |
@@ -1,20 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
class HarmonyEncodingName(Enum):
|
||||
HARMONY_GPT_OSS = ...
|
||||
|
||||
class HarmonyEncoding: ...
|
||||
class HarmonyError(Exception): ...
|
||||
|
||||
class Role(Enum):
|
||||
ASSISTANT = ...
|
||||
|
||||
class StreamableParser:
|
||||
last_content_delta: str
|
||||
current_channel: str | None
|
||||
current_recipient: str | None
|
||||
|
||||
def __init__(self, encoding: HarmonyEncoding, role: Role = ...) -> None: ...
|
||||
def process(self, token_id: int) -> None: ...
|
||||
|
||||
def load_harmony_encoding(name: HarmonyEncodingName) -> HarmonyEncoding: ...
|
||||
@@ -1,17 +0,0 @@
|
||||
class NvmlMemoryInfo:
|
||||
used: int
|
||||
total: int
|
||||
free: int
|
||||
|
||||
class NvmlUtilizationRates:
|
||||
gpu: int
|
||||
memory: int
|
||||
|
||||
def nvmlInit() -> None: ...
|
||||
def nvmlShutdown() -> None: ...
|
||||
def nvmlDeviceGetCount() -> int: ...
|
||||
def nvmlDeviceGetHandleByIndex(index: int) -> object: ...
|
||||
def nvmlDeviceGetUtilizationRates(handle: object) -> NvmlUtilizationRates: ...
|
||||
def nvmlDeviceGetTemperature(handle: object, sensor_type: int) -> int: ...
|
||||
def nvmlDeviceGetPowerUsage(handle: object) -> int: ...
|
||||
def nvmlDeviceGetMemoryInfo(handle: object) -> NvmlMemoryInfo: ...
|
||||
@@ -1,61 +0,0 @@
|
||||
from typing import Any, Sequence
|
||||
|
||||
from torch import backends as backends
|
||||
from torch import cuda as cuda
|
||||
from torch import distributed as distributed
|
||||
|
||||
__version__: str
|
||||
|
||||
class version:
|
||||
cuda: str
|
||||
|
||||
class dtype: ...
|
||||
|
||||
bfloat16: dtype
|
||||
float16: dtype
|
||||
float32: dtype
|
||||
int8: dtype
|
||||
int32: dtype
|
||||
int64: dtype
|
||||
long: dtype
|
||||
float8_e4m3fn: dtype
|
||||
|
||||
class Tensor:
|
||||
shape: Sequence[int]
|
||||
dtype: dtype
|
||||
def __getitem__(self, key: Any) -> Tensor: ...
|
||||
def __setitem__(self, key: Any, value: Any) -> None: ...
|
||||
def to(self, *args: Any, **kwargs: Any) -> Tensor: ...
|
||||
def cpu(self) -> Tensor: ...
|
||||
def detach(self) -> Tensor: ...
|
||||
def clone(self) -> Tensor: ...
|
||||
def flatten(self, start_dim: int = 0, end_dim: int = -1) -> Tensor: ...
|
||||
def view(self, *shape: Any) -> Tensor: ...
|
||||
def squeeze(self, dim: int = ...) -> Tensor: ...
|
||||
def unsqueeze(self, dim: int) -> Tensor: ...
|
||||
def permute(self, *dims: int) -> Tensor: ...
|
||||
def float(self) -> Tensor: ...
|
||||
def numpy(self) -> Any: ...
|
||||
def numel(self) -> int: ...
|
||||
def nelement(self) -> int: ...
|
||||
@property
|
||||
def is_cuda(self) -> bool: ...
|
||||
@property
|
||||
def device(self) -> device: ...
|
||||
def __len__(self) -> int: ...
|
||||
def data_ptr(self) -> int: ...
|
||||
def tolist(self) -> Any: ...
|
||||
def abs(self) -> Tensor: ...
|
||||
def max(self) -> Tensor: ...
|
||||
def mean(self) -> Tensor: ...
|
||||
def sum(self, dim: int = ...) -> Tensor: ...
|
||||
def item(self) -> float: ...
|
||||
|
||||
def tensor(data: Any, dtype: dtype | None = None, device: Any = None) -> Tensor: ...
|
||||
def zeros(*size: Any, dtype: dtype | None = None, device: Any = None) -> Tensor: ...
|
||||
def empty(*size: Any, dtype: dtype | None = None, device: Any = None) -> Tensor: ...
|
||||
def from_numpy(ndarray: Any) -> Tensor: ...
|
||||
def inference_mode() -> Any: ...
|
||||
|
||||
class device:
|
||||
def __init__(self, type: str, index: int = ...) -> None: ...
|
||||
@@ -1 +0,0 @@
|
||||
from torch.backends import cuda as cuda
|
||||
@@ -1 +0,0 @@
|
||||
def is_built() -> bool: ...
|
||||
@@ -1,10 +0,0 @@
|
||||
class _DeviceProperties:
|
||||
total_memory: int
|
||||
|
||||
def is_available() -> bool: ...
|
||||
def get_device_name(device: int) -> str: ...
|
||||
def get_device_properties(device: int) -> _DeviceProperties: ...
|
||||
def empty_cache() -> None: ...
|
||||
def mem_get_info() -> tuple[int, int]: ...
|
||||
def synchronize() -> None: ...
|
||||
def max_memory_allocated() -> int: ...
|
||||
@@ -1,2 +0,0 @@
|
||||
def is_initialized() -> bool: ...
|
||||
def destroy_process_group() -> None: ...
|
||||
@@ -1 +0,0 @@
|
||||
__version__: str
|
||||
@@ -1,2 +0,0 @@
|
||||
class ModelConfig:
|
||||
max_model_len: int
|
||||
@@ -1,18 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class EngineArgs:
|
||||
model: str = ...
|
||||
served_model_name: str | list[str] | None = ...
|
||||
tokenizer: str | None = ...
|
||||
trust_remote_code: bool = ...
|
||||
dtype: str = ...
|
||||
seed: int = ...
|
||||
max_model_len: int | None = ...
|
||||
gpu_memory_utilization: float = ...
|
||||
enforce_eager: bool = ...
|
||||
tensor_parallel_size: int = ...
|
||||
pipeline_parallel_size: int = ...
|
||||
quantization: str | None = ...
|
||||
load_format: str = ...
|
||||
enable_sleep_mode: bool = ...
|
||||
@@ -1,17 +0,0 @@
|
||||
class CompletionOutput:
|
||||
index: int
|
||||
text: str
|
||||
token_ids: list[int]
|
||||
cumulative_logprob: float | None
|
||||
logprobs: object | None
|
||||
finish_reason: str | None
|
||||
stop_reason: int | str | None
|
||||
|
||||
def finished(self) -> bool: ...
|
||||
|
||||
class RequestOutput:
|
||||
request_id: str
|
||||
prompt: str | None
|
||||
prompt_token_ids: list[int] | None
|
||||
outputs: list[CompletionOutput]
|
||||
finished: bool
|
||||
@@ -1,11 +0,0 @@
|
||||
class SamplingParams:
|
||||
n: int
|
||||
temperature: float
|
||||
top_p: float
|
||||
top_k: int
|
||||
min_p: float
|
||||
seed: int | None
|
||||
stop: str | list[str] | None
|
||||
max_tokens: int | None
|
||||
logprobs: int | None
|
||||
repetition_penalty: float
|
||||
@@ -1,3 +0,0 @@
|
||||
from vllm.tokenizers.protocol import TokenizerLike
|
||||
|
||||
__all__ = ["TokenizerLike"]
|
||||
@@ -1,15 +0,0 @@
|
||||
from typing import Protocol
|
||||
|
||||
class TokenizerLike(Protocol):
|
||||
@property
|
||||
def eos_token_id(self) -> int: ...
|
||||
@property
|
||||
def vocab_size(self) -> int: ...
|
||||
def encode(self, text: str, add_special_tokens: bool = ...) -> list[int]: ...
|
||||
def decode(self, ids: list[int] | int, skip_special_tokens: bool = ...) -> str: ...
|
||||
def apply_chat_template(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, object]] | None = ...,
|
||||
**kwargs: object,
|
||||
) -> str | list[int]: ...
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from vllm.v1.core.kv_cache_utils import BlockPool, KVCacheBlock
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
|
||||
class KVCacheBlocks:
|
||||
blocks: tuple[Sequence[KVCacheBlock], ...]
|
||||
def __init__(self, blocks: tuple[Sequence[KVCacheBlock], ...]) -> None: ...
|
||||
def get_block_ids(self) -> tuple[list[int], ...]: ...
|
||||
|
||||
class KVCacheManager:
|
||||
block_pool: BlockPool
|
||||
kv_cache_config: KVCacheConfig
|
||||
enable_caching: bool
|
||||
num_kv_cache_groups: int
|
||||
coordinator: object
|
||||
def __init__(self, *args: object, **kwargs: object) -> None: ...
|
||||
def allocate_slots(
|
||||
self, request: object, num_new_tokens: int, *args: object, **kwargs: object
|
||||
) -> KVCacheBlocks | None: ...
|
||||
def get_computed_blocks(self, request: object) -> tuple[KVCacheBlocks, int]: ...
|
||||
def create_kv_cache_blocks(
|
||||
self, blocks: tuple[list[KVCacheBlock], ...]
|
||||
) -> KVCacheBlocks: ...
|
||||
@@ -1,16 +0,0 @@
|
||||
class KVCacheBlock:
|
||||
block_id: int
|
||||
ref_cnt: int
|
||||
def __init__(self, block_id: int) -> None: ...
|
||||
|
||||
class FreeKVCacheBlockQueue:
|
||||
def append_n(self, blocks: list[KVCacheBlock]) -> None: ...
|
||||
def popleft_n(self, n: int) -> list[KVCacheBlock]: ...
|
||||
|
||||
class BlockPool:
|
||||
blocks: list[KVCacheBlock]
|
||||
free_block_queue: FreeKVCacheBlockQueue
|
||||
num_gpu_blocks: int
|
||||
enable_caching: bool
|
||||
def get_num_free_blocks(self) -> int: ...
|
||||
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]: ...
|
||||
@@ -1,22 +0,0 @@
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.engine.arg_utils import EngineArgs
|
||||
from vllm.outputs import RequestOutput
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
|
||||
class LLMEngine:
|
||||
tokenizer: TokenizerLike | None
|
||||
model_config: ModelConfig
|
||||
|
||||
@classmethod
|
||||
def from_engine_args(cls, engine_args: EngineArgs) -> LLMEngine: ...
|
||||
def add_request(
|
||||
self,
|
||||
request_id: str,
|
||||
prompt: str,
|
||||
params: SamplingParams,
|
||||
arrival_time: float | None = ...,
|
||||
) -> None: ...
|
||||
def step(self) -> list[RequestOutput]: ...
|
||||
def has_unfinished_requests(self) -> bool: ...
|
||||
def get_tokenizer(self) -> TokenizerLike: ...
|
||||
@@ -1,23 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class KVCacheSpec:
|
||||
block_size: int
|
||||
num_kv_heads: int
|
||||
head_size: int
|
||||
|
||||
@dataclass
|
||||
class KVCacheGroupSpec:
|
||||
layer_names: list[str]
|
||||
kv_cache_spec: KVCacheSpec
|
||||
|
||||
@dataclass
|
||||
class KVCacheTensorSpec:
|
||||
shared_by: list[str]
|
||||
size: int
|
||||
|
||||
@dataclass
|
||||
class KVCacheConfig:
|
||||
num_blocks: int
|
||||
kv_cache_groups: list[KVCacheGroupSpec]
|
||||
kv_cache_tensors: list[KVCacheTensorSpec]
|
||||
@@ -1,6 +0,0 @@
|
||||
class Request:
|
||||
request_id: str
|
||||
prompt_token_ids: list[int] | None
|
||||
num_prompt_tokens: int
|
||||
num_computed_tokens: int
|
||||
num_tokens: int
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import torch
|
||||
|
||||
class _CompilationConfig:
|
||||
static_forward_context: dict[str, object]
|
||||
|
||||
class _ModelConfig:
|
||||
hf_config: object
|
||||
|
||||
class GPUModelRunner:
|
||||
kv_caches: list[torch.Tensor]
|
||||
compilation_config: _CompilationConfig
|
||||
model_config: _ModelConfig | None
|
||||
def _allocate_kv_cache_tensors(
|
||||
self, kv_cache_config: object
|
||||
) -> dict[str, torch.Tensor]: ...
|
||||
def initialize_kv_cache_tensors(
|
||||
self, kv_cache_config: object, kernel_block_sizes: list[int]
|
||||
) -> dict[str, torch.Tensor]: ...
|
||||
def _reshape_kv_cache_tensors(
|
||||
self,
|
||||
kv_cache_config: object,
|
||||
raw_tensors: dict[str, torch.Tensor],
|
||||
kernel_block_sizes: list[int],
|
||||
) -> dict[str, torch.Tensor]: ...
|
||||
@@ -1,6 +0,0 @@
|
||||
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
|
||||
|
||||
class Worker:
|
||||
model_runner: GPUModelRunner
|
||||
def determine_available_memory(self) -> int: ...
|
||||
def initialize_from_config(self, kv_cache_config: object) -> None: ...
|
||||
@@ -1 +0,0 @@
|
||||
def extract_layer_index(layer_name: str, num_attn_module: int) -> int: ...
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install Homebrew packages
|
||||
run: brew install just awscli macmon
|
||||
run: brew install just awscli
|
||||
|
||||
- name: Install UV
|
||||
uses: astral-sh/setup-uv@v6
|
||||
@@ -243,6 +243,14 @@ jobs:
|
||||
# Build the bundle
|
||||
# ============================================================
|
||||
|
||||
- name: Add pinned macmon to PATH
|
||||
run: |
|
||||
MACMON_DIR=$(nix develop --command sh -c 'dirname $(which macmon)')
|
||||
echo "Using macmon from: $MACMON_DIR"
|
||||
echo "$MACMON_DIR" >> $GITHUB_PATH
|
||||
# Remove any Homebrew macmon so PyInstaller can't accidentally pick it up
|
||||
brew uninstall macmon 2>/dev/null || true
|
||||
|
||||
- name: Build PyInstaller bundle
|
||||
run: uv run pyinstaller packaging/pyinstaller/exo.spec
|
||||
|
||||
|
||||
@@ -2396,7 +2396,7 @@ def degrees(a: array, /, *, stream: Stream | Device | None = ...) -> array:
|
||||
array: The angles in degrees.
|
||||
"""
|
||||
|
||||
def depends(inputs: array | Sequence[array], dependencies: array | Sequence[array]):
|
||||
def depends[T](inputs: T, dependencies: array | Sequence[array]) -> T:
|
||||
"""
|
||||
Insert dependencies between arrays in the graph. The outputs are
|
||||
identical to ``inputs`` but with dependencies on ``dependencies``.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""
|
||||
This type stub file was generated by pyright.
|
||||
"""
|
||||
|
||||
from layers import *
|
||||
from utils import *
|
||||
from .layers import *
|
||||
from .utils import *
|
||||
|
||||
from . import init as init
|
||||
from . import losses as losses
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"""
|
||||
This type stub file was generated by pyright.
|
||||
"""
|
||||
|
||||
from activations import *
|
||||
from base import *
|
||||
from containers import *
|
||||
from convolution import *
|
||||
from convolution_transpose import *
|
||||
from distributed import *
|
||||
from dropout import *
|
||||
from embedding import *
|
||||
from linear import *
|
||||
from normalization import *
|
||||
from pooling import *
|
||||
from positional_encoding import *
|
||||
from quantized import *
|
||||
from recurrent import *
|
||||
from transformer import *
|
||||
from upsample import *
|
||||
from .activations import *
|
||||
from .base import *
|
||||
from .containers import *
|
||||
from .convolution import *
|
||||
from .convolution_transpose import *
|
||||
from .distributed import *
|
||||
from .dropout import *
|
||||
from .embedding import *
|
||||
from .linear import *
|
||||
from .normalization import *
|
||||
from .pooling import *
|
||||
from .positional_encoding import *
|
||||
from .quantized import *
|
||||
from .recurrent import *
|
||||
from .transformer import *
|
||||
from .upsample import *
|
||||
|
||||
@@ -53,7 +53,7 @@ class Module(dict):
|
||||
mx.eval(model.parameters())
|
||||
"""
|
||||
|
||||
__call__: Callable
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> mx.array: ...
|
||||
def __init__(self) -> None:
|
||||
"""Should be called by the subclasses of ``Module``."""
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def str2bool(string): # -> bool:
|
||||
def setup_arg_parser(): # -> ArgumentParser:
|
||||
"""Set up and return the argument parser."""
|
||||
|
||||
generation_stream = ...
|
||||
generation_stream: mx.Stream
|
||||
|
||||
@contextlib.contextmanager
|
||||
def wired_limit(
|
||||
@@ -266,12 +266,12 @@ def _merge_caches(caches: Any) -> List[Any]: ...
|
||||
class Batch:
|
||||
uids: List[int]
|
||||
y: mx.array
|
||||
logprobs: mx.array
|
||||
logprobs: List[mx.array] | mx.array
|
||||
max_tokens: List[int]
|
||||
num_tokens: List[int]
|
||||
cache: List[Any]
|
||||
samplers: List[Any]
|
||||
logits_processors: List[Any]
|
||||
samplers: List[Callable[[mx.array], mx.array] | None]
|
||||
logits_processors: List[List[Callable[[mx.array, mx.array], mx.array]]]
|
||||
tokens: List[mx.array]
|
||||
def __len__(self) -> int: ...
|
||||
def filter(self, keep_idx: List[int]) -> None: ...
|
||||
@@ -279,13 +279,18 @@ class Batch:
|
||||
def extract_cache(self, idx: int) -> List[Any]: ...
|
||||
|
||||
class BatchGenerator:
|
||||
model: Any
|
||||
model: nn.Module
|
||||
sampler: Callable[[mx.array], mx.array]
|
||||
stop_tokens: set[int]
|
||||
max_kv_size: Optional[int]
|
||||
prefill_step_size: int
|
||||
completion_batch_size: int
|
||||
prefill_batch_size: int
|
||||
unprocessed_prompts: List[Any]
|
||||
active_batch: Optional[Batch]
|
||||
prompt_progress_callback: Callable[[List[Tuple[int, int, int]]], None]
|
||||
_stats: BatchStats
|
||||
_next_count: int
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
|
||||
@@ -88,8 +88,8 @@ def create_attention_mask(
|
||||
) -> array | Literal["causal"] | None: ...
|
||||
|
||||
class _BaseCache(Cache):
|
||||
keys: mx.array
|
||||
values: mx.array
|
||||
keys: mx.array | None
|
||||
values: mx.array | None
|
||||
offset: int
|
||||
@property
|
||||
def state(self) -> tuple[mx.array | None, mx.array | None]: ...
|
||||
@@ -268,29 +268,14 @@ class CacheList(_BaseCache):
|
||||
"""
|
||||
|
||||
class BatchKVCache(_BaseCache):
|
||||
step = ...
|
||||
def __init__(self, left_padding: List[int]) -> None:
|
||||
"""
|
||||
The BatchKV cache expects inputs to be left-padded.
|
||||
|
||||
E.g. the following prompts:
|
||||
|
||||
[1, 3, 5]
|
||||
[7]
|
||||
[2, 6, 8, 9]
|
||||
|
||||
Should be padded like so:
|
||||
|
||||
[0, 1, 3, 5]
|
||||
[0, 0, 0, 7]
|
||||
[2, 6, 8, 9]
|
||||
|
||||
And ``left_padding`` specifies the amount of padding for each.
|
||||
In this case, ``left_padding = [1, 3, 0]``.
|
||||
"""
|
||||
|
||||
def update_and_fetch(self, keys, values): # -> tuple[array | Any, array | Any]:
|
||||
...
|
||||
step: int
|
||||
keys: array | None
|
||||
values: array | None
|
||||
offset: array
|
||||
left_padding: array
|
||||
_idx: int
|
||||
def __init__(self, left_padding: List[int]) -> None: ...
|
||||
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
@property
|
||||
def state(
|
||||
self,
|
||||
@@ -316,12 +301,21 @@ class BatchKVCache(_BaseCache):
|
||||
"""
|
||||
|
||||
class BatchRotatingKVCache(_BaseCache):
|
||||
step = ...
|
||||
def __init__(self, max_size, left_padding: List[int]) -> None: ...
|
||||
def update_and_fetch(
|
||||
self, keys, values
|
||||
): # -> tuple[array | Any, array | Any] | tuple[array | Any, array | Any | None]:
|
||||
...
|
||||
step: int
|
||||
keys: array | None
|
||||
values: array | None
|
||||
offset: array
|
||||
left_padding: array
|
||||
max_size: int
|
||||
_idx: int
|
||||
_offset: int
|
||||
rotated: bool
|
||||
_lengths: array | None
|
||||
def __init__(self, max_size: int, left_padding: List[int]) -> None: ...
|
||||
def _trim(self, trim_size: int, v: array, append: array | None = ...) -> array: ...
|
||||
def _update_in_place(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
def _update_concat(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
def update_and_fetch(self, keys: array, values: array) -> tuple[array, array]: ...
|
||||
@property
|
||||
def state(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Optional
|
||||
|
||||
import mlx.core as mx
|
||||
|
||||
def compute_g(A_log: mx.array, a: mx.array, dt_bias: mx.array) -> mx.array: ...
|
||||
def gated_delta_update(
|
||||
q: mx.array,
|
||||
k: mx.array,
|
||||
v: mx.array,
|
||||
a: mx.array,
|
||||
b: mx.array,
|
||||
A_log: mx.array,
|
||||
dt_bias: mx.array,
|
||||
state: Optional[mx.array] = ...,
|
||||
mask: Optional[mx.array] = ...,
|
||||
use_kernel: bool = ...,
|
||||
) -> tuple[mx.array, mx.array]: ...
|
||||
def gated_delta_ops(
|
||||
q: mx.array,
|
||||
k: mx.array,
|
||||
v: mx.array,
|
||||
g: mx.array,
|
||||
beta: mx.array,
|
||||
state: Optional[mx.array] = ...,
|
||||
mask: Optional[mx.array] = ...,
|
||||
) -> tuple[mx.array, mx.array]: ...
|
||||
def gated_delta_kernel(
|
||||
q: mx.array,
|
||||
k: mx.array,
|
||||
v: mx.array,
|
||||
g: mx.array,
|
||||
beta: mx.array,
|
||||
state: mx.array,
|
||||
mask: Optional[mx.array] = ...,
|
||||
) -> tuple[mx.array, mx.array]: ...
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
import mlx.nn as nn
|
||||
|
||||
class YarnRoPE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dims: int,
|
||||
traditional: bool = ...,
|
||||
max_position_embeddings: int = ...,
|
||||
base: float = ...,
|
||||
scaling_factor: float = ...,
|
||||
original_max_position_embeddings: int = ...,
|
||||
beta_fast: float = ...,
|
||||
beta_slow: float = ...,
|
||||
mscale: float = ...,
|
||||
mscale_all_dim: float = ...,
|
||||
) -> None: ...
|
||||
|
||||
class Llama3RoPE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dims: int,
|
||||
traditional: bool = ...,
|
||||
max_position_embeddings: int = ...,
|
||||
base: float = ...,
|
||||
scaling_factor: float = ...,
|
||||
original_max_position_embeddings: int = ...,
|
||||
low_freq_factor: float = ...,
|
||||
high_freq_factor: float = ...,
|
||||
) -> None: ...
|
||||
|
||||
class SuScaledRoPE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dims: int,
|
||||
traditional: bool = ...,
|
||||
max_position_embeddings: int = ...,
|
||||
base: float = ...,
|
||||
short_factor: Any = ...,
|
||||
long_factor: Any = ...,
|
||||
original_max_position_embeddings: int = ...,
|
||||
) -> None: ...
|
||||
|
||||
def initialize_rope(
|
||||
dims: int,
|
||||
base: float = ...,
|
||||
traditional: bool = ...,
|
||||
scaling_config: Optional[dict[str, Any]] = ...,
|
||||
max_position_embeddings: Optional[int] = ...,
|
||||
) -> nn.Module: ...
|
||||
@@ -0,0 +1,12 @@
|
||||
from typing import Any
|
||||
|
||||
def get_message_json(
|
||||
model_name: str,
|
||||
prompt: str,
|
||||
role: str = "user",
|
||||
skip_image_token: bool = False,
|
||||
skip_audio_token: bool = False,
|
||||
num_images: int = 0,
|
||||
num_audios: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]: ...
|
||||
@@ -0,0 +1,15 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
class ImageProcessor:
|
||||
def preprocess(
|
||||
self, images: list[dict[str, Any]], **kwargs: Any
|
||||
) -> dict[str, Any]: ...
|
||||
def __call__(self, **kwargs: Any) -> dict[str, Any]: ...
|
||||
|
||||
def load_image_processor(
|
||||
model_path: str | Path, **kwargs: Any
|
||||
) -> ImageProcessor | None: ...
|
||||
def load_processor(
|
||||
model_path: str | Path, add_detokenizer: bool = ..., **kwargs: Any
|
||||
) -> ImageProcessor: ...
|
||||
@@ -0,0 +1,8 @@
|
||||
from typing import Any, Self
|
||||
|
||||
class safe_open:
|
||||
def __init__(self, filename: str, framework: str = "pt") -> None: ...
|
||||
def __enter__(self) -> Self: ...
|
||||
def __exit__(self, *args: Any) -> None: ...
|
||||
def keys(self) -> list[str]: ...
|
||||
def get_tensor(self, name: str) -> Any: ...
|
||||
+11
-2
@@ -11,9 +11,18 @@ To run EXO from source:
|
||||
```bash
|
||||
brew install uv
|
||||
```
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
|
||||
```bash
|
||||
brew install macmon
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
Use the pinned fork revision used by this repo instead of Homebrew `macmon`.
|
||||
```bash
|
||||
cargo install --git https://github.com/swiftraccoon/macmon \
|
||||
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
|
||||
macmon \
|
||||
--force
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -95,11 +95,10 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
- [uv](https://github.com/astral-sh/uv) (for Python dependency management)
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
- [node](https://github.com/nodejs/node) (for building the dashboard)
|
||||
|
||||
```bash
|
||||
brew install uv macmon node
|
||||
brew install uv node
|
||||
```
|
||||
- [rust](https://github.com/rust-lang/rustup) (to build Rust bindings, nightly for now)
|
||||
|
||||
@@ -107,6 +106,17 @@ Then restart the Nix daemon: `sudo launchctl kickstart -k system/org.nixos.nix-d
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
- [macmon](https://github.com/vladkens/macmon) (for hardware monitoring on Apple Silicon)
|
||||
|
||||
Install the pinned fork revision used by this repo instead of Homebrew `macmon`.
|
||||
Homebrew `macmon 0.6.1` still crashes on Apple M5.
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/swiftraccoon/macmon \
|
||||
--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b \
|
||||
macmon \
|
||||
--force
|
||||
```
|
||||
|
||||
Clone the repo, build the dashboard, and run exo:
|
||||
|
||||
@@ -285,8 +295,9 @@ exo supports several environment variables for configuration:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `EXO_MODELS_PATH` | Colon-separated paths to search for pre-downloaded models (e.g., on NFS mounts or shared storage) | None |
|
||||
| `EXO_MODELS_DIR` | Directory where exo downloads and stores models | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
|
||||
| `EXO_DEFAULT_MODELS_DIR` | Default directory for model downloads and caches. Always first in the writable dirs list. | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
|
||||
| `EXO_MODELS_DIRS` | Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
|
||||
| `EXO_MODELS_READ_ONLY_DIRS` | Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
|
||||
| `EXO_OFFLINE` | Run without internet connection (uses only local models) | `false` |
|
||||
| `EXO_ENABLE_IMAGE_MODELS` | Enable image model support | `false` |
|
||||
| `EXO_LIBP2P_NAMESPACE` | Custom namespace for cluster isolation | None |
|
||||
@@ -296,8 +307,11 @@ exo supports several environment variables for configuration:
|
||||
**Example usage:**
|
||||
|
||||
```bash
|
||||
# Use pre-downloaded models from NFS mount
|
||||
EXO_MODELS_PATH=/mnt/nfs/models:/opt/ai-models uv run exo
|
||||
# Use pre-downloaded models from NFS mount (read-only)
|
||||
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
|
||||
|
||||
# Download models to an external SSD (falls back to default dir if full)
|
||||
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
|
||||
|
||||
# Run in offline mode
|
||||
EXO_OFFLINE=true uv run exo
|
||||
|
||||
@@ -4,6 +4,7 @@ import Foundation
|
||||
|
||||
private let customNamespaceKey = "EXOCustomNamespace"
|
||||
private let hfTokenKey = "EXOHFToken"
|
||||
private let hfEndpointKey = "EXOHFEndpoint"
|
||||
private let enableImageModelsKey = "EXOEnableImageModels"
|
||||
private let offlineModeKey = "EXOOfflineMode"
|
||||
private let onboardingCompletedKey = "EXOOnboardingCompleted"
|
||||
@@ -53,6 +54,14 @@ final class ExoProcessController: ObservableObject {
|
||||
UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
|
||||
}
|
||||
}
|
||||
@Published var hfEndpoint: String = {
|
||||
return UserDefaults.standard.string(forKey: hfEndpointKey) ?? ""
|
||||
}()
|
||||
{
|
||||
didSet {
|
||||
UserDefaults.standard.set(hfEndpoint, forKey: hfEndpointKey)
|
||||
}
|
||||
}
|
||||
@Published var enableImageModels: Bool = {
|
||||
return UserDefaults.standard.bool(forKey: enableImageModelsKey)
|
||||
}()
|
||||
@@ -273,6 +282,9 @@ final class ExoProcessController: ObservableObject {
|
||||
if !hfToken.isEmpty {
|
||||
environment["HF_TOKEN"] = hfToken
|
||||
}
|
||||
if !hfEndpoint.isEmpty {
|
||||
environment["HF_ENDPOINT"] = hfEndpoint
|
||||
}
|
||||
if enableImageModels {
|
||||
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct SettingsView: View {
|
||||
|
||||
@State private var pendingNamespace: String = ""
|
||||
@State private var pendingHFToken: String = ""
|
||||
@State private var pendingHFEndpoint: String = ""
|
||||
@State private var pendingEnableImageModels = false
|
||||
@State private var pendingOfflineMode = false
|
||||
@State private var needsRestart = false
|
||||
@@ -42,6 +43,7 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
pendingNamespace = controller.customNamespace
|
||||
pendingHFToken = controller.hfToken
|
||||
pendingHFEndpoint = controller.hfEndpoint
|
||||
pendingEnableImageModels = controller.enableImageModels
|
||||
pendingOfflineMode = controller.offlineMode
|
||||
needsRestart = false
|
||||
@@ -74,6 +76,17 @@ struct SettingsView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("HuggingFace Endpoint") {
|
||||
TextField("default", text: $pendingHFEndpoint)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 200)
|
||||
}
|
||||
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Offline Mode", isOn: $pendingOfflineMode)
|
||||
Text("Skip internet checks and use only locally available models.")
|
||||
@@ -454,6 +467,7 @@ struct SettingsView: View {
|
||||
|
||||
private var hasGeneralChanges: Bool {
|
||||
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
|
||||
|| pendingHFEndpoint != controller.hfEndpoint
|
||||
|| pendingOfflineMode != controller.offlineMode
|
||||
}
|
||||
|
||||
@@ -464,6 +478,7 @@ struct SettingsView: View {
|
||||
private func applyGeneralSettings() {
|
||||
controller.customNamespace = pendingNamespace
|
||||
controller.hfToken = pendingHFToken
|
||||
controller.hfEndpoint = pendingHFEndpoint
|
||||
controller.offlineMode = pendingOfflineMode
|
||||
restartIfRunning()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# name, patterns, reasoning
|
||||
#
|
||||
# Optional per-model overrides (CLI flags take priority over these):
|
||||
# temperature, top_p, max_tokens, reasoning_effort
|
||||
# temperature, top_p, max_tokens, reasoning_effort, enable_thinking
|
||||
#
|
||||
# Fallback defaults (when no per-model config):
|
||||
# reasoning: temperature=1.0, max_tokens=131072, reasoning_effort="high"
|
||||
@@ -18,10 +18,9 @@
|
||||
|
||||
# ─── Qwen3.5 (Feb 2026) ─────────────────────────────────────────────
|
||||
# Source: HuggingFace model cards (Qwen/Qwen3.5-*)
|
||||
# 35B-A3B thinking general: temp=1.0, top_p=0.95, top_k=20
|
||||
# 397B thinking: temp=0.6, top_p=0.95, top_k=20
|
||||
# Non-thinking: temp=0.7, top_p=0.8, top_k=20
|
||||
# max_tokens: 32768 general, 81920 for complex math/code
|
||||
# Model card recommends: temp=0.6, top_p=0.95, top_k=20
|
||||
# We omit top_k to match vllm eval (which doesn't set it).
|
||||
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin).
|
||||
|
||||
[[model]]
|
||||
name = "Qwen3.5 2B"
|
||||
@@ -29,7 +28,8 @@ patterns = ["Qwen3.5-2B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
max_tokens = 81920
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "Qwen3.5 9B"
|
||||
@@ -37,7 +37,8 @@ patterns = ["Qwen3.5-9B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
max_tokens = 81920
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "Qwen3.5 27B"
|
||||
@@ -45,15 +46,17 @@ patterns = ["Qwen3.5-27B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
max_tokens = 81920
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "Qwen3.5 35B A3B"
|
||||
patterns = ["Qwen3.5-35B-A3B"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
max_tokens = 81920
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "Qwen3.5 122B A10B"
|
||||
@@ -61,7 +64,8 @@ patterns = ["Qwen3.5-122B-A10B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
max_tokens = 81920
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "Qwen3.5 397B A17B"
|
||||
@@ -69,12 +73,14 @@ patterns = ["Qwen3.5-397B-A17B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
max_tokens = 81920
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
# ─── Qwen3 (Apr 2025) ───────────────────────────────────────────────
|
||||
# Source: HuggingFace model cards (Qwen/Qwen3-*)
|
||||
# Thinking: temp=0.6, top_p=0.95, top_k=20
|
||||
# Non-thinking: temp=0.7, top_p=0.8, top_k=20
|
||||
# Model card recommends: temp=0.6, top_p=0.95, top_k=20
|
||||
# We omit top_k to match vllm eval (which doesn't set it).
|
||||
# Non-thinking: temp=0.7, top_p=0.8
|
||||
# max_tokens: 32768 general, 38912 for complex math/code
|
||||
|
||||
[[model]]
|
||||
@@ -83,6 +89,7 @@ patterns = ["Qwen3-0.6B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
max_tokens = 38912
|
||||
|
||||
[[model]]
|
||||
@@ -91,6 +98,7 @@ patterns = ["Qwen3-30B-A3B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
max_tokens = 38912
|
||||
|
||||
[[model]]
|
||||
@@ -99,6 +107,7 @@ patterns = ["Qwen3-235B-A22B"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
max_tokens = 38912
|
||||
|
||||
[[model]]
|
||||
@@ -107,6 +116,7 @@ patterns = ["Qwen3-Next-80B-A3B-Thinking"]
|
||||
reasoning = true
|
||||
temperature = 0.6
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
max_tokens = 38912
|
||||
|
||||
[[model]]
|
||||
@@ -129,9 +139,9 @@ max_tokens = 16384
|
||||
name = "Qwen3 Coder Next"
|
||||
patterns = ["Qwen3-Coder-Next"]
|
||||
reasoning = false
|
||||
temperature = 0.7
|
||||
top_p = 0.8
|
||||
max_tokens = 16384
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
max_tokens = 121072
|
||||
|
||||
# ─── GPT-OSS (OpenAI) ───────────────────────────────────────────────
|
||||
# Source: OpenAI GitHub README + HuggingFace discussion #21
|
||||
@@ -165,10 +175,38 @@ patterns = ["DeepSeek-V3.1"]
|
||||
reasoning = true
|
||||
temperature = 0.0
|
||||
|
||||
[[model]]
|
||||
name = "DeepSeek V3.2"
|
||||
patterns = ["DeepSeek-V3.2"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
|
||||
# ─── NVIDIA Nemotron ───────────────────────────────────────────────────
|
||||
# Source: HuggingFace model cards
|
||||
# All variants: temp=1.0, top_p=0.95, enable_thinking=true
|
||||
|
||||
[[model]]
|
||||
name = "Nemotron Cascade 2 30B A3B"
|
||||
patterns = ["Nemotron-Cascade-2-30B-A3B"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
|
||||
[[model]]
|
||||
name = "Nemotron 3 Super 120B A12B"
|
||||
patterns = ["Nemotron-3-Super-120B-A12B", "NVIDIA-Nemotron-3-Super-120B-A12B"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
|
||||
# ─── GLM (ZhipuAI / THUDM) ──────────────────────────────────────────
|
||||
# Source: HuggingFace model cards + generation_config.json + docs.z.ai
|
||||
# GLM 4.5+: temp=1.0, top_p=0.95
|
||||
# Reasoning tasks: 131072 max_tokens; coding/SWE tasks: temp=0.7
|
||||
# max_tokens=121072 to match vllm eval (131072 context - 10000 safety margin)
|
||||
|
||||
[[model]]
|
||||
name = "GLM-5"
|
||||
@@ -176,7 +214,8 @@ patterns = ["GLM-5"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
max_tokens = 131072
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "GLM 4.5 Air"
|
||||
@@ -191,7 +230,8 @@ patterns = ["GLM-4.7-"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
max_tokens = 131072
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
# Note: matches both GLM-4.7 and GLM-4.7-Flash
|
||||
|
||||
# ─── Kimi (Moonshot AI) ─────────────────────────────────────────────
|
||||
@@ -213,7 +253,8 @@ patterns = ["Kimi-K2.5"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
max_tokens = 131072
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
[[model]]
|
||||
name = "Kimi K2 Instruct"
|
||||
@@ -223,7 +264,8 @@ temperature = 0.6
|
||||
|
||||
# ─── MiniMax ─────────────────────────────────────────────────────────
|
||||
# Source: HuggingFace model cards + generation_config.json
|
||||
# All models: temp=1.0, top_p=0.95, top_k=40
|
||||
# All models: temp=1.0, top_p=0.95
|
||||
# max_tokens=90000 to match vllm eval (100000 context - 10000 safety margin)
|
||||
|
||||
[[model]]
|
||||
name = "MiniMax M2.5"
|
||||
@@ -231,6 +273,8 @@ patterns = ["MiniMax-M2.5"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
max_tokens = 90000
|
||||
|
||||
[[model]]
|
||||
name = "MiniMax M2.1"
|
||||
@@ -251,6 +295,8 @@ patterns = ["Step-3.5-Flash"]
|
||||
reasoning = true
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
enable_thinking = true
|
||||
max_tokens = 121072
|
||||
|
||||
# ─── Llama (Meta) ───────────────────────────────────────────────────
|
||||
# Source: generation_config.json + meta-llama/llama-models generation.py
|
||||
|
||||
@@ -17,13 +17,12 @@ from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
ensure_cuda_available,
|
||||
capture_cluster_snapshot,
|
||||
instance_id_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
validate_vllm_args,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
@@ -935,7 +934,6 @@ Examples:
|
||||
help="Write JSON results to stdout instead of file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
validate_vllm_args(args)
|
||||
|
||||
all_scenarios = load_scenarios(SCENARIOS_PATH)
|
||||
if args.scenarios:
|
||||
@@ -955,8 +953,6 @@ Examples:
|
||||
|
||||
log = sys.stderr if args.stdout else sys.stdout
|
||||
exo = ExoClient(args.host, args.port, timeout_s=args.timeout)
|
||||
if args.ensure_cuda:
|
||||
ensure_cuda_available(exo)
|
||||
_short_id, full_model_id = resolve_model_short_id(exo, args.model)
|
||||
|
||||
selected = settle_and_fetch_placements(
|
||||
@@ -1011,6 +1007,7 @@ Examples:
|
||||
sys.exit(1)
|
||||
|
||||
time.sleep(1)
|
||||
cluster_snapshot = capture_cluster_snapshot(exo)
|
||||
all_results: list[ScenarioResult] = []
|
||||
|
||||
try:
|
||||
@@ -1089,16 +1086,19 @@ Examples:
|
||||
print(f" - {r.name} [{r.api}/{r.phase}]: {r.error}", file=log)
|
||||
|
||||
json_results = [result_to_dict(r) for r in all_results]
|
||||
output: dict[str, Any] = {"results": json_results}
|
||||
if cluster_snapshot:
|
||||
output["cluster"] = cluster_snapshot
|
||||
|
||||
if args.stdout:
|
||||
print(json.dumps(json_results, indent=2))
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
json_path = args.json_out
|
||||
parent = os.path.dirname(json_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(json_results, f, indent=2)
|
||||
json.dump(output, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"\nJSON results written to {json_path}", file=log)
|
||||
|
||||
|
||||
+150
-19
@@ -22,6 +22,7 @@ import contextlib
|
||||
import itertools
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
@@ -33,13 +34,13 @@ from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
ensure_cuda_available,
|
||||
capture_cluster_snapshot,
|
||||
instance_id_from_instance,
|
||||
node_ids_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
validate_vllm_args,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
@@ -78,7 +79,7 @@ def load_tokenizer_for_bench(model_id: str) -> Any:
|
||||
model_path = Path(
|
||||
snapshot_download(
|
||||
model_id,
|
||||
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model"],
|
||||
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model", "*.jinja"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -133,6 +134,91 @@ def format_peak_memory(b: float) -> str:
|
||||
raise ValueError("You're using petabytes of memory. Something went wrong...")
|
||||
|
||||
|
||||
_SAMPLER_METRICS = ("gpuUsage", "temp", "sysPower", "pcpuUsage", "ecpuUsage")
|
||||
|
||||
|
||||
class SystemMetricsSampler:
|
||||
def __init__(self, client: ExoClient, node_ids: list[str], interval_s: float = 1.0):
|
||||
self._client = client
|
||||
self._node_ids = node_ids
|
||||
self._interval_s = interval_s
|
||||
self._samples: dict[str, list[tuple[float, dict[str, float]]]] = {
|
||||
nid: [] for nid in node_ids
|
||||
}
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
t = time.monotonic()
|
||||
for nid in self._node_ids:
|
||||
try:
|
||||
data = self._client.get_node_system(nid)
|
||||
if data:
|
||||
self._samples[nid].append(
|
||||
(t, {k: data.get(k, 0.0) for k in _SAMPLER_METRICS})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._stop.wait(self._interval_s)
|
||||
|
||||
def energy_between(self, t0: float, t1: float) -> float:
|
||||
total_joules = 0.0
|
||||
for _nid, samples in self._samples.items():
|
||||
window = [(t, s["sysPower"]) for t, s in samples if t0 <= t <= t1]
|
||||
if len(window) >= 2:
|
||||
for i in range(1, len(window)):
|
||||
dt = window[i][0] - window[i - 1][0]
|
||||
avg_power = (window[i][1] + window[i - 1][1]) / 2
|
||||
total_joules += avg_power * dt
|
||||
elif len(window) == 1:
|
||||
total_joules += window[0][1] * (t1 - t0)
|
||||
return total_joules
|
||||
|
||||
def summarize(self) -> dict[str, dict[str, dict[str, float]]]:
|
||||
result: dict[str, dict[str, dict[str, float]]] = {}
|
||||
for nid, samples in self._samples.items():
|
||||
if not samples:
|
||||
continue
|
||||
metrics: dict[str, dict[str, float]] = {}
|
||||
for key in _SAMPLER_METRICS:
|
||||
values = [s[key] for t, s in samples]
|
||||
metrics[key] = {
|
||||
"min": round(min(values), 2),
|
||||
"max": round(max(values), 2),
|
||||
"mean": round(mean(values), 2),
|
||||
"samples": len(values),
|
||||
}
|
||||
result[nid] = metrics
|
||||
return result
|
||||
|
||||
def print_summary(self, placement_label: str) -> None:
|
||||
summary = self.summarize()
|
||||
if not summary:
|
||||
return
|
||||
logger.info(f"--- System Metrics ({placement_label}) ---")
|
||||
for nid, metrics in summary.items():
|
||||
gpu = metrics.get("gpuUsage", {})
|
||||
temp = metrics.get("temp", {})
|
||||
power = metrics.get("sysPower", {})
|
||||
logger.info(
|
||||
f" {nid}: "
|
||||
f"GPU {gpu.get('mean', 0) * 100:.0f}% avg ({gpu.get('min', 0) * 100:.0f}–{gpu.get('max', 0) * 100:.0f}%) | "
|
||||
f"{temp.get('mean', 0):.1f}°C avg | "
|
||||
f"{power.get('mean', 0):.1f}W avg"
|
||||
)
|
||||
|
||||
|
||||
def parse_int_list(values: list[str]) -> list[int]:
|
||||
items: list[int] = []
|
||||
for v in values:
|
||||
@@ -281,8 +367,18 @@ def main() -> int:
|
||||
action="store_true",
|
||||
help="Force all pp×tg combinations (cartesian product) even when lists have equal length.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--no-system-metrics",
|
||||
action="store_true",
|
||||
help="Disable GPU utilization, temperature, and power collection during inference.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--metrics-interval",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="System metrics polling interval in seconds (default: 1.0).",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
validate_vllm_args(args)
|
||||
|
||||
pp_list = parse_int_list(args.pp)
|
||||
tg_list = parse_int_list(args.tg)
|
||||
@@ -307,8 +403,6 @@ def main() -> int:
|
||||
logger.info(f"pp/tg mode: tandem (zip) - {len(pp_list)} pairs")
|
||||
|
||||
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
|
||||
if args.ensure_cuda:
|
||||
ensure_cuda_available(client)
|
||||
short_id, full_model_id = resolve_model_short_id(
|
||||
client, args.model, force_download=args.force_download
|
||||
)
|
||||
@@ -336,7 +430,7 @@ def main() -> int:
|
||||
key=lambda p: (
|
||||
str(p.get("instance_meta", "")),
|
||||
str(p.get("sharding", "")),
|
||||
-nodes_used_in_instance(p["instance"]),
|
||||
nodes_used_in_instance(p["instance"]),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
@@ -369,7 +463,9 @@ def main() -> int:
|
||||
else:
|
||||
logger.info("Download: model already cached")
|
||||
|
||||
cluster_snapshot = capture_cluster_snapshot(client)
|
||||
all_rows: list[dict[str, Any]] = []
|
||||
all_system_metrics: dict[str, dict[str, dict[str, float]]] = {}
|
||||
|
||||
for preview in selected:
|
||||
instance = preview["instance"]
|
||||
@@ -395,6 +491,16 @@ def main() -> int:
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
sampler: SystemMetricsSampler | None = None
|
||||
if not args.no_system_metrics:
|
||||
nids = node_ids_from_instance(instance)
|
||||
sampler = SystemMetricsSampler(
|
||||
ExoClient(args.host, args.port, timeout_s=30),
|
||||
nids,
|
||||
interval_s=args.metrics_interval,
|
||||
)
|
||||
sampler.start()
|
||||
|
||||
try:
|
||||
for i in range(args.warmup):
|
||||
run_one_completion(
|
||||
@@ -413,15 +519,18 @@ def main() -> int:
|
||||
for concurrency in concurrency_list:
|
||||
logger.info(f"--- pp={pp} tg={tg} concurrency={concurrency} ---")
|
||||
runs: list[dict[str, Any]] = []
|
||||
inference_windows: list[tuple[float, float]] = []
|
||||
for r in range(args.repeat):
|
||||
time.sleep(3)
|
||||
|
||||
if concurrency <= 1:
|
||||
# Sequential: single request
|
||||
try:
|
||||
inf_t0 = time.monotonic()
|
||||
row, actual_pp_tokens = run_one_completion(
|
||||
client, full_model_id, pp, tg, prompt_sizer
|
||||
)
|
||||
inference_windows.append((inf_t0, time.monotonic()))
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
continue
|
||||
@@ -462,6 +571,7 @@ def main() -> int:
|
||||
c, full_model_id, _pp, _tg, prompt_sizer
|
||||
)
|
||||
|
||||
inf_t0 = time.monotonic()
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
||||
futures = {
|
||||
pool.submit(_run_concurrent, i): i
|
||||
@@ -473,6 +583,7 @@ def main() -> int:
|
||||
except Exception as e:
|
||||
logger.error(f"Concurrent request failed: {e}")
|
||||
batch_errors += 1
|
||||
inference_windows.append((inf_t0, time.monotonic()))
|
||||
|
||||
for idx, (row, actual_pp_tokens) in enumerate(
|
||||
batch_results
|
||||
@@ -506,36 +617,50 @@ def main() -> int:
|
||||
for x, _ in batch_results
|
||||
if x["stats"]["generation_tps"] > 0
|
||||
]
|
||||
agg_gen_tps = (
|
||||
per_req_tps = (
|
||||
mean(valid_gen_tps) if valid_gen_tps else 0.0
|
||||
)
|
||||
gen_tps = agg_gen_tps / concurrency
|
||||
agg_gen_tps = per_req_tps * concurrency
|
||||
logger.info(
|
||||
f"[concurrent {concurrency}x] "
|
||||
f"agg_gen_tps={agg_gen_tps:.2f} "
|
||||
f"gen_tps={gen_tps:.2f} "
|
||||
f"per_req_tps={per_req_tps:.2f} "
|
||||
f"errors={batch_errors}"
|
||||
)
|
||||
|
||||
if runs:
|
||||
prompt_tps = mean(x["stats"]["prompt_tps"] for x in runs)
|
||||
gen_tps = mean(
|
||||
x["stats"]["generation_tps"] / x["concurrency"]
|
||||
for x in runs
|
||||
)
|
||||
per_req_tps = mean(x["stats"]["generation_tps"] for x in runs)
|
||||
gen_tps = per_req_tps * concurrency
|
||||
ptok = mean(x["stats"]["prompt_tokens"] for x in runs)
|
||||
gtok = mean(x["stats"]["generation_tokens"] for x in runs)
|
||||
peak = mean(
|
||||
x["stats"]["peak_memory_usage"]["inBytes"] for x in runs
|
||||
)
|
||||
|
||||
logger.info(
|
||||
summary = (
|
||||
f"prompt_tps={prompt_tps:.2f} gen_tps={gen_tps:.2f} "
|
||||
f"prompt_tokens={ptok} gen_tokens={gtok} "
|
||||
f"peak_memory={format_peak_memory(peak)}\n"
|
||||
f"peak_memory={format_peak_memory(peak)}"
|
||||
)
|
||||
if sampler and inference_windows:
|
||||
joules = sum(
|
||||
sampler.energy_between(t0, t1)
|
||||
for t0, t1 in inference_windows
|
||||
)
|
||||
inf_seconds = sum(t1 - t0 for t0, t1 in inference_windows)
|
||||
avg_watts = joules / inf_seconds if inf_seconds > 0 else 0
|
||||
summary += f" energy={joules:.1f}J ({avg_watts:.1f}W avg over {inf_seconds:.1f}s inference)"
|
||||
logger.info(f"{summary}\n")
|
||||
time.sleep(2)
|
||||
finally:
|
||||
if sampler:
|
||||
sampler.stop()
|
||||
placement_label = f"{sharding}/{instance_meta}/{n_nodes} nodes"
|
||||
sampler.print_summary(placement_label)
|
||||
placement_metrics = sampler.summarize()
|
||||
if placement_metrics:
|
||||
all_system_metrics.update(placement_metrics)
|
||||
|
||||
try:
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
except ExoHttpError as e:
|
||||
@@ -546,11 +671,17 @@ def main() -> int:
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
output: dict[str, Any] = {"runs": all_rows}
|
||||
if cluster_snapshot:
|
||||
output["cluster"] = cluster_snapshot
|
||||
if all_system_metrics:
|
||||
output["system_metrics"] = all_system_metrics
|
||||
|
||||
if args.stdout:
|
||||
json.dump(all_rows, sys.stdout, indent=2, ensure_ascii=False)
|
||||
json.dump(output, sys.stdout, indent=2, ensure_ascii=False)
|
||||
elif args.json_out:
|
||||
with open(args.json_out, "w", encoding="utf-8") as f:
|
||||
json.dump(all_rows, f, indent=2, ensure_ascii=False)
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
logger.debug(f"\nWrote results JSON: {args.json_out}")
|
||||
|
||||
return 0
|
||||
|
||||
+473
-95
@@ -46,13 +46,12 @@ from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
add_common_instance_args,
|
||||
ensure_cuda_available,
|
||||
capture_cluster_snapshot,
|
||||
instance_id_from_instance,
|
||||
nodes_used_in_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
settle_and_fetch_placements,
|
||||
validate_vllm_args,
|
||||
wait_for_instance_gone,
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
@@ -63,6 +62,12 @@ from loguru import logger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAX_RETRIES = 30
|
||||
INSTANCE_HEALTH_CHECK_AFTER = 3 # Check instance health after this many consecutive failures
|
||||
|
||||
|
||||
class InstanceFailedError(RuntimeError):
|
||||
"""Raised when the exo instance is detected as failed/gone."""
|
||||
pass
|
||||
DEFAULT_MAX_TOKENS = 16_384
|
||||
REASONING_MAX_TOKENS = 131_072
|
||||
TEMPERATURE_NON_REASONING = 0.0
|
||||
@@ -272,7 +277,7 @@ def run_humaneval_test(
|
||||
|
||||
@dataclass
|
||||
class QuestionResult:
|
||||
question_id: int
|
||||
question_id: int | str
|
||||
prompt: str
|
||||
response: str
|
||||
extracted_answer: str | None
|
||||
@@ -282,7 +287,11 @@ class QuestionResult:
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
reasoning_tokens: int = 0
|
||||
reasoning_content: str = ""
|
||||
finish_reason: str = ""
|
||||
elapsed_s: float = 0.0
|
||||
power_watts: float = 0.0
|
||||
energy_joules: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -518,6 +527,10 @@ class ApiResult:
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
reasoning_tokens: int
|
||||
reasoning_content: str = ""
|
||||
finish_reason: str = ""
|
||||
power_watts: float = 0.0
|
||||
energy_joules: float = 0.0
|
||||
|
||||
|
||||
async def _call_api(
|
||||
@@ -531,6 +544,9 @@ async def _call_api(
|
||||
system_message: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
top_p: float | None = None,
|
||||
top_k: int | None = None,
|
||||
min_p: float | None = None,
|
||||
enable_thinking: bool | None = None,
|
||||
) -> ApiResult:
|
||||
messages = []
|
||||
if system_message:
|
||||
@@ -547,6 +563,12 @@ async def _call_api(
|
||||
body["reasoning_effort"] = reasoning_effort
|
||||
if top_p is not None:
|
||||
body["top_p"] = top_p
|
||||
if top_k is not None:
|
||||
body["top_k"] = top_k
|
||||
if min_p is not None:
|
||||
body["min_p"] = min_p
|
||||
if enable_thinking is not None:
|
||||
body["enable_thinking"] = enable_thinking
|
||||
|
||||
resp = await client.post(
|
||||
f"{base_url}/v1/chat/completions",
|
||||
@@ -555,19 +577,40 @@ async def _call_api(
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
if not content or not content.strip():
|
||||
choice = data["choices"][0]
|
||||
message = choice["message"]
|
||||
content = message.get("content") or ""
|
||||
reasoning_content = message.get("reasoning_content") or ""
|
||||
finish_reason = choice.get("finish_reason") or ""
|
||||
|
||||
# For thinking models, empty content is expected when finish_reason is "length"
|
||||
if not content.strip() and finish_reason != "length" and not reasoning_content:
|
||||
raise ValueError("Empty response from model")
|
||||
usage = data.get("usage", {})
|
||||
details = usage.get("completion_tokens_details", {})
|
||||
power = data.get("power_usage") or {}
|
||||
return ApiResult(
|
||||
content=content,
|
||||
prompt_tokens=usage.get("prompt_tokens", 0),
|
||||
completion_tokens=usage.get("completion_tokens", 0),
|
||||
reasoning_tokens=details.get("reasoning_tokens", 0) if details else 0,
|
||||
reasoning_content=reasoning_content,
|
||||
finish_reason=finish_reason,
|
||||
power_watts=power.get("total_avg_sys_power_watts", 0.0),
|
||||
energy_joules=power.get("total_energy_joules", 0.0),
|
||||
)
|
||||
|
||||
|
||||
async def _check_instance_health(base_url: str) -> bool:
|
||||
"""Return True if the exo instance is still reachable."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(f"{base_url}/models", timeout=5.0)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def call_with_retries(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
@@ -579,8 +622,14 @@ async def call_with_retries(
|
||||
system_message: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
top_p: float | None = None,
|
||||
top_k: int | None = None,
|
||||
min_p: float | None = None,
|
||||
enable_thinking: bool | None = None,
|
||||
instance_failed: asyncio.Event | None = None,
|
||||
) -> ApiResult | None:
|
||||
for attempt in range(MAX_RETRIES):
|
||||
if instance_failed and instance_failed.is_set():
|
||||
raise InstanceFailedError("Instance already marked as failed")
|
||||
try:
|
||||
return await _call_api(
|
||||
client,
|
||||
@@ -593,8 +642,17 @@ async def call_with_retries(
|
||||
system_message,
|
||||
reasoning_effort,
|
||||
top_p,
|
||||
top_k,
|
||||
min_p,
|
||||
enable_thinking,
|
||||
)
|
||||
except Exception as e:
|
||||
is_conn_error = isinstance(e, (httpx.ConnectError, httpx.RemoteProtocolError, ConnectionRefusedError, OSError))
|
||||
if is_conn_error and attempt >= INSTANCE_HEALTH_CHECK_AFTER:
|
||||
if not await _check_instance_health(base_url):
|
||||
if instance_failed:
|
||||
instance_failed.set()
|
||||
raise InstanceFailedError(f"Instance is down after {attempt + 1} failures: {e}")
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = min(2**attempt, 60)
|
||||
logger.warning(
|
||||
@@ -619,10 +677,16 @@ async def evaluate_benchmark(
|
||||
max_tokens: int,
|
||||
concurrency: int = 1,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
timeout: float | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
top_p: float | None = None,
|
||||
top_k: int | None = None,
|
||||
min_p: float | None = None,
|
||||
enable_thinking: bool | None = None,
|
||||
difficulty: str | None = None,
|
||||
checkpoint_path: Path | None = None,
|
||||
release_version: str | None = None,
|
||||
) -> list[QuestionResult]:
|
||||
"""Run a benchmark. Returns per-question results."""
|
||||
import datasets
|
||||
@@ -653,7 +717,21 @@ async def evaluate_benchmark(
|
||||
ds = ds.filter(lambda x: x["difficulty"] == difficulty)
|
||||
logger.info(f"Filtered to {len(ds)} {difficulty} problems")
|
||||
|
||||
if release_version and "release_version" in ds.column_names:
|
||||
ds = ds.filter(lambda x: x["release_version"] == release_version)
|
||||
logger.info(
|
||||
f"Filtered to {len(ds)} problems with release_version={release_version}"
|
||||
)
|
||||
|
||||
# Sort by question_id to match LCB runner ordering (scenario_router.py:60).
|
||||
# This ensures [offset:offset+limit] slices select the same problems as vllm.
|
||||
if "question_id" in ds.column_names:
|
||||
ds = ds.sort("question_id")
|
||||
|
||||
total = len(ds)
|
||||
if offset > 0:
|
||||
ds = ds.select(range(min(offset, total), total))
|
||||
total = len(ds)
|
||||
if limit and limit < total:
|
||||
ds = ds.select(range(limit))
|
||||
total = limit
|
||||
@@ -661,6 +739,13 @@ async def evaluate_benchmark(
|
||||
logger.info(
|
||||
f"Evaluating {benchmark_name}: {total} questions, concurrency={concurrency}, "
|
||||
f"temperature={temperature}, max_tokens={max_tokens}"
|
||||
+ (f", top_k={top_k}" if top_k is not None else "")
|
||||
+ (f", min_p={min_p}" if min_p is not None else "")
|
||||
+ (
|
||||
f", enable_thinking={enable_thinking}"
|
||||
if enable_thinking is not None
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
if config.kind == "code":
|
||||
@@ -668,16 +753,64 @@ async def evaluate_benchmark(
|
||||
"Code benchmarks execute model-generated code. Use a sandboxed environment."
|
||||
)
|
||||
|
||||
# Load checkpoint for resume
|
||||
checkpoint_data: dict[str | int, dict[str, Any]] = {}
|
||||
if checkpoint_path and checkpoint_path.exists():
|
||||
with open(checkpoint_path) as f:
|
||||
for line in f:
|
||||
entry = json.loads(line)
|
||||
checkpoint_data[entry["question_id"]] = entry
|
||||
logger.info(f"Loaded {len(checkpoint_data)} checkpointed results")
|
||||
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
instance_failed = asyncio.Event()
|
||||
results: list[QuestionResult | None] = [None] * total
|
||||
completed = 0
|
||||
lock = asyncio.Lock()
|
||||
|
||||
def _get_question_id(idx: int, doc: dict) -> str | int:
|
||||
"""Get a stable question ID for checkpointing."""
|
||||
if benchmark_name == "livecodebench":
|
||||
return doc.get("question_id", idx)
|
||||
elif benchmark_name == "humaneval":
|
||||
return doc.get("task_id", idx)
|
||||
return idx
|
||||
|
||||
async def process_question(
|
||||
idx: int, doc: dict, http_client: httpx.AsyncClient
|
||||
) -> None:
|
||||
nonlocal completed
|
||||
system_msg = None
|
||||
question_id = _get_question_id(idx, doc)
|
||||
|
||||
# Bail out early if instance is already dead
|
||||
if instance_failed.is_set():
|
||||
return
|
||||
|
||||
# Check checkpoint
|
||||
if question_id in checkpoint_data:
|
||||
cached = checkpoint_data[question_id]
|
||||
results[idx] = QuestionResult(
|
||||
question_id=question_id,
|
||||
prompt=cached.get("prompt", ""),
|
||||
response=cached.get("response", ""),
|
||||
extracted_answer=cached.get("extracted_answer"),
|
||||
gold_answer=cached.get("gold_answer", ""),
|
||||
correct=cached.get("correct", False),
|
||||
error=cached.get("error"),
|
||||
prompt_tokens=cached.get("prompt_tokens", 0),
|
||||
completion_tokens=cached.get("completion_tokens", 0),
|
||||
reasoning_tokens=cached.get("reasoning_tokens", 0),
|
||||
reasoning_content=cached.get("reasoning_content", ""),
|
||||
finish_reason=cached.get("finish_reason", ""),
|
||||
elapsed_s=cached.get("elapsed_s", 0.0),
|
||||
power_watts=cached.get("power_watts", 0.0),
|
||||
energy_joules=cached.get("energy_joules", 0.0),
|
||||
)
|
||||
async with lock:
|
||||
completed += 1
|
||||
logger.info(f" [{completed}/{total}] {question_id} (cached)")
|
||||
return
|
||||
|
||||
if benchmark_name == "gpqa_diamond":
|
||||
prompt, gold = format_gpqa_question(doc, idx)
|
||||
@@ -698,24 +831,48 @@ async def evaluate_benchmark(
|
||||
raise ValueError(f"Unknown benchmark: {benchmark_name}")
|
||||
|
||||
async with semaphore:
|
||||
if instance_failed.is_set():
|
||||
return
|
||||
t0 = time.monotonic()
|
||||
api_result = await call_with_retries(
|
||||
http_client,
|
||||
base_url,
|
||||
model,
|
||||
prompt,
|
||||
temperature,
|
||||
max_tokens,
|
||||
timeout,
|
||||
system_message=system_msg,
|
||||
reasoning_effort=reasoning_effort,
|
||||
top_p=top_p,
|
||||
)
|
||||
try:
|
||||
# Race the API call against the instance_failed event
|
||||
api_task = asyncio.create_task(call_with_retries(
|
||||
http_client,
|
||||
base_url,
|
||||
model,
|
||||
prompt,
|
||||
temperature,
|
||||
max_tokens,
|
||||
timeout,
|
||||
system_message=system_msg,
|
||||
reasoning_effort=reasoning_effort,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
min_p=min_p,
|
||||
enable_thinking=enable_thinking,
|
||||
instance_failed=instance_failed,
|
||||
))
|
||||
failed_waiter = asyncio.create_task(instance_failed.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
[api_task, failed_waiter],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for p in pending:
|
||||
p.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await p
|
||||
if instance_failed.is_set() and api_task not in done:
|
||||
logger.error(f"Instance failed, aborting {question_id}")
|
||||
return
|
||||
api_result = api_task.result()
|
||||
except InstanceFailedError:
|
||||
logger.error(f"Instance failed, skipping {question_id}")
|
||||
return
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
if api_result is None:
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response="",
|
||||
extracted_answer=None,
|
||||
@@ -730,13 +887,17 @@ async def evaluate_benchmark(
|
||||
"prompt_tokens": api_result.prompt_tokens,
|
||||
"completion_tokens": api_result.completion_tokens,
|
||||
"reasoning_tokens": api_result.reasoning_tokens,
|
||||
"reasoning_content": api_result.reasoning_content,
|
||||
"finish_reason": api_result.finish_reason,
|
||||
"elapsed_s": elapsed,
|
||||
"power_watts": api_result.power_watts,
|
||||
"energy_joules": api_result.energy_joules,
|
||||
}
|
||||
|
||||
if config.kind == "mc":
|
||||
extracted = extract_mc_answer(response, valid_letters)
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer=extracted,
|
||||
@@ -750,7 +911,7 @@ async def evaluate_benchmark(
|
||||
check_aime_answer(extracted, int(gold)) if extracted else False
|
||||
)
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer=extracted,
|
||||
@@ -764,7 +925,7 @@ async def evaluate_benchmark(
|
||||
code = extract_code_block(response, preserve_indent=keep_indent)
|
||||
if code is None:
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer=None,
|
||||
@@ -779,7 +940,7 @@ async def evaluate_benchmark(
|
||||
code,
|
||||
)
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer="pass" if passed else "fail",
|
||||
@@ -794,7 +955,7 @@ async def evaluate_benchmark(
|
||||
exec_meta["sample"],
|
||||
)
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer="pass" if passed else "fail",
|
||||
@@ -805,7 +966,7 @@ async def evaluate_benchmark(
|
||||
)
|
||||
else:
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer=None,
|
||||
@@ -816,7 +977,7 @@ async def evaluate_benchmark(
|
||||
)
|
||||
else:
|
||||
result = QuestionResult(
|
||||
question_id=idx,
|
||||
question_id=question_id,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
extracted_answer=None,
|
||||
@@ -828,24 +989,80 @@ async def evaluate_benchmark(
|
||||
|
||||
results[idx] = result
|
||||
|
||||
# Write checkpoint (skip failures so they get retried on resume)
|
||||
if checkpoint_path is not None and not result.error and result.response:
|
||||
_write_checkpoint(checkpoint_path, result)
|
||||
|
||||
async with lock:
|
||||
completed += 1
|
||||
n = completed
|
||||
if n % max(1, total // 20) == 0 or n == total:
|
||||
correct_so_far = sum(1 for r in results if r is not None and r.correct)
|
||||
answered = sum(1 for r in results if r is not None)
|
||||
logger.info(
|
||||
f" [{n}/{total}] {correct_so_far}/{answered} correct "
|
||||
f"({correct_so_far / max(answered, 1):.1%})"
|
||||
)
|
||||
|
||||
# Log progress
|
||||
thinking_info = ""
|
||||
if result.reasoning_content:
|
||||
thinking_info = f", {len(result.reasoning_content)} chars thinking"
|
||||
logger.info(
|
||||
f" [{n}/{total}] {question_id}: {len(result.response)} chars{thinking_info}, "
|
||||
f"tokens: {result.prompt_tokens}+{result.completion_tokens} "
|
||||
f"[{result.finish_reason}]"
|
||||
+ (f" {result.extracted_answer}" if result.extracted_answer else "")
|
||||
)
|
||||
|
||||
async def _health_monitor() -> None:
|
||||
"""Periodically check if the instance is still alive."""
|
||||
# Wait a bit before first check to let things start
|
||||
await asyncio.sleep(10)
|
||||
while not instance_failed.is_set():
|
||||
if not await _check_instance_health(base_url):
|
||||
# Double-check to avoid false positives
|
||||
await asyncio.sleep(2)
|
||||
if not await _check_instance_health(base_url):
|
||||
logger.error("Health monitor: instance is down!")
|
||||
instance_failed.set()
|
||||
return
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
monitor = asyncio.create_task(_health_monitor())
|
||||
tasks = [process_question(i, doc, http_client) for i, doc in enumerate(ds)]
|
||||
await asyncio.gather(*tasks)
|
||||
monitor.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await monitor
|
||||
|
||||
if instance_failed.is_set():
|
||||
completed_count = sum(1 for r in results if r is not None)
|
||||
logger.error(
|
||||
f"Instance failed! Completed {completed_count}/{total} problems. "
|
||||
f"Checkpoint saved — restart to resume remaining problems."
|
||||
)
|
||||
raise InstanceFailedError("Instance failed during evaluation")
|
||||
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
|
||||
def _write_checkpoint(path: Path, result: QuestionResult) -> None:
|
||||
"""Append a single result to the JSONL checkpoint file."""
|
||||
entry = {
|
||||
"question_id": result.question_id,
|
||||
"response": result.response,
|
||||
"extracted_answer": result.extracted_answer,
|
||||
"gold_answer": result.gold_answer,
|
||||
"correct": result.correct,
|
||||
"error": result.error,
|
||||
"prompt_tokens": result.prompt_tokens,
|
||||
"completion_tokens": result.completion_tokens,
|
||||
"reasoning_tokens": result.reasoning_tokens,
|
||||
"reasoning_content": result.reasoning_content,
|
||||
"finish_reason": result.finish_reason,
|
||||
"elapsed_s": round(result.elapsed_s, 2),
|
||||
"power_watts": round(result.power_watts, 2),
|
||||
"energy_joules": round(result.energy_joules, 2),
|
||||
}
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Results display
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -868,6 +1085,8 @@ def print_results(
|
||||
total_elapsed = sum(r.elapsed_s for r in results)
|
||||
wall_clock = max(r.elapsed_s for r in results) if results else 0.0
|
||||
avg_gen_tps = total_completion_tokens / total_elapsed if total_elapsed > 0 else 0.0
|
||||
total_energy = sum(r.energy_joules for r in results)
|
||||
avg_power = sum(r.power_watts for r in results) / max(total, 1)
|
||||
|
||||
label = f"[c={concurrency}] " if concurrency is not None else ""
|
||||
print(f"\n{label}{benchmark_name}: {correct}/{total} ({accuracy:.1%})")
|
||||
@@ -879,6 +1098,10 @@ def print_results(
|
||||
f" | total time: {total_elapsed:.1f}s wall clock: {wall_clock:.1f}s"
|
||||
)
|
||||
print(tok_line)
|
||||
if total_energy > 0:
|
||||
print(
|
||||
f" power: avg {avg_power:.1f}W | total energy: {total_energy:.1f}J ({total_energy / 3600:.2f}Wh)"
|
||||
)
|
||||
if errors:
|
||||
print(f" API errors: {errors}")
|
||||
if no_extract:
|
||||
@@ -897,6 +1120,8 @@ def print_results(
|
||||
"total_elapsed_s": total_elapsed,
|
||||
"wall_clock_s": wall_clock,
|
||||
"avg_gen_tps": avg_gen_tps,
|
||||
"avg_power_watts": avg_power,
|
||||
"total_energy_joules": total_energy,
|
||||
}
|
||||
|
||||
|
||||
@@ -1029,16 +1254,18 @@ def save_results(
|
||||
concurrency: int,
|
||||
results: list[QuestionResult],
|
||||
scores: dict[str, Any],
|
||||
cluster: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark_name
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
path = out_dir / f"c{concurrency}_{ts}.json"
|
||||
|
||||
data = {
|
||||
data: dict[str, Any] = {
|
||||
"benchmark": benchmark_name,
|
||||
"model": model,
|
||||
"concurrency": concurrency,
|
||||
**({"cluster": cluster} if cluster else {}),
|
||||
"scores": scores,
|
||||
"results": [
|
||||
{
|
||||
@@ -1052,7 +1279,11 @@ def save_results(
|
||||
"prompt_tokens": r.prompt_tokens,
|
||||
"completion_tokens": r.completion_tokens,
|
||||
"reasoning_tokens": r.reasoning_tokens,
|
||||
"reasoning_content": r.reasoning_content,
|
||||
"finish_reason": r.finish_reason,
|
||||
"elapsed_s": round(r.elapsed_s, 2),
|
||||
"power_watts": round(r.power_watts, 2),
|
||||
"energy_joules": round(r.energy_joules, 2),
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
@@ -1068,6 +1299,32 @@ def save_results(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_existing_instance(client: ExoClient, model_id: str) -> str | None:
|
||||
"""Find an existing instance for the given model."""
|
||||
try:
|
||||
state = client.request_json("GET", "/state")
|
||||
except Exception:
|
||||
return None
|
||||
for inst_id, inst in state.get("instances", {}).items():
|
||||
# Instance structure is nested: {"MlxJacclInstance": {"shardAssignments": {"modelId": ...}}}
|
||||
for _inst_type, inner in inst.items():
|
||||
if not isinstance(inner, dict):
|
||||
continue
|
||||
sa = inner.get("shardAssignments", {})
|
||||
if sa.get("modelId") == model_id:
|
||||
return inst_id
|
||||
return None
|
||||
|
||||
|
||||
def _checkpoint_path(
|
||||
results_dir: str, benchmark: str, model: str, concurrency: int
|
||||
) -> Path:
|
||||
"""Return the JSONL checkpoint path for a benchmark run."""
|
||||
out_dir = Path(results_dir) / model.replace("/", "_") / benchmark
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
return out_dir / f"c{concurrency}.checkpoint.jsonl"
|
||||
|
||||
|
||||
def parse_int_list(values: list[str]) -> list[int]:
|
||||
items: list[int] = []
|
||||
for v in values:
|
||||
@@ -1095,6 +1352,12 @@ def main() -> int:
|
||||
default=None,
|
||||
help="Max questions per benchmark (for fast iteration).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--offset",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Skip first N questions (0-based).",
|
||||
)
|
||||
|
||||
reasoning_group = ap.add_mutually_exclusive_group()
|
||||
reasoning_group.add_argument(
|
||||
@@ -1114,6 +1377,8 @@ def main() -> int:
|
||||
"--temperature", type=float, default=None, help="Override temperature."
|
||||
)
|
||||
ap.add_argument("--top-p", type=float, default=None, help="Override top_p.")
|
||||
ap.add_argument("--top-k", type=int, default=None, help="Override top_k.")
|
||||
ap.add_argument("--min-p", type=float, default=None, help="Override min_p.")
|
||||
ap.add_argument(
|
||||
"--max-tokens", type=int, default=None, help="Override max output tokens."
|
||||
)
|
||||
@@ -1147,19 +1412,44 @@ def main() -> int:
|
||||
choices=["easy", "medium", "hard"],
|
||||
help="Filter by difficulty (livecodebench only). E.g. --difficulty hard",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--release-version",
|
||||
default=None,
|
||||
help="LCB dataset release version (livecodebench only). E.g. release_v5",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--results-dir",
|
||||
default="eval_results",
|
||||
help="Directory for result JSON files (default: eval_results).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--enable-thinking",
|
||||
type=lambda v: v.lower() in ("true", "1", "yes"),
|
||||
default=None,
|
||||
help="Enable thinking mode for models that support it.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--skip-instance-setup",
|
||||
action="store_true",
|
||||
help="Skip exo instance management (assumes model is already running).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Discard any existing checkpoint and run from scratch.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--keep-instance",
|
||||
action="store_true",
|
||||
help="Skip deleting the instance after eval (for chaining runs).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--reuse-instance",
|
||||
action="store_true",
|
||||
help="Reuse an existing ready instance instead of creating a new one.",
|
||||
)
|
||||
|
||||
args, _ = ap.parse_known_args()
|
||||
validate_vllm_args(args)
|
||||
|
||||
# Resolve tasks
|
||||
if args.tasks:
|
||||
@@ -1176,68 +1466,91 @@ def main() -> int:
|
||||
|
||||
# Instance management
|
||||
client = ExoClient(args.host, args.port, timeout_s=args.timeout)
|
||||
if args.ensure_cuda:
|
||||
ensure_cuda_available(client)
|
||||
instance_id: str | None = None
|
||||
|
||||
if not args.skip_instance_setup:
|
||||
short_id, full_model_id = resolve_model_short_id(
|
||||
_short_id, full_model_id = resolve_model_short_id(
|
||||
client,
|
||||
args.model,
|
||||
force_download=args.force_download,
|
||||
)
|
||||
selected = settle_and_fetch_placements(
|
||||
client,
|
||||
full_model_id,
|
||||
args,
|
||||
settle_timeout=args.settle_timeout,
|
||||
)
|
||||
if not selected:
|
||||
logger.error("No valid placements matched your filters.")
|
||||
return 1
|
||||
|
||||
selected.sort(
|
||||
key=lambda p: (
|
||||
str(p.get("instance_meta", "")),
|
||||
str(p.get("sharding", "")),
|
||||
-nodes_used_in_instance(p["instance"]),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
preview = selected[0]
|
||||
instance = preview["instance"]
|
||||
instance_id = instance_id_from_instance(instance)
|
||||
# Try to reuse an existing instance if --reuse-instance is set
|
||||
if args.reuse_instance:
|
||||
existing = _find_existing_instance(client, full_model_id)
|
||||
if existing:
|
||||
instance_id = existing
|
||||
logger.info(f"Reusing existing instance {instance_id}")
|
||||
|
||||
logger.info(
|
||||
f"PLACEMENT: {preview['sharding']} / {preview['instance_meta']} / "
|
||||
f"nodes={nodes_used_in_instance(instance)}"
|
||||
)
|
||||
if instance_id is None:
|
||||
selected = settle_and_fetch_placements(
|
||||
client,
|
||||
full_model_id,
|
||||
args,
|
||||
settle_timeout=args.settle_timeout,
|
||||
)
|
||||
if not selected:
|
||||
logger.error("No valid placements matched your filters.")
|
||||
return 1
|
||||
|
||||
settle_deadline = (
|
||||
time.monotonic() + args.settle_timeout if args.settle_timeout > 0 else None
|
||||
)
|
||||
download_duration = run_planning_phase(
|
||||
client,
|
||||
full_model_id,
|
||||
preview,
|
||||
args.danger_delete_downloads,
|
||||
args.timeout,
|
||||
settle_deadline,
|
||||
)
|
||||
if download_duration is not None:
|
||||
logger.info(f"Download: {download_duration:.1f}s")
|
||||
selected.sort(
|
||||
key=lambda p: (
|
||||
str(p.get("instance_meta", "")),
|
||||
str(p.get("sharding", "")),
|
||||
nodes_used_in_instance(p["instance"]),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
preview = selected[0]
|
||||
instance = preview["instance"]
|
||||
instance_id = instance_id_from_instance(instance)
|
||||
|
||||
client.request_json("POST", "/instance", body={"instance": instance})
|
||||
try:
|
||||
wait_for_instance_ready(client, instance_id)
|
||||
except (RuntimeError, TimeoutError) as e:
|
||||
logger.error(f"Failed to initialize: {e}")
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
return 1
|
||||
time.sleep(1)
|
||||
logger.info(
|
||||
f"PLACEMENT: {preview['sharding']} / {preview['instance_meta']} / "
|
||||
f"nodes={nodes_used_in_instance(instance)}"
|
||||
)
|
||||
|
||||
settle_deadline = (
|
||||
time.monotonic() + args.settle_timeout
|
||||
if args.settle_timeout > 0
|
||||
else None
|
||||
)
|
||||
download_duration = run_planning_phase(
|
||||
client,
|
||||
full_model_id,
|
||||
preview,
|
||||
args.danger_delete_downloads,
|
||||
args.timeout,
|
||||
settle_deadline,
|
||||
)
|
||||
if download_duration is not None:
|
||||
logger.info(f"Download: {download_duration:.1f}s")
|
||||
|
||||
# Delete any existing instances to free resources before placing
|
||||
try:
|
||||
state = client.request_json("GET", "/state")
|
||||
for old_id in list(state.get("instances", {}).keys()):
|
||||
logger.info(f"Deleting stale instance {old_id}")
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{old_id}")
|
||||
if state.get("instances"):
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client.request_json("POST", "/instance", body={"instance": instance})
|
||||
try:
|
||||
wait_for_instance_ready(client, instance_id)
|
||||
except (RuntimeError, TimeoutError) as e:
|
||||
logger.error(f"Failed to initialize: {e}")
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
return 1
|
||||
time.sleep(1)
|
||||
cluster_snapshot = capture_cluster_snapshot(client)
|
||||
else:
|
||||
full_model_id = args.model
|
||||
cluster_snapshot = None
|
||||
|
||||
# Auto-detect reasoning from model config
|
||||
model_config = load_model_config(full_model_id)
|
||||
@@ -1291,16 +1604,57 @@ def main() -> int:
|
||||
reasoning_effort = str(cfg["reasoning_effort"])
|
||||
else:
|
||||
reasoning_effort = "high" if is_reasoning else None
|
||||
|
||||
if args.top_k is not None:
|
||||
top_k: int | None = args.top_k
|
||||
elif "top_k" in cfg:
|
||||
top_k = int(cfg["top_k"])
|
||||
else:
|
||||
top_k = None
|
||||
|
||||
if args.min_p is not None:
|
||||
min_p: float | None = args.min_p
|
||||
elif "min_p" in cfg:
|
||||
min_p = float(cfg["min_p"])
|
||||
else:
|
||||
min_p = None
|
||||
|
||||
if args.enable_thinking is not None:
|
||||
enable_thinking: bool | None = args.enable_thinking
|
||||
elif "enable_thinking" in cfg:
|
||||
enable_thinking = bool(cfg["enable_thinking"])
|
||||
else:
|
||||
enable_thinking = None
|
||||
|
||||
base_url = f"http://{args.host}:{args.port}"
|
||||
|
||||
logger.info(f"Model: {full_model_id}")
|
||||
logger.info(
|
||||
f"Settings: temperature={temperature}, max_tokens={max_tokens}, "
|
||||
+ (f"top_p={top_p}, " if top_p is not None else "")
|
||||
+ (f"top_k={top_k}, " if top_k is not None else "")
|
||||
+ (f"min_p={min_p}, " if min_p is not None else "")
|
||||
+ f"reasoning={'yes' if is_reasoning else 'no'}"
|
||||
+ (f", reasoning_effort={reasoning_effort}" if reasoning_effort else "")
|
||||
+ (
|
||||
f", enable_thinking={enable_thinking}"
|
||||
if enable_thinking is not None
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
# Common kwargs for evaluate_benchmark
|
||||
eval_kwargs: dict[str, Any] = {
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"top_p": top_p,
|
||||
"top_k": top_k,
|
||||
"min_p": min_p,
|
||||
"enable_thinking": enable_thinking,
|
||||
"difficulty": args.difficulty,
|
||||
"offset": args.offset,
|
||||
"release_version": args.release_version,
|
||||
}
|
||||
|
||||
try:
|
||||
if args.compare_concurrency:
|
||||
concurrency_levels = parse_int_list(args.compare_concurrency)
|
||||
@@ -1309,6 +1663,11 @@ def main() -> int:
|
||||
for c in concurrency_levels:
|
||||
logger.info(f"\n{'=' * 50}")
|
||||
logger.info(f"Running {task_name} at concurrency={c}")
|
||||
checkpoint_path = _checkpoint_path(
|
||||
args.results_dir, task_name, full_model_id, c
|
||||
)
|
||||
if args.force and checkpoint_path.exists():
|
||||
checkpoint_path.unlink()
|
||||
results = asyncio.run(
|
||||
evaluate_benchmark(
|
||||
task_name,
|
||||
@@ -1319,9 +1678,8 @@ def main() -> int:
|
||||
concurrency=c,
|
||||
limit=args.limit,
|
||||
timeout=args.request_timeout,
|
||||
reasoning_effort=reasoning_effort,
|
||||
top_p=top_p,
|
||||
difficulty=args.difficulty,
|
||||
checkpoint_path=checkpoint_path,
|
||||
**eval_kwargs,
|
||||
)
|
||||
)
|
||||
if results:
|
||||
@@ -1333,12 +1691,21 @@ def main() -> int:
|
||||
c,
|
||||
results,
|
||||
scores,
|
||||
cluster=cluster_snapshot,
|
||||
)
|
||||
results_by_c[c] = results
|
||||
# Clean up checkpoint on success
|
||||
if checkpoint_path.exists():
|
||||
checkpoint_path.unlink()
|
||||
if len(results_by_c) >= 2:
|
||||
print_comparison(task_name, results_by_c)
|
||||
else:
|
||||
for task_name in task_names:
|
||||
checkpoint_path = _checkpoint_path(
|
||||
args.results_dir, task_name, full_model_id, args.num_concurrent
|
||||
)
|
||||
if args.force and checkpoint_path.exists():
|
||||
checkpoint_path.unlink()
|
||||
results = asyncio.run(
|
||||
evaluate_benchmark(
|
||||
task_name,
|
||||
@@ -1349,9 +1716,8 @@ def main() -> int:
|
||||
concurrency=args.num_concurrent,
|
||||
limit=args.limit,
|
||||
timeout=args.request_timeout,
|
||||
reasoning_effort=reasoning_effort,
|
||||
top_p=top_p,
|
||||
difficulty=args.difficulty,
|
||||
checkpoint_path=checkpoint_path,
|
||||
**eval_kwargs,
|
||||
)
|
||||
)
|
||||
if results:
|
||||
@@ -1363,15 +1729,27 @@ def main() -> int:
|
||||
args.num_concurrent,
|
||||
results,
|
||||
scores,
|
||||
cluster=cluster_snapshot,
|
||||
)
|
||||
# Clean up checkpoint on success
|
||||
if checkpoint_path.exists():
|
||||
checkpoint_path.unlink()
|
||||
finally:
|
||||
if instance_id is not None:
|
||||
try:
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
except ExoHttpError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
wait_for_instance_gone(client, instance_id)
|
||||
if args.keep_instance:
|
||||
logger.info(f"Keeping instance {instance_id} (--keep-instance)")
|
||||
else:
|
||||
try:
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
except ExoHttpError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
try:
|
||||
wait_for_instance_gone(client, instance_id)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Timed out waiting for instance {instance_id} to be deleted"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
+101
-66
@@ -69,6 +69,35 @@ class ExoClient:
|
||||
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.request_json("POST", "/bench/chat/completions", body=payload)
|
||||
|
||||
def get_state_path(self, path: str) -> Any:
|
||||
try:
|
||||
return self.request_json("GET", f"/state/{path}")
|
||||
except ExoHttpError as e:
|
||||
if e.status == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"instances/{instance_id}")
|
||||
|
||||
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"runners/{runner_id}")
|
||||
|
||||
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
|
||||
return self.get_state_path(f"downloads/{node_id}")
|
||||
|
||||
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"nodeDisk/{node_id}")
|
||||
|
||||
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"nodeSystem/{node_id}")
|
||||
|
||||
def get_node_identities(self) -> dict[str, Any] | None:
|
||||
return self.get_state_path("nodeIdentities")
|
||||
|
||||
def get_topology(self) -> dict[str, Any] | None:
|
||||
return self.get_state_path("topology")
|
||||
|
||||
|
||||
def unwrap_instance(instance: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(instance) != 1:
|
||||
@@ -97,6 +126,11 @@ def runner_ids_from_instance(instance: dict[str, Any]) -> list[str]:
|
||||
return list(runner_to_shard.keys())
|
||||
|
||||
|
||||
def node_ids_from_instance(instance: dict[str, Any]) -> list[str]:
|
||||
inner = unwrap_instance(instance)
|
||||
return list(inner["shardAssignments"]["nodeToRunner"].keys())
|
||||
|
||||
|
||||
def runner_ready(runner: dict[str, Any]) -> bool:
|
||||
return "RunnerReady" in runner
|
||||
|
||||
@@ -116,13 +150,12 @@ def wait_for_instance_ready(
|
||||
) -> None:
|
||||
start_time = time.time()
|
||||
instance_existed = False
|
||||
last_loaded: dict[str, int] = {}
|
||||
while time.time() - start_time < timeout:
|
||||
state = client.request_json("GET", "/state")
|
||||
instances = state.get("instances", {})
|
||||
instance = client.get_instance(instance_id)
|
||||
|
||||
if instance_id not in instances:
|
||||
if instance is None:
|
||||
if instance_existed:
|
||||
# Instance was deleted after being created - likely due to runner failure
|
||||
raise RuntimeError(
|
||||
f"Instance {instance_id} was deleted (runner may have failed)"
|
||||
)
|
||||
@@ -130,18 +163,25 @@ def wait_for_instance_ready(
|
||||
continue
|
||||
|
||||
instance_existed = True
|
||||
instance = instances[instance_id]
|
||||
runner_ids = runner_ids_from_instance(instance)
|
||||
runners = state.get("runners", {})
|
||||
rids = runner_ids_from_instance(instance)
|
||||
|
||||
# Check for failed runners first
|
||||
for rid in runner_ids:
|
||||
runner = runners.get(rid, {})
|
||||
all_ready = True
|
||||
for rid in rids:
|
||||
runner = client.get_runner(rid) or {}
|
||||
if runner_failed(runner):
|
||||
error_msg = get_runner_failed_message(runner) or "Unknown error"
|
||||
raise RuntimeError(f"Runner {rid} failed: {error_msg}")
|
||||
if "RunnerLoading" in runner:
|
||||
loading = runner["RunnerLoading"]
|
||||
loaded = loading.get("layersLoaded", 0)
|
||||
total = loading.get("totalLayers", 0)
|
||||
if total > 0 and last_loaded.get(rid) != loaded:
|
||||
last_loaded[rid] = loaded
|
||||
logger.debug(f"Runner {rid}: loading layers {loaded}/{total}")
|
||||
if not runner_ready(runner):
|
||||
all_ready = False
|
||||
|
||||
if all(runner_ready(runners.get(rid, {})) for rid in runner_ids):
|
||||
if all_ready:
|
||||
return
|
||||
|
||||
time.sleep(0.1)
|
||||
@@ -165,6 +205,23 @@ def wait_for_instance_gone(
|
||||
raise TimeoutError(f"Instance {instance_id} did not get deleted within {timeout=}")
|
||||
|
||||
|
||||
def capture_cluster_snapshot(client: ExoClient) -> dict[str, Any]:
|
||||
snapshot: dict[str, Any] = {}
|
||||
identities = client.get_node_identities()
|
||||
if identities:
|
||||
snapshot["nodeIdentities"] = identities
|
||||
topology = client.get_topology()
|
||||
if topology:
|
||||
snapshot["topology"] = topology
|
||||
node_memory = client.get_state_path("nodeMemory")
|
||||
if node_memory:
|
||||
snapshot["nodeMemory"] = node_memory
|
||||
node_system = client.get_state_path("nodeSystem")
|
||||
if node_system:
|
||||
snapshot["nodeSystem"] = node_system
|
||||
return snapshot
|
||||
|
||||
|
||||
def resolve_model_short_id(
|
||||
client: ExoClient, model_arg: str, *, force_download: bool = False
|
||||
) -> tuple[str, str]:
|
||||
@@ -196,31 +253,6 @@ def resolve_model_short_id(
|
||||
raise ValueError(f"Model not found in /models: {model_arg}")
|
||||
|
||||
|
||||
def validate_vllm_args(args: argparse.Namespace) -> None:
|
||||
if args.instance_meta != "vllm":
|
||||
return
|
||||
if args.sharding == "tensor":
|
||||
raise SystemExit(
|
||||
"--instance-meta vllm is incompatible with --sharding tensor (vllm is pipeline-only)"
|
||||
)
|
||||
if args.min_nodes > 1:
|
||||
raise SystemExit(
|
||||
"--instance-meta vllm is incompatible with --min-nodes > 1 (vllm is single-node)"
|
||||
)
|
||||
if args.max_nodes > 1:
|
||||
raise SystemExit(
|
||||
"--instance-meta vllm is incompatible with --max-nodes > 1 (vllm is single-node)"
|
||||
)
|
||||
|
||||
|
||||
def ensure_cuda_available(client: ExoClient) -> None:
|
||||
capabilities = client.request_json("GET", "/capabilities")
|
||||
if not capabilities or not capabilities.get("vllm_available"):
|
||||
raise SystemExit(
|
||||
"--ensure-cuda: vllm is not available on the exo cluster (no CUDA capability)"
|
||||
)
|
||||
|
||||
|
||||
def placement_filter(instance_meta: str, wanted: str) -> bool:
|
||||
s = (instance_meta or "").lower()
|
||||
if wanted == "both":
|
||||
@@ -351,16 +383,11 @@ def run_planning_phase(
|
||||
node_ids = list(inner["shardAssignments"]["nodeToRunner"].keys())
|
||||
runner_to_shard = inner["shardAssignments"]["runnerToShard"]
|
||||
|
||||
state = client.request_json("GET", "/state")
|
||||
downloads = state.get("downloads", {})
|
||||
node_disk = state.get("nodeDisk", {})
|
||||
|
||||
needs_download = False
|
||||
|
||||
for node_id in node_ids:
|
||||
node_downloads = downloads.get(node_id, [])
|
||||
node_downloads = client.get_node_downloads(node_id) or []
|
||||
|
||||
# Check if model already downloaded on this node
|
||||
already_downloaded = any(
|
||||
"DownloadCompleted" in p
|
||||
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
|
||||
@@ -374,8 +401,7 @@ def run_planning_phase(
|
||||
|
||||
needs_download = True
|
||||
|
||||
# Wait for disk info if settle_deadline is set
|
||||
disk_info = node_disk.get(node_id, {})
|
||||
disk_info = client.get_node_disk(node_id) or {}
|
||||
backoff = _SETTLE_INITIAL_BACKOFF_S
|
||||
while not disk_info and settle_deadline and time.monotonic() < settle_deadline:
|
||||
remaining = settle_deadline - time.monotonic()
|
||||
@@ -384,9 +410,7 @@ def run_planning_phase(
|
||||
)
|
||||
time.sleep(min(backoff, remaining))
|
||||
backoff = min(backoff * _SETTLE_BACKOFF_MULTIPLIER, _SETTLE_MAX_BACKOFF_S)
|
||||
state = client.request_json("GET", "/state")
|
||||
node_disk = state.get("nodeDisk", {})
|
||||
disk_info = node_disk.get(node_id, {})
|
||||
disk_info = client.get_node_disk(node_id) or {}
|
||||
|
||||
if not disk_info:
|
||||
logger.warning(f"No disk info for {node_id}, skipping space check")
|
||||
@@ -402,7 +426,6 @@ def run_planning_phase(
|
||||
f"have {avail // (1024**3)}GB. Use --danger-delete-downloads to free space."
|
||||
)
|
||||
|
||||
# Delete from smallest to largest (skip read-only models from EXO_MODELS_PATH)
|
||||
completed = [
|
||||
(
|
||||
unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][
|
||||
@@ -439,24 +462,22 @@ def run_planning_phase(
|
||||
)
|
||||
logger.info(f"Started download on {node_id}")
|
||||
|
||||
# Wait for downloads
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
state = client.request_json("GET", "/state")
|
||||
downloads = state.get("downloads", {})
|
||||
# Wait for downloads (no timeout — poll until complete or failed)
|
||||
while True:
|
||||
all_done = True
|
||||
for node_id in node_ids:
|
||||
node_downloads = client.get_node_downloads(node_id) or []
|
||||
done = any(
|
||||
"DownloadCompleted" in p
|
||||
and unwrap_instance(p["DownloadCompleted"]["shardMetadata"])[
|
||||
"modelCard"
|
||||
]["modelId"]
|
||||
== full_model_id
|
||||
for p in downloads.get(node_id, [])
|
||||
for p in node_downloads
|
||||
)
|
||||
failed = [
|
||||
p["DownloadFailed"]["errorMessage"]
|
||||
for p in downloads.get(node_id, [])
|
||||
for p in node_downloads
|
||||
if "DownloadFailed" in p
|
||||
and unwrap_instance(p["DownloadFailed"]["shardMetadata"])["modelCard"][
|
||||
"modelId"
|
||||
@@ -467,13 +488,32 @@ def run_planning_phase(
|
||||
raise RuntimeError(f"Download failed on {node_id}: {failed[0]}")
|
||||
if not done:
|
||||
all_done = False
|
||||
ongoing = [
|
||||
p
|
||||
for p in node_downloads
|
||||
if "DownloadOngoing" in p
|
||||
and unwrap_instance(p["DownloadOngoing"]["shardMetadata"])[
|
||||
"modelCard"
|
||||
]["modelId"]
|
||||
== full_model_id
|
||||
]
|
||||
if ongoing:
|
||||
prog = ongoing[0]["DownloadOngoing"]["downloadProgress"]
|
||||
speed_mb = prog.get("speed", 0) / (1024 * 1024)
|
||||
eta_s = prog.get("etaMs", 0) / 1000
|
||||
dl_bytes = prog.get("downloaded", {}).get("inBytes", 0)
|
||||
total_bytes = prog.get("total", {}).get("inBytes", 0)
|
||||
pct = (dl_bytes / total_bytes * 100) if total_bytes else 0
|
||||
logger.info(
|
||||
f"Downloading on {node_id}: {pct:.1f}% @ {speed_mb:.1f} MB/s, "
|
||||
f"ETA {eta_s:.0f}s "
|
||||
f"({prog.get('completedFiles', 0)}/{prog.get('totalFiles', 0)} files)"
|
||||
)
|
||||
if all_done:
|
||||
if download_t0 is not None:
|
||||
return time.perf_counter() - download_t0
|
||||
return None
|
||||
time.sleep(1)
|
||||
|
||||
raise TimeoutError("Downloads did not complete in time")
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
|
||||
@@ -500,7 +540,7 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
|
||||
help="Only consider placements using >= this many nodes.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--instance-meta", choices=["ring", "jaccl", "vllm", "both"], default="both"
|
||||
"--instance-meta", choices=["ring", "jaccl", "both"], default="both"
|
||||
)
|
||||
ap.add_argument(
|
||||
"--sharding", choices=["pipeline", "tensor", "both"], default="both"
|
||||
@@ -521,16 +561,11 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
|
||||
ap.add_argument(
|
||||
"--settle-timeout",
|
||||
type=float,
|
||||
default=0,
|
||||
help="Max seconds to wait for the cluster to produce valid placements (0 = try once).",
|
||||
default=7200,
|
||||
help="Max seconds to wait for the cluster to produce valid placements (default: 7200).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--danger-delete-downloads",
|
||||
action="store_true",
|
||||
help="Delete existing models from smallest to largest to make room for benchmark model.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--ensure-cuda",
|
||||
action="store_true",
|
||||
help="Verify the exo cluster has CUDA/vllm capability; error if not.",
|
||||
)
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
# type: ignore
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import mlx.core as mx
|
||||
|
||||
DTYPE_MAP = {
|
||||
"float32": (mx.float32, 4),
|
||||
"float16": (mx.float16, 2),
|
||||
"bfloat16": (mx.bfloat16, 2),
|
||||
}
|
||||
|
||||
SIZES = [
|
||||
1 * 1024,
|
||||
4 * 1024,
|
||||
16 * 1024,
|
||||
64 * 1024,
|
||||
256 * 1024,
|
||||
1 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
16 * 1024 * 1024,
|
||||
64 * 1024 * 1024,
|
||||
256 * 1024 * 1024,
|
||||
1 * 1024 * 1024 * 1024,
|
||||
2 * 1024 * 1024 * 1024,
|
||||
4 * 1024 * 1024 * 1024,
|
||||
8 * 1024 * 1024 * 1024,
|
||||
]
|
||||
|
||||
|
||||
def format_bytes(n: int) -> str:
|
||||
if n >= 1024 * 1024 * 1024:
|
||||
return f"{n / (1024 * 1024 * 1024):.0f} GB"
|
||||
if n >= 1024 * 1024:
|
||||
return f"{n / (1024 * 1024):.0f} MB"
|
||||
if n >= 1024:
|
||||
return f"{n / 1024:.0f} KB"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def format_time(seconds: float) -> str:
|
||||
if seconds >= 1.0:
|
||||
return f"{seconds:.3f} s"
|
||||
if seconds >= 0.001:
|
||||
return f"{seconds * 1000:.2f} ms"
|
||||
return f"{seconds * 1_000_000:.1f} us"
|
||||
|
||||
|
||||
def format_bandwidth(bytes_per_sec: float) -> str:
|
||||
if bytes_per_sec >= 1024 * 1024 * 1024:
|
||||
return f"{bytes_per_sec / (1024 * 1024 * 1024):.2f} GB/s"
|
||||
if bytes_per_sec >= 1024 * 1024:
|
||||
return f"{bytes_per_sec / (1024 * 1024):.1f} MB/s"
|
||||
return f"{bytes_per_sec / 1024:.1f} KB/s"
|
||||
|
||||
|
||||
def barrier(group: mx.distributed.Group) -> None:
|
||||
mx.eval(mx.distributed.all_sum(mx.array(1.0), group=group))
|
||||
|
||||
|
||||
def init_ring(
|
||||
rank: int, self_ip: str, peer_ip: str, port: int, tmpdir: str
|
||||
) -> mx.distributed.Group:
|
||||
if rank == 0:
|
||||
hosts = [f"{self_ip}:{port}", f"{peer_ip}:{port}"]
|
||||
else:
|
||||
hosts = [f"{peer_ip}:{port}", f"{self_ip}:{port}"]
|
||||
|
||||
hostfile = os.path.join(tmpdir, "hosts.json")
|
||||
with open(hostfile, "w") as f:
|
||||
json.dump(hosts, f)
|
||||
|
||||
for var in ("MLX_HOSTFILE", "MLX_RANK", "MLX_IBV_DEVICES", "MLX_JACCL_COORDINATOR"):
|
||||
os.environ.pop(var, None)
|
||||
|
||||
os.environ["MLX_HOSTFILE"] = hostfile
|
||||
os.environ["MLX_RANK"] = str(rank)
|
||||
return mx.distributed.init(backend="ring", strict=True)
|
||||
|
||||
|
||||
def init_jaccl(
|
||||
rank: int, interface: str, coordinator: str, port: int, tmpdir: str
|
||||
) -> mx.distributed.Group:
|
||||
devices = [[None, interface], [interface, None]]
|
||||
devfile = os.path.join(tmpdir, "devices.json")
|
||||
with open(devfile, "w") as f:
|
||||
json.dump(devices, f)
|
||||
|
||||
for var in ("MLX_HOSTFILE", "MLX_RANK", "MLX_IBV_DEVICES", "MLX_JACCL_COORDINATOR"):
|
||||
os.environ.pop(var, None)
|
||||
|
||||
os.environ["MLX_IBV_DEVICES"] = devfile
|
||||
os.environ["MLX_RANK"] = str(rank)
|
||||
if rank == 0:
|
||||
os.environ["MLX_JACCL_COORDINATOR"] = f"0.0.0.0:{port}"
|
||||
else:
|
||||
os.environ["MLX_JACCL_COORDINATOR"] = coordinator
|
||||
|
||||
return mx.distributed.init(backend="jaccl", strict=True)
|
||||
|
||||
|
||||
def bench_unidirectional(
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
size_bytes: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
) -> list[float]:
|
||||
n_elements = size_bytes // element_size
|
||||
tensor = mx.random.normal(shape=(n_elements,)).astype(dtype)
|
||||
mx.eval(tensor)
|
||||
|
||||
for _ in range(warmup):
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
barrier(group)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(iterations):
|
||||
barrier(group)
|
||||
t0 = time.perf_counter()
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
barrier(group)
|
||||
t1 = time.perf_counter()
|
||||
times.append(t1 - t0)
|
||||
|
||||
return times
|
||||
|
||||
|
||||
def bench_rtt(
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
size_bytes: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
) -> list[float]:
|
||||
n_elements = size_bytes // element_size
|
||||
tensor = mx.random.normal(shape=(n_elements,)).astype(dtype)
|
||||
mx.eval(tensor)
|
||||
|
||||
for _ in range(warmup):
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
received = mx.distributed.recv_like(tensor, src=1, group=group)
|
||||
mx.eval(received)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
sent = mx.distributed.send(received, dst=0, group=group)
|
||||
mx.eval(sent)
|
||||
barrier(group)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(iterations):
|
||||
barrier(group)
|
||||
t0 = time.perf_counter()
|
||||
if rank == 0:
|
||||
sent = mx.distributed.send(tensor, dst=1, group=group)
|
||||
mx.eval(sent)
|
||||
received = mx.distributed.recv_like(tensor, src=1, group=group)
|
||||
mx.eval(received)
|
||||
else:
|
||||
received = mx.distributed.recv_like(tensor, src=0, group=group)
|
||||
mx.eval(received)
|
||||
sent = mx.distributed.send(received, dst=0, group=group)
|
||||
mx.eval(sent)
|
||||
barrier(group)
|
||||
t1 = time.perf_counter()
|
||||
times.append(t1 - t0)
|
||||
|
||||
return times
|
||||
|
||||
|
||||
def bench_all_gather(
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
size_bytes: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
) -> list[float]:
|
||||
n_elements = (size_bytes // 2) // element_size
|
||||
tensor = mx.random.normal(shape=(n_elements,)).astype(dtype)
|
||||
mx.eval(tensor)
|
||||
|
||||
for _ in range(warmup):
|
||||
gathered = mx.distributed.all_gather(tensor, group=group)
|
||||
mx.eval(gathered)
|
||||
barrier(group)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(iterations):
|
||||
barrier(group)
|
||||
t0 = time.perf_counter()
|
||||
gathered = mx.distributed.all_gather(tensor, group=group)
|
||||
mx.eval(gathered)
|
||||
t1 = time.perf_counter()
|
||||
times.append(t1 - t0)
|
||||
|
||||
return times
|
||||
|
||||
|
||||
def print_table(title: str, rows: list[dict[str, str]]) -> None:
|
||||
print(f"\n=== {title} ===")
|
||||
headers = ["Size", "Median", "Min", "Max", "Bandwidth"]
|
||||
widths = [
|
||||
max(len(h), max((len(r[h]) for r in rows), default=0)) + 2 for h in headers
|
||||
]
|
||||
header_line = "".join(h.ljust(w) for h, w in zip(headers, widths, strict=True))
|
||||
print(header_line)
|
||||
print("-" * len(header_line))
|
||||
for row in rows:
|
||||
print("".join(row[h].ljust(w) for h, w in zip(headers, widths, strict=True)))
|
||||
|
||||
|
||||
def run_bench(
|
||||
name: str,
|
||||
bench_fn,
|
||||
group: mx.distributed.Group,
|
||||
rank: int,
|
||||
dtype: mx.Dtype,
|
||||
element_size: int,
|
||||
warmup: int,
|
||||
iterations: int,
|
||||
bw_multiplier: int = 1,
|
||||
) -> None:
|
||||
rows: list[dict[str, str]] = []
|
||||
for size in SIZES:
|
||||
if rank == 0:
|
||||
print(f" {name}: {format_bytes(size)}...", end="", flush=True)
|
||||
times = bench_fn(group, rank, size, dtype, element_size, warmup, iterations)
|
||||
if rank == 0:
|
||||
med = statistics.median(times)
|
||||
mn = min(times)
|
||||
mx_ = max(times)
|
||||
bw = (size * bw_multiplier) / med
|
||||
rows.append(
|
||||
{
|
||||
"Size": format_bytes(size),
|
||||
"Median": format_time(med),
|
||||
"Min": format_time(mn),
|
||||
"Max": format_time(mx_),
|
||||
"Bandwidth": format_bandwidth(bw),
|
||||
}
|
||||
)
|
||||
print(f" {format_bandwidth(bw)}")
|
||||
if rank == 0:
|
||||
print_table(name, rows)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MLX Distributed Communication Benchmark"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="backend", required=True)
|
||||
|
||||
ring_parser = subparsers.add_parser("ring")
|
||||
ring_parser.add_argument("--rank", type=int, required=True, choices=[0, 1])
|
||||
ring_parser.add_argument("--self-ip", required=True)
|
||||
ring_parser.add_argument("--peer-ip", required=True)
|
||||
ring_parser.add_argument("--port", type=int, default=5555)
|
||||
|
||||
jaccl_parser = subparsers.add_parser("jaccl")
|
||||
jaccl_parser.add_argument("--rank", type=int, required=True, choices=[0, 1])
|
||||
jaccl_parser.add_argument("--interface", required=True)
|
||||
jaccl_parser.add_argument(
|
||||
"--coordinator",
|
||||
type=str,
|
||||
default=None,
|
||||
help="IP:PORT of rank 0 (required for rank 1)",
|
||||
)
|
||||
jaccl_parser.add_argument(
|
||||
"--port", type=int, default=9999, help="Coordinator port (rank 0 only)"
|
||||
)
|
||||
|
||||
for p in [ring_parser, jaccl_parser]:
|
||||
p.add_argument("--warmup", type=int, default=3)
|
||||
p.add_argument("--iterations", type=int, default=10)
|
||||
p.add_argument("--dtype", choices=list(DTYPE_MAP.keys()), default="float32")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.backend == "jaccl" and args.rank == 1 and args.coordinator is None:
|
||||
jaccl_parser.error("--coordinator is required for rank 1")
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
dtype, element_size = DTYPE_MAP[args.dtype]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if args.backend == "ring":
|
||||
print(f"Initializing ring backend (rank {args.rank})...")
|
||||
group = init_ring(args.rank, args.self_ip, args.peer_ip, args.port, tmpdir)
|
||||
else:
|
||||
print(f"Initializing jaccl backend (rank {args.rank})...")
|
||||
group = init_jaccl(
|
||||
args.rank, args.interface, args.coordinator or "", args.port, tmpdir
|
||||
)
|
||||
|
||||
print(f"Rank {group.rank()} of {group.size()} initialized")
|
||||
barrier(group)
|
||||
|
||||
if args.rank == 0:
|
||||
print("\nMLX Distributed Communication Benchmark")
|
||||
print(
|
||||
f"Backend: {args.backend} | Dtype: {args.dtype} | Warmup: {args.warmup} | Iterations: {args.iterations}"
|
||||
)
|
||||
|
||||
run_bench(
|
||||
"Unidirectional (rank 0 -> rank 1)",
|
||||
bench_unidirectional,
|
||||
group,
|
||||
args.rank,
|
||||
dtype,
|
||||
element_size,
|
||||
args.warmup,
|
||||
args.iterations,
|
||||
)
|
||||
run_bench(
|
||||
"Round-Trip (ping-pong)",
|
||||
bench_rtt,
|
||||
group,
|
||||
args.rank,
|
||||
dtype,
|
||||
element_size,
|
||||
args.warmup,
|
||||
args.iterations,
|
||||
bw_multiplier=2,
|
||||
)
|
||||
run_bench(
|
||||
"All-Gather",
|
||||
bench_all_gather,
|
||||
group,
|
||||
args.rank,
|
||||
dtype,
|
||||
element_size,
|
||||
args.warmup,
|
||||
args.iterations,
|
||||
)
|
||||
|
||||
if args.rank == 0:
|
||||
print("\nDone.")
|
||||
else:
|
||||
print("Rank 1 complete.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
sys.exit(1)
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
echo "=== Starting overnight bench runs at $(date) ==="
|
||||
|
||||
echo "--- [4/8] Qwen3.5-122B-A10B-GPTQ-Int4 ---"
|
||||
echo "Skipping because Int 4"
|
||||
#uv run bench/exo_bench.py --force-download --model "Qwen/Qwen3.5-122B-A10B-GPTQ-Int4" --pp 700 --tg 36000 --repeat 1
|
||||
|
||||
echo "--- [5/8] Qwen3.5-27B-FP8 ---"
|
||||
#uv run bench/exo_bench.py --force-download --model "Qwen/Qwen3.5-27B-FP8" --pp 700 --tg 35133 --repeat 1
|
||||
|
||||
echo "--- [6/8] GLM-4.7-Flash-bf16 ---"
|
||||
uv run bench/exo_bench.py --force-download --model "mlx-community/GLM-4.7-Flash-bf16" --pp 700 --tg 29000 --repeat 1
|
||||
|
||||
echo "--- [7/8] NVIDIA-Nemotron-3-Nano-30B-A3B (23000,1200) ---"
|
||||
uv run bench/exo_bench.py --force-download --model "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-MLX-BF16" --pp 700 --tg 23000,1200 --repeat 1
|
||||
|
||||
echo "--- [8/8] Qwen3.5-27B-bf16 ---"
|
||||
uv run bench/exo_bench.py --force-download --model "mlx-community/Qwen3.5-27B-bf16" --pp 700 --tg 35400 --repeat 1
|
||||
|
||||
echo "=== All bench runs complete at $(date) ==="
|
||||
Generated
+272
-1
@@ -11,7 +11,8 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.27",
|
||||
"marked": "^17.0.1",
|
||||
"mode-watcher": "^1.1.0"
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^5.6.205"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -518,6 +519,256 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -2635,6 +2886,26 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.6.205",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
|
||||
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.96",
|
||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.27",
|
||||
"marked": "^17.0.1",
|
||||
"mode-watcher": "^1.1.0"
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^5.6.205"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,8 @@
|
||||
return "🖼";
|
||||
case "text":
|
||||
return "📄";
|
||||
case "pdf":
|
||||
return "📑";
|
||||
default:
|
||||
return "📎";
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
quantization: string;
|
||||
}
|
||||
|
||||
function normalizeBaseModel(s: string): string {
|
||||
return s.toLowerCase().replace(/[-_]/g, " ").trim();
|
||||
}
|
||||
|
||||
// Auto mode tier list (for when user just starts typing)
|
||||
export const AUTO_TIERS: string[][] = [
|
||||
// Tier 1 (frontier)
|
||||
@@ -47,9 +43,8 @@
|
||||
|
||||
/** Return the tier index (0 = best) for a base_model name. */
|
||||
export function getAutoTierIndex(baseModel: string): number {
|
||||
const norm = normalizeBaseModel(baseModel);
|
||||
for (let i = 0; i < AUTO_TIERS.length; i++) {
|
||||
if (AUTO_TIERS[i].some((t) => normalizeBaseModel(t) === norm)) return i;
|
||||
if (AUTO_TIERS[i].includes(baseModel)) return i;
|
||||
}
|
||||
return AUTO_TIERS.length; // not in any tier → lowest priority
|
||||
}
|
||||
@@ -65,8 +60,7 @@
|
||||
const variants = modelList
|
||||
.filter(
|
||||
(m) =>
|
||||
normalizeBaseModel(m.base_model) ===
|
||||
normalizeBaseModel(baseModel) &&
|
||||
m.base_model === baseModel &&
|
||||
(m.storage_size_megabytes || 0) / 1024 <= memoryGB &&
|
||||
(m.storage_size_megabytes || 0) > 0,
|
||||
)
|
||||
@@ -168,11 +162,7 @@
|
||||
/** For a given base_model name, find the biggest quant variant that fits in memory. */
|
||||
function pickBestVariant(baseModel: string): ChatModelInfo | null {
|
||||
const variants = models
|
||||
.filter(
|
||||
(m) =>
|
||||
normalizeBaseModel(m.base_model) === normalizeBaseModel(baseModel) &&
|
||||
fitsInMemory(m),
|
||||
)
|
||||
.filter((m) => m.base_model === baseModel && fitsInMemory(m))
|
||||
.sort((a, b) => getModelSizeGB(b) - getModelSizeGB(a));
|
||||
return variants[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** "macbook pro" | "mac studio" | "mac mini" | "dgx spark" | "linux" etc. */
|
||||
/** "macbook pro" | "mac studio" | "mac mini" etc. */
|
||||
deviceType: string;
|
||||
/** Center X coordinate in SVG space */
|
||||
cx: number;
|
||||
@@ -38,43 +38,10 @@
|
||||
const LOGO_NATIVE_WIDTH = 814;
|
||||
const LOGO_NATIVE_HEIGHT = 1000;
|
||||
|
||||
// NVIDIA logo SVG path
|
||||
const NVIDIA_LOGO_PATH =
|
||||
"M0.81 0.429V0.299c0.013 -0.001 0.026 -0.002 0.038 -0.002 0.355 -0.011 0.588 0.306 0.588 0.306S1.186 0.952 0.916 0.952c-0.036 0 -0.071 -0.006 -0.105 -0.017V0.542c0.138 0.017 0.166 0.078 0.249 0.216l0.185 -0.155s-0.135 -0.177 -0.362 -0.177c-0.024 -0.001 -0.048 0.001 -0.072 0.003m0 -0.429v0.194l0.038 -0.002c0.494 -0.017 0.816 0.405 0.816 0.405s-0.37 0.45 -0.754 0.45c-0.034 0 -0.066 -0.003 -0.099 -0.009v0.12c0.027 0.003 0.055 0.006 0.082 0.006 0.358 0 0.618 -0.183 0.869 -0.399 0.042 0.034 0.212 0.114 0.247 0.15 -0.238 0.2 -0.794 0.361 -1.11 0.361 -0.03 0 -0.059 -0.002 -0.088 -0.005v0.169h1.362V0zm0 0.935v0.102c-0.331 -0.059 -0.423 -0.404 -0.423 -0.404s0.159 -0.176 0.423 -0.205v0.112h-0.001C0.671 0.524 0.562 0.654 0.562 0.654s0.062 0.218 0.248 0.282m-0.588 -0.316s0.196 -0.29 0.589 -0.32V0.194C0.376 0.229 0 0.597 0 0.597s0.213 0.616 0.81 0.672v-0.112c-0.438 -0.054 -0.588 -0.538 -0.588 -0.538";
|
||||
|
||||
const wireColor = "rgba(179,179,179,0.8)";
|
||||
const strokeWidth = 1.5;
|
||||
|
||||
const modelLower = $derived(deviceType.toLowerCase());
|
||||
const isSpark = $derived(
|
||||
modelLower.includes("dgx") || modelLower.includes("gx10"),
|
||||
);
|
||||
const isLinux = $derived(!isSpark && modelLower.startsWith("linux"));
|
||||
const isLinuxLaptop = $derived(isLinux && modelLower.includes("laptop"));
|
||||
|
||||
// ── DGX Spark dimensions ──
|
||||
const dgxW = $derived(size * 1.55);
|
||||
const dgxH = $derived(size * 0.58);
|
||||
const dgxX = $derived(cx - dgxW / 2);
|
||||
const dgxY = $derived(cy - dgxH / 2);
|
||||
const dgxChassisX = $derived(dgxX - dgxW * 0.03);
|
||||
const dgxChassisW = $derived(dgxW * 1.05);
|
||||
const dgxHandleW = $derived(dgxW * 0.27);
|
||||
const dgxHandleGap = $derived(dgxH * 0.05);
|
||||
const dgxHandleH = $derived(dgxH - dgxHandleGap * 2);
|
||||
const dgxHandleY = $derived(dgxY + dgxHandleGap);
|
||||
const dgxInnerHandleW = $derived(dgxW * 0.12);
|
||||
const dgxInnerHandleH = $derived(dgxHandleH - dgxH * 0.06);
|
||||
const dgxLeftHandleX = $derived(dgxX + 4);
|
||||
const dgxRightHandleX = $derived(dgxX + dgxW - dgxHandleW - 4);
|
||||
const dgxClipId = $derived(`di-dgx-${uid}`);
|
||||
const dgxTextureId = $derived(`di-dgx-tex-${uid}`);
|
||||
|
||||
// ── Linux Desktop dimensions (reuses Mac Studio proportions) ──
|
||||
const linuxDesktopClipId = $derived(`di-linux-desktop-${uid}`);
|
||||
|
||||
// ── Linux Laptop dimensions (reuses MacBook proportions) ──
|
||||
const linuxScreenClipId = $derived(`di-linux-screen-${uid}`);
|
||||
|
||||
// ── Mac Studio dimensions (same ratios as TopologyGraph) ──
|
||||
const studioW = $derived(size * 1.25);
|
||||
@@ -147,264 +114,7 @@
|
||||
const studioClipId = $derived(`di-studio-${uid}`);
|
||||
</script>
|
||||
|
||||
{#if isSpark}
|
||||
<!-- DGX Spark -->
|
||||
<defs>
|
||||
<clipPath id={dgxClipId}>
|
||||
<rect x={dgxX} y={dgxY} width={dgxW} height={dgxH} rx="3" />
|
||||
</clipPath>
|
||||
<pattern
|
||||
id={dgxTextureId}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<rect width="8" height="8" fill="#6f6248" />
|
||||
<circle cx="2" cy="2" r="1" fill="#5a4f3b" opacity="0.5" />
|
||||
<circle cx="6" cy="6" r="1" fill="#4a4232" opacity="0.45" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Main body -->
|
||||
<rect
|
||||
x={dgxChassisX}
|
||||
y={dgxY}
|
||||
width={dgxChassisW}
|
||||
height={dgxH}
|
||||
rx="3"
|
||||
fill="url(#{dgxTextureId})"
|
||||
stroke={wireColor}
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
|
||||
<!-- Side border accents -->
|
||||
<rect
|
||||
x={dgxChassisX}
|
||||
y={dgxY}
|
||||
width={dgxW * 0.02}
|
||||
height={dgxH}
|
||||
fill="#8a7a56"
|
||||
/>
|
||||
<rect
|
||||
x={dgxChassisX + dgxChassisW - dgxW * 0.02}
|
||||
y={dgxY}
|
||||
width={dgxW * 0.02}
|
||||
height={dgxH}
|
||||
fill="#8a7a56"
|
||||
/>
|
||||
|
||||
<!-- Memory fill -->
|
||||
{#if ramPercent > 0}
|
||||
<rect
|
||||
x={dgxX}
|
||||
y={dgxY + dgxH - (ramPercent / 100) * dgxH}
|
||||
width={dgxW}
|
||||
height={(ramPercent / 100) * dgxH}
|
||||
fill="rgba(255,215,0,0.45)"
|
||||
clip-path="url(#{dgxClipId})"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Left handle -->
|
||||
<rect
|
||||
x={dgxLeftHandleX}
|
||||
y={dgxHandleY}
|
||||
width={dgxHandleW}
|
||||
height={dgxHandleH}
|
||||
rx="2.4"
|
||||
fill="#b3a170"
|
||||
stroke="#403723"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x={dgxLeftHandleX + dgxHandleW * 0.06}
|
||||
y={dgxHandleY + dgxH * 0.03}
|
||||
width={dgxInnerHandleW}
|
||||
height={dgxInnerHandleH}
|
||||
rx="1.6"
|
||||
fill="#8a7a56"
|
||||
/>
|
||||
|
||||
<!-- Right handle -->
|
||||
<rect
|
||||
x={dgxRightHandleX}
|
||||
y={dgxHandleY}
|
||||
width={dgxHandleW}
|
||||
height={dgxHandleH}
|
||||
rx="2.4"
|
||||
fill="#b3a170"
|
||||
stroke="#403723"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x={dgxRightHandleX + dgxHandleW - dgxInnerHandleW - dgxHandleW * 0.08}
|
||||
y={dgxHandleY + dgxH * 0.03}
|
||||
width={dgxInnerHandleW}
|
||||
height={dgxInnerHandleH}
|
||||
rx="1.6"
|
||||
fill="#8a7a56"
|
||||
/>
|
||||
|
||||
<!-- NVIDIA logo (rotated 90deg on left handle) -->
|
||||
{@const badgeW = dgxW * 0.09}
|
||||
{@const badgeH = dgxHandleH * 0.5}
|
||||
{@const badgeX = dgxLeftHandleX + dgxHandleW - badgeW - dgxHandleW * 0.06}
|
||||
{@const badgeYPos = dgxHandleY + (dgxHandleH - badgeH) / 2}
|
||||
{@const textSz = badgeW * 0.58}
|
||||
{@const logoW = textSz * 1.2}
|
||||
{@const logoH = logoW * (1.438 / 2.174)}
|
||||
{@const ctrX = badgeX + badgeW / 2 - badgeW * 0.03}
|
||||
{@const ctrY = badgeYPos + badgeH / 2}
|
||||
{@const labelGap = badgeW * 0.15}
|
||||
{@const totalW = logoW + labelGap + textSz * 3.6}
|
||||
<g transform="rotate(90 {ctrX} {ctrY})">
|
||||
<svg
|
||||
x={ctrX - totalW / 2}
|
||||
y={ctrY - logoH / 2}
|
||||
width={logoW}
|
||||
height={logoH}
|
||||
viewBox="0 0 2.174 1.438"
|
||||
>
|
||||
<path d={NVIDIA_LOGO_PATH} fill="#76b900" />
|
||||
</svg>
|
||||
<text
|
||||
x={ctrX - totalW / 2 + logoW + labelGap}
|
||||
y={ctrY}
|
||||
text-anchor="start"
|
||||
dominant-baseline="middle"
|
||||
fill="#8a7a56"
|
||||
font-size={textSz}
|
||||
font-family="monospace"
|
||||
font-weight="700">NVIDIA</text
|
||||
>
|
||||
</g>
|
||||
{:else if isLinuxLaptop}
|
||||
<!-- Linux Laptop — MacBook shape with Tux logo -->
|
||||
<defs>
|
||||
<clipPath id={linuxScreenClipId}>
|
||||
<rect
|
||||
x={mbScreenX + mbBezel}
|
||||
y={mbY + mbBezel}
|
||||
width={mbScreenW - mbBezel * 2}
|
||||
height={mbScreenH - mbBezel * 2}
|
||||
rx="2"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect
|
||||
x={mbScreenX}
|
||||
y={mbY}
|
||||
width={mbScreenW}
|
||||
height={mbScreenH}
|
||||
rx="3"
|
||||
fill="#1a1a1a"
|
||||
stroke={wireColor}
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
<rect
|
||||
x={mbScreenX + mbBezel}
|
||||
y={mbY + mbBezel}
|
||||
width={mbScreenW - mbBezel * 2}
|
||||
height={mbScreenH - mbBezel * 2}
|
||||
rx="2"
|
||||
fill="#0a0a12"
|
||||
/>
|
||||
{#if ramPercent > 0}
|
||||
<rect
|
||||
x={mbScreenX + mbBezel}
|
||||
y={mbY + mbBezel + (mbMemTotalH - mbMemH)}
|
||||
width={mbScreenW - mbBezel * 2}
|
||||
height={mbMemH}
|
||||
fill="rgba(255,215,0,0.85)"
|
||||
clip-path="url(#{linuxScreenClipId})"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Terminal prompt on screen -->
|
||||
<text
|
||||
x={cx}
|
||||
y={mbY + mbScreenH / 2}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
fill="#FFFFFF"
|
||||
opacity="0.9"
|
||||
font-size={mbScreenH * 0.25}
|
||||
font-family="SF Mono, Monaco, monospace"
|
||||
font-weight="700">{">_"}</text
|
||||
>
|
||||
|
||||
<path
|
||||
d="M {mbBaseTopX} {mbBaseY} L {mbBaseTopX +
|
||||
mbBaseTopW} {mbBaseY} L {mbBaseBottomX + mbBaseBottomW} {mbBaseY +
|
||||
mbBaseH} L {mbBaseBottomX} {mbBaseY + mbBaseH} Z"
|
||||
fill="#2c2c2c"
|
||||
stroke={wireColor}
|
||||
stroke-width="1"
|
||||
/>
|
||||
<rect
|
||||
x={mbKbX}
|
||||
y={mbKbY}
|
||||
width={mbKbW}
|
||||
height={mbKbH}
|
||||
fill="rgba(0,0,0,0.2)"
|
||||
rx="2"
|
||||
/>
|
||||
<rect
|
||||
x={mbTpX}
|
||||
y={mbTpY}
|
||||
width={mbTpW}
|
||||
height={mbTpH}
|
||||
fill="rgba(255,255,255,0.08)"
|
||||
rx="2"
|
||||
/>
|
||||
{:else if isLinux}
|
||||
<!-- Linux Desktop — Mac Studio shape with Tux logo -->
|
||||
<defs>
|
||||
<clipPath id={linuxDesktopClipId}>
|
||||
<rect
|
||||
x={studioX}
|
||||
y={studioY + studioTopH}
|
||||
width={studioW}
|
||||
height={studioH - studioTopH}
|
||||
rx={studioCorner - 1}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect
|
||||
x={studioX}
|
||||
y={studioY}
|
||||
width={studioW}
|
||||
height={studioH}
|
||||
rx={studioCorner}
|
||||
fill="#1a1a1a"
|
||||
stroke={wireColor}
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
{#if ramPercent > 0}
|
||||
<rect
|
||||
x={studioX}
|
||||
y={studioY + studioTopH + (studioMemTotalH - studioMemH)}
|
||||
width={studioW}
|
||||
height={studioMemH}
|
||||
fill="rgba(255,215,0,0.75)"
|
||||
clip-path="url(#{linuxDesktopClipId})"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Terminal prompt on front face -->
|
||||
<text
|
||||
x={cx}
|
||||
y={studioY + studioTopH + (studioH - studioTopH) / 2}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
fill="rgba(255,255,255,0.5)"
|
||||
font-size={(studioH - studioTopH) * 0.4}
|
||||
font-family="SF Mono, Monaco, monospace"
|
||||
font-weight="700">{">_"}</text
|
||||
>
|
||||
{:else if modelLower === "mac studio" || modelLower === "mac mini"}
|
||||
{#if modelLower === "mac studio" || modelLower === "mac mini"}
|
||||
<!-- Mac Studio / Mac Mini -->
|
||||
<defs>
|
||||
<clipPath id={studioClipId}>
|
||||
|
||||
@@ -88,6 +88,12 @@
|
||||
d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if family === "nemotron"}
|
||||
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
kimi: "Kimi",
|
||||
flux: "FLUX",
|
||||
"qwen-image": "Qwen Img",
|
||||
nemotron: "NVIDIA",
|
||||
};
|
||||
|
||||
function getFamilyName(family: string): string {
|
||||
@@ -41,31 +42,20 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[72px] sm:min-w-[64px] overflow-y-auto scrollbar-hide"
|
||||
class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[80px] sm:min-w-[72px] overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<!-- All models (no filter) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelect(null)}
|
||||
class="group flex flex-col items-center justify-center p-2 sm:p-2 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
|
||||
class="group flex items-center justify-center px-3 py-2.5 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
|
||||
null
|
||||
? 'bg-exo-yellow/20 border-l-2 border-exo-yellow'
|
||||
: 'hover:bg-white/5 border-l-2 border-transparent'}"
|
||||
title="All models"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 {selectedFamily === null
|
||||
? 'text-exo-yellow'
|
||||
: 'text-white/50 group-hover:text-white/70'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[9px] font-mono mt-0.5 {selectedFamily === null
|
||||
class="text-[12px] font-mono font-medium {selectedFamily === null
|
||||
? 'text-exo-yellow'
|
||||
: 'text-white/40 group-hover:text-white/60'}">All</span
|
||||
>
|
||||
@@ -89,7 +79,7 @@
|
||||
: "text-white/50 group-hover:text-amber-400/70"}
|
||||
/>
|
||||
<span
|
||||
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'favorites'
|
||||
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'favorites'
|
||||
? 'text-amber-400'
|
||||
: 'text-white/40 group-hover:text-white/60'}">Faves</span
|
||||
>
|
||||
@@ -114,7 +104,7 @@
|
||||
: "text-white/50 group-hover:text-white/70"}
|
||||
/>
|
||||
<span
|
||||
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'recents'
|
||||
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'recents'
|
||||
? 'text-exo-yellow'
|
||||
: 'text-white/40 group-hover:text-white/60'}">Recent</span
|
||||
>
|
||||
@@ -138,7 +128,7 @@
|
||||
: "text-white/50 group-hover:text-orange-400/70"}
|
||||
/>
|
||||
<span
|
||||
class="text-[9px] font-mono mt-0.5 {selectedFamily === 'huggingface'
|
||||
class="text-[11px] font-mono mt-0.5 {selectedFamily === 'huggingface'
|
||||
? 'text-orange-400'
|
||||
: 'text-white/40 group-hover:text-white/60'}">Hub</span
|
||||
>
|
||||
@@ -164,7 +154,7 @@
|
||||
: "text-white/50 group-hover:text-white/70"}
|
||||
/>
|
||||
<span
|
||||
class="text-[9px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
|
||||
class="text-[11px] font-mono mt-0.5 truncate max-w-full {selectedFamily ===
|
||||
family
|
||||
? 'text-exo-yellow'
|
||||
: 'text-white/40 group-hover:text-white/60'}"
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export let showHome = true;
|
||||
export let onHome: (() => void) | null = null;
|
||||
export let showSidebarToggle = false;
|
||||
export let sidebarVisible = true;
|
||||
export let onToggleSidebar: (() => void) | null = null;
|
||||
export let showMobileMenuToggle = false;
|
||||
export let mobileMenuOpen = false;
|
||||
export let onToggleMobileMenu: (() => void) | null = null;
|
||||
export let showMobileRightToggle = false;
|
||||
export let mobileRightOpen = false;
|
||||
export let onToggleMobileRight: (() => void) | null = null;
|
||||
export let downloadProgress: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
} | null = null;
|
||||
interface Props {
|
||||
showHome?: boolean;
|
||||
onHome?: (() => void) | null;
|
||||
showSidebarToggle?: boolean;
|
||||
sidebarVisible?: boolean;
|
||||
onToggleSidebar?: (() => void) | null;
|
||||
showMobileMenuToggle?: boolean;
|
||||
mobileMenuOpen?: boolean;
|
||||
onToggleMobileMenu?: (() => void) | null;
|
||||
showMobileRightToggle?: boolean;
|
||||
mobileRightOpen?: boolean;
|
||||
onToggleMobileRight?: (() => void) | null;
|
||||
downloadProgress?: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let {
|
||||
showHome = true,
|
||||
onHome = null,
|
||||
showSidebarToggle = false,
|
||||
sidebarVisible = true,
|
||||
onToggleSidebar = null,
|
||||
showMobileMenuToggle = false,
|
||||
mobileMenuOpen = false,
|
||||
onToggleMobileMenu = null,
|
||||
showMobileRightToggle = false,
|
||||
mobileRightOpen = false,
|
||||
onToggleMobileRight = null,
|
||||
downloadProgress = null,
|
||||
}: Props = $props();
|
||||
|
||||
function handleHome(): void {
|
||||
if (onHome) {
|
||||
@@ -259,5 +276,26 @@
|
||||
{/if}
|
||||
<span class="hidden sm:inline">Downloads</span>
|
||||
</a>
|
||||
<a
|
||||
href="/#/integrations"
|
||||
class="text-xs md:text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-1.5 md:gap-2 cursor-pointer"
|
||||
title="Integration configs for external tools"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path
|
||||
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Integrations</span>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
config: string;
|
||||
description?: string;
|
||||
language?: "json" | "bash";
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
config,
|
||||
description = "",
|
||||
language = "json",
|
||||
}: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copyToClipboard() {
|
||||
await navigator.clipboard.writeText(config);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="border border-exo-light-gray/20 rounded-lg bg-exo-medium-gray/20 overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-white text-sm font-semibold tracking-wide">{title}</h3>
|
||||
<p class="text-exo-light-gray/60 text-xs mt-0.5 font-mono">{subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={copyToClipboard}
|
||||
class="px-3 py-1.5 text-xs rounded border transition-all duration-200 cursor-pointer
|
||||
{copied
|
||||
? 'border-green-500/50 text-green-400 bg-green-500/10'
|
||||
: 'border-exo-light-gray/30 text-exo-light-gray hover:border-exo-yellow/50 hover:text-exo-yellow'}"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
{#if description}
|
||||
<p class="text-exo-light-gray/70 text-xs px-5 pb-3">{description}</p>
|
||||
{/if}
|
||||
<div class="bg-black/30 border-t border-exo-light-gray/10">
|
||||
<pre
|
||||
class="text-xs text-exo-light-gray/90 font-mono p-4 overflow-x-auto whitespace-pre">{config}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,7 +16,9 @@
|
||||
perNode?: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
status: "completed" | "partial" | "pending" | "downloading";
|
||||
percentage: number;
|
||||
progress: DownloadProgress | null;
|
||||
}>;
|
||||
} | null;
|
||||
nodes?: Record<string, NodeInfo>;
|
||||
@@ -145,10 +147,7 @@
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
|
||||
const progress = $derived(downloadStatus?.progress);
|
||||
const percentage = $derived(progress?.percentage ?? 0);
|
||||
let expandedNodes = $state<Set<string>>(new Set());
|
||||
const perNode = $derived(downloadStatus?.perNode ?? []);
|
||||
|
||||
function toggleNodeDetails(nodeId: string): void {
|
||||
const next = new Set(expandedNodes);
|
||||
@@ -169,10 +168,8 @@
|
||||
|
||||
function getDeviceType(
|
||||
name: string,
|
||||
): "macbook" | "studio" | "mini" | "dgx" | "linux" | "unknown" {
|
||||
): "macbook" | "studio" | "mini" | "unknown" {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("dgx") || lower.includes("gx10")) return "dgx";
|
||||
if (lower.includes("linux")) return "linux";
|
||||
if (lower.includes("macbook")) return "macbook";
|
||||
if (lower.includes("studio")) return "studio";
|
||||
if (lower.includes("mini")) return "mini";
|
||||
@@ -280,7 +277,7 @@
|
||||
let placementNodes: Array<{
|
||||
id: string;
|
||||
deviceName: string;
|
||||
deviceType: "macbook" | "studio" | "mini" | "dgx" | "linux" | "unknown";
|
||||
deviceType: "macbook" | "studio" | "mini" | "unknown";
|
||||
totalGB: number;
|
||||
currentUsedGB: number;
|
||||
modelUsageGB: number;
|
||||
@@ -589,23 +586,49 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Download Status -->
|
||||
{#if isDownloading && progress}
|
||||
<!-- Download Status (per-node) -->
|
||||
{#if perNode.length > 0}
|
||||
<div class="mb-2 space-y-1">
|
||||
<div class="flex items-center justify-between text-xs font-mono">
|
||||
<span class="text-blue-400 tracking-wider uppercase">Downloading</span
|
||||
>
|
||||
<span class="text-white/60"
|
||||
>{percentage.toFixed(1)}% · {formatSpeed(progress.speed)}
|
||||
· {formatEta(progress.etaMs)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="h-1 bg-exo-medium-gray/30 rounded overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-500/70 transition-all duration-300"
|
||||
style="width: {percentage}%"
|
||||
></div>
|
||||
<div
|
||||
class="text-[10px] font-mono text-white/20 tracking-widest uppercase"
|
||||
>
|
||||
Download progress
|
||||
</div>
|
||||
{#each perNode as node}
|
||||
<div class="flex items-center gap-2 text-xs font-mono">
|
||||
<span class="text-white/40 w-20 truncate" title={node.nodeId}
|
||||
>{node.nodeName}</span
|
||||
>
|
||||
<div
|
||||
class="flex-1 h-1 bg-exo-medium-gray/30 rounded overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full transition-all duration-300 {node.status ===
|
||||
'downloading'
|
||||
? 'bg-blue-500/70'
|
||||
: node.status === 'completed'
|
||||
? 'bg-exo-yellow/40'
|
||||
: 'bg-white/20'}"
|
||||
style="width: {node.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
<span
|
||||
class="text-right {node.status === 'completed'
|
||||
? 'text-exo-yellow/60'
|
||||
: node.status === 'downloading'
|
||||
? 'text-blue-400/60'
|
||||
: 'text-white/30'}"
|
||||
>
|
||||
{#if node.status === "downloading" && node.progress}
|
||||
{Math.round(node.percentage)}% {formatSpeed(
|
||||
node.progress.speed,
|
||||
)}
|
||||
{:else}
|
||||
{node.percentage > 0 ? `${Math.round(node.percentage)}%` : "0%"}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -664,15 +687,7 @@
|
||||
{@const allConnections =
|
||||
isDebugMode && usedNodes.length > 1
|
||||
? (() => {
|
||||
const conns: Array<{
|
||||
ip: string;
|
||||
iface: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
midX: number;
|
||||
midY: number;
|
||||
arrow: string;
|
||||
}> = [];
|
||||
const conns: Array = [];
|
||||
for (let i = 0; i < usedNodes.length; i++) {
|
||||
for (let j = i + 1; j < usedNodes.length; j++) {
|
||||
const n1 = usedNodes[i];
|
||||
@@ -684,7 +699,12 @@
|
||||
const toPos = nodePositions[c.to];
|
||||
const arrow =
|
||||
fromPos && toPos ? getArrow(fromPos, toPos) : "→";
|
||||
conns.push({ ...c, midX, midY, arrow });
|
||||
conns.push({
|
||||
...c,
|
||||
midX,
|
||||
midY,
|
||||
arrow,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -970,137 +990,6 @@
|
||||
/>
|
||||
{/if}
|
||||
</g>
|
||||
{:else if node.deviceType === "dgx"}
|
||||
<!-- DGX Spark icon -->
|
||||
{@const s = node.iconSize}
|
||||
{@const dgxW = s * 1.4}
|
||||
{@const dgxH = s * 0.52}
|
||||
<g transform="translate({-dgxW / 2}, {-dgxH / 2})">
|
||||
<!-- Chassis -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={dgxW}
|
||||
height={dgxH}
|
||||
rx="2"
|
||||
fill="#6f6248"
|
||||
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Side accents -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={dgxW * 0.02}
|
||||
height={dgxH}
|
||||
fill="#8a7a56"
|
||||
/>
|
||||
<rect
|
||||
x={dgxW - dgxW * 0.02}
|
||||
y="0"
|
||||
width={dgxW * 0.02}
|
||||
height={dgxH}
|
||||
fill="#8a7a56"
|
||||
/>
|
||||
<!-- Left handle -->
|
||||
<rect
|
||||
x={dgxW * 0.04}
|
||||
y={dgxH * 0.08}
|
||||
width={dgxW * 0.22}
|
||||
height={dgxH * 0.84}
|
||||
rx="2"
|
||||
fill="#b3a170"
|
||||
stroke="#403723"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<!-- Right handle -->
|
||||
<rect
|
||||
x={dgxW - dgxW * 0.04 - dgxW * 0.22}
|
||||
y={dgxH * 0.08}
|
||||
width={dgxW * 0.22}
|
||||
height={dgxH * 0.84}
|
||||
rx="2"
|
||||
fill="#b3a170"
|
||||
stroke="#403723"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<!-- Memory fill -->
|
||||
<rect
|
||||
x="2"
|
||||
y={dgxH - dgxH * (node.currentPercent / 100)}
|
||||
width={dgxW - 4}
|
||||
height={dgxH * (node.currentPercent / 100)}
|
||||
fill="rgba(255,215,0,0.35)"
|
||||
/>
|
||||
{#if node.modelUsageGB > 0 && node.isUsed}
|
||||
<rect
|
||||
x="2"
|
||||
y={dgxH - dgxH * (node.newPercent / 100)}
|
||||
width={dgxW - 4}
|
||||
height={dgxH *
|
||||
((node.newPercent - node.currentPercent) / 100)}
|
||||
fill="#FFD700"
|
||||
filter="url(#memGlow-{filterId})"
|
||||
class="animate-pulse-slow"
|
||||
/>
|
||||
{/if}
|
||||
</g>
|
||||
{:else if node.deviceType === "linux"}
|
||||
<!-- Linux Tux penguin icon -->
|
||||
{@const sz = node.iconSize}
|
||||
{@const sc = sz / 100}
|
||||
<g transform="translate({-sz / 2}, {-sz / 2})">
|
||||
<!-- Body -->
|
||||
<path
|
||||
d="M50 8c-8 0-14 6-14 13 0 4 2 8 5 10-8 4-16 14-16 28v12c0 4 2 7 5 9l-6 4c-2 1-3 3-3 5v3c0 2 2 4 4 4h10l4-4h22l4 4h10c2 0 4-2 4-4v-3c0-2-1-4-3-5l-6-4c3-2 5-5 5-9V59c0-14-8-24-16-28 3-2 5-6 5-10 0-7-6-13-14-13z"
|
||||
transform="scale({sc})"
|
||||
fill="#1a1a1a"
|
||||
stroke={node.isUsed ? "#FFD700" : "#4B5563"}
|
||||
stroke-width={1.5 / sc}
|
||||
/>
|
||||
<!-- Belly -->
|
||||
<path
|
||||
d="M38 52c0-8 5-15 12-15s12 7 12 15v14c0 4-5 7-12 7s-12-3-12-7V52z"
|
||||
transform="scale({sc})"
|
||||
fill="rgba(220,220,220,0.85)"
|
||||
/>
|
||||
<!-- Eyes -->
|
||||
<circle cx={44 * sc} cy={16 * sc} r={2.5 * sc} fill="white" />
|
||||
<circle cx={56 * sc} cy={16 * sc} r={2.5 * sc} fill="white" />
|
||||
<circle
|
||||
cx={44 * sc}
|
||||
cy={16 * sc}
|
||||
r={1.2 * sc}
|
||||
fill="#1a1a1a"
|
||||
/>
|
||||
<circle
|
||||
cx={56 * sc}
|
||||
cy={16 * sc}
|
||||
r={1.2 * sc}
|
||||
fill="#1a1a1a"
|
||||
/>
|
||||
<!-- Beak -->
|
||||
<path
|
||||
d="M{46 * sc} {22 * sc} L{50 * sc} {27 * sc} L{54 *
|
||||
sc} {22 * sc} Z"
|
||||
fill="#E8A317"
|
||||
/>
|
||||
<!-- Feet -->
|
||||
<ellipse
|
||||
cx={42 * sc}
|
||||
cy={94 * sc}
|
||||
rx={6 * sc}
|
||||
ry={2.5 * sc}
|
||||
fill="#E8A317"
|
||||
/>
|
||||
<ellipse
|
||||
cx={58 * sc}
|
||||
cy={94 * sc}
|
||||
rx={6 * sc}
|
||||
ry={2.5 * sc}
|
||||
fill="#E8A317"
|
||||
/>
|
||||
</g>
|
||||
{:else}
|
||||
<!-- Unknown device - hexagon -->
|
||||
<g
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-10"
|
||||
class="filter-popover absolute right-0 top-full mt-2 w-64 bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-xl z-20"
|
||||
transition:fly={{ y: -10, duration: 200, easing: cubicOut }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
|
||||
@@ -379,16 +379,12 @@
|
||||
return hfTrendingModels;
|
||||
});
|
||||
|
||||
function normalizeBaseModel(s: string): string {
|
||||
return s.toLowerCase().replace(/[-_]/g, " ").trim();
|
||||
}
|
||||
|
||||
// Group models by base_model
|
||||
const groupedModels = $derived.by((): ModelGroup[] => {
|
||||
const groups = new Map<string, ModelGroup>();
|
||||
|
||||
for (const model of models) {
|
||||
const groupId = normalizeBaseModel(model.base_model || model.id);
|
||||
const groupId = model.base_model || model.id;
|
||||
const groupName = model.base_model || model.name || model.id;
|
||||
|
||||
if (!groups.has(groupId)) {
|
||||
@@ -463,6 +459,7 @@
|
||||
"llama",
|
||||
"flux",
|
||||
"qwen-image",
|
||||
"nemotron",
|
||||
];
|
||||
return Array.from(families).sort((a, b) => {
|
||||
const aIdx = familyOrder.indexOf(a);
|
||||
@@ -582,7 +579,7 @@
|
||||
const model = models.find((m) => m.id === id);
|
||||
if (model) {
|
||||
result.push({
|
||||
id: normalizeBaseModel(model.base_model || model.id),
|
||||
id: model.base_model || model.id,
|
||||
name: model.name || model.id,
|
||||
capabilities: model.capabilities || ["text"],
|
||||
family: model.family || "",
|
||||
|
||||
@@ -117,10 +117,6 @@
|
||||
const LOGO_NATIVE_WIDTH = 814;
|
||||
const LOGO_NATIVE_HEIGHT = 1000;
|
||||
|
||||
// NVIDIA logo SVG path (from exo-nvidia)
|
||||
const NVIDIA_LOGO_PATH =
|
||||
"M0.81 0.429V0.299c0.013 -0.001 0.026 -0.002 0.038 -0.002 0.355 -0.011 0.588 0.306 0.588 0.306S1.186 0.952 0.916 0.952c-0.036 0 -0.071 -0.006 -0.105 -0.017V0.542c0.138 0.017 0.166 0.078 0.249 0.216l0.185 -0.155s-0.135 -0.177 -0.362 -0.177c-0.024 -0.001 -0.048 0.001 -0.072 0.003m0 -0.429v0.194l0.038 -0.002c0.494 -0.017 0.816 0.405 0.816 0.405s-0.37 0.45 -0.754 0.45c-0.034 0 -0.066 -0.003 -0.099 -0.009v0.12c0.027 0.003 0.055 0.006 0.082 0.006 0.358 0 0.618 -0.183 0.869 -0.399 0.042 0.034 0.212 0.114 0.247 0.15 -0.238 0.2 -0.794 0.361 -1.11 0.361 -0.03 0 -0.059 -0.002 -0.088 -0.005v0.169h1.362V0zm0 0.935v0.102c-0.331 -0.059 -0.423 -0.404 -0.423 -0.404s0.159 -0.176 0.423 -0.205v0.112h-0.001C0.671 0.524 0.562 0.654 0.562 0.654s0.062 0.218 0.248 0.282m-0.588 -0.316s0.196 -0.29 0.589 -0.32V0.194C0.376 0.229 0 0.597 0 0.597s0.213 0.616 0.81 0.672v-0.112c-0.438 -0.054 -0.588 -0.538 -0.588 -0.538";
|
||||
|
||||
function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (!bytes || bytes === 0) return "0B";
|
||||
const k = 1024;
|
||||
@@ -558,13 +554,6 @@
|
||||
const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
|
||||
const modelLower = modelId.toLowerCase();
|
||||
const identity = identitiesData[nodeInfo.id];
|
||||
const nameLower = (friendlyName || "").toLowerCase();
|
||||
const isSpark = modelLower.includes("dgx") || modelLower.includes("gx10");
|
||||
const isLinux =
|
||||
!isSpark &&
|
||||
(modelLower.startsWith("linux") || identity?.osVersion === "Linux");
|
||||
const isLinuxLaptop = isLinux && modelLower.includes("laptop");
|
||||
|
||||
// Check node states for styling
|
||||
const isHighlighted = highlightedNodes.has(nodeInfo.id);
|
||||
@@ -634,382 +623,7 @@
|
||||
`${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
|
||||
);
|
||||
|
||||
if (isSpark) {
|
||||
// NVIDIA DGX Spark — gold chassis with textured front, side handles, and NVIDIA badge
|
||||
iconBaseWidth = nodeRadius * 1.55;
|
||||
iconBaseHeight = nodeRadius * 0.58;
|
||||
const x = nodeInfo.x - iconBaseWidth / 2;
|
||||
const y = nodeInfo.y - iconBaseHeight / 2;
|
||||
const chassisX = x - iconBaseWidth * 0.03;
|
||||
const chassisWidth = iconBaseWidth * 1.05;
|
||||
const cornerRadius = 3;
|
||||
|
||||
const dgxClipId = `dgx-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
defs
|
||||
.append("clipPath")
|
||||
.attr("id", dgxClipId)
|
||||
.append("rect")
|
||||
.attr("x", x)
|
||||
.attr("y", y)
|
||||
.attr("width", iconBaseWidth)
|
||||
.attr("height", iconBaseHeight)
|
||||
.attr("rx", cornerRadius);
|
||||
|
||||
// Chassis texture pattern
|
||||
const textureId = `chassis-texture-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
defs
|
||||
.append("pattern")
|
||||
.attr("id", textureId)
|
||||
.attr("patternUnits", "userSpaceOnUse")
|
||||
.attr("width", 8)
|
||||
.attr("height", 8);
|
||||
const texturePattern = defs.select(`#${textureId}`);
|
||||
texturePattern
|
||||
.append("rect")
|
||||
.attr("width", 8)
|
||||
.attr("height", 8)
|
||||
.attr("fill", "#6f6248");
|
||||
texturePattern
|
||||
.append("circle")
|
||||
.attr("cx", 2)
|
||||
.attr("cy", 2)
|
||||
.attr("r", 1)
|
||||
.attr("fill", "#5a4f3b")
|
||||
.attr("opacity", 0.5);
|
||||
texturePattern
|
||||
.append("circle")
|
||||
.attr("cx", 6)
|
||||
.attr("cy", 6)
|
||||
.attr("r", 1)
|
||||
.attr("fill", "#4a4232")
|
||||
.attr("opacity", 0.45);
|
||||
|
||||
// Main body
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("class", "node-outline")
|
||||
.attr("x", chassisX)
|
||||
.attr("y", y)
|
||||
.attr("width", chassisWidth)
|
||||
.attr("height", iconBaseHeight)
|
||||
.attr("rx", cornerRadius)
|
||||
.attr("fill", `url(#${textureId})`)
|
||||
.attr("stroke", wireColor)
|
||||
.attr("stroke-width", strokeWidth);
|
||||
|
||||
// Side border accents
|
||||
const sideThickness = iconBaseWidth * 0.02;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", chassisX)
|
||||
.attr("y", y)
|
||||
.attr("width", sideThickness)
|
||||
.attr("height", iconBaseHeight)
|
||||
.attr("fill", "#8a7a56");
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", chassisX + chassisWidth - sideThickness)
|
||||
.attr("y", y)
|
||||
.attr("width", sideThickness)
|
||||
.attr("height", iconBaseHeight)
|
||||
.attr("fill", "#8a7a56");
|
||||
|
||||
// Memory fill (bottom up)
|
||||
if (ramUsagePercent > 0) {
|
||||
const memFillHeight = (ramUsagePercent / 100) * iconBaseHeight;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", x)
|
||||
.attr("y", y + iconBaseHeight - memFillHeight)
|
||||
.attr("width", iconBaseWidth)
|
||||
.attr("height", memFillHeight)
|
||||
.attr("fill", "rgba(255,215,0,0.45)")
|
||||
.attr("clip-path", `url(#${dgxClipId})`);
|
||||
}
|
||||
|
||||
// Side handles with inner recess
|
||||
const handleWidth = iconBaseWidth * 0.27;
|
||||
const handleGap = iconBaseHeight * 0.05;
|
||||
const handleHeight = iconBaseHeight - handleGap * 2;
|
||||
const handleY = y + handleGap;
|
||||
const innerHandleWidth = iconBaseWidth * 0.12;
|
||||
const innerHandleHeight = handleHeight - iconBaseHeight * 0.06;
|
||||
const leftHandleX = x + 4;
|
||||
const rightHandleX = x + iconBaseWidth - handleWidth - 4;
|
||||
|
||||
// Left handle
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", leftHandleX)
|
||||
.attr("y", handleY)
|
||||
.attr("width", handleWidth)
|
||||
.attr("height", handleHeight)
|
||||
.attr("rx", 2.4)
|
||||
.attr("fill", "#b3a170")
|
||||
.attr("stroke", "#403723")
|
||||
.attr("stroke-width", 0.7);
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", leftHandleX + handleWidth * 0.06)
|
||||
.attr("y", handleY + iconBaseHeight * 0.03)
|
||||
.attr("width", innerHandleWidth)
|
||||
.attr("height", innerHandleHeight)
|
||||
.attr("rx", 1.6)
|
||||
.attr("fill", "#8a7a56");
|
||||
|
||||
// Right handle
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", rightHandleX)
|
||||
.attr("y", handleY)
|
||||
.attr("width", handleWidth)
|
||||
.attr("height", handleHeight)
|
||||
.attr("rx", 2.4)
|
||||
.attr("fill", "#b3a170")
|
||||
.attr("stroke", "#403723")
|
||||
.attr("stroke-width", 0.7);
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr(
|
||||
"x",
|
||||
rightHandleX + handleWidth - innerHandleWidth - handleWidth * 0.08,
|
||||
)
|
||||
.attr("y", handleY + iconBaseHeight * 0.03)
|
||||
.attr("width", innerHandleWidth)
|
||||
.attr("height", innerHandleHeight)
|
||||
.attr("rx", 1.6)
|
||||
.attr("fill", "#8a7a56");
|
||||
|
||||
// NVIDIA logo + text label (rotated 90 deg on left handle)
|
||||
const badgeWidth = iconBaseWidth * 0.09;
|
||||
const badgeHeight = handleHeight * 0.5;
|
||||
const badgeX =
|
||||
leftHandleX + handleWidth - badgeWidth - handleWidth * 0.06;
|
||||
const badgeY = handleY + (handleHeight - badgeHeight) / 2;
|
||||
const textSize = badgeWidth * 0.58;
|
||||
const logoWidth = textSize * 1.2;
|
||||
const logoHeight = logoWidth * (1.438 / 2.174);
|
||||
const centerX = badgeX + badgeWidth / 2 - badgeWidth * 0.03;
|
||||
const centerY = badgeY + badgeHeight / 2;
|
||||
const gap = badgeWidth * 0.15;
|
||||
const totalWidth = logoWidth + gap + textSize * 3.6;
|
||||
|
||||
const labelGroup = nodeG
|
||||
.append("g")
|
||||
.attr("transform", `rotate(90 ${centerX} ${centerY})`);
|
||||
|
||||
labelGroup
|
||||
.append("svg")
|
||||
.attr("x", centerX - totalWidth / 2)
|
||||
.attr("y", centerY - logoHeight / 2)
|
||||
.attr("width", logoWidth)
|
||||
.attr("height", logoHeight)
|
||||
.attr("viewBox", "0 0 2.174 1.438")
|
||||
.append("path")
|
||||
.attr("d", NVIDIA_LOGO_PATH)
|
||||
.attr("fill", "#76b900");
|
||||
|
||||
labelGroup
|
||||
.append("text")
|
||||
.attr("x", centerX - totalWidth / 2 + logoWidth + gap)
|
||||
.attr("y", centerY)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.attr("fill", "#8a7a56")
|
||||
.attr("font-size", textSize)
|
||||
.attr("font-family", "monospace")
|
||||
.attr("font-weight", "700")
|
||||
.text("NVIDIA");
|
||||
} else if (isLinuxLaptop) {
|
||||
// Linux Laptop — same shape as MacBook but with Tux logo
|
||||
iconBaseWidth = nodeRadius * 1.6;
|
||||
iconBaseHeight = nodeRadius * 1.15;
|
||||
const x = nodeInfo.x - iconBaseWidth / 2;
|
||||
const y = nodeInfo.y - iconBaseHeight / 2;
|
||||
|
||||
const screenHeight = iconBaseHeight * 0.7;
|
||||
const baseHeight = iconBaseHeight * 0.3;
|
||||
const screenWidth = iconBaseWidth * 0.85;
|
||||
const screenX = nodeInfo.x - screenWidth / 2;
|
||||
const screenBezel = 3;
|
||||
|
||||
const linuxScreenClipId = `linux-screen-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
defs
|
||||
.append("clipPath")
|
||||
.attr("id", linuxScreenClipId)
|
||||
.append("rect")
|
||||
.attr("x", screenX + screenBezel)
|
||||
.attr("y", y + screenBezel)
|
||||
.attr("width", screenWidth - screenBezel * 2)
|
||||
.attr("height", screenHeight - screenBezel * 2)
|
||||
.attr("rx", 2);
|
||||
|
||||
// Screen outer frame
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("class", "node-outline")
|
||||
.attr("x", screenX)
|
||||
.attr("y", y)
|
||||
.attr("width", screenWidth)
|
||||
.attr("height", screenHeight)
|
||||
.attr("rx", 3)
|
||||
.attr("fill", "#1a1a1a")
|
||||
.attr("stroke", wireColor)
|
||||
.attr("stroke-width", strokeWidth);
|
||||
|
||||
// Screen inner
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", screenX + screenBezel)
|
||||
.attr("y", y + screenBezel)
|
||||
.attr("width", screenWidth - screenBezel * 2)
|
||||
.attr("height", screenHeight - screenBezel * 2)
|
||||
.attr("rx", 2)
|
||||
.attr("fill", "#0a0a12");
|
||||
|
||||
// Memory fill on screen
|
||||
if (ramUsagePercent > 0) {
|
||||
const memFillTotalHeight = screenHeight - screenBezel * 2;
|
||||
const memFillActualHeight =
|
||||
(ramUsagePercent / 100) * memFillTotalHeight;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", screenX + screenBezel)
|
||||
.attr(
|
||||
"y",
|
||||
y + screenBezel + (memFillTotalHeight - memFillActualHeight),
|
||||
)
|
||||
.attr("width", screenWidth - screenBezel * 2)
|
||||
.attr("height", memFillActualHeight)
|
||||
.attr("fill", "rgba(255,215,0,0.85)")
|
||||
.attr("clip-path", `url(#${linuxScreenClipId})`);
|
||||
}
|
||||
|
||||
// Terminal prompt on screen
|
||||
nodeG
|
||||
.append("text")
|
||||
.attr("x", nodeInfo.x)
|
||||
.attr("y", y + screenHeight / 2)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.attr("fill", "#FFFFFF")
|
||||
.attr("opacity", 0.9)
|
||||
.attr("font-size", screenHeight * 0.25)
|
||||
.attr("font-family", "SF Mono, Monaco, monospace")
|
||||
.attr("font-weight", "700")
|
||||
.text(">_");
|
||||
|
||||
// Keyboard base (trapezoidal)
|
||||
const baseY = y + screenHeight;
|
||||
const baseTopWidth = screenWidth;
|
||||
const baseBottomWidth = iconBaseWidth;
|
||||
const baseTopX = nodeInfo.x - baseTopWidth / 2;
|
||||
const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
|
||||
|
||||
nodeG
|
||||
.append("path")
|
||||
.attr(
|
||||
"d",
|
||||
`M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`,
|
||||
)
|
||||
.attr("fill", "#2c2c2c")
|
||||
.attr("stroke", wireColor)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Keyboard area
|
||||
const keyboardX = baseTopX + 6;
|
||||
const keyboardY = baseY + 3;
|
||||
const keyboardWidth = baseTopWidth - 12;
|
||||
const keyboardHeight = baseHeight * 0.55;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", keyboardX)
|
||||
.attr("y", keyboardY)
|
||||
.attr("width", keyboardWidth)
|
||||
.attr("height", keyboardHeight)
|
||||
.attr("fill", "rgba(0,0,0,0.2)")
|
||||
.attr("rx", 2);
|
||||
|
||||
// Trackpad
|
||||
const trackpadWidth = baseTopWidth * 0.4;
|
||||
const trackpadX = nodeInfo.x - trackpadWidth / 2;
|
||||
const trackpadY = baseY + keyboardHeight + 5;
|
||||
const trackpadHeight = baseHeight * 0.3;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", trackpadX)
|
||||
.attr("y", trackpadY)
|
||||
.attr("width", trackpadWidth)
|
||||
.attr("height", trackpadHeight)
|
||||
.attr("fill", "rgba(255,255,255,0.08)")
|
||||
.attr("rx", 2);
|
||||
} else if (isLinux) {
|
||||
// Linux Desktop — same shape as Mac Studio but with Tux logo
|
||||
iconBaseWidth = nodeRadius * 1.25;
|
||||
iconBaseHeight = nodeRadius * 0.85;
|
||||
const x = nodeInfo.x - iconBaseWidth / 2;
|
||||
const y = nodeInfo.y - iconBaseHeight / 2;
|
||||
const cornerRadius = 4;
|
||||
const topSurfaceHeight = iconBaseHeight * 0.15;
|
||||
|
||||
const linuxDesktopClipId = `linux-desktop-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
defs
|
||||
.append("clipPath")
|
||||
.attr("id", linuxDesktopClipId)
|
||||
.append("rect")
|
||||
.attr("x", x)
|
||||
.attr("y", y + topSurfaceHeight)
|
||||
.attr("width", iconBaseWidth)
|
||||
.attr("height", iconBaseHeight - topSurfaceHeight)
|
||||
.attr("rx", cornerRadius - 1);
|
||||
|
||||
// Main body
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("class", "node-outline")
|
||||
.attr("x", x)
|
||||
.attr("y", y)
|
||||
.attr("width", iconBaseWidth)
|
||||
.attr("height", iconBaseHeight)
|
||||
.attr("rx", cornerRadius)
|
||||
.attr("fill", "#1a1a1a")
|
||||
.attr("stroke", wireColor)
|
||||
.attr("stroke-width", strokeWidth);
|
||||
|
||||
// Memory fill
|
||||
if (ramUsagePercent > 0) {
|
||||
const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
|
||||
const memFillActualHeight =
|
||||
(ramUsagePercent / 100) * memFillTotalHeight;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", x)
|
||||
.attr(
|
||||
"y",
|
||||
y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
|
||||
)
|
||||
.attr("width", iconBaseWidth)
|
||||
.attr("height", memFillActualHeight)
|
||||
.attr("fill", "rgba(255,215,0,0.75)")
|
||||
.attr("clip-path", `url(#${linuxDesktopClipId})`);
|
||||
}
|
||||
|
||||
// Terminal prompt on front face
|
||||
nodeG
|
||||
.append("text")
|
||||
.attr("x", nodeInfo.x)
|
||||
.attr(
|
||||
"y",
|
||||
y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) / 2,
|
||||
)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dominant-baseline", "middle")
|
||||
.attr("fill", "rgba(255,255,255,0.5)")
|
||||
.attr("font-size", (iconBaseHeight - topSurfaceHeight) * 0.4)
|
||||
.attr("font-family", "SF Mono, Monaco, monospace")
|
||||
.attr("font-weight", "700")
|
||||
.text(">_");
|
||||
} else if (modelLower === "mac studio") {
|
||||
if (modelLower === "mac studio") {
|
||||
// Mac Studio - classic cube with memory fill
|
||||
iconBaseWidth = nodeRadius * 1.25;
|
||||
iconBaseHeight = nodeRadius * 0.85;
|
||||
@@ -1568,12 +1182,8 @@
|
||||
debugLabelY += debugLineHeight;
|
||||
}
|
||||
|
||||
const dbgIdentity = identitiesData[nodeInfo.id];
|
||||
if (dbgIdentity?.osVersion) {
|
||||
const osLabel =
|
||||
dbgIdentity.osVersion === "Linux"
|
||||
? "Linux"
|
||||
: `macOS ${dbgIdentity.osVersion}${dbgIdentity.osBuildVersion ? ` (${dbgIdentity.osBuildVersion})` : ""}`;
|
||||
const identity = identitiesData[nodeInfo.id];
|
||||
if (identity?.osVersion) {
|
||||
nodeG
|
||||
.append("text")
|
||||
.attr("x", nodeInfo.x)
|
||||
@@ -1582,7 +1192,9 @@
|
||||
.attr("fill", "rgba(179,179,179,0.7)")
|
||||
.attr("font-size", debugFontSize)
|
||||
.attr("font-family", "SF Mono, Monaco, monospace")
|
||||
.text(osLabel);
|
||||
.text(
|
||||
`macOS ${identity.osVersion}${identity.osBuildVersion ? ` (${identity.osBuildVersion})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,3 +13,4 @@ export { default as ModelFilterPopover } from "./ModelFilterPopover.svelte";
|
||||
export { default as ModelPickerGroup } from "./ModelPickerGroup.svelte";
|
||||
export { default as ModelPickerModal } from "./ModelPickerModal.svelte";
|
||||
export { default as ChatModelSelector } from "./ChatModelSelector.svelte";
|
||||
export { default as IntegrationCard } from "./IntegrationCard.svelte";
|
||||
|
||||
@@ -168,7 +168,7 @@ export interface ModelDownloadStatus {
|
||||
export interface PlacementPreview {
|
||||
model_id: string;
|
||||
sharding: "Pipeline" | "Tensor";
|
||||
instance_meta: "MlxRing" | "MlxJaccl" | "Vllm";
|
||||
instance_meta: "MlxRing" | "MlxJaccl";
|
||||
instance: unknown | null;
|
||||
memory_delta_by_node: Record<string, number> | null;
|
||||
error: string | null;
|
||||
@@ -257,11 +257,12 @@ interface RawStateResponse {
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
type: "image" | "text" | "file" | "generated-image";
|
||||
type: "image" | "text" | "file" | "generated-image" | "pdf";
|
||||
name: string;
|
||||
content?: string;
|
||||
preview?: string;
|
||||
mimeType?: string;
|
||||
pageImages?: string[];
|
||||
}
|
||||
|
||||
export interface TopLogprob {
|
||||
@@ -547,7 +548,6 @@ class AppStore {
|
||||
{ total: { inBytes: number }; available: { inBytes: number } }
|
||||
>
|
||||
>({});
|
||||
vllmAvailable = $state(false);
|
||||
placementPreviews = $state<PlacementPreview[]>([]);
|
||||
selectedPreviewModelId = $state<string | null>(null);
|
||||
isLoadingPreviews = $state(false);
|
||||
@@ -1332,15 +1332,6 @@ class AppStore {
|
||||
this.isConnected = true;
|
||||
}
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
fetch("/capabilities")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { vllm_available?: boolean } | null) => {
|
||||
this.vllmAvailable = data?.vllm_available ?? false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.vllmAvailable = false;
|
||||
});
|
||||
} catch (error) {
|
||||
this.consecutiveFailures++;
|
||||
if (
|
||||
@@ -1803,6 +1794,14 @@ class AppStore {
|
||||
this.persistConversation(targetConversationId);
|
||||
}
|
||||
},
|
||||
{
|
||||
generation_stats: (data) => {
|
||||
const stats = data as { generation_tps: number };
|
||||
if (stats.generation_tps > 0) {
|
||||
this.tps = stats.generation_tps;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Final update
|
||||
@@ -2000,6 +1999,14 @@ class AppStore {
|
||||
this.persistConversation(targetConversationId);
|
||||
}
|
||||
},
|
||||
{
|
||||
generation_stats: (data) => {
|
||||
const stats = data as { generation_tps: number };
|
||||
if (stats.generation_tps > 0) {
|
||||
this.tps = stats.generation_tps;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Final cleanup of the message (if conversation still exists)
|
||||
@@ -2236,6 +2243,7 @@ class AppStore {
|
||||
type: string;
|
||||
textContent?: string;
|
||||
preview?: string;
|
||||
pageImages?: string[];
|
||||
}[],
|
||||
enableThinking?: boolean | null,
|
||||
): Promise<void> {
|
||||
@@ -2271,6 +2279,20 @@ class AppStore {
|
||||
preview: file.preview,
|
||||
mimeType: file.type,
|
||||
});
|
||||
} else if (
|
||||
file.pageImages ||
|
||||
(file.textContent && file.type === "application/pdf")
|
||||
) {
|
||||
attachments.push({
|
||||
type: "pdf",
|
||||
name: file.name,
|
||||
content: file.textContent,
|
||||
pageImages: file.pageImages,
|
||||
mimeType: file.type,
|
||||
});
|
||||
if (file.textContent) {
|
||||
fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
|
||||
}
|
||||
} else if (file.textContent) {
|
||||
attachments.push({
|
||||
type: "text",
|
||||
@@ -2338,13 +2360,70 @@ class AppStore {
|
||||
const apiMessages = [
|
||||
systemPrompt,
|
||||
...targetConversation.messages.slice(0, -1).map((m) => {
|
||||
// Build content including any text file attachments
|
||||
// Check if this message has image or PDF attachments
|
||||
const visualAttachments = m.attachments?.filter(
|
||||
(a) =>
|
||||
(a.type === "image" && a.preview) ||
|
||||
(a.type === "pdf" && a.pageImages?.length),
|
||||
);
|
||||
|
||||
if (visualAttachments && visualAttachments.length > 0) {
|
||||
// Build multimodal content array (OpenAI vision format)
|
||||
const contentParts: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } }
|
||||
> = [];
|
||||
|
||||
// Add image parts first
|
||||
for (const att of visualAttachments) {
|
||||
if (att.type === "image" && att.preview) {
|
||||
contentParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: att.preview },
|
||||
});
|
||||
} else if (att.type === "pdf" && att.pageImages) {
|
||||
for (const pageImg of att.pageImages) {
|
||||
contentParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: pageImg },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build text content including any text/pdf file attachments
|
||||
let textContent = m.content;
|
||||
if (m.attachments) {
|
||||
for (const attachment of m.attachments) {
|
||||
if (
|
||||
(attachment.type === "text" || attachment.type === "pdf") &&
|
||||
attachment.content
|
||||
) {
|
||||
textContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (textContent) {
|
||||
contentParts.push({ type: "text", text: textContent });
|
||||
}
|
||||
|
||||
return {
|
||||
role: m.role,
|
||||
content: contentParts,
|
||||
};
|
||||
}
|
||||
|
||||
// Text-only message (original path)
|
||||
let msgContent = m.content;
|
||||
|
||||
// Add text attachments as context
|
||||
// Add text/pdf attachments as context
|
||||
if (m.attachments) {
|
||||
for (const attachment of m.attachments) {
|
||||
if (attachment.type === "text" && attachment.content) {
|
||||
if (
|
||||
(attachment.type === "text" || attachment.type === "pdf") &&
|
||||
attachment.content
|
||||
) {
|
||||
msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
|
||||
}
|
||||
}
|
||||
@@ -2407,7 +2486,7 @@ class AppStore {
|
||||
|
||||
let streamedContent = "";
|
||||
let streamedThinking = "";
|
||||
|
||||
let serverTpsReceived = false;
|
||||
interface ChatCompletionChunk {
|
||||
choices?: Array<{
|
||||
delta?: { content?: string; reasoning_content?: string };
|
||||
@@ -2472,7 +2551,6 @@ class AppStore {
|
||||
tokenCount += 1;
|
||||
this.totalTokens = tokenCount;
|
||||
|
||||
// Update real-time TPS during streaming
|
||||
if (firstTokenTime !== null && tokenCount > 1) {
|
||||
const elapsed = performance.now() - firstTokenTime;
|
||||
this.tps = (tokenCount / elapsed) * 1000;
|
||||
@@ -2523,16 +2601,24 @@ class AppStore {
|
||||
startedAt: this.prefillProgress?.startedAt ?? performance.now(),
|
||||
};
|
||||
},
|
||||
generation_stats: (data) => {
|
||||
const stats = data as { generation_tps: number };
|
||||
|
||||
if (stats.generation_tps > 0) {
|
||||
this.tps = stats.generation_tps;
|
||||
serverTpsReceived = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Clear prefill progress after stream ends
|
||||
this.prefillProgress = null;
|
||||
|
||||
// Calculate final TPS
|
||||
if (firstTokenTime !== null && tokenCount > 1) {
|
||||
// Use server-side TPS if available, otherwise fall back to client-side
|
||||
if (!serverTpsReceived && firstTokenTime !== null && tokenCount > 1) {
|
||||
const totalGenerationTime = performance.now() - firstTokenTime;
|
||||
this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
|
||||
this.tps = (tokenCount / totalGenerationTime) * 1000;
|
||||
}
|
||||
|
||||
// Final cleanup of the message (if conversation still exists)
|
||||
@@ -2637,6 +2723,9 @@ class AppStore {
|
||||
this.syncActiveMessagesIfNeeded(targetConversationId);
|
||||
this.saveConversationsToStorage();
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.currentAbortController = abortController;
|
||||
|
||||
try {
|
||||
// Determine the model to use
|
||||
const model = this.getModelForRequest(modelId);
|
||||
@@ -2691,6 +2780,7 @@ class AppStore {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -2830,14 +2920,27 @@ class AppStore {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating image:", error);
|
||||
this.handleStreamingError(
|
||||
error,
|
||||
targetConversationId,
|
||||
assistantMessage.id,
|
||||
"Failed to generate image",
|
||||
);
|
||||
if (abortController.signal.aborted) {
|
||||
this.updateConversationMessage(
|
||||
targetConversationId,
|
||||
assistantMessage.id,
|
||||
(msg) => {
|
||||
msg.content = "Cancelled";
|
||||
msg.attachments = [];
|
||||
},
|
||||
);
|
||||
this.syncActiveMessagesIfNeeded(targetConversationId);
|
||||
} else {
|
||||
console.error("Error generating image:", error);
|
||||
this.handleStreamingError(
|
||||
error,
|
||||
targetConversationId,
|
||||
assistantMessage.id,
|
||||
"Failed to generate image",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.currentAbortController = null;
|
||||
this.isLoading = false;
|
||||
this.saveConversationsToStorage();
|
||||
}
|
||||
@@ -2901,6 +3004,9 @@ class AppStore {
|
||||
// Clear editing state
|
||||
this.editingImage = null;
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.currentAbortController = abortController;
|
||||
|
||||
try {
|
||||
// Determine the model to use
|
||||
const model = this.getModelForRequest(modelId);
|
||||
@@ -2962,6 +3068,7 @@ class AppStore {
|
||||
const apiResponse = await fetch("/v1/images/edits", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
@@ -3062,14 +3169,27 @@ class AppStore {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error editing image:", error);
|
||||
this.handleStreamingError(
|
||||
error,
|
||||
targetConversationId,
|
||||
assistantMessage.id,
|
||||
"Failed to edit image",
|
||||
);
|
||||
if (abortController.signal.aborted) {
|
||||
this.updateConversationMessage(
|
||||
targetConversationId,
|
||||
assistantMessage.id,
|
||||
(msg) => {
|
||||
msg.content = "Cancelled";
|
||||
msg.attachments = [];
|
||||
},
|
||||
);
|
||||
this.syncActiveMessagesIfNeeded(targetConversationId);
|
||||
} else {
|
||||
console.error("Error editing image:", error);
|
||||
this.handleStreamingError(
|
||||
error,
|
||||
targetConversationId,
|
||||
assistantMessage.id,
|
||||
"Failed to edit image",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.currentAbortController = null;
|
||||
this.isLoading = false;
|
||||
this.saveConversationsToStorage();
|
||||
}
|
||||
@@ -3258,6 +3378,7 @@ export const sendMessage = (
|
||||
type: string;
|
||||
textContent?: string;
|
||||
preview?: string;
|
||||
pageImages?: string[];
|
||||
}[],
|
||||
enableThinking?: boolean | null,
|
||||
) => appStore.sendMessage(content, files, enableThinking);
|
||||
@@ -3344,7 +3465,6 @@ export const nodeThunderbolt = () => appStore.nodeThunderbolt;
|
||||
export const nodeRdmaCtl = () => appStore.nodeRdmaCtl;
|
||||
export const thunderboltBridgeCycles = () => appStore.thunderboltBridgeCycles;
|
||||
export const nodeThunderboltBridge = () => appStore.nodeThunderboltBridge;
|
||||
export const vllmAvailable = () => appStore.vllmAvailable;
|
||||
|
||||
// Image generation params
|
||||
export const imageGenerationParams = () => appStore.getImageGenerationParams();
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
* File attachment types for the chat interface
|
||||
*/
|
||||
|
||||
import { getDocument, GlobalWorkerOptions, version } from "pdfjs-dist";
|
||||
import type { DocumentInitParameters } from "pdfjs-dist/types/src/display/api";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.mjs`;
|
||||
|
||||
const PDF_PAGE_SCALE = 2.0;
|
||||
const PDF_MAX_PAGES = 20;
|
||||
const PDF_MAX_TEXT_CHARS = 100_000;
|
||||
|
||||
export interface ChatUploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -10,6 +19,7 @@ export interface ChatUploadedFile {
|
||||
file: File;
|
||||
preview?: string;
|
||||
textContent?: string;
|
||||
pageImages?: string[];
|
||||
}
|
||||
|
||||
export interface ChatAttachment {
|
||||
@@ -194,6 +204,58 @@ export function readFileAsText(file: File): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function extractPdfContent(
|
||||
file: File,
|
||||
): Promise<{ text: string; pageImages: string[] }> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({
|
||||
data: new Uint8Array(arrayBuffer),
|
||||
useSystemFonts: true,
|
||||
} as DocumentInitParameters).promise;
|
||||
|
||||
const numPages = Math.min(pdf.numPages, PDF_MAX_PAGES);
|
||||
const pageTexts: string[] = [];
|
||||
const pageImages: string[] = [];
|
||||
|
||||
for (let i = 1; i <= numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
|
||||
const content = await page.getTextContent();
|
||||
const strings = content.items
|
||||
.filter((item: any) => "str" in item)
|
||||
.map((item: any) => item.str as string);
|
||||
pageTexts.push(strings.join(" "));
|
||||
|
||||
const viewport = page.getViewport({ scale: PDF_PAGE_SCALE });
|
||||
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
await page.render({ canvasContext: ctx as any, viewport }).promise;
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: "image/jpeg",
|
||||
quality: 0.8,
|
||||
});
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
pageImages.push(dataUrl);
|
||||
}
|
||||
}
|
||||
|
||||
let text = pageTexts.join("\n\n").trim();
|
||||
if (text.length > PDF_MAX_TEXT_CHARS) {
|
||||
text = text.slice(0, PDF_MAX_TEXT_CHARS) + "\n\n[truncated]";
|
||||
}
|
||||
if (pdf.numPages > PDF_MAX_PAGES) {
|
||||
text += `\n\n[showing ${PDF_MAX_PAGES} of ${pdf.numPages} pages]`;
|
||||
}
|
||||
|
||||
return { text, pageImages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process uploaded files into ChatUploadedFile format
|
||||
*/
|
||||
@@ -223,7 +285,12 @@ export async function processUploadedFiles(
|
||||
const textContent = await readFileAsText(file);
|
||||
results.push({ ...base, textContent });
|
||||
} else if (category === "pdf") {
|
||||
results.push(base);
|
||||
const { text, pageImages } = await extractPdfContent(file);
|
||||
results.push({
|
||||
...base,
|
||||
textContent: text || undefined,
|
||||
pageImages: pageImages.length > 0 ? pageImages : undefined,
|
||||
});
|
||||
} else if (category === "audio") {
|
||||
const preview = await readFileAsDataURL(file);
|
||||
results.push({ ...base, preview });
|
||||
|
||||
+232
-289
@@ -42,6 +42,7 @@
|
||||
setSelectedChatModel,
|
||||
selectedChatModel,
|
||||
sendMessage,
|
||||
thinkingEnabled,
|
||||
generateImage,
|
||||
editImage,
|
||||
editingImage,
|
||||
@@ -64,7 +65,6 @@
|
||||
nodeThunderboltBridge,
|
||||
nodeIdentities,
|
||||
isConnected,
|
||||
vllmAvailable,
|
||||
type DownloadProgress,
|
||||
type PlacementPreview,
|
||||
} from "$lib/stores/app.svelte";
|
||||
@@ -265,6 +265,7 @@
|
||||
|
||||
let mounted = $state(false);
|
||||
let localNodeId = $state<string | null>(null);
|
||||
let pendingFirefoxQuery = $state<string | null>(null); // ?q= param deferred until state loads
|
||||
|
||||
// ── Onboarding wizard state ──
|
||||
const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
|
||||
@@ -295,9 +296,7 @@
|
||||
const seen = new Set<string>();
|
||||
const deduped: typeof candidates = [];
|
||||
for (const m of candidates) {
|
||||
const key = (m.base_model || m.family || m.id)
|
||||
.toLowerCase()
|
||||
.replace(/[-_]/g, " ");
|
||||
const key = m.base_model || m.family || m.id;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(m);
|
||||
@@ -703,10 +702,7 @@
|
||||
? Object.keys(topologyData()!.nodes).length
|
||||
: 1;
|
||||
const sharding = nodeCount <= 1 ? "Pipeline" : selectedSharding;
|
||||
const instanceType =
|
||||
nodeCount <= 1 && selectedInstanceType !== "Vllm"
|
||||
? "MlxRing"
|
||||
: selectedInstanceType;
|
||||
const instanceType = nodeCount <= 1 ? "MlxRing" : selectedInstanceType;
|
||||
try {
|
||||
const placementResponse = await fetch(
|
||||
`/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${sharding}&instance_meta=${instanceType}&min_nodes=1`,
|
||||
@@ -858,7 +854,7 @@
|
||||
) {
|
||||
const model = selectedChatModel();
|
||||
if (!model) {
|
||||
sendMessage(content, files, null);
|
||||
sendMessage(content, files, thinkingEnabled());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -886,11 +882,11 @@
|
||||
}
|
||||
|
||||
// Default: text chat
|
||||
sendMessage(content, files, null);
|
||||
sendMessage(content, files, thinkingEnabled());
|
||||
}
|
||||
|
||||
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
|
||||
type InstanceMeta = "MlxRing" | "MlxJaccl" | "Vllm";
|
||||
type InstanceMeta = "MlxRing" | "MlxJaccl";
|
||||
|
||||
// Launch defaults persistence
|
||||
const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults-v2";
|
||||
@@ -936,11 +932,7 @@
|
||||
// Apply sharding and instance type unconditionally
|
||||
selectedSharding = defaults.sharding;
|
||||
selectedInstanceType =
|
||||
defaults.instanceType === "Vllm"
|
||||
? "Vllm"
|
||||
: defaults.instanceType === "MlxRing"
|
||||
? "MlxRing"
|
||||
: "MlxJaccl";
|
||||
defaults.instanceType === "MlxRing" ? "MlxRing" : "MlxJaccl";
|
||||
|
||||
// Apply minNodes if valid (between 1 and maxNodes)
|
||||
if (
|
||||
@@ -1154,7 +1146,9 @@
|
||||
}
|
||||
|
||||
const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
|
||||
runtime === selectedInstanceType;
|
||||
selectedInstanceType === "MlxRing"
|
||||
? runtime === "MlxRing"
|
||||
: runtime === "MlxJaccl";
|
||||
|
||||
// Helper to check if a model can be launched (has valid placement with >= minNodes)
|
||||
function canModelFit(modelId: string): boolean {
|
||||
@@ -1316,6 +1310,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Firefox AI sidebar integration: handle ?q= query parameter
|
||||
// Firefox's built-in AI sidebar (about:config: browser.ml.chat.enabled) sends
|
||||
// the user's prompt as ?q=<URL-encoded prompt> to the configured provider URL.
|
||||
// See: https://support.mozilla.org/en-US/kb/ai-chatbot
|
||||
const queryParam = params.get("q");
|
||||
if (queryParam) {
|
||||
// Clean up the URL to prevent re-submission on page refresh
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
// Defer the auto-send until cluster state is loaded (topologyData,
|
||||
// instances, availableMemory) so that model auto-selection works
|
||||
// correctly. The $effect below will pick this up once data is ready.
|
||||
pendingFirefoxQuery = queryParam;
|
||||
}
|
||||
|
||||
// Check server-side onboarding state (persisted in ~/.exo)
|
||||
try {
|
||||
const res = await fetch("/onboarding");
|
||||
@@ -1336,6 +1344,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Deferred Firefox AI sidebar auto-send: wait for cluster state and model
|
||||
// list before submitting. Both data (from /state polling) and models (from
|
||||
// the async /models fetch in onMount) must be loaded for handleAutoSend to
|
||||
// correctly auto-select a model.
|
||||
$effect(() => {
|
||||
if (pendingFirefoxQuery && data && models.length > 0) {
|
||||
const query = pendingFirefoxQuery;
|
||||
pendingFirefoxQuery = null;
|
||||
handleChatSend(query);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const response = await fetch("/models");
|
||||
@@ -1543,34 +1563,44 @@
|
||||
}
|
||||
|
||||
// Helper to get download status for a model (checks all downloads for matching model ID)
|
||||
function getModelDownloadStatus(modelId: string): {
|
||||
type NodeDownloadStatus = {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
status: "completed" | "partial" | "pending" | "downloading";
|
||||
percentage: number;
|
||||
progress: DownloadProgress | null;
|
||||
};
|
||||
|
||||
// Shared helper: collect per-node download status for a model across a set of nodes.
|
||||
// Handles deduplication, entry parsing, and aggregation in one place.
|
||||
function collectDownloadStatus(
|
||||
modelId: string,
|
||||
nodeIds?: string[],
|
||||
): {
|
||||
isDownloading: boolean;
|
||||
progress: DownloadProgress | null;
|
||||
perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}>;
|
||||
perNode: NodeDownloadStatus[];
|
||||
failedError: string | null;
|
||||
} {
|
||||
const empty = {
|
||||
isDownloading: false,
|
||||
progress: null,
|
||||
perNode: [] as NodeDownloadStatus[],
|
||||
failedError: null,
|
||||
};
|
||||
|
||||
if (!downloadsData || Object.keys(downloadsData).length === 0) {
|
||||
return { isDownloading: false, progress: null, perNode: [] };
|
||||
return empty;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
let downloadedBytes = 0;
|
||||
let totalSpeed = 0;
|
||||
let completedFiles = 0;
|
||||
let totalFiles = 0;
|
||||
let isDownloading = false;
|
||||
const allFiles: DownloadProgress["files"] = [];
|
||||
const perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}> = [];
|
||||
// Deduplicate by nodeId — a node can have multiple entries for the same model
|
||||
// (e.g. PipelineShardMetadata + TensorShardMetadata). Keep the last entry,
|
||||
// which is the most recently applied event.
|
||||
const perNodeMap = new Map<string, NodeDownloadStatus>();
|
||||
|
||||
// Check all nodes for downloads matching this model
|
||||
const nodeIdSet = nodeIds ? new Set(nodeIds) : null;
|
||||
for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
|
||||
if (nodeIdSet && !nodeIdSet.has(nodeId)) continue;
|
||||
if (!Array.isArray(nodeDownloads)) continue;
|
||||
|
||||
for (const downloadWrapped of nodeDownloads) {
|
||||
@@ -1583,29 +1613,45 @@
|
||||
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
|
||||
downloadKind
|
||||
] as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
downloadKind !== "DownloadOngoing" &&
|
||||
downloadKind !== "DownloadPending"
|
||||
)
|
||||
continue;
|
||||
if (!downloadPayload) continue;
|
||||
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
if (!downloadModelId || downloadModelId !== modelId) continue;
|
||||
|
||||
// Match if the model ID contains or equals the requested model
|
||||
// (handles cases like "mlx-community/Meta-Llama..." matching)
|
||||
if (
|
||||
!downloadModelId ||
|
||||
!downloadModelId.includes(modelId.split("/").pop() || modelId)
|
||||
) {
|
||||
// Try exact match or partial match
|
||||
if (downloadModelId !== modelId) continue;
|
||||
// DownloadFailed — return with any data collected so far
|
||||
if (downloadKind === "DownloadFailed") {
|
||||
return {
|
||||
isDownloading: false,
|
||||
progress: null,
|
||||
perNode: Array.from(perNodeMap.values()),
|
||||
failedError:
|
||||
(downloadPayload.errorMessage as string) ||
|
||||
(downloadPayload.error_message as string) ||
|
||||
"Download failed",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
downloadKind !== "DownloadOngoing" &&
|
||||
downloadKind !== "DownloadPending" &&
|
||||
downloadKind !== "DownloadCompleted"
|
||||
)
|
||||
continue;
|
||||
|
||||
const nodeName =
|
||||
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
|
||||
|
||||
if (downloadKind === "DownloadCompleted") {
|
||||
perNodeMap.set(nodeId, {
|
||||
nodeId,
|
||||
nodeName,
|
||||
status: "completed",
|
||||
percentage: 100,
|
||||
progress: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// For DownloadPending with partial bytes (paused/resumed downloads),
|
||||
// synthesize a progress object from the top-level downloaded/total fields
|
||||
let progress: DownloadProgress | null;
|
||||
if (downloadKind === "DownloadPending") {
|
||||
const pendingDownloaded = getBytes(
|
||||
downloadPayload.downloaded ??
|
||||
@@ -1618,44 +1664,67 @@
|
||||
downloadPayload.totalBytes,
|
||||
);
|
||||
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
|
||||
isDownloading = true;
|
||||
progress = {
|
||||
totalBytes: pendingTotal,
|
||||
downloadedBytes: pendingDownloaded,
|
||||
speed: 0,
|
||||
etaMs: 0,
|
||||
percentage:
|
||||
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
files: [],
|
||||
};
|
||||
} else {
|
||||
isDownloading = true;
|
||||
progress = parseDownloadProgress(downloadPayload);
|
||||
const pct =
|
||||
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
|
||||
perNodeMap.set(nodeId, {
|
||||
nodeId,
|
||||
nodeName,
|
||||
status: pendingDownloaded > 0 ? "partial" : "pending",
|
||||
percentage: pct,
|
||||
progress: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
// Sum all values across nodes - each node downloads independently
|
||||
totalBytes += progress.totalBytes;
|
||||
downloadedBytes += progress.downloadedBytes;
|
||||
totalSpeed += progress.speed;
|
||||
completedFiles += progress.completedFiles;
|
||||
totalFiles += progress.totalFiles;
|
||||
allFiles.push(...progress.files);
|
||||
// DownloadOngoing
|
||||
const progress = parseDownloadProgress(downloadPayload);
|
||||
if (
|
||||
!progress ||
|
||||
(progress.downloadedBytes <= 0 && progress.totalBytes <= 0)
|
||||
)
|
||||
continue;
|
||||
|
||||
const nodeName =
|
||||
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
|
||||
perNode.push({ nodeId, nodeName, progress });
|
||||
}
|
||||
perNodeMap.set(nodeId, {
|
||||
nodeId,
|
||||
nodeName,
|
||||
status: "downloading",
|
||||
percentage: progress.percentage,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate from deduplicated per-node entries
|
||||
const perNode = Array.from(perNodeMap.values());
|
||||
let totalBytes = 0;
|
||||
let downloadedBytes = 0;
|
||||
let totalSpeed = 0;
|
||||
let completedFiles = 0;
|
||||
let totalFiles = 0;
|
||||
let isDownloading = false;
|
||||
const allFiles: DownloadProgress["files"] = [];
|
||||
|
||||
for (const node of perNode) {
|
||||
if (node.status === "downloading" && node.progress) {
|
||||
isDownloading = true;
|
||||
totalBytes += node.progress.totalBytes;
|
||||
downloadedBytes += node.progress.downloadedBytes;
|
||||
totalSpeed += node.progress.speed;
|
||||
completedFiles += node.progress.completedFiles;
|
||||
totalFiles += node.progress.totalFiles;
|
||||
allFiles.push(...node.progress.files);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDownloading) {
|
||||
return { isDownloading: false, progress: null, perNode: [] };
|
||||
return {
|
||||
isDownloading: false,
|
||||
progress: null,
|
||||
perNode,
|
||||
failedError: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ETA = total remaining bytes / total speed across all nodes
|
||||
const remainingBytes = totalBytes - downloadedBytes;
|
||||
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
|
||||
|
||||
@@ -1672,9 +1741,21 @@
|
||||
files: allFiles,
|
||||
},
|
||||
perNode,
|
||||
failedError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getModelDownloadStatus(
|
||||
modelId: string,
|
||||
nodeIds?: string[],
|
||||
): {
|
||||
isDownloading: boolean;
|
||||
progress: DownloadProgress | null;
|
||||
perNode: NodeDownloadStatus[];
|
||||
} {
|
||||
return collectDownloadStatus(modelId, nodeIds);
|
||||
}
|
||||
|
||||
// Helper to get download status for an instance
|
||||
function getInstanceDownloadStatus(
|
||||
instanceId: string,
|
||||
@@ -1685,26 +1766,9 @@
|
||||
errorMessage: string | null;
|
||||
progress: DownloadProgress | null;
|
||||
statusText: string;
|
||||
perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}>;
|
||||
perNode: NodeDownloadStatus[];
|
||||
} {
|
||||
if (!downloadsData || Object.keys(downloadsData).length === 0) {
|
||||
// No download data yet — defer to runner status instead of assuming RUNNING
|
||||
const statusInfo = deriveInstanceStatus(instanceWrapped);
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: false,
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
statusText: statusInfo.statusText,
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Unwrap the instance
|
||||
// Unwrap the instance to get shard assignments
|
||||
const [instanceTag, instance] = getTagged(instanceWrapped);
|
||||
if (!instance || typeof instance !== "object") {
|
||||
return {
|
||||
@@ -1724,132 +1788,9 @@
|
||||
modelId?: string;
|
||||
};
|
||||
};
|
||||
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
|
||||
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
|
||||
const instanceModelId = inst.shardAssignments?.modelId;
|
||||
|
||||
// Build reverse mapping: runnerId -> nodeId
|
||||
const runnerToNode: Record<string, string> = {};
|
||||
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
|
||||
runnerToNode[runnerId] = nodeId;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
let downloadedBytes = 0;
|
||||
let totalSpeed = 0;
|
||||
let completedFiles = 0;
|
||||
let totalFiles = 0;
|
||||
let isDownloading = false;
|
||||
const allFiles: DownloadProgress["files"] = [];
|
||||
const perNode: Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
progress: DownloadProgress;
|
||||
}> = [];
|
||||
|
||||
// Check downloads for nodes that are part of this instance
|
||||
for (const runnerId of Object.keys(runnerToShard)) {
|
||||
const nodeId = runnerToNode[runnerId];
|
||||
if (!nodeId) continue;
|
||||
|
||||
const nodeDownloads = downloadsData[nodeId];
|
||||
if (!Array.isArray(nodeDownloads)) continue;
|
||||
|
||||
for (const downloadWrapped of nodeDownloads) {
|
||||
if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
|
||||
|
||||
const keys = Object.keys(downloadWrapped as Record<string, unknown>);
|
||||
if (keys.length !== 1) continue;
|
||||
|
||||
const downloadKind = keys[0];
|
||||
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
|
||||
downloadKind
|
||||
] as Record<string, unknown>;
|
||||
|
||||
// Handle DownloadFailed - return immediately with error info
|
||||
if (downloadKind === "DownloadFailed") {
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
if (
|
||||
instanceModelId &&
|
||||
downloadModelId &&
|
||||
downloadModelId === instanceModelId
|
||||
) {
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: true,
|
||||
errorMessage:
|
||||
(downloadPayload.errorMessage as string) || "Download failed",
|
||||
progress: null,
|
||||
statusText: "FAILED",
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
downloadKind !== "DownloadOngoing" &&
|
||||
downloadKind !== "DownloadPending"
|
||||
)
|
||||
continue;
|
||||
if (!downloadPayload) continue;
|
||||
|
||||
// Check if this download is for this instance's model
|
||||
const downloadModelId = extractModelIdFromDownload(downloadPayload);
|
||||
if (
|
||||
instanceModelId &&
|
||||
downloadModelId &&
|
||||
downloadModelId === instanceModelId
|
||||
) {
|
||||
// For DownloadPending with partial bytes, synthesize progress
|
||||
let progress: DownloadProgress | null;
|
||||
if (downloadKind === "DownloadPending") {
|
||||
const pendingDownloaded = getBytes(
|
||||
downloadPayload.downloaded ??
|
||||
downloadPayload.downloaded_bytes ??
|
||||
downloadPayload.downloadedBytes,
|
||||
);
|
||||
const pendingTotal = getBytes(
|
||||
downloadPayload.total ??
|
||||
downloadPayload.total_bytes ??
|
||||
downloadPayload.totalBytes,
|
||||
);
|
||||
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
|
||||
isDownloading = true;
|
||||
progress = {
|
||||
totalBytes: pendingTotal,
|
||||
downloadedBytes: pendingDownloaded,
|
||||
speed: 0,
|
||||
etaMs: 0,
|
||||
percentage:
|
||||
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
files: [],
|
||||
};
|
||||
} else {
|
||||
isDownloading = true;
|
||||
progress = parseDownloadProgress(downloadPayload);
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
// Sum all values across nodes - each node downloads independently
|
||||
totalBytes += progress.totalBytes;
|
||||
downloadedBytes += progress.downloadedBytes;
|
||||
totalSpeed += progress.speed;
|
||||
completedFiles += progress.completedFiles;
|
||||
totalFiles += progress.totalFiles;
|
||||
allFiles.push(...progress.files);
|
||||
|
||||
const nodeName =
|
||||
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
|
||||
perNode.push({ nodeId, nodeName, progress });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDownloading) {
|
||||
// Check runner status for other states
|
||||
if (!instanceModelId) {
|
||||
const statusInfo = deriveInstanceStatus(instanceWrapped);
|
||||
return {
|
||||
isDownloading: false,
|
||||
@@ -1861,26 +1802,49 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ETA = total remaining bytes / total speed across all nodes
|
||||
const remainingBytes = totalBytes - downloadedBytes;
|
||||
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
|
||||
// Get node IDs assigned to this instance
|
||||
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
|
||||
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
|
||||
const runnerToNode: Record<string, string> = {};
|
||||
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
|
||||
runnerToNode[runnerId] = nodeId;
|
||||
}
|
||||
const instanceNodeIds = Object.keys(runnerToShard)
|
||||
.map((runnerId) => runnerToNode[runnerId])
|
||||
.filter(Boolean);
|
||||
|
||||
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
|
||||
|
||||
if (result.failedError) {
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: true,
|
||||
errorMessage: result.failedError,
|
||||
progress: null,
|
||||
statusText: "FAILED",
|
||||
perNode: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.isDownloading) {
|
||||
const statusInfo = deriveInstanceStatus(instanceWrapped);
|
||||
return {
|
||||
isDownloading: false,
|
||||
isFailed: statusInfo.statusText === "FAILED",
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
statusText: statusInfo.statusText,
|
||||
perNode: result.perNode,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDownloading: true,
|
||||
isFailed: false,
|
||||
errorMessage: null,
|
||||
progress: {
|
||||
totalBytes,
|
||||
downloadedBytes,
|
||||
speed: totalSpeed,
|
||||
etaMs,
|
||||
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
|
||||
completedFiles,
|
||||
totalFiles,
|
||||
files: allFiles,
|
||||
},
|
||||
progress: result.progress,
|
||||
statusText: "DOWNLOADING",
|
||||
perNode,
|
||||
perNode: result.perNode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2099,7 +2063,6 @@
|
||||
let instanceType = "Unknown";
|
||||
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
|
||||
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
|
||||
else if (instanceTag === "VllmInstance") instanceType = "vLLM (CUDA)";
|
||||
|
||||
const inst = instance as {
|
||||
shardAssignments?: {
|
||||
@@ -4639,7 +4602,7 @@
|
||||
type="button"
|
||||
onclick={() => {
|
||||
completeOnboarding();
|
||||
sendMessage(chip);
|
||||
sendMessage(chip, undefined, thinkingEnabled());
|
||||
}}
|
||||
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
@@ -5378,10 +5341,10 @@
|
||||
<div
|
||||
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
|
||||
>
|
||||
{#each downloadInfo.perNode as nodeProg}
|
||||
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
|
||||
{@const nodePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, nodeProg.progress.percentage),
|
||||
Math.max(0, nodeProg.percentage),
|
||||
)}
|
||||
{@const isExpanded =
|
||||
instanceDownloadExpandedNodes.has(
|
||||
@@ -5437,15 +5400,17 @@
|
||||
>
|
||||
<span
|
||||
>{formatBytes(
|
||||
nodeProg.progress.downloadedBytes,
|
||||
nodeProg.progress?.downloadedBytes ??
|
||||
0,
|
||||
)} / {formatBytes(
|
||||
nodeProg.progress.totalBytes,
|
||||
nodeProg.progress?.totalBytes ?? 0,
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>{formatSpeed(nodeProg.progress.speed)} •
|
||||
ETA {formatEta(
|
||||
nodeProg.progress.etaMs,
|
||||
>{formatSpeed(
|
||||
nodeProg.progress?.speed ?? 0,
|
||||
)} • ETA {formatEta(
|
||||
nodeProg.progress?.etaMs ?? 0,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
@@ -5453,14 +5418,14 @@
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#if nodeProg.progress.files.length === 0}
|
||||
{#if nodeProg.progress?.files ?? [].length === 0}
|
||||
<div
|
||||
class="text-[11px] font-mono text-exo-light-gray/70"
|
||||
>
|
||||
No file details reported.
|
||||
</div>
|
||||
{:else}
|
||||
{#each nodeProg.progress.files as f}
|
||||
{#each nodeProg.progress?.files ?? [] as f}
|
||||
{@const filePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, f.percentage ?? 0),
|
||||
@@ -5849,32 +5814,6 @@
|
||||
</span>
|
||||
RDMA (Fast)
|
||||
</button>
|
||||
{#if vllmAvailable()}
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedInstanceType = "Vllm";
|
||||
saveLaunchDefaults();
|
||||
}}
|
||||
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
|
||||
'Vllm'
|
||||
? 'bg-transparent text-exo-yellow border-exo-yellow'
|
||||
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
|
||||
>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
|
||||
'Vllm'
|
||||
? 'border-exo-yellow'
|
||||
: 'border-exo-medium-gray'}"
|
||||
>
|
||||
{#if selectedInstanceType === "Vllm"}
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
vLLM (CUDA)
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5962,12 +5901,15 @@
|
||||
)}
|
||||
{@const allPreviews = filteredPreviews()}
|
||||
{#if selectedModel && allPreviews.length > 0}
|
||||
{@const downloadStatus = getModelDownloadStatus(
|
||||
selectedModel.id,
|
||||
)}
|
||||
{@const tags = modelTags()[selectedModel.id] || []}
|
||||
<div class="space-y-3">
|
||||
{#each allPreviews as apiPreview, i}
|
||||
{@const downloadStatus = getModelDownloadStatus(
|
||||
selectedModel.id,
|
||||
apiPreview.memory_delta_by_node
|
||||
? Object.keys(apiPreview.memory_delta_by_node)
|
||||
: undefined,
|
||||
)}
|
||||
<div
|
||||
role="group"
|
||||
onmouseenter={() => {
|
||||
@@ -6155,7 +6097,7 @@
|
||||
onclick={() => {
|
||||
chatLaunchState = "idle";
|
||||
selectedChatCategory = null;
|
||||
sendMessage(prompt);
|
||||
sendMessage(prompt, undefined, thinkingEnabled());
|
||||
}}
|
||||
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
@@ -6538,10 +6480,10 @@
|
||||
<div
|
||||
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
|
||||
>
|
||||
{#each downloadInfo.perNode as nodeProg}
|
||||
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
|
||||
{@const nodePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, nodeProg.progress.percentage),
|
||||
Math.max(0, nodeProg.percentage),
|
||||
)}
|
||||
{@const isExpanded =
|
||||
instanceDownloadExpandedNodes.has(
|
||||
@@ -6600,16 +6542,17 @@
|
||||
>
|
||||
<span
|
||||
>{formatBytes(
|
||||
nodeProg.progress.downloadedBytes,
|
||||
nodeProg.progress
|
||||
?.downloadedBytes ?? 0,
|
||||
)} / {formatBytes(
|
||||
nodeProg.progress.totalBytes,
|
||||
nodeProg.progress?.totalBytes ?? 0,
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>{formatSpeed(
|
||||
nodeProg.progress.speed,
|
||||
nodeProg.progress?.speed ?? 0,
|
||||
)} • ETA {formatEta(
|
||||
nodeProg.progress.etaMs,
|
||||
nodeProg.progress?.etaMs ?? 0,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
@@ -6617,14 +6560,14 @@
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#if nodeProg.progress.files.length === 0}
|
||||
{#if nodeProg.progress?.files ?? [].length === 0}
|
||||
<div
|
||||
class="text-[11px] font-mono text-exo-light-gray/70"
|
||||
>
|
||||
No file details reported.
|
||||
</div>
|
||||
{:else}
|
||||
{#each nodeProg.progress.files as f}
|
||||
{#each nodeProg.progress?.files ?? [] as f}
|
||||
{@const filePercent = Math.min(
|
||||
100,
|
||||
Math.max(0, f.percentage ?? 0),
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import HeaderNav from "$lib/components/HeaderNav.svelte";
|
||||
import IntegrationCard from "$lib/components/IntegrationCard.svelte";
|
||||
import { instances, refreshState } from "$lib/stores/app.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const apiUrl = browser
|
||||
? window.location.origin.replace("localhost", "127.0.0.1")
|
||||
: "http://127.0.0.1:52415";
|
||||
|
||||
const instancesData = $derived(instances());
|
||||
|
||||
let modelCapabilities = $state<Record<string, string[]>>({});
|
||||
let modelContextLengths = $state<Record<string, number>>({});
|
||||
|
||||
const runningModels = $derived.by(() => {
|
||||
const models: string[] = [];
|
||||
for (const [, wrapper] of Object.entries(instancesData)) {
|
||||
if (wrapper && typeof wrapper === "object") {
|
||||
const values = Object.values(wrapper as Record<string, unknown>);
|
||||
if (values.length > 0) {
|
||||
const instance = values[0];
|
||||
if (instance && typeof instance === "object") {
|
||||
const inst = instance as {
|
||||
shardAssignments?: { modelId?: string };
|
||||
};
|
||||
const modelId = inst.shardAssignments?.modelId;
|
||||
if (modelId && !models.includes(modelId)) {
|
||||
models.push(modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return models;
|
||||
});
|
||||
|
||||
function estimateParamSize(modelId: string): number {
|
||||
const match = modelId.match(/(\d+(?:\.\d+)?)[Bb]/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
const modelsBySize = $derived(
|
||||
[...runningModels].sort(
|
||||
(a, b) => estimateParamSize(b) - estimateParamSize(a),
|
||||
),
|
||||
);
|
||||
|
||||
const defaultTiers = $derived.by(() => {
|
||||
const n = modelsBySize.length;
|
||||
if (n === 0)
|
||||
return {
|
||||
opus: "your-model-id",
|
||||
sonnet: "your-model-id",
|
||||
haiku: "your-model-id",
|
||||
};
|
||||
if (n === 1)
|
||||
return {
|
||||
opus: modelsBySize[0],
|
||||
sonnet: modelsBySize[0],
|
||||
haiku: modelsBySize[0],
|
||||
};
|
||||
if (n === 2)
|
||||
return {
|
||||
opus: modelsBySize[0],
|
||||
sonnet: modelsBySize[1],
|
||||
haiku: modelsBySize[1],
|
||||
};
|
||||
return {
|
||||
opus: modelsBySize[0],
|
||||
sonnet: modelsBySize[Math.floor(n / 2)],
|
||||
haiku: modelsBySize[n - 1],
|
||||
};
|
||||
});
|
||||
|
||||
let opusModel = $state("");
|
||||
let sonnetModel = $state("");
|
||||
let haikuModel = $state("");
|
||||
|
||||
$effect(() => {
|
||||
opusModel = defaultTiers.opus;
|
||||
sonnetModel = defaultTiers.sonnet;
|
||||
haikuModel = defaultTiers.haiku;
|
||||
});
|
||||
|
||||
let codexModel = $state("");
|
||||
let codexMcpPath = $state("/Users/username");
|
||||
let openClawModel = $state("");
|
||||
$effect(() => {
|
||||
const def = modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id";
|
||||
codexModel = def;
|
||||
openClawModel = def;
|
||||
});
|
||||
|
||||
const claudeShellCommand = $derived(
|
||||
[
|
||||
`ANTHROPIC_BASE_URL=${apiUrl} \\`,
|
||||
`ANTHROPIC_API_KEY=x \\`,
|
||||
`ANTHROPIC_DEFAULT_OPUS_MODEL=${opusModel} \\`,
|
||||
`ANTHROPIC_DEFAULT_SONNET_MODEL=${sonnetModel} \\`,
|
||||
`ANTHROPIC_DEFAULT_HAIKU_MODEL=${haikuModel} \\`,
|
||||
`API_TIMEOUT_MS=3000000 \\`,
|
||||
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \\`,
|
||||
`claude`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const claudeSettingsJson = $derived(
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: apiUrl,
|
||||
ANTHROPIC_API_KEY: "x",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const openCodeConfig = $derived.by(() => {
|
||||
const models: Record<string, Record<string, unknown>> = {};
|
||||
for (const modelId of runningModels) {
|
||||
const caps = modelCapabilities[modelId] || [];
|
||||
const ctxLen = modelContextLengths[modelId] || 0;
|
||||
const entry: Record<string, unknown> = { name: modelId };
|
||||
if (ctxLen > 0) {
|
||||
entry.limit = { context: ctxLen, output: Math.min(ctxLen, 16384) };
|
||||
}
|
||||
if (caps.includes("vision")) {
|
||||
entry.modalities = { input: ["text", "image"], output: ["text"] };
|
||||
}
|
||||
models[modelId] = entry;
|
||||
}
|
||||
if (Object.keys(models).length === 0) {
|
||||
models["your-model-id"] = { name: "your-model-name" };
|
||||
}
|
||||
const firstModel =
|
||||
runningModels.length > 0 ? runningModels[0] : "your-model-id";
|
||||
return JSON.stringify(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
exo: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "exo",
|
||||
options: {
|
||||
baseURL: `${apiUrl}/v1`,
|
||||
apiKey: "x",
|
||||
},
|
||||
models,
|
||||
},
|
||||
},
|
||||
model: `exo/${firstModel}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
const codexShellCommand = $derived(`EXO_API_KEY=x npx @openai/codex`);
|
||||
|
||||
const codexConfig = $derived(
|
||||
[
|
||||
`model = "${codexModel}"`,
|
||||
`model_provider = "exo"`,
|
||||
``,
|
||||
`[model_providers.exo]`,
|
||||
`name = "exo"`,
|
||||
`base_url = "${apiUrl}/v1"`,
|
||||
`env_key = "EXO_API_KEY"`,
|
||||
``,
|
||||
`[mcp_servers.filesystem]`,
|
||||
`command = "npx"`,
|
||||
`args = ["-y", "@modelcontextprotocol/server-filesystem", "${codexMcpPath}"]`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const openClawConfig = $derived(
|
||||
JSON.stringify(
|
||||
{
|
||||
gateway: { mode: "local" },
|
||||
models: {
|
||||
providers: {
|
||||
exo: {
|
||||
baseUrl: `${apiUrl}/v1`,
|
||||
apiKey: "x",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: openClawModel,
|
||||
name: "exo local",
|
||||
input: (modelCapabilities[openClawModel] || []).includes(
|
||||
"vision",
|
||||
)
|
||||
? ["text", "image"]
|
||||
: ["text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: `exo/${openClawModel}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const ollamaCommand = $derived(
|
||||
`OLLAMA_HOST=${apiUrl}/ollama ollama run ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"}`,
|
||||
);
|
||||
|
||||
const openWebUiCommand = $derived(
|
||||
[
|
||||
`docker run -d -p 3000:8080 \\`,
|
||||
` -e OLLAMA_BASE_URL=${apiUrl.replace("localhost", "host.docker.internal")}/ollama \\`,
|
||||
` -v open-webui:/app/backend/data \\`,
|
||||
` --name open-webui \\`,
|
||||
` ghcr.io/open-webui/open-webui:main`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const n8nDockerCommand = $derived(
|
||||
[
|
||||
`docker run -d -p 5678:5678 \\`,
|
||||
` -v n8n_data:/home/node/.n8n \\`,
|
||||
` --name n8n \\`,
|
||||
` docker.n8n.io/n8nio/n8n`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const n8nCredentialSteps = $derived(
|
||||
[
|
||||
`1. Go to Credentials → Add Credential → search "OpenAI API"`,
|
||||
`2. Set API Key to: x`,
|
||||
`3. Set Base URL to: ${apiUrl.replace("127.0.0.1", "host.docker.internal").replace("localhost", "host.docker.internal")}/v1`,
|
||||
`4. Save the credential`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const n8nWorkflowSteps = $derived(
|
||||
[
|
||||
`1. Create a new workflow → "Start from Scratch"`,
|
||||
`2. Add an "AI Agent" or "Basic LLM Chain" node`,
|
||||
`3. Inside it, add an "OpenAI Chat Model" sub-node`,
|
||||
`4. Select the OpenAI credential you just created`,
|
||||
`5. Set Model to "From list" and pick your model (e.g. ${modelsBySize.length > 0 ? modelsBySize[0] : "your-model-id"})`,
|
||||
`6. Optionally toggle "Use Responses API", add Built-in Tools, or click "Add Option" for sampling settings`,
|
||||
`7. Connect a "Chat Trigger" node for interactive chat`,
|
||||
`8. On the Chat Trigger, enable "Allow File Uploads" for vision`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const firefoxConfig = $derived(
|
||||
[
|
||||
`1. Open about:config in Firefox`,
|
||||
`2. Set browser.ml.chat.enabled to true`,
|
||||
`3. Set browser.ml.chat.hideLocalhost to false`,
|
||||
`4. Set browser.ml.chat.provider to: ${apiUrl}/`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
"Claude Code",
|
||||
"OpenCode",
|
||||
"Codex",
|
||||
"OpenClaw",
|
||||
"Open WebUI",
|
||||
"n8n",
|
||||
"Firefox",
|
||||
] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
const stored = browser ? localStorage.getItem("exo-integrations-tab") : null;
|
||||
let activeTab = $state<Tab>(
|
||||
stored && tabs.includes(stored as Tab) ? (stored as Tab) : "Claude Code",
|
||||
);
|
||||
$effect(() => {
|
||||
if (browser) localStorage.setItem("exo-integrations-tab", activeTab);
|
||||
});
|
||||
|
||||
const selectClass =
|
||||
"bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none appearance-none cursor-pointer";
|
||||
|
||||
onMount(async () => {
|
||||
refreshState();
|
||||
try {
|
||||
const resp = await fetch("/v1/models");
|
||||
const data = (await resp.json()) as {
|
||||
data: { id: string; capabilities: string[]; context_length: number }[];
|
||||
};
|
||||
const caps: Record<string, string[]> = {};
|
||||
const ctxs: Record<string, number> = {};
|
||||
for (const model of data.data) {
|
||||
caps[model.id] = model.capabilities || [];
|
||||
if (model.context_length > 0) ctxs[model.id] = model.context_length;
|
||||
}
|
||||
modelCapabilities = caps;
|
||||
modelContextLengths = ctxs;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-exo-dark-gray flex flex-col">
|
||||
<HeaderNav showHome={true} />
|
||||
|
||||
<main
|
||||
class="flex-1 max-w-3xl mx-auto w-full px-4 md:px-6 py-8"
|
||||
in:fade={{ duration: 200 }}
|
||||
>
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
class="text-white text-xl md:text-2xl font-semibold tracking-wide mb-2"
|
||||
>
|
||||
Integrations
|
||||
</h1>
|
||||
<p class="text-exo-light-gray/60 text-sm">
|
||||
Connect external tools to your exo cluster.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-8">
|
||||
<span class="text-exo-light-gray/70 text-xs uppercase tracking-wider"
|
||||
>API Endpoint</span
|
||||
>
|
||||
<span class="text-white font-mono text-sm ml-2">{apiUrl}</span>
|
||||
{#if runningModels.length > 0}
|
||||
<div class="text-exo-light-gray/50 text-xs mt-2">
|
||||
Running model{runningModels.length > 1 ? "s" : ""}:
|
||||
<ul class="mt-1 space-y-0.5 list-none">
|
||||
{#each runningModels as model}
|
||||
<li class="text-exo-yellow font-mono">{model}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-exo-light-gray/40 text-xs mt-2 italic">
|
||||
No models currently running
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- API Endpoints -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 text-xs font-mono text-exo-light-gray/70"
|
||||
>
|
||||
<div
|
||||
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
|
||||
>
|
||||
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
|
||||
>OpenAI-compatible</span
|
||||
>
|
||||
<span class="text-white/80">{apiUrl}/v1</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
|
||||
>
|
||||
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
|
||||
>Claude-compatible</span
|
||||
>
|
||||
<span class="text-white/80">{apiUrl}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 bg-black/20 border border-exo-light-gray/10 rounded px-3 py-2"
|
||||
>
|
||||
<span class="text-exo-light-gray/40 text-[10px] uppercase block mb-1"
|
||||
>Ollama-compatible</span
|
||||
>
|
||||
<span class="text-white/80">{apiUrl}/ollama</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 mb-6 border-b border-exo-light-gray/10 pb-3"
|
||||
>
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
onclick={() => (activeTab = tab)}
|
||||
class="px-3 py-1.5 text-xs rounded-md transition-all cursor-pointer
|
||||
{activeTab === tab
|
||||
? 'bg-exo-yellow/15 text-exo-yellow border border-exo-yellow/30'
|
||||
: 'text-exo-light-gray/60 hover:text-white/80 border border-transparent hover:border-exo-light-gray/20'}"
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="space-y-4">
|
||||
{#if activeTab === "Claude Code"}
|
||||
{#if runningModels.length > 1}
|
||||
<div class="grid grid-cols-3 gap-3 text-xs">
|
||||
{#each [{ label: "Opus", bind: () => opusModel, set: (v: string) => (opusModel = v) }, { label: "Sonnet", bind: () => sonnetModel, set: (v: string) => (sonnetModel = v) }, { label: "Haiku", bind: () => haikuModel, set: (v: string) => (haikuModel = v) }] as tier}
|
||||
<div>
|
||||
<span
|
||||
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
|
||||
>{tier.label}</span
|
||||
>
|
||||
<select
|
||||
value={tier.bind()}
|
||||
onchange={(e) =>
|
||||
tier.set((e.target as HTMLSelectElement).value)}
|
||||
class="w-full {selectClass}"
|
||||
>
|
||||
{#each runningModels as model}
|
||||
<option value={model}>{model.split("/").pop()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<IntegrationCard
|
||||
title="Shell Command"
|
||||
subtitle="Run in terminal"
|
||||
description="Launch Claude Code with exo as the backend. Paste this into your terminal."
|
||||
config={claudeShellCommand}
|
||||
language="bash"
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="Settings File"
|
||||
subtitle="~/.claude/settings.json"
|
||||
description="Or add this to your Claude Code settings for persistent configuration."
|
||||
config={claudeSettingsJson}
|
||||
/>
|
||||
{:else if activeTab === "OpenCode"}
|
||||
<IntegrationCard
|
||||
title="Config File"
|
||||
subtitle="opencode.json"
|
||||
description="Add this to your project root or ~/.config/opencode/opencode.json for global config. Vision models automatically get image input modality."
|
||||
config={openCodeConfig}
|
||||
/>
|
||||
{:else if activeTab === "Codex"}
|
||||
<div class="flex gap-3 text-xs">
|
||||
{#if runningModels.length > 1}
|
||||
<div>
|
||||
<span
|
||||
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
|
||||
>Model</span
|
||||
>
|
||||
<select bind:value={codexModel} class={selectClass}>
|
||||
{#each runningModels as model}
|
||||
<option value={model}>{model.split("/").pop()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
|
||||
>MCP Filesystem Path</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={codexMcpPath}
|
||||
class="w-full bg-black/30 border border-exo-light-gray/20 rounded px-2 py-1.5 text-white font-mono text-xs focus:border-exo-yellow/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationCard
|
||||
title="Config File"
|
||||
subtitle="~/.codex/config.toml"
|
||||
description="Add this to your Codex CLI config so the model and provider persist."
|
||||
config={codexConfig}
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="Shell Command"
|
||||
subtitle="Run in terminal"
|
||||
description="Launch Codex with exo as the backend."
|
||||
config={codexShellCommand}
|
||||
language="bash"
|
||||
/>
|
||||
{:else if activeTab === "OpenClaw"}
|
||||
{#if runningModels.length > 1}
|
||||
<div class="text-xs">
|
||||
<span
|
||||
class="text-exo-light-gray/50 text-[10px] uppercase tracking-wider block mb-1"
|
||||
>Model</span
|
||||
>
|
||||
<select bind:value={openClawModel} class={selectClass}>
|
||||
{#each runningModels as model}
|
||||
<option value={model}>{model.split("/").pop()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<IntegrationCard
|
||||
title="Config File"
|
||||
subtitle="~/.openclaw/openclaw.json"
|
||||
description="Add this to your OpenClaw config. If you haven't installed OpenClaw yet, run: npm install -g openclaw@latest"
|
||||
config={openClawConfig}
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="Setup Commands"
|
||||
subtitle="Run in terminal"
|
||||
description="After saving the config, run these commands to fix metadata and start the gateway."
|
||||
config={`openclaw doctor --fix${(modelCapabilities[openClawModel] || []).includes("vision") ? `\nopenclaw models set-image exo/${openClawModel}` : ""}\nopenclaw gateway &\nopenclaw dashboard`}
|
||||
language="bash"
|
||||
/>
|
||||
{:else if activeTab === "Open WebUI"}
|
||||
<IntegrationCard
|
||||
title="1. Start Open WebUI"
|
||||
subtitle="Run in terminal"
|
||||
description="Run this to start Open WebUI."
|
||||
config={openWebUiCommand}
|
||||
language="bash"
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="2. Open & Select Model"
|
||||
subtitle="http://localhost:3000"
|
||||
description={`Open http://localhost:3000 in your browser. Select the running model from the dropdown at the top: ${runningModels.length > 0 ? runningModels.join(", ") : "no models running"}`}
|
||||
config={"open http://localhost:3000"}
|
||||
language="bash"
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="Ollama CLI"
|
||||
subtitle="Run in terminal"
|
||||
description="Or use the Ollama CLI directly."
|
||||
config={ollamaCommand}
|
||||
language="bash"
|
||||
/>
|
||||
{:else if activeTab === "n8n"}
|
||||
<IntegrationCard
|
||||
title="1. Start n8n"
|
||||
subtitle="Run in terminal"
|
||||
description="Start n8n with Docker. If you already have n8n running, skip this step."
|
||||
config={n8nDockerCommand}
|
||||
language="bash"
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="2. Open n8n"
|
||||
subtitle="http://localhost:5678"
|
||||
description="Open n8n in your browser. If this is your first time, complete the setup and select 'Start from Scratch' when prompted."
|
||||
config={"open http://localhost:5678"}
|
||||
language="bash"
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="3. Add OpenAI Credential"
|
||||
subtitle="n8n UI → Credentials"
|
||||
description="Create an OpenAI credential pointing at your exo cluster."
|
||||
config={n8nCredentialSteps}
|
||||
/>
|
||||
<IntegrationCard
|
||||
title="4. Build a Workflow"
|
||||
subtitle="n8n UI → Workflows"
|
||||
description="Create a workflow that uses your exo-powered model."
|
||||
config={n8nWorkflowSteps}
|
||||
/>
|
||||
{:else if activeTab === "Firefox"}
|
||||
<IntegrationCard
|
||||
title="Firefox AI Chatbot"
|
||||
subtitle="about:config"
|
||||
description="Use the exo dashboard as Firefox's built-in AI chatbot. Requires Firefox 130+."
|
||||
config={firefoxConfig}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
Generated
+15
-15
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1774313767,
|
||||
"narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=",
|
||||
"lastModified": 1767744144,
|
||||
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "3d9df76e29656c679c744968b17fbaf28f0e923d",
|
||||
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -47,11 +47,11 @@
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774596377,
|
||||
"narHash": "sha256-DiSLMxyTwIUAlhOe34r6kKNQRv6PTF+vf0MG45mAyn4=",
|
||||
"lastModified": 1768287139,
|
||||
"narHash": "sha256-nsXFt0OzUi6K7dUzzJD5/v9e0Ic+fvclfIW936/43ZM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "a88a1c8cf2f094da6347fcec54089f4bcb518409",
|
||||
"rev": "a4a3aa956931f90f35453cb519e4545e9ad7f773",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -83,11 +83,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -214,11 +214,11 @@
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1774569884,
|
||||
"narHash": "sha256-E8iWEPzg7OnE0XXXjo75CX7xFauqzJuGZ5wSO9KS8Ek=",
|
||||
"lastModified": 1768224240,
|
||||
"narHash": "sha256-Pp1dDrXKPBUJReZnnDElFyHYn67XTd48zRhToheLjtk=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "443ddcddd0c73b07b799d052f5ef3b448c2f3508",
|
||||
"rev": "725349602e525df37f377701e001fe8aab807878",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -257,11 +257,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773297127,
|
||||
"narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=",
|
||||
"lastModified": 1768158989,
|
||||
"narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "71b125cd05fbfd78cab3e070b73544abe24c5016",
|
||||
"rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
};
|
||||
|
||||
nixConfig = {
|
||||
extra-trusted-public-keys = "exo.cachix.org-1:okq7hl624TBeAR3kV+g39dUFSiaZgLRkLsFBCuJ2NZI= cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=";
|
||||
extra-substituters = "https://exo.cachix.org https://cache.nixos-cuda.org";
|
||||
extra-trusted-public-keys = "exo.cachix.org-1:okq7hl624TBeAR3kV+g39dUFSiaZgLRkLsFBCuJ2NZI=";
|
||||
extra-substituters = "https://exo.cachix.org";
|
||||
};
|
||||
|
||||
outputs =
|
||||
@@ -78,21 +78,23 @@
|
||||
pkgsSwift = import inputs.nixpkgs-swift { inherit system; };
|
||||
in
|
||||
{
|
||||
_module.args.cudaPkgs = import inputs.nixpkgs
|
||||
{
|
||||
inherit system;
|
||||
config = {
|
||||
allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "cuda-merged" "cuda_cuobjdump" "cuda_gdb" "cuda_nvcc" "cuda_nvdisasm" "cuda_nvprune" "cuda_cccl" "cuda_cudart" "cuda_cupti" "cuda_cuxxfilt" "cuda_nvml_dev" "cuda_nvrtc" "cuda_nvtx" "cuda_profiler_api" "cuda_sanitizer_api" "libcublas" "libcufft" "libcurand" "libcusolver" "libnvjitlink" "libcusparse" "libnpp" "cudnn" "libcusparse_lt" "libcufile" "libnvshmem" "libnvvm" "cuda_crt" ];
|
||||
cudaSupport = true;
|
||||
cudaCapabilities = [ "12.1" ];
|
||||
};
|
||||
};
|
||||
# Allow unfree for metal-toolchain (needed for Darwin Metal packages)
|
||||
_module.args.pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "metal-toolchain" ];
|
||||
overlays = lib.optionals (system == "aarch64-darwin") [
|
||||
config.allowUnfreePredicate = pkg: (pkg.pname or "") == "metal-toolchain";
|
||||
overlays = [
|
||||
(import ./nix/apple-sdk-overlay.nix)
|
||||
(final: prev: {
|
||||
macmon = prev.macmon.overrideAttrs (_: {
|
||||
version = "git";
|
||||
src = final.fetchFromGitHub {
|
||||
owner = "swiftraccoon";
|
||||
repo = "macmon";
|
||||
rev = "9154d234f763fbeffdcb4135d0bbbaf80609699b";
|
||||
hash = "sha256-CwhilKNbs5XL9/tF5DMwyPBlE/hpmjGNTuxQ36sM50M=";
|
||||
};
|
||||
});
|
||||
})
|
||||
];
|
||||
};
|
||||
treefmt = {
|
||||
@@ -121,68 +123,67 @@
|
||||
};
|
||||
};
|
||||
|
||||
packages = (lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin
|
||||
|
||||
packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin (
|
||||
let
|
||||
uvLock = builtins.fromTOML (builtins.readFile ./uv.lock);
|
||||
mlxPackage = builtins.head (builtins.filter (p: p.name == "mlx" && p.source ? git) uvLock.package);
|
||||
uvLockMlxVersion = mlxPackage.version;
|
||||
uvLockMlxRev = builtins.elemAt (builtins.split "#" mlxPackage.source.git) 2;
|
||||
in
|
||||
{
|
||||
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
|
||||
}
|
||||
) // {
|
||||
default = self'.packages.exo;
|
||||
};
|
||||
devShells =
|
||||
{
|
||||
default = with pkgs; mkShell {
|
||||
inputsFrom = [ self'.checks.cargo-build ];
|
||||
|
||||
packages =
|
||||
[
|
||||
# FORMATTING
|
||||
config.treefmt.build.wrapper
|
||||
|
||||
# PYTHON
|
||||
self'.packages.editable-venv
|
||||
uv
|
||||
|
||||
# RUST
|
||||
config.rust.toolchain
|
||||
maturin
|
||||
|
||||
# NIX
|
||||
nixpkgs-fmt
|
||||
|
||||
# SVELTE
|
||||
nodejs
|
||||
|
||||
# MISC
|
||||
just
|
||||
jq
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
unixtools.ifconfig
|
||||
]
|
||||
++ lib.optionals stdenv.isDarwin [
|
||||
macmon
|
||||
];
|
||||
|
||||
env = {
|
||||
UV_NO_SYNC = "1";
|
||||
UV_PYTHON = "${self'.packages.editable-venv}/bin/python";
|
||||
UV_PYTHON_DOWNLOADS = "never";
|
||||
UV_PROJECT_ENVIRONMENT = self'.packages.editable-venv;
|
||||
VIRTUAL_ENV = self'.packages.editable-venv;
|
||||
OPENSSL_NO_VENDOR = "1";
|
||||
};
|
||||
|
||||
shellHook = ''
|
||||
unset PYTHONPATH
|
||||
export REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${self'.packages.editable-venv}/lib"
|
||||
${lib.optionalString stdenv.isLinux ''
|
||||
export LD_LIBRARY_PATH="${openssl.out}/lib:${lib.getLib pkgs.util-linux}/lib:${lib.getLib pkgs.systemd}/lib:${lib.getLib pkgs.numactl}/lib:${lib.getLib pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH"
|
||||
''}
|
||||
'';
|
||||
mlx = pkgs.callPackage ./nix/mlx.nix {
|
||||
inherit (self'.packages) metal-toolchain;
|
||||
inherit uvLockMlxVersion uvLockMlxRev;
|
||||
};
|
||||
};
|
||||
default = self'.packages.exo;
|
||||
}
|
||||
);
|
||||
|
||||
devShells.default = with pkgs; pkgs.mkShell {
|
||||
inputsFrom = [ self'.checks.cargo-build ];
|
||||
|
||||
packages =
|
||||
[
|
||||
# FORMATTING
|
||||
config.treefmt.build.wrapper
|
||||
|
||||
# PYTHON
|
||||
python313
|
||||
uv
|
||||
ruff
|
||||
basedpyright
|
||||
|
||||
# RUST
|
||||
config.rust.toolchain
|
||||
maturin
|
||||
|
||||
# NIX
|
||||
nixpkgs-fmt
|
||||
|
||||
# SVELTE
|
||||
nodejs
|
||||
|
||||
# MISC
|
||||
just
|
||||
jq
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
unixtools.ifconfig
|
||||
]
|
||||
++ lib.optionals stdenv.isDarwin [
|
||||
macmon
|
||||
];
|
||||
|
||||
OPENSSL_NO_VENDOR = "1";
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${python313}/lib"
|
||||
${lib.optionalString stdenv.isLinux ''
|
||||
export LD_LIBRARY_PATH="${openssl.out}/lib:$LD_LIBRARY_PATH"
|
||||
''}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
|
||||
|
||||
default: lint fmt
|
||||
all: lint fmt check
|
||||
|
||||
fmt:
|
||||
treefmt || nix fmt
|
||||
|
||||
@@ -15,18 +18,6 @@ check:
|
||||
sync:
|
||||
uv sync --all-packages
|
||||
|
||||
sync-cuda:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
arch=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader | head -1 | tr -d ' ')
|
||||
export TORCH_CUDA_ARCH_LIST="$arch"
|
||||
export VLLM_TARGET_DEVICE=cuda
|
||||
fi
|
||||
uv pip install "cmake>=3.26.1" ninja "packaging>=24.2" "setuptools>=77.0.3,<81.0.0" "setuptools-scm>=8.0" wheel jinja2
|
||||
find ~/.cache/uv/git-v0 -name CMakeCache.txt -delete 2>/dev/null || true
|
||||
uv sync --extra cuda
|
||||
|
||||
sync-clean:
|
||||
uv sync --all-packages --force-reinstall --no-cache
|
||||
|
||||
@@ -43,6 +34,10 @@ build-dashboard:
|
||||
package:
|
||||
uv run pyinstaller packaging/pyinstaller/exo.spec
|
||||
|
||||
build-app: package
|
||||
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
|
||||
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
|
||||
|
||||
clean:
|
||||
rm -rf **/__pycache__
|
||||
rm -rf target/
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
{ nixpkgs, system }:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
if system == "aarch64-linux" then
|
||||
import nixpkgs
|
||||
{
|
||||
inherit system;
|
||||
config = {
|
||||
allowUnfree = true;
|
||||
allowBroken = true;
|
||||
allowUnsupportedSystem = true;
|
||||
cudaSupport = true;
|
||||
cudaCapabilities = [ "12.1" ];
|
||||
};
|
||||
overlays = [
|
||||
(final: prev:
|
||||
let
|
||||
cudaCompatStub = cfinal: cprev: {
|
||||
cuda_compat = prev.runCommand "cuda13.0-cuda_compat-stub" { } "mkdir -p $out";
|
||||
};
|
||||
in
|
||||
{
|
||||
cudaPackages = prev.cudaPackages_13.overrideScope cudaCompatStub // {
|
||||
override = args:
|
||||
(prev.cudaPackages_13.override args).overrideScope cudaCompatStub;
|
||||
};
|
||||
|
||||
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
|
||||
(pyFinal: pyPrev: {
|
||||
fastsafetensors = pyFinal.buildPythonPackage {
|
||||
pname = "fastsafetensors";
|
||||
version = "0.2.2";
|
||||
src = prev.fetchFromGitHub {
|
||||
owner = "foundation-model-stack";
|
||||
repo = "fastsafetensors";
|
||||
rev = "v0.2.2";
|
||||
hash = "";
|
||||
};
|
||||
pyproject = true;
|
||||
build-system = [
|
||||
pyFinal.setuptools
|
||||
pyFinal.pybind11
|
||||
];
|
||||
buildInputs = [
|
||||
final.cudaPackages.cuda_cudart
|
||||
final.cudaPackages.cuda_nvml_dev
|
||||
];
|
||||
nativeBuildInputs = [
|
||||
final.cudaPackages.cuda_nvcc
|
||||
];
|
||||
dependencies = [
|
||||
pyFinal.typer
|
||||
];
|
||||
env.CUDA_HOME = "${final.cudaPackages.cuda_nvcc}";
|
||||
pythonImportsCheck = [ "fastsafetensors" ];
|
||||
};
|
||||
cupy = pyPrev.cupy.override {
|
||||
cudaPackages = final.cudaPackages;
|
||||
};
|
||||
|
||||
bitsandbytes = pyPrev.bitsandbytes.overrideAttrs (old: {
|
||||
preConfigure = (old.preConfigure or "") + ''
|
||||
export CXXFLAGS="''${CXXFLAGS:-} -I${final.cudaPackages.cuda_crt}/include"
|
||||
export CUDAFLAGS="''${CUDAFLAGS:-} -I${final.cudaPackages.cuda_crt}/include"
|
||||
export CMAKE_CUDA_FLAGS="''${CMAKE_CUDA_FLAGS:-} -I${final.cudaPackages.cuda_crt}/include"
|
||||
'';
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [ final.cudaPackages.cuda_crt ];
|
||||
});
|
||||
|
||||
vllm = pyPrev.vllm.overrideAttrs (old: {
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [
|
||||
final.cuda_cccl_with_prefix
|
||||
final.cudaPackages.cuda_crt
|
||||
];
|
||||
preConfigure = (old.preConfigure or "") + ''
|
||||
export CXXFLAGS="''${CXXFLAGS:-} -I${final.cuda_cccl_with_prefix}/include -I${final.cudaPackages.cuda_crt}/include"
|
||||
export CUDAFLAGS="''${CUDAFLAGS:-} -I${final.cuda_cccl_with_prefix}/include -I${final.cudaPackages.cuda_crt}/include"
|
||||
'';
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
magma-cuda-static = prev.magma-cuda-static.overrideAttrs (old: {
|
||||
postPatch = (old.postPatch or "") + ''
|
||||
sed -i '/err = cudaGetDeviceProperties( &prop, dev );/a\ int clock_khz = 0; cudaDeviceGetAttribute(\&clock_khz, cudaDevAttrClockRate, dev);' interface_cuda/interface.cpp
|
||||
sed -i 's/prop\.clockRate/clock_khz/g' interface_cuda/interface.cpp
|
||||
'';
|
||||
});
|
||||
|
||||
cuda_cccl_with_prefix = prev.runCommand "cuda13.0-cuda_cccl-with-cccl-prefix" { } ''
|
||||
mkdir -p $out/include
|
||||
ln -s ${final.cudaPackages.cuda_cccl}/include $out/include/cccl
|
||||
'';
|
||||
|
||||
opencv = prev.opencv.override { enableCuda = false; };
|
||||
opencv4 = prev.opencv4.override { enableCuda = false; };
|
||||
})
|
||||
];
|
||||
}
|
||||
else
|
||||
null
|
||||
@@ -1,20 +0,0 @@
|
||||
diff --git a/mlx/backend/cuda/device/binary_ops.cuh b/mlx/backend/cuda/device/binary_ops.cuh
|
||||
index b0b79628..bbc377be 100644
|
||||
--- a/mlx/backend/cuda/device/binary_ops.cuh
|
||||
+++ b/mlx/backend/cuda/device/binary_ops.cuh
|
||||
@@ -46,6 +46,15 @@ struct Remainder {
|
||||
}
|
||||
} else if constexpr (is_complex_v<T>) {
|
||||
return x % y;
|
||||
+ } else if constexpr (
|
||||
+ cuda::std::is_same_v<T, __half> ||
|
||||
+ cuda::std::is_same_v<T, __nv_bfloat16>) {
|
||||
+ float yf = static_cast<float>(y);
|
||||
+ float r = cuda::std::fmod(static_cast<float>(x), y);
|
||||
+ if (r != 0.0f && ((r < 0.0f) != (yf < 0.0f))) {
|
||||
+ r += yf;
|
||||
+ }
|
||||
+ return static_cast<T>(r);
|
||||
} else {
|
||||
T r = cuda::std::fmod(x, y);
|
||||
if (r != 0 && (r < 0 != y < 0)) {
|
||||
@@ -1,12 +0,0 @@
|
||||
diff --git a/setup.py b/setup.py
|
||||
index 68861fe4b..fd4738089 100644
|
||||
--- a/setup.py
|
||||
+++ b/setup.py
|
||||
@@ -150,6 +150,7 @@ class cmake_build_ext(build_ext):
|
||||
cmake_args = [
|
||||
"-DCMAKE_BUILD_TYPE={}".format(cfg),
|
||||
"-DVLLM_TARGET_DEVICE={}".format(VLLM_TARGET_DEVICE),
|
||||
+ *json.loads(os.environ.get("UV2NIX_CMAKE_FLAGS_JSON", "[]"))
|
||||
]
|
||||
|
||||
verbose = envs.VERBOSE
|
||||
@@ -1,25 +0,0 @@
|
||||
diff --git a/cpp/nanobind/CMakeLists.txt b/cpp/nanobind/CMakeLists.txt
|
||||
index ebfd3da..6ef06e3 100644
|
||||
--- a/cpp/nanobind/CMakeLists.txt
|
||||
+++ b/cpp/nanobind/CMakeLists.txt
|
||||
@@ -18,8 +18,18 @@ target_sources(python_methods PRIVATE python_methods.cc)
|
||||
target_link_libraries(python_methods PUBLIC xgrammar)
|
||||
|
||||
# Any code that uses nanobind directly lives here
|
||||
-nanobind_add_module(xgrammar_bindings LTO nanobind.cc)
|
||||
-target_link_libraries(xgrammar_bindings PRIVATE python_methods)
|
||||
+nanobind_build_library(nanobind)
|
||||
+add_library(xgrammar_bindings MODULE nanobind.cc)
|
||||
+target_link_libraries(xgrammar_bindings PRIVATE
|
||||
+ python_methods
|
||||
+ nanobind
|
||||
+)
|
||||
+nanobind_opt_size(xgrammar_bindings)
|
||||
+nanobind_lto(xgrammar_bindings)
|
||||
+nanobind_set_visibility(xgrammar_bindings)
|
||||
+nanobind_extension(xgrammar_bindings)
|
||||
+nanobind_compile_options(xgrammar_bindings)
|
||||
+nanobind_link_options(xgrammar_bindings)
|
||||
|
||||
if(DEFINED SKBUILD_PROJECT_NAME)
|
||||
# Building wheel through scikit-build-core
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/cpp/nanobind/CMakeLists.txt b/cpp/nanobind/CMakeLists.txt
|
||||
index 03cf0dc..3c9a90a 100644
|
||||
--- a/cpp/nanobind/CMakeLists.txt
|
||||
+++ b/cpp/nanobind/CMakeLists.txt
|
||||
@@ -18,7 +18,7 @@ target_sources(python_methods PRIVATE python_methods.cc)
|
||||
target_link_libraries(python_methods PUBLIC xgrammar)
|
||||
|
||||
# Any code that uses nanobind directly lives here
|
||||
-nanobind_add_module(xgrammar_bindings LTO nanobind.cc)
|
||||
+nanobind_add_module(xgrammar_bindings nanobind.cc)
|
||||
target_link_libraries(xgrammar_bindings PRIVATE python_methods)
|
||||
|
||||
if(DEFINED SKBUILD_PROJECT_NAME)
|
||||
@@ -71,7 +71,9 @@ MACMON_PATH = shutil.which("macmon")
|
||||
if MACMON_PATH is None:
|
||||
raise SystemExit(
|
||||
"macmon binary not found in PATH. "
|
||||
"Install it via: brew install macmon"
|
||||
"Install the pinned fork used by exo via: "
|
||||
"cargo install --git https://github.com/swiftraccoon/macmon "
|
||||
"--rev 9154d234f763fbeffdcb4135d0bbbaf80609699b macmon --force"
|
||||
)
|
||||
|
||||
BINARIES: list[tuple[str, str]] = [
|
||||
@@ -120,4 +122,3 @@ coll = COLLECT(
|
||||
upx_exclude=[],
|
||||
name="exo",
|
||||
)
|
||||
|
||||
|
||||
+20
-72
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "exo"
|
||||
version = "0.3.68"
|
||||
version = "0.3.69"
|
||||
description = "Exo"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
@@ -12,23 +12,25 @@ dependencies = [
|
||||
"fastapi>=0.116.1",
|
||||
"filelock>=3.18.0",
|
||||
"rustworkx>=0.17.1",
|
||||
"huggingface-hub>=0.33.4",
|
||||
"huggingface-hub>=1.8.0",
|
||||
"psutil>=7.0.0",
|
||||
"loguru>=0.7.3",
|
||||
"exo_pyo3_bindings", # rust bindings
|
||||
"exo_pyo3_bindings", # rust bindings
|
||||
"anyio==4.11.0",
|
||||
"mlx",
|
||||
"mlx[cpu]; sys_platform == 'linux'",
|
||||
"mlx; sys_platform == 'darwin'",
|
||||
"mlx[cpu]==0.30.6; sys_platform == 'linux'",
|
||||
"mlx-lm",
|
||||
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
|
||||
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
|
||||
"hypercorn>=0.18.0",
|
||||
"openai-harmony>=0.0.8",
|
||||
"httpx>=0.28.1",
|
||||
"tomlkit>=0.14.0",
|
||||
"mflux==0.17.2; sys_platform == 'darwin'",
|
||||
"mflux==0.17.2",
|
||||
"python-multipart>=0.0.21",
|
||||
"msgspec>=0.19.0",
|
||||
"zstandard>=0.23.0",
|
||||
"mlx-vlm>=0.3.11",
|
||||
"transformers>=5.0.0,<5.4.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -45,14 +47,11 @@ dev = [
|
||||
"ruff>=0.11.13",
|
||||
]
|
||||
|
||||
# mlx[cuda] requires a newer version of mlx. the ideal on linux is: default to mlx[cpu] unless[cuda] specified.
|
||||
[project.optional-dependencies]
|
||||
build = ["nanobind"]
|
||||
cuda = [
|
||||
"torch>=2.10.0; sys_platform == 'linux'",
|
||||
"vllm>=0.13.0; sys_platform == 'linux'",
|
||||
"fastsafetensors>=0.1.10; sys_platform == 'linux'",
|
||||
"mlx[cuda13]; sys_platform == 'linux'",
|
||||
]
|
||||
# cuda = [
|
||||
# "mlx[cuda]==0.26.3",
|
||||
# ]
|
||||
|
||||
###
|
||||
# workspace configuration
|
||||
@@ -63,15 +62,11 @@ members = ["rust/exo_pyo3_bindings", "bench"]
|
||||
|
||||
[tool.uv.sources]
|
||||
exo_pyo3_bindings = { workspace = true }
|
||||
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks" }
|
||||
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/eval-left-padding-in-batched-rotation" }
|
||||
torch = [{ index = "pytorch-cu130", marker = "sys_platform == 'linux'" }]
|
||||
vllm = { git = "https://github.com/hmellor/vllm.git", branch = "transformers-v5" }
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cu130"
|
||||
url = "https://download.pytorch.org/whl/cu130"
|
||||
explicit = true
|
||||
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
|
||||
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/fix-arrayscache-leak" }
|
||||
# Uncomment to use local mlx/mlx-lm development versions:
|
||||
# mlx = { path = "/Users/Shared/mlx", editable=true }
|
||||
# mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.9,<0.9.0"]
|
||||
@@ -106,16 +101,8 @@ exclude = [
|
||||
"**/.direnv",
|
||||
"**/rust",
|
||||
"**/.github",
|
||||
"**/vllm_patches",
|
||||
"**/engines/vllm",
|
||||
]
|
||||
stubPath = ".mlx_typings"
|
||||
extraPaths = [".cuda_typings"]
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "src/exo/worker/runner/vllm_inference"
|
||||
extraPaths = ["src", ".cuda_typings"]
|
||||
reportMissingModuleSource = false
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "src"
|
||||
@@ -129,46 +116,7 @@ root = "src"
|
||||
required-version = ">=0.8.6"
|
||||
prerelease = "allow"
|
||||
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
|
||||
conflicts = [[{ package = "exo", extra = "cuda" }, { package = "exo-bench" }]]
|
||||
override-dependencies = ["compressed-tensors==0.13.0", "torch==2.10.0"]
|
||||
constraint-dependencies = ["transformers>=5.0.0,<5.4.0"]
|
||||
|
||||
[tool.uv.extra-build-dependencies]
|
||||
mlx = [
|
||||
"setuptools",
|
||||
"typing-extensions",
|
||||
"nanobind",
|
||||
"pybind11",
|
||||
"wheel",
|
||||
"cmake",
|
||||
"ninja",
|
||||
]
|
||||
mlx-lm = ["setuptools"]
|
||||
xgrammar = [
|
||||
"nanobind",
|
||||
"setuptools",
|
||||
"scikit-build-core",
|
||||
"packaging",
|
||||
"pathspec",
|
||||
]
|
||||
rouge-score = ["setuptools"]
|
||||
sacrebleu = ["setuptools"]
|
||||
sqlitedict = ["setuptools"]
|
||||
word2number = ["setuptools"]
|
||||
vllm = [
|
||||
"setuptools",
|
||||
"setuptools-scm",
|
||||
"scikit-build-core",
|
||||
"jinja2",
|
||||
"wheel",
|
||||
"markupsafe",
|
||||
"typing-extensions",
|
||||
"torch",
|
||||
]
|
||||
fastsafetensors = ["setuptools", "pybind11"]
|
||||
torch = ["typing-extensions"]
|
||||
torchvision = ["torch"]
|
||||
torchaudio = ["torch"]
|
||||
extra-build-dependencies = { "miniaudio" = ["setuptools", "cffi", "pycparser"] }
|
||||
|
||||
###
|
||||
# ruff configuration
|
||||
@@ -176,8 +124,8 @@ torchaudio = ["torch"]
|
||||
|
||||
[tool.ruff]
|
||||
extend-exclude = [
|
||||
"shared/protobufs/**",
|
||||
"*mlx_typings/**",
|
||||
"*cuda_typings/**",
|
||||
"rust/exo_pyo3_bindings/**",
|
||||
"bench/vendor/**",
|
||||
]
|
||||
|
||||
+135
-425
@@ -1,434 +1,146 @@
|
||||
{ inputs, ... }:
|
||||
let
|
||||
mkPythonSet = { self', pkgs, lib, apple-sdk, editable ? false }:
|
||||
{
|
||||
perSystem =
|
||||
{ config, self', pkgs, lib, system, ... }:
|
||||
let
|
||||
inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux isx86_64;
|
||||
inherit (pkgs.config) cudaSupport;
|
||||
inherit (pkgs) cudaPackages;
|
||||
cudaLibs = with cudaPackages; [
|
||||
cuda_cudart
|
||||
cuda_cccl
|
||||
cuda_cupti
|
||||
cuda_nvrtc
|
||||
cuda_nvtx
|
||||
cudnn
|
||||
libcufile
|
||||
libcublas
|
||||
libcufft
|
||||
libcurand
|
||||
libcusolver
|
||||
libcusparse
|
||||
libcusparse_lt
|
||||
libnvjitlink
|
||||
libnvshmem
|
||||
nccl
|
||||
];
|
||||
cuda_cccl_compat = pkgs.runCommand "cuda-cccl-compat" { } ''
|
||||
mkdir -p $out/include
|
||||
ln -s ${cudaPackages.cuda_cccl}/include $out/include/cccl
|
||||
'';
|
||||
|
||||
cudaRoot = pkgs.symlinkJoin {
|
||||
name = "cuda-merged-exo";
|
||||
paths = builtins.concatMap (p: [ (lib.getBin p) (lib.getLib p) (lib.getDev p) ]) (cudaLibs ++ [ cudaPackages.cuda_nvcc cuda_cccl_compat ]);
|
||||
};
|
||||
exoOverlay = final: prev:
|
||||
{
|
||||
mlx = prev.mlx.overrideAttrs (old:
|
||||
let
|
||||
# Static dependencies included directly during compilation
|
||||
gguf-tools = pkgs.fetchFromGitHub {
|
||||
owner = "antirez";
|
||||
repo = "gguf-tools";
|
||||
rev = "8fa6eb65236618e28fd7710a0fba565f7faa1848";
|
||||
hash = "sha256-15FvyPOFqTOr5vdWQoPnZz+mYH919++EtghjozDlnSA=";
|
||||
};
|
||||
|
||||
metal_cpp = pkgs.fetchzip {
|
||||
url = "https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip";
|
||||
hash = "sha256-7n2eI2lw/S+Us6l7YPAATKwcIbRRpaQ8VmES7S8ZjY8=";
|
||||
};
|
||||
|
||||
nanobind = pkgs.fetchFromGitHub {
|
||||
owner = "wjakob";
|
||||
repo = "nanobind";
|
||||
rev = "v2.10.2";
|
||||
hash = "sha256-io44YhN+VpfHFWyvvLWSanRgbzA0whK8WlDNRi3hahU=";
|
||||
fetchSubmodules = true;
|
||||
};
|
||||
|
||||
nvtx = pkgs.fetchFromGitHub {
|
||||
name = "nvtx3";
|
||||
owner = "NVIDIA";
|
||||
repo = "NVTX";
|
||||
rev = "v3.1.1";
|
||||
hash = "sha256-sx72N+Gskg9Vtqc3sXsWoE/2PHFI2Hq08lEaw0sll5Y=";
|
||||
};
|
||||
cudnn = pkgs.fetchFromGitHub {
|
||||
name = "cudnn_frontend";
|
||||
owner = "NVIDIA";
|
||||
repo = "cudnn-frontend";
|
||||
rev = "v1.16.0";
|
||||
hash = "sha256-+8aBl9dKd2Uz50XoOr91NRyJ4OGJtzfDNNNYGQJ9b94=";
|
||||
};
|
||||
mlx_cuda_cccl_compat = pkgs.runCommand "cuda-cccl-compat" { } ''
|
||||
mkdir -p $out/include
|
||||
exit 1
|
||||
ln -s ${cudaPackages.cuda_cccl}/include/cuda $out/include/cuda
|
||||
ln -s ${cudaPackages.cuda_cccl}/include/nv $out/include/nv
|
||||
'';
|
||||
in
|
||||
{
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.cmake ] ++ lib.optionals isDarwin [ self'.packages.metal-toolchain ] ++ lib.optionals cudaSupport [
|
||||
cudaPackages.cuda_nvcc
|
||||
pkgs.autoAddDriverRunpath
|
||||
pkgs.autoPatchelfHook
|
||||
];
|
||||
# TODO: non-sdk_26 support
|
||||
buildInputs = (old.buildInputs or [ ])
|
||||
++ [ gguf-tools pkgs.fmt pkgs.nlohmann_json pkgs.openblas ]
|
||||
++ lib.optionals isDarwin [ apple-sdk ]
|
||||
++ lib.optionals cudaSupport (cudaLibs ++ [ cudaPackages.cudnn ]);
|
||||
patches = (old.patches or [ ])
|
||||
++ lib.optionals cudaSupport [ ../nix/mlx_patch_fmod.patch ]
|
||||
++ lib.optionals isDarwin [
|
||||
(pkgs.replaceVars ../nix/darwin-build-fixes.patch {
|
||||
sdkVersion = apple-sdk.version;
|
||||
inherit (self'.packages.metal-toolchain) metalVersion;
|
||||
})
|
||||
];
|
||||
postPatch = ''
|
||||
substituteInPlace mlx/backend/cpu/jit_compiler.cpp \
|
||||
--replace-fail "g++" "${lib.getExe' pkgs.stdenv.cc "c++"}"
|
||||
'';
|
||||
autoPatchelfIgnoreMissingDeps = (old.autoPatchelfIgnoreMissingDeps or [ ]) ++ lib.optionals cudaSupport [ "libcuda.so.1" ];
|
||||
|
||||
DEV_RELEASE = 1;
|
||||
CMAKE_ARGS = toString ([
|
||||
(lib.cmakeBool "USE_SYSTEM_FMT" true)
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_GGUFLIB" "${gguf-tools}")
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_JSON" "${pkgs.nlohmann_json.src}")
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_NANOBIND" "${nanobind}")
|
||||
(lib.cmakeBool "FETCHCONTENT_FULLY_DISCONNECTED" true)
|
||||
(lib.cmakeBool "MLX_BUILD_CPU" true)
|
||||
(lib.cmakeBool "MLX_BUILD_METAL" isDarwin)
|
||||
(lib.cmakeBool "MLX_BUILD_CUDA" false)
|
||||
(lib.cmakeOptionType "string" "CMAKE_INSTALL_LIBDIR" "lib")
|
||||
] ++ lib.optionals cudaSupport [
|
||||
(lib.cmakeOptionType "filepath" "CUDAToolkit_ROOT" "${cudaRoot}")
|
||||
(lib.cmakeOptionType "string" "MLX_CUDA_ARCHITECTURES" "121")
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_CUTLASS" "${cudaPackages.cutlass}")
|
||||
# TODO: replace with cudaPackages.cudnn
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_CUDNN" "${cudnn}")
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_CCCL" "${cudaPackages.cuda_cccl}")
|
||||
# TODO: replace with cudaPackages.nvtx
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_NVTX3" "${nvtx}")
|
||||
] ++ lib.optionals isDarwin [
|
||||
(lib.cmakeOptionType "filepath" "FETCHCONTENT_SOURCE_DIR_METAL_CPP" "${metal_cpp}")
|
||||
(lib.cmakeOptionType "string" "CMAKE_OSX_DEPLOYMENT_TARGET" "${apple-sdk.version}")
|
||||
(lib.cmakeOptionType "filepath" "CMAKE_OSX_SYSROOT" "${apple-sdk.passthru.sdkroot}")
|
||||
] ++ lib.optionals (isDarwin && isx86_64) [
|
||||
(lib.cmakeBool "MLX_ENABLE_X64_MAC" true)
|
||||
]);
|
||||
} // lib.optionalAttrs isDarwin {
|
||||
SDKROOT = apple-sdk.passthru.sdkroot;
|
||||
MACOSX_DEPLOYMENT_TARGET = apple-sdk.version;
|
||||
});
|
||||
exo-pyo3-bindings = pkgs.stdenv.mkDerivation {
|
||||
pname = "exo-pyo3-bindings";
|
||||
version = "0.1.0";
|
||||
src = self'.packages.exo_pyo3_bindings;
|
||||
# Install from pre-built wheel
|
||||
nativeBuildInputs = [ final.pyprojectWheelHook ];
|
||||
dontStrip = true;
|
||||
passthru = prev.exo-pyo3-bindings.passthru or { };
|
||||
postInstall = ''
|
||||
local siteDir=$out/${final.python.sitePackages}/exo_pyo3_bindings
|
||||
cp ${inputs.self}/rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi $siteDir/
|
||||
touch $siteDir/py.typed
|
||||
'';
|
||||
};
|
||||
torch = prev.torch.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
buildInputs = (old.buildInputs or [ ]) ++ lib.optionals cudaSupport cudaLibs;
|
||||
autoPatchelfIgnoreMissingDeps = (old.autoPatchelfIgnoreMissingDeps or [ ]) ++ lib.optionals cudaSupport [ "libcuda.so.1" ];
|
||||
});
|
||||
torchaudio = prev.torchaudio.overrideAttrs (old:
|
||||
{
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
buildInputs = (old.buildInputs or [ ]) ++ lib.optionals cudaSupport [
|
||||
cudaPackages.cuda_cudart
|
||||
];
|
||||
preFixup = (old.preFixup or "") + ''
|
||||
addAutoPatchelfSearchPath "${final.torch}"
|
||||
'';
|
||||
autoPatchelfIgnoreMissingDeps = (old.autoPatchelfIgnoreMissingDeps or [ ]) ++ lib.optionals cudaSupport [ "libcuda.so.1" ];
|
||||
});
|
||||
torchvision = prev.torchvision.overrideAttrs (old:
|
||||
{
|
||||
nativebuildInputs = (old.nativebuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
preFixup = (old.preFixup or "") + ''
|
||||
addAutoPatchelfSearchPath "${final.torch}"
|
||||
'';
|
||||
autoPatchelfIgnoreMissingDeps = (old.autoPatchelfIgnoreMissingDeps or [ ]) ++ lib.optionals cudaSupport [ "libcuda.so.1" ];
|
||||
});
|
||||
torch-c-dlpack-ext = prev.torch-c-dlpack-ext.overrideAttrs (old:
|
||||
{
|
||||
nativebuildInputs = (old.nativebuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
preFixup = (old.preFixup or "") + ''
|
||||
addAutoPatchelfSearchPath "${final.torch}"
|
||||
'';
|
||||
autoPatchelfIgnoreMissingDeps = (old.autoPatchelfIgnoreMissingDeps or [ ]) ++ lib.optionals cudaSupport [ "libcuda.so.1" ];
|
||||
});
|
||||
xgrammar = prev.xgrammar.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.cmake pkgs.autoPatchelfHook ];
|
||||
});
|
||||
# Currently treating vllm as a cuda dep. it obviously exists as a non cuda dep
|
||||
vllm = prev.vllm.overrideAttrs (old:
|
||||
let
|
||||
cutlass = pkgs.fetchFromGitHub {
|
||||
name = "cutlass-source";
|
||||
owner = "NVIDIA";
|
||||
repo = "cutlass";
|
||||
tag = "v4.2.1";
|
||||
hash = "sha256-iP560D5Vwuj6wX1otJhwbvqe/X4mYVeKTpK533Wr5gY=";
|
||||
};
|
||||
triton-kernels = pkgs.fetchFromGitHub {
|
||||
owner = "triton-lang";
|
||||
repo = "triton";
|
||||
tag = "v3.6.0";
|
||||
hash = "sha256-JFSpQn+WsNnh7CAPlcpOcUp0nyKXNbJEANdXqmkt4Tc=";
|
||||
};
|
||||
|
||||
cutlass-flashmla = pkgs.fetchFromGitHub {
|
||||
owner = "NVIDIA";
|
||||
repo = "cutlass";
|
||||
rev = "147f5673d0c1c3dcf66f78d677fd647e4a020219";
|
||||
hash = "sha256-dHQto08IwTDOIuFUp9jwm1MWkFi8v2YJ/UESrLuG71g=";
|
||||
};
|
||||
|
||||
flashmla = pkgs.stdenv.mkDerivation {
|
||||
pname = "flashmla";
|
||||
version = "1.0.0";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
name = "FlashMLA-source";
|
||||
owner = "vllm-project";
|
||||
repo = "FlashMLA";
|
||||
rev = "c2afa9cb93e674d5a9120a170a6da57b89267208";
|
||||
hash = "sha256-pKlwxV6G9iHag/jbu3bAyvYvnu5TbrQwUMFV0AlGC3s=";
|
||||
};
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
rm -rf csrc/cutlass
|
||||
ln -sf ${cutlass-flashmla} csrc/cutlass
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
cp -rva . $out
|
||||
'';
|
||||
};
|
||||
qutlass = pkgs.fetchFromGitHub {
|
||||
name = "qutlass-source";
|
||||
owner = "IST-DASLab";
|
||||
repo = "qutlass";
|
||||
rev = "830d2c4537c7396e14a02a46fbddd18b5d107c65";
|
||||
hash = "sha256-aG4qd0vlwP+8gudfvHwhtXCFmBOJKQQTvcwahpEqC84=";
|
||||
};
|
||||
vllm-flash-attn = pkgs.stdenv.mkDerivation {
|
||||
pname = "vllm-flash-attn";
|
||||
version = "2.7.2.post1";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
name = "flash-attention-source";
|
||||
owner = "vllm-project";
|
||||
repo = "flash-attention";
|
||||
rev = "188be16520ceefdc625fdf71365585d2ee348fe2";
|
||||
hash = "sha256-Osec+/IF3+UDtbIhDMBXzUeWJ7hDJNb5FpaVaziPSgM=";
|
||||
};
|
||||
|
||||
patches = [
|
||||
(pkgs.fetchpatch {
|
||||
url = "https://github.com/Dao-AILab/flash-attention/commit/dad67c88d4b6122c69d0bed1cebded0cded71cea.patch";
|
||||
hash = "sha256-JSgXWItOp5KRpFbTQj/cZk+Tqez+4mEz5kmH5EUeQN4=";
|
||||
})
|
||||
(pkgs.fetchpatch {
|
||||
url = "https://github.com/Dao-AILab/flash-attention/commit/e26dd28e487117ee3e6bc4908682f41f31e6f83a.patch";
|
||||
hash = "sha256-NkCEowXSi+tiWu74Qt+VPKKavx0H9JeteovSJKToK9A=";
|
||||
})
|
||||
];
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
rm -rf csrc/cutlass
|
||||
ln -sf ${cutlass} csrc/cutlass
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
cp -rva . $out
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
patches = (old.patches or [ ]) ++ [ ../nix/vllm_uv2nix_cmake.patch ];
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
pkgs.cmake
|
||||
pkgs.ninja
|
||||
pkgs.autoAddDriverRunpath
|
||||
] ++ lib.optionals cudaSupport [
|
||||
cudaPackages.cuda_nvcc
|
||||
];
|
||||
# TODO: vllm rocm/cpu
|
||||
VLLM_TARGET_DEVICE = "empty";
|
||||
# TODO: vllm non cuda13 support, more arch's, etc.
|
||||
} // lib.optionalAttrs cudaSupport {
|
||||
buildInputs = (old.buildInputs or [ ]) ++ cudaLibs;
|
||||
|
||||
CUDA_HOME = "${cudaRoot}";
|
||||
CUDAToolkit_ROOT = "${cudaRoot}";
|
||||
CUDACXX = "${cudaRoot}/bin/nvcc";
|
||||
VLLM_CUTLASS_SRC_DIR = "${lib.getDev cutlass}";
|
||||
VLLM_TARGET_DEVICE = "cuda";
|
||||
TORCH_CUDA_ARCH_LIST = "12.0;12.1";
|
||||
TRITON_KERNELS_SRC_DIR = "${lib.getDev triton-kernels}/python/triton_kernels/triton_kernels";
|
||||
FLASH_MLA_SRC_DIR = "${lib.getDev flashmla}";
|
||||
QUTLASS_SRC_DIR = "${lib.getDev qutlass}";
|
||||
VLLM_FLASH_ATTN_SRC_DIR = "${lib.getDev vllm-flash-attn}";
|
||||
CAFFE2_USE_CUDNN = "ON";
|
||||
CAFFE2_USE_CUFILE = "ON";
|
||||
CUTLASS_ENABLE_CUBLAS = "ON";
|
||||
CUTLASS_NVCC_ARCHS_ENABLED = "12.0;12.1";
|
||||
|
||||
UV2NIX_CMAKE_FLAGS_JSON = builtins.toJSON [
|
||||
"-DCUDAToolkit_ROOT=${cudaRoot}"
|
||||
"-DCMAKE_CUDA_COMPILER=${cudaRoot}/bin/nvcc"
|
||||
"-DCMAKE_PREFIX_PATH=${cudaRoot}"
|
||||
"-DFETCHCONTENT_SOURCE_DIR_CUTLASS=${lib.getDev cutlass}"
|
||||
"-DFLASH_MLA_SRC_DIR=${lib.getDev flashmla}"
|
||||
"-DVLLM_FLASH_ATTN_SRC_DIR=${lib.getDev vllm-flash-attn}"
|
||||
"-DQUTLASS_SRC_DIR=${lib.getDev qutlass}"
|
||||
"-DTORCH_CUDA_ARCH_LIST=12.0;12.1"
|
||||
"-DCUTLASS_NVCC_ARCHS_ENABLED=${cudaPackages.flags.cmakeCudaArchitecturesString}"
|
||||
"-DCAFFE2_USE_CUDNN=ON"
|
||||
"-DCAFFE2_USE_CUFILE=ON"
|
||||
"-DCUTLASS_ENABLE_CUBLAS=ON"
|
||||
];
|
||||
});
|
||||
} // lib.optionalAttrs cudaSupport {
|
||||
nvidia-cufile = prev.nvidia-cufile.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.rdma-core ];
|
||||
propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ pkgs.util-linux ];
|
||||
});
|
||||
nvidia-cusolver = prev.nvidia-cusolver.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
buildInputs = (old.buildInputs or [ ]) ++ (with cudaPackages; [ libnvjitlink libcublas libcusparse ]);
|
||||
});
|
||||
nvidia-cusparse = prev.nvidia-cusparse.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [ cudaPackages.libnvjitlink ];
|
||||
});
|
||||
nvidia-nvshmem-cu13 = prev.nvidia-nvshmem-cu13.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.rdma-core pkgs.pmix pkgs.libfabric pkgs.ucx pkgs.openmpi ];
|
||||
});
|
||||
nvidia-cutlass-dsl-libs-base = prev.nvidia-cutlass-dsl-libs-base.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.autoAddDriverRunpath ];
|
||||
autoPatchelfIgnoreMissingDeps = (old.autoPatchelfIgnoreMissingDeps or [ ]) ++ [ "libcuda.so.1" ];
|
||||
});
|
||||
} // lib.optionalAttrs (cudaSupport && isx86_64) {
|
||||
numba = prev.numba.overrideAttrs (old: {
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.tbb ];
|
||||
});
|
||||
intel-openmp = prev.intel-openmp.overrideAttrs (_old: {
|
||||
postFixup = ''
|
||||
rm -f $out/lib/libarcher.so
|
||||
rm -f $out/lib/libomptarget.so
|
||||
rm -f $out/lib/libomptarget.rtl.*.so*
|
||||
rm -f $out/lib/libomptarget.sycl.wrap.so
|
||||
'';
|
||||
});
|
||||
};
|
||||
|
||||
# Load workspace from uv.lock
|
||||
workspace = inputs.uv2nix.lib.workspace.loadWorkspace {
|
||||
workspaceRoot = ../.;
|
||||
workspaceRoot = inputs.self;
|
||||
};
|
||||
|
||||
# Create overlay from workspace
|
||||
# Use wheels from PyPI for most packages; we override mlx with our pure Nix Metal build
|
||||
overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
|
||||
in
|
||||
(pkgs.callPackage inputs.pyproject-nix.build.packages {
|
||||
|
||||
# Override overlay to inject Nix-built components
|
||||
exoOverlay = final: prev: {
|
||||
# Replace workspace exo_pyo3_bindings with Nix-built wheel.
|
||||
# Preserve passthru so mkVirtualEnv can resolve dependency groups.
|
||||
# Copy .pyi stub + py.typed marker so basedpyright can find the types.
|
||||
exo-pyo3-bindings = pkgs.stdenv.mkDerivation {
|
||||
pname = "exo-pyo3-bindings";
|
||||
version = "0.1.0";
|
||||
src = self'.packages.exo_pyo3_bindings;
|
||||
# Install from pre-built wheel
|
||||
nativeBuildInputs = [ final.pyprojectWheelHook ];
|
||||
dontStrip = true;
|
||||
passthru = prev.exo-pyo3-bindings.passthru or { };
|
||||
postInstall = ''
|
||||
local siteDir=$out/${final.python.sitePackages}/exo_pyo3_bindings
|
||||
cp ${inputs.self}/rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi $siteDir/
|
||||
touch $siteDir/py.typed
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
python = pkgs.python313;
|
||||
}).overrideScope (
|
||||
lib.composeManyExtensions ([
|
||||
inputs.pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
exoOverlay
|
||||
] ++ lib.optionals editable [
|
||||
(workspace.mkEditablePyprojectOverlay { root = "$REPO_ROOT"; members = [ "exo" "bench" ]; })
|
||||
])
|
||||
);
|
||||
|
||||
mkExo = args@{ self', pkgs, lib, ... }:
|
||||
let
|
||||
venv = ((mkPythonSet args).mkVirtualEnv "exo-env" {
|
||||
exo = lib.optionals pkgs.config.cudaSupport [ "cuda" ];
|
||||
}).overrideAttrs {
|
||||
venvSkip = [ "lib/python3.13/site-packages/build_backend.py" ];
|
||||
# Overlay to provide build systems and custom packages
|
||||
buildSystemsOverlay = final: prev: {
|
||||
# mlx-lm is a git dependency that needs setuptools
|
||||
mlx-lm = prev.mlx-lm.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
final.setuptools
|
||||
];
|
||||
});
|
||||
# rouge-score and sacrebleu don't declare setuptools as a build dependency
|
||||
rouge-score = prev.rouge-score.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
final.setuptools
|
||||
];
|
||||
});
|
||||
sacrebleu = prev.sacrebleu.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
final.setuptools
|
||||
];
|
||||
});
|
||||
sqlitedict = prev.sqlitedict.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
final.setuptools
|
||||
];
|
||||
});
|
||||
word2number = prev.word2number.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
final.setuptools
|
||||
];
|
||||
});
|
||||
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
|
||||
# Use our pure Nix-built MLX with Metal support (macOS only)
|
||||
mlx = self'.packages.mlx;
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "exo"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
}
|
||||
''
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create wrapper script
|
||||
makeWrapper ${venv}/bin/exo $out/bin/exo \
|
||||
--set EXO_DASHBOARD_DIR ${self'.packages.dashboard} \
|
||||
--set EXO_RESOURCES_DIR ${inputs.self + /resources} \
|
||||
${lib.optionalString pkgs.stdenv.hostPlatform.isDarwin "--prefix PATH : ${pkgs.macmon}/bin"}
|
||||
'';
|
||||
in
|
||||
{
|
||||
perSystem =
|
||||
{ self', pkgs, cudaPkgs, lib, ... }:
|
||||
let
|
||||
inherit (pkgs.stdenv.hostPlatform) isDarwin;
|
||||
pythonSet = mkPythonSet { inherit self' pkgs lib; apple-sdk = pkgs.apple-sdk_26; };
|
||||
# taking cudaPkgs.cudaPackages_13.pkgs creates a new nixpkgs that defaults to cuda 13
|
||||
cudaPythonSet = mkPythonSet { inherit self' lib; inherit (cudaPkgs.cudaPackages_13) pkgs; apple-sdk = pkgs.apple-sdk_26; };
|
||||
# Additional overlay for Linux-specific fixes (type checking env).
|
||||
# Native wheels have shared lib dependencies we don't need at type-check time.
|
||||
linuxOverlay = final: prev:
|
||||
let
|
||||
ignoreMissing = drv: drv.overrideAttrs { autoPatchelfIgnoreMissingDeps = [ "*" ]; };
|
||||
nvidiaPackages = lib.filterAttrs (name: _: lib.hasPrefix "nvidia-" name) prev;
|
||||
in
|
||||
lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux (
|
||||
(lib.mapAttrs (_: ignoreMissing) nvidiaPackages) // {
|
||||
mlx = ignoreMissing prev.mlx;
|
||||
mlx-cuda-13 = prev.mlx-cuda-13.overrideAttrs (old: {
|
||||
buildInputs = (old.buildInputs or [ ]) ++ [
|
||||
final.nvidia-cublas
|
||||
final.nvidia-cuda-nvrtc
|
||||
final.nvidia-cudnn-cu13
|
||||
final.nvidia-nccl-cu13
|
||||
];
|
||||
preFixup = ''
|
||||
addAutoPatchelfSearchPath ${final.nvidia-cublas}
|
||||
addAutoPatchelfSearchPath ${final.nvidia-cuda-nvrtc}
|
||||
addAutoPatchelfSearchPath ${final.nvidia-cudnn-cu13}
|
||||
addAutoPatchelfSearchPath ${final.nvidia-nccl-cu13}
|
||||
'';
|
||||
autoPatchelfIgnoreMissingDeps = [ "libcuda.so.1" ];
|
||||
});
|
||||
torch = ignoreMissing prev.torch;
|
||||
triton = ignoreMissing prev.triton;
|
||||
}
|
||||
);
|
||||
|
||||
editablePythonSet = mkPythonSet { inherit self' lib pkgs; apple-sdk = pkgs.apple-sdk_26; editable = true; };
|
||||
evenv = (editablePythonSet.mkVirtualEnv "exo-dev-env"
|
||||
{
|
||||
exo = [ "dev" ];
|
||||
exo-pyo3-bindings = [ ];
|
||||
exo-bench = [ ];
|
||||
}).overrideAttrs {
|
||||
venvSkip = [ "lib/python3.13/site-packages/build_backend.py" ];
|
||||
};
|
||||
exoCudaVenv = (cudaPythonSet.mkVirtualEnv "exo-env" {
|
||||
exo = [ "cuda" ];
|
||||
exo-pyo3-bindings = [ ];
|
||||
}).overrideAttrs {
|
||||
venvSkip = [ "lib/python3.13/site-packages/build_backend.py" ];
|
||||
pythonSet = (pkgs.callPackage inputs.pyproject-nix.build.packages {
|
||||
inherit python;
|
||||
}).overrideScope (
|
||||
lib.composeManyExtensions [
|
||||
inputs.pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
exoOverlay
|
||||
buildSystemsOverlay
|
||||
linuxOverlay
|
||||
]
|
||||
);
|
||||
# mlx-cpu and mlx-cuda-13 both ship mlx/ site-packages files; keep first.
|
||||
# mlx-cpu/mlx-cuda-13 and nvidia-cudnn-cu12/cu13 ship overlapping files.
|
||||
venvCollisionPaths = lib.optionals pkgs.stdenv.hostPlatform.isLinux [
|
||||
"lib/python3.13/site-packages/mlx*"
|
||||
"lib/python3.13/site-packages/nvidia*"
|
||||
];
|
||||
|
||||
# Exclude bench deps from main env (bench has its own benchVenv)
|
||||
exoDeps = removeAttrs workspace.deps.default [ "exo-bench" ];
|
||||
|
||||
exoVenv = (pythonSet.mkVirtualEnv "exo-env" exoDeps).overrideAttrs {
|
||||
venvIgnoreCollisions = venvCollisionPaths;
|
||||
};
|
||||
|
||||
# Virtual environment with dev dependencies for testing
|
||||
testVenv = (pythonSet.mkVirtualEnv "exo-test-env"
|
||||
{
|
||||
testVenv = (pythonSet.mkVirtualEnv "exo-test-env" (
|
||||
exoDeps // {
|
||||
exo = [ "dev" ]; # Include pytest, pytest-asyncio, pytest-env
|
||||
exo-pyo3-bindings = [ ];
|
||||
}
|
||||
).overrideAttrs {
|
||||
# venvIgnoreCollisions = venvCollisionPaths;
|
||||
)).overrideAttrs {
|
||||
venvIgnoreCollisions = venvCollisionPaths;
|
||||
};
|
||||
|
||||
mkPythonScript = name: path: pkgs.writeShellApplication {
|
||||
inherit name;
|
||||
runtimeInputs = [ exoVenv ];
|
||||
runtimeEnv = {
|
||||
EXO_DASHBOARD_DIR = self'.packages.dashboard;
|
||||
EXO_RESOURCES_DIR = inputs.self + /resources;
|
||||
};
|
||||
text = ''exec python ${path} "$@"'';
|
||||
};
|
||||
|
||||
benchVenv = pythonSet.mkVirtualEnv "exo-bench-env" {
|
||||
@@ -447,34 +159,32 @@ in
|
||||
text = ''exec python ${path} "$@"'';
|
||||
};
|
||||
|
||||
exoCudaPackage = cudaPkgs.runCommand "exo"
|
||||
exoPackage = pkgs.runCommand "exo"
|
||||
{
|
||||
nativeBuildInputs = [ cudaPkgs.makeWrapper ];
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
}
|
||||
''
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create wrapper script
|
||||
makeWrapper ${exoCudaVenv}/bin/exo $out/bin/exo \
|
||||
makeWrapper ${exoVenv}/bin/exo $out/bin/exo \
|
||||
--set EXO_DASHBOARD_DIR ${self'.packages.dashboard} \
|
||||
--set EXO_RESOURCES_DIR ${inputs.self + /resources} \
|
||||
--prefix LD_LIBRARY_PATH : /run/opengl-driver/lib:${lib.getLib pkgs.util-linux}/lib:${lib.getLib pkgs.systemd}/lib:${lib.getLib pkgs.numactl}/lib:${lib.getLib pkgs.stdenv.cc.cc.lib}/lib \
|
||||
${lib.optionalString isDarwin "--prefix PATH : ${pkgs.macmon}/bin"}
|
||||
${lib.optionalString pkgs.stdenv.hostPlatform.isDarwin "--prefix PATH : ${pkgs.macmon}/bin"}
|
||||
'';
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
exo = mkExo { inherit self' lib pkgs; apple-sdk = pkgs.apple-sdk_26; };
|
||||
exo-cuda = exoCudaPackage;
|
||||
# Python package only available on macOS (requires MLX/Metal)
|
||||
packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin
|
||||
{
|
||||
exo = exoPackage;
|
||||
# Test environment for running pytest outside of Nix sandbox (needs GPU access)
|
||||
exo-test-env = testVenv;
|
||||
} // {
|
||||
exo-bench = mkBenchScript "exo-bench" (inputs.self + /bench/exo_bench.py);
|
||||
exo-eval = mkBenchScript "exo-eval" (inputs.self + /bench/exo_eval.py);
|
||||
exo-eval-tool-calls = mkBenchScript "exo-eval-tool-calls" (inputs.self + /bench/eval_tool_calls.py);
|
||||
exo-get-all-models-on-cluster = mkSimplePythonScript "exo-get-all-models-on-cluster" (inputs.self + /tests/get_all_models_on_cluster.py);
|
||||
editable-venv = evenv;
|
||||
} // lib.optionalAttrs isDarwin {
|
||||
# Test environment for running pytest outside of Nix sandbox (needs GPU access)
|
||||
exo-test-env = testVenv;
|
||||
exo-osx14 = mkExo { inherit self' lib pkgs; apple-sdk = pkgs.apple-sdk_14; };
|
||||
};
|
||||
|
||||
checks = {
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "4bit"
|
||||
base_model = "DeepSeek V3.1"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 405874409472
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "8bit"
|
||||
base_model = "DeepSeek V3.1"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 765577920512
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
model_id = "mlx-community/DeepSeek-V3.2-4bit"
|
||||
n_layers = 61
|
||||
hidden_size = 7168
|
||||
num_key_value_heads = 128
|
||||
supports_tensor = true
|
||||
tasks = ["TextGeneration"]
|
||||
family = "deepseek"
|
||||
quantization = "4bit"
|
||||
base_model = "DeepSeek V3.2"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 378086226621
|
||||
@@ -0,0 +1,15 @@
|
||||
model_id = "mlx-community/DeepSeek-V3.2-8bit"
|
||||
n_layers = 61
|
||||
hidden_size = 7168
|
||||
num_key_value_heads = 128
|
||||
supports_tensor = true
|
||||
tasks = ["TextGeneration"]
|
||||
family = "deepseek"
|
||||
quantization = "8bit"
|
||||
base_model = "DeepSeek V3.2"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 755957120916
|
||||
@@ -9,5 +9,7 @@ quantization = "8bit"
|
||||
base_model = "GLM 4.5 Air"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 122406567936
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "bf16"
|
||||
base_model = "GLM 4.5 Air"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 229780750336
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "4bit"
|
||||
base_model = "GLM 4.7"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 198556925568
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "6bit"
|
||||
base_model = "GLM 4.7"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 286737579648
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "8bit"
|
||||
base_model = "GLM 4.7"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 396963397248
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "4bit"
|
||||
base_model = "GLM 4.7 Flash"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 19327352832
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "5bit"
|
||||
base_model = "GLM 4.7 Flash"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 22548578304
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "6bit"
|
||||
base_model = "GLM 4.7 Flash"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 26843545600
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "8bit"
|
||||
base_model = "GLM 4.7 Flash"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 34359738368
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "8bit"
|
||||
base_model = "GLM-5"
|
||||
capabilities = ["text", "thinking"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 790517400864
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "MXFP4-Q8"
|
||||
base_model = "GLM-5"
|
||||
capabilities = ["text", "thinking"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 405478939008
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "bf16"
|
||||
base_model = "GLM-5"
|
||||
capabilities = ["text", "thinking"]
|
||||
|
||||
context_length = 202752
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 1487822475264
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = "4bit"
|
||||
base_model = "Kimi K2"
|
||||
capabilities = ["text"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 620622774272
|
||||
|
||||
@@ -9,5 +9,7 @@ quantization = ""
|
||||
base_model = "Kimi K2"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
|
||||
context_length = 262144
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 706522120192
|
||||
|
||||
@@ -7,7 +7,15 @@ tasks = ["TextGeneration"]
|
||||
family = "kimi"
|
||||
quantization = ""
|
||||
base_model = "Kimi K2.5"
|
||||
capabilities = ["text", "thinking", "thinking_toggle"]
|
||||
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
|
||||
|
||||
context_length = 262144
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 662498705408
|
||||
|
||||
[vision]
|
||||
image_token_id = 163605
|
||||
model_type = "kimi_vl"
|
||||
weights_repo = "davehind/Kimi-K2.5-vision"
|
||||
processor_repo = "moonshotai/Kimi-K2.5"
|
||||
|
||||
+2
@@ -8,5 +8,7 @@ quantization = "4bit"
|
||||
base_model = "NVIDIA Llama-3.1-Nemotron-70B-Instruct"
|
||||
capabilities = ["text"]
|
||||
|
||||
context_length = 131072
|
||||
|
||||
[storage_size]
|
||||
in_bytes = 39688355840
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user