Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4557598
Add RotorQuant KV cache backend with deferred prefill on Metal
Apr 8, 2026
53a0907
Address PR review: deferred-flush guard, state pytree, doc fixes
Apr 8, 2026
9c93b6e
Merge remote-tracking branch 'origin/main' into kv-cache-rotorquant
Apr 9, 2026
ff21598
Fix RoPE position bug in deferred-prefill multi-chunk path
Apr 9, 2026
f40e9d4
Add head_dim preflight guard for rotorquant, fix docs and tooltip
Apr 9, 2026
aba98c2
Fix pipeline warmup hang: enable prefill mode for stream_generate path
Apr 9, 2026
8545421
Make env var the source of truth for KV backend, sync to config
Apr 9, 2026
032593b
Protect user-set KV backend from cluster config sync
Apr 9, 2026
f587d2b
Fix pipeline all_gather deadlock: eval full tensor before slicing
Apr 9, 2026
a43ed82
Replace decode all_gather with point-to-point broadcast, fix warmup s…
Apr 9, 2026
7ba35fc
Add diagnostic logging to agree_on_tasks and mx_all_gather_tasks
Apr 9, 2026
f41b97d
Replace decode broadcast send/recv with all_sum to fix JACCL corruption
Apr 10, 2026
3d526b7
Move decode broadcast all_sum to CPU stream to fix 3-node JACCL hang
Apr 10, 2026
3100207
Fix JACCL deadlock: run mx_all_gather_tasks collectives on CPU stream
Apr 10, 2026
bae6455
Fix prefill deadlock: skip distributed callback in stream_generate path
Apr 10, 2026
c5ec472
Replace all JACCL all_gather with all_sum to fix 3-node deadlocks
Apr 10, 2026
13a5f47
Reduce empty distributed task polling during generation
Apr 10, 2026
04893db
Use explicit pipeline prefill for short prompts
Apr 10, 2026
6c4ba6f
Gate experimental rotorquant backend
Apr 10, 2026
d612d14
Restore Gemma 4 live prompt boundary
Apr 10, 2026
a50a537
Let warmup finish one-token generation
Apr 10, 2026
856b30f
Force sequential generation for Gemma 4
Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 88 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,90 @@ a more modern dashboard, richer API workflows, sophisticated cache quantization,
- Form a small cluster of Macs and split larger models across them.
- Use a central model store so the cluster downloads once and stages locally.
- Talk to the cluster through OpenAI Chat Completions, OpenAI Responses, Claude Messages, or Ollama-compatible APIs.
- Push KV cache memory down hard with rotation-based 3-bit quantization (RotorQuant, OptiQ, TurboQuant) and pick a backend per workload from the dashboard.
- Use a model-aware reasoning contract that handles toggleable and non-toggleable thinking models without baking assumptions into client code.
- Experiment with advanced placement modes, RDMA, and KV cache backends when you are ready.
- Run non-chat workloads such as embeddings and other specialized model flows.
- Build TTS-oriented and other API-driven workflows on top of the cluster.
- Actually use your cluster for real inference workloads instead of treating it as a demo.

## Everything Different About Skulk

