The snapshot-capability gates in both load paths checked model_type ==
"qwen3_5" only, silently denying the qwen3_next family (Coder-Next,
80B-A3B) prefix caching — and, since #98 keys engine activation off
the same gate, batched decode. The qwen3_5 arch serves both
model_types with identical snapshot support (the committed parity
fixture IS qwen3_next).
Surfaced live: with max_in_flight=8 on beast, the 27B spawned its
engine but Coder-Next loaded without one (and, it turns out, has
never had prefix caching).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Activates the #98 engine for beast's TP models (27B, Coder-Next):
admission bound and engine slot count in one knob. Reverting this
section (or NEURON_BATCHING=0) restores batch-1 serialization.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
cfg(cuda)-only: the unload path moved the pool out of the (previously
owned) mutex; with TpLoadedModel.pool behind Arc for the engine's
owned guard, recover sole ownership via Arc::try_unwrap —
Arc::try_unwrap(tp) succeeding already implies the engine is idle, so
this is the expected case; the fallback (engine guard mid-release
race) unloads through the lock and lets the pool drop with its last
reference.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The engine loop now drives either serving substrate through a
BackendConfig/ActiveSession seam:
- Single (unchanged semantics): Arc'd clones of the LoadedModel
pieces; active phase holds inference_lock.
- Tp: a Weak<TpLoadedModel>, upgraded only while the batch is active —
an idle engine never keeps an unloaded model alive, and an unload
mid-batch completes when the batch drains. The active phase holds
the pool mutex (TpLoadedModel.pool is now Arc<Mutex<WorkerPool>> so
the guard can be owned), which is exactly the serialization the
per-request TP path used — vision and non-streaming requests wait
their turn as before.
Backend ops (op_prefill/op_step/op_assemble/op_extract/
op_snapshot_seq/op_drop_snap) dispatch per substrate: the TP arms
reuse restore_or_clear_tp/chunked_prefill_tp/store_prefix_snapshot_tp
verbatim and mint pool-wide snapshot ids from next_snapshot_id so all
ranks key identically.
Wiring: TpLoadedModel.engine is a OnceLock set right after the Arc is
built (the Weak needs the Arc to exist); spawn gated on the same
conditions as single-GPU (max_in_flight > 1, snapshot-capable,
NEURON_BATCHING != 0 — default config keeps everything off).
inference_tp_stream routes text streams through the engine when
present.
Engine gold test (single backend) unchanged and green; the TP arms
are cfg(cuda) — branch CUDA type-check + live smoke on beast are the
gates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
TP mirror of the batched-decode primitives, spanning all three tiers:
- arch: TpQwen3_5Model/ForCausalLM forward_batch_decode +
batch_decode_mask (same per-row batch_cos_sin rope path and
one-gap-per-row mask as single-GPU; the shared attention_context
core already handles the batched shapes).
- RPC: GenerateStepBatch (per-row tokens + broadcast geometry),
AssembleKvBatch (pool-minted snapshot ids), ExtractKvRows
(pre-minted ids so every rank keys identically); responses
KvBatchAssembled { padded_len } / KvRowsExtracted { bytes }. No
protocol version to bump — the RPC evolves by adding variants
(subprocess is spawned from the same binary; unknown ops fail as
bad_request).
- subprocess worker: handlers derive per-row positions + padding mask
locally and discard logits (the NCCL collectives are the point),
and run assemble_batch/extract_row against the rank's own shard
snapshots — the snapshot types and geometry are identical across
ranks, only head counts shard.
- leader: Job::{TpForwardLogitsBatch, TpAssembleKvBatch,
TpExtractKvRows} + dispatch handlers against the TP slab + handle
wrappers.
- pool: WorkerPool::{generate_step_batch, assemble_kv_batch,
extract_kv_rows} with the same fan-out → leader → always-drain
shape as generate_step (incl. the #17 watchdog on the batched
step); assembly asserts every rank agrees on padded_len.
Nearly all of this is cfg(cuda) — the branch CUDA type-check is the
real gate. Engine backend seam (TpLoadedModel wiring) follows in the
next commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
candle.rs:2441 sits behind cfg(cuda) — the CPU build can't see it, and
the CUDA type-check caught the Option<&Arc<Mutex<…>>> vs
Option<&Mutex<…>> mismatch introduced by the LoadedModel Arc change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Slice 3b: per-model engine task multiplexing concurrent text chat
streams through one (B,1) forward per decode step, replacing the
per-request inference_lock serialization when [admission]
max_in_flight > 1 on a snapshot-capable worker-path model.
- harness/engine.rs: engine loop (join → B=1 prefill via the existing
chunked-prefill + prefix-cache paths → snapshot → rebatch via
ExtractKvRows/AssembleKvBatch; lockstep ForwardLogitsBatch steps;
per-slot CPU sampling with per-slot LogitsProcessor + repeat-penalty
history). Per-slot router tasks own the incremental detokenizer and
the reasoning/tool-call state machine (same logic as route_token!)
and emit InferenceEvents — DecodeStream's tokenizer borrow lives
inside the router's async block, and slow consumers decouple from
the lockstep loop.
- The engine holds the model's inference_lock while it has active
slots, so vision and non-streaming requests (which keep the direct
path) still serialize safely against the batch; the lock releases
when the batch drains.
- Fatal worker errors fail all active slots, mark the model poisoned
on device-fault classification, and stop the engine (later submits
fail fast).
- LoadedModel: poisoned/inference_lock/prefix_cache/prefill_rate move
behind Arc (shared with the engine without keeping the model alive);
new engine: Option<EngineHandle> field. Engine activates only when
max_in_flight > 1 (config default 1 = existing behaviour everywhere)
and NEURON_BATCHING != 0 (kill switch).
- Prefill helpers (restore_or_clear/chunked_prefill/store_prefix/
emit_delta) ungated from cfg(cuda) so the engine and its CPU tests
compile everywhere.
Gold test: three greedy requests submitted concurrently (ragged
prompts, mid-flight joins, rebatching) produce byte-identical text,
token counts, and finish reasons to the same requests run
sequentially through the same engine, on the tiny fixture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Slice 3a: extract_row slices one row of a batched cache state back
into a contiguous single-sequence snapshot — attention KV keeps the
prefix [0, prefix_len) plus the lockstep decode columns [padded_len,
padded_len + steps) and drops the padding gap; GDN rows are sliced
whole and deep-copied. A join/leave rebatches by extracting every
surviving row and re-running assemble_batch, preserving the
one-gap-per-row invariant without any scatter kernels.
Worker side: Job::ExtractKvRows captures the live state once and
stores each extracted row in the snapshot slab; composes with
AssembleKvBatch / DropKvSnapshot for the full rebatch.
Tests: arch-level round-trip (batched decode → extract → solo B=1
continuation matches pure-sequential) and a worker end-to-end leave
path (3-row batch → extract 2 survivors → re-assemble → continued
lockstep decode matches the sequential reference).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Slice 2: AssembleKvBatch + ForwardLogitsBatch route the batched-decode
primitives through the per-device worker thread.
- AssembleKvBatch assembles stored per-sequence KV snapshots into one
batched (B,…) live state via snapshot::assemble_batch and installs
it through the existing restore path; replies the padded KV length.
- ForwardLogitsBatch runs one lockstep decode step: builds the (B,1)
input on the worker's device, derives per-row positions and the
padding mask from prefix_lens/padded_len/step, and replies one CPU
[vocab] logits row per batch row — same tensors-never-escape
contract as ForwardLogits.
- ModelArch::forward_batch_decode / batch_decode_mask dispatch (qwen3_5
only; other archs error as defence in depth).
- Dense safetensors loads now pick f32 on non-CUDA devices: candle's
CPU backend has no bf16 matmul, so the CPU fallback (and the CPU
test worker) was broken at first forward under the hardcoded BF16.
End-to-end test: tiny qwen3_next fixture loaded through the worker,
three ragged sequences prefilled+snapshotted, assembled, and every
ForwardLogitsBatch row checked against the sequential ForwardLogits
reference at each step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Slice 1 of continuous batching (F4d): arch-level batched decode with
per-row positions, assembled from per-sequence prefix snapshots.
- rope: batch_cos_sin gathers cos/sin at arbitrary per-row positions
((B,1,half)); apply_cos_sin dispatches on cos rank — rank-3 takes a
local GLM rotate-half broadcast path (rope_slow only broadcasts one
position table across the batch).
- snapshot: assemble_batch cats per-sequence KvCacheSnapshots into one
(B,…) batched state — attention K/V right-padded along the sequence
axis (keys are stored post-RoPE, so padding is position-inert), GDN
conv/recurrent states cat on dim 0. Text-only: rope_delta must be 0.
- model: forward_batch_decode((B,1), positions, mask) lockstep decode
step returning (B,1,vocab); batch_decode_mask builds the (B,1,1,S)
additive mask covering each row's padding gap [prefix_len,
padded_len), None when lengths are uniform.
Gold test: three ragged sequences decoded in one lockstep batch match
the same sequences decoded sequentially, hidden-for-hidden at every
step, on the tiny CPU fixture. Plus rope gather/uniform-equivalence
and mask-geometry units.
No serving-path changes yet — the engine loop (slice 3) and worker
job plumbing (slice 2) build on these primitives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The flash-attn feature only forwarded to candle-transformers, which
enables flash inside ITS models — candle_flash_attn was not nameable
from neuron's own attention core (E0433 in the CUDA type-check).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
F4a. The hybrid's full-attention layers computed eager
matmul→softmax→matmul with materialised GQA-repeated K/V and O(L²)
score/mask tensors — the dominant term in prefill at agentic context
sizes (measured: ~30 s TTFT at 16k tokens, the trigger for the
opencode timeout spiral).
- attention_context(): shared attention core for the single-GPU and
TP paths. With the flash-attn feature on a CUDA device in f16/bf16
it dispatches to candle_flash_attn::flash_attn — GQA native (no
repeated-K/V materialisation), causality as a kernel flag, no score
matrix. Everything else (CPU, f32, feature off) keeps the eager
fallback, byte-identical to the previous math — the qwen3_next HF
parity fixture pins this in CI.
- Chunked prefill correctness rides flash-attention v2.1+ causal
semantics (bottom-right alignment for seqlen_q != seqlen_k); the
invariant and the caller's mask contract are documented on the
helper. On-beast flash-vs-eager greedy parity is the merge gate.
- NEURON_FLASH_ATTN=0 forces eager at runtime — A/B lever + rollback.
- Feature enabled for the blackwell flavour build only (beast carries
the agentic prefill pain; ada/ampere follow once the win is
measured). CUDA type-check now covers cuda,flash-attn; timeout
raised for the first uncached kernel compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The scenario stream reader only counted `content` deltas: reasoning
models (Qwen3-Next-Thinking; Qwen3 family with thinking on) stream
reasoning_content first — for their whole budget on small-max_tokens
scenarios — so the harness saw a dead stream ("no content chunks
received"), failed the cell, and re-retried it every sweep forever
(measured=0 skipped=15 failed=27, for hours, live 2026-07-02).
Meanwhile decode_tps divided usage.completion_tokens (reasoning
INCLUSIVE) by the visible-content-only chunk window — reporting
244 tok/s for a 1.7B on a 3060 whose true rate is ~39.
- Liveness now counts any generated delta (content or
reasoning_content); a cell fails only when the stream is truly
dead. ttft_s = first generated delta — unchanged semantics for
non-thinking models (their first delta is content) and honest
engine latency for thinking models.
- decode_tps prefers the server-measured prefill/decode split (#85),
which the reader was already capturing but not using; the client
fallback divides the CHUNK count by the chunk window — one frame,
never mixed.
- Captured artifacts remain visible-content-only.
Cells are keyed by the target neuron's build SHA, so shipping this
mid-collection does not re-key the in-progress F3 rotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
AdmissionController::enter() incremented the pending + per-principal
counts, then awaited the in-flight semaphore. That await is where a
client disconnect lands: axum drops the request future, and neither
the success path nor the timeout branch's rollback ever ran — every
abandoned wait permanently leaked one pending slot and one
per-principal count.
Under a client retry storm (opencode re-sending after aborting), the
leaks ratchet pending to max_pending, after which every request gets
an instant QueueFull 429; the leaked per-principal counts alone can
pin a principal at its fair-share cap forever. Observed live
2026-07-02 during the implementation-eval runs: queue_depth stuck at 1
on an idle model, ~70ms 429s at the gateway, 36 aborted attempts and
507k prompt tokens burned against 38k generated in one 40-minute
window.
The reservation is now a RAII PendingReservation taken before the
await: admitted permits carry it for the request lifetime, and any
other exit — timeout, or the future being dropped mid-queue — rolls
the counts back in Drop. Regression test aborts two queued waiters
and asserts both the queue depth and the principal count recover.
Follow-up (separate issue): abandon chunked prefill early when the
streaming client is gone — needs a clean sentinel distinct from the
device-fault path so it never trips poison/auto-recovery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
When the chat template force-opens the think block in the generation
prompt (Qwen3-Next-80B-A3B-Thinking ends with
'<|im_start|>assistant\n<think>\n') and the generation exhausts
max_tokens before emitting </think>, the non-streaming
split_off_reasoning had no close-marker anchor and returned the whole
chain-of-thought as content. Thread the existing
prompt_opens_reasoning result (already used by the streaming state
machine) into the split: no close marker + prompt-opened block means
content is empty and every generated token counts as reasoning.
Closes#112
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The 80B-A3B-Thinking template force-opens a think block, so its
token budget goes to reasoning first — at 2048 the post-think answer
would be empty or truncated (the 27B and Coder-Next already ran to
~1930 tokens). The next neuron build SHA re-collects every model's
capability cells at 4096, giving F3 a consistent comparison set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The fused path fed f32 activations into the replicated bf16 router —
a dtype-mismatched matmul that failed the first live fused request on
the 80B and (correctly) tripped the poison/auto-recovery machinery.
Route in the original activation dtype like route_scatter does; only
the softmax and downstream weights are f32 (which the grouped-GEMM
kernel requires for topk_weights anyway).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
F1 slice 4 — replaces the correctness-first scatter loop on CUDA:
- TpExpertStore::Fused holds each projection as ONE stacked per-rank
QTensor ([E, out/ws, in]) built at load: read every expert's rank
slice, stack, ISQ the stack in a single parallel pass per
projection (~0.5 GB transient bf16 per stack at 80B dims).
- forward_fused ports candle-transformers' FusedMoeGGUF::forward onto
the per-rank stacks via candle-nn's moe_gemm_gguf grouped-GEMM
kernels: routing, index sort, and all expert GEMMs stay on-device —
three kernel launches per layer regardless of top-k, no GPU→CPU
routing sync. gate/up run unweighted (tokens×topk rows); the down
GEMM folds the routing weights in-kernel; the (tokens, topk, hidden)
view sums over topk. Output is the rank's partial; the shared expert
and single block-end AllReduce are unchanged.
- Store chosen at load: CUDA + ISQ with a kernel-supported GGML dtype
(q2k-q6k, q8_0) + GGUF block alignment on both GEMM K dims;
NEURON_MOE_FUSED=0 forces scatter as escape hatch and A/B lever.
CPU/non-cuda builds keep scatter (the CI parity anchor).
Motivation: the 80B-A3B live smoke measured 4.3 tok/s decode on the
scatter path (host routing sync + ~30 tiny GEMV launches per layer
per token) vs ~27 tok/s for the dense 27B. Target: close the ~6x gap.
On-beast A/B (fused vs NEURON_MOE_FUSED=0 scatter, greedy-token
equivalence + decode tok/s) follows this merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
git log --date=format: renders each commit's timestamp in that
commit's OWN recorded timezone. Local commits (UTC+3) and Gitea
server-side merge commits (UTC) therefore produced non-monotonic
release stamps: the 2026-07-01 bench-config commit stamped 235959
(local) while the later F1 merges stamped 2123xx/2154xx (UTC), so RPM
EVR comparison ranked the older build newest. The deploy gate
correctly detected the mismatch (it compares by buildTime) but dnf
upgrade refused the "downgrade" and silently restarted the stale
binary fleet-wide.
TZ=UTC0 + --date=format-local: pins the stamp to UTC for every
commit. Hosts currently pinned on the 235959 build self-heal on the
first build after 2026-07-02 00:00 UTC; beast is downgraded manually
(it needs the F1 #92 build for the 80B bring-up).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The post-deploy probe races real traffic by construction: a deploy
publishes a new build SHA, which is exactly what triggers helexa-bench
to re-sweep every scenario against the restarted neuron — and the
capability probes (#91) hold the batch-1 model for minutes of
generation. Admission control answers concurrent requests with 429/503
+ Retry-After (#53/#63); the old single-shot `curl -fsS` treated that
honest backpressure as deploy failure (run 694, deploy-neurons/benjy).
The probe now retries on 429/503 honoring Retry-After (min 5s,
default 15s) within a ~6-minute budget; any other error or a wrong
response still fails immediately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The CUDA type-check compiles test targets too, where
TpQwen3_5MoeBlock::load takes an NCCL Comm the tests cannot
construct (E0061). The CPU Test job runs the partial-sum parity
test; the CUDA job type-checks the cuda load/reduce variants.
Also drops an unused binding only the cuda test build flagged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
F1 slice 3 — the MoE FFN under tensor parallelism:
- TpQwen3_5MoeBlock: per-expert sharding along moe_intermediate_size
(gate/up column-sliced, down input-sliced) with forward_partial
outputs, deliberately NOT composed from RowParallelLinear — that
embeds an AllReduce per call, and top-10 routing would mean ten
collectives per layer. Partial sums from routed experts and the
shared expert add linearly, so the whole block does exactly ONE
AllReduce at the end, preserving the one-reduce-per-FFN pattern.
- Router + shared_expert_gate are replicated (tiny): every rank
computes identical top-k assignments with zero communication, via
the route_scatter helper now shared with the single-GPU block.
- TpMlpKind { Dense, Moe } decoder dispatch on layer_uses_moe,
mirroring the single-GPU MlpKind; per-rank ISQ applies to expert
slices through the existing MaybeQuantLinear path.
- TP fused-checkpoint loading: K- and V-heads shard uniformly
together, so each rank owns a contiguous span of whole k-head
groups of the fused in_proj_qkvz/in_proj_ba tensors — a uniform
dim-0 shard is exactly the rank's groups, then the per-rank
de-interleave (split_fused_qkvz/ba with per-rank head counts)
restores the contiguous [Q|K|V]+Z / B+A layout. Auto-detected;
Qwen3.6's separate-tensor path untouched.
- text_weight_prefix threaded through the TP model load (qwen3_next
keeps the text core at model.*); the slice-1 TP MoE guard removed.
Validation (CPU, runs in CI): with the block-end AllReduce elided,
rank-0 + rank-1 partial outputs at world_size=2 sum exactly to the
single-GPU block's output (which itself has exact HF parity from
slice 2) — pinning expert slicing, replicated routing, and
shared-expert partial scaling. A second test pins the per-rank
group-span de-interleave against row-slices of the full split.
The cfg(cuda) load/reduce variants are validated by the CUDA
type-check in CI; live TP-2 smoke lands with F2's checkpoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
F1 slice 2 — the MoE block itself, CPU/single-GPU:
- arch/qwen3_5/moe.rs: Qwen3_5MoeBlock — top-k router (upstream
softmax-then-topk order, renorm iff norm_topk_prob), per-expert
SwiGLU (reusing Qwen3_5MLP at moe_intermediate_size), and the
always-on shared expert mixed via sigmoid(shared_expert_gate).
Correctness-first host-side scatter dispatch; the fused grouped-GEMM
path is slice 4 behind the same forward signature.
- decoder.rs: MlpKind { Dense, Moe } dispatch on layer_uses_moe,
mirroring AttentionKind.
- linear_attn.rs: fused-checkpoint support — qwen3_next stores
in_proj_qkvz / in_proj_ba interleaved per key-head group (upstream
fix_query_key_value_ordering layout); split_fused_qkvz/ba
de-interleave once at load into the contiguous [Q|K|V] + Z / B + A
layout the forward path (incl. the conv channels) already uses.
Auto-detected via contains_tensor, so Qwen3.6 checkpoints are
untouched.
- mod.rs: text_weight_prefix() — qwen3_next checkpoints put the text
core at `model.*`, Qwen3.6 at `model.language_model.*`; the slice-1
single-GPU MoE guard is removed (TP guard stays until slice 3).
Validation:
- qwen3_next_parity integration test replays a committed tiny
random-weight HF Qwen3NextForCausalLM checkpoint (generated by
script/dump_qwen3_next_tiny.py on beast: transformers 5.9.0,
torch 2.9.1) through neuron's full load path: max_abs 0.000000,
cosine 1.00000000 at f32 — exact parity, pinning the config
normalisation, weight prefix, qkvz/ba de-interleave, hybrid layer
interleaving, and the whole MoE block against upstream.
- Unit tests: scatter forward vs per-token dense reference,
no-shared-expert/no-renorm behaviour, fused-split round-trip, and a
flat-layout end-to-end structural load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Opt in the bob bench host to the concurrency (#89) and capability-probe
(#91) scenario families so the Qwen3.6-27B baseline carries
p95-under-concurrency and scored planning-quality data before the F3
A/B decision gate (#94) needs it for comparison against the 80B-A3B
variants. Already synced to bob and live; this is the tracked record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
F1 slice 1 — config + normalization, no kernel work yet:
- TextConfig gains the MoE hyperparameters (num_experts,
num_experts_per_tok, moe_intermediate_size,
shared_expert_intermediate_size, decoder_sparse_step,
mlp_only_layers, norm_topk_prob), all defaulting to the dense case
so existing Qwen3.6 configs parse unchanged, plus layer_uses_moe()
implementing the upstream sparse-layer selection.
- Config::from_config_json normalises the flat qwen3_next layout
(Qwen3-Next-80B-A3B family) into the nested qwen3_5 shape: wraps
flat hyperparameters, nests flat rope fields into rope_parameters,
forces attn_output_gate (unconditional in upstream qwen3_next), and
derives layer_types from full_attention_interval via the upstream
(i+1) % interval convention when absent.
- All four config parse sites (dense CPU, dense CUDA worker, TP
leader, TP worker subprocess) route through from_config_json;
"qwen3_next" added to DENSE/TP_SUPPORTED_MODEL_TYPES and the
dispatch match arms.
- Explicit num_experts > 0 guards in Qwen3_5Model::load and
TpQwen3_5Model::load so a premature 80B load fails with a clear
"MoE not implemented yet (#92)" instead of a cryptic missing-tensor
error. Slices 2/3 remove them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
Hermes Agent (and the wider vLLM-convention client ecosystem) probes
/v1/models for flat context-window keys and cannot see helexa's
limit.context — it fell back to a hardcoded catalogue guess that only
matched by luck. Re-add the flat fields additively, derived from the
settled `limit` at serialization time (cortex list_models and the
router's federation aggregate), omitted when the window is genuinely
unknown. `limit` stays the opencode-oriented source of truth.
Flips the old regression guard that asserted max_model_len must not
appear — the removal it guarded was based on the wrong assumption that
the field had no consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
The hand-off brief was committed unintentionally; it belongs in the local
doc/plan/ notes folder (gitignored), not in source. Content preserved
locally at doc/plan/milestone-b-epic-84.md.
Reverts d1366614.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
Hand-off for the next session opening the Reasoning-frontier milestone
(80B-A3B MoE). Captures: the strategic rationale, decisions already made
(vision via cold-swap; variant-agnostic MoE; measure-first), what
Milestone A shipped and how to use the bench for F3, the F1–F4e todo with
dependencies, an F1 deep-dive (the qwen3_5+MoE fusion, files, HF-reference
validation), and the branch→local-CI→CUDA-gate→merge rhythm.
Docs-only; no cfg(cuda) impact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
The six perf scenarios measure speed/resources; this measures the axis
they miss — reasoning/planning quality — so the frontier A/B (F3) can
pick on capability, not just throughput.
Per the chosen approach: store the artifact always, with schema for BOTH
a manual score and a future LLM-judge; start manual.
- scenario: CapabilityScenario (capability:<name>) runs a fixed prompt
and captures the full output text (stream_and_measure gains a
capture_text path); opt-in via config.capability_probes (empty
default — long outputs, deliberate).
- store: three additive columns (artifact, quality_score, scorer);
capability_runs(unscored_only) worklist + set_score(id, score, scorer).
Drill-down RunRow omits the large artifact column.
- cli: `helexa-bench score --id <n> --score <x> [--scorer ...]` (manual);
`report --capability` (per-model median score + per-run artifact
snippets); GET /api/capability. LLM-judge deferred (schema ready).
- example config documents an implementation-planning probe.
Tests: artifact storage + scoring lifecycle, capability scenario built
from config, capability markdown (median + snippet).
Part of the Performance observability epic (#83), O7 — completes the
milestone. Feeds the F3 frontier A/B decision gate (#94).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
The vision cold-swap policy (F4e) needs real numbers for unload→reload
latency and the cold first-request after a reload — not guesses.
Adds a deliberate `swap-cost` subcommand (NOT the continuous sweep,
since unloading takes the live model offline for the reload). For each
neuron target's warm models: unload, time the synchronous reload, then
time a cold first request. Recorded under scenario "swap" so it tracks
per build like everything else.
- client: unload_model, load_model, and spec_from_info (reconstructs a
ModelSpec from /models — TP inferred from device count, quant left
None for neuron to resolve).
- sweep: Sweeper::swap_cost_once + measure_swap (build_record gains a
swap-timing param; existing sweep call sites pass None).
- scenario: cold_probe — a single timed request reusing the SSE core.
- store: two additive columns (swap_unload_ms, swap_load_ms) + a
swap_costs() pivot (reload latency + cold first-request medians).
- report: `report --swap` view + render_swap_json; GET /api/swap.
- cli: `helexa-bench swap-cost` with an explicit offline warning.
Tests: swap_costs pivot + scenario exclusion, swap markdown render.
Part of the Performance observability epic (#83), O6. Feeds the vision
cold-swap policy (#99 / F4e).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
The chat:<n> cells already capture prefill & decode tok/s per context
(via #85/#86); this pivots them into a per-(target,model) scaling curve
and computes decode-flatness — decode tok/s at the largest context ÷ the
smallest. ~1.0 confirms the Gated-DeltaNet O(1)-in-sequence-length
decode; a sharp drop locates where the model stops scaling for free.
- store: Store::scaling() pivots the latest-build chat:<n> report cells
into ScalingCurve/ScalingPoint, ordered by context, with the flatness
ratio (concurrency:<n> and other scenarios are excluded).
- report: render_scaling_markdown (one block per model: prefill/decode
tok/s vs ctx + a flatness verdict) and render_scaling_json.
- cli: `helexa-bench report --scaling` selects the view.
- api: GET /api/scaling for the bench UI.
- example config documents widening prompt_sizes into a scaling ladder.
No new request shape — reuses the chat-latency measurement points, so a
denser curve is just more prompt_sizes entries (operators widen the
ladder deliberately; large contexts cost more per sample).
Tests: scaling pivot + flatness + scenario exclusion, markdown render.
Part of the Performance observability epic (#83), O4. Validates the
long-context property that makes the 80B-A3B frontier model (#84) viable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
Batch-1 single-request timing can't characterize the real workload —
a0/hermes/opencode each fan out many agentic requests per user turn.
Add a ConcurrencyScenario (concurrency:<n>) that fires N simultaneous
streams and records, per burst: aggregate node throughput (total tokens
/ burst window), within-burst TTFT p95 (the tail under load), median
admission queue-wait — derived as TTFT − server prefill_ms (#85), no
/health poll needed — and the count of streams shed by admission
(429/503). On a batch-1 server, throughput stays flat while queue-wait
and p95 inflate with N; that gap is the evidence for/against continuous
batching (#98).
- scenario: ConcurrencyScenario + shared chat_payload helper; reuses
stream_and_measure per stream via join_all.
- config: concurrency_levels (default empty — opt-in, a burst loads the
fleet) + concurrency_prompt_tokens (512); example config documents it.
- store: four additive columns (same migration pattern); aggregate
surfaces concurrency, within-burst p95, queue-wait, rejected medians.
- report: markdown gains conc / queue ms / rej columns; JSON the full set.
Tests: median/percentile/admission-detect helpers, config builds
concurrency scenarios, store surfaces burst metrics.
Part of the Performance observability epic (#83), O5. Produces the
evidence base for continuous batching (#98 / F4d).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
Bench had no measured VRAM headroom — only the anecdotal "~2/3 used".
Sample neuron's GET /health (DeviceHealth: vram_used/free, util%, temp)
right after each measured run and record node-sum VRAM used plus the
hottest device's utilization and temperature.
- client: fetch_health (neuron only; soft None on transport failure so a
flaky /health never fails a measurement).
- sweep: HealthAgg folds a /health snapshot to node totals; sampled
post-run (the recent decode-VRAM peak) and threaded into the record.
/health is ~5s-cached neuron-side, so this is a coarse high-water proxy,
documented as such.
- store: three additive columns (same PRAGMA-guarded migration);
aggregate emits vram_used median + node vram_total (summed from the
discovery devices in gpus_json) for real headroom, plus util/temp
medians.
- report: markdown gains a "VRAM (GB)" used/total column; JSON gains the
used/total + util/temp medians.
Tests: VRAM/headroom aggregation, gpu_total_vram summation.
Part of the Performance observability epic (#83). Completes the p1-now
trio (O1-O3); unblocks the enriched-27B baseline capture on beast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
Bench reported only a single median per cell, hiding tail latency and
unable to record the server-measured prefill/decode split now emitted
on `usage.helexa_timing` (#85).
- scenario: parse `usage.helexa_timing` into ScenarioMetrics
(prefill_ms, decode_ms, prefill_tokens).
- store: persist the three columns (additive PRAGMA-guarded migration
via ensure_columns, so pre-#85 DBs backfill as NULL); aggregate now
emits p50/p95/p99 for TTFT and total (nearest-rank) plus a
prefill-tok/s median derived from the split.
- report: markdown gains prefill tok/s, TTFT p95, total p95 columns;
JSON gains p95/p99 + prefill_ms/decode_ms/prefill_tps medians.
Tests: nearest-rank percentile, idempotent backfill migration, and a
report cell asserting percentiles + prefill split.
Part of the Performance observability epic (#83). Stacked on #85.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
The TP streaming producer builds its Finish event after the 'work
labelled block exits (inside `if failure.is_none()`), but prefill_elapsed
and decode_start were declared inside that block — so the CUDA type-check
failed with E0425 (the CPU build doesn't compile this cfg(cuda) path).
Hoist `prefill_ms_measured: u32` and `decode_start: Option<Instant>`
above the block; populate them at the prefill→decode boundary inside;
read them at the terminal Finish. The worker and local producers were
unaffected (their timers already share the Finish scope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
The harness emitted only token counts on InferenceEvent::Finish; all
timing was client-side SSE arrival, so bench "TTFT" conflated tokenize
+ prefill and decode tok/s was a window estimate.
Add FinishTiming { prefill_ms, decode_ms, prefill_tokens } to the
Finish event, populated by all three streaming producers (TP, worker,
and local CPU paths), and surface it on the OpenAI chat
`usage.helexa_timing` extension so helexa-bench can compute true
prefill vs decode tok/s. cortex forwards usage verbatim, so the field
survives proxying. Non-streaming and Responses paths carry None for
now (bench reads the streaming chat path).
Keystone for the Performance observability epic (#83).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
The non-streaming responses handler hardcoded `output_tokens_details: None`,
so the reasoning sub-count that the chat path now computes (via
`split_off_reasoning`, which strips the `<think>` span and counts it into
`completion_tokens_details.reasoning_tokens`) never reached the Responses-API
usage object. The streaming responses path already emits it.
Carry `chat usage.completion_tokens_details.reasoning_tokens` through to
`ResponsesUsage.output_tokens_details.reasoning_tokens`, so streaming and
non-streaming `/v1/responses` report reasoning accounting identically.
`output_tokens` still counts every generated token (reasoning included);
`reasoning_tokens` is the additive sub-count, per OpenAI's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLyKaJVFDAYnAGiLvVrK8K
The non-streaming inference path (single-GPU `chat_completion` and the TP
`chat_completion_tp_inner`) returned the model's full decode — reasoning
preamble + `</think>` + answer — as the assistant `content`. The streaming
path already drops reasoning (emits it as ReasoningDelta, which the chat and
Responses projectors discard), so the two transports disagreed: a streaming
client saw the clean answer, a non-streaming client saw the chain-of-thought
glued to the front of it.
This is exactly what broke agent-zero v2.0 on `/v1/responses` (non-streaming):
the model produced a correct in-band JSON answer after `</think>`, but a0's
parser saw the `<think>` preamble first and rejected the turn as "misformat,
no valid tool request found". a0 v2.1 happens to tolerate the preamble, but
any strict non-streaming client (and a0 v2.0) does not — and reasoning tokens
leaking into `content` also misreport as visible output.
Add `split_off_reasoning(generated_ids, reasoning_pair)`: if the model
declares a reasoning marker pair and its close token (`</think>`) appears in
the output, return only the tokens after the last close marker as content and
count the rest as reasoning. The chat template injects the *opening* marker
into the prompt, so the generated tokens carry the close marker but not the
open one — splitting on the close-token id (not a decoded string) is robust to
tokenizer byte-fallback. Non-reasoning models, thinking-disabled requests, and
generations truncated mid-reasoning have no close token and pass through
unchanged.
Both non-streaming sites now decode only the answer span and populate
`completion_tokens_details.reasoning_tokens` (the streaming-only accounting
gap noted at the call sites, #64). Unit tests cover the strip, no-marker,
no-pair, close-at-end, and multiple-close cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLyKaJVFDAYnAGiLvVrK8K
agent-zero (via litellm) drives the OpenAI Responses API and sends
`input` items in the "easy input message" form — bare `{role, content}`
objects with NO `type` field. Our `ResponsesInputItem` is internally
tagged (`#[serde(tag="type")]`), so every such item failed the untagged
`ResponsesInput` deserialize and axum's `Json` extractor returned 422:
OpenAIException - Failed to deserialize the JSON body into the target
type: data did not match any variant of untagged enum ResponsesInput
This was a *total* failure for agent-zero (both the main model on beast
and the utility model on benjy), confirmed by on-wire capture of 15 live
requests: 36/36 input items were bare easy-messages. Other clients
(/v1/chat/completions, /v1/messages) were unaffected — only the
Responses path was exercised this strictly, for the first time.
Make `input`-item parsing match OpenAI's real tolerance, mirroring the
forward-compat `extra: Value` already at the top level of the request:
- New `ResponsesInputElement` wraps the existing typed item enum with
two more shapes: `EasyMessage { role, content }` (bare, no type;
`content` optional so an assistant turn with `content: null` parses)
and `Other(Value)` — a catch-all so a single unmodeled item can never
again 422 the whole request. The typed enum is unchanged.
- `ResponsesContentPart` gains a `#[serde(other)] Unknown` arm (e.g.
`refusal`, audio) — dropped in translation, not rejected.
- `FunctionCallOutput.output` is now `Value` (string OR array of content
parts, per OpenAI) so a structured tool result isn't lost.
- Translator handles all three element shapes; easy-messages translate
exactly like typed messages, `Other` and unknown parts are dropped.
Tests cover the bare-message, null-content, unknown-item, unknown-part,
and array-tool-output shapes, validated against the 15 captured bodies.
Tools forwarding + native function_call projection on the Responses path
is deliberately a follow-up (Round 2), gated on observing how agent-zero
consumes responses once unblocked (in-band JSON vs native tool calls).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLyKaJVFDAYnAGiLvVrK8K
Final beta-readiness pass for helexa.ai.
- BetaBanner: a slim, dismissible (session-scoped) public-beta strip above
the header, shown only when VITE_PUBLIC_BETA=true; theme-aware styling;
beta.{tag,message,dismiss} i18n keys across all languages.
- deploy/nginx.conf: edge config serving the built SPA and reverse-proxying
both backends on the SAME ORIGIN — `/` SPA history fallback, `/v1`+
`/health` → helexa-router (SSE: proxy_buffering off, 300s read), `/api/`
→ helexa-upstream `/web/v1/`. No CORS; the user's key is a first-party
bearer.
- README: a deploy section (build → /var/www → nginx) reiterating
no-server-side-chat-history.
Validated: lint, typecheck, build, i18n:check all green. Explicit-path
commit; no node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Ships the mesh authority as an RPM on the same prebuilt-binary pipeline as
helexa-bench.
- rpm/helexa-upstream-prerelease.spec: wraps the CI-built binary; installs
the systemd unit, sysusers, firewalld service (tcp/8090), and
/etc/helexa-upstream/helexa-upstream.toml (config noreplace).
- data/helexa-upstream.{service,sysusers.conf,firewalld.xml}: Type=simple
unit (serve --config), dedicated system user with a StateDirectory home,
inbound 8090 (/authz/v1 + /web/v1). PostgreSQL is reached outbound; the
schema migrates on startup.
- build-prerelease.yml: build-upstream + package-upstream jobs with
change-detection over crates/helexa-upstream/ (UPSTREAM_RE), gated into
publish. SQLX_OFFLINE=true is set defensively — helexa-upstream uses the
sqlx runtime query API (no compile-time macros), so it builds with no
database and no .sqlx cache; DB integration tests stay gated behind
UPSTREAM_TEST_DATABASE_URL.
Validated: workflow YAML parses, rpmspec expands, and
`SQLX_OFFLINE=true cargo build --release -p helexa-upstream` succeeds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Signing in upgrades the chat workspace from anonymous to account-scoped.
- Auth context exposes accountId (resolved on login and on reload for an
existing token) so the chat can scope its Dexie owner and the dashboard
can query.
- Chat: when authed, owner switches to the account id (anon history was
already re-owned via claimAnonymousData on login), the anon message cap
is lifted (budget is enforced upstream by the account allocation), the
full default model (VITE_DEFAULT_MODEL) is used, and the user's API key
is sent as the bearer.
- The bearer is the raw key the user stored locally via "use for chat on
this device" in the key-creation modal (client-side only — consistent
with no server-side secrets). Signed in without a stored key → a banner
prompts creating/enabling one (sending is disabled until then).
- Error mapping: insufficient_quota → top-up link (/account) when authed,
sign-up (/register) when anon; rate_limit_exceeded → a wait-and-retry
hint; both distinct from generic errors.
- New chat (topUp/rateLimited/needsKey/manageKeysLink) and account
(keys.useForChat/usedForChat) i18n keys across all languages.
Validated: lint, typecheck, build, i18n:check all green. Explicit-path
commit; no node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Operators are now metered for the tokens they serve on behalf of mesh
accounts, and the upstream rolls that up for compensation.
cortex-gateway:
- served_usage.rs: an in-process per-(account,key,UTC-day) served-token
counter, incremented in metering::usage_sink alongside spend/settle for
every authenticated request. A flush task (spawned in run() when
[upstream].enabled, mirroring poller/evictor) POSTs ABSOLUTE cumulative
counters to upstream on [upstream].served_usage_report_interval_secs.
- The no-limit infra key is the operator's local key with hard_cap=None
(already supported) — it's metered for served-usage but never budget-
refused and never hits upstream.
helexa-upstream:
- POST /authz/v1/served-usage: upserts rows keyed by (operator_id from the
client bearer, account, key, period) with
GREATEST(existing, incoming) — monotonic + idempotent, so re-sends,
races, and a restarted cortex's lower counter never regress the total.
- reconcile.rs + `helexa-upstream reconcile` CLI: rolls up unreconciled
served_usage per operator/period (SUM::bigint), stamps reconciled_at,
prints the totals. Payout mechanism out of scope.
Validated against a throwaway Postgres: monotonic upsert (100→250, a 50
re-send stays 250, same-value idempotent) and reconcile rollup +
stamp-once; cortex counter unit test for per-principal accumulation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The self-service account surface consuming helexa-upstream's /web/v1 (B4/B5),
with a mock so it works before/independent of the live backend.
- api/types.ts + api/account.ts: typed AccountApi over a same-origin `/api`
prefix (vite-proxied in dev, nginx in prod) covering register/verify/
login/password-reset/keys(list,create,archive,limit)/account/redeem;
ApiError carries the backend code. MockAccountApi behind
VITE_USE_MOCK_ACCOUNT_API (in-memory account, raw-key-once, redeem).
- auth/: context + useAuth, AuthProvider (JWT in localStorage, login fetches
the account and runs claimAnonymousData → anon IndexedDB history is
re-owned to the account, still client-side), RequireAuth guard
(→ /login?next=).
- pages/auth/: Login, Register (sends the FingerprintJS visitor id →
triggers the silent abuse detection), VerifyEmail (?token), RequestReset,
ResetPassword (?token, matches the backend /reset?token= link).
- pages/account/: Dashboard (allocation balance + usage bar, redeem top-up,
logout) and ApiKeys (list, create-modal showing the raw key ONCE with
copy, per-key limit editor percent↔hardcap, archive). 401 → logout.
- App wraps AuthProvider + routes (account guarded); Header auth cluster
reflects useAuth (Account/Sign out vs Sign in/up).
- `account` i18n namespace (53 keys) added + wired across all 32 langs.
Validated: lint, typecheck, build, i18n:check, lang-labels all green.
Explicit-path commit; no node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The beta centerpiece: a chat workspace at `/` with zero server-side
history. Everything personal lives in the browser (Dexie/IndexedDB);
inference streams from the mesh router.
- data/db.ts: Dexie schema (projects, conversations[owner:'anon'|accountId],
messages, meta) — `owner` namespaces anonymous vs account data;
claimAnonymousData() (repositories) re-owns anon data on login (F4),
still client-side.
- data/repositories.ts: typed CRUD + ordered queries (projects,
conversations, messages) used reactively via useLiveQuery.
- lib/fingerprint.ts: FingerprintJS OSS, cached in meta (best-effort, never
auth) — namespaces anon data + a soft throttle id.
- lib/chatClient.ts: streamChatCompletion → POST /v1/chat/completions
stream:true; parses the SSE byte stream incrementally (data:/[DONE]/
partial frames), surfaces the OpenAI envelope error.code, AbortController
for Stop.
- lib/useChat.ts: persists the user turn, opens a streaming assistant
message, appends deltas to Dexie live, titles the conversation, finalizes
on done/error.
- pages/Chat.tsx: sidebar (new chat / new project, conversations grouped by
project + Unsorted), live-updating thread with streaming + error
rendering, composer with send/stop. Anonymous mode: no bearer +
VITE_ANON_MODEL + a client message cap with a sign-up nudge. Routed at `/`.
- chat i18n namespace extended (newChat/newProject/unsorted/emptyState/
anonBanner/signUp/stop) across all 33 languages (parity holds).
Validated: npm run lint, typecheck, build all green; i18n:check consistent.
(Full browser flow exercised in F6 verify.) Explicit-path commit; no
node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The secondary narrative page, re-focused (vs the reference placeholder) to
lead with European digital sovereignty.
- Renamed the `home` i18n namespace → `mission` (32 langs' home.json →
mission.json; updated i18n/index.ts imports + ns list and the
check-i18n NAMESPACES). The reference's narrative content was already
translated, so the non-English languages carry over as mission copy.
- Rewrote en/mission.json to lead with sovereignty: data residency, a
GDPR-native no-server-side-history stance, EU operator ownership,
region-affine routing, and independence from US hyperscalers — same key
structure, so cross-language parity holds.
- src/pages/Mission.tsx ported from the reference Home page (sections:
hero/intent/whyNow/howItWorks/principles/roadAhead/joinMesh) bound to the
`mission` namespace; routed at /mission in App.tsx (the Header link from
F1 now resolves).
Validated: npm run lint, typecheck, build all green; i18n:check (mission
namespace consistent across all languages) and i18n:lang-labels pass.
Explicit-path commit; no node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Ports the reference site's visual + i18n foundation into helexa.ai and
adds the deliberate usage-ordered language picker.
- Ported from ~/git/helexa-ai/helexa.ai: src/layout (ThemeProvider/theme,
localStorage + data-theme, light/dark), src/App.css (cyan/hot-pink accents,
system fonts), src/i18n (index + languages + translation-priority +
resources for 33 languages × common/home/chat), Footer, DirectionalIcon,
public assets, and the check-i18n-* scripts.
- getLanguageOptionsByUsage() (in translation-priority.ts): orders the
selector by the TRANSLATION_PRIORITY ranking (≈ native-speaker usage),
deduping repeated entries and appending any unranked supported language —
NOT alphabetical, the marketing-driven choice that foregrounds helexa's
international grounding. RTL preserved.
- Header: usage-ordered language dropdown (autonym + secondary label in the
current language), theme toggle, and new nav — `/` (chat), `/mission`,
and a Login/Register auth cluster stubbed until F4. New nav keys
(mission/login/register/account/logout) injected into all 33 common.json
with English placeholders so key-parity holds.
- App composes ThemeProvider → BrowserRouter → Header + routes + Footer
(placeholders for `/` and `/mission`); main.tsx loads i18n.
Validated: npm run lint, typecheck, build all green; npm run i18n:check
reports all keys consistent across the 33 languages. (Build emits a
chunk-size advisory — code-splitting is deferred to F6 polish.) Staged with
explicit paths; no node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Completes the hybrid allocation model — the flat free grant (B4) plus
top-up codes that extend an account's allocation.
- topup.rs: redeem(account, raw_code) — timing-safe, single-use. A
conditional `UPDATE … WHERE redeemed_by IS NULL RETURNING value` claims
the code atomically (concurrent double-redeem → exactly one winner), then
raises accounts.allocation_total in the same tx. Only sha256(code) is
stored; a not-found code and an already-redeemed code return the SAME
generic error via the same path (no "valid but spent" oracle). Raising
the total automatically lifts every percent-limited key's effective cap;
hardcap keys stay pinned (by design).
- mint(value, count, denomination) — inserts codes (hash-only), returns the
raw codes once. Exposed as `helexa-upstream mint --value --count
[--denomination]` (raw codes to stdout, one per line) — the seam the
future faucet bot calls. Bot itself out of scope.
- POST /web/v1/redeem (session-protected) → {allocation_total} | generic 400.
Validated against Postgres: redeem raises the 1_000_000 free grant to
1_500_000, second redemption + unknown code both generic-400, and a
concurrent race for one code yields exactly one HTTP 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
cortex can now validate locally-unrecognised bearer keys against the
helexa-upstream authority and reserve/settle their budget there — mesh
accounts work for real inference. The EntitlementProvider trait is the
seam, so cortex's enforcement (auth.rs, metering.rs) is otherwise
unchanged.
- entitlements_upstream.rs: UpstreamEntitlementProvider over reqwest →
B2's /authz/v1 (resolve/reserve/settle/release/snapshot), presenting the
operator client bearer. Maps the wire contract back to the trait: granted
→ Reservation, rejected → BudgetError, 401 → InvalidKey. Fail-closed —
unreachable resolve → AuthError::Unavailable (503, never 401);
unreachable reserve → retryable BudgetError::RateLimited (refuse, never
serve un-authorized). settle/release are best-effort (the upstream
sweeper reaps a lost one).
- entitlements_chain.rs: ChainedEntitlementProvider tries local first
(operator + infra keys, no network), falls through to upstream for
unknown keys, and dispatches reserve/settle/release/snapshot to whichever
backend resolved each account (local treats unknown principals as
uncapped, so it can't be the blind default).
- cortex-core: AuthError::Unavailable{retry_after_secs}; [upstream] config
(enabled/url/bearer/timeout). auth.rs maps Unavailable → 503 +
Retry-After distinctly from InvalidKey → 401, regardless of require_auth.
- state.rs wires the chain when [upstream].enabled, else stays purely local.
Tests (upstream_chain.rs, 4): local key resolves without touching upstream;
unknown key falls through to a mock upstream; unknown-everywhere → 401
InvalidKey; upstream-unreachable → Unavailable (503-mapped), with local keys
still resolving. Existing gateway suites updated for the new config field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The human-facing account surface the helexa.ai frontend (F4) consumes,
on top of B2's authz surface. Email+password auth with JWT sessions
(distinct from inference API keys); plain-JSON errors (the #63 envelope
stays on the authz surface).
- Auth lifecycle: register → email-verify → login → password-reset
(request/confirm). argon2id passwords; verify/reset via single-use
sha256-hashed email tokens; register and reset-request always return
202 (no account enumeration). Email via a pluggable EmailSender (lettre
Smtp + dev Log transport).
- API keys: create (sk-helexa-<base62(32 OsRng)>, raw shown once, stored
as sha256 + non-secret prefix), list (prefix never the secret), archive,
PATCH per-key limit (percent|hardcap). Protected by a JWT session
middleware.
- Account balance endpoint (allocation total/spent/reserved).
- Silent fingerprint abuse: register captures the browser fingerprint;
>= threshold (default 5) accounts sharing one fingerprint are silently
deactivated + flagged — registration still returns a normal 202, and a
deactivated account's key resolves as an ordinary 401 at the authz
surface (no "banned" signal anywhere).
- crypto: argon2 hash/verify + CSPRNG token/key minting (base62). config
gains [auth] + [email]. CORS on the app for the browser SPA.
Validated against a throwaway Postgres 16: verify-once, full lifecycle
(register→verify→login→create key→account→list→authz resolve→archive→401),
and 5-same-fingerprint → all accounts silently deactivated + no-clue 401.
8 unit + 11 gated integration tests; all skip cleanly without
UPSTREAM_TEST_DATABASE_URL so CI stays green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The B2/B4 branches were cut before F0 added the helexa.ai .gitignore
entries, so a 'git add -A' on those branches swept node_modules into the
commit; it rode into main via the B2 merge. Untrack it (it stays on disk,
now correctly ignored). History still carries the blobs — acceptable for
an internal repo; can gc/filter later if size matters.
The machine surface cortex's UpstreamEntitlementProvider (#57) consumes,
mirroring the EntitlementProvider trait 1:1 over the B1 ledger.
- `authz.rs`: POST /authz/v1/{resolve,reserve,settle,release,snapshot}.
resolve → {principal, snapshot} | 401 invalid_api_key (a deactivated
account resolves as the SAME 401 — the silent-abuse no-clue property).
reserve returns 200 whether granted ({reservation_id}) or budget-refused
({rejected:{kind,...}}) — a refusal is an authoritative answer, not a
transport failure; non-2xx means "fail closed" to the client. settle/
release → 204 (idempotent). snapshot → {hard_cap,spent,reserved} | 404.
Rejections use the shared #63 OpenAiError envelope (cortex-core dep).
- Client auth: shared-bearer middleware (constant-time compare via subtle)
maps a token → operator_id (stamped into request extensions for #58
served-usage); empty config = open dev surface (logged). mTLS deferred.
- ledger gains resolve_key (sha256 lookup, account-active-gated), snapshot,
and sweep_stale (one data-modifying-CTE statement releasing aged-out open
reservations and folding their reserved tokens back into accounts+keys).
- Sweeper task spawned in run(); [authz] ttl/interval + [client_auth]
config; crypto::sha256 helper.
Validated against a throwaway Postgres 16 (fresh schema): resolve→reserve→
settle→snapshot round-trip, over-cap → 200 insufficient_quota rejection
(not retried away), deactivated account → 401 (no clue), missing/wrong
client bearer → 401 before any DB hit. 5 unit + 8 gated integration tests;
all skip cleanly without UPSTREAM_TEST_DATABASE_URL so CI stays green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
New top-level `helexa.ai/` app for the public beta — Vite + React (SWC) +
TypeScript + react-bootstrap + react-router + react-i18next-ready. Not a
Cargo crate; lives beside the workspace.
- Vite with @vitejs/plugin-react-swc (standard Vite + npm, not the
reference's rolldown/pnpm pin). `vite.config.ts` dev-proxies the mesh
data-plane (/v1, /health → helexa-router) and account control-plane
(/api → helexa-upstream /web/v1) same-origin, targets overridable via
VITE_ROUTER_BASE_URL / VITE_ACCOUNT_BASE_URL.
- tsconfig (app/node, ported from the reference), eslint flat config,
minimal index.css reset + bootstrap CSS, a placeholder App shell.
- Deps pre-declared for later phases: dexie + dexie-react-hooks (IndexedDB
chat history), @fingerprintjs/fingerprintjs (anon throttle + register
fingerprint), i18next/react-i18next, react-icons.
- Monorepo: root .gitignore ignores helexa.ai/{node_modules,dist} +
.env.local (mirrors the existing /bench entries); committed
package-lock.json for reproducible installs.
Validated: npm install resolves (vite 7 + plugin-react-swc 4 + react 19),
`npm run lint`/`typecheck`/`build` all green (344 modules via SWC →
dist/). The frontend isn't in the Cargo workspace, so the Rust CI is
unaffected. A path-filtered web CI job is deferred (needs a Node-capable
runner confirmed) and folded into a later phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
First milestone of the mesh-level account/authorization authority (#59).
New workspace crate `crates/helexa-upstream` (binary + lib), mirroring the
helexa-router skeleton: axum `build_app`/`run`, clap `serve --config`,
figment `UPSTREAM_`-prefixed config.
- **Storage:** PostgreSQL via sqlx (runtime query API — builds in CI with no
DB or offline cache; correctness covered by gated integration tests).
`migrations/0001_init.sql` is the full schema: users (argon2 hash slot,
email_verified, registration_fingerprint), email_tokens, accounts
(allocation total/spent/reserved + `accounts_no_overshoot` CHECK, silent
`status` deactivation flag, fingerprint_flagged), api_keys (sha256 hash,
percent|hardcap limit, cap_window, per-key ledger), reservations
(BIGSERIAL id → maps to cortex Reservation.id u64), top_up_codes,
served_usage, sessions. Migrations run on startup.
- **Ledger (no-overshoot core):** `ledger::{reserve,settle,release}` —
reserve takes `SELECT … FOR UPDATE` on the account + key rows so
concurrent reserves serialize and spent+reserved can never exceed the
effective cap (= min(resolved key cap, remaining account allocation));
the CHECK is the DB backstop. Settle clamps actual to [0,reserved] and is
idempotent; release is idempotent. `resolve_abs_cap` (percent/hardcap,
i128 math) is pure + unit-tested. Balance semantics here; rolling-window
sub-caps + RateLimited land with the authz API (B2).
- `/health` does a DB round-trip.
- Config + example TOML ([server]/[db]/[grant] free grant/[abuse]
fingerprint threshold).
Validated end-to-end against a throwaway Postgres 16: migration applies,
20 concurrent reserves of 100 against a 500 cap admit exactly 5 (reserved
== 500, never over), settle/release idempotent, hardcap key sub-cap binds
below the account, `/health` → db ok. CI runs the cap-math + config unit
tests; the DB integration tests skip cleanly when UPSTREAM_TEST_DATABASE_URL
is unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The router is a TLS client to cortexes; the router->cortex hop crosses
the helexa->operator boundary carrying the client's bearer. This pins
that hop to an enrolled cert.
Trust mechanism (the open question): per-cortex enrolled trust anchor.
Each [[cortexes]] entry gets an optional `tls_ca` — a PEM CA (or
self-signed cert) the cortex's TLS cert must chain to. When set, the
router builds a client that trusts ONLY that anchor (platform roots
disabled), so the cortex must present the expected cert and a rogue
endpoint with any other (even publicly-valid) cert is rejected at the
handshake. Enrolment = the operator hands helexa the cortex's cert,
referenced by path in router config. This is the natural model for
self-hosted operators behind their own nginx/private CA, and reuses the
reqwest public API (no custom rustls verifier, no new TLS backend).
- `RouterState` now holds a per-cortex `reqwest::Client` map
(`client_for`), replacing the single shared client; poller and dispatch
use the per-cortex client. `build_client(tls_ca)` is the builder.
- Fail closed: a `tls_ca` that can't load omits the cortex from the
client map — it's never polled or routed to, rather than silently
degrading to unpinned TLS. The poller treats a missing client (and a
rejected handshake) as a failed poll, so #72's existing reachability
debounce excludes it.
Tests (`tls.rs`, 4): a live tokio-rustls HTTPS server proves a client
enrolled with the server's cert is accepted (200) while clients pinned to
a different cert — or using default roots — are rejected; the poller
marks a wrong-cert cortex unreachable while a correctly-enrolled one is
reachable; a missing pin file disables the cortex (fail closed); garbage
PEM is rejected at build. Existing suites updated for the per-cortex
client + new config field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The router's /v1/models is now the deduped union of every reachable
cortex's catalogue, so an opencode client doing discovery against the
router resolves the whole federation without knowing about operators or
cortexes (resolves#61's "Router/discovery contract").
To preserve per-model limit/cost, the topology poller now retains each
cortex's full `cortex_core::node::CortexModelEntry` (was distilled to a
{loaded, feasible} bool). `entry_feasible()` replaces the dropped field;
dispatch (#73) and `cortexes_serving` use it — no routing behaviour
change.
`catalogue.rs::aggregate_models`:
- Dedupe by model id; a model served by >=1 reachable cortex appears once.
- Merge availability: `loaded` OR across operators; only feasible
(loaded-or-cold-loadable) entries surface — a catalogue-only model no
neuron can host is hidden.
- Re-tier to operator names: `feasible_on` becomes the cortexes that can
serve it and `locations` the operators it's loaded on (node = cortex
name), so the federation view doesn't leak each operator's neuron names
or per-device VRAM.
- Conflict resolution: `limit` → tightest (smallest context, so a client
never overflows the most-constrained operator); `cost` → cheapest
(the federation "from" price). Richer range/region policy couples to
#68, noted as follow-up.
Tests: 4 unit (dedupe+merge, unreachable excluded, infeasible hidden,
tightest-limit+cheapest-cost) + 1 end-to-end (two mock cortexes
overlapping on a model → GET /v1/models over HTTP asserts the merged
union). dispatch/topology suites updated for the entry-storage change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The router's data path. Wires the topology poller (#72) and the shared
streaming proxy (#71) into real request routing.
- `dispatch.rs`: `select_cortexes(model)` ranks reachable cortexes that
can serve the model, best-first — loaded/warm before cold-loadable,
region match before not, more healthy nodes before fewer, name for
determinism. `dispatch()` extracts `model`, picks candidates, and
forwards via `helexa_stream::forward_streaming` (bearer + bytes
verbatim, SSE streamed back). Cortex's #63 rejections (429/400/…) pass
through untouched; transport failures fail over to the next candidate;
a genuine HTTP response — any status — is returned as-is, never retried
away.
- Router-originated rejections use the #63 envelope: 404 model_not_found
(no operator serves it), 503 service_unavailable + Retry-After (known
but all unreachable / all candidates failed to connect), 400
missing_model_field. `error.rs` is the router's envelope→axum adapter
(mirrors cortex-gateway's).
- `handlers.rs`: `/v1/chat/completions`, `/v1/completions`,
`/v1/responses`, `/v1/messages` dispatch to the same path on a chosen
cortex. The router holds zero entitlement logic — routes on capacity,
not budget.
- Config: optional `region` on the router and per-cortex for geo affinity.
Tests (`dispatch.rs`): routes to a serving cortex + forwards the bearer;
cortex 429 passes through and is NOT retried; transport failure fails
over to a live cortex; unknown→404, known-but-unreachable→503,
missing-model→400; ranking order (warm/region/headroom). 7 new, existing
skeleton/topology suites unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Builds the live topology the dispatcher (#73) will route on — the same
pattern as cortex↔neuron, one tier up.
- `poller.rs`: background loop polls each configured cortex's
`GET /v1/models` (deserialised straight into the shared
`cortex_core::node::CortexModelEntry`) and `GET /health`, on a
configurable `poll_interval_secs` (default 10).
- `state.rs`: `RouterState` gains an `http_client`, `poll_interval`, and a
`RwLock<HashMap<cortex_name, CortexTopology>>` pre-populated from config
so the poller/handlers always find an entry. Per cortex: `reachable`,
`consecutive_failures`, `last_poll`, healthy/total node counts, and a
per-model `{loaded, feasible}` map (feasible = loaded OR cortex reports
`feasible_on`, i.e. cold-loadable). `cortexes_serving(model)` returns the
reachable cortexes that can serve a model — groundwork for #73.
- Debounce: a cortex flips unreachable only after
`POLL_FAILURE_THRESHOLD` (3) consecutive failed polls, and recovers on
the next good poll — mirrors cortex's neuron-poll debounce so a blip
can't yank a whole operator out of routing. `/health` poll is
best-effort and never flips reachability on its own.
- `lib.rs` spawns the poll loop in `run()`. `/health` now surfaces
`cortexes.reachable`; `status` stays router-liveness (always `ok`).
Tests (`topology.rs`): live-map build (loaded vs catalogue-only feasible,
node counts, routing helper); unreachable→excluded→recovers across the
debounce threshold; dead endpoint never panics. Skeleton tests unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
The true-streaming SSE passthrough (Body::from_stream, no full-response
buffering, with chunk-observation hooks) was cortex-only. helexa-router
(#69) needs the same mechanism to proxy a chat-completions/messages
stream verbatim to a selected cortex. Extract it once.
New `crates/helexa-stream` owns the *mechanism* (kept HTTP-free
cortex-core untouched — it would have forced axum/reqwest/futures onto
every cortex-core consumer):
- `forward_streaming(client, url, headers, body, observer)` — POST and
stream the response back chunk-for-chunk; status-agnostic, so a
non-2xx (e.g. cortex 429) is passed through with status+headers
intact (the #69 backpressure-passthrough requirement).
- `ChunkObserver` trait + `ObservedStream` wrapper — feeds each chunk to
the observer, calls `finish` exactly once on clean end or on drop
(client disconnect).
- `BodyTail` (bounded tail accumulator) + `last_count_for` (trailing
OpenAI `usage` extraction) — the reusable pieces an observer uses.
cortex keeps its *policy*: `proxy.rs` now supplies a `CortexMetrics`
observer (per-request token metrics + per-principal reservation settle),
its logging contract, and the error envelope, driving the shared
mechanism. `proxy::last_count_for` is re-exported so `handlers`/
`anthropic_sse` call sites are unchanged. No behaviour change — the
existing cortex `streaming.rs` tests pass as-is.
helexa-stream tests prove chunk-for-chunk incremental delivery, observer
finish-once, usage extraction, and non-2xx passthrough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Foundation for epic #69 (public multi-operator ingress proxy). New
`crates/helexa-router` workspace binary: a plaintext axum server that
reuses cortex-core types and serves the two stub endpoints the rest of
#69 builds on.
- `[router] listen` + `[[cortexes]]` config via figment + `HELEXA_ROUTER_`
env overrides, matching the cortex/neuron convention.
- `GET /health` reports the configured downstream cortex count.
- `GET /v1/models` returns an empty OpenAI list (real cross-operator
aggregation is #75).
- No inbound TLS listener (edge nginx terminates client TLS per #69's
posture); no auth layer — the router forwards the client bearer to
cortex and holds zero entitlement logic (#47 stays additive).
- 3 tests: both endpoints over a real ephemeral-port server, plus
TOML+env config load.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
Capture the development loop in-repo so it's available to any session, not
just via personal agent memory: feature branch per change; local CI triad is
CPU-only so the branch CI's CUDA type-check is the real gate for neuron/TP
changes; push on local-green and background-watch; merge when the four
validation jobs are green (not the SRPM/COPR deploy jobs); docs-only changes
can go straight to main. Notes the core.sshCommand key-pinning gotcha.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5aNfNzS2fSZ5wnMeSQ9Wg
Closes#68. Documents ModelCost as the source-of-truth pricing field
(USD per 1M tokens, JSON numbers — models.dev/opencode shape), defines the
absent-vs-0.0 distinction (not-priced vs intentionally-free), adds a wire
test locking it, and documents cost.* in models.example.toml. The cost code
path already existed; this pins the contract. Branch CI green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5aNfNzS2fSZ5wnMeSQ9Wg
The cost code path already exists (cortex list_models populates
cost: profile.cost from the catalogue; aliases inherit it), so opencode's
$0.00 is a config gap (no cost in the live models.toml), not missing
plumbing. What was missing is the *contract*: units pinned against a wire
test, and a defined meaning for "free".
- Document ModelCost as the load-bearing source of truth: USD per 1,000,000
tokens as JSON numbers (models.dev/opencode shape) — NOT per-token, NOT
decimal strings (OpenRouter's pricing shape, which helexa deliberately
does not emit). Define the absent-vs-zero distinction: cost omitted = "not
priced / unknown"; cost present with 0.0 = "intentionally free". Note the
advertised rate must equal what metering (#51) / reconciliation (#58/#59)
bill against — today both read this catalogue value.
- New wire test (model_cost.rs): a priced model with cache tiers flows
through as per-million numbers; an explicit-0.0 free model keeps its cost
block with cache tiers omitted; an unpriced model omits `cost` entirely.
- models.example.toml: document cost.* in the field reference and show all
three cases (priced-free explicit 0.0 vs the unpriced Qwen3-8B with no
cost block).
Decisions recorded on #68: source of truth = operator models.toml for now
(marketplace clearing house #59 later, same value); no OpenRouter-style
`pricing` (opencode/models.dev alignment is sufficient); end-to-end
non-zero $ spent needs operators to populate cost in the live catalogue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5aNfNzS2fSZ5wnMeSQ9Wg
Closes#65. Gives the text prefill path a request-time, length-aware
VRAM guard (reusing #67's ContextProfile KV cost against current free
VRAM), closing the poll-vs-request snapshot staleness gap and the
vision/text asymmetry. Branch CI green (fmt, clippy, test, CUDA type-check).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5aNfNzS2fSZ5wnMeSQ9Wg
Close the poll-vs-request snapshot gap #67 left open. The text prefill
guard (validate_request) only checked the static min_free_vram floor;
the derived input cap (effective_prompt_cap) is computed at /models poll
time from the tightest card's free VRAM *then*. If free VRAM drops
between that poll and the request — a co-resident model loads, a
concurrent prefill grows its KV — a prompt at-or-below the now-stale cap
clears the floor yet no longer fits, OOMing mid-prefill and poisoning the
device context (the 2026-05-26 beast incident #47 exists to eliminate).
validate_request now re-runs #67's length×KV-vs-VRAM physics against
request-time free VRAM, reusing the model's ContextProfile
(kv_bytes_per_token_per_card, full-attention-layer-only, TP-sharded)
rather than re-deriving the cost. Footprint = KV(prompt + output_reserve)
+ activation_headroom + static floor, all per card and commensurable with
the tightest-card free VRAM on both single-GPU and TP loads. Degenerate
zero-KV / no-profile models ride the existing floor check, mirroring
derive_limit's VRAM-ceiling fallback; CPU loads (vram_free_mb == 0) skip
all VRAM checks unchanged.
This closes the vision/text asymmetry: the text path now has the
live-VRAM guard validate_vision_prefill already gave the vision path.
5 unit tests incl. the acceptance staleness test: a cap derived against
ample free VRAM, applied at request time against tightened VRAM, rejects
a prompt sized at the stale cap with a clean InsufficientVram (503)
instead of an OOM. Threaded context_limit_cfg into chat_completion_tp_inner
(spawned, no &self) and used &self.context_limit_cfg at the three method
call sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5aNfNzS2fSZ5wnMeSQ9Wg
Defense-in-depth for the agent0 NoFeasibleNeuron storm (root cause fixed in
neuron). Two cortex resilience gaps this incident exposed:
1. Brittle health flip: the poller marked a node unhealthy on a SINGLE missed
/models poll, instantly yanking the node and all its models from routing.
A busy neuron briefly slow to answer shouldn't be declared dead. Now
debounced: NodeState.consecutive_poll_failures must reach
POLL_FAILURE_THRESHOLD (3) before the node flips unhealthy (~20s at the 10s
poll interval); any successful poll resets it. A never-healthy node stays
unhealthy (the counter only protects an already-healthy node from blips).
2. Transient surfaced as permanent: when a catalogued model's only feasible
neuron is momentarily unhealthy, the router returned 404 NoFeasibleNeuron —
which litellm/clients treat as non-retryable, so agent0 hard-failed.
pick_feasible_neuron now distinguishes "a feasible node exists but is
unhealthy right now" → new RouteError::FeasibleNodeUnhealthy (503 +
Retry-After: 3, retryable) from "no node could ever satisfy the topology" →
404 NoFeasibleNeuron (permanent). Mirrors the beast case exactly: healthy
1-GPU nodes + an unhealthy 2-GPU node → retry, don't fail.
Tests: poller test updated to assert debounce (1 miss keeps healthy, 3 flip);
new feasibility_routing tests cover transient-503 vs permanent-404. Local
fmt/clippy/test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the agent0 `NoFeasibleNeuron` 404 storm: `GET /models` →
`LoadedHandle::derived_limit` (#67) queried free VRAM *synchronously through
the per-device worker thread* on every poll. During inference that worker is
saturated serially processing forward jobs, so the VRAM query queued behind
them and `/models` blocked for seconds. cortex's poller timed out on `/models`,
marked the (sole-feasible) node unhealthy, and the model fell out of routing →
404. Confirmed live: under load, `/version` and `/health` stayed ~4ms while
`/models` hit the 5s timeout.
Fix — the HTTP control plane never touches the inference worker:
- LoadedModel / TpLoadedModel gain `last_free_mb: AtomicU64`, a cached free-VRAM
reading.
- `derived_limit` is now sync and reads `last_free_mb` instead of awaiting a
worker query — so `/models` is a pure cache read regardless of inference load.
- The cache is refreshed off the request path: seeded at load (worker idle),
then by a background `vram_cache_refresh_loop` every 5s. Single-GPU caches the
device's free VRAM; TP caches the tightest free across ranks — the exact
values `derived_limit` used before, just no longer on the request path. A
transient `0` (worker gone/poisoned) never clobbers a good cached value.
- The request-path live VRAM check in `validate_request` is unchanged, so the
real prefill OOM guard still uses fresh readings.
226 neuron unit tests pass; non-CUDA build + fmt + clippy green. CUDA/TP paths
validated by branch CI; live acceptance = `/models` stays responsive under
concurrent inference (re-run of the repro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression from #49: the auth middleware rejected ANY present-but-
unresolvable bearer token with 401 invalid_api_key, even when
require_auth=false. But OpenAI-compatible clients (opencode, Open WebUI,
Agent Zero, litellm) send a placeholder bearer by default — so enabling
the build broke every existing client even though the operator never
opted into auth. Pre-#49 the bearer was never inspected at all.
Fix: in allow-anonymous mode (require_auth=false, the default) an
unrecognized key is now ignored and the request is served anonymously,
restoring pre-#49 behaviour. A bad key only 401s when require_auth=true.
A valid key is still resolved + metered in both modes.
Test renamed/split: unrecognized_key_is_ignored_when_auth_not_required
(now 200, served anonymously) + invalid_key_is_401_when_auth_required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 3 (DX): A0 burned an hour then failed deep in litellm with
prompt_too_long (35544 > 32768). cortex knows each model's real context
window (#62/#67) and can pre-empt that at the edge.
- Pre-validate the prompt against the model's advertised limit.context
before dispatch (in proxy_with_metrics, covering chat/completions/
responses). Over → 400 context_length_exceeded in the #60 envelope — the
same shape neuron emits on overflow, just earlier and without burning a
cold-load/queue slot. cortex has no tokenizer, so estimate_prompt_tokens
under-counts (~4 chars/token over message text); neuron stays the exact
wall and we only catch gross overages. Skipped when no limit is known.
- Advisory X-Helexa-Advice header: fingerprints User-Agent
(litellm / Agent-Zero / Zed) and attaches client-specific guidance.
Strictly advisory — header only, never in the error envelope, behaviour
never depends on it; unknown clients get nothing.
3 integration tests: over-long prompt → 400 context_length_exceeded with
the advice header, refused before neuron is hit; within-context passes
through; unknown client gets a clean 400 with no advice header. cortex-side
(no CUDA); local fmt/clippy/test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2 completes: when a model is loaded on more than one healthy neuron,
the router picks the least-busy replica instead of always taking the first,
and neuron backpressure propagates to the client intact.
- NodeState.model_load: per-model admission load (in_flight + queue_depth),
stashed by the poller from neuron's /health (#53/#2b).
- router::resolve collects all loaded replicas and picks the one with the
lowest in_flight+queue_depth (ties break by node name for determinism),
replacing the previous first-match-wins.
- Backpressure passthrough: the existing streaming proxy already forwards
the upstream status + all headers verbatim, so a neuron 503/429 +
Retry-After + #60 envelope reaches the client unmodified — now covered by
a regression test so a future change can't silently unwrap it.
Tests (tests/load_routing.rs): routes to the idle replica and follows the
lighter load when it flips; ties break by name; a saturated neuron's 503 +
Retry-After + envelope propagates through the gateway intact. All
cortex-side (no CUDA); local fmt/clippy/test green.
Retry-route-to-another-replica-on-backpressure (the issue's stretch goal)
is deferred — least-busy spread + honest passthrough is the substantive win.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Budget caps total spend over time (#52); this caps instantaneous
starvation so one principal's burst can't monopolize a model while others
wait.
- AdmissionController gains per-principal accounting (moved from a lone
atomic to a Mutex<AdmissionState> holding the overall pending count + a
per-principal map). enter(principal) now also fast-rejects when a
principal already has max_per_principal requests in flight/queued →
AdmissionRejection::PrincipalCap. Anonymous (None) requests are exempt.
- Config [harness.candle.admission].max_per_principal (default 2 = one
running + one queued; 0 disables). A bursting principal's overflow is
refused while a different principal still gets a queue slot.
- The principal (account/key) is reconstructed on the neuron side from the
x-helexa-account-id/key-id headers cortex stamps (#49) — trusted over
WireGuard, never from the request body — and threaded explicitly through
all inference entry points (chat_completion, *_stream(_with),
responses_stream, and the TP variants) to the admission gate.
- InferenceError::PerPrincipalLimit → 429 rate_limit_exceeded + Retry-After
(distinct from load-shedding's 503 Overloaded); opencode/AI SDK self-pace.
Tests: fair-share unit test (A floods → A's 2nd is PrincipalCap, B still
queues + is served) + the existing admission tests adapted to enter(None).
Non-CUDA build green locally; TP entry points (cuda-gated) validated by CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes #53: the bounded scheduler's lock-free counters are now visible
to the fleet, which is what cortex's load-aware router (#55) consumes to
spread traffic across replicas and propagate honest backpressure.
- cortex-core::discovery: HealthResponse gains `models: Vec<ModelLoad>`
(#[serde(default)] — back-compatible; older gateways/neurons interop).
ModelLoad { id, in_flight, queue_depth }.
- LoadedHandle::load() → (in_flight, queue_depth), lock-free for both
single-GPU and TP; CandleHarness::load_snapshot() enumerates resident
models; the /health handler overlays it from the candle harness.
Tests: /health always exposes a models array (api integration test); a
pre-#53 payload without `models` still deserializes, and ModelLoad
round-trips (cortex-core serde tests). Local fmt/clippy/test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the per-model unbounded, untimed FIFO of inference-lock waiters
(a busy model made new requests hang ~300s until the client gave up with
an opaque error) with an explicit bounded scheduler.
- harness::admission::AdmissionController: batch-1 scheduler — max_in_flight
running (1) + a bounded queue (max_queue_depth) with a max_wait. enter()
fast-rejects when the queue is full (QueueFull) or the wait elapses
(Timeout); the returned AdmissionPermit is held for the request and frees
both slots on drop. Pure async (no CUDA), lock-free in_flight/queue_depth
counters for future /health reporting. Configurable via
[harness.candle.admission] (max_in_flight=1, max_queue_depth=8,
max_wait_secs=30).
- Gated at all four inference entry points before the inference_lock/pool
lock: single-GPU non-streaming + streaming, TP non-streaming + streaming.
The streaming paths acquire the permit before opening the SSE (so a
rejection is a clean error, not a half-open stream) and move it into the
inference task.
- InferenceError::Overloaded { retry_after_secs } → 503 rate_limit_exceeded
+ Retry-After via the #60/#63 envelope: a fast, retryable "busy" signal
opencode/AI SDK back off on, not a stall.
Scope: this branch is the admission *core* (the hang→backpressure fix).
Exposing in_flight/queue_depth in GET /health (consumed by cortex
load-aware routing #55) is the next focused branch under #53.
4 unit tests (admit/report load, queue-full reject, wait-timeout reject)
+ Overloaded envelope mapping test. Non-CUDA build green locally; the
CUDA + TP sites are validated by branch CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1 complete: the A0 seatbelt (#52). Flips the metering-only reserve(0)
from #51 to the request's real upper-bound cost and refuses over-cap
requests *before* neuron is hit.
- metering::reservation_estimate: prompt estimate (~4 chars/token over the
body — cortex has no tokenizer, so a conservative over-estimate; neuron
stays the exact context wall) + max output. Max output comes from
max_completion_tokens / legacy max_tokens, else the model's advertised
limit.output (#62), else FALLBACK_MAX_OUTPUT. Over-reserving is safe —
settle reconciles to actual.
- metering::reserve_or_reject: reserve the estimate; on BudgetError map to
the #63 envelope and the caller refuses before dispatch — rolling window →
429 rate_limit_exceeded + Retry-After (until reset); hard balance → 429
insufficient_quota (no Retry-After). Never 402.
- Wired into both the OpenAI proxy path (proxy_with_metrics) and the
Anthropic path (estimate from the translated body). advertised_output_limit
reads the loaded model's limit.output from fleet state.
- Reservation prevents overshoot under concurrency: a successful reserve
gates on spent+reserved+estimate ≤ cap, and settle records actual ≤
reserved, so spend can never exceed the hard cap.
4 integration tests with a hit-counting mock neuron: balance over-cap →
429 insufficient_quota (no Retry-After, not dispatched); rolling over-cap →
429 rate_limit_exceeded + Retry-After (not dispatched); within-cap served;
**A0 repro** — a capped key's 20-request fan-out drains the cap, then is
refused, neuron only saw the served ones, and spend never exceeds the cap.
Plus 5 metering unit tests. Local fmt/clippy/test all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1 accounting (#51): capture real per-request usage and feed it to
the spend ledger + per-principal metrics. Establishes the reserve→settle
lifecycle that budget enforcement (#52) will tighten.
- cortex-gateway::metering: ReservationGuard makes reservation leaks
impossible — settle() records actual spend + releases the remainder;
dropping an un-settled guard releases the whole reservation, so any
early return / error / dropped stream resolves it. UsageSink is the
completion hook; principal_from_headers reconstructs the principal from
the middleware-stamped headers (uniform across all proxy paths, no
handler-signature churn); record_spend emits per-principal counters.
- proxy::TokenMetrics gains an optional usage_sink, invoked exactly once
in finish() with the observed (prompt, completion) — restructured so it
always runs (even when no body/usage arrived → settle 0 → release),
while preserving the existing per-model metric emissions unchanged.
- All proxy paths metered: chat/completions/responses via
proxy_with_metrics (reserve 0 → forward_request → settle in finish);
Anthropic non-streaming settles from the buffered body; Anthropic
streaming (anthropic_sse) now scans the upstream frames for the usage
object (#48) — it captured none before — and settles at pump end.
- This phase reserves 0 tokens (metering only, no enforcement); #52 flips
the reserved amount to prompt+max_output and surfaces BudgetError. The
settle/release plumbing is identical, so that change is localized.
- New Prometheus counters: cortex_spend_tokens_total (+ prompt/completion
splits), labelled by account/key.
2 integration tests: cumulative per-key spend after N requests with
reservations settled to zero outstanding; anonymous requests record no
spend. Local fmt/clippy/test all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1 identity (#49): cortex now knows who a request is for. Identity
rides standard bearer auth only (Authorization: Bearer <key>) — no custom
required headers or body fields — which is what keeps every tier
OpenAI-compatible by construction.
- cortex-gateway::auth: `require_principal` axum middleware
(from_fn_with_state), wired in build_app outer-to-inner as
trace → CORS → auth → handlers (CORS outer so preflight short-circuits).
It resolves the bearer key via the EntitlementProvider, inserts the
typed Principal into request extensions (for metering #51 / enforcement
#52), and stamps internal x-helexa-account-id / x-helexa-key-id headers
so the principal reaches neuron, which trusts cortex over WireGuard (#54).
- Anti-spoofing: client-supplied principal headers are stripped before the
authoritative value is stamped — a client can never assert a principal
it didn't authenticate as.
- Rejection contract (#63): missing key under require_auth, or any present
but unresolvable key, → 401 invalid_api_key in the #60 envelope. /health
and / stay public. require_auth=false (default) allows anonymous through
but still 401s a present-but-invalid key.
- Header-name constants (HEADER_ACCOUNT_ID/KEY_ID) live in cortex-core so
neuron (#54) shares them. The chat/completions/responses paths forward
the stamped headers automatically via proxy::forward_request; the
Anthropic streaming + non-streaming paths forward them explicitly via
auth::forward_principal_headers (they build their own upstream requests).
5 integration tests: missing-key 401, invalid-key 401 (even when auth not
required, not dispatched), valid key reaches neuron with principal headers
+ spoofed header stripped, anonymous allowed when not required, /health
public. Local fmt/clippy/test all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1's build seam (#50): the interface auth, metering, and budget
enforcement all hang off, with a local/static provider so the A0
amplification fix can land before any upstream clearing house exists.
The future helexa-upstream client (#57) is just another impl.
- cortex-core::entitlements: Principal {account_id, key_id}, CapWindow
(Balance | Rolling{seconds}), Reservation handle, BudgetSnapshot,
AuthError/BudgetError, and the async EntitlementProvider trait
(resolve / reserve / settle / release / snapshot). BudgetError carries
the window semantics so callers pick the #63 code (rate_limit_exceeded
+ Retry-After vs insufficient_quota) without the provider touching HTTP.
- cortex-core::config: [entitlements] section on GatewayConfig
(require_auth + [[entitlements.keys]] with account_id, optional key_id,
hard_cap, window). Additive + serde(default) — anonymous/uncapped when
omitted, so existing setups are unaffected.
- cortex-gateway::entitlements_local: LocalEntitlementProvider. Budget
math serialized under one Mutex so spent+reserved can never exceed a
hard cap under concurrency (the #52 guarantee); rolling windows reset
lazily; uncapped keys (no hard_cap) always reserve but still meter.
- CortexState gains Arc<dyn EntitlementProvider> + require_auth, built in
from_config. Not yet consumed by the request path — auth middleware is
1b (#49), enforcement is 1d (#52).
- cortex.example.toml documents the section; test GatewayConfig literals
updated for the new field.
6 provider unit tests (resolve, unknown-key, round-trip, balance/rolling
over-cap codes, uncapped infra key). Local fmt/clippy/test all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rejection contract (#63) requires every "no" path to speak the
OpenAI envelope with standard codes and, for retryable conditions, a
Retry-After header. Two gaps remained despite #63 being closed:
Retry-After was implemented nowhere, and the envelope was hand-built
inline in four places (gateway handlers/proxy/router, neuron api) with
no shared source of truth — exactly the inconsistency #63 set out to
prevent, and a foundation every Stage 1-2 rejection (401/429/503) needs.
- cortex-core: new `error_envelope::OpenAiError` — an axum-agnostic
builder carrying status, type, code, message, param, optional
retry_after, and diagnostic extras. Named constructors encode the #63
codes (invalid_api_key, rate_limit_exceeded, insufficient_quota,
context_length_exceeded, service_unavailable) and which carry
Retry-After. cortex-core stays a pure types crate; each HTTP crate
owns a thin `envelope_response` adapter that sets the header.
- cortex-gateway: route error_response, ProxyError, and RouteError
through the shared builder; RouteError::retry_after_secs wires
Retry-After on the transient NoHealthyNodes (5s) / ModelRecovering
(2s) variants.
- neuron: route inference_error_response through the shared builder;
InsufficientVram (transient 503) now advertises Retry-After: 5.
Behaviour for existing paths is unchanged (same status/type/code/extras);
only the new Retry-After headers are added. Tests cover the builder wire
shape and Retry-After presence/absence on both sides.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The request path now rejects prompts above the model's self-derived input
budget, not the static NEURON_MAX_PROMPT_TOKENS — so a VRAM-tight host
(where the VRAM ceiling binds below the static cap) rejects an
over-budget prompt up front instead of accepting it and OOMing
mid-prefill.
- derived_input_cap: AtomicUsize on LoadedModel + TpLoadedModel; refreshed
by LoadedHandle::derived_limit (runs on every /models poll). 0 = not
derived yet.
- effective_prompt_cap(): cached derived input when >0, else the static
max_prompt_tokens() (cold-start / no-profile fallback).
- validate_request takes the cap as a param; all 4 call sites
(chat_completion, inference_stream, inference_tp_stream, TP
chat_completion) pass the in-scope model's effective_prompt_cap().
- doc/context-limits.md: enforcement note updated from "remaining" to
landed.
Reads the cap lock-free from the sync validate path (no per-request VRAM
query); the cap tracks live state via the poll-driven derivation. With
this, advertise and enforce agree and both track the resident model.
fmt/clippy/test green; CUDA paths type-checked in CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Demotes the static per-host prompt cap from authority to an optional
upper-bound clamp on the self-derived limit, and rewrites the
context-limits doc around the computed model.
- max_prompt_tokens_clamp(): reads NEURON_MAX_PROMPT_TOKENS directly so
"explicitly set" is distinct from the 16384 default; returns None when
unset (no clamp). Applied as derive_limit's hard_ceiling in
LoadedHandle::derived_limit, so the advertised context is clamped only
when an operator set a backstop — the derivation is otherwise
authoritative and binds below it in practice.
- doc/context-limits.md: intro + "After #62" rewritten as "After #67 —
the neuron computes its own limit" (formula, live signals, config
block, opencode note, NEURON_MAX_PROMPT_TOKENS demotion).
Remaining (phase 5b, follow-up): enforce the *derived* input as the
prompt cap (reject above computed input, not the static
NEURON_MAX_PROMPT_TOKENS) so VRAM-tight hosts can't accept an
OOM-inducing prompt. Needs a per-model cached cap read from the sync
validate path; scoped separately. Until then the static cap remains the
enforced backstop (advertised <= enforced holds when the env is set).
fmt/clippy/test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The neuron now self-derives and advertises limit{context,input,output}
per loaded model; cortex forwards it and stops consulting the
operator-declared catalogue limit (which can't track hot-swapped models
or live capacity). Operator-set `cost` still flows from the catalogue.
neuron:
- CandleHarness gains context_limit_cfg (from [harness.candle.context_limit]).
- LoadedHandle::derived_limit(): profile + live tightest-card free VRAM
(single: query_vram; TP: query_vram_tightest_free_mb) + prefill-rate
EMA (bootstrap until first sample) → derive_limit. None for arches
without a context profile. No operator clamp here (advertise the honest
derived value; the clamp is an enforcement-side backstop).
- list_models() fills ModelInfo.limit from derived_limit (was None).
- derive_limit treats free_tightest_mb == 0 (unknown/CPU sentinel) as
"no VRAM ceiling" instead of collapsing to zero.
cortex:
- ModelEntry gains `limit`, copied from ModelInfo.limit by the poller.
- /v1/models: catalogue `limit` no longer flows (Pass 1 sets None);
Pass 2 adopts the neuron's limit, taking the tightest across neurons
via tightest_limit(). cost unchanged.
- model_limits.rs rewritten: catalogue limit (999999) is ignored; the
neuron's ModelEntry.limit is advertised; cost still from catalogue.
- All ModelEntry literals updated with the new field.
fmt/clippy/test green; CUDA paths type-checked in CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refs #67. Feeds the throughput ceiling a live, per-model prefill rate
instead of only the configured bootstrap estimate, so the advertised
limit tracks real prefill speed and rises automatically as prefix
caching (#11) reduces effective prefill cost.
- context_limit::PrefillRateEma: lock-free f64-bits EMA (alpha 0.3),
ignores degenerate samples, None before the first sample. Unit-tested.
- prefill_rate field on LoadedModel + TpLoadedModel.
- Recorded as total-prompt-tokens / prefill-elapsed in the two streaming
serving paths (TP: inference_tp_stream via tp_for_task; single-GPU:
stream_inference_via_worker via a new &prefill_rate param threaded from
loaded_for_task). Measuring total prompt (not just the divergent
suffix) means a prefix-cache hit shrinks elapsed while the prompt stays
large, so the effective rate — and the ceiling — rises toward the VRAM
ceiling, exactly the #11 payoff.
Per the agreed scope, non-streaming + CPU paths fall back to the
bootstrap estimate (opencode streams; those paths rarely carry the
fleet). fmt/clippy/test green; CUDA paths type-checked in CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refs #67. Captures the per-model context physics at load and adds the
live free-VRAM signal the derivation needs — the tightest card across TP
ranks, not just the leader.
- ContextProfile captured at load:
- single-GPU dense CUDA path (world_size 1) via
context_limit::profile_from_qwen3_5_config(config_path, ..);
- TP path (world_size = tp_size) at TpLoadedModel construction.
GGUF/CPU/non-qwen3_5 → None (fall back to the static prompt cap).
New `context_profile` field on LoadedModel + TpLoadedModel.
- profile_from_qwen3_5_config(): reads config.json (mirrors
VisionMeta::from_config_path), counts full_attention layers
(layer_types authoritative, full_attention_interval fallback), builds
the per-card KV cost via the shared helper.
- Folded the inline per-rank KV-bytes math in tp_qwen3.rs (both
cuda/non-cuda log_construction_complete) and tp_qwen3_5.rs onto
context_limit::kv_bytes_per_token + KV_CACHE_DTYPE_BYTES.
- Per-rank VRAM fan-out (tightest card):
- WorkerRequest::QueryVram + WorkerResponse::VramInfo { free_mb, total_mb };
- worker.rs handle_query_vram (cuda: mem_get_info; non-cuda: error);
- WorkerPool::query_vram_tightest_free_mb fans out to every rank
(leader via its device worker, subprocess ranks via RPC) → min free;
- TpLoadedModel::query_vram_tightest_free_mb convenience wrapper.
No advertise/enforce yet (phases 4/5). fmt/clippy/test green; CUDA paths
type-checked in CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refs #67. The correct limit{context,input,output} for a deployment is a
computed function of model architecture + live free VRAM + a
coherence/throughput trade-off, not an operator-declared static fact that
goes stale on model swap. This lands the arch-agnostic derivation core;
later phases capture per-model physics at load, measure throughput, and
advertise/enforce the computed limit.
- crates/neuron/src/harness/context_limit.rs (new):
- kv_bytes_per_token(): shared per-card KV cost (counts only
full-attention layers; sharded by TP world size). The TP load paths'
inline math folds onto this in phase 2.
- ContextProfile: per-model physics snapshot (max_position_embeddings,
kv_bytes_per_token_per_card, world_size).
- derive_limit(): context = min(max_pos, vram_ceiling,
throughput_ceiling) clamped by an optional backstop; input = context −
output; rounded to 1024. 6 unit tests.
- config.rs: [harness.candle.context_limit] block (mirrors prefix_cache):
target_prefill_latency_secs, bootstrap_prefill_tok_per_sec,
activation_headroom_mb, min_free_floor_mb, output_reserve_tokens.
- neuron.example.toml: documented the new block.
No runtime behaviour change yet. fmt/clippy/test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#64.
opencode meters reasoning tokens separately via the OpenAI-standard
detail objects, which neuron's usage structs didn't expose. Add them
additively so older clients ignore them.
- cortex-core: Usage gains completion_tokens_details/prompt_tokens_details;
ResponsesUsage gains output_tokens_details/input_tokens_details. Optional
+ skip_serializing_if, so the wire shape is unchanged for non-reasoning
models. cached_tokens fields are defined but always None until prompt
caching lands (#11).
- candle.rs: count tokens generated while in_reasoning across all three
streaming paths (TP, worker, CPU); carry the count on InferenceEvent::Finish.
- chat projector: populate completion_tokens_details.reasoning_tokens.
- responses projector: wire up base usage emission on the streaming path
(it emitted none before) and add output_tokens_details.reasoning_tokens.
- non-streaming paths leave details None (they don't track in_reasoning).
reasoning_tokens is a sub-count of completion/output tokens (OpenAI
semantics) — not added into total_tokens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cortex resolved the catalogue path "models.toml" relative to the service's
working directory, so the systemd-launched binary never found
/etc/cortex/models.toml and ran with an EMPTY catalogue in production —
limits, cost, pinning, aliases and feasibility were all silent no-ops,
with models surfacing only via the neuron poller. Tests never caught it
because they pass models_config explicitly; only the defaulted,
packaged path was broken.
Default to the absolute /etc/cortex/models.toml (where cortex.spec installs
it) and document the override in cortex.example.toml. Restores the #62
limit/cost advertisement (the catalogue is now actually read) along with
pinning/aliases/feasibility.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves#62. opencode's helexa provider discovers a model's serving
budget from /v1/models and uses it to size context, trigger compaction,
and show spend with no hand-configuration. Each model entry now carries:
- limit { context, input?, output } — operator-declared in models.toml
- cost { input, output, cache_read?, cache_write? } — USD per 1M tokens
- tool_call / reasoning — runtime-detected by the candle harness and
OR-ed in from each serving neuron
Composition: the catalogue profile supplies limit/cost (Pass 1); the
poller carries the neuron's detected tool_call/reasoning into ModelEntry,
which the gateway unions onto the entry (Pass 2); aliases propagate every
field (Pass 4). Wire types extend ModelInfo / ModelProfile /
CortexModelEntry additively (serde default + skip_serializing_if), so
older neurons and clients are unaffected. helexa-bench's ModelInfo
constructor and the gateway test fixtures are updated for the new fields.
Adds tests/model_limits.rs asserting /v1/models surfaces limit + cost
(catalogue) and tool_call + reasoning (runtime), and that max_model_len
is gone.
Removes max_model_len. It was write-only with no consumer — opencode's
source references it nowhere and it is not an OpenAI /v1/models field —
and doubly misleading: vLLM's max_model_len means total sequence length,
but cortex populated it from NEURON_MAX_PROMPT_TOKENS, a prompt-only cap.
The limit{} contract replaces it. The neuron's max_prompt_tokens remains
the enforced prompt cap (neuron-side); cortex just stops re-advertising a
derived, mis-named copy. Closes#66 — its stale-max_model_len premise is
moot once the field is gone.
limit/cost are operator-declared (catalogue) per #62's design; auto-
deriving the advertised budget from each neuron's reported cap is a
tracked follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Roll the per-model context cap into deploy.yml so it is deterministic per
host and rolled out (with a restart) alongside the rest of the service
config, rather than hand-edited in local.conf. The deploy now writes
/etc/systemd/system/neuron.service.d/model.conf from a new per-host
`max_prompt_tokens` matrix field, and restarts a neuron when the package
OR the drop-in changes — so a cap change applies even with no new RPM.
beast (Qwen3.6-27B, hybrid linear, 2x 32GB) -> 131072 (~128k); benjy and
quadbrat (dense, VRAM-bound) stay at 16384 but become deploy-managed.
Adds the scoped sudoers grant for the root-owned drop-in install, and
doc/context-limits.md documenting the knob relationships and KV/VRAM math
(refs #62 for the eventual /models-advertised source of truth, #65 for
the length-aware text VRAM guard that gates pushing beyond 128k).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fixes#63
Standardize error messages by adding type, code, and param fields to
align with OpenAI API format. Updates include:
- Structured error envelopes with broad type categorization
(invalid_request_error/api_error)
- Specific machine-readable codes (model_not_found/service_unavailable)
- Null param field as required by OpenAI specification
- Consistent error response formatting across handlers, proxy, and
routing layers
New tests verify correct error envelope structure for various failure
scenarios.
Co-Authored-By: Helexa (Qwen3.6-27B, 48k context) <noreply@helexa.ai>
InferenceError responses were a flat `{"error": "..."}` string. OpenAI
clients (opencode, the openai SDK) reach into `error.type`/`error.code`
to drive behaviour — most importantly `code == "context_length_exceeded"`
triggers auto-compaction + retry instead of a hard failure. A flat string
is invisible to that logic.
Rewrite `inference_error_response` to emit the nested envelope
`{"error": {"message","type","code","param", ...diagnostics}}` and map:
- ModelNotLoaded → 404 invalid_request_error / model_not_found
- PromptTooLong → 400 invalid_request_error / context_length_exceeded
(message: "maximum context length is N tokens", + prompt_len/max)
- InsufficientVram → 503 api_error / insufficient_vram
- VisionUnsupported→ 400 invalid_request_error / vision_unsupported
- TemplateRenderFailed → 422 invalid_request_error / template_render_failed
- Other → 500 api_error / null code
Diagnostic extras ride inside the error object so the envelope shape is
stable. Both inline match blocks in the chat-completions handler
(streaming + non-streaming) now defer to the shared helper, which the
responses handler already used — one source of truth.
Adds 4 unit tests covering the envelope shape and codes. Also fixes a
pre-existing clippy lint (cloned_ref_to_slice_refs) in qwen3_5 snapshot
test surfaced by a newer clippy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The deeper reason opencode showed "Context: 0 tokens / 0% used" and flew
into a 400: streaming responses carried NO `usage`. Clients track context
(and trigger compaction) from the `usage` field; the legacy candle
streaming path set `usage: None` on every chunk, so a streaming client
had no token count at all — `max_model_len` alone is a denominator with
no numerator.
InferenceEvent::Finish now carries prompt_tokens + completion_tokens
(the streaming loops already have both: prompt_tokens.len() and the
generated all_tokens.len()). The openai_chat projector emits an
OpenAI-style trailing usage chunk (empty `choices`, populated `usage`)
after the finish chunk. cortex's Anthropic stream translator already
reads chunk.usage, so this fixes context tracking on BOTH the OpenAI
(opencode) and Anthropic (Claude Code) paths.
Also harden the max_model_len plumbing's sibling: cortex re-polls
/discovery while a neuron's max_prompt_tokens is still 0 (unknown), so a
rolling-deploy race where cortex caches discovery before the neuron has
the field self-heals instead of pinning max_model_len to None until a
manual cortex restart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
opencode (and any OpenAI/Anthropic client) couldn't size or compact its
context against helexa because /v1/models never advertised a context
window — opencode showed "0 tokens / 0% used" and flew straight into a
400 PromptTooLong once a conversation + a fetched 64KB log overflowed the
49152-token cap. Compaction is the client's job, but the client needs to
know the limit to do it.
neuron now reports its effective prompt cap (NEURON_MAX_PROMPT_TOKENS)
in GET /discovery (`max_prompt_tokens`). cortex surfaces it on
/v1/models as `max_model_len` (vLLM / OpenAI-compatible convention) per
model — the smallest cap among the neurons that can serve it
(feasible_on ∪ locations), so the advertised limit holds wherever the
request routes. A neuron reporting 0 predates the field and is treated
as unknown (skipped); models with no reporting neuron omit the field.
helexa still rejects over-limit prompts with a clean 400 — this just
gives clients the number to compact *before* hitting it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
opencode (OpenAI path, /v1/chat/completions passthrough) hit the same
chat_template:120 failure Claude Code did — "cannot convert value into
pairs" — because the OpenAI wire format carries
tool_calls[].function.arguments as a JSON *string*, while Qwen3.6's
template iterates it as a dict (`arguments | items`). The Anthropic-side
fix (8880b2f) only covered cortex's translation; the OpenAI path reaches
neuron unchanged.
render_chat_template now normalizes string-form tool-call arguments to
objects across all messages before building the Jinja context, so OpenAI
and Anthropic clients both render. Object args (Anthropic path) pass
through untouched; a string that doesn't parse is left as-is and the
render fails loudly (422 TemplateRenderFailed, a94dd55) rather than
silently dropping tools.
The loud-fail change earned out immediately here: opencode got a clean
422 with the exact `chat_template:120` cause instead of a degraded
session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three of this session's bugs (system-message position, tool_call argument
shape, and the original tool rendering) all hid behind the same silent
behaviour: chat_template render fails → neuron falls back to
format_qwen3_prompt, which drops every tool → the request still returns
200 with degraded, tool-less output. Each cost real debugging time
because the failure was invisible on the wire.
build_prompt_for_request now returns Result. On a render failure it
checks whether the request carried tools: if so it returns the new
InferenceError::TemplateRenderFailed (mapped to 422 with a
template_render_failed code and the underlying Jinja error), instead of
silently degrading. A render failure with no tools still falls back
quietly — there's nothing to lose, and `format_qwen3_prompt` is a
reasonable text-only prompt. The four prompt-build call sites propagate
with `?`.
Now the next client/template incompatibility surfaces as a loud 422 the
operator sees immediately, not a mysteriously-degraded session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified live via the rendered-prompt trace: once a tool call is in the
conversation history, the Qwen3.6 chat template fails to render —
render chat_template: invalid operation: cannot convert value into
pairs (in chat_template:120)
because line 120 iterates `tool_call.arguments | items` (treats arguments
as a dict), while cortex emitted the OpenAI-standard JSON *string*. On
that render error neuron silently falls back to a tool-less prompt, so
the model loses every tool the moment it makes one call — it can make the
first tool call, read the result, then can only narrate ("now let me
check the runs") and stop, because the next turn has no tools. That's the
"drops the ball a little later" symptom: the CC trace shows the get_me
turn rendering 42653 tokens (tools present) and every subsequent
tool-history turn falling back to ~6k tokens (tools gone).
anthropic_to_openai now passes `function.arguments` as the parsed object
rather than stringifying it. Tests updated to expect the object form.
This is the same silent-fallback failure class as the system-message
merge (295b10c) — which is why making neuron's template-render fallback
LOUD (4xx on a tools-bearing request instead of a degraded 200) is now
clearly worth doing: it would have surfaced both in seconds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified live: Qwen/Qwen3.6-27B with a simple prompt and max_tokens=400
generated 400 tokens, finish_reason=length, and 0 visible characters —
the model spent the ENTIRE budget on <think> reasoning, which we then
drop for OpenAI/Anthropic clients (include_thinking=false), starving the
visible answer. This is why Claude Code "dropped the ball": empty or
truncated responses. A/B confirms the cause — same prompt with
chat_template_kwargs.enable_thinking=false yields a full 545-char answer.
The earlier prompt_opens_reasoning fix stopped the reasoning *leaking* as
text but left it consuming the token budget. Couple the two: when the
caller isn't going to see the reasoning (include_thinking=false, the
default), default chat_template_kwargs.enable_thinking to false so the
model doesn't generate it. An explicit client enable_thinking wins;
thinking-aware clients (helexa-acp, x-include-thinking: true) keep
reasoning on. Tests cover the default (false), surfacing (true), explicit
override, and preservation of other kwargs.
Note: only the /v1/chat/completions path (what Claude Code uses via
cortex /v1/messages); /v1/responses could get the same defaulting as a
follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified live via neuron trace: Claude Code's real requests carry a
top-level `system` AND a `role:"system"` turn inside `messages`. cortex
passed the latter through at a non-first position, and Qwen3.6's chat
template hard-rejects it:
WARN chat_template render failed; falling back to format_qwen3_prompt
error=... invalid operation: System message must be at the beginning.
On that render error neuron silently falls back to a template that
renders NO tools, so the model got zero tool-format guidance and
improvised an unparseable `<tool><name>…` syntax — tool calling broke
entirely for real CC traffic, even though synthetic single-system
probes (and the earlier translation/parse fixes) worked.
anthropic_to_openai now accumulates the top-level `system` plus every
`role:"system"` conversation turn and emits a single system message at
index 0, with the non-system turns following in order. Reproduced the
trigger (system-role message at index>0 → fallback) and the fix
(merged → template renders tools). Test covers the merge + ordering.
Secondary hardening worth a follow-up: neuron's silent template
fallback drops tools without surfacing it to the client — a render
failure on a tools-bearing request should arguably 4xx rather than
degrade invisibly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Debugging tool-call format drift (Qwen3.6-27B emitting wrapper-less
<tool><name>…> under Claude Code's real system prompt + 120-tool list,
which neuron's <tool_call> detector can't parse) needs ground truth on
what the model actually sees. neuron logged nothing about the rendered
prompt. Add a trace! in build_prompt_for_request emitting the full
rendered prompt + char count + tool count, so we can see whether the
chat template's <tool_call> format instruction survives a large system
prompt and how the tools render. Gated at trace (the prompt can be tens
of KB): RUST_LOG=neuron::harness::candle=trace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A neuron-blackwell build hung ~90 min (siblings finished in 2) and there
was no job timeout to kill it, so it sat burning a runner. Root cause of
the hang: the inline retry loop treated every failure identically and, on
its final attempt, rebuilt with sccache disabled. When the real failure
is a rustc SIGSEGV or an OOM-kill, an uncached rebuild does *more* work
under the same memory pressure — turning one transient compiler crash
into a wedged job.
Two fixes:
1. timeout-minutes on every job in build-prerelease.yml and ci.yml
(builds 25, neuron CUDA build/cuda-check 35, packaging 20, COPR 60,
fast jobs 10-15). A hang now dies in minutes, not hours.
2. New script/ci-cargo-escalate.sh replaces the five (prerelease) + three
(ci) inline escalation loops. It classifies the failure:
- signal death (exit >=128, or cargo reporting `signal: N`/SIGSEGV/
SIGKILL) → compiler crash, NOT an sccache fault: keep the cache,
one warm retry, then fail fast. Never escalate to uncached.
- sccache fault (recognisable sccache error) → restart the server,
retry, then one final uncached attempt.
- deterministic compile/test error → fail fast (no wasteful retry).
It also folds in the CUDA-image sccache probe the neuron/cuda-check
jobs did inline. Classification verified locally against success,
plain failure, exit-139, and the cargo-wrapped `signal: 11` form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Qwen3.6's chat template injects the opening <think> into the generation
prompt, so generation begins mid-thought and the open marker is never
sampled. The streaming loops flipped in_reasoning to true only on a
*generated* open token, so they stayed in text mode and streamed the
model's reasoning out as visible text — verified live: a tool request
returned a 255-char text block of chain-of-thought ("The user wants to
know the weather… I will construct the function call now.") ahead of the
tool_use block, with the trailing </think> stripped (close token
recognised) but no opening <think>.
Each streaming loop now seeds in_reasoning by replaying the prompt's
reasoning markers (new `prompt_opens_reasoning`): if the prompt ends
inside an open <think>, the loop starts in reasoning mode, the thinking
routes to ReasoningDelta (dropped by the chat projector's default
include_thinking=false, which is what cortex uses), and the model's
</think> flips back to visible text for the answer/tool call. Template-
agnostic and self-correcting: a prompt that doesn't open reasoning (no
think injection, enable_thinking off, non-reasoning model) starts false,
preserving current behaviour. Thinking is hidden, not disabled, so answer
quality is unaffected.
Applied to all three streaming loops (inference_tp_stream,
stream_inference_via_worker, run_inference_streaming). Test covers
open/close replay, multi-turn closed state, reopen-at-tail, and the
no-pair pass-through.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified live (commit d662fa2 logs): cortex now delivers OpenAI-shaped
tools to neuron correctly, but Qwen3.6-27B emits tool calls in the
Qwen-XML form inside the <tool_call> markers —
<tool_call>
<function=get_weather>
<parameter=city>
Brno
</parameter>
</function>
</tool_call>
— while parse_tool_call_body only did serde_json::from_str expecting
{"name":…,"arguments":…}. It returned None, the dispatch re-emitted the
raw block as a text delta, and clients saw the markup as prose. cortex
logged upstream_tool_calls=false finish_reason="stop".
parse_tool_call_body is now format-tolerant: JSON first (Qwen3-Instruct
/ Hermes), then a Qwen-XML parser (Qwen3-Coder / Qwen3.6). Each
<parameter> value is coerced to its declared JSON type using a new
ToolSchemas map built from the request's tools (string stays string,
integer/number/boolean/object/array coerced, mistyped values fall back
to string so an argument is never dropped). build_tool_schemas is
threaded into all three streaming loops (inference_tp_stream,
stream_inference_via_worker, run_inference_streaming).
Each loop also tracks emitted_tool_call and promotes the terminal
finish_reason from Stop to ToolCalls when a call parsed, so the OpenAI
chunk carries finish_reason:"tool_calls" and cortex maps it to Anthropic
stop_reason:"tool_use" — without which an Anthropic agent (Claude Code)
sees a tool_use block but stop_reason:end_turn and may not run the tool.
FinishReason::ToolCalls drops its dead_code allow.
Tests: JSON form still parses; Qwen-XML multi-param parse with
schema-driven string/integer/boolean coercion; no-schema type sniffing;
type-mismatch string fallback; unparseable body returns None.
Known gap (separate): the non-streaming run_inference paths have no
tool-call handling at all; Claude Code streams, so the streaming loops
are the ones that matter here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Code (ANTHROPIC_BASE_URL -> cortex) hits POST /v1/messages, but
anthropic_to_openai forwarded the request's `tools` array verbatim via
the flattened `extra`. neuron feeds that straight into the HF chat
template, which iterates the OpenAI shape (tool.function.name/.parameters).
Anthropic-shaped tools ({name, description, input_schema}) rendered as
broken/empty definitions, the model improvised an unparseable
<tool_use_name>...</tool_use_name> tool-call format, neuron's
<tool_call>{json}</tool_call> detector missed it, and the markup fell
through as plain assistant text — so CC never received a structured
tool_use and the agent loop died.
Request-side translation now reshapes:
- tool definitions: {name, description, input_schema}
-> {type:"function", function:{name, description, parameters}}
- tool_choice: auto->"auto", any->"required", none->"none",
tool->{type:"function",function:{name}}
- assistant tool_use blocks -> OpenAI assistant.tool_calls
(arguments JSON-stringified) — fixes multi-turn
- user tool_result blocks -> standalone role:"tool" messages keyed by
tool_call_id
- system content blocks flatten to text instead of being JSON-serialised
into the prompt; best-effort image-block -> image_url part
Wire-debug instrumentation (tracing levels only; cortex/neuron ship at
info, operator infra runs at debug):
- every handler emits a debug! "inbound request" line tagging the wire
surface (anthropic | openai-chat | openai-responses | openai-completions)
plus model/stream/tools and, for Anthropic, tool_history/system
- response side reports upstream_tool_calls + finish_reason, streaming
and non-streaming
- full inbound + translated-upstream bodies at trace! (UTF-8-safe, capped)
Tests: 8 request-side unit tests + an end-to-end gateway test asserting
the upstream neuron receives OpenAI-shaped tools and a
user->assistant(+tool_calls)->tool->user history.
Also tighten script/infra-log-verbosity.sh: independent cortex/neuron
RUST_LOG args, cortex-only by default (neuron restart behind
--with-neuron so we don't needlessly cold-reload models), mkdir -p the
drop-in dir, symmetric RUST_LOG cleanup, and set -euo pipefail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public visitors don't know the hostnames, so surface each host's GPU(s)
as the resource name across the UI.
- store: gpu_label() turns the stored gpus_json into a compact label
("2× RTX 5090", "RTX 4090"); add `gpu` to ReportRow + RunRow and
`host_gpus`/`model_gpus` maps to /api/dimensions (from each one's
latest run). render_json gains gpu too.
- UI: Overview + Runs show a "GPU" column (gpu, fallback host); Runs'
filter is now GPU-labelled (still filters by host underneath); Trends
shows a "Measured on <gpu>" line for the selected model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public visitors don't know the hostnames or per-host hardware, so the
host picker on Trends was confusing. Select by model + scenario only;
/api/series now takes host as optional and resolves it to the host
serving that (model, scenario) — coherent since each model maps to one
host today. Runs (drill-down) keeps its host filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a dashed vertical ReferenceLine at the first live build (labelled
"bench.py → helexa-bench") so the intentional gap between the gateway
baseline and the direct-to-neuron series reads as a deliberate
measurement-regime change, not missing data. The two series stay
unconnected by design (different regimes, not directly comparable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Option C: a curated static baseline (bench/src/baseline.ts), transcribed
from doc/benchmarks.md (8f6f1d3 + a1952a4 post-#11), overlaid on the
Trends charts as a dashed, clearly-labelled historical series ahead of
the bench era. Host inferred from model via the doc's fleet table;
ordered by snapshot time so it anchors the timeline.
Kept deliberately separate from the live series (no DB/API change) — the
baseline is a different regime (bench.py through the cortex gateway,
medians only) so it's never merged into the direct-to-neuron line; a
caption spells out the distinction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- cert_present() must `sudo test -d /etc/letsencrypt/live/...` (root-only
0700); without sudo it falsely reported "no cert" and downgraded the
bench.helexa.ai vhost to the http-only bootstrap (dropping its 443
server). Now correctly keeps the full TLS vhost.
- bench.internal initial cert: rsync the operator's JWK 'lair' provisioner
password to the host transiently (root, 0600), issue via
step ca certificate, then remove it (trap + belt-and-suspenders rm).
Verified: bench.helexa.ai (LE) and bench.internal (lair CA) both serve the
SPA + /api→bob; step@bench.timer renews; secret removed from host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inside the WireGuard mesh, bench.helexa.ai dead-ends at the OPNsense LAN
interface (only WAN :443 is port-forwarded), so add an internal path:
- asset/nginx/bench.internal.conf — server_name bench.internal, internal
"lair" CA cert, same SPA + /api→bob proxy. Mirrors the *.internal vhost
convention on oolon.kosherinata.internal.
- asset/systemd/step@.{service,timer} — replicate oolon's smallstep cert
renewal (step ca renew via mTLS, every 15 min, reload nginx).
- infra-setup.sh: install the step@ units + /etc/nginx/tls/{cert,key},
install the vhost + enable step@bench.timer once the cert exists; prints
the one-time issuance command otherwise.
Initial cert issuance (JWK provisioner) and bench.internal DNS are
operator steps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The webroot/http-01 approach needed nginx serving :80, but the gateway's
nginx was dormant. Switch to the host's established convention —
certbot --dns-cloudflare --key-type ecdsa with /root/.certbot-internal —
which needs neither nginx nor :80, so the cert provisions independently
of the vhost being served. Also restorecon the webroot (SELinux
enforcing → nginx 403 without httpd_sys_content_t), and only ever
install the full TLS vhost once the cert exists (http-only bootstrap
otherwise) so `nginx -t` always passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nginx on the gateway serves the bench SPA and reverse-proxies /api to the
bob bench API over WireGuard — public, auth-less, same-origin (no CORS),
internal API stays private.
- asset/nginx/bench.helexa.ai.conf (full TLS vhost: SPA + /api proxy) and
a bootstrap http-only vhost for the initial ACME challenge.
- infra-setup.sh: one-time gateway setup — webroot, Let's Encrypt cert
(certbot webroot, idempotent), install + enable the vhost.
- deploy.yml: deploy-bench-ui builds the SPA (setup-node) and rsyncs
dist/ to /var/www/bench.helexa.ai every deploy; built same-origin so
no VITE_API_BASE.
- cortex-host.conf: scoped gitea_ci rsync grant for the webroot.
- bench/README: production hosting notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part A — helexa-bench read API:
- [api] config (enabled, listen :13132); WAL on the store so API reads
never block the sweep writer.
- store read methods: summary, series (chronological per-build medians),
runs (filtered), dimensions, run_count.
- api.rs: axum /api/health|dimensions|summary|series|runs, permissive
CORS (UI is a separate origin). The `run` daemon binds the API
alongside the sweep; new `serve` subcommand serves API-only.
- listener plumbing (bench gains a port): data/helexa-bench-firewalld.xml,
spec install, deploy-bench /api/health probe + firewalld step, sudoers
firewall-cmd grants, [api] in example + bob.toml.
- 5 API tests + serve smoke.
Part B — bench/ Vite + React-SWC-TS app (router, react-bootstrap,
recharts): Overview (summary table), Trends (decode tok/s & TTFT across
build SHAs), Runs (filterable explorer). Typed API client with
VITE_API_BASE + dev proxy to bob. npm build/typecheck clean. Hosted
separately from the API (per design); .gitignore excludes node_modules/dist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a deploy-bench job to deploy.yml that rolls helexa-bench onto bob
(the bench host, also running Agent Zero), following the deploy-cortex
pattern: manifest-gated skip-when-current, light "service stays active"
validation (outbound-only, no listener/model to probe), journal capture.
Runs alongside the cortex→neurons chain (no deploy-ordering dependency —
the sweep loop is version-aware).
Boot persistence: all systemd deployments now `systemctl enable --now`
instead of bare `start`, so cortex / neuron / helexa-bench come back
after a host reboot. Covers deploy.yml (all three services) and
deploy-dev.yml (neuron fast path); sudoers gain the matching
`enable --now <svc>` grant.
infra-setup.sh handles bob: provisions gitea_ci, installs the
bench-host sudoers, enables the lair-cafe-unstable repo (bob is a client
host without it), pre-creates /etc/helexa-bench, and syncs
asset/helexa-bench/bob.toml. New assets: bench-host.conf sudoers and
bob.toml (three neuron targets).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(bench): version-aware benchmark harness + neuron build metadata
Adds GET /version build metadata to neuron and the helexa-bench crate — a continuous, version-aware harness that records fleet benchmarks into SQLite keyed by neuron build SHA, replacing manual bench.py runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds automated, longitudinal performance tracking across neuron builds,
replacing manual script/bench.py runs and hand edits to benchmarks.md.
neuron build metadata + GET /version:
- cortex-core: shared BuildInfo type (build_info.rs).
- neuron build.rs captures git SHA (preferring injected HELEXA_BUILD_SHA,
else git, else "unknown"), dirty flag, build timestamp, rustc version,
profile, target, enabled cargo features, and best-effort candle-core
version from Cargo.lock.
- New GET /version endpoint (version.rs) + clap --version long form.
- SHA injected in CI (build-neuron step) and helexa-neuron.spec
(%{?helexa_commit}) so tarball RPMs report the real SHA. /version is
now the canonical "which build is live" probe.
helexa-bench crate:
- Continuous daemon: hits each neuron directly on :13131, exercises each
warm (status==loaded) model, records every run into a SQLite
system-of-record stamped with the neuron's full BuildInfo.
- Version-aware: skips any (target, build SHA, model, scenario) cell
already at samples_per_version, so a steady fleet costs only cheap
/version + /models polls until a new SHA ships.
- Extensible Scenario trait; phase-1 chat-latency family ported verbatim
from bench.py (synthetic 128/4096-tok prompts, /no_think, streamed
TTFT + decode-window tok/s). `report` regenerates the benchmarks table.
- kind="openai" comparison targets scaffolded, not yet wired.
Packaging: data/helexa-bench.service (+ sysusers), prebuilt-binary RPM
spec (outbound-only, no firewalld), and build/package/publish wiring in
build-prerelease.yml with change detection.
Tests: cortex-core BuildInfo round-trip, neuron GET /version integration,
helexa-bench unit (prompt/SSE/config/store) + end-to-end sweep
(record -> skip -> resume on new SHA). Docs updated (benchmarks.md,
CLAUDE.md addendum).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The neuron fleet builds with `cuda cudnn flash-attn`, but nothing in
neuron uses flash-attn: the qwen3_5 (27B) arch is hand-rolled, the
candle-transformers qwen3 model has no flash path, llama is built with
use_flash_attn=false, and `grep flash crates/neuron/src` is empty. The
feature only pulls in candle-flash-attn's sm_80/sm_86 CUDA kernel
sweep — which is exactly where ptxas SIGSEGVs/hangs in #42 (3 hits in
one day, the last a ~4-hour hang that stalled the whole deploy behind
the ampere job).
Dropping the feature removes the #42 failure surface at the root (not
a mitigation) and cuts the longest, most fragile part of each flavour
build. No runtime change — nothing called those kernels. Removed from
all three flavour builds in build-prerelease.yml and from deploy-dev.yml;
ci.yml's cuda-check already used `--features cuda` only.
Closes#42
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First phase of speculative decoding: the pure, state-free acceptance
logic and per-target config, unit-tested in isolation before the
draft/verify loop and GDN-state rollback wire it into the generation
path.
greedy_accept walks the drafter's K proposed tokens against the
target's greedy token at each of the K+1 positions, accepting the
longest matching prefix and always committing one bonus token on top
(the target's correction at the first mismatch, or a free extra token
when the whole draft matched). So a round commits 1..=K+1 tokens —
never zero, guaranteeing forward progress even with a useless drafter.
Greedy is exact for temperature-0 (the fleet probe + #22 bench
regime); stochastic acceptance is a later phase.
SpeculativeConfig carries the drafter id (must share the target's
tokenizer — Qwen3.5-0.8B for the Qwen3.6-27B target, both qwen3_5,
byte-identical tokenizer, confirmed on beast) and the draft length K.
6 unit tests: full accept, partial accept, zero accept (progress
guarantee), last-position mismatch, single-token draft, config
gating. Not yet wired into the decode path — phase 2 (single-GPU
draft/verify) follows. Design + phasing on the issue.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The single-GPU vision path was still single-shot: a long vision-bearing
prompt to a single-GPU-loaded qwen3_5 had the OOM exposure the TP path
shed in fa01350 (it was only guard-rejected, never served).
Mirror TpQwen3_5ForCausalLM::prefill_with_images_chunked onto the
single-GPU Qwen3_5ForCausalLM: encode the image(s) once, walk the
pre-expanded prompt in prefill_chunk_tokens() windows splicing the
per-chunk <|image_pad|> rows, accumulate KV + GDN state across chunks
via the growing offset, keep the last chunk's logits. Interleaved
M-RoPE positions are computed once over the whole prompt and sliced
per chunk (an image compresses the position space, so per-chunk offset
arithmetic would be wrong) — so Qwen3_5Model::forward_inner gains an
explicit position_ids path alongside the internal-from-grids
(single-shot) and plain (text/decode) paths, plus a forward_with_positions
entry point. The device-worker ForwardLogitsWithImages handler now
calls the chunked method; chunk size comes from prefill_chunk_tokens()
on the worker thread, so the Job/handle surface and the callers are
unchanged.
The shared validate_vision_prefill VRAM/KV backstop stays (TP keeps it
too) — chunking bounds activation memory, not the accumulating KV
cache, so the guard still does useful work.
Verified on real weights (Qwen3.5-0.8B): extended the #15 vision
reference test to also run the chunked path with chunk_size=64 over the
217-token prompt (4 chunks; the ~196-token image-pad run spans them).
Chunked vs single-shot logits: cosine 1.000000, max_abs 0.0001;
argmax matches the HF reference. The test covers all three
forward_inner branches (text plain / single-shot vision / chunked
vision) on a real single-GPU qwen3_5 load.
Closes#18
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
doc/plan/* is gitignored, so the P1 learnings briefing could never be
committed. Move it to doc/learnings/p1.md (verbatim) and add
doc/learnings/p2.md capturing the P2 sprint (#11/#23/#1/#15).
The P2 doc's headline: CI green != correct. Four correctness bugs
passed every CI gate and surfaced only on the live fleet (post-gen
snapshots never re-match reasoning models; full-prompt snapshots
break on BPE retokenization; the chunked delta-rule's nilpotent-
squaring shortcut NaNs on correlated keys; the 0.8B masked two of
these by luck). Plus the device-worker/TP state patterns, the
deploy-dev + systemd-drop-in A/B loop, the per-package change-
detection fleet-split failure mode (#42), and the f32-fixture
numerical-validation rig (#15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
script/dump_reference.py captures fixtures from the HF qwen3_5
implementation (token ids + reference tensors, f32 by default so the
comparison pins math rather than dtype noise);
tests/numerical_reference.rs replays them through our arch and
asserts argmax equality, cosine similarity, and max-abs ceilings. The
tests self-skip without NEURON_REF_MODEL_PATH so CI stays green
without weights.
Measured on beast (f32-vs-f32): text logits max_abs 0.000 / cosine
1.000000 (the >64-token prompt routes through the chunked GDN
prefill, so the production prefill math is what's validated); vision
tower cosine 0.999998, end-to-end vision logits cosine 1.000000 with
identical argmax. Mutation sensitivity: NEURON_VISION_LEGACY_POS=1
collapses tower cosine to 0.75 and fails loudly.
One production fidelity fix the harness surfaced: the pos-embed
bilinear blend now accumulates in f32 and casts once at the end,
matching the reference (we previously rounded the weights to bf16
before blending).
Fixtures: 0.8B text + vision (f32), 27B text (bf16 — an f32 27B
forward needs ~108 GB; the automated comparison runs against the
0.8B, which executes the same arch modules). Regeneration documented
in tests/fixtures/numerical/README.md.
Closes#15
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QTensor::quantize runs its per-block math strictly sequentially on
one core (CUDA storage round-trips through the same CPU path), which
made Q6K ISQ the dominant phase of the 27B TP cold load. Blocks are
independent, so quantize_parallel re-implements the same encoding
through candle's public per-block API (k_quants::GgmlType::from_float)
with rayon fanning blocks across the CPU pool — byte-identical output,
pinned by parity tests against QTensor::quantize for Q6K/Q5K/Q4K/Q8_0.
Threading discipline holds: the device-to-host read and the
QStorage::from_data upload stay on the calling thread (device worker /
subprocess main); rayon workers touch host memory only.
Also adds the per-phase timing the issue asked for first: per-layer
debug + layer-loop total + lm_head info lines, so the next cold load
shows where the time actually goes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live A/B on beast produced NaN logits ("!!!" replies) on real prompts:
the nilpotent-squaring form of (I - T)^-1 computes raw powers of T,
whose entries grow combinatorially (path counts ~ C(62,31)) before
nilpotency collapses them — fine on uncorrelated test data, f32
precision death on real prompts whose repetitive text makes keys
highly correlated. The reference's forward-substitution loop never
forms raw powers; its intermediates are the convergent M entries.
Port the reference loop faithfully (rows accumulate into a fresh
tensor). New adversarial parity test with near-identical keys and
beta ~= 1 diverges to 8e30 under the squaring form and passes under
forward substitution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prefill (seq_len >= 64) now runs the chunk-parallel gated delta rule
ported from the HF reference torch_chunk_gated_delta_rule
(chunk_size=64): identical math reorganised into per-chunk batched
matmuls (cuBLAS/tensor cores on CUDA, gemm on CPU) instead of the
O(L)-sequential per-token recurrence. Decode steps and short prompts
keep the recurrent paths (CUDA kernel / Rust loop) unchanged.
One deliberate deviation from the reference: its in-place row-by-row
UT-transform computes (I - T)^-1 - I by forward substitution; T is
strictly lower triangular and therefore nilpotent at chunk size 64,
so the same inverse is the product of six squarings
prod_{j=0..5}(I + T^(2^j)) — batched matmuls instead of 63 sequential
row updates, which suits candle's immutable tensors. Chunk-local math
runs rank-3 over a flattened B*H*N batch dim (candle matmul supports
at most two batch dims).
Initial-state continuation is supported, so chunked prefill composes
with #11's restored prefix snapshots. Both single-GPU and TP paths
pick this up through the shared run_delta_rule dispatch.
NEURON_GDN_CHUNKED=0 forces the recurrent paths for A/B measurement.
Parity tests pin chunked against recurrent (2e-4 abs) across padding
(L=130), exact multiples with non-zero initial state (L=128 after a
50-token prefix), and a single exact chunk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Appends the 2026-06-12 post-prefix-cache run: 27B @4k warm TTFT
7.07 s -> 1.43 s, no-cache control models unchanged, with a
methodology note that repeated-prompt cells now measure warm TTFT on
qwen3_5-arch models.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second finding from live 27B validation: prompt-covering snapshots
still never matched. The rendered prompt ends with
`<|im_start|>assistant\n`, and when the next turn re-tokenizes that
text followed by the assistant's reply, BPE merges the trailing
newline with the reply's first characters — the final token(s) of the
cached sequence differ from the next prompt's, so the exact-prefix
match never fires. (A reply starting with an atomic special token
like <think> masks this, which is why the 0.8B check passed.)
Snapshot one past the last <|im_start|> instead: special tokens are
hard segmentation points, so ids up to and including it are provably
identical across renders. Prefill pauses at that boundary to capture
the snapshot, then finishes the ~2-token `assistant\n` tail. Applied
to all six request paths; unit tests for the cut helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live validation on beast's Qwen3.6-27B showed reused=0 on every turn:
the post-generation snapshot includes reasoning tokens (<think>...)
that get stripped when the client echoes the assistant message back,
so the cached sequence is never a token-prefix of the next prompt.
quadbrat's 0.8B only matched because its think block round-tripped as
literal text.
Snapshot after prefill instead (covering exactly the prompt tokens) —
that is the state the next turn provably extends under a stable chat
template, regardless of how reasoning or tool-call content is
transformed on echo. Taken after the first healthy sample so
NaN-poisoned prefills never cache their state; this also retires the
forwarded-token bookkeeping and the consumer-hangup store sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two errors only the cuda config surfaces: the TpSnapshotKv dispatch
arms mixed candle and anyhow error types, and restore_or_clear_tp held
the registry MutexGuard across the cleanup await inside a let-chain
(making the TP request futures non-Send). Bind the removed ref before
awaiting, same discipline as the other lock sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the prefix cache to tensor-parallel models — Qwen3.6-27B on
beast, where the TTFT win is largest. Closes#11.
Every rank holds its shard's snapshot under one pool-minted id: the
leader's lives in the device worker beside the TP slab
(Job::TpSnapshotKv / TpRestoreKv / TpDropKvSnapshot), each subprocess
rank stores its own in-process via new WorkerRequest variants
(SnapshotKvCache / RestoreKvCache / DropKvSnapshot). Shard state has
the same shape as single-GPU (attention ConcatKvCache + GDN
conv/recurrent state + rope_delta), so the snapshot types are reused;
all ranks sit at the same token boundary because step fan-out is
synchronous.
Consistency on partial failure: a failed restore falls back to
clear-all-ranks + full prefill (and drops the entry); a failed
snapshot drops the id on every rank so nothing half-stored leaks.
DropTp / UnloadModel invalidate a model's snapshots with it, covering
auto-recovery. Vision requests bypass as on single-GPU. Budget
accounting uses leader bytes x world_size (shards are symmetric).
Wired into both TP request paths (non-streaming inner + streaming
orchestration task); chunked_prefill_tp gains the restored-offset
start.
Closes#11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stop discarding cache state between requests. When an incoming
prompt's token sequence starts with the exact tokens of a stored
snapshot, restore it and prefill only the divergent suffix.
For the hybrid qwen3_5 arch a snapshot is attention ConcatKvCache k/v
+ GatedDeltaNet conv/recurrent state + the rope_delta counter, all at
one token boundary; the recurrent state cannot rewind, so matching is
exact-prefix only. GDN states are deep-copied both directions (the
CUDA delta-rule kernels mutate the state buffer in place); attention
k/v snapshots share storage safely (append-by-cat never mutates).
Snapshots live in the device worker's state next to the model slab
(Job::SnapshotKv / RestoreKv / DropKvSnapshot); the async side holds
only an opaque id + token sequence + byte size. DropArch drops a
model's snapshots with it, so unload and auto-recovery invalidate for
free. CPU loads hold snapshots inline on the legacy path.
Per-model LRU registry (harness/prefix_cache.rs) bounded by
[harness.candle.prefix_cache] budget_mb / max_entries, enabled by
default; inserting a snapshot drops entries it strictly extends.
Vision requests and candle-transformers archs bypass the cache
entirely (clear-every-request, unchanged).
Covers the single-GPU worker path (streaming + non-streaming) and the
CPU-local path. The TP path (Qwen3.6-27B on beast) is a follow-up PR
that closes#11 with before/after bench numbers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The /v1/messages handler translated request envelopes but proxied raw
OpenAI SSE frames back to streaming Anthropic clients — the gap
between the README's "point your tooling at it once" contract and
what Claude Code actually received.
cortex-core gains AnthropicStreamTranslator, a pure per-stream state
machine: OpenAI chunks in, ordered (event, payload) pairs out —
message_start → content_block_start/delta/stop (text and tool_use
blocks, indexed; tool_calls map to input_json_delta) → message_delta
(stop_reason mapped via the now-shared map_stop_reason, which also
teaches the non-streaming path tool_calls→tool_use) → message_stop.
Without an upstream usage frame the output count falls back to the
delta count (engine-exact for neuron's one-chunk-per-token streams,
#31); with one, input/output tokens ride message_delta.
cortex-gateway gains anthropic_sse: the wire pump that splits the
upstream byte stream into SSE events, parses data: payloads
(leniently — engines omit fields on special frames), feeds the
translator, and frames results as `event:`/`data:` pairs through a
bounded channel (slow client back-pressures the upstream read).
Upstream truncation without [DONE] still closes the Anthropic event
sequence. Nothing is buffered beyond the current event's bytes.
Tests: 5 state-machine unit tests (text flow, stop-reason mapping +
defaults, tool_use blocks, usage propagation, idempotent finish) and
2 gateway integration tests (full event sequence + text reassembly,
usage propagation into message_delta). Validated end-to-end by
running this branch's gateway against a production neuron and
streaming a live Anthropic request.
Closes#24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
script/bench.py: stdlib-only, works against any OpenAI-compatible /v1
endpoint (helexa, llama.cpp, Ollama, vLLM) so cross-engine tables are
a concatenation via the --label column. Measures the operator-felt
trio per (model, prompt-size) cell: TTFT (first SSE content chunk),
decode tok/s (visible tokens over the first→last chunk window,
chunk-per-token engine invariant since streaming usage frames aren't
emitted yet — #31), total wall-clock. Medians over N runs after one
warmup; append-only JSONL for longitudinal tracking.
Measurement traps found against the live fleet and handled:
- thinking models burn the budget invisibly (reasoning deltas are
off-wire by default) — the prompt appends Qwen's /no_think soft
switch
- short coalesced replies collapse the decode window to one TCP read
— rates require a ≥200 ms window and the prompt demands ~300 words
doc/benchmarks.md: method, fleet table, and the first published
numbers (2026-06-12, 8f6f1d3): 1.7B@3060 81 tok/s, 8B@4090 62 tok/s,
27B@2×5090 Q6K TP=2 35 tok/s with flat decode from 128→4k context —
and the 7.1 s 4k-prefill TTFT recorded as #23's before-number.
Refs #22 (competitor baselines still pending — the harness is ready
for them)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A deploy previously went green the moment systemd reported the
service started — a merge that broke model loading or inference
itself would deploy "successfully" and only surface when a human
noticed. Each neuron deploy now earns its green:
1. Wait for default models: poll /health until activation.state is
ready, with per-host timeouts in the matrix (beast 900s for the
27B Q6K TP=2 cold-load, benjy/quadbrat 300s). Any entry in
activation.failed fails the deploy with the per-model error —
the structured equivalent of watching the journal for
"loaded default model", plus failure detail the journal line
can't carry.
2. LLM smoke probe: ask the first loaded model to reply with one
specific word (max_tokens 512 so thinking models have room,
temperature 0) and grep the response for it. Not a quality bar —
just proof the deploy didn't lobotomize inference.
Hosts whose package is already current still skip everything — the
validation cost is only paid when a restart actually happened. The
probe was dry-run against benjy's production neuron before landing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deferred Phase 6b, and the unblock for the 7→8 milestone's
benchmark work (#22): until cortex measures itself per request,
nothing downstream can be benchmarked or graphed.
The proxy wraps the upstream byte stream in a pass-through inspector
(TokenMetricsStream): chunks are forwarded verbatim — never buffered
or re-serialised — while the inspector records arrival times and
keeps a bounded (64 KiB) tail of the body text. At stream end (or
client disconnect, via Drop) it extracts the final OpenAI usage
object — present on the last SSE chunk and non-streaming JSON bodies
alike — for engine-truth token counts.
Per request, labelled {model, node}:
- cortex_time_to_first_token_seconds (histogram) — first body chunk
- cortex_tokens_per_second (histogram) — completion tokens over the
decode window (first→last chunk); falls back to total request
duration for single-chunk non-streaming bodies
- cortex_prompt_tokens_total / cortex_completion_tokens_total
(counters)
The extractor is pure and chunk-boundary-safe; quoted-needle matching
keeps completion_tokens_details from shadowing completion_tokens,
and the last usage object wins. Covers chat completions, completions,
the Responses API, and the Anthropic streaming path (which currently
proxies OpenAI SSE).
Tests: 4 extractor unit tests; integration test with a streaming
mock emitting a stream_options-style final usage chunk, asserting
both histograms and exact-or-greater counter values (the test
recorder is process-global and shared across the binary's tests).
Closes#21
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The un-rebooted driver update (userspace libs bumped, kernel module
still old) kills every CUDA call on the host including nvidia-smi,
and neuron surfaced it only as `Comm::from_rank ... NcclError` deep
inside the first model load — 30 minutes of forensics on beast
(2026-06-08) to diagnose. Make it instantly legible instead:
- discovery distinguishes nvidia-smi absent (CPU-only, fine) from
present-but-failing, classifies the "Driver/library version
mismatch" signature, and pairs the userspace NVML version with the
loaded kernel-module version from /proc/driver/nvidia/version.
- DiscoveryResponse gains `cuda_unavailable_reason` (omitted when
None — wire-compatible) so cortex can see why the node has no
devices and route around it.
- startup logs one loud ERROR line with the actionable reason
("reboot the host to reload the kernel module") and skips default
model loads entirely, marking each failed with that reason so
/health activation shows the real cause.
- POST /models/load fast-rejects with 503 + code=cuda_unavailable on
a mismatch host instead of dying minutes later in cuInit/NCCL.
No false positives: other nvidia-smi failures (no devices, perms)
keep their existing behaviour, CPU-only hosts stay silent.
Closes#19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 375 proved the CUDA image ships sccache (probe step printed
"sccache enabled") but the wrapper never reached cargo: the runner
does not propagate GITHUB_ENV across steps, so the builds ran
unwrapped (server stats: 4 compile requests for a ~600-crate build,
durations unchanged). Probe and export inside the build step's own
shell instead, in both build-neuron and ci.yml's cuda-check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The f5fa840 deploy exposed both failure modes of gating with
`dnf check-update` as the gitea_ci user in one run: it hung
indefinitely on quadbrat (blocked process, 0 CPU, killed manually),
and on benjy/beast it silently reported "no updates" two minutes
after new RPMs were published — both hosts skipped a real (luckily
binary-identical) update.
Gate with data we own instead: fetch packages.json from
rpm.lair.cafe (plain curl, no privileges, no dnf locks), take the
newest release per package by buildTime, and skip the
stop/upgrade/start cycle only when it exactly equals
`rpm -q %{VERSION}-%{RELEASE}`. Unreachable or unparsable manifest
fails open to a full deploy. The dnf transaction itself still runs
under the scoped sudoers rules, unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 3 CUDA flavour builds (10-14 min each, the critical path of every
full run) and build-cortex compiled entirely uncached. With the
gongfoo-side sccache hardening in place, wire them up:
- build-cortex: full sccache env (rust image ships it) + the standard
escalation loop (retry -> server restart -> uncached final attempt).
- build-neuron: probe for sccache before enabling the wrapper — the
CUDA image may not ship it, and a missing binary must degrade to an
uncached build, not fail cargo at `sccache rustc -vV` (the original
reason the wrapper was cleared here). rustc compilations are shared
across all three flavours; candle-kernels' nvcc output stays
uncached (build-script artifact).
- ci.yml cuda-check: same probe pattern replaces the blanket env
clear; also pins CUDA_COMPUTE_CAP=86 since the image no longer
ships nvidia-smi for candle-kernels' fallback detection (mirrors
9bb9678 on the #20 branch).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
candle-kernels' build script shells out to nvidia-smi for compute-cap
detection when CUDA_COMPUTE_CAP is unset; the current GPU-less builder
image doesn't ship it, so the type-check died in the build script
before borrow-checking anything. Pin an arbitrary valid cap — the
check is feature-gate compilation only; real caps live in
build-prerelease.yml's flavour matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
During the #17 auto-recovery window (unload → reload, minutes for a
large TP model) the model's registry slot is absent, so it vanished
from neuron's /models — and cortex, routing by /models presence,
answered "model not found on any node" while a direct request to
neuron would have correctly said "recovering, retry shortly".
neuron: the recovery set becomes a map carrying a devices/capabilities
snapshot taken at trigger time (while the registry slot still exists).
list_models reports `recovering` for models in the set — both while
the poisoned slot is still present and during the reload gap, where
the snapshot keeps the model listed.
gateway: ModelStatus grows a Recovering variant (parsed from the
wire); the router holds the route — new RouteError::ModelRecovering
mapped to 503 instead of 404 — and deliberately does not fall through
to the catalogue cold-load, which would race a second placement
against the in-flight recovery. The evictor already ignores
non-Loaded entries.
Tests: neuron unit test (recovering model stays listed with snapshot),
gateway integration tests (poller parses `recovering`; request gets
503 retry-shortly and the model stays on /v1/models).
Closes#20
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 361's Test job failed all 3 attempts with the sccache
dead-server signature (sccache fatal error, ENOENT on its own tmp
files under target/debug/deps). Retrying the same invocation only
helps for transient races; against a wedged server every same-VM
retry fails identically — and under the new pipeline that blocks
publish and the deploy behind it.
Escalate instead: attempt 1 plain, attempt 2 after an sccache server
restart, attempt 3 with RUSTC_WRAPPER unset (uncached). A sick cache
now costs build minutes, never the deploy. Applied to the lint/test
jobs in build-prerelease.yml and ci.yml alike.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Push-to-testable was ~20.5 min for every commit (measured on the
2026-06-08 green chain) plus a ~5 min 27B cold-load, regardless of
what changed. Three structural fixes:
- build-prerelease: a change-detection step in `prepare` diffs HEAD
against the git sha embedded in the last *published* unstable RPM
(per package, from packages.json) and skips builds whose inputs
didn't change. Docs-only commits build nothing; gateway-only
commits skip the 3 CUDA flavour builds. Detection failures fall
open to a full build.
- ci.yml no longer runs on pushes to main; fmt/clippy/test live in
build-prerelease as parallel jobs gating publish. The two workflows
previously queued against each other on the same runner labels,
delaying the cortex build ~12 min. Branches, PRs, and tags keep the
full ci.yml gate.
- deploy: each host self-gates with `dnf check-update` and leaves the
service untouched when the installed package is already current —
no more neuron restarts (and 27B cold-loads) for commits that
didn't change neuron.
- deploy-dev (new): manual single-host fast path — build one CUDA
flavour, scp the binary, restart the service. Skips packaging,
signing, publish, and dnf entirely. Backed by a new exact-form
sudoers rule in asset/sudoers.d/neuron-host.conf (already applied
to all three hosts).
Expected loop times when runners behave: docs ≈ 1 min (nothing
deploys), gateway-only ≈ 6-8 min, single-neuron dev ≈ 8-10 min,
full fleet ≈ 13-15 min.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lead with what helexa is for — near-frontier open-weight models on
consumer hardware you own — instead of a feature list. Adds the scope
section (intentional divergence from vLLM/SGLang; CUDA-only today as a
test-coverage constraint, not a principle), an engine section covering
the per-device worker threads and consumer-GPU tensor parallelism, the
previously-missing helexa-acp crate, and a status section pointing at
git.lair.cafe as the source of truth with GitHub as read-only mirror.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
helexa is the project; cortex (per-operator control plane / LLM proxy)
and neuron (per-host LLM harness) are its components. The Gitea repo
is now helexa/helexa. Update repository URLs in Cargo metadata, RPM
specs, and docs; make the CI changelog push URL rename-proof via the
github.repository context; reframe README.md and CLAUDE.md around the
project name. Binary, package, service, and config-path names are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fork's new commit makes `Comm: Send + Sync` (asserting NCCL's
thread-safety invariant upstream) and makes `Comm::abort` idempotent via
an `aborted` flag (so abort-then-Drop can't double-free) — strictly
better than the previous Drop-no-panic workaround, and the `abort()`
signature is unchanged so the watchdog call site is unaffected.
Because `Comm` is now `Send + Sync`, `Arc<Comm>` and the `SendComm` /
`NcclState` wrappers auto-derive `Send`/`Sync`, which conflicts (E0119)
with neuron's manual `unsafe impl`s. Remove the four now-redundant impls
— the safety assertion lives upstream in cudarc where it belongs. The
conflict is in cuda-gated code, so only the CUDA type-check catches it
(non-cuda build + clippy + tests stay green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No code change. Each deploy run, the degraded CI runner kills a different
single arch build (blackwell, then ada) ~fast, and the all-arch-gated
packaging skips → no publish. Every arch HAS built green across runs
(blackwell ✅ in 342, ampere ✅, ada ✅ in 339) and the gate + CUDA
type-check pass. Re-running to catch all three green in one run so the
Stage-2 RPMs publish. Runner FS/cache health is the real fix (separate
infra work).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No code change. The c94a2ae deploy's neuron-blackwell build died ~12min
into the Blackwell kernel compile on the degraded runner, while
neuron-ampere + neuron-ada built the identical Rust + patched cudarc
cleanly and the CUDA type-check passed. Transient infra; re-running to
get a healthy blackwell build so the RPMs publish and beast (Blackwell)
picks it up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`super::nccl_state` from tp/mod.rs resolves to `crate::harness::nccl_state`
(nonexistent); the module is the child `nccl_state` (cf. the existing
`nccl_state::generate_comm_id_hex` call). The field is cuda-gated so the
non-cuda build couldn't catch it; the branch CUDA type-check flaked on the
runner before compiling. Self-audited fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a hung NCCL collective recoverable instead of a permanent brick.
Today a wedged collective hangs the in-process leader thread forever, and
even Stage 1's recovery can't help — its unload's DropTp queues behind the
stuck thread and hangs too.
- Cache the leader's NCCL Comm handle async-side at init (new cuda-gated
Job::GetLeaderComm → DeviceWorkerHandle::get_leader_comm → stored on
WorkerPool.leader_comm). Fetched while the thread is responsive — a
wedged thread can't service the fetch, which is why it's cached up front.
- Wrap the leader forward in both generate_step and
generate_step_with_images in tokio::time::timeout (default 120s,
NEURON_TP_STEP_TIMEOUT_S). On expiry the watchdog calls
Comm::abort() (ncclCommAbort) on the cached handle from the async
thread — the one NCCL op sanctioned concurrently with an in-flight
collective — which unblocks the leader thread, then fails the step
WITHOUT draining (workers are wedged too; recovery's unload kills them).
The error is a device fault → poison → Stage 1 auto-recovery, which now
completes because the leader thread is responsive again.
- Bumps the cudarc patch to dbc425a (adds the Drop-must-not-panic fix so
the post-abort comm teardown during recovery doesn't double-abort-panic).
Logs the whole sequence at ERROR with greppable `tp watchdog:` /
`ncclCommAbort` markers so a real-world hang leaves a forensic trail —
verification is by inspecting journals after real hangs, not a synthetic
harness. cuda-gated → validated by the blackwell build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#17 Stage 2 (TP hang-recovery) needs to call ncclCommAbort on a LIVE
communicator from another thread — to unblock a collective wedged on a
dead/hung peer so the ranks can resync. No cudarc release (incl. main)
exposes this: the safe Comm only aborts in Drop, which can't fire while a
stuck thread holds an Arc<Comm> clone.
Pin neuron's cudarc 0.19.7 to a fork (grenade/cudarc @ nccl-comm-abort,
rev 4dff0be) adding three thin methods — Comm::abort, get_async_error,
and a raw comm() accessor — to be submitted upstream. The patch targets
0.19.x only; candle's transitive cudarc 0.17.8 stays on crates.io.
Foundation only; the watchdog + abort + comm-rebuild that consume these
land in follow-up commits (cuda-gated → validated by the blackwell build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No code change. The abc6e60 deploy's neuron-ada build died on the
degraded CI runner (container dropped mid-checkout), skipping the
gated publish — even though neuron-blackwell + neuron-ampere compiled
the Stage-1 fault-recovery code cleanly. Re-running to get a healthy
ada build so the RPMs publish and beast picks up the build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-shot, env-gated fault injector for beast verification: when
NEURON_DEBUG_POISON names a model, the first request for it triggers the
auto-recovery path as if a device fault had occurred — exercising
unload→reload→healthy without corrupting the GPU. Latched so it fires
exactly once (no recovery loop). No-op unless the env var is set; wired
into both the single-GPU and TP chat poison gates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When an inference hit a device fault, the model was flagged poisoned and
every subsequent request rejected with "unload and reload the model to
recover" — until a *human* did exactly that. Now the harness rebuilds the
context automatically.
- Retain the loading `ModelSpec` on `LoadedModel`/`TpLoadedModel` (+
`LoadedHandle::spec()`) so a poisoned model can be reloaded without an
operator reconstructing the spec.
- A background recovery task (held via `Weak<CandleHarness>`, spawned in
`new()` when a runtime is present) drains poisoned model ids and runs
`unload_model` → `load_model(spec)`. Unload drops the model → cudarc
`Comm::drop` aborts NCCL + releases the context; reload re-runs NCCL
init + sanity inside the load path, so a successful reload yields a
fresh, healthy model. A failed reload leaves it unloaded (next load
retries) — never poisoned forever.
- The request-entry poison gates now `trigger_recovery` (single-flight
per model via a `recovering` set) and return a transient "recovering,
retry shortly" error instead of the manual-reload message. Requests
that arrive during the brief reload gap (model absent from the registry)
also get "recovering" rather than a misleading "not loaded".
`new()` now returns `Arc<Self>`. Recovery runs only on the background
task — never inline on the request path, which holds `inference_lock`
and would deadlock on the `models` write lock.
Stage 1c of the #17 plan (verified-healthy auto-recovery). Watchdog
(1b) + a fault-injection hook for beast verification follow. The
in-process rank-0 leader's own context fault still needs a reload that
can't rebind it (Stage 3); comm-desync + worker faults recover here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beast testing surfaced a real regression in the dynamic-resolution
default: a tall 808×1600 image resized (within the 1024² max_pixels) to a
90×44 patch grid = 3960 patches, exceeding the vision tower's hard
`num_position_embeddings = 2304` pos-embed budget. The per-rank
`patch count 3960 exceeds pos_embed budget 2304` error fired mid-TP-
forward and poisoned the device context, bricking the model until reload.
Hard-cap `max_pixels` to `2304 × 16² = 589_824` px (≤ 2304 patches →
≤ 576 LM tokens), clamping even the operator env override. `smart_resize`
floors the pixel count under the cap, so no resized image can ever exceed
the budget — the tower check never fires, no poison. The pos-embed grid
(48×48) is the resolution Qwen3.6 was trained at, so the cap is
principled, not just defensive. Still ~3× the old fixed 196 tokens, and
the book-cover OCR test (1176 patches) already reads full title+subtitle.
Test: a huge/tall/wide/extreme image battery stays within the 2304 patch
budget. (Per-rank-error poison robustness itself remains issue #17.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:30:47 +03:00
406 changed files with 57346 additions and 1049 deletions
echo "no default models configured — skipping LLM probe"
exit 0
fi
echo "LLM probe against ${model}"
probe_body=$(printf '{"model":"%s","messages":[{"role":"user","content":"Reply with exactly one word: pineapple"}],"max_tokens":512,"temperature":0}' "${model}")
# The probe races real traffic: a deploy publishes a new build
# SHA, which is exactly what triggers helexa-bench to re-sweep
# every scenario against this neuron — long capability-probe
# generations can hold the batch-1 model for minutes. Admission
# control answers concurrent requests with 429/503 +
# Retry-After (#53/#63); treat those as "busy, try again", not
helexa is a self-hosted LLM serving stack for multi-node GPU inference clusters. It has two components:
- **cortex** — the per-operator control plane and LLM proxy. A Rust reverse-proxy that sits in front of the fleet and presents a unified OpenAI + Anthropic compatible API surface. It handles model routing, lifecycle management (load/unload/evict), request translation, and metrics collection.
- **neuron** — the per-host LLM harness. One instance runs on every GPU host, serving candle-based in-process inference and managing local hardware discovery and model lifecycle.
- **cortex** is the control plane. It exposes the unified API, routes requests, manages model lifecycle across the fleet, and collects metrics.
- **neuron** is the node plane. One instance runs on every GPU host. It discovers local hardware, manages in-process candle inference, handles NCCL tensor parallelism, and reports runtime state.
- cortex never shells out to `nvidia-smi`, never touches systemd units, and never talks directly to a harness. It talks only to neurons via HTTP API on port 13131.
### Per-device worker thread (neuron)
Every CUDA device gets one dedicated OS thread that owns its `CudaContext` for the daemon's lifetime. All CUDA operations route through this thread via a `std::sync::mpsc` job channel. Tensors never escape the worker thread alive. Inference replies carry `Vec<f32>` CPU-side logits; sampled tokens come back as `u32`. The opaque `ArchHandle(u64)` and `TpHandle(u64)` are indices into the worker's state slab, not pointers.
CPU loads (`Device::Cpu` fallback) keep the legacy `tokio::task::spawn_blocking + Arc<Mutex<ModelArch>>` path — there's no context to own and the channel hop would only add latency. Four `spawn_blocking` references in `harness/candle.rs` are deliberate CPU fallback.
### candle-native (not mistral.rs)
neuron builds directly on [candle](https://github.com/huggingface/candle). Every model architecture it serves is implemented in this repository, ported against the HuggingFace reference. No external inference server to babysit. The Harness trait remains as an internal seam for adding future engines (vision/audio/diffusion) but its only implementation is in-process candle.
### Streaming proxy
Chat completions are proxied as SSE streams. The gateway must:
1. Parse the inbound request to extract the model name
2. Route to the correct backend neuron
3. Stream the response back, capturing token timing for metrics
4. NOT buffer the full response — true streaming passthrough
### Anthropic translation
When a request arrives at `/v1/messages` (Anthropic format), the gateway translates it to OpenAI format before proxying to neuron, then translates the response back. This is stateless envelope transformation. Non-streaming round-trip is implemented; streaming SSE translation deferred.
### Eviction
The evictor runs as a background task. Before loading a model on a node where VRAM is tight:
1. Check if the model is already loaded elsewhere → route there instead
2. Find the LRU model on the target node (excluding pinned models)
3. Call `POST {neuron}/models/unload` on that model
4. The incoming request's lazy-load triggers the new model load
### Metrics
Per-request: model, node, prompt_tokens, completion_tokens, total_tokens, tok_per_sec, time_to_first_token_ms, total_latency_ms. Exposed as Prometheus histograms/counters on a separate port (31314).
## Tech Stack
- **Rust 2024 edition** — workspace with 6 crates
- **Axum 0.8** — HTTP framework
- **reqwest** — HTTP client for proxying to backends
Run these locally before pushing. `cargo fmt --all` fixes formatting automatically. Clippy warnings must be resolved, not suppressed with `#[allow(...)]` unless there is a clear rationale.
Tagged releases (`v*`) build SRPMs for `cortex`, `helexa-neuron`, and `helexa-bench` and publish to COPR (`helexa/helexa`). Build metadata SHA injection: CI sets `HELEXA_BUILD_SHA=$(git rev-parse HEAD)`.
## Environment
- Targets Fedora 43 (systemd, SELinux enforcing)
- Nodes communicate over a private network (e.g. WireGuard mesh)
- cortex listens on port 31313 (API) and 31314 (metrics)
- neuron listens on port 13131 on each GPU host
- TLS terminated at gateway or via nginx; internal traffic is plaintext over WireGuard
## Conventions
- Error handling: `anyhow` for binaries, `thiserror` for library crates
- No `unwrap()` in library code; `expect()` only with clear rationale
- All public types derive `Debug, Clone, Serialize, Deserialize` where sensible
- Config structs use `figment` with TOML as primary source, env vars as override
- Prefer `Arc<RwLock<...>>` for shared fleet state; minimize lock duration
- SSE streaming uses `tokio_stream` + `eventsource-stream` for parsing
- Log at `info` for request routing, `debug` for proxy details, `warn` for eviction and node health, `error` for proxy failures
## Testing
### Gateway tests
Use mock neurons spawned via axum in `crates/cortex-gateway/tests/common/mod.rs`. Helpers: `spawn_mock_backend()`, `spawn_gateway()`.
### neuron integration tests
- Numerical reference tests (`numerical_reference.rs`) require `NEURON_REF_MODEL_PATH` env var pointing to a HF snapshot directory. Fixtures are f32-based for precision validation against HuggingFace transformers.
- CUDA integration tests (`tp_worker_lifecycle_cuda.rs`) gated behind `cuda-integration` feature; requires 2+ CUDA devices (e.g., 2x RTX 5090).
### Metrics testing
Use `install_test_recorder()` in test code to capture metrics without the HTTP listener.
## helexa-bench
A continuous, version-aware benchmark harness. Hits each neuron directly on `:13131`, exercises each warm model with a Scenario suite (chat-latency family), and records results into SQLite stamped with the neuron's full `BuildInfo`. The loop is version-aware: skips any (target, build SHA, model, scenario) cell already at `samples_per_version`.
Packaged as `helexa-bench` RPM (prebuilt-binary spec). One systemd unit, typically on the metrics host.
## helexa-acp
Agent Client Protocol bridge — connects ACP editors (Zed, etc.) to any OpenAI-compatible endpoint, cortex by default. Intentionally self-contained: no workspace crate dependencies. Uses `agent-client-protocol` with `unstable_session_model` feature for Zed model picker support. Licensed Apache-2.0 (workspace is GPL-3.0).
## RPM Packaging
-`cortex.spec` — installs the `cortex` binary
-`helexa-neuron.spec` — installs the `neuron` binary under package name `helexa-neuron` (renamed to avoid Fedora's NEURON neural-simulation package collision)
- Systemd units in `data/cortex.service`, `data/neuron.service`
- Example configs: `cortex.example.toml`, `neuron.example.toml`, `models.example.toml`
Install:
```sh
dnf copr enable helexa/helexa
dnf install cortex # gateway host
dnf install helexa-neuron # GPU nodes
```
## Configuration Files
### cortex.toml (gateway)
```toml
[gateway]
listen="0.0.0.0:31313"
metrics_listen="0.0.0.0:31314"
[eviction]
strategy="lru"# lru | priority
defrag_after_cycles=50
[[neurons]]
name="beast"
endpoint="http://beast.internal:13131"
```
### models.toml (catalogue)
```toml
[[models]]
id="Qwen/Qwen3-Coder-30B-A3B-Instruct"
harness="candle"
quant="Q4_K_M"
vram_mb=19000
min_devices=2
min_device_vram_mb=10000
pinned_on=["beast"]# optional: never evict from these neurons
```
### neuron.toml (per-host)
Configured via figment + env override. See `neuron.example.toml` for reference.
## neuron API Endpoints
```
GET /discovery → hardware discovery (hostname, OS, CUDA, devices, harnesses)
GET /health → runtime GPU stats (VRAM, utilization, temperature)
GET /models → loaded/unloaded models with VRAM usage
POST /models/load → load a model with spec (quant, TP, devices)
POST /models/unload → unload a model, freeing device memory
GET /models/{id}/endpoint → inference URL for a model
GET /version → build metadata (SHA, features, candle version, etc.)
```
## Sources of Truth
When prose documentation conflicts with code, trust:
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.