This is the running list of where Skulk diverges from upstream [exo](https://github.com/exo-explore/exo). It is a living section — every meaningful change should land here when it ships, so anyone evaluating Skulk can see the surface area at a glance.

### Inference and KV cache

- **RotorQuant KV cache backend** — pure-MLX port of IsoQuant 3-bit (block-diagonal quaternion rotations + Lloyd-Max centroids) with **deferred prefill on Metal**, a contribution that does not exist in any upstream project (the llama.cpp fork ships it CUDA-only). GQA-native; no fallback for grouped-query models. See [docs/kv-cache-backends.md](docs/kv-cache-backends.md).

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bullet links to docs/kv-cache-backends.md, but that page currently doesn’t mention RotorQuant (it still lists only default/mlx_quantized/turboquant/optiq). Either update docs/kv-cache-backends.md in this PR to include RotorQuant, or change the link to the up-to-date page under website/docs/kv-cache-backends.md / the published docs URL so readers don’t land on stale information.

Suggested change
- **RotorQuant KV cache backend** — pure-MLX port of IsoQuant 3-bit (block-diagonal quaternion rotations + Lloyd-Max centroids) with **deferred prefill on Metal**, a contribution that does not exist in any upstream project (the llama.cpp fork ships it CUDA-only). GQA-native; no fallback for grouped-query models. See [docs/kv-cache-backends.md](docs/kv-cache-backends.md).
- **RotorQuant KV cache backend** — pure-MLX port of IsoQuant 3-bit (block-diagonal quaternion rotations + Lloyd-Max centroids) with **deferred prefill on Metal**, a contribution that does not exist in any upstream project (the llama.cpp fork ships it CUDA-only). GQA-native; no fallback for grouped-query models. See [KV cache backends documentation](https://foxlight-foundation.github.io/Skulk/kv-cache-backends/).

Copilot uses AI. Check for mistakes.
- **TurboQuant native and adaptive backends** — randomized Hadamard rotation + Lloyd-Max centroids, with an adaptive variant that keeps edge attention layers in fp16 for accuracy.
- **OptiQ KV cache integration** — wraps `mlx-optiq`'s rotated-space attention path so the rotation cost stays out of the per-token loop on supported (non-GQA) models.
- **OptiQ mixed-precision weight quantization pipeline** — async wrapper around `mlx-optiq`'s sensitivity analysis and KL-divergence per-layer bit allocation, exposed as a model-store optimization job.
- **KV prefix cache with snapshot/restore** — LRU-evicted prompt-prefix cache that snapshots SSM and rotating-window cache states so prefix matches are reusable across conversation turns even for hybrid Mamba/Transformer architectures.
- **Pipeline-parallel prefill for short prompts** — pipelined models now route every prefill through the pipeline path, fixing prior warmup hangs on Gemma-class models.
- **Force-sequential fallback** — quantized backends transparently fall back to a sequential generator when batch/history mode is incompatible with their cache layout.

### Model capability system

- **Two-layer capability model** — declarative `ModelCard` (with optional `reasoning`, `modalities`, `tooling`, and `runtime` sections) plus a normalized `ResolvedCapabilityProfile` derived from the card and conservative family defaults. This is the source of truth for prompt rendering, output parsing, tool-call handling, and the `/v1/models` `resolved_capabilities` field.
- **Phase 2 thinking contract** — `enable_thinking`, `reasoning_effort`, and the dashboard thinking toggle are all driven by `supports_thinking_toggle`, so non-toggleable reasoning models behave correctly without leaking model-specific quirks into client code.
- **Output parser selection** — model cards declare `output_parser` (`generic`, `gemma4`, `gpt_oss`, `deepseek_v32`, etc.), so reasoning markers are normalized into structured `reasoning_content` per family.
- **Model store metadata pipeline** — capability resolution feeds `/v1/models` so dashboards and clients can discover thinking, multimodal, and tool support without hardcoding model lists.

### API surface

- **Claude Messages API** — `/v1/messages` adapter, including streaming, tool use, image inputs, and capability-aware thinking controls.
- **Ollama compatibility** — both `/api/chat` and `/api/generate`, with adapter-side reasoning normalization.
- **OpenAI Responses API** — `/v1/responses` adapter alongside chat completions.
- **Embeddings endpoint** for non-chat workloads.
- **Model store endpoints** — search, add, download, capability resolution, optimization jobs, and registry management, all exposed under stable URLs and documented in the OpenAPI spec.
- **Cluster-wide config endpoints** — `GET`/`POST` config that gossipsubs to every node and writes back to `skulk.yaml`.
- **Tracing, downloads, instance previews, and placement endpoints** — distributed-system observability and pre-launch placement inspection that upstream does not expose.

### Dashboard

- **React dashboard (default)** — replaces upstream's Svelte UI with a typed React + styled-components app that ships with the binary. The legacy Svelte dashboard is kept only as a fallback in the repo.
- **Cluster topology view** with live device icons, GPU stats, network mesh visualization, and connection status banners.
- **Placement preview / placement manager** for inspecting and choosing valid placements before launching.
- **Model store browser** with HuggingFace search, family sidebar, model filters, capability badges, recent models, and per-model launch controls.
- **Reasoning-aware chat UI** that splits inline `<think>` and Gemma `<|channel>` markers into a dedicated thinking panel and merges them with `reasoning_content` deltas from the API.
- **Image attachments and multimodal chat affordances** for vision models.
- **Cluster-wide settings panel** that writes to `skulk.yaml` and syncs across nodes via gossipsub.
- **Light and dark themes** with first-class theme tokens, screenshots in both modes for documentation work.

### Centralized logging and observability

- **Structured JSON stdout** when `logging.enabled` is set, configurable from the dashboard Settings panel and synced cluster-wide.
- **Vector + VictoriaLogs + Grafana stack** — local Vector log shipper on each node, central VictoriaLogs storage, ready-made Grafana dashboards. Stack definition lives in `deployment/logging/`.
- **Distributed tracing** opt-in via `EXO_TRACING_ENABLED`.

### Model store

- **Centralized model store host** — one node downloads, the rest of the cluster stages over the LAN.
- **Persistent registry** with capability resolution and download tracking.
- **Custom model card support** — add your own model with `POST /models/add`.
- **Image and embedding model cards** behind feature flags.
- **Optimization job pipeline** for mlx-optiq mixed-precision weight quantization.

### Cluster operation

- **Cluster-wide settings sync** for KV cache backend, logging, model store host, HF token, and other inference toggles.
- **Bootstrap peer config from `skulk.yaml`, env, or CLI** for fixed-topology clusters.
- **Election (bully algorithm) + master/worker split** for indexing events and broadcasting state.
- **`SKULK_*` environment variables** alongside the legacy `EXO_*` set, so new options can land without colliding with upstream.
- **`skulk.yaml`** as the canonical config file, with `exo.yaml` kept for backwards compatibility.

### Build, type system, and dev workflow

- **Strict basedpyright** type checking — zero-error policy for new code.
- **Ruff** linting and **`nix fmt`** formatting in CI.
- **Nix flake** for reproducible toolchain setup.
- **Docusaurus docs site** with auto-generated OpenAPI per-endpoint pages and TypeDoc HTML reference for the dashboard, both built from source.
- **Pre-commit checklist** documented in `CLAUDE.md` and enforced in CI.

### Hardware and platform

- **Apple Silicon as the primary target**, including RDMA over Thunderbolt 5 on supported hardware and matched macOS versions.
- **Linux supported** (CPU-oriented in this fork; GPU work happens on Apple Silicon).

## Prerequisites

### macOS
Expand Down Expand Up @@ -106,7 +185,9 @@ Important behavior:
- **Placement previews**: inspect valid placements before launching a model.
- **Thinking-aware chat UI**: chat with compatible models and surface reasoning content.
- **Alternative API compatibility**: OpenAI Chat Completions, OpenAI Responses, Claude Messages, and Ollama.
- **Experimental inference tuning**: OptiQ and other KV cache backends for long-context and memory experiments.
- **Rotation-based KV cache backends**: RotorQuant (IsoQuant 3-bit + deferred prefill), OptiQ, TurboQuant, and TurboQuant Adaptive — pick per workload from the dashboard.
- **Capability-driven thinking contract**: model cards declare reasoning support; the API and dashboard route accordingly.
- **Experimental inference tuning**: long-context and memory experiments via the KV cache backends above.

## Dashboard

Expand Down Expand Up @@ -373,15 +454,18 @@ uv run exo --bootstrap-peers /ip4/192.168.1.20/tcp/5678/p2p/12D3KooW...
| `EXO_NO_BATCH` | Force sequential generation | `false` |
| `EXO_OPTIQ_BITS` | Bit width for `optiq` | `4` |
| `EXO_OPTIQ_FP16_LAYERS` | Edge FP16 layers for `optiq` | `4` |
| `SKULK_ROTORQUANT_FP16_LAYERS` | Edge FP16 layers for `rotorquant_adaptive` | `4` |
| `SKULK_ROTORQUANT_DEFER_PREFILL` | Set to `0` to disable deferred prefill (debugging only) | `1` |
| `EXO_BOOTSTRAP_PEERS` | Comma-separated static peers to dial on startup | None |
| `HF_TOKEN` | Hugging Face token | None |

Examples:

```bash
EXO_OFFLINE=true uv run exo
EXO_ENABLE_IMAGE_MODELS=true uv run exo
EXO_KV_CACHE_BACKEND=optiq EXO_OPTIQ_BITS=4 EXO_OPTIQ_FP16_LAYERS=4 uv run exo
EXO_OFFLINE=true uv run skulk
EXO_ENABLE_IMAGE_MODELS=true uv run skulk
EXO_KV_CACHE_BACKEND=optiq EXO_OPTIQ_BITS=4 EXO_OPTIQ_FP16_LAYERS=4 uv run skulk
SKULK_KV_CACHE_BACKEND=rotorquant_adaptive SKULK_ROTORQUANT_FP16_LAYERS=4 uv run skulk
```

## RDMA on macOS
Expand Down
12 changes: 8 additions & 4 deletions dashboard-react/src/components/layout/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,16 +449,20 @@ export function SettingsPanel({ open, onClose }: SettingsPanelProps) {
filled
content={
`• Default — No cache quantization. Best baseline quality, highest memory use.\n` +
`• OptiQ — Rotation-based quantization via mlx-optiq. Best long-context quality.\n` +
`• RotorQuant Adaptive — IsoQuant 3-bit with deferred prefill, FP16 edge layers. Recommended.\n` +
`• RotorQuant — IsoQuant 3-bit on all KV layers. Most aggressive compression with deferred prefill.\n` +
`• OptiQ — Rotation-based quantization via mlx-optiq. Good long-context quality, no GQA support.\n` +
`• TurboQuant Adaptive — Quantizes middle KV layers, keeps edge layers in FP16. Proven stable.\n` +
`• TurboQuant — Quantizes all KV layers. Most aggressive compression, higher quality risk.\n` +
`• TurboQuant — Quantizes all KV layers. Most aggressive non-rotorquant compression.\n` +
`• MLX Quantized — MLX's built-in cache quantization.\n\n` +
`Takes effect on next model launch. Incompatible models fall back to Default automatically.`
`Takes effect on next model launch. OptiQ falls back to Default for unsupported architectures; other backends will error on incompatible models.`
}
Comment on lines 451 to 457

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tooltip text says “other backends will error on incompatible models”, but the Python backend selection now explicitly falls back to the default cache for RotorQuant when head_dim is not divisible by 128 (src/exo/worker/engines/mlx/cache.py). Please update this copy to reflect the actual behavior (e.g., mention RotorQuant may also fall back in some cases, while other incompatibilities still raise).

Copilot uses AI. Check for mistakes.
/>
</FieldLabel>
<Select value={kvBackend} onChange={(e) => setKvBackend(e.target.value)} disabled={!!envOverride}>
<option value="default">Default (no quantization)</option>
<option value="rotorquant_adaptive">RotorQuant Adaptive (recommended)</option>
<option value="rotorquant">RotorQuant</option>
<option value="optiq">OptiQ (rotation-based)</option>
<option value="turboquant_adaptive">TurboQuant Adaptive</option>
<option value="turboquant">TurboQuant</option>
Expand All @@ -467,7 +471,7 @@ export function SettingsPanel({ open, onClose }: SettingsPanelProps) {
{envOverride ? (
<HintText>Overridden by SKULK_KV_CACHE_BACKEND environment variable. Remove the env var to configure here.</HintText>
) : (
<HintText>Changes take effect on the next model launch. Models with incompatible architectures (GQA, non-power-of-two head_dim) will automatically fall back to default.</HintText>
<HintText>Changes take effect on the next model launch. OptiQ falls back to Default for unsupported architectures; other backends will error on incompatible models.</HintText>

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the tooltip: this hint claims only OptiQ falls back and “other backends will error”, but RotorQuant can also fall back to Default when it detects an unsupported head_dim (not divisible by 128). Align the hint text with the backend behavior.

Copilot uses AI. Check for mistakes.
)}
Comment on lines 474 to 477

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same mismatch as the tooltip: this hint says “other backends will error on incompatible models”, but RotorQuant can fall back to Default for head_dim misalignment. Please align the hint text with the backend’s real fallback/error behavior so operators know what to expect.

Copilot uses AI. Check for mistakes.
</Fieldset>

Expand Down
43 changes: 34 additions & 9 deletions src/exo/download/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,24 +181,48 @@ async def _restart_node(self) -> None:

async def _sync_config(self, config_yaml: str) -> None:
"""Write received config YAML to the local config file and
apply runtime-effective settings (e.g., KV cache backend)."""
apply runtime-effective settings (e.g., KV cache backend).

When the user set ``SKULK_KV_CACHE_BACKEND`` at launch, their
value is the source of truth. The cluster sync must not
overwrite it — neither in ``os.environ`` nor in the config
file.
"""
config_path = resolve_config_path()
try:
import yaml

raw = yaml.safe_load(config_yaml)
user_set_kv = bool(
os.environ.get("_SKULK_KV_BACKEND_USER_SET")
or os.environ.get("_EXO_KV_BACKEND_USER_SET")
)

# Preserve the user's KV backend in the config file when
# the env var was set at launch — don't let cluster sync
# clobber it.
if user_set_kv and raw and isinstance(raw, dict):
local_backend = os.environ.get("SKULK_KV_CACHE_BACKEND")
if local_backend:
Comment on lines +205 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve blank KV override when syncing config

The sync logic only preserves the local KV backend when local_backend is truthy, so an explicit empty SKULK_KV_CACHE_BACKEND is treated as if no override exists. In this codebase, blank SKULK_* values are intentionally meaningful for overriding legacy fallback behavior, so this path can overwrite inference.kv_cache_backend in skulk.yaml with the cluster value despite the launch-time override flag being set. That makes the override non-persistent across restarts and can unexpectedly switch the backend on next boot.

Useful? React with 👍 / 👎.

inference = raw.get("inference")
if isinstance(inference, dict):
inference["kv_cache_backend"] = local_backend
Comment on lines +205 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate preserved KV backend before writing synced config

When a launch-time env override is active, _sync_config copies SKULK_KV_CACHE_BACKEND directly into inference.kv_cache_backend without validating it. A typo such as rotorqunt will be persisted the next time config sync runs, and on restart load_exo_config() can fail Literal validation before the node finishes booting, requiring manual file repair. Validate local_backend against supported backends (or skip persisting invalid values) before writing YAML.

Useful? React with 👍 / 👎.

else:
raw["inference"] = {"kv_cache_backend": local_backend}
config_yaml = yaml.safe_dump(
raw, default_flow_style=False, sort_keys=False
)

config_path.write_text(config_yaml)
logger.info(
f"DownloadCoordinator: synced {config_path.name} from cluster ({len(config_yaml)} bytes)"
)
# Apply inference config to env var so next runner spawn picks it up
import yaml

raw = yaml.safe_load(config_yaml)
# Apply inference config to env var so next runner spawn picks it up
if raw and isinstance(raw, dict):
inference = raw.get("inference")
if isinstance(inference, dict) and "kv_cache_backend" in inference:
# Don't overwrite if user provided the env var at launch
if not os.environ.get(
"_SKULK_KV_BACKEND_USER_SET"
) and not os.environ.get("_EXO_KV_BACKEND_USER_SET"):
if not user_set_kv:
os.environ["SKULK_KV_CACHE_BACKEND"] = str(
inference["kv_cache_backend"]
)
Expand All @@ -210,7 +234,8 @@ async def _sync_config(self, config_yaml: str) -> None:
)
else:
logger.info(
"DownloadCoordinator: skipping KV backend update (user env var override active)"
"DownloadCoordinator: keeping user-set KV_CACHE_BACKEND="
f"{os.environ.get('SKULK_KV_CACHE_BACKEND', '?')}"
)
# Apply HF token if not user-set
hf_token = raw.get("hf_token")
Expand Down
23 changes: 19 additions & 4 deletions src/exo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
load_exo_config,
resolve_config_path,
resolve_node_staging,
update_config_field,
)
from exo.store.model_store import ModelStore
from exo.store.model_store_client import ModelStoreClient, ModelStoreDownloader
Expand Down Expand Up @@ -98,12 +99,26 @@ async def create(cls, args: "Args") -> Self:
"1" if _user_set_kv_backend else ""
) # legacy compat

# Apply inference config to env var so runner subprocesses inherit it.
# Env var takes precedence if user set it at launch.
if (
# Env var is the source of truth for KV backend. When set at launch,
# write it back to the config file so they stay in sync. When not
# set, apply the config file value to the env var so runner
# subprocesses inherit it.
if _user_set_kv_backend:
launch_backend = os.environ.get(
"SKULK_KV_CACHE_BACKEND",
os.environ.get("EXO_KV_CACHE_BACKEND", ""),
)
if launch_backend:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle blank launch backend as an explicit override

_user_set_kv_backend is true whenever SKULK_KV_CACHE_BACKEND exists, including when it is intentionally set to an empty string, but this branch only runs the sync when launch_backend is truthy. That means an explicit blank override (used in this codebase to force default behavior) is silently skipped, so skulk.yaml keeps the old backend and can be rebroadcast/reloaded later, undoing the operator’s launch-time override on restart. Treat key presence as the signal and normalize blank to default before syncing.

Useful? React with 👍 / 👎.

if update_config_field("inference", "kv_cache_backend", launch_backend):
logger.info(
Comment on lines +111 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate backend before writing it to skulk.yaml

This persists launch_backend directly from the environment into config without checking it against supported values. A typo like SKULK_KV_CACHE_BACKEND=rotorqunt is tolerated at runtime (the cache selector falls back to default), but it gets written to disk and then load_exo_config() will fail on the next restart because InferenceConfig.kv_cache_backend is a Literal[...]; that turns a temporary env mistake into a persistent startup failure until the file is manually fixed.

Useful? React with 👍 / 👎.

f"Synced launch env KV backend to config: kv_cache_backend={launch_backend}"
)
# Ensure both env vars are in sync
os.environ["SKULK_KV_CACHE_BACKEND"] = launch_backend
os.environ["EXO_KV_CACHE_BACKEND"] = launch_backend
Comment on lines +102 to +118

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node.create writes the launch env KV backend back into the YAML config file without validating it. If a user accidentally sets an invalid SKULK_KV_CACHE_BACKEND once, this can persist a bad value into skulk.yaml and make the next run (without the env var) fail config validation. Consider validating against the supported backend set before calling update_config_field (and skipping the write / logging a warning if invalid).

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh loaded config after syncing launch KV backend

When _user_set_kv_backend is true, this block writes launch_backend to disk via update_config_field(...), but the in-memory exo_config object was loaded earlier and is not updated. On store-host nodes, _broadcast_config_if_store_host() later serializes self.exo_config and writes/broadcasts that stale value, which can overwrite the just-synced backend in skulk.yaml and propagate the wrong backend to other nodes. This makes launch-time backend overrides non-persistent in clustered model-store deployments.

Useful? React with 👍 / 👎.

elif (
exo_config is not None
and exo_config.inference is not None
and not _user_set_kv_backend
):
os.environ["SKULK_KV_CACHE_BACKEND"] = exo_config.inference.kv_cache_backend
os.environ["EXO_KV_CACHE_BACKEND"] = (
Expand Down
30 changes: 29 additions & 1 deletion src/exo/store/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,38 @@ class InferenceConfig(FrozenModel):
"""

kv_cache_backend: Literal[
"default", "mlx_quantized", "turboquant", "turboquant_adaptive", "optiq"
"default",
"mlx_quantized",
"turboquant",
"turboquant_adaptive",
"optiq",
"rotorquant",
"rotorquant_adaptive",
] = "default"


def update_config_field(section: str, key: str, value: object) -> bool:
"""Update a single field in the config file, preserving all other content.

Reads the raw YAML, patches ``raw[section][key] = value``, and writes
it back. Returns ``True`` if the file was updated, ``False`` if no
config file exists.
Comment on lines +249 to +253

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring claims this function “preserv[es] all other content”, but round-tripping through PyYAML load/dump will drop comments and can reformat the file. Please either adjust the wording to reflect what is actually preserved (data only), or switch to a YAML library that can preserve formatting/comments if that guarantee is important.

Suggested change
"""Update a single field in the config file, preserving all other content.
Reads the raw YAML, patches ``raw[section][key] = value``, and writes
it back. Returns ``True`` if the file was updated, ``False`` if no
config file exists.
"""Update a single field in the config file, preserving other config data.
Reads the YAML into Python data, patches ``raw[section][key] = value``,
and writes it back. This preserves other parsed configuration values,
but comments and original formatting may be rewritten by PyYAML.
Returns ``True`` if the file was updated, ``False`` if no config file
exists.

Copilot uses AI. Check for mistakes.
"""
path = resolve_config_path()
if not path.exists():
return False
with path.open() as f:
raw: dict[str, object] = yaml.safe_load(f) or {}
sec = raw.get(section)
if not isinstance(sec, dict):
sec = {}
raw[section] = sec
sec[key] = value
with path.open("w") as f:
yaml.dump(raw, f, default_flow_style=False, sort_keys=False)
Comment on lines +265 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat config sync writes as best-effort on startup

Writing the synced KV backend uses an unconditional file write with no error handling, and the startup path calls this before the node is fully running. If skulk.yaml exists but is read-only (for example, common config-map style mounts) or the filesystem is temporarily unwritable, this raises and aborts startup even though the env var already provides a valid backend; the sync should fail gracefully instead of taking the node down.

Useful? React with 👍 / 👎.

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes the config back with yaml.dump(), which can emit non-safe YAML tags and is generally discouraged compared to yaml.safe_dump() when persisting user-editable config. Prefer safe_dump (and consider setting explicit encoding) to avoid writing Python-specific tags into skulk.yaml/exo.yaml.

Suggested change
yaml.dump(raw, f, default_flow_style=False, sort_keys=False)
yaml.safe_dump(raw, f, default_flow_style=False, sort_keys=False)

Copilot uses AI. Check for mistakes.
return True
Comment on lines +248 to +267

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_config_field assumes yaml.safe_load() returns a dict and then calls raw.get(...). If the YAML root is not a mapping (or the file is partially corrupted), this will raise and can break startup when SKULK_KV_CACHE_BACKEND is set. Consider guarding with if not isinstance(raw, dict): raw = {} before accessing .get. Also, yaml.dump can emit Python tags for non-primitive values; yaml.safe_dump is safer for config output. The docstring claim about “preserving all other content” is also misleading because load/dump will drop comments/formatting.

Copilot uses AI. Check for mistakes.


def resolve_config_path() -> Path:
"""Find the config file, preferring ``skulk.yaml`` over legacy ``exo.yaml``."""
skulk = Path("skulk.yaml")
Expand Down
Loading
Loading