Compare commits

..

96 Commits

Author SHA1 Message Date
d3ef9513f8 fix(neuron): as_deref for Arc'd prefix_cache in cuda-only worker path (#98)
All checks were successful
CI / Format (push) Successful in 7s
CI / Clippy (push) Successful in 2m19s
CI / Test (push) Successful in 6m0s
CI / CUDA type-check (push) Successful in 12m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / Clippy (pull_request) Successful in 3m4s
CI / Test (pull_request) Successful in 6m16s
CI / CUDA type-check (pull_request) Successful in 12m38s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-03 15:24:00 +03:00
ec7a5b750a feat(neuron): lockstep batched decode engine (#98)
Some checks failed
CI / Format (push) Successful in 7s
CI / Clippy (push) Successful in 2m14s
CI / Test (push) Successful in 5m25s
CI / CUDA type-check (push) Failing after 12m25s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-07-03 15:03:43 +03:00
7f2fcc3527 feat(neuron): batch-row extraction for rebatch on join/leave (#98)
All checks were successful
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m27s
CI / Test (push) Successful in 5m41s
CI / CUDA type-check (push) Successful in 12m3s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-07-03 14:46:49 +03:00
9ac1a1586c feat(neuron): device-worker jobs for batched decode (#98)
Some checks failed
CI / Test (push) Waiting to run
CI / CUDA type-check (push) Waiting to run
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m17s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
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
2026-07-03 14:42:06 +03:00
05b6c70a3a feat(neuron): batched lockstep decode groundwork for qwen3_5 (#98)
Some checks failed
CI / CUDA type-check (push) Waiting to run
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m23s
CI / Test (push) Successful in 5m51s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
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
2026-07-03 14:34:31 +03:00
9c037c5a0b Merge pull request 'perf(neuron): route only prefill through FlashAttention (#95)' (#120) from fix/95-flash-prefill-only into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 11s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m27s
build-prerelease / Test (push) Successful in 6m43s
build-prerelease / Build neuron-blackwell (push) Successful in 9m0s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m11s
build-prerelease / Build neuron-ada (push) Successful in 2m12s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 47s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 48s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 2m54s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 21s
2026-07-03 09:29:51 +00:00
cb6ddbbd98 perf(neuron): route only prefill through FlashAttention (#95)
All checks were successful
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m17s
CI / Test (push) Successful in 5m55s
CI / CUDA type-check (push) Successful in 12m39s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / Clippy (pull_request) Successful in 2m21s
CI / Test (pull_request) Successful in 6m59s
CI / CUDA type-check (pull_request) Successful in 12m39s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
On-beast A/B (27B, 30,419-token prompt, 2x RTX 5090, e824ea3):
prefill 24.8s eager → 22.1s flash (~11%, diluted by the hybrid's
~25% full-attention layer ratio), but decode REGRESSED ~20%
(50 → 60 ms/token at 30k KV) — FA2 without flash-decoding is weak at
query-length 1 and the per-step layout transposes add overhead.
Greedy outputs byte-identical in both modes (parity gate passed,
incl. chunked-prefill causal alignment).

Restrict the flash dispatch to q_len > 1: prefill keeps the win,
decode keeps the eager path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
2026-07-03 12:15:03 +03:00
e824ea3274 Merge pull request 'perf(neuron): FlashAttention on the qwen3_5 full-attention layers (#95)' (#119) from feat/95-flash-attn into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m25s
build-prerelease / Test (push) Successful in 7m24s
build-prerelease / Build neuron-blackwell (push) Successful in 8m52s
build-prerelease / Build helexa-bench binary (push) Successful in 1m32s
build-prerelease / Build cortex binary (push) Successful in 1m54s
build-prerelease / Build neuron-ada (push) Successful in 2m7s
build-prerelease / Build neuron-ampere (push) Successful in 2m8s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m11s
build-prerelease / Package helexa-bench RPM (push) Successful in 23s
build-prerelease / Package cortex RPM (push) Successful in 25s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 43s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 48s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 2m51s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 22s
2026-07-03 08:47:24 +00:00
9139b07d71 fix(neuron): candle-flash-attn must be a direct optional dependency (#95)
All checks were successful
CI / Format (push) Successful in 7s
CI / Clippy (push) Successful in 2m18s
CI / Test (push) Successful in 5m38s
CI / CUDA type-check (push) Successful in 12m35s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 8s
CI / Clippy (pull_request) Successful in 2m14s
CI / Test (pull_request) Successful in 6m59s
CI / CUDA type-check (pull_request) Successful in 12m35s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-03 11:31:28 +03:00
4762537225 perf(neuron): FlashAttention on the qwen3_5 full-attention layers (#95)
Some checks failed
CI / Format (push) Successful in 13s
CI / Clippy (push) Successful in 2m21s
CI / Test (push) Successful in 5m27s
CI / CUDA type-check (push) Failing after 12m37s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-07-03 11:14:47 +03:00
9b635bfe5c Merge pull request 'fix(helexa-bench): thinking-model streams misread as dead + impossible decode rates (#117)' (#118) from fix/117-bench-thinking-metrics into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 1m32s
build-prerelease / Package helexa-bench RPM (push) Successful in 21s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m25s
build-prerelease / Test (push) Successful in 7m5s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 13s
2026-07-02 20:10:21 +00:00
28aa06b768 fix(helexa-bench): thinking-model streams misread as dead + impossible decode rates (#117)
All checks were successful
CI / Format (push) Successful in 7s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m17s
CI / Test (push) Successful in 5m49s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m35s
CI / Clippy (pull_request) Successful in 2m26s
CI / Test (pull_request) Successful in 6m50s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 23:03:15 +03:00
fc95faaed6 Merge pull request 'fix(neuron): admission queue leaked slots when clients cancelled mid-wait' (#115) from fix/admission-cancel-leak into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Successful in 1m18s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m22s
build-prerelease / Test (push) Successful in 7m20s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 1m57s
build-prerelease / Build neuron-ampere (push) Successful in 2m7s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 46s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 44s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 46s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 16s
2026-07-02 13:36:11 +00:00
2df829ed73 fix(neuron): admission queue leaked slots when clients cancelled mid-wait
All checks were successful
CI / Format (push) Successful in 7s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m25s
CI / Test (push) Successful in 5m48s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m35s
CI / Clippy (pull_request) Successful in 2m15s
CI / Test (pull_request) Successful in 7m10s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 16:28:50 +03:00
f611e3479b Merge pull request 'fix(neuron): truncated-mid-think non-streaming responses are all reasoning (#112)' (#113) from fix/112-prompt-opened-reasoning into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Successful in 1m19s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m37s
build-prerelease / Test (push) Successful in 7m30s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 1m58s
build-prerelease / Build neuron-ampere (push) Successful in 2m5s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 45s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 44s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 48s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 14s
2026-07-02 09:04:50 +00:00
41ec05e40c fix(neuron): truncated-mid-think non-streaming responses are all reasoning (#112)
All checks were successful
CI / Format (push) Successful in 9s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m16s
CI / Test (push) Successful in 5m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m36s
CI / Clippy (pull_request) Successful in 2m20s
CI / Test (pull_request) Successful in 7m19s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 11:57:52 +03:00
3242cce39b chore(bench): capability probes need 4096 tokens for the Thinking variant (#94)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 11s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m22s
build-prerelease / Test (push) Successful in 6m43s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
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
2026-07-02 10:47:18 +03:00
35041f11de Merge pull request 'fix(neuron): route the fused MoE block in the activation dtype (#92)' (#111) from fix/92-fused-router-dtype into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Build neuron-blackwell (push) Successful in 1m29s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m10s
build-prerelease / Build neuron-ada (push) Successful in 2m10s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m40s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 47s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 43s
build-prerelease / Test (push) Successful in 5m43s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m3s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 18s
2026-07-02 04:34:40 +00:00
40fecb2cf7 fix(neuron): route the fused MoE block in the activation dtype (#92)
All checks were successful
CI / Format (push) Successful in 7s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m17s
CI / Test (push) Successful in 5m43s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m39s
CI / Clippy (pull_request) Successful in 2m20s
CI / Test (pull_request) Successful in 6m25s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 07:27:43 +03:00
8d50918a1a Merge pull request 'perf(neuron): fused grouped-GEMM dispatch for the qwen3_next MoE block (#92)' (#110) from feat/92-moe-fused into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Successful in 1m23s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m47s
build-prerelease / Test (push) Successful in 6m15s
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m2s
build-prerelease / Build neuron-ampere (push) Successful in 2m11s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 44s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 48s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 50s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 15s
2026-07-02 03:53:11 +00:00
29a7054c23 perf(neuron): fused grouped-GEMM dispatch for the qwen3_next MoE block (#92)
All checks were successful
CI / Format (push) Successful in 8s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m17s
CI / Test (push) Successful in 5m33s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m38s
CI / Clippy (pull_request) Successful in 2m33s
CI / Test (pull_request) Successful in 5m56s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 06:46:06 +03:00
7489a49d24 fix(ci): render RPM release stamps in UTC regardless of committer TZ
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m21s
build-prerelease / Build neuron-blackwell (push) Successful in 1m40s
build-prerelease / Build helexa-bench binary (push) Successful in 1m36s
build-prerelease / Build cortex binary (push) Successful in 2m2s
build-prerelease / Build neuron-ampere (push) Successful in 2m25s
build-prerelease / Build neuron-ada (push) Successful in 2m27s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m23s
build-prerelease / Test (push) Successful in 5m32s
build-prerelease / Package helexa-bench RPM (push) Successful in 20s
build-prerelease / Package helexa-upstream RPM (push) Successful in 20s
build-prerelease / Package cortex RPM (push) Successful in 25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 44s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 44s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 49s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 18s
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
2026-07-02 01:29:46 +03:00
341f97f705 Merge pull request 'feat(neuron): TP expert sharding for the qwen3_next MoE block (#92)' (#109) from feat/92-qwen3_5-moe into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Build neuron-blackwell (push) Successful in 1m26s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m17s
build-prerelease / Test (push) Successful in 9m12s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 7m24s
build-prerelease / Build neuron-ampere (push) Successful in 7m39s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 42s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 45s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 47s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 15s
2026-07-01 21:54:11 +00:00
fc56eafbf7 fix(ci): deploy LLM probe retries on 429/503 instead of hard-failing
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m51s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 5m54s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
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
2026-07-02 00:44:14 +03:00
a157c8fa27 fix(neuron): gate TP MoE load tests to non-cuda builds (#92)
All checks were successful
CI / Format (push) Successful in 8s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 2m16s
CI / Test (push) Successful in 5m58s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 15s
CI / CUDA type-check (pull_request) Successful in 1m37s
CI / Clippy (pull_request) Successful in 2m22s
CI / Test (pull_request) Successful in 9m35s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 00:42:45 +03:00
201cc54a7b feat(neuron): TP expert sharding for the qwen3_next MoE block (#92)
Some checks failed
CI / Format (push) Successful in 6s
CI / CUDA type-check (push) Failing after 1m31s
CI / Clippy (push) Successful in 2m19s
CI / Test (push) Successful in 5m35s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-07-02 00:34:07 +03:00
781d92d9a3 Merge pull request 'feat(neuron): qwen3_next config + MoE FFN block, single-GPU path with exact HF parity (#92)' (#108) from feat/92-qwen3_5-moe into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 14s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m31s
build-prerelease / Test (push) Successful in 6m52s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m4s
build-prerelease / Build neuron-ampere (push) Successful in 2m10s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 46s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 50s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 58s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 16s
2026-07-01 21:23:23 +00:00
9bf13f09dd feat(neuron): qwen3_next MoE FFN block, single-GPU path + HF parity (#92)
All checks were successful
CI / Format (push) Successful in 8s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m17s
CI / Test (push) Successful in 5m17s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m38s
CI / Clippy (pull_request) Successful in 2m15s
CI / Test (pull_request) Successful in 6m54s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-07-02 00:16:19 +03:00
63fb8c6e76 chore(bench): enable 27B baseline capability + concurrency scenarios (#94)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 11s
build-prerelease / Build neuron-blackwell (push) Successful in 1m36s
build-prerelease / Build neuron-ampere (push) Successful in 2m11s
build-prerelease / Build neuron-ada (push) Successful in 2m7s
build-prerelease / Build helexa-bench binary (push) Successful in 2m43s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m15s
build-prerelease / Build cortex binary (push) Successful in 3m34s
build-prerelease / Build helexa-upstream binary (push) Successful in 3m38s
build-prerelease / Package helexa-bench RPM (push) Successful in 20s
build-prerelease / Package cortex RPM (push) Successful in 24s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 49s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 50s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 58s
build-prerelease / Test (push) Successful in 7m18s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 17s
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
2026-07-01 23:59:59 +03:00
a1426f177c feat(neuron): accept qwen3_next configs into the qwen3_5 arch (#92)
All checks were successful
CI / Format (push) Successful in 8s
CI / CUDA type-check (push) Successful in 1m59s
CI / Clippy (push) Successful in 2m26s
CI / Test (push) Successful in 6m42s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-07-01 23:59:44 +03:00
a9f5ac7ab4 Merge pull request 'feat(cortex): re-expose flat max_model_len / max_input_tokens / max_output_tokens on /v1/models (#78)' (#107) from feat/78-max-model-len into main
Some checks failed
build-prerelease / Lint (fmt + clippy) (push) Blocked by required conditions
build-prerelease / Build neuron-blackwell (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Test (push) Has been cancelled
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build helexa-upstream binary (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-upstream RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-07-01 20:58:08 +00:00
0126144a93 ci: retrigger after runner died mid-job (#78)
All checks were successful
CI / Format (push) Successful in 8s
CI / CUDA type-check (pull_request) Successful in 1m32s
CI / Format (pull_request) Successful in 6s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (pull_request) Successful in 3m12s
CI / Clippy (push) Successful in 3m29s
CI / Test (push) Successful in 10m39s
CI / Test (pull_request) Successful in 10m23s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
2026-07-01 23:44:01 +03:00
869033d08e feat(cortex): re-expose flat max_model_len/max_input_tokens/max_output_tokens on /v1/models (#78)
Some checks failed
CI / Format (push) Successful in 13s
CI / CUDA type-check (pull_request) Successful in 1m26s
CI / Format (pull_request) Successful in 1m35s
CI / Clippy (pull_request) Successful in 5m19s
CI / Test (push) Successful in 10m37s
CI / Test (pull_request) Failing after 10m25s
CI / Clippy (push) Failing after 14m38s
CI / CUDA type-check (push) Failing after 16m6s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
CI / Build cortex SRPM (pull_request) Has been cancelled
CI / Build neuron SRPM (pull_request) Has been cancelled
CI / Publish cortex to COPR (pull_request) Has been cancelled
CI / Publish neuron to COPR (pull_request) Has been cancelled
CI / Bump version in source (pull_request) Has been cancelled
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
2026-07-01 23:02:09 +03:00
7c5011e5d3 docs: remove milestone-b hand-off from source (moved to gitignored doc/plan/)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 2m3s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m16s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m24s
build-prerelease / Test (push) Successful in 6m37s
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 51s
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
2026-06-27 15:24:23 +03:00
d1366614f9 docs: Milestone B (epic #84) session hand-off brief
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Build helexa-bench binary (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 35s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m49s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
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
2026-06-27 15:19:40 +03:00
1884535542 Merge pull request 'feat(helexa-bench): capability probe scenario + quality scoring (#91)' (#106) from feat/91-bench-capability into main
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Successful in 44s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 2m17s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m58s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m19s
build-prerelease / Test (push) Successful in 7m34s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-27 12:10:20 +00:00
140f9124ee feat(helexa-bench): capability probe scenario + quality scoring (#91)
All checks were successful
CI / Format (push) Successful in 43s
CI / CUDA type-check (push) Successful in 2m35s
CI / Clippy (push) Successful in 2m46s
CI / Test (push) Successful in 5m54s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 43s
CI / CUDA type-check (pull_request) Successful in 1m53s
CI / Clippy (pull_request) Successful in 2m57s
CI / Test (pull_request) Successful in 7m42s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 15:03:13 +03:00
3b77dbfa32 Merge pull request 'feat(helexa-bench): cold-load / model-swap cost measurement (#90)' (#105) from feat/90-bench-swap-cost into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 41s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 2m29s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m26s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m22s
build-prerelease / Test (push) Successful in 7m24s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 56s
2026-06-27 11:22:36 +00:00
842caf5d4d feat(helexa-bench): cold-load / model-swap cost measurement (#90)
All checks were successful
CI / Format (push) Successful in 45s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m56s
CI / Test (push) Successful in 5m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 41s
CI / CUDA type-check (pull_request) Successful in 1m37s
CI / Clippy (pull_request) Successful in 3m8s
CI / Test (pull_request) Successful in 6m33s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 14:15:42 +03:00
a488ade675 Merge pull request 'feat(helexa-bench): context-length scaling view (#88)' (#104) from feat/88-bench-context-scaling into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 42s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 2m25s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m13s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m20s
build-prerelease / Test (push) Successful in 6m38s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 49s
2026-06-27 11:03:35 +00:00
d472b6428a feat(helexa-bench): context-length scaling view (#88)
All checks were successful
CI / Format (push) Successful in 38s
CI / CUDA type-check (push) Successful in 1m49s
CI / Clippy (push) Successful in 2m44s
CI / Test (push) Successful in 6m5s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 38s
CI / CUDA type-check (pull_request) Successful in 1m36s
CI / Clippy (pull_request) Successful in 3m12s
CI / Test (pull_request) Successful in 6m14s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 13:56:07 +03:00
0488768afc Merge pull request 'feat(helexa-bench): concurrency / agentic-load scenario (#89)' (#103) from feat/89-bench-concurrency into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 38s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m36s
build-prerelease / Test (push) Successful in 6m34s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 2m13s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m27s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 59s
2026-06-27 10:25:27 +00:00
209bf58207 feat(helexa-bench): concurrency / agentic-load scenario (#89)
All checks were successful
CI / Format (push) Successful in 37s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m46s
CI / Test (push) Successful in 6m17s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 38s
CI / CUDA type-check (pull_request) Successful in 1m36s
CI / Clippy (pull_request) Successful in 3m5s
CI / Test (pull_request) Successful in 6m16s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 13:17:10 +03:00
1ea7425585 Merge pull request 'feat(helexa-bench): per-run VRAM high-water + GPU util/temp from /health (#87)' (#102) from feat/87-bench-vram-telemetry into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 44s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 2m4s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m18s
build-prerelease / Lint (fmt + clippy) (push) Successful in 4m24s
build-prerelease / Test (push) Successful in 7m22s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 50s
2026-06-27 09:57:58 +00:00
a415454962 feat(helexa-bench): per-run VRAM high-water + GPU util/temp from /health (#87)
All checks were successful
CI / Format (push) Successful in 37s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m46s
CI / Test (push) Successful in 6m0s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 39s
CI / CUDA type-check (pull_request) Successful in 1m37s
CI / Clippy (pull_request) Successful in 2m58s
CI / Test (pull_request) Successful in 7m18s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 12:50:53 +03:00
06a36566d1 Merge pull request 'feat(helexa-bench): percentiles + prefill/decode split in store & report (#86)' (#101) from feat/86-bench-percentiles into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 31s
build-prerelease / Build neuron-blackwell (push) Successful in 1m39s
build-prerelease / Build neuron-ada (push) Successful in 2m15s
build-prerelease / Build neuron-ampere (push) Successful in 2m18s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m52s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m49s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m15s
build-prerelease / Build cortex binary (push) Successful in 2m39s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m51s
build-prerelease / Package cortex RPM (push) Successful in 1m15s
build-prerelease / Test (push) Successful in 6m52s
build-prerelease / Build helexa-bench binary (push) Successful in 2m12s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m52s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m19s
build-prerelease / Package helexa-upstream RPM (push) Successful in 1m18s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 54s
2026-06-27 09:08:53 +00:00
4cb52e3144 Merge pull request 'feat(neuron): server-measured prefill/decode timing on Finish (#85)' (#100) from feat/85-prefill-decode-timing into main
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Waiting to run
build-prerelease / Lint (fmt + clippy) (push) Has been cancelled
build-prerelease / Test (push) Has been cancelled
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build helexa-upstream binary (push) Has been cancelled
build-prerelease / Build neuron-blackwell (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-upstream RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-27 09:08:15 +00:00
afc1f7a706 feat(helexa-bench): percentiles + prefill/decode split in store & report (#86)
All checks were successful
CI / Format (push) Successful in 38s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 3m13s
CI / Test (push) Successful in 6m24s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 47s
CI / CUDA type-check (pull_request) Successful in 1m32s
CI / Clippy (pull_request) Successful in 3m11s
CI / Test (pull_request) Successful in 6m35s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 11:58:36 +03:00
6f956dfda3 fix(neuron): hoist TP prefill/decode timers out of 'work block (#85)
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m32s
CI / Clippy (push) Successful in 3m17s
CI / Test (push) Successful in 7m6s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 39s
CI / CUDA type-check (pull_request) Successful in 1m49s
CI / Clippy (pull_request) Successful in 2m58s
CI / Test (pull_request) Successful in 7m3s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-27 11:58:26 +03:00
6e0f15c888 feat(neuron): server-measured prefill/decode timing on Finish (#85)
Some checks failed
CI / CUDA type-check (push) Failing after 1m40s
CI / Format (push) Successful in 42s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 6m16s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-27 11:41:17 +03:00
66eb9f558f Merge pull request 'fix(neuron): surface reasoning_tokens in non-streaming /v1/responses usage' (#82) from fix/responses-usage-reasoning-tokens into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 47s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m4s
build-prerelease / Build neuron-ampere (push) Successful in 2m14s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m41s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m57s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m52s
build-prerelease / Test (push) Successful in 6m41s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
2026-06-26 18:30:05 +00:00
f96a2e7ed3 fix(neuron): surface reasoning_tokens in non-streaming /v1/responses usage
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m42s
CI / Test (push) Successful in 6m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 36s
CI / CUDA type-check (pull_request) Successful in 1m35s
CI / Clippy (pull_request) Successful in 2m54s
CI / Test (pull_request) Successful in 6m33s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-26 21:21:59 +03:00
b17b555a3d Merge pull request 'fix(neuron): strip &lt;think&gt; reasoning from non-streaming completions' (#81) from fix/responses-nonstreaming-reasoning-leak into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 46s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m3s
build-prerelease / Build neuron-ampere (push) Successful in 2m12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m40s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m51s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m47s
build-prerelease / Test (push) Successful in 6m26s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 54s
2026-06-26 17:47:45 +00:00
13daf95514 fix(neuron): strip <think> reasoning from non-streaming completions
All checks were successful
CI / Format (push) Successful in 39s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 2m47s
CI / Test (push) Successful in 5m51s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 40s
CI / CUDA type-check (pull_request) Successful in 1m38s
CI / Clippy (pull_request) Successful in 2m55s
CI / Test (pull_request) Successful in 6m20s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-26 20:39:41 +03:00
319b01e0b2 Merge pull request 'fix(neuron): accept bare {role,content} input on /v1/responses (agent-zero)' (#80) from fix/responses-easy-message-input into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 49s
build-prerelease / Build neuron-blackwell (push) Successful in 1m29s
build-prerelease / Build helexa-bench binary (push) Successful in 2m21s
build-prerelease / Build cortex binary (push) Successful in 3m23s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m50s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m39s
build-prerelease / Test (push) Successful in 6m43s
build-prerelease / Package cortex RPM (push) Successful in 1m30s
build-prerelease / Build neuron-ada (push) Successful in 2m7s
build-prerelease / Build neuron-ampere (push) Successful in 2m15s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m42s
build-prerelease / Package helexa-upstream RPM (push) Successful in 1m20s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m42s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m46s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 51s
2026-06-26 16:53:08 +00:00
6731adca51 fix(neuron): accept bare {role,content} input on /v1/responses (agent-zero)
All checks were successful
CI / Format (push) Successful in 41s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 2m45s
CI / Test (push) Successful in 6m14s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 39s
CI / CUDA type-check (pull_request) Successful in 1m36s
CI / Clippy (pull_request) Successful in 3m18s
CI / Test (pull_request) Successful in 7m6s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
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
2026-06-26 19:45:22 +03:00
7e11a7688c Merge feat/F6-beta-polish: public-beta banner + same-origin nginx + deploy (F6)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 39s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m17s
build-prerelease / Build neuron-blackwell (push) Successful in 1m43s
build-prerelease / Build neuron-ada (push) Successful in 2m15s
build-prerelease / Build neuron-ampere (push) Successful in 2m17s
build-prerelease / Build cortex binary (push) Successful in 2m50s
build-prerelease / Build helexa-bench binary (push) Successful in 2m25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m1s
build-prerelease / Build helexa-upstream binary (push) Successful in 4m8s
build-prerelease / Test (push) Successful in 6m28s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m18s
build-prerelease / Package helexa-upstream RPM (push) Successful in 1m19s
build-prerelease / Package cortex RPM (push) Successful in 1m24s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 58s
2026-06-23 12:13:35 +03:00
5600575ba2 Merge feat/B7-upstream-packaging: RPM + systemd + CI for helexa-upstream (B7)
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Has been cancelled
build-prerelease / Lint (fmt + clippy) (push) Has been cancelled
build-prerelease / Test (push) Has been cancelled
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build helexa-upstream binary (push) Has been cancelled
build-prerelease / Build neuron-blackwell (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-upstream RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 12:13:25 +03:00
bc7476bf1b feat(F6): public-beta polish — banner, same-origin nginx, deploy notes
All checks were successful
CI / CUDA type-check (push) Successful in 1m36s
CI / Format (push) Successful in 35s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 7m14s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 12:08:33 +03:00
5a8f6bc7b3 Merge feat/F5-auth-chat: authenticated chat + key usage (F5)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 35s
build-prerelease / Build neuron-blackwell (push) Successful in 1m41s
build-prerelease / Build helexa-bench binary (push) Successful in 2m7s
build-prerelease / Build neuron-ampere (push) Successful in 2m23s
build-prerelease / Build neuron-ada (push) Successful in 2m23s
build-prerelease / Build cortex binary (push) Successful in 3m12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m30s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m19s
build-prerelease / Package cortex RPM (push) Successful in 1m23s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m52s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m53s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m53s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 12:06:13 +03:00
452d7d9b3d feat(B7): packaging + CI for helexa-upstream
All checks were successful
CI / CUDA type-check (push) Successful in 1m32s
CI / Format (push) Successful in 57s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 8m0s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 12:00:41 +03:00
21eb211d6a Merge feat/B6-served-usage: served-usage ledger + reconciliation (B6, #58)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Package cortex RPM (push) Blocked by required conditions
build-prerelease / Package helexa-bench RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Successful in 1m25s
build-prerelease / Build neuron-ada (push) Successful in 2m3s
build-prerelease / Build neuron-ampere (push) Successful in 2m10s
build-prerelease / Build helexa-bench binary (push) Successful in 2m13s
build-prerelease / Build cortex binary (push) Successful in 3m25s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m45s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m51s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m50s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:56:06 +03:00
508b326bf7 feat(F5): authenticated chat + key usage integration
All checks were successful
CI / Format (push) Successful in 32s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 3m22s
CI / Test (push) Successful in 7m4s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 11:55:48 +03:00
0de99a8cc7 Merge feat/F4-account-dashboard: auth + account dashboard (F4)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 47s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m16s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:46:14 +03:00
f4117224fc feat(B6): served-usage ledger + reconciliation (#58)
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m53s
CI / Test (push) Successful in 6m41s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 11:45:57 +03:00
ce29e0c171 Merge feat/F3-anon-chat: anonymous chat landing + IndexedDB + SSE (F3)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 52s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 6m13s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:32:00 +03:00
1bf3348c8c feat(F4): auth + account dashboard (mockable client)
All checks were successful
CI / Format (push) Successful in 42s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 3m9s
CI / Test (push) Successful in 6m30s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 11:31:43 +03:00
7c12b9ea98 Merge feat/F2-mission: /mission route — EU digital sovereignty (F2)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 49s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m48s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 6m59s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:19:38 +03:00
c596519dbd feat(F3): anonymous chat landing — IndexedDB history + SSE + fingerprint
All checks were successful
CI / Format (push) Successful in 46s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 3m20s
CI / Test (push) Successful in 8m3s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 11:19:19 +03:00
a6b1fdc33d Merge feat/F1-theming-i18n: theming + 33-lang i18n + usage-ordered selector (F1)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 41s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Test (push) Successful in 5m37s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:10:33 +03:00
8dd82776f1 feat(F2): /mission route — European digital sovereignty
All checks were successful
CI / Format (push) Successful in 50s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 3m31s
CI / Test (push) Successful in 7m22s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 11:10:14 +03:00
8600d4fbf2 Merge feat/B5-topup-codes: single-use top-up codes + mint CLI (B5, #59)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m22s
build-prerelease / Test (push) Successful in 5m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:01:26 +03:00
7a6f252fe0 feat(F1): theming + 33-language i18n + usage-ordered language selector
All checks were successful
CI / Format (push) Successful in 40s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m50s
CI / Test (push) Successful in 6m34s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 11:01:04 +03:00
bb0d1e51b8 Merge feat/B3-cortex-upstream-client: cortex upstream entitlement client + chain (B3, #57)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 41s
build-prerelease / Build neuron-blackwell (push) Successful in 1m37s
build-prerelease / Build neuron-ampere (push) Successful in 2m14s
build-prerelease / Build neuron-ada (push) Successful in 2m14s
build-prerelease / Build helexa-bench binary (push) Successful in 2m45s
build-prerelease / Build cortex binary (push) Successful in 2m58s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m32s
build-prerelease / Test (push) Successful in 6m44s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m38s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m46s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m47s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m15s
build-prerelease / Package cortex RPM (push) Successful in 1m17s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 54s
2026-06-23 10:51:03 +03:00
2348cc2234 feat(B5): single-use top-up codes (redeem + mint CLI)
All checks were successful
CI / Format (push) Successful in 33s
CI / CUDA type-check (push) Successful in 1m33s
CI / Clippy (push) Successful in 3m5s
CI / Test (push) Successful in 6m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 10:50:43 +03:00
f2ba12bbc5 Merge feat/B4-account-api: /web/v1 account API + silent fingerprint abuse (B4, #59)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 33s
build-prerelease / Build neuron-blackwell (push) Successful in 1m36s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m34s
build-prerelease / Build neuron-ada (push) Successful in 2m5s
build-prerelease / Build neuron-ampere (push) Successful in 2m15s
build-prerelease / Build helexa-bench binary (push) Successful in 2m24s
build-prerelease / Build cortex binary (push) Successful in 2m34s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m39s
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 10:42:16 +03:00
a9d7382be8 feat(B3): cortex upstream entitlement client (#57) + chained provider
All checks were successful
CI / Format (push) Successful in 40s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m56s
CI / Test (push) Successful in 6m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 10:41:48 +03:00
d94c62c143 feat(B4): /web/v1 account API + silent fingerprint multi-account abuse
All checks were successful
CI / Format (push) Successful in 44s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 6m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 10:28:20 +03:00
cb9e7c7c2e chore: untrack helexa.ai/node_modules + dist (B2 .gitignore slip)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 36s
build-prerelease / Build neuron-blackwell (push) Successful in 1m41s
build-prerelease / Build neuron-ada (push) Successful in 2m20s
build-prerelease / Build neuron-ampere (push) Successful in 2m21s
build-prerelease / Build cortex binary (push) Successful in 3m27s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m43s
build-prerelease / Test (push) Successful in 6m11s
build-prerelease / Package cortex RPM (push) Successful in 1m35s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m46s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m52s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m54s
build-prerelease / Build helexa-bench binary (push) Successful in 2m0s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m20s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
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.
2026-06-23 10:27:37 +03:00
2604b9f134 Merge feat/B2-authz-api: /authz/v1 authority surface + client-auth + sweeper (B2)
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Successful in 45s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Has been skipped
build-prerelease / Test (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 10:25:27 +03:00
178e3092d5 Merge feat/F0-helexa-ai-scaffold: helexa.ai frontend scaffold (F0)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 53s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Build neuron-ada (push) Successful in 2m4s
build-prerelease / Build neuron-ampere (push) Successful in 2m12s
build-prerelease / Build cortex binary (push) Successful in 2m32s
build-prerelease / Build helexa-bench binary (push) Successful in 2m41s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m49s
build-prerelease / Package cortex RPM (push) Successful in 1m16s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m35s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m36s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m29s
build-prerelease / Test (push) Successful in 6m40s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 2m2s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
2026-06-23 10:12:38 +03:00
46befde4cd feat(B2): /authz/v1 authority surface + client-auth + reservation sweeper
All checks were successful
CI / Format (push) Successful in 49s
CI / CUDA type-check (push) Successful in 1m42s
CI / Clippy (push) Successful in 3m11s
CI / Test (push) Successful in 7m16s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 10:12:19 +03:00
cf87e156c5 Merge feat/B1-helexa-upstream-skeleton: helexa-upstream skeleton + schema + ledger (B1, #59)
Some checks are pending
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 32s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m19s
build-prerelease / Test (push) Successful in 6m30s
build-prerelease / Build neuron-ada (push) Successful in 2m5s
build-prerelease / Build neuron-ampere (push) Successful in 2m14s
build-prerelease / Build helexa-bench binary (push) Successful in 2m15s
build-prerelease / Build cortex binary (push) Successful in 2m24s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m43s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m18s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m42s
2026-06-23 09:58:58 +03:00
79073170ec feat(F0): helexa.ai frontend scaffold + monorepo coexistence
All checks were successful
CI / Format (push) Successful in 47s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m38s
CI / Test (push) Successful in 5m24s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 09:58:35 +03:00
71106afaf1 feat(B1): helexa-upstream crate skeleton + Postgres schema + ledger
All checks were successful
CI / CUDA type-check (push) Successful in 1m37s
CI / Format (push) Successful in 33s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 5m38s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-23 09:49:19 +03:00
d2dcdd6ebb Merge feat/74-outbound-tls-pinning: verify downstream cortex TLS certs (#74)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 32s
build-prerelease / Build neuron-blackwell (push) Successful in 1m48s
build-prerelease / Build helexa-bench binary (push) Successful in 1m59s
build-prerelease / Build neuron-ada (push) Successful in 2m23s
build-prerelease / Build neuron-ampere (push) Successful in 2m25s
build-prerelease / Build cortex binary (push) Successful in 2m58s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m8s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m22s
build-prerelease / Test (push) Successful in 5m37s
build-prerelease / Package cortex RPM (push) Successful in 1m14s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m38s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m38s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m41s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 54s
2026-06-21 21:29:15 +03:00
222c2a6116 Merge feat/75-federation-catalogue: aggregate /v1/models across operators (#75)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 40s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m33s
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-21 21:23:49 +03:00
1115bb0942 feat(#74): verify downstream cortex TLS certs (outbound pinning)
All checks were successful
CI / Format (push) Successful in 41s
CI / CUDA type-check (push) Successful in 1m34s
CI / Clippy (push) Successful in 2m23s
CI / Test (push) Successful in 5m19s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-21 21:23:20 +03:00
63f578cb15 feat(#75): aggregate /v1/models across operators (federation catalogue)
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m18s
CI / Test (push) Successful in 4m54s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-21 21:08:16 +03:00
76c90fa993 Merge feat/73-capacity-aware-dispatch: capacity-aware dispatch + region affinity + failover (#73)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 32s
build-prerelease / Build neuron-blackwell (push) Successful in 1m38s
build-prerelease / Build helexa-bench binary (push) Successful in 2m13s
build-prerelease / Build neuron-ampere (push) Successful in 2m19s
build-prerelease / Build neuron-ada (push) Successful in 2m20s
build-prerelease / Build cortex binary (push) Successful in 2m24s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m6s
build-prerelease / Test (push) Successful in 5m41s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m21s
build-prerelease / Package cortex RPM (push) Successful in 1m19s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m33s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m34s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m37s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 53s
2026-06-21 19:48:13 +03:00
7984d27553 feat(#73): capacity-aware dispatch with region affinity + failover
All checks were successful
CI / Format (push) Successful in 40s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m16s
CI / Test (push) Successful in 4m53s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-21 19:40:07 +03:00
43ffffdccb Merge feat/72-router-topology-poller: router↔cortex capacity & catalogue poller (#72)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 34s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m18s
build-prerelease / Build neuron-blackwell (push) Successful in 1m43s
build-prerelease / Build helexa-bench binary (push) Successful in 2m6s
build-prerelease / Build neuron-ada (push) Successful in 2m17s
build-prerelease / Build neuron-ampere (push) Successful in 2m18s
build-prerelease / Build cortex binary (push) Successful in 2m18s
build-prerelease / Test (push) Successful in 4m50s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m19s
build-prerelease / Package cortex RPM (push) Successful in 1m25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m41s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m40s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
2026-06-21 19:09:13 +03:00
5fd7736abd feat(#72): router↔cortex topology poller (multi-operator capacity map)
All checks were successful
CI / Format (push) Successful in 39s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m14s
CI / Test (push) Successful in 4m43s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-21 19:01:29 +03:00
03fd4960c3 Merge fix/71-shared-streaming-proxy: shared helexa-stream SSE proxy (#71)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 35s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m24s
build-prerelease / Build neuron-blackwell (push) Successful in 1m40s
build-prerelease / Build neuron-ampere (push) Successful in 2m17s
build-prerelease / Build helexa-bench binary (push) Successful in 2m11s
build-prerelease / Build neuron-ada (push) Successful in 2m18s
build-prerelease / Build cortex binary (push) Successful in 2m25s
build-prerelease / Test (push) Successful in 4m53s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m16s
build-prerelease / Package cortex RPM (push) Successful in 1m15s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m36s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m39s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 54s
# Conflicts:
#	Cargo.lock
#	Cargo.toml
2026-06-21 18:15:12 +03:00
5ed6bc3390 Merge feat/70-router-skeleton: helexa-router binary skeleton (#70)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 56s
build-prerelease / Build neuron-blackwell (push) Successful in 1m31s
build-prerelease / Build neuron-ada (push) Successful in 2m4s
build-prerelease / Build neuron-ampere (push) Successful in 2m11s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m29s
build-prerelease / Build cortex binary (push) Successful in 2m42s
build-prerelease / Build helexa-bench binary (push) Successful in 2m56s
build-prerelease / Package cortex RPM (push) Successful in 1m18s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m42s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m47s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m24s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 2m25s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-21 18:06:07 +03:00
cabec1d08a fix(#71): extract SSE streaming passthrough into shared helexa-stream
All checks were successful
CI / Format (push) Successful in 40s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m15s
CI / Test (push) Successful in 6m28s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
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
2026-06-21 18:05:35 +03:00
307 changed files with 32986 additions and 546 deletions

View File

@@ -66,6 +66,7 @@ jobs:
build_cortex: ${{ steps.changes.outputs.build_cortex }}
build_neuron: ${{ steps.changes.outputs.build_neuron }}
build_bench: ${{ steps.changes.outputs.build_bench }}
build_upstream: ${{ steps.changes.outputs.build_upstream }}
check_rust: ${{ steps.changes.outputs.check_rust }}
steps:
- uses: actions/checkout@v4
@@ -86,7 +87,14 @@ jobs:
# rpmvercmp ranks digit-prefixed segments above alpha ones.
# The SHA stays only as a debug identifier; sort order is
# decided entirely by the timestamp.
COMMIT_TIMESTAMP=$(git log -1 --format=%cd --date=format:%Y%m%d%H%M%S HEAD)
# format-local + TZ=UTC0 renders the stamp in UTC regardless
# of the committer's recorded timezone. Plain `format:` uses
# each commit's own TZ offset — local commits (UTC+3) and
# Gitea server-side merge commits (UTC) interleaved
# non-monotonically, letting an older build out-rank newer
# ones in RPM EVR comparison (the 2026-07-01 "235959" stamp
# that froze the fleet on a stale build).
COMMIT_TIMESTAMP=$(TZ=UTC0 git log -1 --format=%cd --date=format-local:%Y%m%d%H%M%S HEAD)
RELEASE="0.1.${COMMIT_TIMESTAMP}.git${SHORT_SHA}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
@@ -104,6 +112,7 @@ jobs:
BUILD_CORTEX=true
BUILD_NEURON=true
BUILD_BENCH=true
BUILD_UPSTREAM=true
CHECK_RUST=true
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
@@ -149,6 +158,7 @@ jobs:
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
# Any Rust change (incl. crates not packaged here, e.g.
# helexa-acp) still needs lint+test on main.
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
@@ -156,10 +166,12 @@ jobs:
CORTEX_BASE=$(base_for cortex)
NEURON_BASE=$(base_for helexa-neuron-blackwell)
BENCH_BASE=$(base_for helexa-bench)
UPSTREAM_BASE=$(base_for helexa-upstream)
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then
BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
CHECK_RUST=true
else
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
@@ -170,8 +182,9 @@ jobs:
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
# fmt + clippy + test moved here from ci.yml for main pushes so the
# two workflows stop queueing against each other (ci.yml's checks
@@ -303,6 +316,45 @@ jobs:
path: artifacts/helexa-bench
retention-days: 1
build-upstream:
name: Build helexa-upstream binary
timeout-minutes: 25
needs: prepare
if: needs.prepare.outputs.build_upstream == 'true'
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
runs-on: rust
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: sccache
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
SCCACHE_REGION: auto
SCCACHE_S3_USE_SSL: "false"
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
# helexa-upstream uses the sqlx runtime query API (no compile-time
# query macros), so it builds without a database or a .sqlx cache.
# Set OFFLINE defensively so a stray macro can never reach for a DB.
SQLX_OFFLINE: "true"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Build helexa-upstream (release, sccache escalation)
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
- name: Stage binary
run: |
mkdir --parents artifacts
cp target/release/helexa-upstream artifacts/helexa-upstream
./artifacts/helexa-upstream --version || true
- uses: actions/upload-artifact@v3
with:
name: upstream-fc43
path: artifacts/helexa-upstream
retention-days: 1
build-neuron:
name: Build neuron-${{ matrix.flavour }}
timeout-minutes: 35
@@ -332,7 +384,10 @@ jobs:
cuda_home: /usr/local/cuda-13.0
build_jobs: 8
nvcc_threads: 4
cargo_features: "cuda cudnn"
# flash-attn on blackwell first (#95): beast carries the
# agentic prefill pain; ada/ampere follow once the win is
# measured. NEURON_FLASH_ATTN=0 is the runtime rollback.
cargo_features: "cuda cudnn flash-attn"
runs-on: ${{ matrix.runner }}
env:
SCCACHE_BUCKET: sccache
@@ -459,6 +514,44 @@ jobs:
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-upstream:
name: Package helexa-upstream RPM
timeout-minutes: 20
needs: [prepare, build-upstream]
runs-on: rpm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/download-artifact@v3
with:
name: upstream-fc43
path: artifacts/
- name: Build RPM
run: |
set -eux
rm -f ~/.rpmmacros
rpmdev-setuptree
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
cp LICENSE ~/rpmbuild/SOURCES/
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
--undefine dist \
--define "dist .fc43"
- uses: actions/upload-artifact@v3
with:
name: rpm-upstream-fc43
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-neuron:
name: Package helexa-neuron-${{ matrix.flavour }} RPM
timeout-minutes: 20
@@ -508,7 +601,7 @@ jobs:
publish:
name: Publish to rpm.lair.cafe (unstable)
timeout-minutes: 25
needs: [lint, test, package-cortex, package-neuron, package-bench]
needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
# Runs when at least one package was built and nothing failed.
# lint/test may be skipped (docs-only refs never get here because
# no packages build), but a real failure in any blocks the
@@ -518,10 +611,11 @@ jobs:
!cancelled()
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped')
&& (needs.test.result == 'success' || needs.test.result == 'skipped')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
&& needs.package-cortex.result != 'failure'
&& needs.package-neuron.result != 'failure'
&& needs.package-bench.result != 'failure'
&& needs.package-upstream.result != 'failure'
}}
runs-on: rpm
concurrency:

View File

@@ -82,7 +82,9 @@ jobs:
# see commit history).
cuda-check:
name: CUDA type-check
timeout-minutes: 35
# flash-attn kernel compilation dominates the first uncached run;
# sccache + the cargo target cache absorb it afterwards.
timeout-minutes: 70
runs-on: cuda-13.0
# The workflow-level env sets `RUSTC_WRAPPER: sccache`
# unconditionally, which hard-fails cargo if the CUDA image
@@ -115,7 +117,7 @@ jobs:
export PATH="/usr/local/cuda-13.0/bin:${PATH}"
export LD_LIBRARY_PATH="/usr/local/cuda-13.0/targets/x86_64-linux/lib:/usr/local/cuda-13.0/lib64:${LD_LIBRARY_PATH:-}"
export LIBRARY_PATH="/usr/local/cuda-13.0/targets/x86_64-linux/lib:/usr/local/cuda-13.0/lib64:${LIBRARY_PATH:-}"
script/ci-cargo-escalate.sh cargo check -p neuron --features cuda --all-targets
script/ci-cargo-escalate.sh cargo check -p neuron --features cuda,flash-attn --all-targets
srpm-cortex:
name: Build cortex SRPM

View File

@@ -274,19 +274,52 @@ jobs:
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}")
resp=$(curl -fsS --max-time 180 -H "content-type: application/json" \
-d "${probe_body}" http://localhost:13131/v1/chat/completions) || {
echo "FAIL: probe request errored"
exit 1
}
if printf %s "${resp}" | grep -qi pineapple; then
echo "LLM probe passed"
else
echo "FAIL: probe response missing expected token"
printf %s "${resp}" | head -c 2000
echo
exit 1
fi
# 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
# deploy failure. Overall budget ~6 min.
probe_deadline=$(( $(date +%s) + 360 ))
attempt=0
while :; do
attempt=$(( attempt + 1 ))
hdrs=$(mktemp)
resp=$(curl -sS --max-time 180 -D "${hdrs}" \
-H "content-type: application/json" \
-d "${probe_body}" http://localhost:13131/v1/chat/completions) || resp=""
status=$(awk 'toupper($1) ~ /^HTTP/ {code=$2} END {print code}' "${hdrs}")
retry_after=$(awk 'tolower($1) == "retry-after:" {print $2+0; exit}' "${hdrs}")
rm -f "${hdrs}"
if [ "${status}" = "200" ]; then
if printf %s "${resp}" | grep -qi pineapple; then
echo "LLM probe passed (attempt ${attempt})"
break
fi
echo "FAIL: probe response missing expected token"
printf %s "${resp}" | head -c 2000
echo
exit 1
fi
case "${status}" in
429|503) ;; # busy/queue-full — retryable
*)
echo "FAIL: probe request errored (HTTP ${status:-none})"
printf %s "${resp}" | head -c 2000
echo
exit 1
;;
esac
if [ "$(date +%s)" -ge "${probe_deadline}" ]; then
echo "FAIL: model still busy (HTTP ${status}) after probe budget"
exit 1
fi
wait_s=${retry_after:-15}
[ "${wait_s}" -ge 5 ] 2>/dev/null || wait_s=15
echo "model busy (HTTP ${status}), retrying in ${wait_s}s (attempt ${attempt})"
sleep "${wait_s}"
done
DEPLOY
- name: Ensure firewalld allows helexa-neuron

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
/target
/bench/node_modules
/bench/dist
/helexa.ai/node_modules
/helexa.ai/dist
helexa.ai/.env.local
*.swp
*.swo
.idea/

856
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ members = [
"crates/helexa-acp",
"crates/helexa-bench",
"crates/helexa-router",
"crates/helexa-stream",
"crates/helexa-upstream",
]
[workspace.package]

View File

@@ -18,6 +18,35 @@ db_path = "/var/lib/helexa-bench/bench.sqlite"
[scenarios]
prompt_sizes = [128, 4096]
max_tokens = 256
# Concurrency / agentic-load scenarios (#89), enabled so the 27B baseline
# carries p95-under-concurrency data before the F3 A/B gate (#94) needs
# it for comparison. Levels mirror the real a0/hermes/opencode fan-out.
concurrency_levels = [2, 4, 8]
concurrency_prompt_tokens = 512
# Capability probes (#91) — the reasoning/planning axis the speed
# scenarios miss; scored manually via `helexa-bench score` (O7). Enabled
# for the same reason: the F3 gate compares 80B-A3B variants against the
# 27B on planning quality, so the 27B needs scored artifacts first.
[[scenarios.capability_probes]]
name = "rust-plan"
max_tokens = 4096
prompt = """
Write an implementation plan for adding rate limiting to an Axum service.
Honor existing conventions, call out trade-offs, and sequence the work.
"""
[[scenarios.capability_probes]]
name = "debug-reason"
max_tokens = 4096
prompt = """
A Rust axum server streams SSE responses through a reverse proxy. Clients
report that streams stall for exactly 60 seconds and then resume, but only
when response chunks are small and infrequent. Curling the backend directly
never stalls. List the most likely causes in order of probability, explain
the mechanism behind each, and describe the smallest experiment that would
confirm or eliminate each cause.
"""
# Read-only JSON API consumed by the bench UI (hosted separately) and for
# programmatic access. Served alongside the sweep loop.

View File

@@ -90,3 +90,20 @@ account_id = "operator"
key_id = "infra"
# No hard_cap → uncapped operator infra key (own fleet, own use). Still
# metered for visibility.
# -- Upstream (helexa mesh) entitlements client (#57) --------------------
# When enabled, a bearer key NOT found in [[entitlements.keys]] above is
# validated against the helexa-upstream authority (mesh accounts), and its
# budget is reserved/settled there. Operator-local keys (incl. the infra
# key) never leave this process. Fail-closed: if upstream is unreachable a
# request is refused (503 + Retry-After), never served un-authorized.
# Disabled by default — a standalone operator runs purely local.
[upstream]
enabled = false
# url = "https://upstream.helexa.ai"
# Shared client bearer this cortex presents (maps to an operator_id
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
# bearer = "replace-with-operator-client-secret"
# timeout_secs = 5
# How often to flush served-usage counters to upstream for reconciliation (#58).
# served_usage_report_interval_secs = 60

View File

@@ -22,6 +22,43 @@ pub struct GatewayConfig {
/// setups keep working until keys are configured.
#[serde(default)]
pub entitlements: EntitlementsConfig,
/// helexa-upstream client (#57). When enabled, keys not found in the
/// local `[entitlements]` config are validated against the mesh
/// authority, and budget is reserved/settled there. Disabled by default
/// — a single operator runs purely local.
#[serde(default)]
pub upstream: UpstreamClientConfig,
}
/// `[upstream]` — the helexa-upstream authority client (#57). Locally
/// unrecognised bearer keys are resolved against `url`'s `/authz/v1` surface
/// (mesh accounts); local keys (operator + infra) never leave the process.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpstreamClientConfig {
/// Enable the upstream fallthrough. Off → purely local entitlements.
#[serde(default)]
pub enabled: bool,
/// Base URL of helexa-upstream (e.g. "https://upstream.helexa.ai").
#[serde(default)]
pub url: String,
/// Shared client bearer this cortex presents to `/authz/v1` (maps to an
/// operator_id upstream). Sent as `Authorization: Bearer <bearer>`.
#[serde(default)]
pub bearer: String,
/// Per-call timeout (seconds) to upstream.
#[serde(default = "default_upstream_timeout")]
pub timeout_secs: u64,
/// How often (seconds) to flush served-usage counters to upstream for
/// reconciliation (#58).
#[serde(default = "default_served_usage_interval")]
pub served_usage_report_interval_secs: u64,
}
fn default_upstream_timeout() -> u64 {
5
}
fn default_served_usage_interval() -> u64 {
60
}
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
@@ -129,6 +166,7 @@ impl Default for GatewayConfig {
neurons: vec![],
models_config: default_models_path(),
entitlements: EntitlementsConfig::default(),
upstream: UpstreamClientConfig::default(),
}
}
}

View File

@@ -81,12 +81,19 @@ pub struct BudgetSnapshot {
pub reserved: u64,
}
/// Authentication failure — the bearer key could not be resolved. Maps to
/// `401 invalid_api_key` (#49/#63).
/// Authentication failure — the bearer key could not be resolved.
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
/// The key is genuinely unknown → `401 invalid_api_key` (#49/#63).
#[error("invalid or unknown API key")]
InvalidKey,
/// The authority that could resolve the key is unreachable (e.g. the
/// helexa-upstream client failed, #57). Fail **closed** but distinctly:
/// a transient outage must surface as `503 service_unavailable` +
/// `Retry-After`, never `401` — a real key must not be rejected as
/// invalid during an upstream blip.
#[error("entitlement authority unavailable; retry in {retry_after_secs}s")]
Unavailable { retry_after_secs: u64 },
}
/// Why a reservation was refused. Carries enough for the caller to build the

View File

@@ -147,6 +147,39 @@ pub struct CortexModelEntry {
/// `true` when any neuron reports this model supports reasoning tokens.
#[serde(default)]
pub reasoning: bool,
// ── Flat ecosystem context-window fields (issue #78) ──────
// Duplicates of `limit` under the flat, vLLM-convention key names
// (`max_model_len` et al.) that OpenAI-ecosystem clients (Hermes
// Agent, vLLM tooling) probe for — they cannot see `limit.context`.
// Additive: `limit` stays the opencode-oriented source of truth.
// Derived, never set directly — call [`sync_flat_limit`] after the
// final `limit` value is known. Omitted (not `0`) when the window
// is unknown; absent-vs-zero is load-bearing, as with `cost`.
//
// [`sync_flat_limit`]: CortexModelEntry::sync_flat_limit
/// Served max-seq-len in tokens — mirrors `limit.context`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_model_len: Option<usize>,
/// Usable input budget — mirrors `limit.input` when present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_input_tokens: Option<usize>,
/// Maximum generation tokens — mirrors `limit.output`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<usize>,
}
impl CortexModelEntry {
/// Re-derive the flat ecosystem fields (#78) from `limit`.
///
/// Must run after the final `limit` is known (post merge/tightening),
/// immediately before serialization. Fully overwrites: a `None` limit
/// clears the flat fields, so stale values can't survive a merge that
/// dropped the limit.
pub fn sync_flat_limit(&mut self) {
self.max_model_len = self.limit.as_ref().map(|l| l.context);
self.max_input_tokens = self.limit.as_ref().and_then(|l| l.input);
self.max_output_tokens = self.limit.as_ref().map(|l| l.output);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -116,6 +116,23 @@ pub struct Usage {
/// prompt caching lands (#11); `None` until then.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<PromptTokensDetails>,
/// helexa extension (non-OpenAI): server-measured prefill/decode
/// timing, so the bench harness can compute true prefill vs decode
/// tok/s instead of inferring both from client-side SSE arrival
/// (#85). Additive and optional — standard OpenAI clients ignore
/// it; cortex forwards usage verbatim so it survives proxying.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub helexa_timing: Option<HelexaTiming>,
}
/// helexa extension carried on [`Usage::helexa_timing`]. Mirrors
/// neuron's internal `FinishTiming`. All fields are server-measured;
/// `prefill_tokens` is the prefill-rate denominator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelexaTiming {
pub prefill_ms: u64,
pub decode_ms: u64,
pub prefill_tokens: u64,
}
/// Sub-counts of `Usage::completion_tokens`.

View File

@@ -66,14 +66,48 @@ pub struct ResponsesRequest {
pub extra: Value,
}
/// `input` is either a single string or an array of typed items.
/// `input` is either a single string or an array of items.
/// `#[serde(untagged)]` so the wire shape `"input": "hi"` and
/// `"input": [{...}]` both deserialize.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesInput {
Text(String),
Items(Vec<ResponsesInputItem>),
Items(Vec<ResponsesInputElement>),
}
/// One element of an `input` array.
///
/// OpenAI's Responses API accepts three shapes here, and real clients
/// use all of them — most notably agent-zero (via litellm), which
/// sends the bare "easy message" form. We must tolerate every shape,
/// because `input` is an `#[serde(untagged)]` array: a single element
/// that matches no variant fails the *entire* request with a 422
/// (`did not match any variant of untagged enum ResponsesInput`).
///
/// 1. [`Self::Typed`] — an item carrying an explicit `"type"`
/// discriminant (`message`, `function_call`, `function_call_output`,
/// `reasoning`).
/// 2. [`Self::EasyMessage`] — a bare `{role, content}` with **no**
/// `type` field. This is OpenAI's `EasyInputMessage` and what
/// litellm emits for every turn. `content` is optional so an
/// assistant turn carrying only tool calls (`content: null`) still
/// parses.
/// 3. [`Self::Other`] — anything else, captured as raw JSON and
/// dropped during translation. This is the forward-compat escape
/// hatch that mirrors [`ResponsesRequest::extra`] at the item
/// level: an unmodeled item type can never again reject the whole
/// request.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesInputElement {
Typed(ResponsesInputItem),
EasyMessage {
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<ResponsesMessageContent>,
},
Other(Value),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -91,8 +125,11 @@ pub enum ResponsesInputItem {
name: String,
arguments: String,
},
/// User is feeding a tool result back into the model.
FunctionCallOutput { call_id: String, output: String },
/// User is feeding a tool result back into the model. `output`
/// is a `Value` because OpenAI allows it to be either a plain
/// string or an array of content parts; the translator renders
/// either form to text rather than losing the tool result.
FunctionCallOutput { call_id: String, output: Value },
/// Reasoning items emitted by o-series models. Accepted but
/// not forwarded to the model — neuron's candle path doesn't
/// surface reasoning separately yet.
@@ -132,6 +169,11 @@ pub enum ResponsesContentPart {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<Value>,
},
/// Any content-part type we don't model (e.g. `refusal`, audio).
/// Captured as a unit so an unknown part can't reject the whole
/// request; dropped during translation.
#[serde(other)]
Unknown,
}
// ── Response (non-streaming) ─────────────────────────────────────────
@@ -277,20 +319,116 @@ mod tests {
ResponsesInput::Items(items) => {
assert_eq!(items.len(), 1);
match &items[0] {
ResponsesInputItem::Message { role, content } => {
ResponsesInputElement::Typed(ResponsesInputItem::Message { role, content }) => {
assert_eq!(role, "user");
match content {
ResponsesMessageContent::Text(t) => assert_eq!(t, "hi"),
other => panic!("expected Text content, got {other:?}"),
}
}
other => panic!("expected Message item, got {other:?}"),
other => panic!("expected typed Message item, got {other:?}"),
}
}
other => panic!("expected Items, got {other:?}"),
}
}
#[test]
fn deserialises_bare_easy_message_without_type() {
// The shape agent-zero (via litellm) actually sends: `input`
// items are bare `{role, content}` with NO `type` field. This
// is the exact payload that was returning 422.
let raw = r#"{
"model": "Qwen/Qwen3.6-27B",
"store": true,
"tools": [{"type": "function", "name": "x", "description": "d", "parameters": {}}],
"input": [
{"role": "system", "content": "you are helpful"},
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
{"role": "user", "content": "hi"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let items = match req.input {
ResponsesInput::Items(i) => i,
other => panic!("expected Items, got {other:?}"),
};
assert_eq!(items.len(), 3);
for el in &items {
assert!(
matches!(el, ResponsesInputElement::EasyMessage { .. }),
"expected EasyMessage, got {el:?}"
);
}
// `tools` / `store` ride through `extra`, not `input`.
assert!(req.extra.get("tools").is_some());
assert_eq!(req.extra.get("store"), Some(&Value::Bool(true)));
}
#[test]
fn tolerates_null_content_and_unknown_item_types() {
// An assistant turn carrying only tool calls has `content: null`;
// and a future/unmodeled item type must not 422 the request.
let raw = r#"{
"model": "m",
"input": [
{"role": "assistant", "content": null},
{"type": "item_reference", "id": "abc"},
{"type": "function_call_output", "call_id": "c1",
"output": [{"type": "output_text", "text": "result"}]},
{"role": "user", "content": "go"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let items = match req.input {
ResponsesInput::Items(i) => i,
other => panic!("expected Items, got {other:?}"),
};
assert_eq!(items.len(), 4);
assert!(matches!(
&items[0],
ResponsesInputElement::EasyMessage { content: None, .. }
));
assert!(matches!(&items[1], ResponsesInputElement::Other(_)));
assert!(matches!(
&items[2],
ResponsesInputElement::Typed(ResponsesInputItem::FunctionCallOutput { .. })
));
assert!(matches!(
&items[3],
ResponsesInputElement::EasyMessage { .. }
));
}
#[test]
fn tolerates_unknown_content_part_type() {
// A `refusal` (or any unmodeled) content part must parse, not 422.
let raw = r#"{
"model": "m",
"input": [
{"role": "assistant", "content": [
{"type": "refusal", "refusal": "no"},
{"type": "output_text", "text": "ok"}
]}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let items = match req.input {
ResponsesInput::Items(i) => i,
other => panic!("expected Items, got {other:?}"),
};
let parts = match &items[0] {
ResponsesInputElement::EasyMessage {
content: Some(ResponsesMessageContent::Parts(p)),
..
} => p,
other => panic!("expected EasyMessage with Parts, got {other:?}"),
};
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], ResponsesContentPart::Unknown));
assert!(matches!(&parts[1], ResponsesContentPart::OutputText { .. }));
}
#[test]
fn deserialises_input_with_image() {
let raw = r#"{
@@ -308,10 +446,10 @@ mod tests {
other => panic!("expected Items, got {other:?}"),
};
let parts = match &items[0] {
ResponsesInputItem::Message {
ResponsesInputElement::Typed(ResponsesInputItem::Message {
content: ResponsesMessageContent::Parts(p),
..
} => p,
}) => p,
other => panic!("expected Parts, got {other:?}"),
};
assert_eq!(parts.len(), 2);

View File

@@ -400,6 +400,7 @@ pub fn openai_to_anthropic(resp: ChatCompletionResponse) -> MessagesResponse {
total_tokens: 0,
completion_tokens_details: None,
prompt_tokens_details: None,
helexa_timing: None,
});
MessagesResponse {
@@ -772,6 +773,7 @@ mod stream_tests {
total_tokens: 267,
completion_tokens_details: None,
prompt_tokens_details: None,
helexa_timing: None,
});
t.on_chunk(&usage_chunk);
let fin = t.finish();

View File

@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
cortex-core.workspace = true
helexa-stream = { path = "../helexa-stream" }
async-trait.workspace = true
tokio.workspace = true
axum.workspace = true

View File

@@ -22,7 +22,7 @@ use axum::http::header::AUTHORIZATION;
use axum::http::{HeaderMap, HeaderValue};
use axum::middleware::Next;
use axum::response::Response;
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
use cortex_core::entitlements::{AuthError, HEADER_ACCOUNT_ID, HEADER_KEY_ID};
use cortex_core::error_envelope::OpenAiError;
use std::sync::Arc;
@@ -83,14 +83,25 @@ pub async fn require_principal(
req.extensions_mut().insert(principal);
next.run(req).await
}
// An unrecognized key only hard-fails when auth is *required*.
// In allow-anonymous mode (the default) we must IGNORE it and
// serve the request unauthenticated — otherwise the placeholder
// keys that OpenAI-compatible clients send by default (opencode,
// Open WebUI, Agent Zero, litellm) would all break, even though
// the operator never opted into auth. Pre-#49 the bearer was
// never inspected at all; this preserves that for require_auth=false.
Err(_) => {
// The entitlement authority is unreachable (upstream client
// blip, #57). Fail **closed but distinct**: a transient outage
// must not reject a real key as `401 invalid_api_key` — it's a
// retryable `503`. This holds regardless of require_auth: we
// can't safely serve a key we couldn't authorize.
Err(AuthError::Unavailable { retry_after_secs }) => {
envelope_response(OpenAiError::service_unavailable(
"entitlement authority temporarily unavailable",
Some(retry_after_secs),
))
}
// A genuinely unrecognized key only hard-fails when auth is
// *required*. In allow-anonymous mode (the default) we IGNORE it
// and serve unauthenticated — otherwise the placeholder keys that
// OpenAI-compatible clients send by default (opencode, Open WebUI,
// Agent Zero, litellm) would all break though the operator never
// opted into auth. Pre-#49 the bearer was never inspected; this
// preserves that for require_auth=false.
Err(AuthError::InvalidKey) => {
if fleet.require_auth {
unauthorized("invalid API key")
} else {

View File

@@ -0,0 +1,112 @@
//! Chained entitlement provider (#57): operator-local keys first, mesh
//! upstream for everything else.
//!
//! `resolve` tries the [`LocalEntitlementProvider`] (operator + infra keys —
//! never a network hop); only a locally-unknown key falls through to
//! [`UpstreamEntitlementProvider`]. Because the local provider treats an
//! unconfigured principal as uncapped, reserve/settle/release/snapshot must
//! **not** blindly hit local — they dispatch to whichever backend resolved
//! that account, remembered in a map keyed by `account_id` (populated at
//! resolve time).
use crate::entitlements_local::LocalEntitlementProvider;
use crate::entitlements_upstream::UpstreamEntitlementProvider;
use async_trait::async_trait;
use cortex_core::entitlements::{
AuthError, BudgetError, BudgetSnapshot, EntitlementProvider, Principal, Reservation,
};
use std::collections::HashMap;
use tokio::sync::RwLock;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Backend {
Local,
Upstream,
}
pub struct ChainedEntitlementProvider {
local: LocalEntitlementProvider,
upstream: UpstreamEntitlementProvider,
/// account_id → which backend owns it, learned at resolve time.
backends: RwLock<HashMap<String, Backend>>,
}
impl ChainedEntitlementProvider {
pub fn new(local: LocalEntitlementProvider, upstream: UpstreamEntitlementProvider) -> Self {
Self {
local,
upstream,
backends: RwLock::new(HashMap::new()),
}
}
async fn record(&self, account_id: &str, backend: Backend) {
self.backends
.write()
.await
.insert(account_id.to_string(), backend);
}
/// The backend that owns `account_id`. Defaults to `Upstream` for an
/// account never resolved this process-lifetime (a resolve always
/// precedes reserve in a request, so this is just a safe fallback —
/// upstream fails closed if the account is bogus).
async fn backend_for(&self, account_id: &str) -> Backend {
self.backends
.read()
.await
.get(account_id)
.copied()
.unwrap_or(Backend::Upstream)
}
}
#[async_trait]
impl EntitlementProvider for ChainedEntitlementProvider {
async fn resolve(&self, api_key: &str) -> Result<Principal, AuthError> {
match self.local.resolve(api_key).await {
Ok(p) => {
self.record(&p.account_id, Backend::Local).await;
Ok(p)
}
Err(AuthError::InvalidKey) => {
let p = self.upstream.resolve(api_key).await?;
self.record(&p.account_id, Backend::Upstream).await;
Ok(p)
}
Err(e) => Err(e),
}
}
async fn reserve(
&self,
principal: &Principal,
max_tokens: u64,
) -> Result<Reservation, BudgetError> {
match self.backend_for(&principal.account_id).await {
Backend::Local => self.local.reserve(principal, max_tokens).await,
Backend::Upstream => self.upstream.reserve(principal, max_tokens).await,
}
}
async fn settle(&self, reservation: Reservation, actual_tokens: u64) {
match self.backend_for(&reservation.principal.account_id).await {
Backend::Local => self.local.settle(reservation, actual_tokens).await,
Backend::Upstream => self.upstream.settle(reservation, actual_tokens).await,
}
}
async fn release(&self, reservation: Reservation) {
match self.backend_for(&reservation.principal.account_id).await {
Backend::Local => self.local.release(reservation).await,
Backend::Upstream => self.upstream.release(reservation).await,
}
}
async fn snapshot(&self, principal: &Principal) -> Option<BudgetSnapshot> {
match self.backend_for(&principal.account_id).await {
Backend::Local => self.local.snapshot(principal).await,
Backend::Upstream => self.upstream.snapshot(principal).await,
}
}
}

View File

@@ -0,0 +1,246 @@
//! helexa-upstream client (#57): an [`EntitlementProvider`] that resolves
//! keys and reserves/settles budget against the mesh authority's
//! `/authz/v1` surface (B2). It is "just another impl of the trait" — cortex
//! enforcement (`auth.rs`, `metering.rs`) is unchanged.
//!
//! **Fail closed.** When upstream is unreachable, `resolve` returns
//! [`AuthError::Unavailable`] (→ `503`, never `401`) and `reserve` refuses
//! with a retryable [`BudgetError::RateLimited`] — a request is never served
//! on an un-authorized key, and a real key is never rejected as invalid
//! during a blip.
use async_trait::async_trait;
use cortex_core::config::UpstreamClientConfig;
use cortex_core::entitlements::{
AuthError, BudgetError, BudgetSnapshot, EntitlementProvider, Principal, Reservation,
};
use serde::Deserialize;
use std::time::Duration;
/// Retry-After (seconds) advertised when we fail closed on an upstream
/// outage.
const FAIL_CLOSED_RETRY_SECS: u64 = 5;
pub struct UpstreamEntitlementProvider {
client: reqwest::Client,
base_url: String,
bearer: String,
}
#[derive(Deserialize)]
struct PrincipalDto {
account_id: String,
key_id: String,
}
#[derive(Deserialize)]
struct SnapshotDto {
hard_cap: Option<u64>,
spent: u64,
reserved: u64,
}
#[derive(Deserialize)]
struct ResolveResp {
principal: PrincipalDto,
#[allow(dead_code)]
snapshot: Option<SnapshotDto>,
}
#[derive(Deserialize)]
struct ReserveResp {
reservation_id: Option<i64>,
rejected: Option<Rejection>,
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Rejection {
InsufficientQuota {
requested: u64,
available: u64,
},
RateLimited {
requested: u64,
available: u64,
retry_after_secs: u64,
},
}
impl UpstreamEntitlementProvider {
pub fn new(cfg: &UpstreamClientConfig) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(cfg.timeout_secs))
.build()
.expect("failed to build upstream HTTP client");
Self {
client,
base_url: cfg.url.trim_end_matches('/').to_string(),
bearer: cfg.bearer.clone(),
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
}
#[async_trait]
impl EntitlementProvider for UpstreamEntitlementProvider {
async fn resolve(&self, api_key: &str) -> Result<Principal, AuthError> {
let resp = self
.client
.post(self.url("/authz/v1/resolve"))
.bearer_auth(&self.bearer)
.json(&serde_json::json!({ "api_key": api_key }))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "upstream resolve unreachable; failing closed");
return Err(AuthError::Unavailable {
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
});
}
};
if resp.status().as_u16() == 401 {
return Err(AuthError::InvalidKey);
}
if !resp.status().is_success() {
return Err(AuthError::Unavailable {
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
});
}
match resp.json::<ResolveResp>().await {
Ok(r) => Ok(Principal {
account_id: r.principal.account_id,
key_id: r.principal.key_id,
}),
Err(e) => {
tracing::warn!(error = %e, "upstream resolve: bad body; failing closed");
Err(AuthError::Unavailable {
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
})
}
}
}
async fn reserve(
&self,
principal: &Principal,
max_tokens: u64,
) -> Result<Reservation, BudgetError> {
let fail_closed = || BudgetError::RateLimited {
requested: max_tokens,
available: 0,
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
};
let resp = self
.client
.post(self.url("/authz/v1/reserve"))
.bearer_auth(&self.bearer)
.json(&serde_json::json!({
"account_id": principal.account_id,
"key_id": principal.key_id,
"max_tokens": max_tokens,
}))
.send()
.await;
let resp = match resp {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
tracing::warn!(status = %r.status(), "upstream reserve non-2xx; failing closed");
return Err(fail_closed());
}
Err(e) => {
tracing::warn!(error = %e, "upstream reserve unreachable; failing closed");
return Err(fail_closed());
}
};
match resp.json::<ReserveResp>().await {
Ok(ReserveResp {
reservation_id: Some(id),
..
}) => Ok(Reservation {
id: id as u64,
principal: principal.clone(),
reserved: max_tokens,
}),
Ok(ReserveResp {
rejected:
Some(Rejection::InsufficientQuota {
requested,
available,
}),
..
}) => Err(BudgetError::InsufficientQuota {
requested,
available,
}),
Ok(ReserveResp {
rejected:
Some(Rejection::RateLimited {
requested,
available,
retry_after_secs,
}),
..
}) => Err(BudgetError::RateLimited {
requested,
available,
retry_after_secs,
}),
_ => Err(fail_closed()),
}
}
async fn settle(&self, reservation: Reservation, actual_tokens: u64) {
// Best-effort; a lost settle is reaped by the upstream sweeper (B2).
let _ = self
.client
.post(self.url("/authz/v1/settle"))
.bearer_auth(&self.bearer)
.json(&serde_json::json!({
"reservation_id": reservation.id as i64,
"actual_tokens": actual_tokens,
}))
.send()
.await
.inspect_err(
|e| tracing::warn!(error = %e, "upstream settle failed (sweeper will reap)"),
);
}
async fn release(&self, reservation: Reservation) {
let _ = self
.client
.post(self.url("/authz/v1/release"))
.bearer_auth(&self.bearer)
.json(&serde_json::json!({ "reservation_id": reservation.id as i64 }))
.send()
.await
.inspect_err(
|e| tracing::warn!(error = %e, "upstream release failed (sweeper will reap)"),
);
}
async fn snapshot(&self, principal: &Principal) -> Option<BudgetSnapshot> {
let resp = self
.client
.post(self.url("/authz/v1/snapshot"))
.bearer_auth(&self.bearer)
.json(&serde_json::json!({
"account_id": principal.account_id,
"key_id": principal.key_id,
}))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let dto = resp.json::<SnapshotDto>().await.ok()?;
Some(BudgetSnapshot {
hard_cap: dto.hard_cap,
spent: dto.spent,
reserved: dto.reserved,
})
}
}

View File

@@ -322,7 +322,11 @@ async fn anthropic_messages(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Err(env) => return crate::error::envelope_response(env),
}
}
@@ -577,6 +581,11 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
// Runtime-detected — will be OR-ed in Pass 2 from neuron data.
tool_call: false,
reasoning: false,
// Flat #78 fields are derived from `limit` in the final
// sync pass, once merging is done.
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
},
);
}
@@ -634,6 +643,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
cost: None,
tool_call: entry.tool_call,
reasoning: entry.reasoning,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
});
}
}
@@ -690,6 +702,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
cost: None,
tool_call: false,
reasoning: false,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
});
}
}
@@ -724,11 +739,24 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
cost: target_entry.cost.clone(),
tool_call: target_entry.tool_call,
reasoning: target_entry.reasoning,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
},
);
}
let data: Vec<Value> = entries.values().map(|e| json!(e)).collect();
// Final pass: derive the flat ecosystem context-window fields (#78)
// from each entry's now-settled `limit`, so vLLM-convention clients
// (Hermes Agent et al.) can read the window without knowing helexa's
// `limit` schema.
let data: Vec<Value> = entries
.values_mut()
.map(|e| {
e.sync_flat_limit();
json!(e)
})
.collect();
Json(json!({
"object": "list",
"data": data,
@@ -802,7 +830,11 @@ async fn proxy_with_metrics(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Err(env) => return crate::error::envelope_response(env),
}
}

View File

@@ -1,6 +1,8 @@
pub mod anthropic_sse;
pub mod auth;
pub mod entitlements_chain;
pub mod entitlements_local;
pub mod entitlements_upstream;
pub mod error;
pub mod evictor;
pub mod handlers;
@@ -9,6 +11,7 @@ pub mod metrics;
pub mod poller;
pub mod proxy;
pub mod router;
pub mod served_usage;
pub mod state;
use anyhow::Result;
@@ -55,6 +58,28 @@ pub async fn run(config: GatewayConfig) -> Result<()> {
evictor::eviction_loop(evictor_fleet).await;
});
// Served-usage reporter (#58): when this operator is part of the mesh,
// periodically flush absolute per-principal served-token counters to
// upstream for reconciliation.
if config.upstream.enabled {
let su_fleet = Arc::clone(&fleet);
let url = config.upstream.url.clone();
let bearer = config.upstream.bearer.clone();
let interval =
std::time::Duration::from_secs(config.upstream.served_usage_report_interval_secs);
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
let rows = su_fleet.served_usage.snapshot();
if let Err(e) =
served_usage::report(&su_fleet.http_client, &url, &bearer, &rows).await
{
tracing::warn!(error = %e, "served-usage report failed (will retry)");
}
}
});
}
let app = build_app(Arc::clone(&fleet));
let listen_addr = config.gateway.listen.parse::<std::net::SocketAddr>()?;

View File

@@ -117,9 +117,21 @@ impl Drop for ReservationGuard {
/// Build the completion sink for an authenticated request: record spend and
/// settle the reservation with the observed total. Dropping it unused (no
/// usage observed) releases the reservation via the guard.
pub fn usage_sink(principal: Principal, guard: ReservationGuard) -> UsageSink {
pub fn usage_sink(
principal: Principal,
guard: ReservationGuard,
served_usage: std::sync::Arc<crate::served_usage::ServedUsage>,
) -> UsageSink {
Box::new(move |prompt, completion| {
record_spend(&principal, prompt, completion);
// Per-principal served-usage tally for #58 reconciliation. Recorded
// for every metered (authenticated) request; the flush task reports
// it to upstream when the operator is part of the mesh.
served_usage.add(
&principal.account_id,
&principal.key_id,
prompt + completion,
);
guard.settle(prompt + completion);
})
}

View File

@@ -1,21 +1,27 @@
//! Streaming HTTP reverse proxy to neuron backends.
//!
//! For streaming requests, SSE chunks are forwarded as they arrive.
//! The proxy captures timing information for metrics but does not
//! buffer the full response.
//! The streaming *mechanism* — forward an SSE body chunk-for-chunk without
//! buffering, observing the bytes for metrics — lives in the shared
//! [`helexa_stream`] crate (#71), so cortex and helexa-router use one
//! implementation. This module supplies cortex's *policy*: the
//! [`CortexMetrics`] observer (per-request token metrics + per-principal
//! reservation settle), cortex's logging contract, and the cortex error
//! envelope. The usage-extraction helper is re-exported from the shared
//! crate so existing call sites keep working.
use crate::router::RouteDecision;
use anyhow::Result;
use axum::body::Body;
use axum::http::{HeaderMap, StatusCode};
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use futures::Stream;
use futures::stream::BoxStream;
use helexa_stream::{BodyTail, ChunkObserver, StreamError};
use reqwest::Client;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Instant;
/// Re-export the shared usage-extraction helper. Several cortex modules
/// (`handlers`, `anthropic_sse`) pull token counts out of a buffered body
/// tail via this function; it lives in `helexa-stream` now.
pub use helexa_stream::last_count_for;
/// Proxy a request body to the resolved backend node and stream the response.
///
/// Logging contract: every call emits exactly one structured event at
@@ -42,66 +48,41 @@ pub async fn forward_request(
"proxying request"
);
let mut req_builder = client.post(&url).body(body);
let observer = CortexMetrics::new(model_id, &route.node_name, request_start, usage_sink);
// Forward relevant headers.
for (key, value) in headers.iter() {
if key == "host" || key == "content-length" {
continue; // reqwest sets these
}
req_builder = req_builder.header(key, value);
}
let response = helexa_stream::forward_streaming(client, &url, headers, body, observer)
.await
.map_err(|e| {
match &e {
StreamError::Upstream(err) => tracing::warn!(
node = %route.node_name,
url = %url,
error = %err,
"proxy: upstream request failed (network)"
),
StreamError::ResponseBuild(err) => tracing::warn!(
node = %route.node_name,
url = %url,
error = %err,
"proxy: failed to build response"
),
}
ProxyError::from(e)
})?;
let upstream_resp = match req_builder.send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
node = %route.node_name,
url = %url,
error = %e,
"proxy: upstream request failed (network)"
);
return Err(ProxyError::Upstream(e));
}
};
let upstream_status = upstream_resp.status();
if !upstream_status.is_success() {
if !response.status().is_success() {
// Streaming body — can't snippet without breaking the stream
// pass-through. Log status + URL; the client still gets the
// upstream status, just without the leaked body.
tracing::warn!(
node = %route.node_name,
url = %url,
status = upstream_status.as_u16(),
status = response.status().as_u16(),
"proxy: upstream returned non-2xx"
);
}
let status = StatusCode::from_u16(upstream_status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let resp_headers = upstream_resp.headers().clone();
let stream = TokenMetricsStream::new(
Box::pin(upstream_resp.bytes_stream()),
TokenMetrics::new(model_id, &route.node_name, request_start, usage_sink),
);
let body = Body::from_stream(stream);
let mut response = Response::builder().status(status);
for (key, value) in resp_headers.iter() {
response = response.header(key, value);
}
response.body(body).map_err(|e| {
tracing::warn!(
node = %route.node_name,
url = %url,
error = %e,
"proxy: failed to build response"
);
ProxyError::ResponseBuild(e.to_string())
})
Ok(response)
}
#[derive(Debug, thiserror::Error)]
@@ -112,6 +93,15 @@ pub enum ProxyError {
ResponseBuild(String),
}
impl From<StreamError> for ProxyError {
fn from(e: StreamError) -> Self {
match e {
StreamError::Upstream(err) => ProxyError::Upstream(err),
StreamError::ResponseBuild(msg) => ProxyError::ResponseBuild(msg),
}
}
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
@@ -139,9 +129,10 @@ impl IntoResponse for ProxyError {
//
// The proxy never buffers or re-serialises the upstream body — chunks
// are forwarded verbatim. For metrics it observes each chunk's arrival
// time and keeps a bounded tail of the body text, from which the final
// OpenAI `usage` object (present on the last SSE chunk and on
// non-streaming JSON bodies alike) yields engine-truth token counts.
// time and keeps a bounded tail of the body text (via the shared
// `helexa_stream::BodyTail`), from which the final OpenAI `usage` object
// (present on the last SSE chunk and on non-streaming JSON bodies alike)
// yields engine-truth token counts.
//
// Emitted per request, labelled {model, node}:
// cortex_time_to_first_token_seconds (histogram) — first body chunk
@@ -155,37 +146,15 @@ impl IntoResponse for ProxyError {
/// non-streaming bodies.
const TAIL_CAP_BYTES: usize = 64 * 1024;
/// Find the value of the LAST `"key": <integer>` occurrence in `tail`.
/// Pure and chunk-boundary-safe (the tail is contiguous appended text).
/// The quoted-needle form means `completion_tokens` never matches
/// `completion_tokens_details`.
pub(crate) fn last_count_for(tail: &str, key: &str) -> Option<u64> {
let needle = format!("\"{key}\"");
let mut result = None;
for (idx, _) in tail.match_indices(&needle) {
let rest = tail[idx + needle.len()..].trim_start();
let Some(rest) = rest.strip_prefix(':') else {
continue;
};
let rest = rest.trim_start();
let digits: &str = &rest[..rest
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(i, _)| i)
.unwrap_or(rest.len())];
if let Ok(v) = digits.parse::<u64>() {
result = Some(v);
}
}
result
}
struct TokenMetrics {
/// cortex's [`ChunkObserver`]: per-request token metrics plus the
/// per-principal reservation settle. Drives cortex policy over the shared
/// streaming mechanism.
struct CortexMetrics {
labels: [(&'static str, String); 2],
request_start: Instant,
first_chunk: Option<Instant>,
last_chunk: Option<Instant>,
tail: String,
tail: BodyTail,
finished: bool,
/// Per-principal metering hook (#51). Invoked exactly once in `finish`
/// with the observed `(prompt, completion)` so the reservation can be
@@ -193,7 +162,7 @@ struct TokenMetrics {
usage_sink: Option<crate::metering::UsageSink>,
}
impl TokenMetrics {
impl CortexMetrics {
fn new(
model_id: &str,
node_name: &str,
@@ -208,26 +177,19 @@ impl TokenMetrics {
request_start,
first_chunk: None,
last_chunk: None,
tail: String::new(),
tail: BodyTail::new(TAIL_CAP_BYTES),
finished: false,
usage_sink,
}
}
}
impl ChunkObserver for CortexMetrics {
fn observe(&mut self, chunk: &[u8]) {
let now = Instant::now();
self.first_chunk.get_or_insert(now);
self.last_chunk = Some(now);
self.tail.push_str(&String::from_utf8_lossy(chunk));
if self.tail.len() > TAIL_CAP_BYTES {
// Keep the newest half; the usage object is always at the
// very end of the body. Split at a char boundary.
let mut cut = self.tail.len() - TAIL_CAP_BYTES / 2;
while !self.tail.is_char_boundary(cut) {
cut += 1;
}
self.tail.drain(..cut);
}
self.tail.push(chunk);
}
/// Emit the metrics exactly once — called on clean stream end and
@@ -239,8 +201,8 @@ impl TokenMetrics {
}
self.finished = true;
let prompt = last_count_for(&self.tail, "prompt_tokens");
let completion = last_count_for(&self.tail, "completion_tokens");
let prompt = last_count_for(self.tail.as_str(), "prompt_tokens");
let completion = last_count_for(self.tail.as_str(), "completion_tokens");
// Per-model metrics — only when body chunks actually arrived.
if let Some(first) = self.first_chunk {
@@ -280,97 +242,3 @@ impl TokenMetrics {
}
}
}
/// Pass-through stream wrapper that feeds [`TokenMetrics`]. Emits on
/// clean end-of-stream; the Drop impl covers client disconnects.
struct TokenMetricsStream {
inner: BoxStream<'static, Result<bytes::Bytes, reqwest::Error>>,
metrics: TokenMetrics,
}
impl TokenMetricsStream {
fn new(
inner: BoxStream<'static, Result<bytes::Bytes, reqwest::Error>>,
metrics: TokenMetrics,
) -> Self {
Self { inner, metrics }
}
}
impl Stream for TokenMetricsStream {
type Item = Result<bytes::Bytes, reqwest::Error>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => {
this.metrics.observe(&chunk);
Poll::Ready(Some(Ok(chunk)))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => {
this.metrics.finish();
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
impl Drop for TokenMetricsStream {
fn drop(&mut self) {
self.metrics.finish();
}
}
#[cfg(test)]
mod tests {
use super::last_count_for;
#[test]
fn extracts_counts_from_final_sse_usage_chunk() {
let tail = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":225,",
"\"completion_tokens\":42,\"total_tokens\":267}}\n\n",
"data: [DONE]\n\n"
);
assert_eq!(last_count_for(tail, "prompt_tokens"), Some(225));
assert_eq!(last_count_for(tail, "completion_tokens"), Some(42));
}
#[test]
fn extracts_counts_from_non_streaming_body() {
let tail = "{\"choices\":[{\"message\":{\"content\":\"hi\"}}],\
\"usage\":{\"prompt_tokens\": 12, \"completion_tokens\": 7}}";
assert_eq!(last_count_for(tail, "prompt_tokens"), Some(12));
assert_eq!(last_count_for(tail, "completion_tokens"), Some(7));
}
#[test]
fn ignores_details_variants_and_takes_last_occurrence() {
// completion_tokens_details must not shadow completion_tokens,
// and the LAST usage object wins (matters when content echoes
// a usage-shaped string earlier in the stream).
let tail = concat!(
"data: {\"usage\":{\"completion_tokens\":1}}\n\n",
"data: {\"usage\":{\"completion_tokens\":99,",
"\"completion_tokens_details\":{\"reasoning_tokens\":3}}}\n\n"
);
assert_eq!(last_count_for(tail, "completion_tokens"), Some(99));
}
#[test]
fn absent_keys_yield_none() {
assert_eq!(
last_count_for("data: [DONE]\n\n", "completion_tokens"),
None
);
assert_eq!(last_count_for("", "prompt_tokens"), None);
// key present but non-numeric value
assert_eq!(
last_count_for("\"completion_tokens\": null", "completion_tokens"),
None
);
}
}

View File

@@ -0,0 +1,105 @@
//! Served-usage ledger (#58): cortex meters, per principal and per UTC day,
//! the tokens it has served on behalf of mesh accounts, and periodically
//! reports **absolute** cumulative counters to helexa-upstream for
//! reconciliation (operators are compensated for served tokens).
//!
//! Counters are cumulative-since-process-start for the current period;
//! upstream upserts them monotonically (GREATEST), so re-sending the same
//! value is idempotent and a flush that races another is harmless. (A
//! process restart resets the in-memory counter; the monotonic upsert keeps
//! upstream from regressing — at most it under-counts the restarted window,
//! acceptable for beta. One cortex per operator token is assumed.)
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServedRow {
pub account_id: String,
pub key_id: String,
pub period: String, // YYYY-MM-DD (UTC)
pub served_tokens: u64,
}
#[derive(Default)]
pub struct ServedUsage {
inner: Mutex<HashMap<(String, String, String), u64>>,
}
impl ServedUsage {
pub fn new() -> Self {
Self::default()
}
/// Add served tokens for a principal in today's (UTC) period.
pub fn add(&self, account_id: &str, key_id: &str, tokens: u64) {
if tokens == 0 {
return;
}
let period = chrono::Utc::now().format("%Y-%m-%d").to_string();
let mut m = self.inner.lock().expect("served-usage lock");
*m.entry((account_id.to_string(), key_id.to_string(), period))
.or_insert(0) += tokens;
}
/// Absolute cumulative counters, for a flush to upstream.
pub fn snapshot(&self) -> Vec<ServedRow> {
let m = self.inner.lock().expect("served-usage lock");
m.iter()
.map(|((account_id, key_id, period), &served_tokens)| ServedRow {
account_id: account_id.clone(),
key_id: key_id.clone(),
period: period.clone(),
served_tokens,
})
.collect()
}
}
/// POST the absolute counters to upstream's `/authz/v1/served-usage`.
pub async fn report(
client: &reqwest::Client,
base_url: &str,
bearer: &str,
rows: &[ServedRow],
) -> Result<(), reqwest::Error> {
if rows.is_empty() {
return Ok(());
}
let url = format!("{}/authz/v1/served-usage", base_url.trim_end_matches('/'));
client
.post(url)
.bearer_auth(bearer)
.json(&serde_json::json!({ "rows": rows }))
.send()
.await?
.error_for_status()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accumulates_per_principal_and_period() {
let su = ServedUsage::new();
su.add("acct", "key", 10);
su.add("acct", "key", 5);
su.add("acct", "other", 7);
su.add("acct", "key", 0); // no-op
let mut rows = su.snapshot();
rows.sort_by(|a, b| a.key_id.cmp(&b.key_id));
assert_eq!(rows.len(), 2);
let key_row = rows.iter().find(|r| r.key_id == "key").unwrap();
assert_eq!(key_row.served_tokens, 15);
assert_eq!(
rows.iter()
.find(|r| r.key_id == "other")
.unwrap()
.served_tokens,
7
);
}
}

View File

@@ -1,4 +1,6 @@
use crate::entitlements_chain::ChainedEntitlementProvider;
use crate::entitlements_local::LocalEntitlementProvider;
use crate::entitlements_upstream::UpstreamEntitlementProvider;
use cortex_core::catalogue::ModelCatalogue;
use cortex_core::config::{EvictionSettings, GatewayConfig, NeuronEndpoint};
use cortex_core::entitlements::EntitlementProvider;
@@ -20,6 +22,9 @@ pub struct CortexState {
/// Whether to reject unauthenticated requests (#49). Read by the auth
/// middleware once it lands.
pub require_auth: bool,
/// Per-principal served-token tally (#58), reported to upstream for
/// operator reconciliation by the flush task when upstream is enabled.
pub served_usage: Arc<crate::served_usage::ServedUsage>,
}
impl CortexState {
@@ -45,8 +50,20 @@ impl CortexState {
let catalogue = ModelCatalogue::load(&config.models_config);
let entitlements: Arc<dyn EntitlementProvider> =
Arc::new(LocalEntitlementProvider::from_config(&config.entitlements));
// Local provider always handles operator + infra keys. When the
// upstream client is enabled (#57), wrap it in the chain so locally
// unknown keys fall through to the mesh authority; otherwise stay
// purely local.
let local = LocalEntitlementProvider::from_config(&config.entitlements);
let entitlements: Arc<dyn EntitlementProvider> = if config.upstream.enabled {
tracing::info!(url = %config.upstream.url, "upstream entitlement client enabled");
Arc::new(ChainedEntitlementProvider::new(
local,
UpstreamEntitlementProvider::new(&config.upstream),
))
} else {
Arc::new(local)
};
Self {
nodes: RwLock::new(nodes),
@@ -59,6 +76,7 @@ impl CortexState {
.expect("failed to build HTTP client"),
entitlements,
require_auth: config.entitlements.require_auth,
served_usage: Arc::new(crate::served_usage::ServedUsage::new()),
}
}
}

View File

@@ -57,6 +57,7 @@ async fn test_alias_resolves_in_chat_completions() {
}],
models_config: models_path.to_string_lossy().to_string(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -143,6 +144,7 @@ async fn test_aliases_surface_in_v1_models() {
}],
models_config: models_path.to_string_lossy().to_string(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -232,6 +234,7 @@ async fn test_alias_falls_through_for_unmapped_model() {
}],
models_config: models_path.to_string_lossy().to_string(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));

View File

@@ -105,6 +105,7 @@ async fn spawn_gateway(neuron_url: &str, entitlements: EntitlementsConfig) -> St
}],
models_config: "/dev/null".into(),
entitlements,
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));

View File

@@ -81,6 +81,7 @@ async fn spawn_gateway(neuron_url: &str, key: ApiKeyConfig) -> (Arc<CortexState>
require_auth: true,
keys: vec![key],
},
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
{

View File

@@ -430,6 +430,7 @@ pub async fn spawn_gateway_with_state(mock_url: &str) -> (Arc<CortexState>, Stri
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));

View File

@@ -89,6 +89,7 @@ async fn error_response_no_healthy_nodes() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(cortex_gateway::state::CortexState::from_config(&config));

View File

@@ -72,6 +72,7 @@ fn make_fleet(endpoint: &str, defrag_after: u32) -> Arc<CortexState> {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
Arc::new(CortexState::from_config(&config))
}

View File

@@ -74,6 +74,7 @@ async fn fleet_with(big_healthy: bool, big_devices: usize) -> Arc<CortexState> {
],
models_config: cat.to_string_lossy().into_owned(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
{

View File

@@ -72,6 +72,7 @@ async fn two_neuron_fleet(endpoint_a: &str, endpoint_b: &str) -> Arc<CortexState
],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
Arc::new(CortexState::from_config(&config))
}

View File

@@ -53,6 +53,7 @@ async fn spawn_metered_gateway(neuron_url: &str) -> (Arc<CortexState>, String) {
window: CapWindow::Balance,
}],
},
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -158,6 +159,7 @@ async fn anonymous_request_records_no_spend() {
}],
models_config: "/dev/null".into(),
entitlements: EntitlementsConfig::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
{

View File

@@ -66,6 +66,7 @@ harness = "candle"
}],
models_config: cat_path.to_string_lossy().into_owned(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));

View File

@@ -8,8 +8,11 @@
//! - `cost` from the catalogue profile (operator-set pricing).
//! - `tool_call` / `reasoning` from the neuron's runtime detection (OR-ed in)
//!
//! Also a regression guard for the removal of `max_model_len` — the misnamed,
//! unconsumed vLLM-ism that this contract replaces.
//! Also asserts the flat, vLLM-convention duplicates (`max_model_len`,
//! `max_input_tokens`, `max_output_tokens`) mirror `limit` (#78): the
//! earlier removal of `max_model_len` as "unconsumed" was wrong — Hermes
//! Agent (and the wider OpenAI client ecosystem) probes those flat keys
//! and cannot see `limit.context`.
use cortex_core::config::{
EvictionSettings, EvictionStrategy, GatewayConfig, GatewaySettings, NeuronEndpoint,
@@ -55,6 +58,7 @@ capabilities = ["text"]
}],
models_config: cat_path.to_string_lossy().into_owned(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -84,6 +88,21 @@ capabilities = ["text"]
}),
},
);
// A model with no derivable limit: the flat #78 fields must be
// OMITTED (absent-vs-zero is load-bearing), never 0 or a guess.
node.models.insert(
"no-limit-model".into(),
ModelEntry {
id: "no-limit-model".into(),
status: ModelStatus::Loaded,
last_accessed: None,
vram_estimate_mb: None,
capabilities: vec!["text".into()],
tool_call: false,
reasoning: false,
limit: None,
},
);
}
let app = cortex_gateway::build_app(Arc::clone(&fleet));
@@ -122,11 +141,26 @@ capabilities = ["text"]
assert_eq!(entry["tool_call"], true);
assert_eq!(entry["reasoning"], true);
// Regression guard: the removed, unconsumed vLLM-ism must not reappear.
assert!(
entry.get("max_model_len").is_none(),
"max_model_len was removed; /v1/models must not advertise it"
);
// Flat ecosystem duplicates (#78) mirror the advertised `limit` so
// vLLM-convention probes (Hermes Agent) auto-detect the window.
assert_eq!(entry["max_model_len"], 49152);
assert_eq!(entry["max_input_tokens"], 40960);
assert_eq!(entry["max_output_tokens"], 8192);
// No limit → flat fields omitted entirely, never 0 or a guess.
let unknown = body["data"]
.as_array()
.unwrap()
.iter()
.find(|m| m["id"] == "no-limit-model")
.expect("no-limit-model present in /v1/models");
assert!(unknown.get("limit").is_none());
for key in ["max_model_len", "max_input_tokens", "max_output_tokens"] {
assert!(
unknown.get(key).is_none(),
"{key} must be omitted when the window is unknown"
);
}
let _ = std::fs::remove_file(&cat_path);
}

View File

@@ -32,6 +32,7 @@ async fn test_poller_discovers_models() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -84,6 +85,7 @@ async fn test_poller_updates_gateway_models_endpoint() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -156,6 +158,7 @@ async fn test_models_endpoint_unions_capabilities_across_nodes() {
],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -219,6 +222,7 @@ async fn test_poller_marks_unreachable_node_unhealthy() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -273,6 +277,7 @@ async fn test_poller_removes_stale_models() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -304,6 +309,7 @@ async fn test_poller_removes_stale_models() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet2 = Arc::new(CortexState::from_config(&config2));
@@ -386,6 +392,7 @@ async fn test_poller_captures_activation_from_health() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
@@ -431,6 +438,7 @@ async fn test_poller_parses_recovering_status() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));

View File

@@ -75,6 +75,7 @@ async fn spawn_gateway(neuron: &str, context: usize) -> String {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = Arc::new(CortexState::from_config(&config));
{

View File

@@ -118,6 +118,7 @@ async fn test_no_healthy_nodes() {
}],
models_config: "/dev/null".into(),
entitlements: Default::default(),
upstream: Default::default(),
};
let fleet = std::sync::Arc::new(cortex_gateway::state::CortexState::from_config(&config));

View File

@@ -0,0 +1,106 @@
//! B3: the chained entitlement provider (local → upstream) and fail-closed
//! semantics, exercised against a mock helexa-upstream `/authz/v1`.
use axum::{Json, Router, routing::post};
use cortex_core::config::{ApiKeyConfig, EntitlementsConfig, UpstreamClientConfig};
use cortex_core::entitlements::{AuthError, EntitlementProvider};
use cortex_gateway::entitlements_chain::ChainedEntitlementProvider;
use cortex_gateway::entitlements_local::LocalEntitlementProvider;
use cortex_gateway::entitlements_upstream::UpstreamEntitlementProvider;
use serde_json::{Value, json};
use tokio::net::TcpListener;
/// Mock upstream: `mesh-key` resolves to a mesh account; anything else 401.
/// reserve always grants reservation 1.
async fn spawn_mock_upstream() -> String {
async fn resolve(Json(body): Json<Value>) -> axum::response::Response {
use axum::response::IntoResponse;
if body["api_key"] == "mesh-key" {
Json(json!({"principal": {"account_id": "mesh-acct", "key_id": "mesh-key-1"}}))
.into_response()
} else {
(
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({"error": {"code": "invalid_api_key"}})),
)
.into_response()
}
}
async fn reserve() -> Json<Value> {
Json(json!({ "reservation_id": 1 }))
}
let app = Router::new()
.route("/authz/v1/resolve", post(resolve))
.route("/authz/v1/reserve", post(reserve));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
fn local_with_key() -> LocalEntitlementProvider {
let cfg = EntitlementsConfig {
require_auth: false,
keys: vec![ApiKeyConfig {
key: "local-key".into(),
account_id: "op".into(),
key_id: None,
hard_cap: None,
window: Default::default(),
}],
};
LocalEntitlementProvider::from_config(&cfg)
}
fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvider {
let upstream = UpstreamEntitlementProvider::new(&UpstreamClientConfig {
enabled: true,
url: url.to_string(),
bearer: "client-secret".into(),
timeout_secs: 5,
served_usage_report_interval_secs: 60,
});
ChainedEntitlementProvider::new(local, upstream)
}
#[tokio::test]
async fn local_key_resolves_locally() {
let url = spawn_mock_upstream().await;
let c = chain(local_with_key(), &url);
let p = c.resolve("local-key").await.expect("local resolves");
assert_eq!(p.account_id, "op");
}
#[tokio::test]
async fn unknown_key_falls_through_to_upstream() {
let url = spawn_mock_upstream().await;
let c = chain(local_with_key(), &url);
let p = c.resolve("mesh-key").await.expect("upstream resolves");
assert_eq!(p.account_id, "mesh-acct");
assert_eq!(p.key_id, "mesh-key-1");
}
#[tokio::test]
async fn unknown_everywhere_is_invalid_key() {
let url = spawn_mock_upstream().await;
let c = chain(local_with_key(), &url);
match c.resolve("nope").await {
Err(AuthError::InvalidKey) => {}
other => panic!("expected InvalidKey, got {other:?}"),
}
}
#[tokio::test]
async fn upstream_unreachable_fails_closed_as_unavailable() {
// No mock — point at a dead port. A locally-unknown key must surface
// Unavailable (→ 503), never InvalidKey (→ 401).
let c = chain(local_with_key(), "http://127.0.0.1:1");
match c.resolve("some-mesh-key").await {
Err(AuthError::Unavailable { retry_after_secs }) => assert!(retry_after_secs > 0),
other => panic!("expected Unavailable, got {other:?}"),
}
// A local key still resolves without touching upstream.
assert_eq!(c.resolve("local-key").await.unwrap().account_id, "op");
}

View File

@@ -35,6 +35,9 @@ pub fn api_routes(state: ApiState) -> Router {
.route("/api/health", get(health))
.route("/api/dimensions", get(dimensions))
.route("/api/summary", get(summary))
.route("/api/scaling", get(scaling))
.route("/api/swap", get(swap))
.route("/api/capability", get(capability))
.route("/api/series", get(series))
.route("/api/runs", get(runs))
.layer(CorsLayer::permissive())
@@ -73,6 +76,30 @@ async fn summary(
store.summary().map(Json).map_err(err500)
}
/// Context-length scaling curves per (target, model) — prefill & decode
/// tok/s vs context, with decode-flatness (#88).
async fn scaling(
State(s): State<ApiState>,
) -> Result<Json<Vec<crate::store::ScalingCurve>>, ApiError> {
let store = s.lock().await;
store.scaling().map(Json).map_err(err500)
}
/// Cold-load / model-swap costs per (target, model) — reload latency + cold
/// first-request (#90).
async fn swap(State(s): State<ApiState>) -> Result<Json<Vec<crate::store::SwapCost>>, ApiError> {
let store = s.lock().await;
store.swap_costs().map(Json).map_err(err500)
}
/// Capability-probe runs — stored artifacts + quality scores (#91).
async fn capability(
State(s): State<ApiState>,
) -> Result<Json<Vec<crate::store::CapabilityRun>>, ApiError> {
let store = s.lock().await;
store.capability_runs(false).map(Json).map_err(err500)
}
#[derive(Debug, Deserialize)]
struct SeriesQuery {
/// Optional — when omitted the store resolves the host serving this model.

View File

@@ -3,10 +3,10 @@
//! `openai` targets use the OpenAI-compatible surface (preliminary).
use crate::config::{TargetConfig, TargetKind};
use anyhow::{Context, Result};
use anyhow::{Context, Result, anyhow};
use cortex_core::build_info::BuildInfo;
use cortex_core::discovery::DiscoveryResponse;
use cortex_core::harness::ModelInfo;
use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
use cortex_core::harness::{ModelInfo, ModelSpec};
use cortex_core::openai::ModelsResponse;
use std::time::Duration;
@@ -94,6 +94,84 @@ impl TargetClient {
Ok(Some(disco))
}
/// Runtime device health (neuron only): per-GPU VRAM used/free,
/// utilization, and temperature from `GET /health`. Bench samples this
/// around each measured run to record VRAM high-water + GPU telemetry
/// (#87). Returns `Ok(None)` for non-neuron targets; a soft `Ok(None)`
/// (not an error) on transport failure so a flaky `/health` never fails
/// a measurement.
pub async fn fetch_health(&self, target: &TargetConfig) -> Result<Option<HealthResponse>> {
if target.kind != TargetKind::Neuron {
return Ok(None);
}
let base = target.endpoint.trim_end_matches('/');
let health = self
.http
.get(format!("{base}/health"))
.timeout(META_TIMEOUT)
.send()
.await
.context("GET /health")?
.error_for_status()
.context("GET /health status")?
.json::<HealthResponse>()
.await
.context("decoding /health")?;
Ok(Some(health))
}
/// Unload a model (neuron only): `POST /models/unload {model_id}`.
/// Used by the deliberate swap-cost measurement (#90), never the sweep.
pub async fn unload_model(&self, target: &TargetConfig, model_id: &str) -> Result<()> {
let base = target.endpoint.trim_end_matches('/');
self.http
.post(format!("{base}/models/unload"))
.json(&serde_json::json!({ "model_id": model_id }))
.send()
.await
.context("POST /models/unload")?
.error_for_status()
.context("POST /models/unload status")?;
Ok(())
}
/// Load a model from a spec (neuron only): `POST /models/load`. neuron
/// returns synchronously once loaded, so the call duration is the reload
/// cost the swap-cost measurement records (#90).
pub async fn load_model(&self, target: &TargetConfig, spec: &ModelSpec) -> Result<()> {
let base = target.endpoint.trim_end_matches('/');
self.http
.post(format!("{base}/models/load"))
.json(spec)
// A cold load can take tens of seconds; use the full request
// timeout rather than the short metadata one.
.send()
.await
.context("POST /models/load")?
.error_for_status()
.context("POST /models/load status")?;
Ok(())
}
/// Reconstruct a reload [`ModelSpec`] from a model's `/models` entry.
/// Tensor-parallel is inferred from the device count; `quant` is left
/// `None` for neuron to resolve from the catalogue / its prior load.
pub fn spec_from_info(info: &ModelInfo) -> Result<ModelSpec> {
if info.devices.is_empty() {
return Err(anyhow!(
"model '{}' reports no devices; cannot reconstruct a load spec",
info.id
));
}
Ok(ModelSpec {
model_id: info.id.clone(),
harness: info.harness.clone(),
quant: None,
tensor_parallel: (info.devices.len() > 1).then_some(info.devices.len() as u32),
devices: Some(info.devices.clone()),
})
}
/// Warm models — those ready to serve without a cold load.
///
/// Neuron: `GET /models` filtered to `status == "loaded"` (skips

View File

@@ -104,6 +104,37 @@ pub struct ScenarioConfig {
/// Max generated tokens per request.
#[serde(default = "default_max_tokens")]
pub max_tokens: u64,
/// Concurrency levels (#89): one `concurrency:<n>` scenario per entry,
/// each firing N simultaneous streams. Defaults to empty (opt-in) — a
/// burst puts real load on a serving fleet, so operators enable it
/// deliberately, e.g. `concurrency_levels = [2, 4, 8]`.
#[serde(default)]
pub concurrency_levels: Vec<u32>,
/// Approximate prompt size (tokens) used by the concurrency scenarios.
#[serde(default = "default_concurrency_prompt_tokens")]
pub concurrency_prompt_tokens: u32,
/// Capability probes (#91): one `capability:<name>` scenario per entry,
/// each running a fixed prompt and storing the full output artifact for
/// quality scoring (manual now, LLM-judge later). Defaults to empty
/// (opt-in) — these generate long outputs and exist to compare reasoning
/// quality across models, not to run on every sweep by default.
#[serde(default)]
pub capability_probes: Vec<CapabilityProbe>,
}
/// One capability probe: a named prompt whose output is stored and scored
/// for quality (#91). The probe is deterministic (temperature 0) so the
/// same model+build produces a stable artifact to score.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityProbe {
/// Stable id fragment — the scenario becomes `capability:<name>`.
pub name: String,
/// The prompt sent verbatim as the user message.
pub prompt: String,
/// Generation budget for the probe (planning answers are long; the
/// default 256 is too small). Defaults to 2048.
#[serde(default = "default_capability_max_tokens")]
pub max_tokens: u64,
}
impl Default for ScenarioConfig {
@@ -111,6 +142,9 @@ impl Default for ScenarioConfig {
ScenarioConfig {
prompt_sizes: default_prompt_sizes(),
max_tokens: default_max_tokens(),
concurrency_levels: Vec::new(),
concurrency_prompt_tokens: default_concurrency_prompt_tokens(),
capability_probes: Vec::new(),
}
}
}
@@ -187,6 +221,12 @@ fn default_prompt_sizes() -> Vec<u32> {
fn default_max_tokens() -> u64 {
256
}
fn default_concurrency_prompt_tokens() -> u32 {
512
}
fn default_capability_max_tokens() -> u64 {
2048
}
#[cfg(test)]
// Jail's closure must return figment::Result; the large-Err type is

View File

@@ -43,6 +43,33 @@ enum Command {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
},
/// Measure cold-load / model-swap cost (#90): for each neuron target's
/// warm models, unload → time reload → time a cold first request, recorded
/// under scenario "swap". DELIBERATE — takes each model offline for its
/// reload, so run it in a maintenance window, not against live traffic.
SwapCost {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
},
/// Attach a quality score to a capability-probe run (#91). Find run ids
/// with `report --capability`. `--scorer` records who scored it
/// (defaults to "manual"); a future LLM-judge would set e.g. "llm:…".
Score {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
/// Override the SQLite path (skips reading the config file).
#[arg(long)]
db: Option<String>,
/// The run id to score.
#[arg(long)]
id: i64,
/// The quality score to attach (scale is the operator's rubric).
#[arg(long)]
score: f64,
/// Who/what produced the score.
#[arg(long, default_value = "manual")]
scorer: String,
},
/// Render recorded results. Uses `--db` if given, else the db_path
/// from `--config`.
Report {
@@ -54,6 +81,19 @@ enum Command {
/// Output format.
#[arg(long, default_value = "md")]
format: Format,
/// Render the context-length scaling view (prefill & decode tok/s
/// vs context per model, with decode-flatness) instead of the flat
/// results table (#88).
#[arg(long)]
scaling: bool,
/// Render the cold-load / model-swap cost view (#90) instead of the
/// flat results table.
#[arg(long)]
swap: bool,
/// Render the capability-probe view (#91): stored artifacts + quality
/// scores, with per-model median.
#[arg(long)]
capability: bool,
},
}
@@ -122,16 +162,79 @@ async fn run(cli: Cli) -> Result<()> {
);
Ok(())
}
Command::Report { config, db, format } => {
Command::SwapCost { config } => {
let cfg = load_config(&config)?;
require_targets(&cfg)?;
let sweeper = Sweeper::new(cfg)?;
tracing::warn!(
"swap-cost: cycling each warm model (unload → reload → cold request); models go offline during reload"
);
let summary = sweeper.swap_cost_once().await?;
tracing::info!(
measured = summary.measured,
failed = summary.failed,
unreachable = summary.targets_unreachable,
"swap-cost measurement complete"
);
Ok(())
}
Command::Score {
config,
db,
id,
score,
scorer,
} => {
let db_path = match db {
Some(p) => p,
None => load_config(&config)?.bench.db_path,
};
let store = Store::open(&db_path)?;
let rows = store.report_rows()?;
let rendered = match format {
Format::Md => report::render_markdown(&rows),
Format::Json => report::render_json(&rows)?,
match store.set_score(id, score, &scorer)? {
0 => anyhow::bail!("no run with id {id}"),
_ => {
println!("scored run {id}: {score} ({scorer})");
Ok(())
}
}
}
Command::Report {
config,
db,
format,
scaling,
swap,
capability,
} => {
let db_path = match db {
Some(p) => p,
None => load_config(&config)?.bench.db_path,
};
let store = Store::open(&db_path)?;
let rendered = if capability {
let runs = store.capability_runs(false)?;
match format {
Format::Md => report::render_capability_markdown(&runs),
Format::Json => report::render_capability_json(&runs)?,
}
} else if swap {
let costs = store.swap_costs()?;
match format {
Format::Md => report::render_swap_markdown(&costs),
Format::Json => report::render_swap_json(&costs)?,
}
} else if scaling {
let curves = store.scaling()?;
match format {
Format::Md => report::render_scaling_markdown(&curves),
Format::Json => report::render_scaling_json(&curves)?,
}
} else {
let rows = store.report_rows()?;
match format {
Format::Md => report::render_markdown(&rows),
Format::Json => report::render_json(&rows)?,
}
};
println!("{rendered}");
Ok(())

View File

@@ -3,28 +3,36 @@
//! doc: engine, model, prompt tok, TTFT (s), decode tok/s, total (s),
//! plus the build SHA each cell was measured against.
use crate::store::ReportRow;
use crate::store::{CapabilityRun, ReportRow, ScalingCurve, SwapCost};
use anyhow::Result;
pub fn render_markdown(rows: &[ReportRow]) -> String {
let mut out = String::new();
out.push_str(
"| engine | model | prompt tok | TTFT (s) | decode tok/s | total (s) | build | n |\n",
"| engine | model | prompt tok | prefill tok/s | TTFT (s) | TTFT p95 | \
decode tok/s | total (s) | total p95 | VRAM (GB) | conc | queue ms | rej | build | n |\n",
);
out.push_str("|---|---|---:|---:|---:|---:|---|---:|\n");
out.push_str("|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---:|\n");
for r in rows {
let ptok = r
.prompt_tokens
.map(|t| t.to_string())
.unwrap_or_else(|| format!("~{}", r.prompt_size_approx));
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
"| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
r.target_name,
r.model_id,
ptok,
fmt_opt(r.prefill_tps_median, 1),
fmt_opt(r.ttft_s_median, 3),
fmt_opt(r.ttft_s_p95, 3),
fmt_opt(r.decode_tps_median, 1),
fmt_opt(r.total_s_median, 3),
fmt_opt(r.total_s_p95, 3),
fmt_vram(r.vram_used_mb_median, r.vram_total_mb),
fmt_u64(r.concurrency),
fmt_opt(r.queue_wait_ms_median, 0),
fmt_opt(r.rejected_median, 0),
r.git_sha,
r.samples,
));
@@ -43,8 +51,23 @@ pub fn render_json(rows: &[ReportRow]) -> Result<String> {
"prompt_size_approx": r.prompt_size_approx,
"prompt_tokens": r.prompt_tokens,
"ttft_s_median": r.ttft_s_median,
"ttft_s_p95": r.ttft_s_p95,
"ttft_s_p99": r.ttft_s_p99,
"decode_tps_median": r.decode_tps_median,
"total_s_median": r.total_s_median,
"total_s_p95": r.total_s_p95,
"total_s_p99": r.total_s_p99,
"prefill_ms_median": r.prefill_ms_median,
"decode_ms_median": r.decode_ms_median,
"prefill_tps_median": r.prefill_tps_median,
"vram_used_mb_median": r.vram_used_mb_median,
"vram_total_mb": r.vram_total_mb,
"gpu_util_pct_median": r.gpu_util_pct_median,
"gpu_temp_c_median": r.gpu_temp_c_median,
"concurrency": r.concurrency,
"ttft_p95_load_s": r.ttft_p95_load_s,
"queue_wait_ms_median": r.queue_wait_ms_median,
"rejected_median": r.rejected_median,
"git_sha": r.git_sha,
"samples": r.samples,
"gpu": r.gpu,
@@ -54,6 +77,179 @@ pub fn render_json(rows: &[ReportRow]) -> Result<String> {
Ok(serde_json::to_string_pretty(&arr)?)
}
/// Context-length scaling view (#88): one block per (target, model) with
/// prefill & decode tok/s vs context, then the decode-flatness verdict.
pub fn render_scaling_markdown(curves: &[ScalingCurve]) -> String {
let mut out = String::new();
for c in curves {
let gpu = c.gpu.as_deref().unwrap_or("");
out.push_str(&format!(
"### {} · {} (`{}`{})\n\n",
c.target_name,
c.model_id,
c.git_sha,
if gpu.is_empty() {
String::new()
} else {
format!(", {gpu}")
},
));
out.push_str("| ctx tok | prefill tok/s | decode tok/s | n |\n");
out.push_str("|---:|---:|---:|---:|\n");
for p in &c.points {
let ctx = p
.prompt_tokens
.map(|t| t.to_string())
.unwrap_or_else(|| format!("~{}", p.prompt_size));
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
ctx,
fmt_opt(p.prefill_tps, 1),
fmt_opt(p.decode_tps, 1),
p.samples,
));
}
match c.decode_flatness {
Some(f) => out.push_str(&format!(
"\ndecode flatness: {f:.2} — decode tok/s {} across the context range \
({})\n\n",
if f >= 0.9 {
"holds"
} else if f >= 0.7 {
"softens"
} else {
"drops sharply"
},
if f >= 0.9 {
"Gated-DeltaNet O(1) decode confirmed"
} else {
"investigate where it breaks"
},
)),
None => out.push_str("\ndecode flatness: — (need ≥2 context points)\n\n"),
}
}
out
}
pub fn render_scaling_json(curves: &[ScalingCurve]) -> Result<String> {
Ok(serde_json::to_string_pretty(curves)?)
}
/// Cold-load / model-swap cost view (#90): reload latency + cold
/// first-request per model.
pub fn render_swap_markdown(costs: &[SwapCost]) -> String {
let mut out = String::new();
out.push_str(
"| engine | model | unload (s) | reload (s) | cold TTFT (s) | cold total (s) | build | n |\n",
);
out.push_str("|---|---|---:|---:|---:|---:|---|---:|\n");
for c in costs {
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
c.target_name,
c.model_id,
fmt_ms_as_s(c.unload_ms_median),
fmt_ms_as_s(c.load_ms_median),
fmt_opt(c.cold_ttft_s_median, 3),
fmt_opt(c.cold_total_s_median, 3),
c.git_sha,
c.samples,
));
}
out
}
pub fn render_swap_json(costs: &[SwapCost]) -> Result<String> {
Ok(serde_json::to_string_pretty(costs)?)
}
/// Capability-probe view (#91): per (model, probe) the median quality score
/// (the A/B number), then each run's id, score, and an artifact snippet so
/// unscored runs can be located and scored (`helexa-bench score --id …`).
pub fn render_capability_markdown(runs: &[CapabilityRun]) -> String {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(String, String, String), Vec<&CapabilityRun>> = BTreeMap::new();
for r in runs {
groups
.entry((
r.target_name.clone(),
r.model_id.clone(),
r.scenario_id.clone(),
))
.or_default()
.push(r);
}
let mut out = String::new();
for ((target, model, scenario), rs) in groups {
let scores: Vec<f64> = rs.iter().filter_map(|r| r.quality_score).collect();
let median = median_slice(&scores);
out.push_str(&format!(
"### {target} · {model} · {scenario} — median score {} ({}/{} scored)\n\n",
median
.map(|m| format!("{m:.1}"))
.unwrap_or_else(|| "".into()),
scores.len(),
rs.len(),
));
out.push_str("| run | score | scorer | build | artifact (snippet) |\n");
out.push_str("|---:|---:|---|---|---|\n");
for r in rs {
out.push_str(&format!(
"| {} | {} | {} | `{}` | {} |\n",
r.id,
r.quality_score
.map(|s| format!("{s:.1}"))
.unwrap_or_else(|| "".into()),
r.scorer.as_deref().unwrap_or(""),
r.git_sha,
snippet(r.artifact.as_deref()),
));
}
out.push('\n');
}
out
}
pub fn render_capability_json(runs: &[CapabilityRun]) -> Result<String> {
Ok(serde_json::to_string_pretty(runs)?)
}
/// First ~80 chars of an artifact on one line, for the table cell.
fn snippet(artifact: Option<&str>) -> String {
match artifact {
Some(a) => {
let one_line: String = a.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed: String = one_line.chars().take(80).collect();
if one_line.chars().count() > 80 {
format!("{trimmed}")
} else {
trimmed
}
}
None => "".to_string(),
}
}
fn median_slice(v: &[f64]) -> Option<f64> {
if v.is_empty() {
return None;
}
let mut s = v.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let lo = (s.len() - 1) / 2;
let hi = s.len() / 2;
Some((s[lo] + s[hi]) / 2.0)
}
/// Milliseconds rendered as seconds (reload costs read naturally in s).
fn fmt_ms_as_s(ms: Option<f64>) -> String {
match ms {
Some(x) => format!("{:.2}", x / 1000.0),
None => "".to_string(),
}
}
fn fmt_opt(v: Option<f64>, places: usize) -> String {
match v {
Some(x) => format!("{x:.places$}"),
@@ -61,9 +257,113 @@ fn fmt_opt(v: Option<f64>, places: usize) -> String {
}
}
/// Integer cell (concurrency width); `—` when unset (non-concurrency rows).
fn fmt_u64(v: Option<u64>) -> String {
match v {
Some(x) => x.to_string(),
None => "".to_string(),
}
}
/// `used/total` in GB (e.g. `42.0/64.0`) — the headroom-at-a-glance cell.
/// `used` alone if the node total is unknown; `—` if no telemetry.
fn fmt_vram(used_mb: Option<f64>, total_mb: Option<u64>) -> String {
match (used_mb, total_mb) {
(Some(u), Some(t)) => format!("{:.1}/{:.1}", u / 1024.0, t as f64 / 1024.0),
(Some(u), None) => format!("{:.1}", u / 1024.0),
_ => "".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{ScalingCurve, ScalingPoint};
#[test]
fn capability_markdown_groups_with_median_and_snippet() {
let runs = vec![
CapabilityRun {
id: 7,
ts: "t".into(),
target_name: "beast".into(),
model_id: "m".into(),
scenario_id: "capability:plan".into(),
git_sha: "abc".into(),
quality_score: Some(8.0),
scorer: Some("manual".into()),
artifact: Some("A detailed plan with trade-offs and sequencing.".into()),
},
CapabilityRun {
id: 8,
ts: "t".into(),
target_name: "beast".into(),
model_id: "m".into(),
scenario_id: "capability:plan".into(),
git_sha: "abc".into(),
quality_score: Some(6.0),
scorer: Some("manual".into()),
artifact: Some("Shorter plan.".into()),
},
];
let md = render_capability_markdown(&runs);
assert!(md.contains("capability:plan"));
assert!(md.contains("median score 7.0")); // median(8,6)
assert!(md.contains("trade-offs"));
assert!(md.contains("| 7 |"));
}
#[test]
fn swap_markdown_renders_reload_and_cold_costs() {
let costs = vec![SwapCost {
target_name: "beast".into(),
model_id: "Qwen/Qwen3.6-27B".into(),
git_sha: "abc1234".into(),
gpu: Some("2× RTX 5090".into()),
unload_ms_median: Some(320.0),
load_ms_median: Some(25000.0),
cold_ttft_s_median: Some(2.5),
cold_total_s_median: Some(5.0),
samples: 3,
}];
let md = render_swap_markdown(&costs);
assert!(md.contains("reload (s)"));
assert!(md.contains("beast"));
assert!(md.contains("25.00")); // 25000 ms → 25.00 s
assert!(md.contains("2.500"));
}
#[test]
fn scaling_markdown_renders_curve_and_flatness() {
let curves = vec![ScalingCurve {
target_name: "beast".into(),
model_id: "Qwen/Qwen3.6-27B".into(),
git_sha: "abc1234".into(),
gpu: Some("2× RTX 5090".into()),
points: vec![
ScalingPoint {
prompt_size: 128,
prompt_tokens: Some(130),
prefill_tps: Some(900.0),
decode_tps: Some(50.0),
samples: 5,
},
ScalingPoint {
prompt_size: 4096,
prompt_tokens: Some(4100),
prefill_tps: Some(2800.0),
decode_tps: Some(48.0),
samples: 5,
},
],
decode_flatness: Some(0.96),
}];
let md = render_scaling_markdown(&curves);
assert!(md.contains("### beast · Qwen/Qwen3.6-27B"));
assert!(md.contains("ctx tok"));
assert!(md.contains("decode flatness: 0.96"));
assert!(md.contains("holds"));
}
#[test]
fn markdown_has_header_and_row() {
@@ -77,14 +377,36 @@ mod tests {
ttft_s_median: Some(0.123),
decode_tps_median: Some(45.6),
total_s_median: Some(1.234),
ttft_s_p95: Some(0.222),
ttft_s_p99: Some(0.250),
total_s_p95: Some(1.5),
total_s_p99: Some(1.6),
prefill_ms_median: Some(120.0),
decode_ms_median: Some(1100.0),
prefill_tps_median: Some(1066.7),
vram_used_mb_median: Some(43008.0),
vram_total_mb: Some(65536),
gpu_util_pct_median: Some(89.0),
gpu_temp_c_median: Some(64.0),
concurrency: None,
ttft_p95_load_s: None,
queue_wait_ms_median: None,
rejected_median: None,
samples: 5,
gpu: Some("2× RTX 5090".into()),
}];
let md = render_markdown(&rows);
assert!(md.contains("| engine |"));
assert!(md.contains("prefill tok/s"));
assert!(md.contains("VRAM (GB)"));
assert!(md.contains("conc"));
assert!(md.contains("beast"));
assert!(md.contains("`30d50d6`"));
assert!(md.contains("0.123"));
// p95 column rendered.
assert!(md.contains("0.222"));
// VRAM used/total in GB (43008/65536 MiB → 42.0/64.0).
assert!(md.contains("42.0/64.0"));
}
#[test]
@@ -99,6 +421,21 @@ mod tests {
ttft_s_median: Some(0.1),
decode_tps_median: None,
total_s_median: Some(0.5),
ttft_s_p95: Some(0.1),
ttft_s_p99: Some(0.1),
total_s_p95: Some(0.5),
total_s_p99: Some(0.5),
prefill_ms_median: None,
decode_ms_median: None,
prefill_tps_median: None,
vram_used_mb_median: None,
vram_total_mb: None,
gpu_util_pct_median: None,
gpu_temp_c_median: None,
concurrency: None,
ttft_p95_load_s: None,
queue_wait_ms_median: None,
rejected_median: None,
samples: 1,
gpu: None,
}];

View File

@@ -62,6 +62,41 @@ pub struct ScenarioMetrics {
pub prompt_tokens: Option<u64>,
/// Completion tokens: from `usage` when present, else content-chunk count.
pub completion_tokens: u64,
/// Server-measured prefill duration (ms), from the `usage.helexa_timing`
/// extension (#85). `None` when the server didn't emit it (external
/// engines, non-instrumented paths). The honest prefill-phase number,
/// distinct from client-observed `ttft_s` which also includes request
/// setup + first-byte network latency.
pub prefill_ms: Option<u64>,
/// Server-measured decode duration (ms), from `usage.helexa_timing`.
pub decode_ms: Option<u64>,
/// Tokens submitted to prefill — the denominator for prefill tok/s.
pub prefill_tokens: Option<u64>,
// ── Concurrency / agentic-load fields (#89) ──────────────────────────
// Set only by the concurrency scenario, which fans out N simultaneous
// streams to characterize the real a0/hermes/opencode workload that
// batch-1 single-request measurement can't see. `None` for single
// requests. For a concurrency burst, the inherited fields carry the
// aggregate: `ttft_s` = median TTFT across streams, `decode_tps` = node
// throughput (total tokens / burst window), `total_s` = burst wall-clock,
// `completion_tokens` = total across streams.
/// Number of simultaneous streams in the burst (the cell dimension).
pub concurrency: Option<u32>,
/// p95 of per-stream TTFT within the burst — the tail under simultaneous
/// load, where batch-1 serialization actually hurts.
pub ttft_p95_s: Option<f64>,
/// Median per-stream admission queue-wait (ms), approximated as
/// `ttft prefill_ms` (#85): on a batch-1 server, later streams wait for
/// earlier ones, so TTFT inflates while server prefill stays constant —
/// the gap is the wait. `None` if streams didn't report `helexa_timing`.
pub queue_wait_ms_median: Option<f64>,
/// Streams shed by admission control (HTTP 429/503) during the burst —
/// honest backpressure, not silent failures.
pub rejected: Option<u32>,
/// Full generated text, captured only by the capability probe (#91) so
/// the output can be quality-scored later (manual or LLM-judge). `None`
/// for latency/throughput scenarios, which discard the text.
pub artifact: Option<String>,
}
#[async_trait]
@@ -85,10 +120,13 @@ pub trait Scenario: Send + Sync {
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics>;
}
/// Build the active scenario set from config. One chat-latency scenario
/// per configured prompt size.
/// Build the active scenario set from config: one chat-latency scenario per
/// prompt size, plus one concurrency scenario per configured level (#89).
/// Concurrency levels default to empty (opt-in), since a burst puts real
/// simultaneous load on a serving fleet — operators enable it deliberately.
pub fn build_scenarios(cfg: &ScenarioConfig) -> Vec<Box<dyn Scenario>> {
cfg.prompt_sizes
let mut scenarios: Vec<Box<dyn Scenario>> = cfg
.prompt_sizes
.iter()
.map(|&size| {
Box::new(ChatLatencyScenario {
@@ -96,7 +134,46 @@ pub fn build_scenarios(cfg: &ScenarioConfig) -> Vec<Box<dyn Scenario>> {
approx_prompt_tokens: size,
}) as Box<dyn Scenario>
})
.collect()
.collect();
for &n in &cfg.concurrency_levels {
scenarios.push(Box::new(ConcurrencyScenario {
id: format!("concurrency:{n}"),
concurrency: n,
approx_prompt_tokens: cfg.concurrency_prompt_tokens,
}) as Box<dyn Scenario>);
}
for probe in &cfg.capability_probes {
scenarios.push(Box::new(CapabilityScenario {
id: format!("capability:{}", probe.name),
prompt: probe.prompt.clone(),
max_tokens: probe.max_tokens,
}) as Box<dyn Scenario>);
}
scenarios
}
/// A single small streamed request, timed like a chat-latency run. Used by
/// the swap-cost measurement (#90) to capture the cold first-request latency
/// straight after a reload. Reuses the shared SSE-timing core.
pub async fn cold_probe(ctx: &RunCtx<'_>) -> Result<ScenarioMetrics> {
let prompt = build_prompt(128);
let payload = chat_payload(ctx, &prompt);
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload))
.await
.map_err(|_| anyhow!("cold probe timed out after {:?}", ctx.timeout))?
}
/// The chat-completions request body shared by the latency and concurrency
/// scenarios — streamed, deterministic (temperature 0), usage included.
fn chat_payload(ctx: &RunCtx, prompt: &str) -> serde_json::Value {
json!({
"model": ctx.model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": ctx.max_tokens,
"temperature": 0,
"stream": true,
"stream_options": {"include_usage": true},
})
}
/// Streamed single-request chat-completions latency probe — the batch-1
@@ -118,15 +195,7 @@ impl Scenario for ChatLatencyScenario {
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
let prompt = build_prompt(self.approx_prompt_tokens);
let payload = json!({
"model": ctx.model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": ctx.max_tokens,
"temperature": 0,
"stream": true,
"stream_options": {"include_usage": true},
});
let payload = chat_payload(ctx, &prompt);
let fut = stream_and_measure(ctx, &payload);
tokio::time::timeout(ctx.timeout, fut)
.await
@@ -134,11 +203,182 @@ impl Scenario for ChatLatencyScenario {
}
}
/// Fan-out load probe: fire `concurrency` identical streams at once and
/// measure how the fleet behaves under simultaneous pressure (#89). This is
/// the only scenario that exercises the real a0/hermes/opencode pattern —
/// many agentic requests per user turn — which batch-1 single-request
/// timing cannot characterize. On a batch-1 serialized server, aggregate
/// throughput stays ~flat while TTFT/queue-wait inflate with `concurrency`;
/// that gap is the evidence for/against continuous batching.
pub struct ConcurrencyScenario {
id: String,
concurrency: u32,
approx_prompt_tokens: u32,
}
#[async_trait]
impl Scenario for ConcurrencyScenario {
fn id(&self) -> &str {
&self.id
}
fn prompt_size(&self) -> u32 {
self.approx_prompt_tokens
}
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
let prompt = build_prompt(self.approx_prompt_tokens);
let payload = chat_payload(ctx, &prompt);
// Fire all streams at once; each is independently timed and capped by
// the per-request timeout so one hung stream can't stall the burst.
let burst_start = Instant::now();
let futs = (0..self.concurrency).map(|_| async {
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload)).await
});
let results = futures::future::join_all(futs).await;
let burst_window = burst_start.elapsed().as_secs_f64();
let mut streams: Vec<ScenarioMetrics> = Vec::new();
let mut rejected: u32 = 0;
for r in results {
match r {
Ok(Ok(m)) => streams.push(m),
// Admission backpressure (429/503) is shed load, counted
// separately from genuine failures/timeouts.
Ok(Err(e)) if is_admission_reject(&e) => rejected += 1,
Ok(Err(_)) | Err(_) => {}
}
}
if streams.is_empty() {
return Err(anyhow!(
"all {} concurrent streams failed ({rejected} shed by admission)",
self.concurrency
));
}
let total_tokens: u64 = streams.iter().map(|m| m.completion_tokens).sum();
let ttfts: Vec<f64> = streams.iter().map(|m| m.ttft_s).collect();
// queue-wait ≈ TTFT server prefill (#85); only for streams that
// reported helexa_timing.
let queue_waits: Vec<f64> = streams
.iter()
.filter_map(|m| {
m.prefill_ms
.map(|p| (m.ttft_s * 1000.0 - p as f64).max(0.0))
})
.collect();
// Aggregate decode throughput across the whole node for the burst.
let aggregate_tps = if burst_window > 0.0 {
Some(total_tokens as f64 / burst_window)
} else {
None
};
Ok(ScenarioMetrics {
ttft_s: median(&ttfts).unwrap_or(0.0),
decode_tps: aggregate_tps,
total_s: burst_window,
prompt_tokens: streams.iter().find_map(|m| m.prompt_tokens),
completion_tokens: total_tokens,
prefill_ms: None,
decode_ms: None,
prefill_tokens: None,
concurrency: Some(self.concurrency),
ttft_p95_s: percentile(&ttfts, 95.0),
queue_wait_ms_median: median(&queue_waits),
rejected: Some(rejected),
artifact: None,
})
}
}
/// Quality probe (#91): runs a fixed prompt and stores the full generated
/// text as an artifact for later scoring (manual now, LLM-judge later). The
/// point is to compare reasoning/planning quality across models — the axis
/// speed-only scenarios miss — so the frontier A/B (F3) picks on capability,
/// not just throughput.
pub struct CapabilityScenario {
id: String,
prompt: String,
max_tokens: u64,
}
#[async_trait]
impl Scenario for CapabilityScenario {
fn id(&self) -> &str {
&self.id
}
/// Capability probes have no synthetic prompt-token target; the cell is
/// keyed by the scenario id alone.
fn prompt_size(&self) -> u32 {
0
}
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
let payload = json!({
"model": ctx.model_id,
"messages": [{"role": "user", "content": self.prompt}],
"max_tokens": self.max_tokens,
"temperature": 0,
"stream": true,
"stream_options": {"include_usage": true},
});
let fut = stream_and_measure_inner(ctx, &payload, true);
tokio::time::timeout(ctx.timeout, fut)
.await
.map_err(|_| anyhow!("capability probe timed out after {:?}", ctx.timeout))?
}
}
/// Whether a stream error was admission backpressure (HTTP 429/503) rather
/// than a genuine failure. `stream_and_measure` renders the upstream status
/// into the error string, so a substring check is sufficient.
fn is_admission_reject(e: &anyhow::Error) -> bool {
let s = e.to_string();
s.contains("429") || s.contains("503")
}
/// Median of a slice (sorted copy). `None` if empty.
fn median(values: &[f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
let mut v = values.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let lo = (v.len() - 1) / 2;
let hi = v.len() / 2;
Some((v[lo] + v[hi]) / 2.0)
}
/// Nearest-rank percentile of a slice (`p` in 0..=100). `None` if empty.
fn percentile(values: &[f64], p: f64) -> Option<f64> {
if values.is_empty() {
return None;
}
let mut v = values.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let rank = (p / 100.0 * v.len() as f64).ceil() as usize;
Some(v[rank.clamp(1, v.len()) - 1])
}
/// The SSE-timing core, ported from `bench.py::one_run`. Kept free of the
/// `Scenario` trait so it's unit-testable against a mock byte stream.
async fn stream_and_measure(
ctx: &RunCtx<'_>,
payload: &serde_json::Value,
) -> Result<ScenarioMetrics> {
stream_and_measure_inner(ctx, payload, false).await
}
/// As [`stream_and_measure`] but accumulates the full visible text when
/// `capture_text` is set — used by the capability probe (#91) to store the
/// generated artifact for later quality scoring.
async fn stream_and_measure_inner(
ctx: &RunCtx<'_>,
payload: &serde_json::Value,
capture_text: bool,
) -> Result<ScenarioMetrics> {
let start = Instant::now();
let resp = ctx
@@ -160,6 +400,10 @@ async fn stream_and_measure(
let mut chunk_count: u64 = 0;
let mut prompt_tokens: Option<u64> = None;
let mut completion_tokens: Option<u64> = None;
let mut prefill_ms: Option<u64> = None;
let mut decode_ms: Option<u64> = None;
let mut prefill_tokens: Option<u64> = None;
let mut captured = String::new();
while let Some(event) = stream.next().await {
let event = event.context("reading SSE stream")?;
@@ -172,46 +416,86 @@ async fn stream_and_measure(
Ok(c) => c,
Err(_) => continue, // tolerate non-JSON keepalive frames
};
if let Some(choice) = chunk.choices.first()
&& choice
if let Some(choice) = chunk.choices.first() {
// Liveness counts ANY generated delta (#117). Thinking
// models (Qwen3-Next-Thinking, Qwen3 with thinking on)
// stream `reasoning_content` first — sometimes for their
// entire budget — and a content-only view misread that as
// a dead stream ("no content chunks received") while also
// producing impossible client-side rates (reasoning-
// inclusive token counts over a visible-content-only
// window; observed: "244 tok/s" on a 3060). For
// non-thinking models the first delta IS content, so
// `ttft_s` semantics are unchanged for them.
let content = choice
.delta
.get("content")
.and_then(|c| c.as_str())
.is_some_and(|s| !s.is_empty())
{
if first.is_none() {
first = Some(now);
.filter(|c| !c.is_empty());
let reasoning = choice
.delta
.get("reasoning_content")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty());
if content.is_some() || reasoning.is_some() {
if first.is_none() {
first = Some(now);
}
last = Some(now);
chunk_count += 1;
}
if capture_text && let Some(text) = content {
captured.push_str(text);
}
last = Some(now);
chunk_count += 1;
}
if let Some(usage) = chunk.usage {
prompt_tokens = Some(usage.prompt_tokens);
completion_tokens = Some(usage.completion_tokens);
if let Some(t) = usage.helexa_timing {
prefill_ms = Some(t.prefill_ms);
decode_ms = Some(t.decode_ms);
prefill_tokens = Some(t.prefill_tokens);
}
}
}
let end = Instant::now();
let first = first.ok_or_else(|| anyhow!("no content chunks received"))?;
let first = first.ok_or_else(|| anyhow!("no generated chunks received"))?;
// neuron emits one SSE chunk per visible token, so chunk_count is an
// engine-truth count when no usage frame is sent.
// neuron emits one SSE chunk per generated token, so chunk_count is
// an engine-truth count when no usage frame is sent.
let tokens = completion_tokens.filter(|&t| t > 0).unwrap_or(chunk_count);
// decode rate is only meaningful over a real inter-chunk window.
// Decode rate: prefer the server-measured split (#85) — it counts
// every generated token over the actual decode window, immune to
// reasoning-suppression frame mismatches. Fall back to the client
// inter-chunk window with the CHUNK count (same frame) — never
// usage.completion_tokens over the chunk window, which mixes a
// reasoning-inclusive numerator with a visible-only denominator.
let window = last
.filter(|&l| l > first)
.map(|l| (l - first).as_secs_f64())
.unwrap_or(0.0);
let decode_tps = match decode_ms {
Some(ms) if ms > 200 && tokens > 0 => Some(tokens as f64 / (ms as f64 / 1000.0)),
_ if window > 0.2 => Some(chunk_count as f64 / window),
_ => None,
};
Ok(ScenarioMetrics {
ttft_s: (first - start).as_secs_f64(),
decode_tps: if window > 0.2 {
Some(tokens as f64 / window)
} else {
None
},
decode_tps,
total_s: (end - start).as_secs_f64(),
prompt_tokens,
completion_tokens: tokens,
prefill_ms,
decode_ms,
prefill_tokens,
// Concurrency fields unset on the single-request path; the
// concurrency scenario builds its own aggregate (#89).
concurrency: None,
ttft_p95_s: None,
queue_wait_ms_median: None,
rejected: None,
artifact: if capture_text { Some(captured) } else { None },
})
}
@@ -229,6 +513,54 @@ mod tests {
assert!(small.ends_with("/no_think"));
}
#[test]
fn median_and_percentile_basics() {
assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
assert_eq!(median(&[]), None);
let v = [1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(percentile(&v, 50.0), Some(3.0));
assert_eq!(percentile(&v, 95.0), Some(5.0)); // nearest-rank → max with n=5
assert_eq!(percentile(&[], 95.0), None);
}
#[test]
fn admission_rejects_detected_by_status() {
assert!(is_admission_reject(&anyhow!(
"upstream returned 429 Too Many Requests"
)));
assert!(is_admission_reject(&anyhow!(
"upstream returned 503 Service Unavailable"
)));
assert!(!is_admission_reject(&anyhow!(
"upstream returned 500 Internal"
)));
assert!(!is_admission_reject(&anyhow!("connection refused")));
}
#[test]
fn concurrency_scenarios_built_from_config() {
use crate::config::{CapabilityProbe, ScenarioConfig};
let cfg = ScenarioConfig {
prompt_sizes: vec![128],
max_tokens: 64,
concurrency_levels: vec![2, 8],
concurrency_prompt_tokens: 512,
capability_probes: vec![CapabilityProbe {
name: "plan".into(),
prompt: "Write a plan.".into(),
max_tokens: 2048,
}],
};
let ids: Vec<String> = build_scenarios(&cfg)
.iter()
.map(|s| s.id().to_string())
.collect();
assert!(ids.contains(&"chat:128".to_string()));
assert!(ids.contains(&"concurrency:2".to_string()));
assert!(ids.contains(&"concurrency:8".to_string()));
assert!(ids.contains(&"capability:plan".to_string()));
}
#[test]
fn prompt_floor_for_tiny_targets() {
// max(approx,16) floor means even 0 yields a non-trivial prompt.

View File

@@ -51,6 +51,35 @@ pub struct RunRecord {
pub decode_tps: Option<f64>,
pub total_s: Option<f64>,
pub completion_tokens: Option<u64>,
// server-measured prefill/decode split (#85), null on engines/paths
// that don't emit `usage.helexa_timing`.
pub prefill_ms: Option<u64>,
pub decode_ms: Option<u64>,
pub prefill_tokens: Option<u64>,
// GPU telemetry sampled from /health around the run (#87), null for
// non-neuron targets or when /health was unreachable. vram_used_mb is
// the node sum; util/temp are the hottest single device.
pub vram_used_mb: Option<u64>,
pub gpu_util_pct: Option<u32>,
pub gpu_temp_c: Option<u32>,
// concurrency / agentic-load burst metrics (#89), null for single-request
// scenarios. For a burst, ttft_s/decode_tps/total_s carry the aggregate.
pub concurrency: Option<u32>,
pub ttft_p95_s: Option<f64>,
pub queue_wait_ms: Option<f64>,
pub rejected: Option<u32>,
// cold-load / model-swap cost (#90), set only by the deliberate
// `swap-cost` measurement (scenario_id = "swap"). The other metric
// fields carry the cold first-request after reload.
pub swap_unload_ms: Option<u64>,
pub swap_load_ms: Option<u64>,
// capability probe (#91): the full generated text (scenario_id =
// "capability:<name>"), scored for quality later. `quality_score` /
// `scorer` are null at insert — set by `score` (manual) or a future
// LLM-judge.
pub artifact: Option<String>,
pub quality_score: Option<f64>,
pub scorer: Option<String>,
// outcome
pub ok: bool,
pub error: Option<String>,
@@ -123,6 +152,21 @@ impl Store {
decode_tps REAL,
total_s REAL,
completion_tokens INTEGER,
prefill_ms INTEGER,
decode_ms INTEGER,
prefill_tokens INTEGER,
vram_used_mb INTEGER,
gpu_util_pct INTEGER,
gpu_temp_c INTEGER,
concurrency INTEGER,
ttft_p95_s REAL,
queue_wait_ms REAL,
rejected INTEGER,
swap_unload_ms INTEGER,
swap_load_ms INTEGER,
artifact TEXT,
quality_score REAL,
scorer TEXT,
ok INTEGER NOT NULL,
error TEXT
);
@@ -133,6 +177,51 @@ impl Store {
"#,
)
.context("initialising sqlite schema")?;
// Additive migrations for DBs created before a column existed.
// `CREATE TABLE IF NOT EXISTS` above only seeds fresh DBs; existing
// ones need the columns backfilled (as NULL) so older rows coexist
// with new metrics. There is no migration framework — each entry is
// an idempotent "add if missing".
Self::ensure_columns(
conn,
"runs",
&[
("prefill_ms", "INTEGER"),
("decode_ms", "INTEGER"),
("prefill_tokens", "INTEGER"),
("vram_used_mb", "INTEGER"),
("gpu_util_pct", "INTEGER"),
("gpu_temp_c", "INTEGER"),
("concurrency", "INTEGER"),
("ttft_p95_s", "REAL"),
("queue_wait_ms", "REAL"),
("rejected", "INTEGER"),
("swap_unload_ms", "INTEGER"),
("swap_load_ms", "INTEGER"),
("artifact", "TEXT"),
("quality_score", "REAL"),
("scorer", "TEXT"),
],
)?;
Ok(())
}
/// Add any of `columns` that the table is missing (`ALTER TABLE ADD
/// COLUMN`). Idempotent: existing columns are read from
/// `PRAGMA table_info` and skipped, so this is safe to run on every open.
fn ensure_columns(conn: &Connection, table: &str, columns: &[(&str, &str)]) -> Result<()> {
let mut existing = std::collections::HashSet::new();
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let names = stmt.query_map([], |row| row.get::<_, String>(1))?;
for name in names {
existing.insert(name?);
}
for (name, ty) in columns {
if !existing.contains(*name) {
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {name} {ty};"))
.with_context(|| format!("adding column {table}.{name}"))?;
}
}
Ok(())
}
@@ -166,6 +255,11 @@ impl Store {
model_id, harness, capabilities_json, devices_json,
scenario_id, prompt_size_approx, prompt_tokens_actual, max_tokens,
ttft_s, decode_tps, total_s, completion_tokens,
prefill_ms, decode_ms, prefill_tokens,
vram_used_mb, gpu_util_pct, gpu_temp_c,
concurrency, ttft_p95_s, queue_wait_ms, rejected,
swap_unload_ms, swap_load_ms,
artifact, quality_score, scorer,
ok, error
) VALUES (
?1, ?2, ?3, ?4,
@@ -176,7 +270,12 @@ impl Store {
?20, ?21, ?22, ?23,
?24, ?25, ?26, ?27,
?28, ?29, ?30, ?31,
?32, ?33
?32, ?33, ?34,
?35, ?36, ?37,
?38, ?39, ?40, ?41,
?42, ?43,
?44, ?45, ?46,
?47, ?48
)",
params![
r.ts,
@@ -210,6 +309,21 @@ impl Store {
r.decode_tps,
r.total_s,
r.completion_tokens,
r.prefill_ms,
r.decode_ms,
r.prefill_tokens,
r.vram_used_mb,
r.gpu_util_pct,
r.gpu_temp_c,
r.concurrency,
r.ttft_p95_s,
r.queue_wait_ms,
r.rejected,
r.swap_unload_ms,
r.swap_load_ms,
r.artifact,
r.quality_score,
r.scorer,
r.ok as i64,
r.error,
],
@@ -224,7 +338,10 @@ impl Store {
// successful run, then median that SHA's samples.
let mut stmt = self.conn.prepare(
"SELECT target_name, model_id, scenario_id, prompt_size_approx, git_sha,
ttft_s, decode_tps, total_s, prompt_tokens_actual, gpus_json
ttft_s, decode_tps, total_s, prompt_tokens_actual, gpus_json,
prefill_ms, decode_ms, prefill_tokens,
vram_used_mb, gpu_util_pct, gpu_temp_c,
concurrency, ttft_p95_s, queue_wait_ms, rejected
FROM runs
WHERE ok=1
ORDER BY target_name, model_id, scenario_id, id",
@@ -241,12 +358,188 @@ impl Store {
total_s: row.get(7)?,
prompt_tokens_actual: row.get(8)?,
gpus_json: row.get(9)?,
prefill_ms: row.get(10)?,
decode_ms: row.get(11)?,
prefill_tokens: row.get(12)?,
vram_used_mb: row.get(13)?,
gpu_util_pct: row.get(14)?,
gpu_temp_c: row.get(15)?,
concurrency: row.get(16)?,
ttft_p95_s: row.get(17)?,
queue_wait_ms: row.get(18)?,
rejected: row.get(19)?,
})
})?;
let raws: Vec<RawRow> = rows.collect::<rusqlite::Result<_>>()?;
Ok(aggregate(raws))
}
/// Context-length scaling curves (#88): per (target, model), the latest
/// build's `chat:<n>` cells pivoted by prompt size into prefill & decode
/// tok/s vs context. The headline is `decode_flatness` — decode tok/s at
/// the largest context divided by the smallest. Near 1.0 confirms the
/// Gated-DeltaNet O(1)-in-sequence-length decode; a sharp drop locates
/// where the model stops scaling for free.
pub fn scaling(&self) -> Result<Vec<ScalingCurve>> {
use std::collections::BTreeMap;
// Reuse the already-aggregated report cells; the chat:<n> rows are
// exactly the per-context measurement points.
let mut by_model: BTreeMap<(String, String), Vec<ReportRow>> = BTreeMap::new();
for r in self.report_rows()? {
if r.scenario_id.starts_with("chat:") {
by_model
.entry((r.target_name.clone(), r.model_id.clone()))
.or_default()
.push(r);
}
}
let mut out = Vec::new();
for ((target_name, model_id), mut rows) in by_model {
rows.sort_by_key(|r| r.prompt_size_approx);
let points: Vec<ScalingPoint> = rows
.iter()
.map(|r| ScalingPoint {
prompt_size: r.prompt_size_approx,
prompt_tokens: r.prompt_tokens,
prefill_tps: r.prefill_tps_median,
decode_tps: r.decode_tps_median,
samples: r.samples,
})
.collect();
// Flatness across the smallest→largest points that both have a
// decode rate (skips cells where the decode window was too short).
let with_decode: Vec<&ScalingPoint> =
points.iter().filter(|p| p.decode_tps.is_some()).collect();
let decode_flatness = match (with_decode.first(), with_decode.last()) {
(Some(lo), Some(hi)) if with_decode.len() >= 2 => {
match (lo.decode_tps, hi.decode_tps) {
(Some(a), Some(b)) if a > 0.0 => Some(b / a),
_ => None,
}
}
_ => None,
};
out.push(ScalingCurve {
target_name,
model_id,
git_sha: rows.first().map(|r| r.git_sha.clone()).unwrap_or_default(),
gpu: rows.iter().find_map(|r| r.gpu.clone()),
points,
decode_flatness,
});
}
Ok(out)
}
/// Cold-load / model-swap costs (#90): per (target, model) at the latest
/// build, the median unload→reload latency and the cold first-request
/// latency after reload (the `scenario_id = "swap"` rows).
pub fn swap_costs(&self) -> Result<Vec<SwapCost>> {
use std::collections::BTreeMap;
let mut stmt = self.conn.prepare(
"SELECT target_name, model_id, git_sha, gpus_json,
swap_unload_ms, swap_load_ms, ttft_s, total_s
FROM runs
WHERE ok=1 AND scenario_id='swap'
ORDER BY target_name, model_id, id",
)?;
struct Raw {
target: String,
model: String,
sha: String,
gpus_json: Option<String>,
unload_ms: Option<f64>,
load_ms: Option<f64>,
ttft_s: Option<f64>,
total_s: Option<f64>,
}
let raws: Vec<Raw> = stmt
.query_map([], |r| {
Ok(Raw {
target: r.get(0)?,
model: r.get(1)?,
sha: r.get(2)?,
gpus_json: r.get(3)?,
unload_ms: r.get::<_, Option<i64>>(4)?.map(|v| v as f64),
load_ms: r.get::<_, Option<i64>>(5)?.map(|v| v as f64),
ttft_s: r.get(6)?,
total_s: r.get(7)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
let mut by: BTreeMap<(String, String), Vec<Raw>> = BTreeMap::new();
for r in raws {
by.entry((r.target.clone(), r.model.clone()))
.or_default()
.push(r);
}
let mut out = Vec::new();
for ((target, model), rows) in by {
let latest = rows.last().map(|r| r.sha.clone()).unwrap_or_default();
let cell: Vec<&Raw> = rows.iter().filter(|r| r.sha == latest).collect();
out.push(SwapCost {
target_name: target,
model_id: model,
git_sha: latest,
gpu: cell
.iter()
.find_map(|r| r.gpus_json.as_deref().and_then(gpu_label)),
unload_ms_median: median(cell.iter().filter_map(|r| r.unload_ms)),
load_ms_median: median(cell.iter().filter_map(|r| r.load_ms)),
cold_ttft_s_median: median(cell.iter().filter_map(|r| r.ttft_s)),
cold_total_s_median: median(cell.iter().filter_map(|r| r.total_s)),
samples: cell.len(),
});
}
Ok(out)
}
/// Capability-probe runs (#91): the stored output artifacts and their
/// quality scores. `unscored_only` filters to runs awaiting a score —
/// the worklist for manual scoring or a future LLM-judge.
pub fn capability_runs(&self, unscored_only: bool) -> Result<Vec<CapabilityRun>> {
let sql = format!(
"SELECT id, ts, target_name, model_id, scenario_id, git_sha,
quality_score, scorer, artifact
FROM runs
WHERE ok=1 AND scenario_id LIKE 'capability:%'{}
ORDER BY id DESC",
if unscored_only {
" AND quality_score IS NULL"
} else {
""
},
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map([], |r| {
Ok(CapabilityRun {
id: r.get(0)?,
ts: r.get(1)?,
target_name: r.get(2)?,
model_id: r.get(3)?,
scenario_id: r.get(4)?,
git_sha: r.get(5)?,
quality_score: r.get(6)?,
scorer: r.get(7)?,
artifact: r.get(8)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Attach a quality score to one capability run (#91). `scorer` records
/// who/what scored it ("manual", "llm:claude-…"). Returns the row count
/// updated (0 if the id doesn't exist).
pub fn set_score(&self, run_id: i64, score: f64, scorer: &str) -> Result<usize> {
let n = self.conn.execute(
"UPDATE runs SET quality_score=?1, scorer=?2 WHERE id=?3",
params![score, scorer, run_id],
)?;
Ok(n)
}
// ── Read API surface (consumed by api.rs) ─────────────────────────
/// Total recorded runs (for `/api/health`).
@@ -379,7 +672,10 @@ impl Store {
"SELECT id, ts, target_name, hostname, git_sha, build_timestamp, package_version,
model_id, harness, scenario_id, prompt_size_approx, prompt_tokens_actual,
max_tokens, ttft_s, decode_tps, total_s, completion_tokens, ok, error,
gpus_json
gpus_json, prefill_ms, decode_ms, prefill_tokens,
vram_used_mb, gpu_util_pct, gpu_temp_c,
concurrency, ttft_p95_s, queue_wait_ms, rejected,
swap_unload_ms, swap_load_ms
FROM runs",
);
let mut conds: Vec<String> = Vec::new();
@@ -435,6 +731,18 @@ impl Store {
completion_tokens: r.get(16)?,
ok: r.get::<_, i64>(17)? != 0,
error: r.get(18)?,
prefill_ms: r.get(20)?,
decode_ms: r.get(21)?,
prefill_tokens: r.get(22)?,
vram_used_mb: r.get(23)?,
gpu_util_pct: r.get(24)?,
gpu_temp_c: r.get(25)?,
concurrency: r.get(26)?,
ttft_p95_s: r.get(27)?,
queue_wait_ms: r.get(28)?,
rejected: r.get(29)?,
swap_unload_ms: r.get(30)?,
swap_load_ms: r.get(31)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -554,6 +862,18 @@ pub struct RunRow {
pub decode_tps: Option<f64>,
pub total_s: Option<f64>,
pub completion_tokens: Option<u64>,
pub prefill_ms: Option<u64>,
pub decode_ms: Option<u64>,
pub prefill_tokens: Option<u64>,
pub vram_used_mb: Option<u64>,
pub gpu_util_pct: Option<u64>,
pub gpu_temp_c: Option<u64>,
pub concurrency: Option<u64>,
pub ttft_p95_s: Option<f64>,
pub queue_wait_ms: Option<f64>,
pub rejected: Option<u64>,
pub swap_unload_ms: Option<u64>,
pub swap_load_ms: Option<u64>,
pub ok: bool,
pub error: Option<String>,
}
@@ -569,6 +889,16 @@ struct RawRow {
total_s: Option<f64>,
prompt_tokens_actual: Option<u64>,
gpus_json: Option<String>,
prefill_ms: Option<u64>,
decode_ms: Option<u64>,
prefill_tokens: Option<u64>,
vram_used_mb: Option<u64>,
gpu_util_pct: Option<u64>,
gpu_temp_c: Option<u64>,
concurrency: Option<u64>,
ttft_p95_s: Option<f64>,
queue_wait_ms: Option<f64>,
rejected: Option<u64>,
}
/// An aggregated cell ready for the report table.
@@ -583,11 +913,100 @@ pub struct ReportRow {
pub ttft_s_median: Option<f64>,
pub decode_tps_median: Option<f64>,
pub total_s_median: Option<f64>,
/// Latency tail percentiles — where batch-1 pain actually shows up, and
/// invisible behind a bare median. p95/p99 nearest-rank; with few
/// samples they collapse toward the max (honest, not interpolated).
pub ttft_s_p95: Option<f64>,
pub ttft_s_p99: Option<f64>,
pub total_s_p95: Option<f64>,
pub total_s_p99: Option<f64>,
/// Server-measured prefill/decode split (#85). `prefill_tps_median` is
/// the true prompt-encoding rate (prefill_tokens / prefill_ms),
/// complementing `decode_tps_median` (the generation rate).
pub prefill_ms_median: Option<f64>,
pub decode_ms_median: Option<f64>,
pub prefill_tps_median: Option<f64>,
/// GPU telemetry sampled from /health around the run (#87). `vram_used_mb`
/// is the node sum; `vram_total_mb` (from discovery) lets the report show
/// real headroom — the "2/3 used" hunch as a number. util/temp are the
/// hottest device. All `None` for non-neuron targets.
pub vram_used_mb_median: Option<f64>,
pub vram_total_mb: Option<u64>,
pub gpu_util_pct_median: Option<f64>,
pub gpu_temp_c_median: Option<f64>,
/// Concurrency / agentic-load burst metrics (#89). `concurrency` is the
/// burst width (constant per cell). `ttft_p95_load_s` is the within-burst
/// TTFT tail; `queue_wait_ms_median` the admission wait; `rejected_median`
/// the per-burst shed count. All `None` for non-concurrency scenarios.
pub concurrency: Option<u64>,
pub ttft_p95_load_s: Option<f64>,
pub queue_wait_ms_median: Option<f64>,
pub rejected_median: Option<f64>,
pub samples: usize,
/// Public-facing resource name (the host's GPU(s)), e.g. "2× RTX 5090".
pub gpu: Option<String>,
}
/// One context-length scaling curve for a (target, model) at its latest
/// build — the points ordered by prompt size, plus the decode-flatness
/// summary (#88).
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScalingCurve {
pub target_name: String,
pub model_id: String,
pub git_sha: String,
pub gpu: Option<String>,
pub points: Vec<ScalingPoint>,
/// decode tok/s at the largest context ÷ at the smallest. ~1.0 = flat
/// (GDN O(1) decode); <1 quantifies the drop-off. `None` with <2 points.
pub decode_flatness: Option<f64>,
}
/// Cold-load / model-swap cost for a (target, model) at its latest build
/// (#90): the reload latency and the cold first-request after it.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SwapCost {
pub target_name: String,
pub model_id: String,
pub git_sha: String,
pub gpu: Option<String>,
/// Time to free the model (`POST /models/unload`), ms.
pub unload_ms_median: Option<f64>,
/// Time to reload it (`POST /models/load`, synchronous), ms — the
/// headline swap cost feeding the vision cold-swap policy (F4e).
pub load_ms_median: Option<f64>,
/// TTFT of the first request after reload (cold caches), seconds.
pub cold_ttft_s_median: Option<f64>,
/// Total wall-clock of that cold first request, seconds.
pub cold_total_s_median: Option<f64>,
pub samples: usize,
}
/// One capability-probe run (#91): the stored output artifact plus its
/// quality score (null until scored manually or by a future LLM-judge).
#[derive(Debug, Clone, serde::Serialize)]
pub struct CapabilityRun {
pub id: i64,
pub ts: String,
pub target_name: String,
pub model_id: String,
pub scenario_id: String,
pub git_sha: String,
pub quality_score: Option<f64>,
pub scorer: Option<String>,
pub artifact: Option<String>,
}
/// One point on a [`ScalingCurve`]: the throughput at a given context size.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScalingPoint {
pub prompt_size: u32,
pub prompt_tokens: Option<u64>,
pub prefill_tps: Option<f64>,
pub decode_tps: Option<f64>,
pub samples: usize,
}
/// Group by (target, model, scenario), keep only the latest SHA's rows
/// (latest = the SHA of the last-inserted row, since input is id-ordered),
/// and median each metric.
@@ -611,6 +1030,11 @@ fn aggregate(raws: Vec<RawRow>) -> Vec<ReportRow> {
let latest_sha = rows.last().map(|r| r.git_sha.clone()).unwrap_or_default();
let cell: Vec<&RawRow> = rows.iter().filter(|r| r.git_sha == latest_sha).collect();
let prompt_size_approx = cell.first().map(|r| r.prompt_size_approx).unwrap_or(0);
// Per-row prefill tok/s, derived from the server-measured split.
let prefill_tps = |r: &&RawRow| match (r.prefill_tokens, r.prefill_ms) {
(Some(tok), Some(ms)) if ms > 0 => Some(tok as f64 * 1000.0 / ms as f64),
_ => None,
};
out.push(ReportRow {
target_name,
model_id,
@@ -621,6 +1045,27 @@ fn aggregate(raws: Vec<RawRow>) -> Vec<ReportRow> {
ttft_s_median: median(cell.iter().filter_map(|r| r.ttft_s)),
decode_tps_median: median(cell.iter().filter_map(|r| r.decode_tps)),
total_s_median: median(cell.iter().filter_map(|r| r.total_s)),
ttft_s_p95: percentile(cell.iter().filter_map(|r| r.ttft_s), 95.0),
ttft_s_p99: percentile(cell.iter().filter_map(|r| r.ttft_s), 99.0),
total_s_p95: percentile(cell.iter().filter_map(|r| r.total_s), 95.0),
total_s_p99: percentile(cell.iter().filter_map(|r| r.total_s), 99.0),
prefill_ms_median: median(cell.iter().filter_map(|r| r.prefill_ms.map(|m| m as f64))),
decode_ms_median: median(cell.iter().filter_map(|r| r.decode_ms.map(|m| m as f64))),
prefill_tps_median: median(cell.iter().filter_map(prefill_tps)),
vram_used_mb_median: median(
cell.iter().filter_map(|r| r.vram_used_mb.map(|v| v as f64)),
),
vram_total_mb: cell
.iter()
.find_map(|r| r.gpus_json.as_deref().and_then(gpu_total_vram_mb)),
gpu_util_pct_median: median(
cell.iter().filter_map(|r| r.gpu_util_pct.map(|v| v as f64)),
),
gpu_temp_c_median: median(cell.iter().filter_map(|r| r.gpu_temp_c.map(|v| v as f64))),
concurrency: cell.iter().find_map(|r| r.concurrency),
ttft_p95_load_s: median(cell.iter().filter_map(|r| r.ttft_p95_s)),
queue_wait_ms_median: median(cell.iter().filter_map(|r| r.queue_wait_ms)),
rejected_median: median(cell.iter().filter_map(|r| r.rejected.map(|v| v as f64))),
samples: cell.len(),
gpu: cell
.iter()
@@ -630,6 +1075,19 @@ fn aggregate(raws: Vec<RawRow>) -> Vec<ReportRow> {
out
}
/// Node total VRAM in MB, summed across the devices in a run's stored
/// `gpus_json` (the discovery `DeviceInfo` list, each with `vram_total_mb`).
/// Pairs with the sampled `vram_used_mb` to report real headroom (#87).
/// `None` when empty/absent or no device declares a total.
fn gpu_total_vram_mb(gpus_json: &str) -> Option<u64> {
let devices: Vec<serde_json::Value> = serde_json::from_str(gpus_json).ok()?;
let total: u64 = devices
.iter()
.filter_map(|d| d.get("vram_total_mb").and_then(|v| v.as_u64()))
.sum();
(total > 0).then_some(total)
}
/// Compact GPU label from a run's stored `gpus_json` (the discovery device
/// list) — e.g. "2× RTX 5090", "RTX 4090". `None` when empty/absent. Used
/// as the public-facing resource name in place of internal hostnames.
@@ -680,6 +1138,22 @@ fn median(values: impl Iterator<Item = f64>) -> Option<f64> {
Some((v[lo] + v[hi]) / 2.0)
}
/// Nearest-rank percentile (`p` in 0..=100). Chosen over interpolation
/// because bench cells hold only a handful of samples: with n=5, p95/p99
/// resolve to the max, which honestly says "this is the worst we saw"
/// rather than inventing a value between samples we never observed.
fn percentile(values: impl Iterator<Item = f64>, p: f64) -> Option<f64> {
let mut v: Vec<f64> = values.collect();
if v.is_empty() {
return None;
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// rank = ceil(p/100 * n), clamped to [1, n]; index is rank-1.
let rank = (p / 100.0 * v.len() as f64).ceil() as usize;
let idx = rank.clamp(1, v.len()) - 1;
Some(v[idx])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -717,6 +1191,21 @@ mod tests {
decode_tps: Some(50.0),
total_s: Some(1.0),
completion_tokens: Some(50),
prefill_ms: Some(200),
decode_ms: Some(1000),
prefill_tokens: Some(130),
vram_used_mb: Some(42000),
gpu_util_pct: Some(88),
gpu_temp_c: Some(64),
concurrency: None,
ttft_p95_s: None,
queue_wait_ms: None,
rejected: None,
swap_unload_ms: None,
swap_load_ms: None,
artifact: None,
quality_score: None,
scorer: None,
ok,
error: if ok { None } else { Some("boom".into()) },
}
@@ -755,6 +1244,197 @@ mod tests {
assert!((rows[0].ttft_s_median.unwrap() - 0.3).abs() < 1e-9);
}
#[test]
fn report_surfaces_percentiles_and_prefill_split() {
let s = Store::open_in_memory().unwrap();
// Five samples on one cell with spread TTFT so percentiles differ
// from the median, plus a server-measured prefill/decode split.
for (i, ttft) in [0.10, 0.12, 0.14, 0.16, 0.50].iter().enumerate() {
let mut r = rec("beast", "sha", "m", "chat:128", true);
r.ttft_s = Some(*ttft);
r.total_s = Some(ttft + 1.0);
r.prefill_ms = Some(200 + i as u64);
r.prefill_tokens = Some(400);
s.insert_run(&r).unwrap();
}
let rows = s.report_rows().unwrap();
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.samples, 5);
// p50 is the middle value; p95/p99 (nearest-rank, n=5) hit the max.
assert!((row.ttft_s_median.unwrap() - 0.14).abs() < 1e-9);
assert!((row.ttft_s_p95.unwrap() - 0.50).abs() < 1e-9);
assert!((row.ttft_s_p99.unwrap() - 0.50).abs() < 1e-9);
// prefill tok/s = 400 tok / ~0.2 s ≈ 2000 tok/s.
assert!(row.prefill_tps_median.unwrap() > 1900.0);
assert!(row.prefill_ms_median.is_some());
}
#[test]
fn report_surfaces_vram_and_gpu_telemetry() {
let s = Store::open_in_memory().unwrap();
let mut r = rec("beast", "sha", "m", "chat:128", true);
// Node total VRAM from discovery devices → headroom denominator.
r.gpus_json =
Some(r#"[{"name":"RTX 5090","vram_total_mb":32000},{"name":"RTX 5090","vram_total_mb":32000}]"#.into());
r.vram_used_mb = Some(42000);
r.gpu_util_pct = Some(90);
r.gpu_temp_c = Some(66);
s.insert_run(&r).unwrap();
let rows = s.report_rows().unwrap();
let row = &rows[0];
assert_eq!(row.vram_used_mb_median, Some(42000.0));
assert_eq!(row.vram_total_mb, Some(64000)); // 2× 32000
assert_eq!(row.gpu_util_pct_median, Some(90.0));
assert_eq!(row.gpu_temp_c_median, Some(66.0));
}
#[test]
fn report_surfaces_concurrency_burst_metrics() {
let s = Store::open_in_memory().unwrap();
// Two concurrency:8 burst-runs with shed load and a queue-wait tail.
for (qw, rej) in [(120.0, 1u32), (180.0, 3u32)] {
let mut r = rec("beast", "sha", "m", "concurrency:8", true);
r.concurrency = Some(8);
r.ttft_p95_s = Some(0.9);
r.queue_wait_ms = Some(qw);
r.rejected = Some(rej);
s.insert_run(&r).unwrap();
}
let row = s
.report_rows()
.unwrap()
.into_iter()
.find(|r| r.scenario_id == "concurrency:8")
.unwrap();
assert_eq!(row.concurrency, Some(8));
assert_eq!(row.queue_wait_ms_median, Some(150.0)); // median(120,180)
assert_eq!(row.rejected_median, Some(2.0)); // median(1,3)
assert_eq!(row.ttft_p95_load_s, Some(0.9));
}
#[test]
fn capability_runs_store_artifact_and_accept_scores() {
let s = Store::open_in_memory().unwrap();
let mut r = rec("beast", "sha", "m", "capability:plan", true);
r.artifact = Some("a thorough implementation plan...".into());
s.insert_run(&r).unwrap();
// A non-capability row must not appear in capability_runs.
s.insert_run(&rec("beast", "sha", "m", "chat:128", true))
.unwrap();
let unscored = s.capability_runs(true).unwrap();
assert_eq!(unscored.len(), 1);
let id = unscored[0].id;
assert!(unscored[0].artifact.as_deref().unwrap().contains("plan"));
assert!(unscored[0].quality_score.is_none());
// Score it; it then drops off the unscored worklist.
assert_eq!(s.set_score(id, 7.5, "manual").unwrap(), 1);
assert!(s.capability_runs(true).unwrap().is_empty());
let all = s.capability_runs(false).unwrap();
assert_eq!(all[0].quality_score, Some(7.5));
assert_eq!(all[0].scorer.as_deref(), Some("manual"));
// Unknown id updates nothing.
assert_eq!(s.set_score(999_999, 1.0, "manual").unwrap(), 0);
}
#[test]
fn swap_costs_pivots_swap_rows() {
let s = Store::open_in_memory().unwrap();
for (unload, load) in [(300u64, 24000u64), (340, 26000)] {
let mut r = rec("beast", "sha", "m", "swap", true);
r.swap_unload_ms = Some(unload);
r.swap_load_ms = Some(load);
r.ttft_s = Some(2.5); // cold first-request
r.total_s = Some(5.0);
s.insert_run(&r).unwrap();
}
// A non-swap row must be ignored by swap_costs.
s.insert_run(&rec("beast", "sha", "m", "chat:128", true))
.unwrap();
let costs = s.swap_costs().unwrap();
assert_eq!(costs.len(), 1);
let c = &costs[0];
assert_eq!(c.unload_ms_median, Some(320.0)); // median(300,340)
assert_eq!(c.load_ms_median, Some(25000.0)); // median(24000,26000)
assert_eq!(c.cold_ttft_s_median, Some(2.5));
assert_eq!(c.samples, 2);
}
#[test]
fn scaling_pivots_chat_cells_and_computes_flatness() {
let s = Store::open_in_memory().unwrap();
// Two context points for one model: decode tok/s 50 @128, 45 @4096.
let mut small = rec("beast", "sha", "m", "chat:128", true);
small.prompt_size_approx = 128;
small.decode_tps = Some(50.0);
small.prefill_ms = Some(100);
small.prefill_tokens = Some(128);
s.insert_run(&small).unwrap();
let mut big = rec("beast", "sha", "m", "chat:4096", true);
big.prompt_size_approx = 4096;
big.decode_tps = Some(45.0);
big.prefill_ms = Some(1000);
big.prefill_tokens = Some(4096);
s.insert_run(&big).unwrap();
// A concurrency cell must NOT leak into the scaling curve.
let mut conc = rec("beast", "sha", "m", "concurrency:8", true);
conc.concurrency = Some(8);
s.insert_run(&conc).unwrap();
let curves = s.scaling().unwrap();
assert_eq!(curves.len(), 1);
let c = &curves[0];
assert_eq!(c.points.len(), 2); // only the two chat:<n> points
assert_eq!(c.points[0].prompt_size, 128); // ordered ascending
assert_eq!(c.points[1].prompt_size, 4096);
// flatness = decode@largest / decode@smallest = 45/50 = 0.9
assert!((c.decode_flatness.unwrap() - 0.9).abs() < 1e-9);
}
#[test]
fn gpu_total_vram_sums_devices() {
let j = r#"[{"vram_total_mb":32000},{"vram_total_mb":32000}]"#;
assert_eq!(gpu_total_vram_mb(j), Some(64000));
assert_eq!(gpu_total_vram_mb("[]"), None);
assert_eq!(gpu_total_vram_mb("not json"), None);
}
#[test]
fn percentile_nearest_rank() {
let vals = || [1.0, 2.0, 3.0, 4.0, 5.0].into_iter();
assert_eq!(percentile(vals(), 50.0), Some(3.0));
assert_eq!(percentile(vals(), 95.0), Some(5.0));
assert_eq!(percentile(vals(), 99.0), Some(5.0));
assert_eq!(percentile(std::iter::empty(), 95.0), None);
}
#[test]
fn migration_is_idempotent_and_backfills() {
// A DB whose `runs` table predates the prefill columns: create the
// pre-#85 shape, insert a row, then run ensure_columns twice.
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE runs (id INTEGER PRIMARY KEY, ttft_s REAL);
INSERT INTO runs (ttft_s) VALUES (0.1);",
)
.unwrap();
for _ in 0..2 {
Store::ensure_columns(
&conn,
"runs",
&[("prefill_ms", "INTEGER"), ("decode_ms", "INTEGER")],
)
.unwrap();
}
// Columns now exist and the old row reads them back as NULL.
let got: Option<i64> = conn
.query_row("SELECT prefill_ms FROM runs", [], |r| r.get(0))
.unwrap();
assert_eq!(got, None);
}
#[test]
fn gpu_label_formats() {
let two = r#"[{"name":"NVIDIA GeForce RTX 5090"},{"name":"NVIDIA GeForce RTX 5090"}]"#;

View File

@@ -9,11 +9,11 @@
use crate::client::TargetClient;
use crate::config::{BenchConfig, TargetConfig, TargetKind};
use crate::scenario::{RunCtx, build_scenarios};
use crate::scenario::{RunCtx, ScenarioMetrics, build_scenarios};
use crate::store::{RunRecord, Store};
use anyhow::Result;
use cortex_core::build_info::BuildInfo;
use cortex_core::discovery::DiscoveryResponse;
use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
use cortex_core::harness::ModelInfo;
/// helexa-bench's own build version.
@@ -38,6 +38,38 @@ pub struct SweepSummary {
pub targets_unreachable: usize,
}
/// Node-level GPU telemetry folded from one `/health` snapshot (#87):
/// VRAM used summed across the node's devices, and the hottest/busiest
/// single device for utilization and temperature.
#[derive(Debug, Clone, Copy)]
struct HealthAgg {
vram_used_mb: u64,
gpu_util_pct: u32,
gpu_temp_c: u32,
}
/// Cold-load / model-swap timing for one measure_swap cycle (#90).
#[derive(Debug, Clone, Copy)]
struct SwapTiming {
unload_ms: u64,
load_ms: u64,
}
impl HealthAgg {
fn from_health(h: &HealthResponse) -> Self {
HealthAgg {
vram_used_mb: h.devices.iter().map(|d| d.vram_used_mb).sum(),
gpu_util_pct: h
.devices
.iter()
.map(|d| d.utilization_pct)
.max()
.unwrap_or(0),
gpu_temp_c: h.devices.iter().map(|d| d.temp_c).max().unwrap_or(0),
}
}
}
pub struct Sweeper {
cfg: BenchConfig,
client: TargetClient,
@@ -72,6 +104,105 @@ impl Sweeper {
}
}
/// Deliberate cold-load / model-swap cost measurement (#90), invoked by
/// the `swap-cost` subcommand — **never** the continuous sweep. For each
/// neuron target and each currently-warm model: unload it, time the
/// reload, then time a cold first request. This takes the model offline
/// for the reload, so it is an explicit operator action (maintenance
/// window), recorded under `scenario_id = "swap"`.
pub async fn swap_cost_once(&self) -> Result<SweepSummary> {
let mut summary = SweepSummary::default();
for target in &self.cfg.targets {
if target.kind != TargetKind::Neuron {
continue; // load/unload is a neuron-native operation
}
let build = match self.client.fetch_version(target).await {
Ok(b) => b,
Err(e) => {
summary.targets_unreachable += 1;
tracing::warn!(target = %target.name, error = %format!("{e:#}"), "swap: target unreachable");
continue;
}
};
let discovery = self.client.fetch_discovery(target).await.unwrap_or(None);
let models = self.client.warm_models(target).await.unwrap_or_default();
for model in &models {
match self
.measure_swap(target, &build, discovery.as_ref(), model)
.await
{
Ok(()) => summary.measured += 1,
Err(e) => {
summary.failed += 1;
tracing::warn!(target = %target.name, model = %model.id, error = %format!("{e:#}"), "swap: measurement failed");
}
}
}
}
Ok(summary)
}
/// Unload → timed reload → timed cold first request for one model.
async fn measure_swap(
&self,
target: &TargetConfig,
build: &BuildInfo,
discovery: Option<&DiscoveryResponse>,
model: &ModelInfo,
) -> Result<()> {
let spec = TargetClient::spec_from_info(model)?;
tracing::warn!(target = %target.name, model = %model.id, "swap: unloading (model goes offline until reload)");
let t0 = std::time::Instant::now();
self.client.unload_model(target, &model.id).await?;
let unload_ms = t0.elapsed().as_millis() as u64;
let t1 = std::time::Instant::now();
self.client.load_model(target, &spec).await?;
let load_ms = t1.elapsed().as_millis() as u64;
tracing::info!(target = %target.name, model = %model.id, unload_ms, load_ms, "swap: reloaded; measuring cold first request");
// Cold first request — caches empty straight after the load.
let ctx = RunCtx {
client: self.client.http(),
chat_url: self.client.chat_url(target),
model_id: model.id.clone(),
max_tokens: self.cfg.scenarios.max_tokens,
timeout: self.cfg.bench.request_timeout(),
};
let cold = crate::scenario::cold_probe(&ctx).await;
let swap = SwapTiming { unload_ms, load_ms };
let rec = match &cold {
Ok(m) => self.build_record(
target,
build,
discovery,
model,
"swap",
0,
Ok(m),
None,
Some(swap),
),
Err(e) => {
let msg = format!("{e:#}");
self.build_record(
target,
build,
discovery,
model,
"swap",
0,
Err(&msg),
None,
Some(swap),
)
}
};
self.store.insert_run(&rec)?;
Ok(())
}
/// One full pass over all targets.
pub async fn run_once(&self) -> Result<SweepSummary> {
let mut summary = SweepSummary::default();
@@ -131,7 +262,21 @@ impl Sweeper {
}
for i in 0..need {
match scenario.run(&ctx).await {
let result = scenario.run(&ctx).await;
// Sample GPU telemetry right after the run, while the
// model is loaded and decode VRAM is at its recent peak
// (#87). neuron's /health is ~5s-cached, so this is a
// coarse high-water proxy, not an instantaneous peak — but
// it's the headroom signal we can read over the wire. A
// flaky /health degrades to None, never a failed run.
let health = self
.client
.fetch_health(target)
.await
.ok()
.flatten()
.map(|h| HealthAgg::from_health(&h));
match result {
Ok(m) => {
let rec = self.build_record(
target,
@@ -141,6 +286,8 @@ impl Sweeper {
scenario.id(),
scenario.prompt_size(),
Ok(&m),
health,
None,
);
self.store.insert_run(&rec)?;
summary.measured += 1;
@@ -160,6 +307,8 @@ impl Sweeper {
scenario.id(),
scenario.prompt_size(),
Err(&msg),
health,
None,
);
self.store.insert_run(&rec)?;
summary.failed += 1;
@@ -186,19 +335,14 @@ impl Sweeper {
scenario_id: &str,
prompt_size: u32,
result: Result<&crate::scenario::ScenarioMetrics, &str>,
health: Option<HealthAgg>,
swap: Option<SwapTiming>,
) -> RunRecord {
let (ok, error, ttft, decode, total, prompt_tokens, completion) = match result {
Ok(m) => (
true,
None,
Some(m.ttft_s),
m.decode_tps,
Some(m.total_s),
m.prompt_tokens,
Some(m.completion_tokens),
),
Err(e) => (false, Some(e.to_string()), None, None, None, None, None),
let (m, error): (Option<&ScenarioMetrics>, Option<String>) = match result {
Ok(m) => (Some(m), None),
Err(e) => (None, Some(e.to_string())),
};
let ok = m.is_some();
RunRecord {
ts: chrono::Utc::now().to_rfc3339(),
@@ -230,12 +374,29 @@ impl Sweeper {
.unwrap_or_else(|_| "[]".to_string()),
scenario_id: scenario_id.to_string(),
prompt_size_approx: prompt_size,
prompt_tokens_actual: prompt_tokens,
prompt_tokens_actual: m.and_then(|m| m.prompt_tokens),
max_tokens: self.cfg.scenarios.max_tokens,
ttft_s: ttft,
decode_tps: decode,
total_s: total,
completion_tokens: completion,
ttft_s: m.map(|m| m.ttft_s),
decode_tps: m.and_then(|m| m.decode_tps),
total_s: m.map(|m| m.total_s),
completion_tokens: m.map(|m| m.completion_tokens),
prefill_ms: m.and_then(|m| m.prefill_ms),
decode_ms: m.and_then(|m| m.decode_ms),
prefill_tokens: m.and_then(|m| m.prefill_tokens),
vram_used_mb: health.map(|h| h.vram_used_mb),
gpu_util_pct: health.map(|h| h.gpu_util_pct),
gpu_temp_c: health.map(|h| h.gpu_temp_c),
concurrency: m.and_then(|m| m.concurrency),
ttft_p95_s: m.and_then(|m| m.ttft_p95_s),
queue_wait_ms: m.and_then(|m| m.queue_wait_ms_median),
rejected: m.and_then(|m| m.rejected),
swap_unload_ms: swap.map(|s| s.unload_ms),
swap_load_ms: swap.map(|s| s.load_ms),
// Capability artifact (#91); score/scorer are attached later by
// the `score` subcommand or a future LLM-judge.
artifact: m.and_then(|m| m.artifact.clone()),
quality_score: None,
scorer: None,
ok,
error,
}

View File

@@ -46,6 +46,21 @@ fn rec(
decode_tps: if ok { Some(30.0) } else { None },
total_s: if ok { Some(2.0) } else { None },
completion_tokens: if ok { Some(60) } else { None },
prefill_ms: if ok { Some(150) } else { None },
decode_ms: if ok { Some(1800) } else { None },
prefill_tokens: if ok { Some(130) } else { None },
vram_used_mb: if ok { Some(42000) } else { None },
gpu_util_pct: if ok { Some(85) } else { None },
gpu_temp_c: if ok { Some(63) } else { None },
concurrency: None,
ttft_p95_s: None,
queue_wait_ms: None,
rejected: None,
swap_unload_ms: None,
swap_load_ms: None,
artifact: None,
quality_score: None,
scorer: None,
ok,
error: if ok { None } else { Some("boom".into()) },
}

View File

@@ -89,6 +89,9 @@ fn config_for(endpoint: String, db_path: String) -> BenchConfig {
scenarios: ScenarioConfig {
prompt_sizes: vec![128], // single scenario keeps assertions simple
max_tokens: 16,
concurrency_levels: Vec::new(),
concurrency_prompt_tokens: 512,
capability_probes: Vec::new(),
},
api: Default::default(),
targets: vec![TargetConfig {

View File

@@ -15,6 +15,7 @@ path = "src/lib.rs"
[dependencies]
cortex-core = { workspace = true }
helexa-stream = { path = "../helexa-stream" }
tokio = { workspace = true }
axum = { workspace = true }
@@ -24,10 +25,17 @@ serde = { workspace = true }
serde_json = { workspace = true }
figment = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
# Jail (isolated cwd + env) for config tests.
figment = { workspace = true, features = ["test"] }
# Self-signed cert generation + a minimal HTTPS server for the outbound
# TLS-pinning tests (#74).
rcgen = "0.13"
rustls = "0.23"
tokio-rustls = "0.26"

View File

@@ -0,0 +1,260 @@
//! Federation catalogue (#75) — the router's aggregate `/v1/models`.
//!
//! Presents the **deduped union** of every reachable cortex's `/v1/models`
//! as the router's own 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").
//!
//! Re-tiering: the fractal design is neuron ← cortex ← router. At the
//! router tier the "nodes" are **cortexes**, so the merged entry's
//! `feasible_on` / `locations` are rewritten to **operator names**, not the
//! neuron names a cortex reports. That keeps the federation view honest
//! ("served by these operators") without leaking each operator's internal
//! topology (neuron names, per-device VRAM) to end users.
//!
//! Conflict resolution when operators advertise the same model with
//! different enrichment:
//! - **`limit`** → the *tightest* (smallest `context`), so a client never
//! overflows the most-constrained operator that might serve it (same rule
//! cortex uses across its neurons).
//! - **`cost`** → the *cheapest* (lowest input, then output), the
//! federation "from" price. Richer policy (a range, region/price-aware
//! selection) couples to #68 and is left as a follow-up.
use crate::state::{CortexTopology, entry_feasible};
use cortex_core::harness::{ModelCost, ModelLimit};
use cortex_core::node::{CortexModelEntry, ModelLocation, ModelStatus};
use std::collections::HashMap;
/// Build the federation catalogue: the deduped union of every reachable
/// cortex's serveable models, merged across operators and sorted by id.
pub fn aggregate_models(topology: &HashMap<String, CortexTopology>) -> Vec<CortexModelEntry> {
// Iterate cortexes in name order so `feasible_on` / `locations` and the
// limit/cost tie-breaks are deterministic regardless of map ordering.
let mut cortexes: Vec<(&String, &CortexTopology)> = topology.iter().collect();
cortexes.sort_by(|a, b| a.0.cmp(b.0));
let mut merged: HashMap<String, CortexModelEntry> = HashMap::new();
for (cortex_name, t) in cortexes {
if !t.reachable {
continue;
}
for entry in t.models.values() {
// Only surface models the cortex can actually serve — a
// catalogue-only entry no neuron can host shouldn't appear in
// the federation view.
if !entry_feasible(entry) {
continue;
}
merged
.entry(entry.id.clone())
.and_modify(|acc| merge_into(acc, cortex_name, entry))
.or_insert_with(|| router_entry(cortex_name, entry));
}
}
let mut out: Vec<CortexModelEntry> = merged.into_values().collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
// Re-derive the flat ecosystem fields (#78) from the merged (tightest)
// limit — the values deserialized from each cortex are per-operator and
// may not match the federation-wide merge.
for e in &mut out {
e.sync_flat_limit();
}
out
}
/// Seed a federation entry from the first cortex that serves the model,
/// re-tiering `feasible_on` / `locations` to the operator name.
fn router_entry(cortex: &str, e: &CortexModelEntry) -> CortexModelEntry {
CortexModelEntry {
id: e.id.clone(),
object: "model".into(),
created: e.created,
owned_by: e.owned_by.clone(),
loaded: e.loaded,
feasible_on: vec![cortex.to_string()],
locations: loaded_location(cortex, e),
capabilities: e.capabilities.clone(),
limit: e.limit.clone(),
cost: e.cost.clone(),
tool_call: e.tool_call,
reasoning: e.reasoning,
// Derived from `limit` by the final sync pass in aggregate_models.
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}
}
/// Fold another cortex's view of the same model into the merged entry.
fn merge_into(acc: &mut CortexModelEntry, cortex: &str, e: &CortexModelEntry) {
acc.loaded |= e.loaded;
acc.feasible_on.push(cortex.to_string());
acc.locations.extend(loaded_location(cortex, e));
for cap in &e.capabilities {
if !acc.capabilities.contains(cap) {
acc.capabilities.push(cap.clone());
}
}
acc.tool_call |= e.tool_call;
acc.reasoning |= e.reasoning;
acc.limit = tightest_limit(acc.limit.take(), e.limit.clone());
acc.cost = cheapest_cost(acc.cost.take(), e.cost.clone());
}
/// A single cortex-tier location when the model is loaded at that operator;
/// empty when only cold-loadable. Neuron-level VRAM is deliberately dropped.
fn loaded_location(cortex: &str, e: &CortexModelEntry) -> Vec<ModelLocation> {
if e.loaded {
vec![ModelLocation {
node: cortex.to_string(),
status: ModelStatus::Loaded,
vram_estimate_mb: None,
}]
} else {
Vec::new()
}
}
/// Smaller `context` wins — never advertise more headroom than the
/// most-constrained operator can honour.
fn tightest_limit(a: Option<ModelLimit>, b: Option<ModelLimit>) -> Option<ModelLimit> {
match (a, b) {
(None, x) | (x, None) => x,
(Some(a), Some(b)) => Some(if b.context < a.context { b } else { a }),
}
}
/// Cheapest by (input, output) price — the federation "from" price.
fn cheapest_cost(a: Option<ModelCost>, b: Option<ModelCost>) -> Option<ModelCost> {
match (a, b) {
(None, x) | (x, None) => x,
(Some(a), Some(b)) => Some(if (b.input, b.output) < (a.input, a.output) {
b
} else {
a
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::CortexTopology;
fn entry(id: &str, loaded: bool, feasible: bool) -> CortexModelEntry {
CortexModelEntry {
id: id.into(),
object: "model".into(),
created: 0,
owned_by: "helexa".into(),
loaded,
feasible_on: if feasible || loaded {
vec!["some-neuron".into()]
} else {
vec![]
},
locations: vec![],
capabilities: vec![],
limit: None,
cost: None,
tool_call: false,
reasoning: false,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}
}
fn cortex(reachable: bool, entries: Vec<CortexModelEntry>) -> CortexTopology {
CortexTopology {
reachable,
consecutive_failures: 0,
last_poll: None,
healthy_nodes: 1,
total_nodes: 1,
models: entries.into_iter().map(|e| (e.id.clone(), e)).collect(),
}
}
#[test]
fn dedupes_and_merges_availability_across_cortexes() {
let mut topo = HashMap::new();
// c-a: model loaded. c-b: same model only cold-loadable.
topo.insert("c-a".into(), cortex(true, vec![entry("m", true, true)]));
topo.insert("c-b".into(), cortex(true, vec![entry("m", false, true)]));
let out = aggregate_models(&topo);
assert_eq!(out.len(), 1, "duplicate model id collapses to one");
let m = &out[0];
assert!(m.loaded, "loaded somewhere → loaded");
// feasible_on re-tiered to operator names, both present, sorted.
assert_eq!(m.feasible_on, vec!["c-a".to_string(), "c-b".to_string()]);
// Only the loaded operator contributes a location, named by operator.
assert_eq!(m.locations.len(), 1);
assert_eq!(m.locations[0].node, "c-a");
assert_eq!(m.locations[0].vram_estimate_mb, None);
}
#[test]
fn unreachable_cortex_is_excluded() {
let mut topo = HashMap::new();
topo.insert("up".into(), cortex(true, vec![entry("m", true, true)]));
topo.insert(
"down".into(),
cortex(false, vec![entry("other", true, true)]),
);
let out = aggregate_models(&topo);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "m");
}
#[test]
fn catalogue_only_infeasible_entries_are_hidden() {
let mut topo = HashMap::new();
topo.insert("c".into(), cortex(true, vec![entry("ghost", false, false)]));
assert!(aggregate_models(&topo).is_empty());
}
#[test]
fn preserves_tightest_limit_and_cheapest_cost() {
let mut a = entry("m", true, true);
a.limit = Some(ModelLimit {
context: 32_768,
input: None,
output: 4096,
});
a.cost = Some(ModelCost {
input: 0.50,
output: 1.50,
cache_read: None,
cache_write: None,
});
let mut b = entry("m", true, true);
b.limit = Some(ModelLimit {
context: 16_384, // tighter
input: None,
output: 4096,
});
b.cost = Some(ModelCost {
input: 0.20, // cheaper
output: 0.80,
cache_read: None,
cache_write: None,
});
let mut topo = HashMap::new();
topo.insert("c-a".into(), cortex(true, vec![a]));
topo.insert("c-b".into(), cortex(true, vec![b]));
let out = aggregate_models(&topo);
assert_eq!(out.len(), 1);
assert_eq!(out[0].limit.as_ref().unwrap().context, 16_384);
assert_eq!(out[0].cost.as_ref().unwrap().input, 0.20);
// Flat #78 fields re-derived from the merged (tightest) limit.
assert_eq!(out[0].max_model_len, Some(16_384));
assert_eq!(out[0].max_input_tokens, None);
assert_eq!(out[0].max_output_tokens, Some(4096));
}
}

View File

@@ -27,17 +27,50 @@ pub struct RouterSettings {
/// of the router (see #69's TLS posture). The router never owns an
/// inbound TLS listener.
pub listen: String,
/// How often (seconds) the background poller refreshes each cortex's
/// health + `/v1/models` topology (#72). Defaults to 10s, matching the
/// cortex↔neuron poll cadence one tier down.
#[serde(default = "default_poll_interval_secs")]
pub poll_interval_secs: u64,
/// This router instance's region (e.g. "eu-west"). When set, dispatch
/// (#73) prefers cortexes whose `region` matches, before falling back to
/// any feasible cortex. `None` → no geo affinity.
#[serde(default)]
pub region: Option<String>,
}
fn default_poll_interval_secs() -> u64 {
10
}
/// One downstream cortex the router may proxy to. The router verifies the
/// cortex's outbound TLS cert (#74) and routes on capacity (#73); it holds
/// no entitlement logic of its own and forwards the client bearer verbatim.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CortexEndpoint {
/// Human-readable label (e.g. "lair-cafe").
pub name: String,
/// Base URL of the cortex gateway (e.g. "https://cortex.example.com").
pub endpoint: String,
/// Optional region tag (e.g. "eu-west") for geo affinity in dispatch
/// (#73). `None` → no region preference applies to this cortex.
#[serde(default)]
pub region: Option<String>,
/// Path to a PEM trust anchor that **enrols** this cortex (#74): the
/// expected CA (or self-signed cert) the cortex's TLS cert must chain
/// to. When set on an `https://` endpoint, the router builds a client
/// that trusts **only** this anchor (platform roots disabled), so the
/// outbound router→cortex hop — which carries the client's bearer —
/// reaches a cert the router was told to expect, and a rogue endpoint
/// presenting any other (even publicly-valid) cert is rejected at the
/// TLS handshake. A rejected handshake surfaces as a connection error,
/// which the poller (#72) already treats as unreachable → excluded.
///
/// `None` → standard platform-root validation (use for cortexes behind
/// a publicly-trusted cert, or plaintext `http://` on a private network
/// where the WireGuard mesh is the trust boundary).
#[serde(default)]
pub tls_ca: Option<String>,
}
impl RouterConfig {
@@ -58,6 +91,8 @@ impl Default for RouterConfig {
Self {
router: RouterSettings {
listen: "0.0.0.0:8088".into(),
poll_interval_secs: default_poll_interval_secs(),
region: None,
},
cortexes: vec![],
}

View File

@@ -0,0 +1,221 @@
//! Capacity-aware dispatch (#73) — the router's data path.
//!
//! Given an inbound request's `model`, pick a reachable cortex that can
//! serve it (preferring warm/loaded, region-affine, higher-headroom),
//! forward the client's bearer **unchanged** (auth stays at cortex), and
//! stream the response back verbatim via the shared [`helexa_stream`]
//! module. Cortex's #63-shaped rejections (`429 rate_limit_exceeded`,
//! `400 context_length_exceeded`, …) pass through untouched. Transport
//! failures fail over to the next feasible cortex; a genuine HTTP response —
//! any status — is returned as-is and never retried away.
//!
//! The router holds **no entitlement logic**: it routes on capacity, not
//! budget.
use crate::config::CortexEndpoint;
use crate::error::envelope_response;
use crate::state::RouterState;
use axum::body::Bytes;
use axum::http::HeaderMap;
use axum::response::Response;
use cortex_core::error_envelope::OpenAiError;
use helexa_stream::{ChunkObserver, StreamError};
use std::cmp::Reverse;
use std::collections::HashMap;
/// Retry-After hint (seconds) on the router's own transient rejections.
const RETRY_AFTER_SECS: u64 = 5;
/// Outcome of choosing where to send a request.
#[derive(Debug, PartialEq, Eq)]
pub enum Selection {
/// Feasible reachable cortexes, best-first (failover order).
Candidates(Vec<CortexEndpoint>),
/// Some cortex knows the model but none are reachable right now → 503.
NoReachableCapacity,
/// No configured cortex serves the model at all → 404.
UnknownModel,
}
/// Rank the reachable cortexes that can serve `model`, best-first.
///
/// Ordering (each a tie-break for the next): loaded/warm before cold-loadable
/// · region match before not · more healthy nodes before fewer · name for
/// determinism.
pub async fn select_cortexes(state: &RouterState, model: &str) -> Selection {
let topo = state.topology.read().await;
let by_name: HashMap<&str, &CortexEndpoint> = state
.cortexes
.iter()
.map(|c| (c.name.as_str(), c))
.collect();
let mut ranked: Vec<Ranked> = Vec::new();
let mut known_anywhere = false;
for (name, t) in topo.iter() {
let Some(entry) = t.models.get(model) else {
continue;
};
if !crate::state::entry_feasible(entry) {
continue;
}
// Known even via an unreachable cortex's last-good poll — lets us
// tell "temporarily down" (503) from "nobody serves it" (404).
known_anywhere = true;
if !t.reachable {
continue;
}
let Some(ep) = by_name.get(name.as_str()) else {
continue;
};
let region_match = match (&state.region, &ep.region) {
(Some(r), Some(cr)) => r == cr,
_ => false,
};
ranked.push(Ranked {
loaded: entry.loaded,
region_match,
healthy_nodes: t.healthy_nodes,
endpoint: (*ep).clone(),
});
}
if ranked.is_empty() {
return if known_anywhere {
Selection::NoReachableCapacity
} else {
Selection::UnknownModel
};
}
ranked.sort_by(|a, b| {
// false < true, so negate the "good" booleans to sort good first.
(
!a.loaded,
!a.region_match,
Reverse(a.healthy_nodes),
&a.endpoint.name,
)
.cmp(&(
!b.loaded,
!b.region_match,
Reverse(b.healthy_nodes),
&b.endpoint.name,
))
});
Selection::Candidates(ranked.into_iter().map(|r| r.endpoint).collect())
}
struct Ranked {
loaded: bool,
region_match: bool,
healthy_nodes: u32,
endpoint: CortexEndpoint,
}
/// Proxy an inbound inference request to a capacity-bearing cortex.
///
/// `path` is the inference path to forward to (same on the cortex, e.g.
/// `/v1/chat/completions`). The body is parsed only to extract `model`.
pub async fn dispatch(
state: &RouterState,
path: &str,
headers: HeaderMap,
body: Bytes,
) -> Response {
let Some(model) = extract_model(&body) else {
return envelope_response(OpenAiError::new(
400,
"invalid_request_error",
"missing_model_field",
"missing 'model' field in request body",
));
};
let candidates = match select_cortexes(state, &model).await {
Selection::Candidates(c) => c,
Selection::UnknownModel => {
return envelope_response(
OpenAiError::new(
404,
"invalid_request_error",
"model_not_found",
format!("no operator serves model '{model}'"),
)
.with_param("model"),
);
}
Selection::NoReachableCapacity => {
return envelope_response(OpenAiError::service_unavailable(
format!("model '{model}' is temporarily unavailable on all operators"),
Some(RETRY_AFTER_SECS),
));
}
};
// Try candidates in order, failing over only on transport errors. A
// genuine HTTP response (any status — including cortex's #63 429/400)
// is returned verbatim and never retried away.
for ep in &candidates {
// A candidate whose pinned TLS client failed to build (#74) is
// disabled — skip it and fail over, same as an unreachable cortex.
let Some(client) = state.client_for(&ep.name) else {
tracing::warn!(cortex = %ep.name, "no TLS client (disabled); skipping candidate");
continue;
};
let url = format!("{}{}", ep.endpoint, path);
tracing::info!(cortex = %ep.name, url = %url, model = %model, "dispatching");
match helexa_stream::forward_streaming(
client,
&url,
headers.clone(),
body.clone(),
NoopObserver,
)
.await
{
Ok(resp) => return resp,
Err(StreamError::Upstream(e)) => {
tracing::warn!(
cortex = %ep.name,
url = %url,
error = %e,
"cortex unreachable; failing over"
);
continue;
}
Err(StreamError::ResponseBuild(msg)) => {
tracing::error!(cortex = %ep.name, error = %msg, "failed to build proxied response");
return envelope_response(OpenAiError::without_code(
500,
"api_error",
"failed to build proxied response",
));
}
}
}
// Every feasible cortex failed to connect.
tracing::warn!(model = %model, tried = candidates.len(), "all feasible operators unreachable");
envelope_response(OpenAiError::service_unavailable(
format!("all operators able to serve '{model}' are unreachable"),
Some(RETRY_AFTER_SECS),
))
}
/// Pull the `model` field out of a request body without re-serialising it.
fn extract_model(body: &Bytes) -> Option<String> {
let v: serde_json::Value = serde_json::from_slice(body).ok()?;
v.get("model")?.as_str().map(str::to_string)
}
/// The router proxies bytes verbatim and keeps no per-request policy, so it
/// needs no observation hooks. (Token metrics/metering stay at cortex.)
struct NoopObserver;
impl ChunkObserver for NoopObserver {
fn observe(&mut self, _chunk: &[u8]) {}
fn finish(&mut self) {}
}

View File

@@ -0,0 +1,27 @@
//! Router adapter from the shared, axum-agnostic
//! [`cortex_core::error_envelope::OpenAiError`] (#60/#63) to an axum
//! [`Response`], setting `Retry-After` when the envelope carries one.
//!
//! cortex-core owns the envelope shape; this is the only place the router
//! crosses from that data into axum. Mirrors cortex-gateway's adapter so
//! the router's own rejections (no feasible operator, all unreachable) are
//! the same #63-shaped envelopes clients already understand — distinct from
//! cortex's rejections, which the router proxies through verbatim.
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use cortex_core::error_envelope::OpenAiError;
/// Render an [`OpenAiError`] as an axum response (status + JSON envelope +
/// optional `Retry-After`).
pub fn envelope_response(err: OpenAiError) -> Response {
let status = StatusCode::from_u16(err.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let retry_after = err.retry_after_secs;
let mut response = (status, Json(err.body())).into_response();
if let Some(secs) = retry_after
&& let Ok(value) = HeaderValue::from_str(&secs.to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}

View File

@@ -1,37 +1,89 @@
use crate::state::RouterState;
use axum::{Json, Router, extract::State, routing::get};
use cortex_core::openai::ModelsResponse;
use crate::{catalogue, dispatch};
use axum::body::Bytes;
use axum::http::HeaderMap;
use axum::response::Response;
use axum::{Json, Router, extract::State, routing::get, routing::post};
use serde_json::{Value, json};
use std::sync::Arc;
/// Routes served by the router skeleton. The inference paths
/// (`/v1/chat/completions`, `/v1/messages`, …) arrive with capacity-aware
/// dispatch (#73); for now the router only answers `/health` and a stub
/// `/v1/models`.
/// Routes served by the router. Inference paths are capacity-aware-dispatched
/// (#73) to a downstream cortex; `/health` and a stub `/v1/models` are local.
pub fn api_routes() -> Router<Arc<RouterState>> {
Router::new()
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/completions", post(completions))
.route("/v1/responses", post(responses))
.route("/v1/messages", post(messages))
.route("/v1/models", get(list_models))
.route("/health", get(health))
.route("/", get(health))
}
/// `GET /health` — liveness plus the configured downstream cortex count.
/// Real per-cortex reachability lands with the poller (#72).
// ── Inference paths — forwarded verbatim to a chosen cortex ──────────
//
// Each handler dispatches to the same path on a capacity-bearing cortex.
// The body is parsed only to read `model`; the bearer and bytes are
// forwarded unchanged, and the SSE response streams back verbatim.
async fn chat_completions(
State(state): State<Arc<RouterState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
dispatch::dispatch(&state, "/v1/chat/completions", headers, body).await
}
async fn completions(
State(state): State<Arc<RouterState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
dispatch::dispatch(&state, "/v1/completions", headers, body).await
}
async fn responses(
State(state): State<Arc<RouterState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
dispatch::dispatch(&state, "/v1/responses", headers, body).await
}
async fn messages(
State(state): State<Arc<RouterState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
dispatch::dispatch(&state, "/v1/messages", headers, body).await
}
/// `GET /health` — router liveness plus a summary of downstream cortex
/// reachability from the topology poller (#72). `status` reflects the
/// router process itself (always `ok` if it answers); downstream health is
/// the informational `cortexes` block, so a fully-degraded fleet doesn't
/// make the router look dead to its own liveness probe.
async fn health(State(state): State<Arc<RouterState>>) -> Json<Value> {
let topo = state.topology.read().await;
let reachable = topo.values().filter(|t| t.reachable).count();
Json(json!({
"status": "ok",
"cortexes": {
"configured": state.cortexes.len(),
"reachable": reachable,
}
}))
}
/// `GET /v1/models` — empty catalogue stub. The real cross-operator union
/// (catalogue × topology feasibility, aggregated from each cortex) is the
/// federation-catalogue issue (#75).
async fn list_models() -> Json<ModelsResponse> {
Json(ModelsResponse {
object: "list".into(),
data: vec![],
})
/// `GET /v1/models` — the federation catalogue (#75): the deduped union of
/// every reachable cortex's `/v1/models`, so a client doing discovery
/// against the router resolves the whole federation without knowing about
/// operators or cortexes.
async fn list_models(State(state): State<Arc<RouterState>>) -> Json<Value> {
let topo = state.topology.read().await;
let data: Vec<Value> = catalogue::aggregate_models(&topo)
.iter()
.map(|e| json!(e))
.collect();
Json(json!({ "object": "list", "data": data }))
}

View File

@@ -8,12 +8,15 @@
//!
//! It holds **zero entitlement logic** — auth/budget stays at cortex
//! (epic #47); the router forwards the client bearer unchanged and routes
//! on capacity (epic #69). This crate is the binary skeleton (#70):
//! a plaintext axum server reusing `cortex-core` types, serving `/health`
//! and a stub `/v1/models`.
//! on capacity (epic #69). A background [`poller`] keeps a live
//! per-cortex topology (#72) that the dispatcher (#73) will route on.
pub mod catalogue;
pub mod config;
pub mod dispatch;
pub mod error;
pub mod handlers;
pub mod poller;
pub mod state;
use anyhow::Result;
@@ -37,7 +40,15 @@ pub fn build_app(state: Arc<state::RouterState>) -> axum::Router {
/// listener. TLS is terminated by edge nginx ahead of this process.
pub async fn run(config: RouterConfig) -> Result<()> {
let state = Arc::new(state::RouterState::from_config(&config));
let app = build_app(state);
// Background topology poller (#72): refresh each cortex's health +
// catalogue so routing decisions see live capacity.
let poller_state = Arc::clone(&state);
tokio::spawn(async move {
poller::poll_loop(poller_state).await;
});
let app = build_app(Arc::clone(&state));
let listen_addr = config.router.listen.parse::<std::net::SocketAddr>()?;
tracing::info!("helexa-router listening on {listen_addr}");

View File

@@ -0,0 +1,150 @@
//! Background poller that refreshes the multi-operator topology (#72).
//!
//! The same pattern as cortex↔neuron, one tier up: periodically poll each
//! configured cortex's `GET /v1/models` (catalogue × topology feasibility +
//! loaded state) and `GET /health` (coarse node-health/load), building the
//! live map the dispatcher (#73) routes on. An unreachable or erroring
//! cortex is debounced over [`POLL_FAILURE_THRESHOLD`] consecutive misses,
//! then flipped unhealthy and excluded from routing; it recovers on the
//! next successful poll.
use crate::state::RouterState;
use chrono::Utc;
use cortex_core::node::CortexModelEntry;
use serde::Deserialize;
use std::time::Duration;
/// Per-cortex HTTP timeout for each poll request.
const POLL_TIMEOUT: Duration = Duration::from_secs(5);
/// Consecutive failed polls before a cortex is marked unreachable. Mirrors
/// cortex's neuron-poll debounce: a single blip (a busy cortex briefly slow
/// to answer) can't yank it — and all its models — out of routing.
pub const POLL_FAILURE_THRESHOLD: u32 = 3;
/// cortex's `/v1/models` envelope — `{ "object": "list", "data": [...] }`.
#[derive(Debug, Deserialize)]
struct ModelsEnvelope {
#[serde(default)]
data: Vec<CortexModelEntry>,
}
/// The subset of cortex's `/health` the router reads.
#[derive(Debug, Deserialize)]
struct CortexHealth {
nodes: CortexHealthNodes,
}
#[derive(Debug, Deserialize)]
struct CortexHealthNodes {
healthy: u32,
total: u32,
}
/// Run forever, polling all cortexes on the configured interval.
pub async fn poll_loop(state: std::sync::Arc<RouterState>) {
loop {
poll_once(&state).await;
tokio::time::sleep(state.poll_interval).await;
}
}
/// Poll every configured cortex once. Public for testing.
pub async fn poll_once(state: &RouterState) {
for cortex in &state.cortexes {
poll_cortex(state, &cortex.name, &cortex.endpoint).await;
}
}
/// Poll one cortex: refresh its model map from `/v1/models`, then its node
/// health from `/health`. A `/v1/models` failure debounces toward
/// unreachable; the `/health` poll is best-effort and never flips
/// reachability on its own (a cortex serving `/v1/models` is routable even
/// if `/health` momentarily isn't).
async fn poll_cortex(state: &RouterState, name: &str, endpoint: &str) {
// A cortex whose pinned TLS client failed to build (#74) is disabled:
// there is no client to poll with, so it stays unreachable.
let Some(client) = state.client_for(name) else {
let mut topo = state.topology.write().await;
if let Some(entry) = topo.get_mut(name) {
entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
entry.reachable = false;
}
tracing::warn!(cortex = name, "no TLS client (disabled); skipping poll");
return;
};
let models = fetch_models(client, endpoint).await;
let mut topo = state.topology.write().await;
let Some(entry) = topo.get_mut(name) else {
return; // not a configured cortex (shouldn't happen)
};
match models {
Ok(models) => {
entry.models = models.into_iter().map(|m| (m.id.clone(), m)).collect();
entry.reachable = true;
entry.consecutive_failures = 0;
entry.last_poll = Some(Utc::now());
tracing::debug!(cortex = name, models = entry.models.len(), "poll ok");
}
Err(reason) => {
entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
if entry.consecutive_failures >= POLL_FAILURE_THRESHOLD {
entry.reachable = false;
}
tracing::warn!(
cortex = name,
failures = entry.consecutive_failures,
reachable = entry.reachable,
reason,
"cortex poll failed"
);
}
}
drop(topo);
// Best-effort health (node counts). Never flips reachability.
if let Some((healthy, total)) = fetch_health(client, endpoint).await {
let mut topo = state.topology.write().await;
if let Some(entry) = topo.get_mut(name) {
entry.healthy_nodes = healthy;
entry.total_nodes = total;
}
}
}
/// GET `/v1/models`, returning the parsed entries or a short failure reason.
async fn fetch_models(
client: &reqwest::Client,
endpoint: &str,
) -> Result<Vec<CortexModelEntry>, &'static str> {
let url = format!("{endpoint}/v1/models");
let resp = client
.get(&url)
.timeout(POLL_TIMEOUT)
.send()
.await
.map_err(|_| "unreachable")?;
if !resp.status().is_success() {
return Err("non-success status");
}
let envelope = resp
.json::<ModelsEnvelope>()
.await
.map_err(|_| "bad json")?;
Ok(envelope.data)
}
/// GET `/health`, returning `(healthy, total)` node counts. `None` on any
/// failure — the caller leaves the previous counts in place.
async fn fetch_health(client: &reqwest::Client, endpoint: &str) -> Option<(u32, u32)> {
let url = format!("{endpoint}/health");
let resp = client.get(&url).timeout(POLL_TIMEOUT).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let health = resp.json::<CortexHealth>().await.ok()?;
Some((health.nodes.healthy, health.nodes.total))
}

View File

@@ -1,21 +1,144 @@
use crate::config::{CortexEndpoint, RouterConfig};
use chrono::{DateTime, Utc};
use cortex_core::node::CortexModelEntry;
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::RwLock;
/// Shared router state.
/// Shared router state: the configured cortex list plus the live topology
/// map the poller (#72) maintains and the dispatcher (#73) will route on.
///
/// The skeleton (#70) holds only the static downstream cortex list from
/// config. Live multi-operator topology (per-cortex capacity + catalogue)
/// is added by the poller (#72), at which point this grows an
/// `Arc<RwLock<...>>` topology map alongside the static endpoints.
/// This is the router tier of the fractal neuron ← cortex ← router design:
/// just as cortex polls each neuron for capacity/catalogue, the router
/// polls each cortex's `/health` + `/v1/models`.
#[derive(Debug)]
pub struct RouterState {
/// Downstream cortex endpoints, as configured.
pub cortexes: Vec<CortexEndpoint>,
/// Per-cortex HTTP client, keyed by cortex name (#74). A cortex enrolled
/// with a `tls_ca` gets a client that trusts only that anchor; others
/// get a default client. A cortex whose `tls_ca` failed to load is
/// **absent** here — `client_for` returns `None` and it is never
/// polled or routed to (fail closed: a misconfigured pin must not
/// silently fall back to unpinned TLS).
clients: HashMap<String, reqwest::Client>,
/// This router instance's region, for dispatch geo affinity (#73).
pub region: Option<String>,
/// How often the poller refreshes the topology.
pub poll_interval: Duration,
/// Live per-cortex topology, keyed by cortex name. Pre-populated from
/// config (every configured cortex present, `reachable = false`) so the
/// poller and handlers always find an entry; the poller flips
/// reachability and fills the model map.
pub topology: RwLock<HashMap<String, CortexTopology>>,
}
/// Live view of one downstream cortex, refreshed each poll.
#[derive(Debug, Clone, Default)]
pub struct CortexTopology {
/// Whether the cortex is currently routable. Flipped `false` only after
/// [`crate::poller::POLL_FAILURE_THRESHOLD`] consecutive failed polls
/// (debounces transient blips); restored on the next successful poll.
pub reachable: bool,
/// Consecutive failed polls; reset to 0 on success.
pub consecutive_failures: u32,
/// Timestamp of the last successful poll.
pub last_poll: Option<DateTime<Utc>>,
/// Healthy / total neuron counts from the cortex's `/health` (coarse
/// load signal; #73 refines headroom). 0/0 until first health poll.
pub healthy_nodes: u32,
pub total_nodes: u32,
/// The cortex's full `/v1/models` entries, keyed by model id. Stored
/// whole (not distilled to a loaded/feasible bool) so the federation
/// catalogue (#75) can preserve per-model `limit`/`cost`/capabilities.
pub models: HashMap<String, CortexModelEntry>,
}
/// Whether a cortex can serve this model — loaded now, or feasible to
/// cold-load (its catalogue × topology says some neuron can host it).
pub fn entry_feasible(entry: &CortexModelEntry) -> bool {
entry.loaded || !entry.feasible_on.is_empty()
}
impl RouterState {
pub fn from_config(config: &RouterConfig) -> Self {
let topology = config
.cortexes
.iter()
.map(|c| (c.name.clone(), CortexTopology::default()))
.collect();
// One client per cortex. A `tls_ca` that fails to load omits the
// cortex from the map (fail closed) rather than degrading to an
// unpinned client.
let mut clients = HashMap::new();
for c in &config.cortexes {
match build_client(c.tls_ca.as_deref()) {
Ok(client) => {
clients.insert(c.name.clone(), client);
}
Err(e) => {
tracing::error!(
cortex = %c.name,
tls_ca = c.tls_ca.as_deref().unwrap_or(""),
error = %e,
"failed to build pinned TLS client; cortex disabled (fail closed)"
);
}
}
}
Self {
cortexes: config.cortexes.clone(),
clients,
region: config.router.region.clone(),
poll_interval: Duration::from_secs(config.router.poll_interval_secs),
topology: RwLock::new(topology),
}
}
/// The HTTP client to use for `name`, or `None` if the cortex is
/// disabled (its `tls_ca` failed to load). Callers must treat `None` as
/// "not routable / not pollable".
pub fn client_for(&self, name: &str) -> Option<&reqwest::Client> {
self.clients.get(name)
}
/// Names of reachable cortexes that can serve `model_id` (loaded or
/// feasible to cold-load). Groundwork for capacity-aware dispatch (#73);
/// unreachable cortexes are excluded by construction.
pub async fn cortexes_serving(&self, model_id: &str) -> Vec<String> {
let topo = self.topology.read().await;
topo.iter()
.filter(|(_, t)| t.reachable)
.filter(|(_, t)| t.models.get(model_id).is_some_and(entry_feasible))
.map(|(name, _)| name.clone())
.collect()
}
}
/// Build a cortex HTTP client. With `tls_ca` set, the client trusts **only**
/// that PEM anchor (platform roots disabled) — pinning the router→cortex hop
/// to an enrolled cert (#74). Without it, standard platform-root validation.
pub fn build_client(tls_ca: Option<&str>) -> Result<reqwest::Client, BuildClientError> {
let mut builder = reqwest::Client::builder();
if let Some(path) = tls_ca {
let pem = std::fs::read(path).map_err(|e| BuildClientError::Read(path.to_string(), e))?;
let cert = reqwest::Certificate::from_pem(&pem).map_err(BuildClientError::Parse)?;
builder = builder
.tls_built_in_root_certs(false)
.add_root_certificate(cert);
}
builder.build().map_err(BuildClientError::Build)
}
/// Why a cortex's pinned client could not be built (→ cortex disabled).
#[derive(Debug, thiserror::Error)]
pub enum BuildClientError {
#[error("reading TLS anchor '{0}'")]
Read(String, #[source] std::io::Error),
#[error("parsing TLS anchor PEM")]
Parse(#[source] reqwest::Error),
#[error("building HTTP client")]
Build(#[source] reqwest::Error),
}

View File

@@ -0,0 +1,132 @@
//! End-to-end federation-catalogue test for #75: poll two mock cortexes
//! that overlap on a model, then `GET /v1/models` on the router and verify
//! the deduped union with merged availability and preserved limit/cost.
use axum::Router;
use axum::routing::get;
use helexa_router::config::{CortexEndpoint, RouterConfig};
use helexa_router::poller::poll_once;
use helexa_router::state::RouterState;
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::net::TcpListener;
/// Spawn a mock cortex serving the given `/v1/models` `data` array.
async fn spawn_cortex(models: Value) -> String {
let models = Arc::new(models);
let app = Router::new()
.route(
"/v1/models",
get({
let models = Arc::clone(&models);
move || {
let models = Arc::clone(&models);
async move { axum::Json(json!({ "object": "list", "data": &*models })) }
}
}),
)
.route(
"/health",
get(|| async { axum::Json(json!({"status":"ok","nodes":{"healthy":1,"total":1}})) }),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
/// Spawn the router (with poller) wired to the given cortex endpoints, and
/// poll once synchronously so the topology is populated before we query.
async fn spawn_router(cortexes: Vec<CortexEndpoint>) -> String {
let cfg = RouterConfig {
cortexes,
..Default::default()
};
let state = Arc::new(RouterState::from_config(&cfg));
poll_once(&state).await; // deterministic: fill topology now
let app = helexa_router::build_app(Arc::clone(&state));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
fn model(id: &str, loaded: bool, feasible_on: &[&str], ctx: u64, input_cost: f64) -> Value {
json!({
"id": id,
"object": "model",
"created": 0,
"owned_by": "helexa",
"loaded": loaded,
"feasible_on": feasible_on,
"locations": [],
"limit": { "context": ctx, "output": 4096 },
"cost": { "input": input_cost, "output": input_cost * 3.0 }
})
}
#[tokio::test]
async fn federation_catalogue_dedupes_and_preserves_limit_cost() {
// cortex A: "shared" loaded (ctx 32768, $0.50) + "only-a" loaded.
let a = spawn_cortex(json!([
model("shared", true, &["beast"], 32_768, 0.50),
model("only-a", true, &["beast"], 8_192, 1.00),
]))
.await;
// cortex B: "shared" cold-loadable, tighter ctx (16384), cheaper ($0.20).
let b = spawn_cortex(json!([model("shared", false, &["benjy"], 16_384, 0.20)])).await;
let router = spawn_router(vec![
CortexEndpoint {
name: "op-a".into(),
endpoint: a,
region: None,
tls_ca: None,
},
CortexEndpoint {
name: "op-b".into(),
endpoint: b,
region: None,
tls_ca: None,
},
])
.await;
let body: Value = reqwest::get(format!("{router}/v1/models"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["object"], "list");
let data = body["data"].as_array().unwrap();
// Deduped union: "shared" once + "only-a".
assert_eq!(data.len(), 2);
let shared = data.iter().find(|m| m["id"] == "shared").unwrap();
// Loaded somewhere (op-a) → loaded.
assert_eq!(shared["loaded"], true);
// feasible_on re-tiered to operator names, both present, sorted.
let feasible: Vec<&str> = shared["feasible_on"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(feasible, vec!["op-a", "op-b"]);
// Tightest limit (16384) and cheapest cost ($0.20) win.
assert_eq!(shared["limit"]["context"], 16_384);
assert_eq!(shared["cost"]["input"], 0.20);
// Loaded location named by operator, no neuron VRAM leaked.
let locs = shared["locations"].as_array().unwrap();
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["node"], "op-a");
assert!(data.iter().any(|m| m["id"] == "only-a"));
}

View File

@@ -0,0 +1,304 @@
//! Capacity-aware dispatch acceptance tests for #73.
//!
//! Covers: a request routes to a cortex serving the model; the client's
//! bearer reaches the cortex; cortex's #63 rejections pass through verbatim
//! and are NOT retried away; transport failure fails over to another
//! feasible cortex; unknown model → 404, no reachable capacity → 503; and
//! the selection ranking (warm/region/headroom).
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use cortex_core::node::CortexModelEntry;
use helexa_router::config::{CortexEndpoint, RouterConfig};
use helexa_router::dispatch::{Selection, dispatch, select_cortexes};
use helexa_router::state::{CortexTopology, RouterState};
use serde_json::{Value, json};
use std::collections::HashMap;
use tokio::net::TcpListener;
/// A minimal `CortexModelEntry` for MODEL with the given serveability.
fn model_entry(loaded: bool, feasible: bool) -> CortexModelEntry {
CortexModelEntry {
id: MODEL.into(),
object: "model".into(),
created: 0,
owned_by: "helexa".into(),
loaded,
feasible_on: if feasible || loaded {
vec!["n".into()]
} else {
vec![]
},
locations: vec![],
capabilities: vec![],
limit: None,
cost: None,
tool_call: false,
reasoning: false,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}
}
const MODEL: &str = "Qwen/Qwen3-Coder-30B";
// ── Mock cortex backend ──────────────────────────────────────────────
/// Behaviour of a mock cortex, carried in axum State.
#[derive(Clone)]
struct MockCortex {
/// Identifies which cortex answered, echoed in the 200 body.
name: &'static str,
/// When true, return a genuine #63-shaped `429 rate_limit_exceeded`.
rate_limited: bool,
}
async fn mock_handler(State(m): State<MockCortex>, headers: HeaderMap) -> Response {
if m.rate_limited {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down","param":null}})),
)
.into_response();
}
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
Json(json!({ "served_by": m.name, "auth_seen": auth })).into_response()
}
async fn spawn_cortex(mock: MockCortex) -> String {
let app = Router::new()
.route("/v1/chat/completions", post(mock_handler))
.with_state(mock);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
fn ok_cortex(name: &'static str) -> MockCortex {
MockCortex {
name,
rate_limited: false,
}
}
// ── Helpers to build state with a hand-set topology ──────────────────
fn state_with(cortexes: Vec<CortexEndpoint>, region: Option<String>) -> RouterState {
let cfg = RouterConfig {
cortexes,
..Default::default()
};
let mut state = RouterState::from_config(&cfg);
state.region = region;
state
}
/// Overwrite the topology entry for `name` so tests control reachability and
/// model serveability directly (no live poll).
async fn set_topology(
state: &RouterState,
name: &str,
reachable: bool,
loaded: bool,
feasible: bool,
healthy_nodes: u32,
) {
let mut topo = state.topology.write().await;
let mut models = HashMap::new();
models.insert(MODEL.to_string(), model_entry(loaded, feasible));
topo.insert(
name.to_string(),
CortexTopology {
reachable,
consecutive_failures: 0,
last_poll: None,
healthy_nodes,
total_nodes: healthy_nodes,
models,
},
);
}
fn ep(name: &str, endpoint: &str, region: Option<&str>) -> CortexEndpoint {
CortexEndpoint {
name: name.into(),
endpoint: endpoint.into(),
region: region.map(str::to_string),
tls_ca: None,
}
}
fn chat_body() -> Bytes {
Bytes::from(format!("{{\"model\":\"{MODEL}\",\"stream\":false}}"))
}
async fn body_json(resp: Response) -> (StatusCode, Value) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, v)
}
// ── Tests ────────────────────────────────────────────────────────────
#[tokio::test]
async fn routes_to_serving_cortex_and_forwards_bearer() {
let url = spawn_cortex(ok_cortex("c1")).await;
let state = state_with(vec![ep("c1", &url, None)], None);
set_topology(&state, "c1", true, true, true, 2).await;
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer sk-test-123".parse().unwrap());
let resp = dispatch(&state, "/v1/chat/completions", headers, chat_body()).await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["served_by"], "c1");
// Bearer reached the cortex unchanged.
assert_eq!(body["auth_seen"], "Bearer sk-test-123");
}
#[tokio::test]
async fn cortex_429_passes_through_and_is_not_retried() {
// c1 (ranked first: loaded) returns a genuine 429; c2 would return 200.
let c1 = spawn_cortex(MockCortex {
name: "c1",
rate_limited: true,
})
.await;
let c2 = spawn_cortex(ok_cortex("c2")).await;
let state = state_with(vec![ep("c1", &c1, None), ep("c2", &c2, None)], None);
// Both reachable + loaded; c1 has more headroom so it ranks first.
set_topology(&state, "c1", true, true, true, 5).await;
set_topology(&state, "c2", true, true, true, 1).await;
let resp = dispatch(
&state,
"/v1/chat/completions",
HeaderMap::new(),
chat_body(),
)
.await;
let (status, body) = body_json(resp).await;
// The genuine 4xx is returned verbatim — NOT retried to c2.
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["error"]["code"], "rate_limit_exceeded");
assert!(body.get("served_by").is_none(), "must not have hit c2");
}
#[tokio::test]
async fn fails_over_to_next_cortex_on_transport_error() {
// c_dead ranks first (more headroom) but its endpoint is a closed port;
// c_live is the fallback. The router must fail over and c_live serves.
let live = spawn_cortex(ok_cortex("c_live")).await;
let state = state_with(
vec![
ep("c_dead", "http://127.0.0.1:1", None),
ep("c_live", &live, None),
],
None,
);
set_topology(&state, "c_dead", true, true, true, 9).await;
set_topology(&state, "c_live", true, true, true, 1).await;
let resp = dispatch(
&state,
"/v1/chat/completions",
HeaderMap::new(),
chat_body(),
)
.await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["served_by"], "c_live");
}
#[tokio::test]
async fn unknown_model_is_404() {
let state = state_with(vec![ep("c1", "http://127.0.0.1:1", None)], None);
// Topology has no entry for MODEL at all.
let resp = dispatch(
&state,
"/v1/chat/completions",
HeaderMap::new(),
chat_body(),
)
.await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "model_not_found");
}
#[tokio::test]
async fn known_but_all_unreachable_is_503() {
let state = state_with(vec![ep("c1", "http://127.0.0.1:1", None)], None);
// Cortex knows the model (from a prior good poll) but is now unreachable.
set_topology(&state, "c1", false, true, true, 2).await;
let resp = dispatch(
&state,
"/v1/chat/completions",
HeaderMap::new(),
chat_body(),
)
.await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["error"]["code"], "service_unavailable");
}
#[tokio::test]
async fn missing_model_field_is_400() {
let state = state_with(vec![ep("c1", "http://127.0.0.1:1", None)], None);
let resp = dispatch(
&state,
"/v1/chat/completions",
HeaderMap::new(),
Bytes::from_static(b"{\"messages\":[]}"),
)
.await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "missing_model_field");
}
#[tokio::test]
async fn ranking_prefers_loaded_then_region_then_headroom() {
// Router is in eu-west. Candidates:
// warm-eu : loaded, region match, 1 node → best
// warm-us : loaded, no region, 9 nodes
// cold-eu : feasible only, region match → worst (cold)
let state = state_with(
vec![
ep("warm-eu", "http://127.0.0.1:1", Some("eu-west")),
ep("warm-us", "http://127.0.0.1:1", Some("us-east")),
ep("cold-eu", "http://127.0.0.1:1", Some("eu-west")),
],
Some("eu-west".into()),
);
set_topology(&state, "warm-eu", true, true, true, 1).await;
set_topology(&state, "warm-us", true, true, true, 9).await;
set_topology(&state, "cold-eu", true, false, true, 5).await;
let Selection::Candidates(order) = select_cortexes(&state, MODEL).await else {
panic!("expected candidates");
};
let names: Vec<&str> = order.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["warm-eu", "warm-us", "cold-eu"]);
}

View File

@@ -31,10 +31,14 @@ async fn health_reports_configured_cortex_count() {
CortexEndpoint {
name: "a".into(),
endpoint: "https://a.example.com".into(),
region: None,
tls_ca: None,
},
CortexEndpoint {
name: "b".into(),
endpoint: "https://b.example.com".into(),
region: None,
tls_ca: None,
},
])
.await;

View File

@@ -0,0 +1,210 @@
//! Outbound TLS pinning tests for #74.
//!
//! Proves the router, as a TLS client to cortexes, reaches a cortex
//! presenting its **enrolled** cert and rejects one presenting an
//! unexpected (or untrusted) cert — and that a rejected handshake flows
//! through the existing reachability path (#72) to exclude the cortex.
//!
//! A minimal `tokio-rustls` HTTPS server presents a self-signed cert; the
//! router's `reqwest` client (native-tls) validates against the PEM anchor
//! enrolled in config. Server (rustls) and client (native-tls) interoperate
//! at the protocol level — what matters is the trust decision.
use helexa_router::config::{CortexEndpoint, RouterConfig};
use helexa_router::poller::poll_once;
use helexa_router::state::{RouterState, build_client};
use std::io::Write;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
/// A self-signed cert: PEM (for the reqwest pin file) + DER cert/key (for
/// the rustls server).
struct TestCert {
cert_pem: String,
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: Vec<u8>,
}
fn make_cert() -> TestCert {
let key = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap();
TestCert {
cert_pem: key.cert.pem(),
cert_der: key.cert.der().clone(),
key_der: key.key_pair.serialize_der(),
}
}
/// Write a cert PEM to a unique temp file (named by `tag`) and return the
/// path. `tag` is caller-unique (we use the bound port), so no randomness.
fn write_pem(tag: &str, pem: &str) -> String {
let path = std::env::temp_dir().join(format!("helexa-router-tls-{tag}.pem"));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(pem.as_bytes()).unwrap();
path.to_string_lossy().into_owned()
}
/// Spawn a minimal HTTPS server presenting `cert`, answering every request
/// with a canned `/v1/models`-shaped 200. Returns its `https://` base URL.
async fn spawn_https(cert: &TestCert) -> String {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let key = rustls::pki_types::PrivateKeyDer::Pkcs8(rustls::pki_types::PrivatePkcs8KeyDer::from(
cert.key_der.clone(),
));
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert.cert_der.clone()], key)
.unwrap();
let acceptor = TlsAcceptor::from(Arc::new(config));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
let acceptor = acceptor.clone();
tokio::spawn(async move {
if let Ok(mut tls) = acceptor.accept(stream).await {
let mut buf = [0u8; 2048];
let _ = tls.read(&mut buf).await; // consume request line/headers
let body = "{\"object\":\"list\",\"data\":[]}";
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = tls.write_all(resp.as_bytes()).await;
let _ = tls.shutdown().await;
}
});
}
});
format!("https://{addr}")
}
fn tag_for(url: &str) -> String {
url.rsplit(':').next().unwrap_or("0").to_string()
}
#[tokio::test]
async fn pinned_client_accepts_enrolled_cert_and_rejects_others() {
let server_cert = make_cert();
let other_cert = make_cert();
let url = spawn_https(&server_cert).await;
let tag = tag_for(&url);
let good_pin = write_pem(&format!("{tag}-good"), &server_cert.cert_pem);
let bad_pin = write_pem(&format!("{tag}-bad"), &other_cert.cert_pem);
// Enrolled with the server's own cert → handshake trusted → 200.
let good = build_client(Some(&good_pin)).unwrap();
let resp = good.get(format!("{url}/v1/models")).send().await;
assert!(resp.is_ok(), "enrolled cert must be accepted: {resp:?}");
assert_eq!(resp.unwrap().status(), 200);
// Enrolled with a different cert → server's cert is unexpected → reject.
let bad = build_client(Some(&bad_pin)).unwrap();
assert!(
bad.get(format!("{url}/v1/models")).send().await.is_err(),
"unexpected cert must be rejected"
);
// No enrollment (default platform roots) → self-signed cert untrusted.
let default = build_client(None).unwrap();
assert!(
default
.get(format!("{url}/v1/models"))
.send()
.await
.is_err(),
"un-enrolled self-signed cert must be rejected by default roots"
);
}
#[tokio::test]
async fn poller_excludes_cortex_with_unexpected_cert() {
let server_cert = make_cert();
let other_cert = make_cert();
let url = spawn_https(&server_cert).await;
let tag = tag_for(&url);
let good_pin = write_pem(&format!("{tag}-pgood"), &server_cert.cert_pem);
let bad_pin = write_pem(&format!("{tag}-pbad"), &other_cert.cert_pem);
// Cortex A enrolled correctly → reachable. Cortex B enrolled with the
// wrong cert → TLS handshake fails → excluded.
let cfg = RouterConfig {
cortexes: vec![
CortexEndpoint {
name: "good".into(),
endpoint: url.clone(),
region: None,
tls_ca: Some(good_pin),
},
CortexEndpoint {
name: "bad".into(),
endpoint: url.clone(),
region: None,
tls_ca: Some(bad_pin),
},
],
..Default::default()
};
let state = RouterState::from_config(&cfg);
poll_once(&state).await;
let topo = state.topology.read().await;
assert!(
topo["good"].reachable,
"correctly-enrolled cortex reachable"
);
assert!(
!topo["bad"].reachable,
"cortex presenting an unexpected cert is excluded"
);
}
#[tokio::test]
async fn misconfigured_pin_disables_cortex_fail_closed() {
// A `tls_ca` pointing at a nonexistent file must NOT fall back to an
// unpinned client — the cortex is disabled entirely.
let cfg = RouterConfig {
cortexes: vec![
CortexEndpoint {
name: "broken".into(),
endpoint: "https://127.0.0.1:1".into(),
region: None,
tls_ca: Some("/no/such/anchor.pem".into()),
},
CortexEndpoint {
name: "plain".into(),
endpoint: "http://127.0.0.1:1".into(),
region: None,
tls_ca: None,
},
],
..Default::default()
};
let state = RouterState::from_config(&cfg);
assert!(
state.client_for("broken").is_none(),
"a cortex with an unloadable pin is disabled (fail closed)"
);
assert!(
state.client_for("plain").is_some(),
"an un-pinned cortex still gets a client"
);
}
#[test]
fn build_client_rejects_garbage_pem() {
let path = write_pem(
"garbage",
"-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----",
);
assert!(build_client(Some(&path)).is_err());
}

View File

@@ -0,0 +1,172 @@
//! Topology-poller acceptance tests for #72: the router maintains a live
//! map of which cortexes serve which models, marks an unreachable/erroring
//! cortex unhealthy and excludes it from routing, and recovers it once
//! reachable again.
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use helexa_router::config::{CortexEndpoint, RouterConfig};
use helexa_router::poller::{POLL_FAILURE_THRESHOLD, poll_once};
use helexa_router::state::{RouterState, entry_feasible};
use serde_json::{Value, json};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::net::TcpListener;
/// Shared "is this mock cortex up?" flag, toggled by tests to simulate
/// outage and recovery.
#[derive(Clone)]
struct MockState {
up: Arc<AtomicBool>,
}
async fn mock_models(State(s): State<MockState>) -> Result<Json<Value>, StatusCode> {
if !s.up.load(Ordering::SeqCst) {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
Ok(Json(json!({
"object": "list",
"data": [
{
"id": "Qwen/Qwen3-Coder-30B",
"object": "model",
"created": 0,
"owned_by": "helexa",
"loaded": true,
"feasible_on": ["beast"],
"locations": [{"node": "beast", "status": "loaded", "vram_estimate_mb": 19000}]
},
{
"id": "Qwen/Qwen3-VL-8B",
"object": "model",
"created": 0,
"owned_by": "helexa",
"loaded": false,
"feasible_on": ["beast"],
"locations": []
}
]
})))
}
async fn mock_health(State(s): State<MockState>) -> Result<Json<Value>, StatusCode> {
if !s.up.load(Ordering::SeqCst) {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
Ok(Json(json!({
"status": "ok",
"nodes": { "healthy": 2, "total": 3 }
})))
}
/// Spawn a mock cortex; returns (base_url, up_flag).
async fn spawn_mock_cortex() -> (String, Arc<AtomicBool>) {
let up = Arc::new(AtomicBool::new(true));
let state = MockState { up: up.clone() };
let app = Router::new()
.route("/v1/models", get(mock_models))
.route("/health", get(mock_health))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}"), up)
}
fn state_for(name: &str, endpoint: &str) -> RouterState {
let cfg = RouterConfig {
cortexes: vec![CortexEndpoint {
name: name.into(),
endpoint: endpoint.into(),
region: None,
tls_ca: None,
}],
..Default::default()
};
RouterState::from_config(&cfg)
}
#[tokio::test]
async fn poll_builds_live_topology() {
let (base, _up) = spawn_mock_cortex().await;
let state = state_for("c1", &base);
poll_once(&state).await;
let topo = state.topology.read().await;
let c1 = topo.get("c1").expect("cortex present");
assert!(c1.reachable, "should be reachable after a good poll");
assert_eq!(c1.consecutive_failures, 0);
assert!(c1.last_poll.is_some());
assert_eq!((c1.healthy_nodes, c1.total_nodes), (2, 3));
// Loaded model: loaded + feasible. Catalogue-only model: feasible only
// (not loaded, but feasible_on non-empty).
let coder = c1.models.get("Qwen/Qwen3-Coder-30B").unwrap();
assert!(coder.loaded && entry_feasible(coder));
let vl = c1.models.get("Qwen/Qwen3-VL-8B").unwrap();
assert!(!vl.loaded && entry_feasible(vl));
drop(topo);
// The routing helper sees both serveable models on the reachable cortex.
assert_eq!(
state.cortexes_serving("Qwen/Qwen3-VL-8B").await,
vec!["c1".to_string()]
);
}
#[tokio::test]
async fn unreachable_cortex_excluded_then_recovers() {
let (base, up) = spawn_mock_cortex().await;
let state = state_for("c1", &base);
// Healthy first.
poll_once(&state).await;
assert!(state.topology.read().await["c1"].reachable);
// Take it down. The first failures debounce (stay reachable) until the
// threshold; only then is it excluded.
up.store(false, Ordering::SeqCst);
for i in 1..POLL_FAILURE_THRESHOLD {
poll_once(&state).await;
assert!(
state.topology.read().await["c1"].reachable,
"still reachable after {i} failure(s) (below threshold)"
);
}
poll_once(&state).await; // crosses the threshold
{
let topo = state.topology.read().await;
assert!(!topo["c1"].reachable, "excluded after threshold failures");
assert!(topo["c1"].consecutive_failures >= POLL_FAILURE_THRESHOLD);
}
// Excluded from routing.
assert!(
state
.cortexes_serving("Qwen/Qwen3-Coder-30B")
.await
.is_empty()
);
// Bring it back: the next successful poll restores it.
up.store(true, Ordering::SeqCst);
poll_once(&state).await;
let topo = state.topology.read().await;
assert!(topo["c1"].reachable, "recovered after a good poll");
assert_eq!(topo["c1"].consecutive_failures, 0);
}
#[tokio::test]
async fn unconfigured_endpoint_is_unreachable() {
// Nothing listening on this port → polls fail; below threshold it stays
// at its initial unreachable state, and never panics.
let state = state_for("dead", "http://127.0.0.1:1");
poll_once(&state).await;
let topo = state.topology.read().await;
assert!(!topo["dead"].reachable);
assert_eq!(topo["dead"].consecutive_failures, 1);
}

View File

@@ -0,0 +1,21 @@
[package]
name = "helexa-stream"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[lib]
name = "helexa_stream"
path = "src/lib.rs"
[dependencies]
axum = { workspace = true }
reqwest = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
tokio-stream = { workspace = true }
async-stream = "0.3"

View File

@@ -0,0 +1,290 @@
//! Shared streaming reverse-proxy mechanism (#71).
//!
//! cortex and helexa-router both need to proxy an OpenAI/Anthropic SSE
//! response from a downstream backend **verbatim** — chunks forwarded as
//! they arrive, never buffering the full body — while observing the bytes
//! for metrics/metering. This crate owns that mechanism so there is one
//! implementation, not one per tier.
//!
//! The split is mechanism vs policy:
//!
//! - **Mechanism (here):** [`forward_streaming`] POSTs to a backend and
//! streams the response body back through an [`ObservedStream`], which
//! feeds every chunk to a caller-supplied [`ChunkObserver`] and calls
//! [`ChunkObserver::finish`] exactly once on clean end-of-stream or on
//! drop (client disconnect mid-stream). [`BodyTail`] and
//! [`last_count_for`] are the reusable pieces an observer uses to pull
//! the trailing OpenAI `usage` object out of the streamed bytes.
//! - **Policy (caller):** what to *do* with the observed bytes — which
//! metric names to emit, which labels, whether to settle a per-principal
//! reservation — lives in the consumer's `ChunkObserver` impl, not here.
//!
//! The proxy is status-agnostic: a non-2xx upstream response (e.g. a
//! cortex `429 rate_limit_exceeded`) is streamed back with its status and
//! headers intact, so honest backpressure reaches the client unchanged.
//! Only a network failure or a malformed response build is an error.
use axum::body::{Body, Bytes};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use futures::Stream;
use futures::stream::BoxStream;
use reqwest::Client;
use std::pin::Pin;
use std::task::{Context, Poll};
/// Observes the bytes of a streamed proxy response without altering them.
///
/// `observe` is called for each forwarded chunk; `finish` is called
/// exactly once — on clean end-of-stream or on drop — and implementations
/// must be idempotent (the [`ObservedStream`] guards against a double call,
/// but a `finish` that runs side effects should still self-guard).
pub trait ChunkObserver: Send + Unpin + 'static {
/// A body chunk has been forwarded downstream. The slice is the exact
/// bytes the client receives.
fn observe(&mut self, chunk: &[u8]);
/// The stream has ended (cleanly or via client disconnect). Called once.
fn finish(&mut self);
}
/// A bounded accumulator for the tail of a streamed body.
///
/// The OpenAI `usage` object rides on the final SSE chunk (and sits at the
/// end of a non-streaming JSON body), so retaining a generous tail is
/// enough to recover token counts via [`last_count_for`]; the cap bounds
/// memory on huge bodies. Appends are char-boundary-safe.
#[derive(Debug)]
pub struct BodyTail {
tail: String,
cap: usize,
}
impl BodyTail {
/// Create a tail retaining at most `cap` bytes.
pub fn new(cap: usize) -> Self {
Self {
tail: String::new(),
cap,
}
}
/// Append a chunk, trimming from the front past the cap. When trimming,
/// the newest half is kept (the usage object is always at the very end).
pub fn push(&mut self, chunk: &[u8]) {
self.tail.push_str(&String::from_utf8_lossy(chunk));
if self.tail.len() > self.cap {
let mut cut = self.tail.len() - self.cap / 2;
while !self.tail.is_char_boundary(cut) {
cut += 1;
}
self.tail.drain(..cut);
}
}
/// The retained tail text.
pub fn as_str(&self) -> &str {
&self.tail
}
}
/// Find the value of the LAST `"key": <integer>` occurrence in `tail`.
///
/// Pure and chunk-boundary-safe (the tail is contiguous appended text).
/// The quoted-needle form means `completion_tokens` never matches
/// `completion_tokens_details`, and taking the last occurrence means the
/// final `usage` object wins even if content earlier in the stream echoed
/// a usage-shaped string.
pub fn last_count_for(tail: &str, key: &str) -> Option<u64> {
let needle = format!("\"{key}\"");
let mut result = None;
for (idx, _) in tail.match_indices(&needle) {
let rest = tail[idx + needle.len()..].trim_start();
let Some(rest) = rest.strip_prefix(':') else {
continue;
};
let rest = rest.trim_start();
let digits: &str = &rest[..rest
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(i, _)| i)
.unwrap_or(rest.len())];
if let Ok(v) = digits.parse::<u64>() {
result = Some(v);
}
}
result
}
/// Error from [`forward_streaming`]. Distinguishes a network/transport
/// failure reaching the backend from a failure assembling the downstream
/// response. A non-2xx upstream *status* is not an error — it is streamed
/// through verbatim.
#[derive(Debug, thiserror::Error)]
pub enum StreamError {
#[error("upstream request failed")]
Upstream(reqwest::Error),
#[error("failed to build response")]
ResponseBuild(String),
}
/// POST `body` to `url` and stream the response back verbatim through
/// `observer`.
///
/// Request headers are forwarded except `host` / `content-length` (reqwest
/// sets these). The returned [`Response`] carries the upstream status and
/// headers unchanged — including non-2xx — with a body that streams the
/// upstream bytes chunk-for-chunk, feeding each chunk to `observer`.
pub async fn forward_streaming<O: ChunkObserver>(
client: &Client,
url: &str,
headers: HeaderMap,
body: Bytes,
observer: O,
) -> Result<Response, StreamError> {
let mut req_builder = client.post(url).body(body);
for (key, value) in headers.iter() {
if key == "host" || key == "content-length" {
continue; // reqwest sets these
}
req_builder = req_builder.header(key, value);
}
let upstream = req_builder.send().await.map_err(StreamError::Upstream)?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let resp_headers = upstream.headers().clone();
let stream = ObservedStream::new(Box::pin(upstream.bytes_stream()), observer);
let body = Body::from_stream(stream);
let mut response = Response::builder().status(status);
for (key, value) in resp_headers.iter() {
response = response.header(key, value);
}
response
.body(body)
.map_err(|e| StreamError::ResponseBuild(e.to_string()))
}
/// Pass-through stream wrapper that feeds a [`ChunkObserver`]. Forwards
/// each chunk verbatim, calls `observe` per chunk, and `finish` once on
/// clean end-of-stream; the `Drop` impl covers client disconnects.
pub struct ObservedStream<O: ChunkObserver> {
inner: BoxStream<'static, Result<Bytes, reqwest::Error>>,
observer: O,
finished: bool,
}
impl<O: ChunkObserver> ObservedStream<O> {
/// Wrap a byte stream with an observer.
pub fn new(inner: BoxStream<'static, Result<Bytes, reqwest::Error>>, observer: O) -> Self {
Self {
inner,
observer,
finished: false,
}
}
fn finish(&mut self) {
if self.finished {
return;
}
self.finished = true;
self.observer.finish();
}
}
impl<O: ChunkObserver> Stream for ObservedStream<O> {
type Item = Result<Bytes, reqwest::Error>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => {
this.observer.observe(&chunk);
Poll::Ready(Some(Ok(chunk)))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => {
this.finish();
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
impl<O: ChunkObserver> Drop for ObservedStream<O> {
fn drop(&mut self) {
self.finish();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_counts_from_final_sse_usage_chunk() {
let tail = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":225,",
"\"completion_tokens\":42,\"total_tokens\":267}}\n\n",
"data: [DONE]\n\n"
);
assert_eq!(last_count_for(tail, "prompt_tokens"), Some(225));
assert_eq!(last_count_for(tail, "completion_tokens"), Some(42));
}
#[test]
fn extracts_counts_from_non_streaming_body() {
let tail = "{\"choices\":[{\"message\":{\"content\":\"hi\"}}],\
\"usage\":{\"prompt_tokens\": 12, \"completion_tokens\": 7}}";
assert_eq!(last_count_for(tail, "prompt_tokens"), Some(12));
assert_eq!(last_count_for(tail, "completion_tokens"), Some(7));
}
#[test]
fn ignores_details_variants_and_takes_last_occurrence() {
// completion_tokens_details must not shadow completion_tokens,
// and the LAST usage object wins (matters when content echoes
// a usage-shaped string earlier in the stream).
let tail = concat!(
"data: {\"usage\":{\"completion_tokens\":1}}\n\n",
"data: {\"usage\":{\"completion_tokens\":99,",
"\"completion_tokens_details\":{\"reasoning_tokens\":3}}}\n\n"
);
assert_eq!(last_count_for(tail, "completion_tokens"), Some(99));
}
#[test]
fn absent_keys_yield_none() {
assert_eq!(
last_count_for("data: [DONE]\n\n", "completion_tokens"),
None
);
assert_eq!(last_count_for("", "prompt_tokens"), None);
// key present but non-numeric value
assert_eq!(
last_count_for("\"completion_tokens\": null", "completion_tokens"),
None
);
}
#[test]
fn body_tail_retains_usage_after_cap_trim() {
// Cap small enough that the filler forces several front-trims, but
// (as in production, where cap ≫ the usage object) large enough that
// the trailing usage object survives the newest-half retention.
let mut tail = BodyTail::new(512);
for _ in 0..100 {
tail.push(b"data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n");
}
assert!(tail.as_str().len() <= 512, "cap must bound the tail");
tail.push(b"data: {\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":9}}\n\n");
assert_eq!(last_count_for(tail.as_str(), "prompt_tokens"), Some(5));
assert_eq!(last_count_for(tail.as_str(), "completion_tokens"), Some(9));
}
}

View File

@@ -0,0 +1,162 @@
//! Integration tests for the shared streaming proxy (#71): proves a backend
//! SSE response is forwarded chunk-for-chunk (no buffering), the observer
//! sees every byte and finishes once, and non-2xx is streamed through with
//! its status intact — the behaviours both cortex and helexa-router rely on.
use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use axum::routing::post;
use helexa_stream::{BodyTail, ChunkObserver, forward_streaming, last_count_for};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
/// Observer that records what it saw, for assertions.
#[derive(Clone, Default)]
struct RecordingObserver {
inner: Arc<Mutex<Recorded>>,
}
#[derive(Default)]
struct Recorded {
chunks: usize,
finished: usize,
tail: String,
}
impl ChunkObserver for RecordingObserver {
fn observe(&mut self, chunk: &[u8]) {
let mut r = self.inner.lock().unwrap();
r.chunks += 1;
r.tail.push_str(&String::from_utf8_lossy(chunk));
}
fn finish(&mut self) {
self.inner.lock().unwrap().finished += 1;
}
}
/// Mock backend that streams 5 SSE chunks with 30ms gaps, then a usage
/// chunk and `[DONE]`.
async fn sse_handler() -> Response {
let chunks: Vec<&'static str> = vec![
"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"c\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"d\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"e\"}}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}}\n\n",
"data: [DONE]\n\n",
];
let stream = async_stream::stream! {
for c in chunks {
tokio::time::sleep(Duration::from_millis(30)).await;
yield Ok::<_, std::io::Error>(axum::body::Bytes::from_static(c.as_bytes()));
}
};
Response::new(Body::from_stream(stream))
}
async fn rate_limited_handler() -> Response {
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.body(Body::from("{\"error\":{\"type\":\"rate_limit_exceeded\"}}"))
.unwrap()
}
async fn spawn_backend(router: Router) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test]
async fn streams_chunks_incrementally_and_observes_usage() {
let base = spawn_backend(Router::new().route("/v1/chat/completions", post(sse_handler))).await;
let observer = RecordingObserver::default();
let probe = observer.clone();
let client = reqwest::Client::new();
let resp = forward_streaming(
&client,
&format!("{base}/v1/chat/completions"),
HeaderMap::new(),
axum::body::Bytes::from_static(b"{\"model\":\"x\",\"stream\":true}"),
observer,
)
.await
.expect("forward ok");
assert_eq!(resp.status(), StatusCode::OK);
// Read the proxied body as a stream, timestamping arrivals.
let mut body = resp.into_body().into_data_stream();
let mut arrivals: Vec<Instant> = Vec::new();
let mut collected = String::new();
use futures::StreamExt;
while let Some(item) = body.next().await {
let bytes = item.unwrap();
arrivals.push(Instant::now());
collected.push_str(&String::from_utf8_lossy(&bytes));
}
// Incremental delivery: first and last chunk are meaningfully apart
// (5×30ms gaps), proving no full-response buffering.
let spread = *arrivals.last().unwrap() - arrivals[0];
assert!(
spread >= Duration::from_millis(100),
"expected incremental delivery, spread was {spread:?}"
);
// The client received the terminator and the usage object verbatim.
assert!(collected.contains("data: [DONE]"));
// The observer saw the bytes and finished exactly once.
let r = probe.inner.lock().unwrap();
assert!(r.chunks >= 5, "observer saw {} chunks", r.chunks);
assert_eq!(r.finished, 1, "finish must run exactly once");
assert_eq!(last_count_for(&r.tail, "prompt_tokens"), Some(11));
assert_eq!(last_count_for(&r.tail, "completion_tokens"), Some(5));
}
#[tokio::test]
async fn non_2xx_is_streamed_through_verbatim() {
let base =
spawn_backend(Router::new().route("/v1/chat/completions", post(rate_limited_handler)))
.await;
let observer = RecordingObserver::default();
let probe = observer.clone();
let client = reqwest::Client::new();
let resp = forward_streaming(
&client,
&format!("{base}/v1/chat/completions"),
HeaderMap::new(),
axum::body::Bytes::new(),
observer,
)
.await
.expect("forward ok");
// Backpressure status reaches the client unchanged.
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(String::from_utf8_lossy(&body).contains("rate_limit_exceeded"));
// finish still runs once even with a tiny non-streaming body.
assert_eq!(probe.inner.lock().unwrap().finished, 1);
}
#[test]
fn body_tail_smoke() {
let mut tail = BodyTail::new(128);
tail.push(b"hello ");
tail.push(b"world");
assert_eq!(tail.as_str(), "hello world");
}

View File

@@ -0,0 +1,63 @@
[package]
name = "helexa-upstream"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "helexa-upstream"
path = "src/main.rs"
[lib]
name = "helexa_upstream"
path = "src/lib.rs"
[dependencies]
tokio = { workspace = true }
axum = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
figment = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
# PostgreSQL — the mesh authority's system of record. Runtime query API
# (not the compile-time `query!` macros) so the crate builds in CI without a
# live database or a committed `.sqlx` offline cache; correctness is covered
# by the gated integration tests. (Macro adoption is a later refinement once
# a dev DB + offline cache exist.)
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
"migrate",
"chrono",
"uuid",
] }
uuid = { version = "1", features = ["v4", "serde"] }
sha2 = "0.10"
subtle = "2.6"
# Web auth (B4): argon2id password hashing, JWT sessions, CSPRNG secrets,
# transactional email.
argon2 = "0.5"
jsonwebtoken = "9"
rand = "0.8"
lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls-tls",
"smtp-transport",
"builder",
] }
# cortex-core for the shared #63 OpenAiError envelope on the authz surface.
cortex-core = { workspace = true }
[dev-dependencies]
figment = { workspace = true, features = ["test"] }
reqwest = { workspace = true }

View File

@@ -0,0 +1,137 @@
-- helexa-upstream initial schema (#59): accounts, keys, ledger, top-up
-- codes, served-usage. The mesh-level authority's system of record.
--
-- Token amounts are BIGINT (i64) throughout; the cortex EntitlementProvider
-- carries u64 but mesh allocations sit comfortably inside i64 and Postgres
-- has no unsigned type.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ── Users (web auth: email + password) ──────────────────────────────
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- argon2id PHC string
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
-- Browser fingerprint captured at registration (#abuse). Best-effort,
-- client-supplied; the primary signal for silent multi-account
-- detection. NULL when the client could not produce one.
registration_fingerprint TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX users_registration_fingerprint_idx
ON users (registration_fingerprint)
WHERE registration_fingerprint IS NOT NULL;
-- Single-use email tokens for verification and password reset. Only the
-- sha256 of the emailed secret is stored.
CREATE TABLE email_tokens (
token_hash BYTEA PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('verify', 'reset')),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
CREATE INDEX email_tokens_user_idx ON email_tokens (user_id);
-- ── Accounts (the billable allocation ledger) ───────────────────────
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
allocation_total BIGINT NOT NULL DEFAULT 0,
allocation_spent BIGINT NOT NULL DEFAULT 0,
allocation_reserved BIGINT NOT NULL DEFAULT 0,
-- 'deactivated' is the SILENT abuse flag: keys stop authorizing but no
-- surface ever tells the user why (see resolve → 401).
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'deactivated')),
-- This account shares a registration fingerprint with >= 1 other.
fingerprint_flagged BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The no-overshoot backstop to the atomic reserve UPDATE.
CONSTRAINT accounts_no_overshoot
CHECK (allocation_spent + allocation_reserved <= allocation_total),
CONSTRAINT accounts_nonneg
CHECK (allocation_spent >= 0 AND allocation_reserved >= 0)
);
CREATE INDEX accounts_owner_idx ON accounts (owner_user_id);
-- ── API keys (Principal.key_id = api_keys.id) ───────────────────────
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_hash BYTEA NOT NULL, -- sha256(raw key)
key_prefix TEXT NOT NULL, -- non-secret display prefix
label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived')),
-- Per-key sub-cap: 'hardcap' = absolute tokens; 'percent' = % of the
-- account's allocation_total (resolved to an absolute at reserve time).
limit_kind TEXT NOT NULL DEFAULT 'percent'
CHECK (limit_kind IN ('percent', 'hardcap')),
limit_value BIGINT NOT NULL DEFAULT 100,
-- serde of cortex_core::entitlements::CapWindow (Balance | Rolling).
cap_window JSONB NOT NULL DEFAULT '{"kind":"balance"}'::jsonb,
-- Per-key running ledger (mirrors the account ledger; Balance semantics
-- in this migration — rolling-window reset lands with the authz API).
key_spent BIGINT NOT NULL DEFAULT 0,
key_reserved BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT api_keys_key_nonneg
CHECK (key_spent >= 0 AND key_reserved >= 0)
);
-- A raw key resolves only while active; the hash is unique among active keys.
CREATE UNIQUE INDEX api_keys_active_hash_idx
ON api_keys (key_hash) WHERE status = 'active';
CREATE INDEX api_keys_account_idx ON api_keys (account_id);
-- ── Reservations (reserve → settle/release) ─────────────────────────
-- id is BIGSERIAL so it maps to the cortex Reservation.id (u64) verbatim,
-- with the Postgres sequence as the sole global authority.
CREATE TABLE reservations (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
reserved BIGINT NOT NULL,
actual BIGINT,
state TEXT NOT NULL DEFAULT 'open'
CHECK (state IN ('open', 'settled', 'released')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settled_at TIMESTAMPTZ
);
-- The sweeper scans open reservations by age.
CREATE INDEX reservations_open_idx
ON reservations (created_at) WHERE state = 'open';
-- ── Top-up codes (hybrid allocation) ────────────────────────────────
CREATE TABLE top_up_codes (
code_hash BYTEA PRIMARY KEY, -- sha256(raw code)
value BIGINT NOT NULL, -- tokens this code grants
denomination TEXT, -- human label (e.g. "small")
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
redeemed_by UUID REFERENCES accounts(id) ON DELETE SET NULL,
redeemed_at TIMESTAMPTZ
);
-- ── Served-usage ledger (#58 reconciliation) ────────────────────────
-- Absolute per-(operator, account, key, period) served tokens, upserted by
-- each cortex; reconciliation rolls these up for operator compensation.
CREATE TABLE served_usage (
operator_id TEXT NOT NULL,
account_id UUID NOT NULL,
key_id UUID NOT NULL,
period DATE NOT NULL,
served_tokens BIGINT NOT NULL DEFAULT 0,
reconciled_at TIMESTAMPTZ,
PRIMARY KEY (operator_id, account_id, key_id, period)
);
-- ── Web sessions (DB-backed; alt/complement to stateless JWT) ───────
CREATE TABLE sessions (
token_hash BYTEA PRIMARY KEY, -- sha256(session token)
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_idx ON sessions (user_id);

View File

@@ -0,0 +1,340 @@
//! `/authz/v1` — the machine surface cortex's `UpstreamEntitlementProvider`
//! (#57) consumes. It mirrors the `cortex_core::entitlements::EntitlementProvider`
//! trait 1:1 (resolve / reserve / settle / release / snapshot) over the B1
//! ledger.
//!
//! Contract notes for the cortex client:
//! - A **non-2xx** response means the authority could not give an
//! authoritative answer (bad caller auth, malformed request, server
//! error) → the client should **fail closed**.
//! - `reserve` returns **200** whether granted or budget-refused: the body
//! carries either `reservation_id` or a `rejected` discriminant. A budget
//! refusal is an authoritative answer, not a transport failure.
//! - Rejections that are genuinely auth failures use the #63 `OpenAiError`
//! envelope so they can be surfaced verbatim.
use crate::crypto::sha256;
use crate::error::envelope_response;
use crate::ledger::{self, LedgerError};
use crate::state::AppState;
use axum::extract::{Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Extension, Json, Router};
use cortex_core::error_envelope::OpenAiError;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use uuid::Uuid;
/// The operator a validated client bearer identifies (served-usage
/// attribution, #58). Inserted into request extensions by [`client_auth`].
#[derive(Debug, Clone)]
pub struct OperatorId(pub String);
/// Build the `/authz/v1` router with the client-auth layer applied.
pub fn router(state: &AppState) -> Router<AppState> {
Router::new()
.route("/authz/v1/resolve", post(resolve))
.route("/authz/v1/reserve", post(reserve))
.route("/authz/v1/settle", post(settle))
.route("/authz/v1/release", post(release))
.route("/authz/v1/snapshot", post(snapshot))
.route("/authz/v1/served-usage", post(served_usage))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
client_auth,
))
}
// ── client auth (shared bearer → operator_id) ───────────────────────
/// Validate the caller's `Authorization: Bearer` against the configured
/// client tokens (constant-time) and stamp the `operator_id`. When no tokens
/// are configured the surface is open (dev) and a synthetic operator is
/// used.
async fn client_auth(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
let tokens = &state.config.client_auth.tokens;
if tokens.is_empty() {
req.extensions_mut().insert(OperatorId("dev".into()));
return next.run(req).await;
}
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.unwrap_or("");
let matched = tokens
.iter()
.find(|t| t.token.as_bytes().ct_eq(presented.as_bytes()).into());
match matched {
Some(t) => {
req.extensions_mut()
.insert(OperatorId(t.operator_id.clone()));
next.run(req).await
}
None => envelope_response(OpenAiError::invalid_api_key(
"missing or invalid client credentials",
)),
}
}
// ── DTOs ────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ResolveReq {
api_key: String,
}
#[derive(Serialize)]
struct PrincipalDto {
account_id: String,
key_id: String,
}
#[derive(Serialize)]
struct SnapshotDto {
hard_cap: Option<i64>,
spent: i64,
reserved: i64,
}
#[derive(Serialize)]
struct ResolveResp {
principal: PrincipalDto,
snapshot: SnapshotDto,
}
#[derive(Deserialize)]
struct ReserveReq {
account_id: String,
key_id: String,
max_tokens: i64,
}
#[derive(Serialize, Default)]
struct ReserveResp {
#[serde(skip_serializing_if = "Option::is_none")]
reservation_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
rejected: Option<Rejection>,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Rejection {
InsufficientQuota {
requested: i64,
available: i64,
},
// Part of the frozen wire contract so the cortex client (#57) can map it
// without a later breaking change. Not yet constructed: the B1 ledger
// implements Balance caps only; rolling-window key sub-caps (which yield
// this) land in a follow-up.
#[allow(dead_code)]
RateLimited {
requested: i64,
available: i64,
retry_after_secs: u64,
},
}
#[derive(Deserialize)]
struct SettleReq {
reservation_id: i64,
actual_tokens: i64,
}
#[derive(Deserialize)]
struct ReservationRef {
reservation_id: i64,
}
#[derive(Deserialize)]
struct SnapshotReq {
account_id: String,
key_id: String,
}
// ── handlers ────────────────────────────────────────────────────────
/// `POST /authz/v1/resolve` — bearer key → principal + snapshot, or
/// `401 invalid_api_key` (also for a deactivated account: no clue).
async fn resolve(State(state): State<AppState>, Json(req): Json<ResolveReq>) -> Response {
match ledger::resolve_key(&state.pool, &sha256(&req.api_key)).await {
Ok(Some(p)) => Json(ResolveResp {
principal: PrincipalDto {
account_id: p.account_id.to_string(),
key_id: p.key_id.to_string(),
},
snapshot: SnapshotDto {
hard_cap: Some(p.hard_cap),
spent: p.key_spent,
reserved: p.key_reserved,
},
})
.into_response(),
Ok(None) => envelope_response(OpenAiError::invalid_api_key("invalid or unknown API key")),
Err(e) => {
tracing::error!(error = %e, "resolve query failed");
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
}
}
}
/// `POST /authz/v1/reserve` — 200 with `reservation_id` (granted) or
/// `rejected` (budget). Non-2xx only for bad input / server error.
async fn reserve(State(state): State<AppState>, Json(req): Json<ReserveReq>) -> Response {
let (Ok(account_id), Ok(key_id)) = (
Uuid::parse_str(&req.account_id),
Uuid::parse_str(&req.key_id),
) else {
return bad_request("account_id and key_id must be UUIDs");
};
match ledger::reserve(&state.pool, account_id, key_id, req.max_tokens).await {
Ok(reservation_id) => Json(ReserveResp {
reservation_id: Some(reservation_id),
rejected: None,
})
.into_response(),
Err(LedgerError::InsufficientQuota {
requested,
available,
}) => Json(ReserveResp {
reservation_id: None,
rejected: Some(Rejection::InsufficientQuota {
requested,
available,
}),
})
.into_response(),
Err(LedgerError::AccountNotFound | LedgerError::KeyNotFound) => {
// Resolve succeeded earlier; the principal vanished (archived /
// deactivated). Treat as no budget — fail closed at the client.
Json(ReserveResp {
reservation_id: None,
rejected: Some(Rejection::InsufficientQuota {
requested: req.max_tokens,
available: 0,
}),
})
.into_response()
}
Err(LedgerError::Db(e)) => {
tracing::error!(error = %e, "reserve failed");
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
}
}
}
/// `POST /authz/v1/settle` — idempotent; `204`.
async fn settle(State(state): State<AppState>, Json(req): Json<SettleReq>) -> Response {
match ledger::settle(&state.pool, req.reservation_id, req.actual_tokens).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
tracing::error!(error = %e, "settle failed");
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
}
}
}
/// `POST /authz/v1/release` — idempotent; `204`.
async fn release(State(state): State<AppState>, Json(req): Json<ReservationRef>) -> Response {
match ledger::release(&state.pool, req.reservation_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
tracing::error!(error = %e, "release failed");
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
}
}
}
/// `POST /authz/v1/snapshot` — `{hard_cap, spent, reserved}` or `404`.
async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -> Response {
let (Ok(account_id), Ok(key_id)) = (
Uuid::parse_str(&req.account_id),
Uuid::parse_str(&req.key_id),
) else {
return bad_request("account_id and key_id must be UUIDs");
};
match ledger::snapshot(&state.pool, account_id, key_id).await {
Ok(Some((hard_cap, spent, reserved))) => Json(SnapshotDto {
hard_cap: Some(hard_cap),
spent,
reserved,
})
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!(error = %e, "snapshot failed");
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
}
}
}
// ── served-usage report (#58) ───────────────────────────────────────
#[derive(Deserialize)]
struct ServedUsageReport {
rows: Vec<ServedUsageRow>,
}
#[derive(Deserialize)]
struct ServedUsageRow {
account_id: String,
key_id: String,
period: String, // YYYY-MM-DD
served_tokens: i64,
}
/// `POST /authz/v1/served-usage` — a cortex reports the absolute served-token
/// counters it has accrued for the current period. Upsert is monotonic
/// (`GREATEST`) so re-sends and races are idempotent and never regress.
/// `operator_id` comes from the validated client bearer (request extension).
async fn served_usage(
State(state): State<AppState>,
Extension(operator): Extension<OperatorId>,
Json(req): Json<ServedUsageReport>,
) -> Response {
for row in &req.rows {
let (Ok(account_id), Ok(key_id)) = (
Uuid::parse_str(&row.account_id),
Uuid::parse_str(&row.key_id),
) else {
continue; // skip malformed ids rather than fail the whole batch
};
let Ok(period) = chrono::NaiveDate::parse_from_str(&row.period, "%Y-%m-%d") else {
continue;
};
let res = sqlx::query(
"INSERT INTO served_usage (operator_id, account_id, key_id, period, served_tokens) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (operator_id, account_id, key_id, period) \
DO UPDATE SET served_tokens = GREATEST(served_usage.served_tokens, EXCLUDED.served_tokens)",
)
.bind(&operator.0)
.bind(account_id)
.bind(key_id)
.bind(period)
.bind(row.served_tokens.max(0))
.execute(&state.pool)
.await;
if let Err(e) = res {
tracing::error!(error = %e, "served-usage upsert failed");
return envelope_response(OpenAiError::service_unavailable("authority error", Some(5)));
}
}
StatusCode::NO_CONTENT.into_response()
}
fn bad_request(msg: &str) -> Response {
envelope_response(OpenAiError::new(
400,
"invalid_request_error",
"invalid_request",
msg,
))
}

View File

@@ -0,0 +1,261 @@
//! helexa-upstream configuration: loaded from `helexa-upstream.toml` with
//! figment, `UPSTREAM_`-prefixed env overrides (mirrors the cortex/router
//! convention, e.g. `UPSTREAM_SERVER__LISTEN`, `UPSTREAM_DB__URL`).
use figment::{
Figment,
providers::{Env, Format, Toml},
};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpstreamConfig {
#[serde(default)]
pub server: ServerSettings,
pub db: DbSettings,
#[serde(default)]
pub grant: GrantSettings,
#[serde(default)]
pub abuse: AbuseSettings,
#[serde(default)]
pub client_auth: ClientAuthSettings,
#[serde(default)]
pub authz: AuthzSettings,
#[serde(default)]
pub auth: AuthSettings,
#[serde(default)]
pub email: EmailSettings,
}
/// `[auth]` — web-session signing + token lifetimes (B4). Web sessions are
/// JWTs, distinct from inference API keys.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthSettings {
/// HMAC secret for signing session JWTs. MUST be overridden in prod
/// (env `UPSTREAM_AUTH__JWT_SECRET`); the default is dev-only.
#[serde(default = "default_jwt_secret")]
pub jwt_secret: String,
/// Session token lifetime (seconds).
#[serde(default = "default_session_ttl")]
pub session_ttl_secs: u64,
/// Email verification / password-reset token lifetime (seconds).
#[serde(default = "default_email_token_ttl")]
pub email_token_ttl_secs: u64,
/// Public base URL of the frontend, used to build verify/reset links.
#[serde(default = "default_app_base_url")]
pub app_base_url: String,
}
impl Default for AuthSettings {
fn default() -> Self {
Self {
jwt_secret: default_jwt_secret(),
session_ttl_secs: default_session_ttl(),
email_token_ttl_secs: default_email_token_ttl(),
app_base_url: default_app_base_url(),
}
}
}
/// `[email]` — transactional email transport for verify/reset.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailSettings {
/// `"log"` (dev — logs the link) or `"smtp"`.
#[serde(default = "default_email_provider")]
pub provider: String,
/// SMTP relay URL (e.g. "smtp://user:pass@host:587") when provider=smtp.
#[serde(default)]
pub smtp_url: Option<String>,
/// `From:` address.
#[serde(default = "default_from_addr")]
pub from_addr: String,
}
impl Default for EmailSettings {
fn default() -> Self {
Self {
provider: default_email_provider(),
smtp_url: None,
from_addr: default_from_addr(),
}
}
}
/// `[client_auth]` — credentials operators' cortexes present to `/authz/v1`.
/// Each token maps to an `operator_id` (served-usage attribution, #58). This
/// transport credential is distinct from end-user API keys (which ride in
/// the `resolve` body). v2 adds mTLS.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientAuthSettings {
/// When empty the authz surface is **open** (dev only; logged at warn).
#[serde(default)]
pub tokens: Vec<ClientToken>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientToken {
/// Shared bearer a cortex presents.
pub token: String,
/// Operator this token identifies.
pub operator_id: String,
}
/// `[authz]` — reservation lifecycle knobs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthzSettings {
/// Open reservations older than this are swept (released), self-healing
/// a reservation whose settle/release from cortex was lost.
#[serde(default = "default_reservation_ttl")]
pub reservation_ttl_secs: u64,
/// How often the sweeper runs.
#[serde(default = "default_sweep_interval")]
pub sweep_interval_secs: u64,
}
impl Default for AuthzSettings {
fn default() -> Self {
Self {
reservation_ttl_secs: default_reservation_ttl(),
sweep_interval_secs: default_sweep_interval(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSettings {
/// Address to listen on (e.g. "0.0.0.0:8090"). Plaintext — edge nginx
/// terminates TLS, consistent with the rest of the stack.
#[serde(default = "default_listen")]
pub listen: String,
}
impl Default for ServerSettings {
fn default() -> Self {
Self {
listen: default_listen(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbSettings {
/// PostgreSQL connection URL (e.g. "postgres://user:pass@host/helexa").
pub url: String,
/// Max pool connections.
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
/// `[grant]` — the flat free token grant every email-verified account
/// receives (the floor of the hybrid allocation model; top-up codes extend
/// it).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrantSettings {
#[serde(default = "default_free_grant")]
pub free_token_grant: i64,
}
impl Default for GrantSettings {
fn default() -> Self {
Self {
free_token_grant: default_free_grant(),
}
}
}
/// `[abuse]` — silent multi-account abuse detection. When at least
/// `fingerprint_account_threshold` accounts share one registration
/// fingerprint, all of them are silently deactivated (no notice to the
/// user; deactivation only surfaces as ordinary inference rejections).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbuseSettings {
#[serde(default = "default_fingerprint_threshold")]
pub fingerprint_account_threshold: i64,
}
impl Default for AbuseSettings {
fn default() -> Self {
Self {
fingerprint_account_threshold: default_fingerprint_threshold(),
}
}
}
fn default_listen() -> String {
"0.0.0.0:8090".into()
}
fn default_max_connections() -> u32 {
16
}
fn default_free_grant() -> i64 {
1_000_000
}
fn default_fingerprint_threshold() -> i64 {
5
}
fn default_reservation_ttl() -> u64 {
120
}
fn default_sweep_interval() -> u64 {
60
}
fn default_jwt_secret() -> String {
"dev-insecure-change-me".into()
}
fn default_session_ttl() -> u64 {
7 * 24 * 3600
}
fn default_email_token_ttl() -> u64 {
24 * 3600
}
fn default_app_base_url() -> String {
"http://localhost:5173".into()
}
fn default_email_provider() -> String {
"log".into()
}
fn default_from_addr() -> String {
"helexa <no-reply@helexa.ai>".into()
}
impl UpstreamConfig {
/// Load from a TOML file with `UPSTREAM_`-prefixed env overrides
/// (`__` nesting separator).
pub fn load(path: impl AsRef<Path>) -> Result<Self, Box<figment::Error>> {
Figment::new()
.merge(Toml::file(path))
.merge(Env::prefixed("UPSTREAM_").split("__"))
.extract()
.map_err(Box::new)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::result_large_err)]
fn loads_toml_with_env_override_and_defaults() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"helexa-upstream.toml",
r#"
[db]
url = "postgres://localhost/helexa"
"#,
)?;
jail.set_env("UPSTREAM_SERVER__LISTEN", "127.0.0.1:9099");
let cfg = UpstreamConfig::load("helexa-upstream.toml").expect("load");
assert_eq!(cfg.server.listen, "127.0.0.1:9099");
assert_eq!(cfg.db.url, "postgres://localhost/helexa");
// Defaults applied when sections omitted.
assert_eq!(cfg.grant.free_token_grant, 1_000_000);
assert_eq!(cfg.abuse.fingerprint_account_threshold, 5);
assert_eq!(cfg.db.max_connections, 16);
Ok(())
});
}
}

View File

@@ -0,0 +1,113 @@
//! Hashing + secret-generation helpers.
//!
//! - **Passwords** (low-entropy) → argon2id PHC strings.
//! - **API keys / top-up codes / email + session tokens** (high-entropy
//! secrets minted here) → stored only as their sha256; sha256 is the fast,
//! sufficient choice for high-entropy material.
use argon2::Argon2;
use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use rand::RngCore;
use sha2::{Digest, Sha256};
/// sha256 of `input`, as raw bytes (matches the `BYTEA` columns).
pub fn sha256(input: &str) -> Vec<u8> {
let mut h = Sha256::new();
h.update(input.as_bytes());
h.finalize().to_vec()
}
/// Hash a password with argon2id, returning a PHC string for storage.
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut ArgonOsRng);
Ok(Argon2::default()
.hash_password(password.as_bytes(), &salt)?
.to_string())
}
/// Verify a password against a stored PHC hash. `false` on any mismatch or
/// malformed hash (never panics).
pub fn verify_password(password: &str, phc: &str) -> bool {
match PasswordHash::new(phc) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
/// A fresh URL-safe high-entropy secret (256 bits) for email/session/reset
/// tokens. The caller stores only `sha256` of this and emails/returns the
/// raw value.
pub fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
base62(&bytes)
}
/// Mint a new API key: `(raw, prefix)`. `raw` is shown to the user once;
/// only `sha256(raw)` is stored. The prefix is a non-secret display tag.
pub fn generate_api_key() -> (String, String) {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let raw = format!("sk-helexa-{}", base62(&bytes));
// Non-secret prefix for the dashboard list (scheme + first few chars).
let prefix: String = raw.chars().take(14).collect();
(raw, prefix)
}
/// base62 encode (0-9A-Za-z) — URL/clipboard friendly, no padding.
fn base62(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Treat the bytes as a big-endian integer and base62 it. 32 bytes → ~43
// chars. Simple repeated-division over a big-uint built from the bytes.
let mut digits: Vec<u8> = vec![0];
for &byte in bytes {
let mut carry = byte as u32;
for d in digits.iter_mut() {
let v = (*d as u32) * 256 + carry;
*d = (v % 62) as u8;
carry = v / 62;
}
while carry > 0 {
digits.push((carry % 62) as u8);
carry /= 62;
}
}
digits
.iter()
.rev()
.map(|&d| ALPHABET[d as usize] as char)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_round_trips_and_rejects_wrong() {
let phc = hash_password("correct horse").unwrap();
assert!(verify_password("correct horse", &phc));
assert!(!verify_password("wrong", &phc));
assert!(!verify_password("correct horse", "not-a-phc-string"));
}
#[test]
fn api_key_has_scheme_prefix_and_unique_body() {
let (raw, prefix) = generate_api_key();
assert!(raw.starts_with("sk-helexa-"));
assert!(prefix.starts_with("sk-helexa-"));
let (raw2, _) = generate_api_key();
assert_ne!(raw, raw2, "keys are unique");
}
#[test]
fn random_tokens_are_unique_and_nonempty() {
let a = random_token();
let b = random_token();
assert!(!a.is_empty());
assert_ne!(a, b);
}
}

View File

@@ -0,0 +1,20 @@
//! PostgreSQL pool + embedded migrations.
use anyhow::{Context, Result};
use sqlx::postgres::{PgPool, PgPoolOptions};
/// Connect to Postgres and run embedded migrations (`./migrations`).
pub async fn connect_and_migrate(url: &str, max_connections: u32) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(url)
.await
.with_context(|| "connecting to PostgreSQL")?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.with_context(|| "running migrations")?;
Ok(pool)
}

View File

@@ -0,0 +1,64 @@
//! Transactional email for verification + password-reset links.
//!
//! Two transports: `Log` (dev — writes the link to the log so flows are
//! testable without a relay) and `Smtp` (lettre over rustls). Built from
//! `[email]` config.
use crate::config::EmailSettings;
use anyhow::{Context, Result};
use lettre::message::Mailbox;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
#[derive(Clone)]
pub enum EmailSender {
/// Dev: log the message instead of sending.
Log { from: String },
Smtp {
from: String,
transport: AsyncSmtpTransport<Tokio1Executor>,
},
}
impl EmailSender {
pub fn from_config(cfg: &EmailSettings) -> Result<Self> {
match cfg.provider.as_str() {
"smtp" => {
let url = cfg
.smtp_url
.as_deref()
.context("[email].smtp_url required when provider = \"smtp\"")?;
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
.context("parsing [email].smtp_url")?
.build();
Ok(EmailSender::Smtp {
from: cfg.from_addr.clone(),
transport,
})
}
_ => Ok(EmailSender::Log {
from: cfg.from_addr.clone(),
}),
}
}
/// Send a plaintext email. Errors are returned but the caller treats
/// send failures as non-fatal to the request (the user can re-request).
pub async fn send(&self, to: &str, subject: &str, body: &str) -> Result<()> {
match self {
EmailSender::Log { from } => {
tracing::info!(%from, %to, %subject, body, "EMAIL (log transport)");
Ok(())
}
EmailSender::Smtp { from, transport } => {
let msg = Message::builder()
.from(from.parse::<Mailbox>().context("parsing from_addr")?)
.to(to.parse::<Mailbox>().context("parsing recipient")?)
.subject(subject)
.body(body.to_string())
.context("building message")?;
transport.send(msg).await.context("sending email")?;
Ok(())
}
}
}
}

View File

@@ -0,0 +1,21 @@
//! Adapter from the shared, axum-agnostic
//! [`cortex_core::error_envelope::OpenAiError`] (#60/#63) to an axum
//! response, with `Retry-After`. The `/authz/v1` surface speaks the #63
//! envelope so cortex (an OpenAI-compatible proxy) can forward rejections
//! verbatim. (The future `/web/v1` surface uses a plain JSON error shape.)
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use cortex_core::error_envelope::OpenAiError;
pub fn envelope_response(err: OpenAiError) -> Response {
let status = StatusCode::from_u16(err.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let retry_after = err.retry_after_secs;
let mut response = (status, Json(err.body())).into_response();
if let Some(secs) = retry_after
&& let Ok(value) = HeaderValue::from_str(&secs.to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}

View File

@@ -0,0 +1,21 @@
//! HTTP handlers. B1 ships `/health`; the authz (`/authz/v1`) and web
//! (`/web/v1`) surfaces land in later phases.
use crate::state::AppState;
use axum::{Json, Router, extract::State, routing::get};
use serde_json::{Value, json};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/health", get(health))
.route("/", get(health))
}
/// `GET /health` — liveness + a database round-trip (`SELECT 1`).
async fn health(State(state): State<AppState>) -> Json<Value> {
let db_ok = sqlx::query("SELECT 1").execute(&state.pool).await.is_ok();
Json(json!({
"status": if db_ok { "ok" } else { "degraded" },
"db": if db_ok { "ok" } else { "unreachable" },
}))
}

View File

@@ -0,0 +1,338 @@
//! The allocation ledger: reserve → settle/release with the no-overshoot
//! guarantee enforced by a row-locked transaction.
//!
//! Each reserve takes `SELECT … FOR UPDATE` on the account (and key) row, so
//! concurrent reserves from many cortexes serialize and `spent + reserved`
//! can never exceed the effective cap. The `accounts_no_overshoot` CHECK is
//! the DB-level backstop. Settle/release are idempotent (they only act on a
//! reservation still in `open`).
//!
//! Per-key effective cap = `min(resolved key cap, remaining account
//! allocation)`. The key cap is resolved from its `limit_kind`:
//! `hardcap` → the value verbatim; `percent` → that % of the account's
//! `allocation_total`.
//!
//! Cap-window semantics: this module implements **Balance** (non-resetting)
//! caps. Rolling-window key sub-caps (and the `RateLimited` rejection that
//! rides them) land with the authz API (B2); today an over-cap is always
//! `InsufficientQuota`.
use sqlx::postgres::PgPool;
use uuid::Uuid;
/// A bearer key resolved to its principal + a budget snapshot.
#[derive(Debug, Clone)]
pub struct ResolvedPrincipal {
pub account_id: Uuid,
pub key_id: Uuid,
/// Effective per-key absolute cap (the key sub-cap; the account cap
/// still binds at reserve time).
pub hard_cap: i64,
pub key_spent: i64,
pub key_reserved: i64,
}
/// Resolve a key by its `sha256` hash to its principal, or `None` when the
/// key is unknown/archived **or its account is deactivated** (the silent
/// abuse flag — indistinguishable from an unknown key, by design: no clue).
pub async fn resolve_key(
pool: &PgPool,
key_hash: &[u8],
) -> Result<Option<ResolvedPrincipal>, sqlx::Error> {
let row = sqlx::query(
"SELECT k.id AS key_id, k.account_id, k.limit_kind, k.limit_value, \
k.key_spent, k.key_reserved, a.allocation_total \
FROM api_keys k JOIN accounts a ON a.id = k.account_id \
WHERE k.key_hash = $1 AND k.status = 'active' AND a.status = 'active'",
)
.bind(key_hash)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
let total: i64 = sqlx::Row::get(&r, "allocation_total");
let limit_kind: String = sqlx::Row::get(&r, "limit_kind");
let limit_value: i64 = sqlx::Row::get(&r, "limit_value");
ResolvedPrincipal {
account_id: sqlx::Row::get(&r, "account_id"),
key_id: sqlx::Row::get(&r, "key_id"),
hard_cap: resolve_abs_cap(&limit_kind, limit_value, total),
key_spent: sqlx::Row::get(&r, "key_spent"),
key_reserved: sqlx::Row::get(&r, "key_reserved"),
}
}))
}
/// Per-key budget snapshot `(hard_cap, spent, reserved)`, or `None` if the
/// key/account isn't an active pair.
pub async fn snapshot(
pool: &PgPool,
account_id: Uuid,
key_id: Uuid,
) -> Result<Option<(i64, i64, i64)>, sqlx::Error> {
let row = sqlx::query(
"SELECT k.limit_kind, k.limit_value, k.key_spent, k.key_reserved, a.allocation_total \
FROM api_keys k JOIN accounts a ON a.id = k.account_id \
WHERE k.id = $1 AND k.account_id = $2 AND k.status = 'active' AND a.status = 'active'",
)
.bind(key_id)
.bind(account_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
let total: i64 = sqlx::Row::get(&r, "allocation_total");
let limit_kind: String = sqlx::Row::get(&r, "limit_kind");
let limit_value: i64 = sqlx::Row::get(&r, "limit_value");
let cap = resolve_abs_cap(&limit_kind, limit_value, total);
(
cap,
sqlx::Row::get::<i64, _>(&r, "key_spent"),
sqlx::Row::get::<i64, _>(&r, "key_reserved"),
)
}))
}
/// Release every `open` reservation older than `max_age_secs`, returning
/// each one's reserved tokens to its account and key in a single statement.
/// The lost-settle self-heal. Returns the number swept.
pub async fn sweep_stale(pool: &PgPool, max_age_secs: i64) -> Result<u64, sqlx::Error> {
// Data-modifying CTEs: release stale rows, then fold their reserved sums
// back into accounts and api_keys. All in one atomic statement.
let result = sqlx::query(
"WITH stale AS ( \
UPDATE reservations SET state = 'released', settled_at = now() \
WHERE state = 'open' AND created_at < now() - make_interval(secs => $1) \
RETURNING account_id, key_id, reserved \
), acct AS ( \
UPDATE accounts a SET allocation_reserved = allocation_reserved - s.total \
FROM (SELECT account_id, SUM(reserved) AS total FROM stale GROUP BY account_id) s \
WHERE a.id = s.account_id \
) \
UPDATE api_keys k SET key_reserved = key_reserved - s.total \
FROM (SELECT key_id, SUM(reserved) AS total FROM stale GROUP BY key_id) s \
WHERE k.id = s.key_id",
)
.bind(max_age_secs as f64)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Resolve a key's per-key cap to an absolute token count.
///
/// `percent` is `floor(allocation_total * limit_value / 100)`; `hardcap` is
/// `limit_value` verbatim. Computed in i128 to avoid overflow, floored at 0.
pub fn resolve_abs_cap(limit_kind: &str, limit_value: i64, allocation_total: i64) -> i64 {
let cap = match limit_kind {
"percent" => (allocation_total as i128 * limit_value as i128) / 100,
_ => limit_value as i128, // "hardcap" (and any unknown → treat as absolute)
};
cap.clamp(0, i64::MAX as i128) as i64
}
#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
#[error("account not found")]
AccountNotFound,
#[error("api key not found or not active")]
KeyNotFound,
/// Account balance or a Balance-window key sub-cap is exhausted.
#[error("insufficient quota: requested {requested}, available {available}")]
InsufficientQuota { requested: i64, available: i64 },
#[error(transparent)]
Db(#[from] sqlx::Error),
}
/// Reserve `max_tokens` against `account_id`/`key_id`. Returns the
/// reservation id (the `BIGSERIAL`, mapped to the cortex `Reservation.id`).
pub async fn reserve(
pool: &PgPool,
account_id: Uuid,
key_id: Uuid,
max_tokens: i64,
) -> Result<i64, LedgerError> {
let mut tx = pool.begin().await?;
// Lock the account row — serializes concurrent reserves on this account.
let acct = sqlx::query(
"SELECT allocation_total, allocation_spent, allocation_reserved \
FROM accounts WHERE id = $1 AND status = 'active' FOR UPDATE",
)
.bind(account_id)
.fetch_optional(&mut *tx)
.await?;
let Some(acct) = acct else {
return Err(LedgerError::AccountNotFound);
};
let total: i64 = sqlx::Row::get(&acct, "allocation_total");
let spent: i64 = sqlx::Row::get(&acct, "allocation_spent");
let reserved: i64 = sqlx::Row::get(&acct, "allocation_reserved");
let account_avail = total - spent - reserved;
// Lock the key row and resolve its absolute sub-cap.
let key = sqlx::query(
"SELECT limit_kind, limit_value, key_spent, key_reserved \
FROM api_keys WHERE id = $1 AND account_id = $2 AND status = 'active' FOR UPDATE",
)
.bind(key_id)
.bind(account_id)
.fetch_optional(&mut *tx)
.await?;
let Some(key) = key else {
return Err(LedgerError::KeyNotFound);
};
let limit_kind: String = sqlx::Row::get(&key, "limit_kind");
let limit_value: i64 = sqlx::Row::get(&key, "limit_value");
let key_spent: i64 = sqlx::Row::get(&key, "key_spent");
let key_reserved: i64 = sqlx::Row::get(&key, "key_reserved");
let key_cap = resolve_abs_cap(&limit_kind, limit_value, total);
let key_avail = key_cap - key_spent - key_reserved;
let available = account_avail.min(key_avail).max(0);
if max_tokens > available {
// tx rolls back on drop
return Err(LedgerError::InsufficientQuota {
requested: max_tokens,
available,
});
}
let id: i64 = sqlx::Row::get(
&sqlx::query(
"INSERT INTO reservations (account_id, key_id, reserved, state) \
VALUES ($1, $2, $3, 'open') RETURNING id",
)
.bind(account_id)
.bind(key_id)
.bind(max_tokens)
.fetch_one(&mut *tx)
.await?,
"id",
);
sqlx::query("UPDATE accounts SET allocation_reserved = allocation_reserved + $1 WHERE id = $2")
.bind(max_tokens)
.bind(account_id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE api_keys SET key_reserved = key_reserved + $1 WHERE id = $2")
.bind(max_tokens)
.bind(key_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(id)
}
/// Settle a reservation with the actual tokens used (clamped to
/// `[0, reserved]`). Idempotent: a second settle (or settle after release)
/// is a no-op.
pub async fn settle(
pool: &PgPool,
reservation_id: i64,
actual_tokens: i64,
) -> Result<(), LedgerError> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
"UPDATE reservations SET state = 'settled', settled_at = now(), \
actual = LEAST(GREATEST($2, 0), reserved) \
WHERE id = $1 AND state = 'open' \
RETURNING reserved, account_id, key_id, actual",
)
.bind(reservation_id)
.bind(actual_tokens)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Ok(()); // already settled/released, or unknown → idempotent no-op
};
let reserved: i64 = sqlx::Row::get(&row, "reserved");
let actual: i64 = sqlx::Row::get(&row, "actual");
let account_id: Uuid = sqlx::Row::get(&row, "account_id");
let key_id: Uuid = sqlx::Row::get(&row, "key_id");
sqlx::query(
"UPDATE accounts SET allocation_reserved = allocation_reserved - $1, \
allocation_spent = allocation_spent + $2 WHERE id = $3",
)
.bind(reserved)
.bind(actual)
.bind(account_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE api_keys SET key_reserved = key_reserved - $1, key_spent = key_spent + $2 WHERE id = $3",
)
.bind(reserved)
.bind(actual)
.bind(key_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Release a reservation, returning its full reserved amount to the
/// allocation. Idempotent.
pub async fn release(pool: &PgPool, reservation_id: i64) -> Result<(), LedgerError> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
"UPDATE reservations SET state = 'released', settled_at = now() \
WHERE id = $1 AND state = 'open' \
RETURNING reserved, account_id, key_id",
)
.bind(reservation_id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Ok(());
};
let reserved: i64 = sqlx::Row::get(&row, "reserved");
let account_id: Uuid = sqlx::Row::get(&row, "account_id");
let key_id: Uuid = sqlx::Row::get(&row, "key_id");
sqlx::query("UPDATE accounts SET allocation_reserved = allocation_reserved - $1 WHERE id = $2")
.bind(reserved)
.bind(account_id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE api_keys SET key_reserved = key_reserved - $1 WHERE id = $2")
.bind(reserved)
.bind(key_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::resolve_abs_cap;
#[test]
fn hardcap_is_verbatim() {
assert_eq!(resolve_abs_cap("hardcap", 50_000, 1_000_000), 50_000);
}
#[test]
fn percent_is_fraction_of_allocation() {
assert_eq!(resolve_abs_cap("percent", 25, 1_000_000), 250_000);
assert_eq!(resolve_abs_cap("percent", 100, 1_000_000), 1_000_000);
// floor
assert_eq!(resolve_abs_cap("percent", 33, 10), 3);
}
#[test]
fn percent_does_not_overflow_on_large_allocation() {
// total * value would overflow i64 if not widened to i128.
let cap = resolve_abs_cap("percent", 100, i64::MAX);
assert_eq!(cap, i64::MAX);
}
#[test]
fn negative_or_zero_clamps_to_zero() {
assert_eq!(resolve_abs_cap("hardcap", -5, 100), 0);
assert_eq!(resolve_abs_cap("percent", 0, 1_000_000), 0);
}
}

View File

@@ -0,0 +1,90 @@
//! helexa-upstream — the mesh-level account/authorization authority (#59).
//!
//! The clearing house above cortex: it issues accounts and API keys, holds
//! the real token-allocation ledger, authorizes inference in real time
//! (reserve → settle, fail-closed), and tracks served usage for operator
//! reconciliation. cortex's `UpstreamEntitlementProvider` (#57) is a client
//! of the `/authz/v1` surface; the helexa.ai frontend is a client of the
//! `/web/v1` surface.
//!
//! Landed so far: B1 — schema + reserve→settle [`ledger`] (no-overshoot) +
//! `/health`. B2 — the `/authz/v1` [`authz`] surface (resolve/reserve/
//! settle/release/snapshot) with shared-bearer client auth and a
//! stale-reservation sweeper.
pub mod authz;
pub mod config;
pub mod crypto;
pub mod db;
pub mod email;
pub mod error;
pub mod handlers;
pub mod ledger;
pub mod reconcile;
pub mod state;
pub mod topup;
pub mod web;
use anyhow::Result;
use config::UpstreamConfig;
use email::EmailSender;
use state::AppState;
use std::time::Duration;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
/// Build the axum application.
pub fn build_app(state: AppState) -> axum::Router {
axum::Router::new()
.merge(handlers::routes())
.merge(authz::router(&state))
.merge(web::router(&state))
// The /web/v1 surface is called cross-origin by the browser SPA in
// dev; same-origin behind nginx in prod. Permissive is fine — these
// endpoints authenticate via bearer/JWT, not cookies.
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.with_state(state)
}
/// Start the service: connect Postgres, run migrations, spawn the
/// reservation sweeper, bind the listener.
pub async fn run(config: UpstreamConfig) -> Result<()> {
let pool = db::connect_and_migrate(&config.db.url, config.db.max_connections).await?;
let email = EmailSender::from_config(&config.email)?;
let listen = config.server.listen.clone();
let state = AppState::new(pool, config, email);
if state.config.client_auth.tokens.is_empty() {
tracing::warn!(
"no [client_auth] tokens configured — the /authz/v1 surface is OPEN (dev only)"
);
}
// Stale-reservation sweeper: releases open reservations whose
// settle/release from cortex was lost, self-healing allocation_reserved.
spawn_sweeper(&state);
let addr = listen.parse::<std::net::SocketAddr>()?;
tracing::info!("helexa-upstream listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, build_app(state)).await?;
Ok(())
}
fn spawn_sweeper(state: &AppState) {
let pool = state.pool.clone();
let ttl = state.config.authz.reservation_ttl_secs as i64;
let interval = Duration::from_secs(state.config.authz.sweep_interval_secs);
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
match ledger::sweep_stale(&pool, ttl).await {
Ok(n) if n > 0 => tracing::info!(swept = n, "released stale reservations"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "reservation sweep failed"),
}
}
});
}

View File

@@ -0,0 +1,99 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use helexa_upstream::config::UpstreamConfig;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "helexa-upstream")]
#[command(about = "Mesh-level account & authorization authority for helexa")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start the upstream server.
Serve {
/// Path to the config file.
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
/// Mint single-use top-up codes and print them (one per line). The raw
/// codes are shown only here — only their hash is stored. (The future
/// faucet bot calls the same path.)
Mint {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
/// Tokens each code grants.
#[arg(long)]
value: i64,
/// How many codes to mint.
#[arg(long, default_value_t = 1)]
count: u32,
/// Optional human label (e.g. "small", "beta-launch").
#[arg(long)]
denomination: Option<String>,
},
/// Roll up not-yet-reconciled served usage per operator/period (#58),
/// stamp it reconciled, and print the totals. Payout is out of scope.
Reconcile {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,helexa_upstream=debug")),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Serve { config } => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
tracing::info!(listen = %cfg.server.listen, "starting helexa-upstream");
helexa_upstream::run(cfg).await?;
}
Commands::Mint {
config,
value,
count,
denomination,
} => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let codes =
helexa_upstream::topup::mint(&pool, value, count, denomination.as_deref()).await?;
// Raw codes to stdout (one per line) for the operator to distribute;
// logs/diagnostics go to stderr via tracing.
for code in codes {
println!("{code}");
}
}
Commands::Reconcile { config } => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
for r in &rollup {
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
}
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
}
}
Ok(())
}

View File

@@ -0,0 +1,43 @@
//! Reconciliation rollup (#58): aggregate the served-usage ledger per
//! operator and period for operator compensation, stamping rows
//! `reconciled_at` so each window is settled once. The payout mechanism
//! itself is out of scope — this produces the authoritative per-operator
//! totals a settlement process consumes.
use sqlx::Row;
use sqlx::postgres::PgPool;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollupRow {
pub operator_id: String,
pub period: chrono::NaiveDate,
pub total_served_tokens: i64,
}
/// Roll up all not-yet-reconciled served-usage into per-(operator, period)
/// totals, then stamp those rows `reconciled_at`. Returns the rollup.
/// Idempotent: a second run finds nothing unreconciled and returns empty.
pub async fn reconcile(pool: &PgPool) -> Result<Vec<RollupRow>, sqlx::Error> {
let mut tx = pool.begin().await?;
let rows = sqlx::query(
// SUM(bigint) is numeric in Postgres — cast back to bigint for i64.
"SELECT operator_id, period, SUM(served_tokens)::bigint AS total \
FROM served_usage WHERE reconciled_at IS NULL \
GROUP BY operator_id, period ORDER BY operator_id, period",
)
.fetch_all(&mut *tx)
.await?;
let rollup: Vec<RollupRow> = rows
.iter()
.map(|r| RollupRow {
operator_id: r.get("operator_id"),
period: r.get("period"),
total_served_tokens: r.get::<i64, _>("total"),
})
.collect();
sqlx::query("UPDATE served_usage SET reconciled_at = now() WHERE reconciled_at IS NULL")
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(rollup)
}

View File

@@ -0,0 +1,23 @@
//! Shared application state.
use crate::config::UpstreamConfig;
use crate::email::EmailSender;
use sqlx::postgres::PgPool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<UpstreamConfig>,
pub email: EmailSender,
}
impl AppState {
pub fn new(pool: PgPool, config: UpstreamConfig, email: EmailSender) -> Self {
Self {
pool,
config: Arc::new(config),
email,
}
}
}

View File

@@ -0,0 +1,82 @@
//! Single-use top-up codes (#B5) — the second half of the hybrid allocation
//! model. Each code grants `value` tokens to the account that redeems it,
//! raising `accounts.allocation_total`. Minting codes is operator/CLI side
//! (the future faucet bot calls the same `mint` path); redemption is a
//! `/web/v1` action.
//!
//! Security: only `sha256(code)` is stored. Redemption is **timing-safe and
//! single-use** — a conditional `UPDATE … WHERE redeemed_by IS NULL` does
//! the claim atomically (concurrent double-redeem → exactly one winner), and
//! a not-found code and an already-redeemed code return the **same** generic
//! failure with the same code path (no oracle for "valid but spent").
use crate::crypto::{random_token, sha256};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum TopUpError {
/// Code unknown OR already redeemed — deliberately indistinguishable.
#[error("invalid or already-redeemed code")]
Invalid,
#[error(transparent)]
Db(#[from] sqlx::Error),
}
/// Redeem `raw_code` for `account_id`, raising the account's
/// `allocation_total` by the code's value. Returns the new total.
pub async fn redeem(pool: &PgPool, account_id: Uuid, raw_code: &str) -> Result<i64, TopUpError> {
let mut tx = pool.begin().await?;
// Atomic single-use claim. `redeemed_by IS NULL` is the guarantee: under
// concurrent redemption exactly one UPDATE touches the row.
let claimed = sqlx::query(
"UPDATE top_up_codes SET redeemed_by = $1, redeemed_at = now() \
WHERE code_hash = $2 AND redeemed_by IS NULL RETURNING value",
)
.bind(account_id)
.bind(sha256(raw_code))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = claimed else {
// Not found or already redeemed — same path, same error.
return Err(TopUpError::Invalid);
};
let value: i64 = row.get("value");
let new_total: i64 = sqlx::query(
"UPDATE accounts SET allocation_total = allocation_total + $1 WHERE id = $2 \
RETURNING allocation_total",
)
.bind(value)
.bind(account_id)
.fetch_one(&mut *tx)
.await?
.get("allocation_total");
tx.commit().await?;
Ok(new_total)
}
/// Mint `count` codes each worth `value` tokens, optionally tagged with a
/// `denomination` label. Returns the raw codes (shown once — only their
/// hash is stored). The CLI prints these; the future faucet bot calls this.
pub async fn mint(
pool: &PgPool,
value: i64,
count: u32,
denomination: Option<&str>,
) -> Result<Vec<String>, sqlx::Error> {
let mut codes = Vec::with_capacity(count as usize);
for _ in 0..count {
let raw = format!("helexa-topup-{}", random_token());
sqlx::query(
"INSERT INTO top_up_codes (code_hash, value, denomination) VALUES ($1, $2, $3)",
)
.bind(sha256(&raw))
.bind(value)
.bind(denomination)
.execute(pool)
.await?;
codes.push(raw);
}
Ok(codes)
}

View File

@@ -0,0 +1,594 @@
//! `/web/v1` — the human-facing account API the helexa.ai frontend (#F4)
//! consumes: email+password auth (register / verify / login / reset),
//! API-key CRUD with per-key limits, and the account balance. Web sessions
//! are JWTs, **distinct** from inference API keys.
//!
//! Errors use a plain JSON shape `{ "error": { "message", "code" } }` (web
//! clients, not OpenAI clients — the #63 envelope is the authz surface).
//!
//! Silent fingerprint abuse (no clue to the abuser): registration captures
//! the browser fingerprint and always succeeds; when ≥ threshold accounts
//! share one fingerprint, all are silently `deactivated` (keys then resolve
//! as ordinary `401`s at the authz surface — never a "banned" signal).
use crate::crypto::{generate_api_key, hash_password, random_token, sha256, verify_password};
use crate::state::AppState;
use axum::extract::{Path, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use axum::{Extension, Router};
use chrono::{DateTime, Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
pub fn router(state: &AppState) -> Router<AppState> {
let protected = Router::new()
.route("/web/v1/account", get(account))
.route("/web/v1/keys", get(list_keys).post(create_key))
.route("/web/v1/keys/{id}/archive", post(archive_key))
.route(
"/web/v1/keys/{id}/limit",
axum::routing::patch(update_key_limit),
)
.route("/web/v1/redeem", post(redeem))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_session,
));
Router::new()
.route("/web/v1/register", post(register))
.route("/web/v1/verify", post(verify))
.route("/web/v1/login", post(login))
.route("/web/v1/password-reset/request", post(reset_request))
.route("/web/v1/password-reset/confirm", post(reset_confirm))
.merge(protected)
}
// ── errors ──────────────────────────────────────────────────────────
enum WebError {
BadRequest(&'static str),
Unauthorized,
Internal,
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
let (status, code, message) = match self {
WebError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m),
WebError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized"),
WebError::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"internal error",
),
};
(
status,
Json(json!({"error": {"message": message, "code": code}})),
)
.into_response()
}
}
impl From<sqlx::Error> for WebError {
fn from(e: sqlx::Error) -> Self {
tracing::error!(error = %e, "web db error");
WebError::Internal
}
}
type WebResult<T> = Result<T, WebError>;
// ── sessions (JWT) ──────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String, // user id
exp: usize,
}
fn mint_session(state: &AppState, user_id: Uuid) -> WebResult<String> {
let exp = (Utc::now() + Duration::seconds(state.config.auth.session_ttl_secs as i64))
.timestamp() as usize;
let claims = Claims {
sub: user_id.to_string(),
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
)
.map_err(|_| WebError::Internal)
}
/// Authenticated user id, injected by [`require_session`].
#[derive(Clone)]
struct AuthUser(Uuid);
async fn require_session(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim);
let Some(token) = token else {
return WebError::Unauthorized.into_response();
};
let decoded = decode::<Claims>(
token,
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
&Validation::default(),
);
match decoded
.ok()
.and_then(|d| Uuid::parse_str(&d.claims.sub).ok())
{
Some(uid) => {
req.extensions_mut().insert(AuthUser(uid));
next.run(req).await
}
None => WebError::Unauthorized.into_response(),
}
}
/// The caller's single account id.
async fn account_id_for(state: &AppState, user_id: Uuid) -> WebResult<Uuid> {
let row = sqlx::query("SELECT id FROM accounts WHERE owner_user_id = $1")
.bind(user_id)
.fetch_optional(&state.pool)
.await?;
row.map(|r| r.get::<Uuid, _>("id"))
.ok_or(WebError::Internal)
}
// ── auth lifecycle ──────────────────────────────────────────────────
#[derive(Deserialize)]
struct RegisterReq {
email: String,
password: String,
#[serde(default)]
fingerprint: Option<String>,
}
/// `POST /web/v1/register` — always returns `202`, regardless of whether the
/// email was new, already taken, or fingerprint-flagged (no enumeration, no
/// abuse clue).
async fn register(State(state): State<AppState>, Json(req): Json<RegisterReq>) -> Response {
match register_inner(&state, req).await {
Ok(()) | Err(WebError::BadRequest(_)) => {}
Err(e) => return e.into_response(),
}
// Generic 202 whatever happened above (except hard server errors).
StatusCode::ACCEPTED.into_response()
}
async fn register_inner(state: &AppState, req: RegisterReq) -> WebResult<()> {
if !req.email.contains('@') {
return Err(WebError::BadRequest("invalid email"));
}
if req.password.len() < 8 {
return Err(WebError::BadRequest("password too short (min 8)"));
}
let phc = hash_password(&req.password).map_err(|_| WebError::Internal)?;
// Insert the user; a duplicate email silently no-ops (no enumeration).
let user_id: Option<Uuid> = sqlx::query(
"INSERT INTO users (email, password_hash, registration_fingerprint) \
VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING RETURNING id",
)
.bind(&req.email)
.bind(&phc)
.bind(&req.fingerprint)
.fetch_optional(&state.pool)
.await?
.map(|r| r.get("id"));
let Some(user_id) = user_id else {
return Ok(()); // email already registered — say nothing
};
// Account with the flat free grant.
sqlx::query("INSERT INTO accounts (owner_user_id, allocation_total) VALUES ($1, $2)")
.bind(user_id)
.bind(state.config.grant.free_token_grant)
.execute(&state.pool)
.await?;
// Silent fingerprint abuse handling.
if let Some(fp) = req.fingerprint.as_deref().filter(|f| !f.is_empty()) {
apply_fingerprint_policy(state, fp).await?;
}
// Email verification link.
let token = random_token();
let expires: DateTime<Utc> =
Utc::now() + Duration::seconds(state.config.auth.email_token_ttl_secs as i64);
sqlx::query(
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
VALUES ($1, $2, 'verify', $3)",
)
.bind(sha256(&token))
.bind(user_id)
.bind(expires)
.execute(&state.pool)
.await?;
let link = format!("{}/verify?token={token}", state.config.auth.app_base_url);
let _ = state
.email
.send(
&req.email,
"Verify your helexa account",
&format!("Welcome to helexa. Verify your email:\n\n{link}\n"),
)
.await;
Ok(())
}
/// Count accounts sharing `fp`; flag them, and silently deactivate all once
/// the count reaches the configured threshold. No response difference — the
/// abuser gets no signal.
async fn apply_fingerprint_policy(state: &AppState, fp: &str) -> WebResult<()> {
let count: i64 =
sqlx::query_scalar("SELECT count(*) FROM users WHERE registration_fingerprint = $1")
.bind(fp)
.fetch_one(&state.pool)
.await?;
if count > 1 {
sqlx::query(
"UPDATE accounts SET fingerprint_flagged = true \
WHERE owner_user_id IN (SELECT id FROM users WHERE registration_fingerprint = $1)",
)
.bind(fp)
.execute(&state.pool)
.await?;
}
if count >= state.config.abuse.fingerprint_account_threshold {
let res = sqlx::query(
"UPDATE accounts SET status = 'deactivated' \
WHERE owner_user_id IN (SELECT id FROM users WHERE registration_fingerprint = $1)",
)
.bind(fp)
.execute(&state.pool)
.await?;
tracing::warn!(
fingerprint = fp,
accounts = res.rows_affected(),
"silently deactivated fingerprint-abusing accounts"
);
}
Ok(())
}
#[derive(Deserialize)]
struct TokenReq {
token: String,
}
/// `POST /web/v1/verify` — consume a verification token, mark verified.
async fn verify(State(state): State<AppState>, Json(req): Json<TokenReq>) -> WebResult<Response> {
let row = sqlx::query(
"UPDATE email_tokens SET consumed_at = now() \
WHERE token_hash = $1 AND kind = 'verify' AND consumed_at IS NULL AND expires_at > now() \
RETURNING user_id",
)
.bind(sha256(&req.token))
.fetch_optional(&state.pool)
.await?;
let Some(row) = row else {
return Err(WebError::BadRequest("invalid or expired token"));
};
let user_id: Uuid = row.get("user_id");
sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::OK.into_response())
}
#[derive(Deserialize)]
struct LoginReq {
email: String,
password: String,
}
/// `POST /web/v1/login` — verify password + email-verified → session JWT.
async fn login(State(state): State<AppState>, Json(req): Json<LoginReq>) -> WebResult<Response> {
let row = sqlx::query("SELECT id, password_hash, email_verified FROM users WHERE email = $1")
.bind(&req.email)
.fetch_optional(&state.pool)
.await?;
// Generic 401 for every failure mode (no enumeration).
let Some(row) = row else {
return Err(WebError::Unauthorized);
};
let phc: String = row.get("password_hash");
let verified: bool = row.get("email_verified");
if !verify_password(&req.password, &phc) || !verified {
return Err(WebError::Unauthorized);
}
let user_id: Uuid = row.get("id");
let token = mint_session(&state, user_id)?;
Ok(Json(json!({
"token": token,
"expires_in": state.config.auth.session_ttl_secs,
}))
.into_response())
}
#[derive(Deserialize)]
struct EmailReq {
email: String,
}
/// `POST /web/v1/password-reset/request` — always `202` (no enumeration);
/// mints + emails a reset token only if the account exists.
async fn reset_request(State(state): State<AppState>, Json(req): Json<EmailReq>) -> Response {
// The inner only ever yields `Internal` (DB failure); a missing email is
// Ok(()) so there's no enumeration. Surface 500 on a real error, else 202.
match reset_request_inner(&state, &req.email).await {
Ok(()) => StatusCode::ACCEPTED.into_response(),
Err(e) => e.into_response(),
}
}
async fn reset_request_inner(state: &AppState, email: &str) -> WebResult<()> {
let row = sqlx::query("SELECT id FROM users WHERE email = $1")
.bind(email)
.fetch_optional(&state.pool)
.await?;
let Some(row) = row else { return Ok(()) };
let user_id: Uuid = row.get("id");
let token = random_token();
let expires: DateTime<Utc> =
Utc::now() + Duration::seconds(state.config.auth.email_token_ttl_secs as i64);
sqlx::query(
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
VALUES ($1, $2, 'reset', $3)",
)
.bind(sha256(&token))
.bind(user_id)
.bind(expires)
.execute(&state.pool)
.await?;
let link = format!("{}/reset?token={token}", state.config.auth.app_base_url);
let _ = state
.email
.send(
email,
"Reset your helexa password",
&format!("Reset your password:\n\n{link}\n"),
)
.await;
Ok(())
}
#[derive(Deserialize)]
struct ResetConfirmReq {
token: String,
new_password: String,
}
/// `POST /web/v1/password-reset/confirm` — consume reset token, rotate hash.
async fn reset_confirm(
State(state): State<AppState>,
Json(req): Json<ResetConfirmReq>,
) -> WebResult<Response> {
if req.new_password.len() < 8 {
return Err(WebError::BadRequest("password too short (min 8)"));
}
let row = sqlx::query(
"UPDATE email_tokens SET consumed_at = now() \
WHERE token_hash = $1 AND kind = 'reset' AND consumed_at IS NULL AND expires_at > now() \
RETURNING user_id",
)
.bind(sha256(&req.token))
.fetch_optional(&state.pool)
.await?;
let Some(row) = row else {
return Err(WebError::BadRequest("invalid or expired token"));
};
let user_id: Uuid = row.get("user_id");
let phc = hash_password(&req.new_password).map_err(|_| WebError::Internal)?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(phc)
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::OK.into_response())
}
// ── account + keys (protected) ──────────────────────────────────────
async fn account(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
let row = sqlx::query(
"SELECT allocation_total, allocation_spent, allocation_reserved FROM accounts WHERE id = $1",
)
.bind(acct)
.fetch_one(&state.pool)
.await?;
Ok(Json(json!({
"account_id": acct.to_string(),
"allocation_total": row.get::<i64, _>("allocation_total"),
"allocation_spent": row.get::<i64, _>("allocation_spent"),
"allocation_reserved": row.get::<i64, _>("allocation_reserved"),
}))
.into_response())
}
async fn list_keys(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
let rows = sqlx::query(
"SELECT id, key_prefix, label, status, limit_kind, limit_value, key_spent, key_reserved, \
created_at \
FROM api_keys WHERE account_id = $1 ORDER BY created_at DESC",
)
.bind(acct)
.fetch_all(&state.pool)
.await?;
let keys: Vec<_> = rows
.iter()
.map(|r| {
json!({
"id": r.get::<Uuid, _>("id").to_string(),
"prefix": r.get::<String, _>("key_prefix"),
"label": r.get::<String, _>("label"),
"status": r.get::<String, _>("status"),
"limit_kind": r.get::<String, _>("limit_kind"),
"limit_value": r.get::<i64, _>("limit_value"),
"spent": r.get::<i64, _>("key_spent"),
"reserved": r.get::<i64, _>("key_reserved"),
"created_at": r.get::<DateTime<Utc>, _>("created_at").to_rfc3339(),
})
})
.collect();
Ok(Json(json!({ "keys": keys })).into_response())
}
#[derive(Deserialize)]
struct CreateKeyReq {
#[serde(default)]
label: String,
/// "percent" | "hardcap" (default percent=100 → full allocation).
#[serde(default)]
limit_kind: Option<String>,
#[serde(default)]
limit_value: Option<i64>,
}
async fn create_key(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Json(req): Json<CreateKeyReq>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
let limit_kind = match req.limit_kind.as_deref() {
Some("hardcap") => "hardcap",
_ => "percent",
};
let limit_value = req.limit_value.unwrap_or(100).max(0);
let (raw, prefix) = generate_api_key();
let id: Uuid = sqlx::query(
"INSERT INTO api_keys (account_id, key_hash, key_prefix, label, limit_kind, limit_value) \
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
)
.bind(acct)
.bind(sha256(&raw))
.bind(&prefix)
.bind(&req.label)
.bind(limit_kind)
.bind(limit_value)
.fetch_one(&state.pool)
.await?
.get("id");
// The raw key is shown exactly once.
Ok((
StatusCode::CREATED,
Json(json!({
"id": id.to_string(),
"key": raw,
"prefix": prefix,
"limit_kind": limit_kind,
"limit_value": limit_value,
})),
)
.into_response())
}
async fn archive_key(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<Uuid>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
let res = sqlx::query(
"UPDATE api_keys SET status = 'archived' WHERE id = $1 AND account_id = $2 AND status = 'active'",
)
.bind(id)
.bind(acct)
.execute(&state.pool)
.await?;
if res.rows_affected() == 0 {
return Err(WebError::BadRequest("no such active key"));
}
Ok(StatusCode::NO_CONTENT.into_response())
}
#[derive(Deserialize)]
struct UpdateLimitReq {
limit_kind: String,
limit_value: i64,
}
async fn update_key_limit(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateLimitReq>,
) -> WebResult<Response> {
if req.limit_kind != "percent" && req.limit_kind != "hardcap" {
return Err(WebError::BadRequest(
"limit_kind must be percent or hardcap",
));
}
if req.limit_value < 0 {
return Err(WebError::BadRequest("limit_value must be >= 0"));
}
let acct = account_id_for(&state, user.0).await?;
let res = sqlx::query(
"UPDATE api_keys SET limit_kind = $1, limit_value = $2 WHERE id = $3 AND account_id = $4",
)
.bind(&req.limit_kind)
.bind(req.limit_value)
.bind(id)
.bind(acct)
.execute(&state.pool)
.await?;
if res.rows_affected() == 0 {
return Err(WebError::BadRequest("no such key"));
}
Ok(StatusCode::NO_CONTENT.into_response())
}
#[derive(Deserialize)]
struct RedeemReq {
code: String,
}
/// `POST /web/v1/redeem` — redeem a single-use top-up code, raising the
/// account's allocation. Returns the new total. Generic 400 for an invalid
/// or already-redeemed code (no oracle).
async fn redeem(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Json(req): Json<RedeemReq>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
match crate::topup::redeem(&state.pool, acct, &req.code).await {
Ok(new_total) => Ok(Json(json!({ "allocation_total": new_total })).into_response()),
Err(crate::topup::TopUpError::Invalid) => {
Err(WebError::BadRequest("invalid or already-redeemed code"))
}
Err(crate::topup::TopUpError::Db(e)) => {
tracing::error!(error = %e, "redeem db error");
Err(WebError::Internal)
}
}
}

View File

@@ -0,0 +1,243 @@
//! Integration tests for the `/authz/v1` surface against a real Postgres,
//! driving the built axum app over HTTP. Gated on `UPSTREAM_TEST_DATABASE_URL`
//! (skips cleanly when unset, so CI stays green without a DB):
//!
//! UPSTREAM_TEST_DATABASE_URL=postgres://helexa:helexa@localhost/helexa_test \
//! cargo test -p helexa-upstream --test authz_pg
use helexa_upstream::config::{ClientToken, UpstreamConfig};
use helexa_upstream::crypto::sha256;
use helexa_upstream::db::connect_and_migrate;
use helexa_upstream::state::AppState;
use serde_json::{Value, json};
use sqlx::Executor;
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
const CLIENT_TOKEN: &str = "test-operator-token";
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
return None;
};
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
let mut config = UpstreamConfig {
server: Default::default(),
db: helexa_upstream::config::DbSettings {
url,
max_connections: 16,
},
grant: Default::default(),
abuse: Default::default(),
client_auth: Default::default(),
authz: Default::default(),
auth: Default::default(),
email: Default::default(),
};
config.client_auth.tokens.push(ClientToken {
token: CLIENT_TOKEN.into(),
operator_id: "op-test".into(),
});
let email = helexa_upstream::email::EmailSender::from_config(&config.email).unwrap();
let state = AppState::new(pool.clone(), config, email);
let app = helexa_upstream::build_app(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Some((format!("http://{addr}"), pool))
}
/// Seed an account with `total` allocation and an active key with raw value
/// `raw` (percent=100). Optionally deactivate the account. Returns
/// (account_id, key_id).
async fn seed_key(pool: &PgPool, total: i64, raw: &str, deactivated: bool) -> (Uuid, Uuid) {
let user_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO users (email, password_hash, email_verified) VALUES ($1,'x',true) RETURNING id",
)
.bind(format!("u-{}@t.local", Uuid::new_v4())),
)
.await
.unwrap()
.get("id");
let status = if deactivated { "deactivated" } else { "active" };
let account_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO accounts (owner_user_id, allocation_total, status) VALUES ($1,$2,$3) RETURNING id",
)
.bind(user_id)
.bind(total)
.bind(status),
)
.await
.unwrap()
.get("id");
let key_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
VALUES ($1,$2,'sk-test','percent',100) RETURNING id",
)
.bind(account_id)
.bind(sha256(raw)),
)
.await
.unwrap()
.get("id");
(account_id, key_id)
}
fn client() -> reqwest::Client {
reqwest::Client::new()
}
async fn post(
c: &reqwest::Client,
url: String,
body: Value,
bearer: Option<&str>,
) -> reqwest::Response {
let mut req = c.post(url).json(&body);
if let Some(b) = bearer {
req = req.bearer_auth(b);
}
req.send().await.unwrap()
}
#[tokio::test]
async fn resolve_reserve_settle_round_trip() {
let Some((base, pool)) = spawn_or_skip("resolve_reserve_settle_round_trip").await else {
return;
};
let raw = format!("sk-{}", Uuid::new_v4());
let (account_id, key_id) = seed_key(&pool, 1000, &raw, false).await;
let c = client();
// resolve
let r = post(
&c,
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(r.status(), 200);
let body: Value = r.json().await.unwrap();
assert_eq!(body["principal"]["account_id"], account_id.to_string());
assert_eq!(body["principal"]["key_id"], key_id.to_string());
assert_eq!(body["snapshot"]["hard_cap"], 1000);
// reserve 400
let r = post(
&c,
format!("{base}/authz/v1/reserve"),
json!({"account_id": account_id, "key_id": key_id, "max_tokens": 400}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(r.status(), 200);
let body: Value = r.json().await.unwrap();
let rid = body["reservation_id"].as_i64().expect("granted");
// settle 150
let r = post(
&c,
format!("{base}/authz/v1/settle"),
json!({"reservation_id": rid, "actual_tokens": 150}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(r.status(), 204);
// snapshot reflects spend
let r = post(
&c,
format!("{base}/authz/v1/snapshot"),
json!({"account_id": account_id, "key_id": key_id}),
Some(CLIENT_TOKEN),
)
.await;
let body: Value = r.json().await.unwrap();
assert_eq!(body["spent"], 150);
assert_eq!(body["reserved"], 0);
}
#[tokio::test]
async fn over_cap_reserve_is_rejected_not_errored() {
let Some((base, pool)) = spawn_or_skip("over_cap_reserve_is_rejected_not_errored").await else {
return;
};
let raw = format!("sk-{}", Uuid::new_v4());
let (account_id, key_id) = seed_key(&pool, 100, &raw, false).await;
let c = client();
let r = post(
&c,
format!("{base}/authz/v1/reserve"),
json!({"account_id": account_id, "key_id": key_id, "max_tokens": 999}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(r.status(), 200, "budget refusal is an authoritative 200");
let body: Value = r.json().await.unwrap();
assert!(body["reservation_id"].is_null());
assert_eq!(body["rejected"]["kind"], "insufficient_quota");
assert_eq!(body["rejected"]["available"], 100);
}
#[tokio::test]
async fn deactivated_account_resolves_as_invalid_no_clue() {
let Some((base, pool)) = spawn_or_skip("deactivated_account_resolves_as_invalid_no_clue").await
else {
return;
};
let raw = format!("sk-{}", Uuid::new_v4());
seed_key(&pool, 1000, &raw, true).await; // deactivated
let c = client();
let r = post(
&c,
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw}),
Some(CLIENT_TOKEN),
)
.await;
// Indistinguishable from an unknown key.
assert_eq!(r.status(), 401);
let body: Value = r.json().await.unwrap();
assert_eq!(body["error"]["code"], "invalid_api_key");
}
#[tokio::test]
async fn missing_client_auth_is_401_before_db() {
let Some((base, pool)) = spawn_or_skip("missing_client_auth_is_401_before_db").await else {
return;
};
let raw = format!("sk-{}", Uuid::new_v4());
seed_key(&pool, 1000, &raw, false).await;
let c = client();
// No bearer → rejected by client_auth.
let r = post(
&c,
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw}),
None,
)
.await;
assert_eq!(r.status(), 401);
// Wrong bearer → also rejected.
let r = post(
&c,
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw}),
Some("wrong"),
)
.await;
assert_eq!(r.status(), 401);
}

View File

@@ -0,0 +1,204 @@
//! Integration tests for the allocation ledger against a real PostgreSQL.
//!
//! Gated on `UPSTREAM_TEST_DATABASE_URL` — when unset (CI's generic runner,
//! local builds without a DB), every test logs a skip and returns, so
//! `cargo test --workspace` stays green without Postgres. Point the env var
//! at a throwaway database to exercise the no-overshoot guarantee and
//! settle/release idempotency:
//!
//! UPSTREAM_TEST_DATABASE_URL=postgres://helexa:helexa@localhost/helexa_test \
//! cargo test -p helexa-upstream --test ledger_pg
use helexa_upstream::db::connect_and_migrate;
use helexa_upstream::ledger::{self, LedgerError};
use sqlx::Executor;
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
/// Returns a migrated pool, or `None` (with a skip log) when the env var is
/// unset.
async fn pool_or_skip(test: &str) -> Option<PgPool> {
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
return None;
};
Some(
connect_and_migrate(&url, 16)
.await
.expect("connect + migrate"),
)
}
/// Seed a verified user + account (with `total` allocation) + an active key
/// (percent=100 so the account cap binds). Returns (account_id, key_id).
async fn seed(pool: &PgPool, total: i64) -> (Uuid, Uuid) {
let user_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO users (email, password_hash, email_verified) \
VALUES ($1, 'x', true) RETURNING id",
)
.bind(format!("u-{}@test.local", Uuid::new_v4())),
)
.await
.unwrap()
.get("id");
let account_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO accounts (owner_user_id, allocation_total) \
VALUES ($1, $2) RETURNING id",
)
.bind(user_id)
.bind(total),
)
.await
.unwrap()
.get("id");
let key_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
VALUES ($1, $2, 'sk-test', 'percent', 100) RETURNING id",
)
.bind(account_id)
.bind(Uuid::new_v4().as_bytes().to_vec()),
)
.await
.unwrap()
.get("id");
(account_id, key_id)
}
async fn account_cols(pool: &PgPool, account_id: Uuid) -> (i64, i64) {
let row = pool
.fetch_one(
sqlx::query("SELECT allocation_spent, allocation_reserved FROM accounts WHERE id = $1")
.bind(account_id),
)
.await
.unwrap();
(row.get("allocation_spent"), row.get("allocation_reserved"))
}
#[tokio::test]
async fn concurrent_reserves_never_overshoot() {
let Some(pool) = pool_or_skip("concurrent_reserves_never_overshoot").await else {
return;
};
// Allocation admits exactly 5 reservations of 100 (cap 500).
let (account_id, key_id) = seed(&pool, 500).await;
let mut handles = Vec::new();
for _ in 0..20 {
let pool = pool.clone();
handles.push(tokio::spawn(async move {
ledger::reserve(&pool, account_id, key_id, 100).await
}));
}
let mut ok = 0;
let mut quota = 0;
for h in handles {
match h.await.unwrap() {
Ok(_) => ok += 1,
Err(LedgerError::InsufficientQuota { .. }) => quota += 1,
Err(e) => panic!("unexpected error: {e}"),
}
}
assert_eq!(ok, 5, "exactly 5 reserves of 100 fit in a 500 allocation");
assert_eq!(quota, 15);
let (spent, reserved) = account_cols(&pool, account_id).await;
assert_eq!(spent, 0);
assert_eq!(reserved, 500, "reserved exactly the cap, never over");
}
#[tokio::test]
async fn settle_is_idempotent_and_reconciles_spend() {
let Some(pool) = pool_or_skip("settle_is_idempotent_and_reconciles_spend").await else {
return;
};
let (account_id, key_id) = seed(&pool, 1000).await;
let rid = ledger::reserve(&pool, account_id, key_id, 400)
.await
.unwrap();
// Settle actual=150 (< reserved 400): spent=150, reserved back to 0.
ledger::settle(&pool, rid, 150).await.unwrap();
let (spent, reserved) = account_cols(&pool, account_id).await;
assert_eq!((spent, reserved), (150, 0));
// Second settle is a no-op.
ledger::settle(&pool, rid, 999).await.unwrap();
let (spent2, reserved2) = account_cols(&pool, account_id).await;
assert_eq!((spent2, reserved2), (150, 0), "settle is idempotent");
}
#[tokio::test]
async fn release_returns_reservation_and_is_idempotent() {
let Some(pool) = pool_or_skip("release_returns_reservation_and_is_idempotent").await else {
return;
};
let (account_id, key_id) = seed(&pool, 1000).await;
let rid = ledger::reserve(&pool, account_id, key_id, 300)
.await
.unwrap();
assert_eq!(account_cols(&pool, account_id).await, (0, 300));
ledger::release(&pool, rid).await.unwrap();
assert_eq!(account_cols(&pool, account_id).await, (0, 0));
// Idempotent; settle-after-release also a no-op.
ledger::release(&pool, rid).await.unwrap();
ledger::settle(&pool, rid, 100).await.unwrap();
assert_eq!(account_cols(&pool, account_id).await, (0, 0));
}
#[tokio::test]
async fn hardcap_key_subcap_binds_below_account() {
let Some(pool) = pool_or_skip("hardcap_key_subcap_binds_below_account").await else {
return;
};
// Account has 1000 but the key is hard-capped at 200.
let user_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO users (email, password_hash, email_verified) \
VALUES ($1, 'x', true) RETURNING id",
)
.bind(format!("u-{}@test.local", Uuid::new_v4())),
)
.await
.unwrap()
.get("id");
let account_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO accounts (owner_user_id, allocation_total) VALUES ($1, 1000) RETURNING id",
)
.bind(user_id),
)
.await
.unwrap()
.get("id");
let key_id: Uuid = pool
.fetch_one(
sqlx::query(
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
VALUES ($1, $2, 'sk-test', 'hardcap', 200) RETURNING id",
)
.bind(account_id)
.bind(Uuid::new_v4().as_bytes().to_vec()),
)
.await
.unwrap()
.get("id");
ledger::reserve(&pool, account_id, key_id, 200)
.await
.unwrap();
match ledger::reserve(&pool, account_id, key_id, 1).await {
Err(LedgerError::InsufficientQuota { available, .. }) => assert_eq!(available, 0),
other => panic!("expected InsufficientQuota, got {other:?}"),
}
}

View File

@@ -0,0 +1,116 @@
//! Integration test for the served-usage report (#58): the idempotent,
//! monotonic upsert and the reconcile rollup. Gated on
//! UPSTREAM_TEST_DATABASE_URL (skips cleanly when unset).
use helexa_upstream::config::{ClientToken, UpstreamConfig};
use helexa_upstream::db::connect_and_migrate;
use helexa_upstream::email::EmailSender;
use helexa_upstream::reconcile::reconcile;
use helexa_upstream::state::AppState;
use serde_json::{Value, json};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
const CLIENT_TOKEN: &str = "su-test-token";
const OPERATOR: &str = "op-su-test";
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
return None;
};
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
let mut config = UpstreamConfig {
server: Default::default(),
db: helexa_upstream::config::DbSettings {
url,
max_connections: 16,
},
grant: Default::default(),
abuse: Default::default(),
client_auth: Default::default(),
authz: Default::default(),
auth: Default::default(),
email: Default::default(),
};
config.client_auth.tokens.push(ClientToken {
token: CLIENT_TOKEN.into(),
operator_id: OPERATOR.into(),
});
let email = EmailSender::from_config(&config.email).unwrap();
let state = AppState::new(pool.clone(), config, email);
let app = helexa_upstream::build_app(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Some((format!("http://{addr}"), pool))
}
async fn report(base: &str, rows: Value) -> u16 {
reqwest::Client::new()
.post(format!("{base}/authz/v1/served-usage"))
.bearer_auth(CLIENT_TOKEN)
.json(&json!({ "rows": rows }))
.send()
.await
.unwrap()
.status()
.as_u16()
}
async fn stored(pool: &PgPool, account: Uuid, key: Uuid) -> i64 {
sqlx::query(
"SELECT served_tokens FROM served_usage WHERE operator_id = $1 AND account_id = $2 AND key_id = $3",
)
.bind(OPERATOR)
.bind(account)
.bind(key)
.fetch_one(pool)
.await
.unwrap()
.get("served_tokens")
}
#[tokio::test]
async fn served_usage_upsert_is_monotonic_and_reconciles() {
let Some((base, pool)) = spawn_or_skip("served_usage_upsert_is_monotonic_and_reconciles").await
else {
return;
};
let account = Uuid::new_v4();
let key = Uuid::new_v4();
let period = "2026-06-23";
let row = |n: i64| json!([{"account_id": account, "key_id": key, "period": period, "served_tokens": n}]);
// First report.
assert_eq!(report(&base, row(100)).await, 204);
assert_eq!(stored(&pool, account, key).await, 100);
// Re-send a higher absolute value → advances.
assert_eq!(report(&base, row(250)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// A lower value (e.g. a restarted cortex) must NOT regress (GREATEST).
assert_eq!(report(&base, row(50)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// Re-sending the same value is idempotent.
assert_eq!(report(&base, row(250)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// Reconcile rolls it up and stamps reconciled_at; a second run is empty.
let rollup = reconcile(&pool).await.unwrap();
let mine = rollup
.iter()
.find(|r| r.operator_id == OPERATOR)
.expect("operator in rollup");
assert!(mine.total_served_tokens >= 250);
let again = reconcile(&pool).await.unwrap();
assert!(
again.iter().all(|r| r.operator_id != OPERATOR),
"already reconciled"
);
}

View File

@@ -0,0 +1,425 @@
//! Integration tests for the `/web/v1` account API + the silent fingerprint
//! abuse policy, driving the built app over HTTP against a real Postgres.
//! Gated on `UPSTREAM_TEST_DATABASE_URL` (skips cleanly when unset).
use helexa_upstream::config::{ClientToken, UpstreamConfig};
use helexa_upstream::crypto::sha256;
use helexa_upstream::db::connect_and_migrate;
use helexa_upstream::email::EmailSender;
use helexa_upstream::state::AppState;
use serde_json::{Value, json};
use sqlx::Executor;
use sqlx::Row;
use sqlx::postgres::PgPool;
const CLIENT_TOKEN: &str = "web-test-operator-token";
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
return None;
};
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
let mut config = UpstreamConfig {
server: Default::default(),
db: helexa_upstream::config::DbSettings {
url,
max_connections: 16,
},
grant: Default::default(),
abuse: Default::default(),
client_auth: Default::default(),
authz: Default::default(),
auth: Default::default(),
email: Default::default(), // Log transport
};
config.client_auth.tokens.push(ClientToken {
token: CLIENT_TOKEN.into(),
operator_id: "op-web".into(),
});
let email = EmailSender::from_config(&config.email).unwrap();
let state = AppState::new(pool.clone(), config, email);
let app = helexa_upstream::build_app(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Some((format!("http://{addr}"), pool))
}
fn unique_email() -> String {
format!("u-{}@test.local", uuid::Uuid::new_v4())
}
async fn post(url: String, body: Value, bearer: Option<&str>) -> reqwest::Response {
let c = reqwest::Client::new();
let mut req = c.post(url).json(&body);
if let Some(b) = bearer {
req = req.bearer_auth(b);
}
req.send().await.unwrap()
}
#[tokio::test]
async fn verify_endpoint_consumes_token_once() {
let Some((base, pool)) = spawn_or_skip("verify_endpoint_consumes_token_once").await else {
return;
};
let email = unique_email();
// Register, then mint a verify token directly (the raw token is only in
// the email; here we insert a known one to drive the endpoint).
assert_eq!(
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None
)
.await
.status(),
202
);
let user_id: uuid::Uuid = pool
.fetch_one(sqlx::query("SELECT id FROM users WHERE email = $1").bind(&email))
.await
.unwrap()
.get("id");
let raw = "verify-raw-token-xyz";
pool.execute(
sqlx::query(
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
VALUES ($1, $2, 'verify', now() + interval '1 hour')",
)
.bind(sha256(raw))
.bind(user_id),
)
.await
.unwrap();
assert_eq!(
post(format!("{base}/web/v1/verify"), json!({"token": raw}), None)
.await
.status(),
200
);
// Consumed → second attempt fails.
assert_eq!(
post(format!("{base}/web/v1/verify"), json!({"token": raw}), None)
.await
.status(),
400
);
let verified: bool = pool
.fetch_one(sqlx::query("SELECT email_verified FROM users WHERE id = $1").bind(user_id))
.await
.unwrap()
.get("email_verified");
assert!(verified);
}
#[tokio::test]
async fn account_lifecycle_and_key_resolves_then_archives() {
let Some((base, pool)) =
spawn_or_skip("account_lifecycle_and_key_resolves_then_archives").await
else {
return;
};
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
// Bypass the email step for the login/key portion.
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
// login → session JWT
let r = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
assert_eq!(r.status(), 200);
let token = r.json::<Value>().await.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
// create key (raw shown once)
let r = post(
format!("{base}/web/v1/keys"),
json!({"label": "laptop"}),
Some(&token),
)
.await;
assert_eq!(r.status(), 201);
let body: Value = r.json().await.unwrap();
let raw_key = body["key"].as_str().unwrap().to_string();
let key_id = body["id"].as_str().unwrap().to_string();
assert!(raw_key.starts_with("sk-helexa-"));
// account balance reflects the free grant
let r = reqwest::Client::new()
.get(format!("{base}/web/v1/account"))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(
r.json::<Value>().await.unwrap()["allocation_total"],
1_000_000
);
// list keys shows the prefix, never the raw secret
let r = reqwest::Client::new()
.get(format!("{base}/web/v1/keys"))
.bearer_auth(&token)
.send()
.await
.unwrap();
let listed = r.json::<Value>().await.unwrap();
let k = &listed["keys"][0];
assert_eq!(k["id"], key_id);
assert!(k.get("key").is_none(), "raw secret never listed");
assert!(k["prefix"].as_str().unwrap().starts_with("sk-helexa-"));
// the key authorizes at the authz surface
let r = post(
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw_key}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(r.status(), 200);
// archive → the key no longer resolves
let r = post(
format!("{base}/web/v1/keys/{key_id}/archive"),
json!({}),
Some(&token),
)
.await;
assert_eq!(r.status(), 204);
let r = post(
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw_key}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(r.status(), 401);
}
#[tokio::test]
async fn fingerprint_abuse_silently_deactivates_all_no_clue() {
let Some((base, pool)) =
spawn_or_skip("fingerprint_abuse_silently_deactivates_all_no_clue").await
else {
return;
};
let fp = format!("fp-{}", uuid::Uuid::new_v4());
// 5 registrations sharing one fingerprint — every one returns a normal 202.
let mut emails = Vec::new();
for _ in 0..5 {
let email = unique_email();
let r = post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123", "fingerprint": fp}),
None,
)
.await;
assert_eq!(r.status(), 202, "registration always looks successful");
emails.push(email);
}
// Silent effect: all 5 accounts are deactivated + flagged.
let (deactivated, flagged): (i64, i64) = {
let row = pool
.fetch_one(
sqlx::query(
"SELECT \
count(*) FILTER (WHERE a.status = 'deactivated') AS d, \
count(*) FILTER (WHERE a.fingerprint_flagged) AS f \
FROM accounts a JOIN users u ON u.id = a.owner_user_id \
WHERE u.registration_fingerprint = $1",
)
.bind(&fp),
)
.await
.unwrap();
(row.get("d"), row.get("f"))
};
assert_eq!(deactivated, 5, "all sharing accounts silently deactivated");
assert_eq!(flagged, 5);
// No clue at the authz surface: a key on a deactivated account resolves
// as an ordinary 401, indistinguishable from an unknown key.
let acct: uuid::Uuid = pool
.fetch_one(
sqlx::query(
"SELECT a.id FROM accounts a JOIN users u ON u.id = a.owner_user_id \
WHERE u.registration_fingerprint = $1 LIMIT 1",
)
.bind(&fp),
)
.await
.unwrap()
.get("id");
let raw = "sk-helexa-deactivated-probe";
pool.execute(
sqlx::query(
"INSERT INTO api_keys (account_id, key_hash, key_prefix) VALUES ($1, $2, 'sk-helexa-')",
)
.bind(acct)
.bind(sha256(raw)),
)
.await
.unwrap();
let r = post(
format!("{base}/authz/v1/resolve"),
json!({"api_key": raw}),
Some(CLIENT_TOKEN),
)
.await;
assert_eq!(
r.status(),
401,
"deactivated account's key looks like any invalid key"
);
}
#[tokio::test]
async fn topup_redeem_raises_allocation_single_use() {
let Some((base, pool)) = spawn_or_skip("topup_redeem_raises_allocation_single_use").await
else {
return;
};
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
let token = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
// Mint a code worth 500_000 (mint path used by the CLI/faucet).
let codes = helexa_upstream::topup::mint(&pool, 500_000, 1, Some("test"))
.await
.unwrap();
let code = &codes[0];
// Redeem → allocation_total rises from the 1_000_000 free grant.
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&token),
)
.await;
assert_eq!(r.status(), 200);
assert_eq!(
r.json::<Value>().await.unwrap()["allocation_total"],
1_500_000
);
// Single-use: a second redemption fails generically (no oracle).
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&token),
)
.await;
assert_eq!(r.status(), 400);
// Unknown code: same generic 400.
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": "helexa-topup-does-not-exist"}),
Some(&token),
)
.await;
assert_eq!(r.status(), 400);
}
#[tokio::test]
async fn topup_concurrent_double_redeem_one_winner() {
let Some((base, pool)) = spawn_or_skip("topup_concurrent_double_redeem_one_winner").await
else {
return;
};
// Two verified accounts.
let mut tokens = Vec::new();
for _ in 0..2 {
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
let t = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
tokens.push(t);
}
let code = helexa_upstream::topup::mint(&pool, 100, 1, None)
.await
.unwrap()
.remove(0);
// Both accounts race to redeem the same code; exactly one wins.
let (a, b) = tokio::join!(
post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&tokens[0])
),
post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&tokens[1])
),
);
let wins = [a.status(), b.status()]
.iter()
.filter(|s| s.as_u16() == 200)
.count();
assert_eq!(wins, 1, "exactly one redemption wins the single-use code");
}

View File

@@ -38,6 +38,7 @@ cudnn = [
flash-attn = [
"cuda",
"candle-transformers/flash-attn",
"dep:candle-flash-attn",
]
# Reserved for GPU-only integration tests in later stages.
cuda-integration = ["cuda"]
@@ -71,6 +72,10 @@ rayon = "1"
candle-core = "0.10.2"
candle-nn = "0.10.2"
candle-transformers = "0.10.2"
# Direct dependency so `candle_flash_attn::flash_attn` is nameable in
# the attention core (#95); the candle-transformers feature alone only
# enables flash inside ITS models.
candle-flash-attn = { version = "0.10.2", optional = true }
# Direct dep on cudarc (matching candle's transitive version) so the
# TP worker pool can call cudarc::nccl::{Comm, Id} directly. Gated on
# the `cuda` feature; same toolchain requirement as candle's CUDA path.

View File

@@ -16,7 +16,7 @@ use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
use cortex_core::harness::ModelSpec;
use cortex_core::openai::{ChatCompletionRequest, MessageContent};
use cortex_core::responses::{ResponsesRequest, ResponsesUsage};
use cortex_core::responses::{OutputTokensDetails, ResponsesRequest, ResponsesUsage};
use futures::stream::{self, StreamExt};
use serde_json::{Value, json};
use std::convert::Infallible;
@@ -418,8 +418,14 @@ async fn responses(
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.prompt_tokens + u.completion_tokens,
// Non-streaming reasoning accounting deferred (#64).
output_tokens_details: None,
// Carry the reasoning sub-count through from the chat
// usage — the non-streaming path now splits off the
// `<think>` span and counts it (see `split_off_reasoning`).
output_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
OutputTokensDetails {
reasoning_tokens: d.reasoning_tokens,
}
}),
input_tokens_details: None,
});
let meta = openai_responses::ResponseMeta {

View File

@@ -94,13 +94,24 @@ impl AdmissionController {
/// overall queue is full or the principal is over its fair-share cap —
/// then waits up to `max_wait` for an in-flight slot. The returned permit
/// must be held for the request's lifetime; dropping it frees the slots.
///
/// CANCELLATION SAFETY: the semaphore wait below is where a client
/// disconnect lands — axum drops the request future mid-await. The
/// reservation therefore lives in a RAII [`PendingReservation`] taken
/// BEFORE the await: if this future is dropped while queued, the
/// guard's Drop rolls the counts back. (The original version
/// incremented raw counters and only decremented on the timeout
/// branch — every abandoned wait leaked a `pending` + per-principal
/// slot, ratcheting the model into a permanent instant-429 state
/// under client retry storms. Observed live 2026-07-02:
/// `queue_depth: 1` pinned on an idle model.)
pub async fn enter(
&self,
principal: Option<&str>,
) -> Result<AdmissionPermit, AdmissionRejection> {
// Decision + reservation under one brief lock so concurrent callers
// can't both slip past the thresholds. No await is held here.
{
let reservation = {
let mut st = self.state.lock().expect("admission state poisoned");
if st.pending >= self.max_pending {
return Err(AdmissionRejection::QueueFull {
@@ -119,31 +130,25 @@ impl AdmissionController {
if let Some(p) = principal {
*st.per_principal.entry(p.to_string()).or_insert(0) += 1;
}
}
PendingReservation {
state: Arc::clone(&self.state),
principal: principal.map(str::to_string),
}
};
match tokio::time::timeout(self.max_wait, Arc::clone(&self.slots).acquire_owned()).await {
Ok(Ok(permit)) => Ok(AdmissionPermit {
_permit: permit,
state: Arc::clone(&self.state),
principal: principal.map(str::to_string),
_reservation: reservation,
}),
// Semaphore is never closed; treat a closed/elapsed wait the
// same. `reservation` drops here, rolling back the counts.
Ok(Err(_)) | Err(_) => Err(AdmissionRejection::Timeout {
retry_after_secs: self.retry_hint(self.max_pending),
}),
// Semaphore is never closed; treat a closed/elapsed wait the same.
Ok(Err(_)) | Err(_) => {
self.release(principal);
Err(AdmissionRejection::Timeout {
retry_after_secs: self.retry_hint(self.max_pending),
})
}
}
}
/// Roll back a reserved-but-not-admitted slot (wait timed out).
fn release(&self, principal: Option<&str>) {
let mut st = self.state.lock().expect("admission state poisoned");
st.pending = st.pending.saturating_sub(1);
decrement_principal(&mut st.per_principal, principal);
}
/// Requests currently running (holding an in-flight slot).
pub fn in_flight(&self) -> usize {
self.max_in_flight
@@ -177,16 +182,17 @@ fn decrement_principal(map: &mut HashMap<String, usize>, principal: Option<&str>
}
}
/// Held for a request's lifetime; frees the in-flight + queue slot (and the
/// principal's fair-share slot) on drop.
/// RAII accounting for one reserved slot (queued or in-flight): decrements
/// `pending` and the principal's fair-share count on drop, whichever way
/// the reservation ends — admitted-and-finished, wait timeout, or the
/// caller's future being dropped mid-queue (client disconnect).
#[derive(Debug)]
pub struct AdmissionPermit {
_permit: OwnedSemaphorePermit,
struct PendingReservation {
state: Arc<Mutex<AdmissionState>>,
principal: Option<String>,
}
impl Drop for AdmissionPermit {
impl Drop for PendingReservation {
fn drop(&mut self) {
let mut st = self.state.lock().expect("admission state poisoned");
st.pending = st.pending.saturating_sub(1);
@@ -194,6 +200,14 @@ impl Drop for AdmissionPermit {
}
}
/// Held for a request's lifetime; frees the in-flight slot (semaphore
/// permit) and the queue + fair-share accounting (reservation) on drop.
#[derive(Debug)]
pub struct AdmissionPermit {
_permit: OwnedSemaphorePermit,
_reservation: PendingReservation,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -295,4 +309,65 @@ mod tests {
drop(_a1);
b.await.unwrap().expect("B is served after A releases");
}
/// Regression for the 2026-07-02 retry-storm incident: a client that
/// disconnects while QUEUED drops the `enter()` future mid-await.
/// The reservation must roll back — the original implementation
/// leaked `pending` + the per-principal count on this path, pinning
/// the model in a permanent instant-429 state.
#[tokio::test]
async fn cancelled_queued_waiter_rolls_back_accounting() {
let cfg = AdmissionConfig {
max_in_flight: 1,
max_queue_depth: 2,
max_wait_secs: 30,
// Cap 3 lets the runner + both waiters coexist; if the two
// cancelled waiters leaked their counts, the principal would
// sit at 3 == cap and the post-cancel enter below would hit
// PrincipalCap instead of queueing.
max_per_principal: 3,
};
let ctrl = Arc::new(AdmissionController::new(&cfg));
let running = ctrl.enter(Some("acct/key")).await.expect("admit running");
// Two waiters from the same principal park in the queue…
let mut waiters = Vec::new();
for _ in 0..2 {
let c = Arc::clone(&ctrl);
waiters.push(tokio::spawn(async move {
c.enter(Some("acct/key")).await.map(drop)
}));
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(ctrl.queue_depth(), 2);
// …and both clients vanish (abort = the dropped request future).
for w in &waiters {
w.abort();
}
for w in waiters {
let _ = w.await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
ctrl.queue_depth(),
0,
"cancelled waiters must not leak queue slots"
);
// The principal's fair-share count must also be clean: with the
// runner still holding 1 of its cap of 3, a new request from the
// same principal queues instead of hitting PrincipalCap (which a
// leak of the two cancelled counts would trigger).
let c = Arc::clone(&ctrl);
let retry = tokio::spawn(async move { c.enter(Some("acct/key")).await.map(drop) });
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(ctrl.queue_depth(), 1, "post-cancel request queues normally");
drop(running);
retry
.await
.unwrap()
.expect("post-cancel request is served — no leaked principal count");
}
}

View File

@@ -22,6 +22,7 @@ use super::TextConfig;
use super::full_attn::Qwen3_5Attention;
use super::linear_attn::GatedDeltaNet;
use super::mlp::Qwen3_5MLP;
use super::moe::Qwen3_5MoeBlock;
use super::rmsnorm::Qwen3_5RmsNorm;
use super::rope::RotaryEmbedding;
use super::snapshot::LayerKvSnapshot;
@@ -35,10 +36,27 @@ enum AttentionKind {
Linear(GatedDeltaNet),
}
/// The FFN slot: dense SwiGLU (Qwen3.6) or the high-sparsity MoE block
/// (qwen3_next 80B-A3B family, #92), selected per layer by
/// [`TextConfig::layer_uses_moe`].
enum MlpKind {
Dense(Qwen3_5MLP),
Moe(Qwen3_5MoeBlock),
}
impl Module for MlpKind {
fn forward(&self, x: &Tensor) -> candle_core::Result<Tensor> {
match self {
MlpKind::Dense(mlp) => mlp.forward(x),
MlpKind::Moe(moe) => moe.forward(x),
}
}
}
pub struct Qwen3_5DecoderLayer {
input_layernorm: Qwen3_5RmsNorm,
post_attention_layernorm: Qwen3_5RmsNorm,
mlp: Qwen3_5MLP,
mlp: MlpKind,
attention: AttentionKind,
}
@@ -73,7 +91,11 @@ impl Qwen3_5DecoderLayer {
),
};
let mlp = Qwen3_5MLP::load(cfg, &vb.pp("mlp"))?;
let mlp = if cfg.layer_uses_moe(layer_idx) {
MlpKind::Moe(Qwen3_5MoeBlock::load(cfg, &vb.pp("mlp"))?)
} else {
MlpKind::Dense(Qwen3_5MLP::load(cfg, &vb.pp("mlp"))?)
};
let input_layernorm =
Qwen3_5RmsNorm::load(&vb.pp("input_layernorm"), cfg.hidden_size, cfg.rms_norm_eps)?;
let post_attention_layernorm = Qwen3_5RmsNorm::load(

View File

@@ -29,6 +29,86 @@ use super::TextConfig;
use super::rmsnorm::Qwen3_5RmsNorm;
use super::rope::RotaryEmbedding;
/// Runtime kill-switch for the FlashAttention path (#95):
/// `NEURON_FLASH_ATTN=0` (or `false`) forces the eager fallback
/// without a rebuild — the A/B lever and the rollback if the kernels
/// misbehave on some device. Read once.
#[cfg(feature = "flash-attn")]
fn flash_attn_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| {
let on = !std::env::var("NEURON_FLASH_ATTN").is_ok_and(|v| v == "0" || v == "false");
tracing::info!(enabled = on, "FlashAttention path (#95)");
on
})
}
/// Attention core shared by the single-GPU and TP full-attention
/// layers (#95): `(B, H, L, D)` query and `(B, H_kv, S, D)` key/value
/// (post-KV-cache, NOT GQA-repeated) → `(B, H, L, D)` context.
///
/// With the `flash-attn` feature on a CUDA device in f16/bf16, this
/// dispatches to the FlashAttention kernel: GQA is native (no
/// repeated-K/V materialisation) and causality is a kernel flag, so
/// the O(L²) mask/score tensors never exist. The kernels align the
/// causal mask to the BOTTOM-RIGHT when `seqlen_q != seqlen_k`
/// (flash-attention v2.1+ semantics), which is exactly what chunked
/// prefill continuation needs: a chunk of L new queries against
/// `offset + L` cached keys masks correctly.
///
/// INVARIANT: `attn_mask` is either `None` (decode / single position)
/// or the standard causal mask — the only mask the qwen3_5 forward
/// constructs. The flash path encodes it as `causal = attn_mask
/// .is_some()`; a future non-causal mask must extend this signature,
/// not silently pass through.
///
/// Falls back to the eager matmul→softmax→matmul everywhere else
/// (CPU, f32, feature off, or `NEURON_FLASH_ATTN=0`).
pub(crate) fn attention_context(
q: &Tensor,
k: &Tensor,
v: &Tensor,
attn_mask: Option<&Tensor>,
num_kv_groups: usize,
scale: f64,
) -> candle_core::Result<Tensor> {
#[cfg(feature = "flash-attn")]
{
use candle_core::DType;
let dtype = q.dtype();
// Prefill only (q_len > 1): measured on beast (27B, 30k-token
// prompt, 2x RTX 5090), flash cuts prefill 24.8s → 22.1s but
// REGRESSES decode ~20% (50 → 60 ms/token at 30k KV) — FA2
// without flash-decoding is weak at query-length 1 and the
// per-step layout transposes add overhead. Greedy outputs are
// byte-identical either way, so this split is purely a
// performance routing decision.
if flash_attn_enabled()
&& q.dim(2)? > 1
&& q.device().is_cuda()
&& (dtype == DType::F16 || dtype == DType::BF16)
{
// flash_attn wants (B, L, H, D); the callers carry (B, H, L, D).
let qf = q.transpose(1, 2)?.contiguous()?;
let kf = k.transpose(1, 2)?.contiguous()?;
let vf = v.transpose(1, 2)?.contiguous()?;
let causal = attn_mask.is_some();
let ctx = candle_flash_attn::flash_attn(&qf, &kf, &vf, scale as f32, causal)?;
return ctx.transpose(1, 2)?.contiguous();
}
}
// Eager fallback: materialise GQA-repeated K/V and the score matrix.
let k = repeat_kv(k.clone(), num_kv_groups)?.contiguous()?;
let v = repeat_kv(v.clone(), num_kv_groups)?.contiguous()?;
let mut scores = (q.matmul(&k.transpose(2, 3)?)? * scale)?;
if let Some(m) = attn_mask {
scores = scores.broadcast_add(m)?;
}
let probs = candle_nn::ops::softmax_last_dim(&scores)?;
probs.matmul(&v)
}
pub struct Qwen3_5Attention {
q_proj: Linear,
k_proj: Linear,
@@ -139,18 +219,10 @@ impl Qwen3_5Attention {
// 4. KV cache.
let (k, v) = self.kv_cache.append(&k, &v)?;
// 5. GQA repeat (cheap shape op).
let k = repeat_kv(k, self.num_kv_groups)?.contiguous()?;
let v = repeat_kv(v, self.num_kv_groups)?.contiguous()?;
// 6. Scaled dot-product + causal mask.
// 5+6. Attention core — FlashAttention when available, eager
// GQA-repeat + masked softmax otherwise (#95).
let scale = 1.0_f64 / (self.head_dim as f64).sqrt();
let mut scores = (q.matmul(&k.transpose(2, 3)?)? * scale)?;
if let Some(m) = attn_mask {
scores = scores.broadcast_add(m)?;
}
let probs = candle_nn::ops::softmax_last_dim(&scores)?;
let ctx = probs.matmul(&v)?; // (B, H, L, D)
let ctx = attention_context(&q, &k, &v, attn_mask, self.num_kv_groups, scale)?; // (B, H, L, D)
// 7. Reshape back, apply the output gate, project.
let ctx = ctx

View File

@@ -139,10 +139,42 @@ impl GatedDeltaNet {
let conv_dim = key_dim * 2 + value_dim;
// ----- Linear projections (all `bias=False` in the reference). -----
let in_proj_qkv = load_linear_no_bias(vb, "in_proj_qkv", cfg.hidden_size, conv_dim)?;
let in_proj_z = load_linear_no_bias(vb, "in_proj_z", cfg.hidden_size, value_dim)?;
let in_proj_b = load_linear_no_bias(vb, "in_proj_b", cfg.hidden_size, num_v_heads)?;
let in_proj_a = load_linear_no_bias(vb, "in_proj_a", cfg.hidden_size, num_v_heads)?;
// Two checkpoint layouts exist for the input projections:
// - Qwen3.6 (qwen3_5): separate `in_proj_qkv` / `in_proj_z` /
// `in_proj_b` / `in_proj_a`, with qkv stored as contiguous
// [Q | K | V] blocks — loads directly.
// - Qwen3-Next 80B-A3B (qwen3_next, #92): fused `in_proj_qkvz`
// + `in_proj_ba`, **interleaved per key-head group** (see
// `split_fused_qkvz`/`split_fused_ba`) — de-interleaved once
// at load into the same contiguous layout, so the forward
// path (incl. the conv over [Q|K|V] channels) is unchanged.
let (in_proj_qkv, in_proj_z, in_proj_b, in_proj_a) =
if vb.contains_tensor("in_proj_qkvz.weight") {
let qkvz = vb
.pp("in_proj_qkvz")
.get((2 * key_dim + 2 * value_dim, cfg.hidden_size), "weight")
.with_context(|| format!("load '{}/in_proj_qkvz/weight'", vb.prefix()))?;
let ba = vb
.pp("in_proj_ba")
.get((2 * num_v_heads, cfg.hidden_size), "weight")
.with_context(|| format!("load '{}/in_proj_ba/weight'", vb.prefix()))?;
let (qkv_w, z_w) =
split_fused_qkvz(&qkvz, num_k_heads, num_v_heads, head_k_dim, head_v_dim)?;
let (b_w, a_w) = split_fused_ba(&ba, num_k_heads, num_v_heads)?;
(
Linear::new(qkv_w, None),
Linear::new(z_w, None),
Linear::new(b_w, None),
Linear::new(a_w, None),
)
} else {
(
load_linear_no_bias(vb, "in_proj_qkv", cfg.hidden_size, conv_dim)?,
load_linear_no_bias(vb, "in_proj_z", cfg.hidden_size, value_dim)?,
load_linear_no_bias(vb, "in_proj_b", cfg.hidden_size, num_v_heads)?,
load_linear_no_bias(vb, "in_proj_a", cfg.hidden_size, num_v_heads)?,
)
};
let out_proj = load_linear_no_bias(vb, "out_proj", value_dim, cfg.hidden_size)?;
// ----- Conv1d weight (depthwise, bias=False). -----
@@ -889,6 +921,61 @@ fn load_linear_no_bias(
Ok(Linear::new(weight, None))
}
/// De-interleave a fused `in_proj_qkvz.weight` (qwen3_next layout, #92)
/// into a contiguous `[Q | K | V]` qkv weight plus a `Z` weight.
///
/// The fused rows are grouped **per key head**: for each of the
/// `num_k_heads` groups (`r = num_v_heads / num_k_heads`, group stride
/// `s = 2*head_k + 2*head_v*r`), the group holds
/// `[q (head_k) | k (head_k) | v (head_v*r) | z (head_v*r)]` — the
/// reshape in upstream `fix_query_key_value_ordering`
/// `(num_k_heads, 2*head_k + 2*head_v*num_v/num_k)`. Concatenating the
/// per-group regions restores the global-contiguous layout the rest of
/// this module (incl. the conv over `[Q|K|V]` channels) expects.
pub(crate) fn split_fused_qkvz(
qkvz: &Tensor,
num_k_heads: usize,
num_v_heads: usize,
head_k_dim: usize,
head_v_dim: usize,
) -> Result<(Tensor, Tensor)> {
let r = num_v_heads / num_k_heads;
let stride = 2 * head_k_dim + 2 * head_v_dim * r;
let (mut qs, mut ks, mut vs, mut zs) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
for g in 0..num_k_heads {
let base = g * stride;
qs.push(qkvz.narrow(0, base, head_k_dim)?);
ks.push(qkvz.narrow(0, base + head_k_dim, head_k_dim)?);
vs.push(qkvz.narrow(0, base + 2 * head_k_dim, head_v_dim * r)?);
zs.push(qkvz.narrow(0, base + 2 * head_k_dim + head_v_dim * r, head_v_dim * r)?);
}
let parts: Vec<Tensor> = qs.into_iter().chain(ks).chain(vs).collect();
let qkv = Tensor::cat(&parts, 0)?.contiguous()?;
let z = Tensor::cat(&zs, 0)?.contiguous()?;
Ok((qkv, z))
}
/// De-interleave a fused `in_proj_ba.weight` (qwen3_next layout, #92)
/// into per-v-head `b` (beta) and `a` (decay) weights. Same per-key-head
/// grouping as [`split_fused_qkvz`]: each group holds `[b (r) | a (r)]`
/// rows, `r = num_v_heads / num_k_heads`.
pub(crate) fn split_fused_ba(
ba: &Tensor,
num_k_heads: usize,
num_v_heads: usize,
) -> Result<(Tensor, Tensor)> {
let r = num_v_heads / num_k_heads;
let (mut bs, mut r#as) = (Vec::new(), Vec::new());
for g in 0..num_k_heads {
let base = g * 2 * r;
bs.push(ba.narrow(0, base, r)?);
r#as.push(ba.narrow(0, base + r, r)?);
}
let b = Tensor::cat(&bs, 0)?.contiguous()?;
let a = Tensor::cat(&r#as, 0)?.contiguous()?;
Ok((b, a))
}
/// Numerically-stable `softplus(x) = ln(1 + exp(x))`. Matches PyTorch's
/// `F.softplus` default (beta=1, threshold=20: for large positive x,
/// returns x as-is to avoid overflow in the exp).
@@ -1138,6 +1225,13 @@ mod tests {
linear_key_head_dim: 4,
linear_value_head_dim: 4,
linear_conv_kernel_dim: 4,
num_experts: 0,
num_experts_per_tok: 0,
moe_intermediate_size: 0,
shared_expert_intermediate_size: 0,
decoder_sparse_step: 1,
mlp_only_layers: Vec::new(),
norm_topk_prob: false,
};
// Build a synthetic VarBuilder with all-zeros weights.
@@ -1179,4 +1273,115 @@ mod tests {
let v: Vec<f32> = y.flatten_all().unwrap().to_vec1().unwrap();
assert!(v.iter().all(|x| x.is_finite()));
}
/// Interleave known per-head Q/K/V/Z (and B/A) rows into the fused
/// qwen3_next layout, split, and expect the original contiguous
/// blocks back. Layout under test: per key-head group g,
/// `[q_g | k_g | v_g | z_g]` with r = num_v/num_k value heads per
/// group (upstream `fix_query_key_value_ordering`).
#[test]
fn split_fused_qkvz_and_ba_roundtrip() {
let dev = Device::Cpu;
let (num_k, num_v, head_k, head_v, hidden) = (2usize, 4usize, 3usize, 2usize, 5usize);
let r = num_v / num_k;
// Distinct constant per logical row so any mis-slicing shows.
let row = |tag: f32| Tensor::full(tag, (1, hidden), &dev).unwrap();
let mut fused_rows: Vec<Tensor> = Vec::new();
let (mut q_rows, mut k_rows, mut v_rows, mut z_rows) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for g in 0..num_k {
let base = 1000.0 * (g as f32 + 1.0);
for i in 0..head_k {
let t = row(base + i as f32);
fused_rows.push(t.clone());
q_rows.push(t);
}
for i in 0..head_k {
let t = row(base + 100.0 + i as f32);
fused_rows.push(t.clone());
k_rows.push(t);
}
for i in 0..head_v * r {
let t = row(base + 200.0 + i as f32);
fused_rows.push(t.clone());
v_rows.push(t);
}
for i in 0..head_v * r {
let t = row(base + 300.0 + i as f32);
fused_rows.push(t.clone());
z_rows.push(t);
}
}
let fused = Tensor::cat(&fused_rows, 0).unwrap();
let expected_qkv = Tensor::cat(
&q_rows
.iter()
.chain(k_rows.iter())
.chain(v_rows.iter())
.cloned()
.collect::<Vec<_>>(),
0,
)
.unwrap();
let expected_z = Tensor::cat(&z_rows, 0).unwrap();
let (qkv, z) = split_fused_qkvz(&fused, num_k, num_v, head_k, head_v).unwrap();
assert_eq!(qkv.dims(), &[2 * num_k * head_k + num_v * head_v, hidden]);
let diff_qkv: f32 = (qkv - expected_qkv)
.unwrap()
.abs()
.unwrap()
.max_all()
.unwrap()
.to_scalar()
.unwrap();
let diff_z: f32 = (z - expected_z)
.unwrap()
.abs()
.unwrap()
.max_all()
.unwrap()
.to_scalar()
.unwrap();
assert_eq!(diff_qkv, 0.0);
assert_eq!(diff_z, 0.0);
// ba: per group, [b (r rows) | a (r rows)].
let mut ba_rows = Vec::new();
let (mut b_rows, mut a_rows) = (Vec::new(), Vec::new());
for g in 0..num_k {
let base = 10.0 * (g as f32 + 1.0);
for i in 0..r {
let t = row(base + i as f32);
ba_rows.push(t.clone());
b_rows.push(t);
}
for i in 0..r {
let t = row(base + 5.0 + i as f32);
ba_rows.push(t.clone());
a_rows.push(t);
}
}
let ba = Tensor::cat(&ba_rows, 0).unwrap();
let (b, a) = split_fused_ba(&ba, num_k, num_v).unwrap();
let diff_b: f32 = (b - Tensor::cat(&b_rows, 0).unwrap())
.unwrap()
.abs()
.unwrap()
.max_all()
.unwrap()
.to_scalar()
.unwrap();
let diff_a: f32 = (a - Tensor::cat(&a_rows, 0).unwrap())
.unwrap()
.abs()
.unwrap()
.max_all()
.unwrap()
.to_scalar()
.unwrap();
assert_eq!(diff_b, 0.0);
assert_eq!(diff_a, 0.0);
}
}

View File

@@ -17,12 +17,32 @@ pub struct Qwen3_5MLP {
}
impl Qwen3_5MLP {
/// Construct directly from pre-built projections (MoE-block tests).
#[cfg(test)]
pub(crate) fn from_weights(gate_proj: Linear, up_proj: Linear, down_proj: Linear) -> Self {
Self {
gate_proj,
up_proj,
down_proj,
}
}
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<Self> {
let h = cfg.hidden_size;
let i = cfg.intermediate_size;
let gate_proj = load_linear_no_bias(vb, "gate_proj", h, i)?;
let up_proj = load_linear_no_bias(vb, "up_proj", h, i)?;
let down_proj = load_linear_no_bias(vb, "down_proj", i, h)?;
Self::load_with_dims(vb, cfg.hidden_size, cfg.intermediate_size)
}
/// Load with explicit dims — the MoE block (#92) reuses this SwiGLU
/// shape for routed experts (`moe_intermediate_size`) and the shared
/// expert (`shared_expert_intermediate_size`), both narrower than
/// the dense `intermediate_size`.
pub fn load_with_dims(
vb: &ShardedVarBuilder,
hidden: usize,
intermediate: usize,
) -> Result<Self> {
let gate_proj = load_linear_no_bias(vb, "gate_proj", hidden, intermediate)?;
let up_proj = load_linear_no_bias(vb, "up_proj", hidden, intermediate)?;
let down_proj = load_linear_no_bias(vb, "down_proj", intermediate, hidden)?;
Ok(Self {
gate_proj,
up_proj,

View File

@@ -76,6 +76,7 @@ pub mod decoder;
pub mod full_attn;
pub mod linear_attn;
pub mod mlp;
pub mod moe;
pub mod rmsnorm;
pub mod rope;
pub mod snapshot;
@@ -90,6 +91,13 @@ use rope::RotaryEmbedding;
/// magic strings.
pub const MODEL_TYPE: &str = "qwen3_5";
/// `model_type` of the MoE sibling family (Qwen3-Next-80B-A3B /
/// Qwen3-Coder-Next): the same Gated DeltaNet hybrid layer stack with
/// a high-sparsity MoE FFN per layer. Served by this same arch module —
/// [`Config::from_config_json`] normalises the flat qwen3_next
/// `config.json` layout into the nested shape used here.
pub const MODEL_TYPE_NEXT: &str = "qwen3_next";
/// Top-level shape of Qwen3-Next's `config.json`. The real
/// hyperparameters live in `text_config`; the rest is multimodal /
/// tokeniser glue we don't need for the language-model forward.
@@ -117,6 +125,79 @@ pub struct Config {
pub image_token_id: Option<u32>,
}
impl Config {
/// Parse a `config.json` for either family this arch serves,
/// normalising layout differences (#92):
///
/// - `model_type == "qwen3_5"` (Qwen3.6): hyperparameters nested
/// under `text_config`, RoPE nested under `rope_parameters` —
/// deserialises directly.
/// - `model_type == "qwen3_next"` (Qwen3-Next-80B-A3B family):
/// **flat** layout — hyperparameters at the top level,
/// `rope_theta`/`partial_rotary_factor` flat, no vision block.
/// Wrapped into the nested shape here. The output gate on full
/// attention is unconditional in the upstream qwen3_next
/// implementation (the config carries no flag), so
/// `attn_output_gate` is forced on.
///
/// Both variants may omit `layer_types` (qwen3_next always does);
/// it is derived from `full_attention_interval` using the upstream
/// convention: layer `i` is `full_attention` iff
/// `(i + 1) % interval == 0`, else `linear_attention`.
pub fn from_config_json(json: &str) -> Result<Self> {
let v: serde_json::Value =
serde_json::from_str(json).context("parse config.json as JSON")?;
let model_type = v
.get("model_type")
.and_then(|m| m.as_str())
.unwrap_or_default()
.to_string();
let mut cfg: Config = if model_type == MODEL_TYPE_NEXT {
let mut text = v.clone();
if text.get("rope_parameters").is_none() {
let mut rope = serde_json::Map::new();
for key in ["rope_theta", "partial_rotary_factor", "rope_type"] {
if let Some(val) = v.get(key) {
rope.insert(key.to_string(), val.clone());
}
}
text["rope_parameters"] = serde_json::Value::Object(rope);
}
let mut text_config: TextConfig = serde_json::from_value(text)
.context("parse flat qwen3_next config.json hyperparameters")?;
text_config.attn_output_gate = true;
Config {
model_type,
text_config,
vision_config: None,
image_token_id: None,
}
} else {
serde_json::from_str(json).context("parse nested qwen3_5 config.json")?
};
if cfg.text_config.layer_types.is_empty() {
let interval = cfg.text_config.full_attention_interval.unwrap_or(4);
anyhow::ensure!(
interval > 0,
"full_attention_interval must be >= 1 to derive layer_types"
);
cfg.text_config.layer_types = (0..cfg.text_config.num_hidden_layers)
.map(|i| {
if (i + 1).is_multiple_of(interval) {
"full_attention".to_string()
} else {
"linear_attention".to_string()
}
})
.collect();
}
Ok(cfg)
}
}
/// Inner config (the `text_config` block). Mirrors the Qwen3 layout
/// but with the extras Qwen3-Next adds (`attn_output_gate`,
/// `layer_types`, `full_attention_interval`, larger `head_dim`).
@@ -185,6 +266,56 @@ pub struct TextConfig {
/// (Qwen3.6-27B: 4).
#[serde(default)]
pub linear_conv_kernel_dim: usize,
// --- High-sparsity MoE FFN (Qwen3-Next 80B-A3B family, #92) --------
// All default to the dense case (0 experts) so existing dense
// configs (Qwen3.6-27B) deserialise unchanged. A layer gets the MoE
// FFN iff `layer_uses_moe` says so; otherwise the dense SwiGLU.
/// Total routed experts per MoE layer (80B-A3B: 512). `0` → dense
/// model, no MoE anywhere.
#[serde(default)]
pub num_experts: usize,
/// Experts activated per token (80B-A3B: 10).
#[serde(default)]
pub num_experts_per_tok: usize,
/// Per-expert FFN width (80B-A3B: 512). Distinct from the dense
/// `intermediate_size`.
#[serde(default)]
pub moe_intermediate_size: usize,
/// Width of the always-on shared expert (80B-A3B: 512). `0` → no
/// shared expert (Qwen3-30B-A3B style).
#[serde(default)]
pub shared_expert_intermediate_size: usize,
/// Every `decoder_sparse_step`-th layer is MoE (1 → all layers,
/// the 80B-A3B case). Follows the upstream `(i+1) % step == 0`
/// convention.
#[serde(default = "default_decoder_sparse_step")]
pub decoder_sparse_step: usize,
/// Layer indices forced to the dense MLP even when MoE is on.
/// Empty for 80B-A3B.
#[serde(default)]
pub mlp_only_layers: Vec<usize>,
/// Renormalise the top-k routing weights to sum to 1 (80B-A3B:
/// true). Upstream selects top-k *after* softmax over all experts.
#[serde(default)]
pub norm_topk_prob: bool,
}
impl TextConfig {
/// Whether decoder layer `layer_idx` carries the MoE FFN (vs the
/// dense SwiGLU). Mirrors upstream `Qwen3NextDecoderLayer`:
/// experts configured, layer not in `mlp_only_layers`, and on the
/// `decoder_sparse_step` grid.
pub fn layer_uses_moe(&self, layer_idx: usize) -> bool {
self.num_experts > 0
&& self.decoder_sparse_step > 0
&& !self.mlp_only_layers.contains(&layer_idx)
&& (layer_idx + 1).is_multiple_of(self.decoder_sparse_step)
}
}
fn default_decoder_sparse_step() -> usize {
1
}
fn default_hidden_act() -> String {
@@ -330,17 +461,20 @@ pub struct Qwen3_5Model {
}
impl Qwen3_5Model {
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<Self> {
/// `text_prefix` is where the text core lives in the checkpoint:
/// - Qwen3.6 (multimodal, `model_type = "qwen3_5"`):
/// `model.language_model` — sibling to `model.visual.*` (the
/// vision tower) and top-level `lm_head` / `mtp.*`.
/// - Qwen3-Next-80B-A3B (text-only, `model_type = "qwen3_next"`):
/// plain `model`.
///
/// [`Qwen3_5ForCausalLM::new`] picks by `Config::model_type` via
/// [`text_weight_prefix`].
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder, text_prefix: &str) -> Result<Self> {
let dtype = vb.dtype();
let device = vb.device().clone();
// Qwen3-Next is a multimodal architecture whose text core lives
// under `model.language_model.*` — sibling to `model.visual.*`
// (the vision tower) and to top-level `lm_head` / `mtp.*`.
// Every text-side tensor in the safetensors files is under
// this prefix; we ignore the vision and MTP weights for
// language-model inference.
let text_vb = vb.pp("model.language_model");
let text_vb = vb.pp(text_prefix);
let embed_vb = text_vb.pp("embed_tokens");
let embed_weight = embed_vb
@@ -444,6 +578,67 @@ impl Qwen3_5Model {
self.forward_inner(input, offset, None, None, &[], None)
}
/// Lockstep batched decode step (#98): `input` is `(B, 1)` — one
/// new token per batch row — with each row at its own sequence
/// position `positions[i]` (typically `prefix_lens[i] + step`).
/// The cache must hold batched state (see
/// `snapshot::assemble_batch`); `attn_mask` is the padding mask
/// from [`Self::batch_decode_mask`] (or `None` when no row is
/// padded). Text-only: `rope_delta` is ignored — positions are
/// explicit and vision requests never enter the batch path.
pub fn forward_batch_decode(
&mut self,
input: &Tensor,
positions: &[usize],
attn_mask: Option<&Tensor>,
) -> candle_core::Result<Tensor> {
let (b, l) = input.dims2()?;
if l != 1 {
candle_core::bail!("forward_batch_decode: expected (B, 1) input, got (B, {l})");
}
if positions.len() != b {
candle_core::bail!(
"forward_batch_decode: {} positions for batch of {b}",
positions.len()
);
}
let mut h = self.embed_tokens.forward(input)?;
let (cos, sin) = self.rotary.batch_cos_sin(positions)?;
for layer in &mut self.layers {
h = layer.forward(&h, attn_mask, &cos, &sin)?;
}
self.norm.forward(&h)
}
/// Additive padding mask for a batched decode step: shape
/// `(B, 1, 1, total_len)`, `-inf` on each row's padding gap
/// `[prefix_lens[i], padded_len)`, zero elsewhere. `total_len` is
/// the KV length *after* this step's append (`padded_len + step +
/// 1`). Returns `None` when no row is padded (uniform prefix
/// lengths) — the decode step then needs no mask at all, matching
/// the single-sequence fast path.
pub fn batch_decode_mask(
&self,
prefix_lens: &[usize],
padded_len: usize,
total_len: usize,
) -> candle_core::Result<Option<Tensor>> {
if prefix_lens.iter().all(|&len| len == padded_len) {
return Ok(None);
}
let minf = f32::NEG_INFINITY;
let b = prefix_lens.len();
let mask: Vec<f32> = prefix_lens
.iter()
.flat_map(|&len| {
(0..total_len).map(move |j| if j >= len && j < padded_len { minf } else { 0. })
})
.collect();
Ok(Some(
Tensor::from_vec(mask, (b, 1, 1, total_len), &self.device)?.to_dtype(self.dtype)?,
))
}
/// Forward for a vision-prefill chunk: optional image-embedding
/// splice plus explicit interleaved-M-RoPE `position_ids` (the
/// chunk's slice of the full prompt's 3D positions). Mirrors the TP
@@ -605,10 +800,20 @@ pub struct Qwen3_5ForCausalLM {
image_token_id: Option<u32>,
}
/// Checkpoint prefix of the text core for a given `model_type` — see
/// [`Qwen3_5Model::load`].
pub fn text_weight_prefix(model_type: &str) -> &'static str {
if model_type == MODEL_TYPE_NEXT {
"model"
} else {
"model.language_model"
}
}
impl Qwen3_5ForCausalLM {
pub fn new(config: Config, vb: ShardedVarBuilder) -> Result<Self> {
let cfg = &config.text_config;
let base = Qwen3_5Model::load(cfg, &vb)?;
let base = Qwen3_5Model::load(cfg, &vb, text_weight_prefix(&config.model_type))?;
let lm_head = if cfg.tie_word_embeddings {
Linear::new(base.embed_weight().clone(), None)
} else {
@@ -676,6 +881,34 @@ impl Qwen3_5ForCausalLM {
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
}
/// Lockstep batched decode step (#98): `(B, 1)` input, per-row
/// positions, padding mask from
/// [`Qwen3_5Model::batch_decode_mask`]. Returns `(B, 1,
/// vocab_size)` — one logits row per batch row.
pub fn forward_batch_decode(
&mut self,
input: &Tensor,
positions: &[usize],
attn_mask: Option<&Tensor>,
) -> candle_core::Result<Tensor> {
let hidden = self
.base
.forward_batch_decode(input, positions, attn_mask)?;
hidden.apply(&self.lm_head)
}
/// Padding mask for a batched decode step — see
/// [`Qwen3_5Model::batch_decode_mask`].
pub fn batch_decode_mask(
&self,
prefix_lens: &[usize],
padded_len: usize,
total_len: usize,
) -> candle_core::Result<Option<Tensor>> {
self.base
.batch_decode_mask(prefix_lens, padded_len, total_len)
}
/// Stage B: forward with image-embedding splice. Mirrors `forward`
/// but routes through `Qwen3_5Model::forward_with_vision` so the
/// LM's input embeddings get the image patches spliced in at
@@ -915,6 +1148,233 @@ mod tests {
assert_eq!(cfg.text_config.layer_types.len(), 4);
assert_eq!(cfg.text_config.rope_parameters.rope_theta, 10_000_000.0);
assert!((cfg.text_config.rope_parameters.partial_rotary_factor - 0.25).abs() < 1e-6);
// Dense config: no MoE anywhere.
assert_eq!(cfg.text_config.num_experts, 0);
assert!(!cfg.text_config.layer_uses_moe(0));
// The normalising entry point must agree with plain serde for
// the nested shape (and leave the explicit layer_types alone).
let via_norm = Config::from_config_json(raw).expect("normalised parse");
assert_eq!(via_norm.text_config.layer_types.len(), 4);
assert_eq!(via_norm.text_config.layer_types[3], "full_attention");
}
/// The flat qwen3_next layout (Qwen3-Next-80B-A3B family): all
/// hyperparameters top-level, flat rope fields, no `layer_types`,
/// MoE fields present. Sample mirrors
/// `Qwen/Qwen3-Next-80B-A3B-Instruct/config.json`.
#[test]
fn config_normalises_the_flat_qwen3_next_shape() {
let raw = r#"{
"architectures": ["Qwen3NextForCausalLM"],
"model_type": "qwen3_next",
"vocab_size": 151936,
"hidden_size": 2048,
"intermediate_size": 5120,
"num_hidden_layers": 48,
"num_attention_heads": 16,
"num_key_value_heads": 2,
"head_dim": 256,
"max_position_embeddings": 262144,
"partial_rotary_factor": 0.25,
"rope_theta": 10000000,
"rms_norm_eps": 1e-6,
"tie_word_embeddings": false,
"full_attention_interval": 4,
"linear_conv_kernel_dim": 4,
"linear_key_head_dim": 128,
"linear_num_key_heads": 16,
"linear_num_value_heads": 32,
"linear_value_head_dim": 128,
"decoder_sparse_step": 1,
"mlp_only_layers": [],
"moe_intermediate_size": 512,
"norm_topk_prob": true,
"num_experts": 512,
"num_experts_per_tok": 10,
"shared_expert_intermediate_size": 512
}"#;
let cfg = Config::from_config_json(raw).expect("parse qwen3_next config");
assert_eq!(cfg.model_type, MODEL_TYPE_NEXT);
assert!(cfg.vision_config.is_none());
let t = &cfg.text_config;
assert_eq!(t.hidden_size, 2048);
// Flat rope fields normalised into the nested block.
assert_eq!(t.rope_parameters.rope_theta, 10_000_000.0);
assert!((t.rope_parameters.partial_rotary_factor - 0.25).abs() < 1e-6);
// Output-gated attention is unconditional for qwen3_next.
assert!(t.attn_output_gate);
// layer_types derived from the interval: (i+1) % 4 == 0 → full.
assert_eq!(t.layer_types.len(), 48);
assert_eq!(t.layer_types[3], "full_attention");
assert_eq!(t.layer_types[47], "full_attention");
assert_eq!(t.layer_types[0], "linear_attention");
assert_eq!(t.layer_types[46], "linear_attention");
assert_eq!(
t.layer_types
.iter()
.filter(|s| *s == "full_attention")
.count(),
12
);
// MoE hyperparameters land.
assert_eq!(t.num_experts, 512);
assert_eq!(t.num_experts_per_tok, 10);
assert_eq!(t.moe_intermediate_size, 512);
assert_eq!(t.shared_expert_intermediate_size, 512);
assert!(t.norm_topk_prob);
// decoder_sparse_step 1 + empty mlp_only_layers → every layer MoE.
assert!(t.layer_uses_moe(0));
assert!(t.layer_uses_moe(47));
}
/// End-to-end structural check for the qwen3_next path (#92): a
/// tiny random-weight checkpoint in the **flat** layout (`model.*`
/// prefix, fused `in_proj_qkvz`/`in_proj_ba`, per-expert MoE
/// tensors, shared expert) loads through `Config::from_config_json`
/// and `Qwen3_5ForCausalLM::new`, producing finite logits of the
/// right shape. Numerical parity vs HF is pinned separately by the
/// `qwen3_next_parity` fixture test.
#[test]
fn tiny_qwen3_next_checkpoint_loads_and_forwards() {
use candle_core::Device;
use std::collections::HashMap;
let raw = r#"{
"model_type": "qwen3_next",
"vocab_size": 32, "hidden_size": 8, "intermediate_size": 16,
"num_hidden_layers": 2, "num_attention_heads": 2,
"num_key_value_heads": 1, "head_dim": 4,
"max_position_embeddings": 64, "rms_norm_eps": 1e-6,
"full_attention_interval": 2,
"linear_num_value_heads": 4, "linear_num_key_heads": 2,
"linear_key_head_dim": 4, "linear_value_head_dim": 4,
"linear_conv_kernel_dim": 4,
"num_experts": 4, "num_experts_per_tok": 2,
"moe_intermediate_size": 4,
"shared_expert_intermediate_size": 4,
"norm_topk_prob": true
}"#;
let cfg = Config::from_config_json(raw).expect("parse tiny qwen3_next config");
assert_eq!(cfg.text_config.layer_types[0], "linear_attention");
assert_eq!(cfg.text_config.layer_types[1], "full_attention");
let dev = Device::Cpu;
let randn = |shape: &[usize]| Tensor::randn(0f32, 0.1f32, shape, &dev).unwrap();
let ones = |shape: &[usize]| Tensor::ones(shape, DType::F32, &dev).unwrap();
let mut t: HashMap<String, Tensor> = HashMap::new();
let (h, vocab) = (8usize, 32usize);
t.insert("model.embed_tokens.weight".into(), randn(&[vocab, h]));
t.insert("lm_head.weight".into(), randn(&[vocab, h]));
t.insert("model.norm.weight".into(), ones(&[h]));
let moe = |t: &mut HashMap<String, Tensor>, p: &str| {
t.insert(format!("{p}.gate.weight"), randn(&[4, h]));
for e in 0..4 {
t.insert(format!("{p}.experts.{e}.gate_proj.weight"), randn(&[4, h]));
t.insert(format!("{p}.experts.{e}.up_proj.weight"), randn(&[4, h]));
t.insert(format!("{p}.experts.{e}.down_proj.weight"), randn(&[h, 4]));
}
t.insert(
format!("{p}.shared_expert.gate_proj.weight"),
randn(&[4, h]),
);
t.insert(format!("{p}.shared_expert.up_proj.weight"), randn(&[4, h]));
t.insert(
format!("{p}.shared_expert.down_proj.weight"),
randn(&[h, 4]),
);
t.insert(format!("{p}.shared_expert_gate.weight"), randn(&[1, h]));
};
// Layer 0: linear_attention with the FUSED qwen3_next input
// projections. key_dim = 2*4 = 8, value_dim = 4*4 = 16 →
// qkvz rows = 2*8 + 2*16 = 48, ba rows = 2*4 = 8, conv_dim = 32.
let l0 = "model.layers.0";
t.insert(
format!("{l0}.linear_attn.in_proj_qkvz.weight"),
randn(&[48, h]),
);
t.insert(
format!("{l0}.linear_attn.in_proj_ba.weight"),
randn(&[8, h]),
);
t.insert(
format!("{l0}.linear_attn.conv1d.weight"),
randn(&[32, 1, 4]),
);
t.insert(format!("{l0}.linear_attn.dt_bias"), randn(&[4]));
t.insert(format!("{l0}.linear_attn.A_log"), randn(&[4]));
t.insert(format!("{l0}.linear_attn.norm.weight"), ones(&[4]));
t.insert(format!("{l0}.linear_attn.out_proj.weight"), randn(&[h, 16]));
t.insert(format!("{l0}.input_layernorm.weight"), ones(&[h]));
t.insert(format!("{l0}.post_attention_layernorm.weight"), ones(&[h]));
moe(&mut t, &format!("{l0}.mlp"));
// Layer 1: full_attention (output-gated: q_proj is 2×).
let l1 = "model.layers.1";
t.insert(
format!("{l1}.self_attn.q_proj.weight"),
randn(&[2 * 2 * 4, h]),
);
t.insert(format!("{l1}.self_attn.k_proj.weight"), randn(&[4, h]));
t.insert(format!("{l1}.self_attn.v_proj.weight"), randn(&[4, h]));
t.insert(format!("{l1}.self_attn.o_proj.weight"), randn(&[h, 8]));
t.insert(format!("{l1}.self_attn.q_norm.weight"), ones(&[4]));
t.insert(format!("{l1}.self_attn.k_norm.weight"), ones(&[4]));
t.insert(format!("{l1}.input_layernorm.weight"), ones(&[h]));
t.insert(format!("{l1}.post_attention_layernorm.weight"), ones(&[h]));
moe(&mut t, &format!("{l1}.mlp"));
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("model.safetensors");
candle_core::safetensors::save(&t, &path).expect("save safetensors");
// SAFETY: mmap of a file this test just wrote; nothing mutates it.
let vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
std::slice::from_ref(&path),
DType::F32,
&dev,
)
.expect("build ShardedVarBuilder")
};
let mut model = Qwen3_5ForCausalLM::new(cfg, vb).expect("load tiny qwen3_next checkpoint");
let input = Tensor::new(&[1u32, 5, 9], &dev)
.unwrap()
.unsqueeze(0)
.unwrap();
let logits = model.forward(&input, 0).expect("forward");
assert_eq!(logits.dims(), &[1, 1, vocab]);
let v: Vec<f32> = logits.flatten_all().unwrap().to_vec1().unwrap();
assert!(v.iter().all(|x| x.is_finite()), "logits must be finite");
}
/// `mlp_only_layers` and `decoder_sparse_step` gate `layer_uses_moe`
/// per the upstream convention.
#[test]
fn layer_uses_moe_respects_step_and_exclusions() {
let raw = r#"{
"model_type": "qwen3_next",
"vocab_size": 8, "hidden_size": 8, "intermediate_size": 8,
"num_hidden_layers": 8, "num_attention_heads": 2,
"num_key_value_heads": 1, "head_dim": 4,
"max_position_embeddings": 128, "rms_norm_eps": 1e-6,
"num_experts": 4, "num_experts_per_tok": 2,
"moe_intermediate_size": 8,
"decoder_sparse_step": 2,
"mlp_only_layers": [3]
}"#;
let cfg = Config::from_config_json(raw).expect("parse");
let t = &cfg.text_config;
// step 2 → layers 1, 3, 5, 7 are on the sparse grid…
assert!(!t.layer_uses_moe(0));
assert!(t.layer_uses_moe(1));
// …but 3 is excluded by mlp_only_layers.
assert!(!t.layer_uses_moe(3));
assert!(t.layer_uses_moe(5));
}
/// `splice_runs` replaces (1, L, H) embedding rows at the given

View File

@@ -0,0 +1,360 @@
//! High-sparsity MoE FFN block for the qwen3_next family (#92).
//!
//! Qwen3-Next-80B-A3B replaces the dense SwiGLU in (almost) every
//! decoder layer with `Qwen3NextSparseMoeBlock`: a top-k router over
//! `num_experts` small SwiGLU experts, plus an always-on **shared
//! expert** mixed in through a per-token sigmoid gate:
//!
//! ```text
//! probs = softmax(gate(x)) # over ALL experts, f32
//! w, idx = topk(probs, num_experts_per_tok)
//! w = w / sum(w) # iff norm_topk_prob
//! routed = Σ_j w_j · expert_{idx_j}(x)
//! shared = sigmoid(shared_expert_gate(x)) · shared_expert(x)
//! y = routed + shared
//! ```
//!
//! Routing follows the upstream softmax-then-topk order (NOT
//! topk-then-softmax — the renormalisation only equals softmax over
//! the selected logits when `norm_topk_prob` is on, and the reference
//! renormalises the *global* softmax values).
//!
//! ## Dispatch strategy
//!
//! This is the correctness-first implementation: a host-side scatter
//! loop over the experts that actually received tokens (the pattern
//! candle-transformers' `Qwen3SparseMoeBlock` uses). Batch-1 decode
//! touches `num_experts_per_tok` experts per layer; prefill batches
//! per-expert token groups. The fused grouped-GEMM path (slice 4)
//! replaces the loop behind the same `forward` signature.
use anyhow::{Context, Result};
use candle_core::{DType, Module, Tensor};
use candle_nn::Linear;
use candle_nn::var_builder::ShardedVarBuilder;
use super::TextConfig;
use super::mlp::Qwen3_5MLP;
pub struct Qwen3_5MoeBlock {
/// Router: `(num_experts, hidden)`, checkpoint name `mlp.gate`.
gate: Linear,
/// Routed experts, checkpoint names `mlp.experts.{i}.{gate,up,down}_proj`.
experts: Vec<Qwen3_5MLP>,
/// Always-on expert, `mlp.shared_expert.*`. `None` when the config
/// declares no shared expert (Qwen3-30B-A3B style).
shared_expert: Option<Qwen3_5MLP>,
/// Per-token sigmoid mix for the shared expert: `(1, hidden)`,
/// checkpoint name `mlp.shared_expert_gate`.
shared_expert_gate: Option<Linear>,
num_experts_per_tok: usize,
norm_topk_prob: bool,
}
impl Qwen3_5MoeBlock {
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<Self> {
anyhow::ensure!(
cfg.num_experts > 0 && cfg.num_experts_per_tok > 0 && cfg.moe_intermediate_size > 0,
"MoE block needs num_experts ({}), num_experts_per_tok ({}) and \
moe_intermediate_size ({}) all > 0",
cfg.num_experts,
cfg.num_experts_per_tok,
cfg.moe_intermediate_size,
);
anyhow::ensure!(
cfg.num_experts_per_tok <= cfg.num_experts,
"num_experts_per_tok ({}) exceeds num_experts ({})",
cfg.num_experts_per_tok,
cfg.num_experts,
);
let h = cfg.hidden_size;
let gate_weight = vb
.pp("gate")
.get((cfg.num_experts, h), "weight")
.with_context(|| format!("load '{}/gate/weight'", vb.prefix()))?;
let gate = Linear::new(gate_weight, None);
let experts_vb = vb.pp("experts");
let mut experts = Vec::with_capacity(cfg.num_experts);
for i in 0..cfg.num_experts {
experts.push(
Qwen3_5MLP::load_with_dims(&experts_vb.pp(i), h, cfg.moe_intermediate_size)
.with_context(|| format!("load expert {i}"))?,
);
}
let (shared_expert, shared_expert_gate) = if cfg.shared_expert_intermediate_size > 0 {
let shared = Qwen3_5MLP::load_with_dims(
&vb.pp("shared_expert"),
h,
cfg.shared_expert_intermediate_size,
)
.context("load shared_expert")?;
let gate_w = vb
.pp("shared_expert_gate")
.get((1, h), "weight")
.with_context(|| format!("load '{}/shared_expert_gate/weight'", vb.prefix()))?;
(Some(shared), Some(Linear::new(gate_w, None)))
} else {
(None, None)
};
Ok(Self {
gate,
experts,
shared_expert,
shared_expert_gate,
num_experts_per_tok: cfg.num_experts_per_tok,
norm_topk_prob: cfg.norm_topk_prob,
})
}
}
/// Per-expert routing assignment: `(token_rows, weights)` per expert,
/// produced by [`route_scatter`].
pub(crate) type ExpertAssignments = (Vec<Vec<u32>>, Vec<Vec<f32>>);
/// Router + host-side scatter shared by the single-GPU and TP MoE
/// blocks (#92): softmax over ALL experts in f32 → descending-argsort
/// top-k → renormalise iff `norm_topk_prob` → per-expert token-row and
/// weight lists. Under TP the router weight is replicated, so every
/// rank computes identical assignments with zero communication.
pub(crate) fn route_scatter(
gate: &Linear,
xs_flat: &Tensor,
num_experts: usize,
num_experts_per_tok: usize,
norm_topk_prob: bool,
) -> candle_core::Result<ExpertAssignments> {
let n_tokens = xs_flat.dim(0)?;
// Router probabilities in f32 (reference uses float softmax
// regardless of activations dtype).
let router_logits = gate.forward(xs_flat)?;
let probs = candle_nn::ops::softmax_last_dim(&router_logits.to_dtype(DType::F32)?)?;
// Top-k selection: descending argsort, take the first k. The
// renormalisation (iff norm_topk_prob) divides by the sum of
// the selected global-softmax values.
let sorted = probs.arg_sort_last_dim(false)?;
let topk_idx = sorted.narrow(1, 0, num_experts_per_tok)?.contiguous()?;
let mut topk_w = probs.gather(&topk_idx, 1)?;
if norm_topk_prob {
let denom = topk_w.sum_keepdim(1)?;
topk_w = topk_w.broadcast_div(&denom)?;
}
// Host-side scatter: token row lists per expert. Cheap relative
// to the expert GEMMs; replaced by grouped-GEMM in slice 4.
let idx_host: Vec<Vec<u32>> = topk_idx.to_vec2()?;
let w_host: Vec<Vec<f32>> = topk_w.to_vec2()?;
let mut tokens_for: Vec<Vec<u32>> = vec![Vec::new(); num_experts];
let mut weights_for: Vec<Vec<f32>> = vec![Vec::new(); num_experts];
for t in 0..n_tokens {
for j in 0..num_experts_per_tok {
let e = idx_host[t][j] as usize;
tokens_for[e].push(t as u32);
weights_for[e].push(w_host[t][j]);
}
}
Ok((tokens_for, weights_for))
}
impl Module for Qwen3_5MoeBlock {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let (b, l, hidden) = xs.dims3()?;
let xs_flat = xs.reshape(((), hidden))?;
let (tokens_for, weights_for) = route_scatter(
&self.gate,
&xs_flat,
self.experts.len(),
self.num_experts_per_tok,
self.norm_topk_prob,
)?;
let mut ys = xs_flat.zeros_like()?;
for (e, expert) in self.experts.iter().enumerate() {
if tokens_for[e].is_empty() {
continue;
}
let rows = Tensor::new(tokens_for[e].as_slice(), xs.device())?;
let picked = xs_flat.index_select(&rows, 0)?;
let out = expert.forward(&picked)?;
let w = Tensor::new(weights_for[e].as_slice(), xs.device())?
.to_dtype(out.dtype())?
.reshape(((), 1))?;
ys = ys.index_add(&rows, &out.broadcast_mul(&w)?, 0)?;
}
if let (Some(shared), Some(gate)) = (&self.shared_expert, &self.shared_expert_gate) {
let mix = candle_nn::ops::sigmoid(&gate.forward(&xs_flat)?)?;
let shared_out = shared.forward(&xs_flat)?.broadcast_mul(&mix)?;
ys = (ys + shared_out)?;
}
ys.reshape((b, l, hidden))
}
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::Device;
fn randn(shape: &[usize]) -> Tensor {
Tensor::randn(0f32, 0.5f32, shape, &Device::Cpu).unwrap()
}
fn rand_mlp(hidden: usize, inter: usize) -> Qwen3_5MLP {
Qwen3_5MLP::from_weights(
Linear::new(randn(&[inter, hidden]), None),
Linear::new(randn(&[inter, hidden]), None),
Linear::new(randn(&[hidden, inter]), None),
)
}
/// The batched scatter forward must equal a per-token dense
/// reference: route each token independently (host softmax → top-k
/// → renorm), run its selected experts one by one, and mix in the
/// shared expert through the sigmoid gate. Catches indexing,
/// weighting, and renormalisation bugs in the scatter path.
#[test]
fn scatter_forward_matches_per_token_reference() {
let (hidden, inter, n_exp, top_k) = (8, 4, 6, 2);
let block = Qwen3_5MoeBlock {
gate: Linear::new(randn(&[n_exp, hidden]), None),
experts: (0..n_exp).map(|_| rand_mlp(hidden, inter)).collect(),
shared_expert: Some(rand_mlp(hidden, inter)),
shared_expert_gate: Some(Linear::new(randn(&[1, hidden]), None)),
num_experts_per_tok: top_k,
norm_topk_prob: true,
};
let (b, l) = (2, 3);
let xs = randn(&[b, l, hidden]);
let got = block.forward(&xs).unwrap();
assert_eq!(got.dims(), &[b, l, hidden]);
let xs_flat = xs.reshape(((), hidden)).unwrap();
let logits: Vec<Vec<f32>> = block.gate.forward(&xs_flat).unwrap().to_vec2().unwrap();
let got_flat: Vec<Vec<f32>> = got.reshape(((), hidden)).unwrap().to_vec2().unwrap();
for t in 0..b * l {
// Host-side softmax over all experts, then top-k + renorm.
let max = logits[t].iter().cloned().fold(f32::MIN, f32::max);
let exps: Vec<f32> = logits[t].iter().map(|v| (v - max).exp()).collect();
let sum: f32 = exps.iter().sum();
let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
let mut order: Vec<usize> = (0..n_exp).collect();
order.sort_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
let selected = &order[..top_k];
let denom: f32 = selected.iter().map(|&e| probs[e]).sum();
let row = xs_flat.narrow(0, t, 1).unwrap();
let mut expect = vec![0f32; hidden];
for &e in selected {
let w = probs[e] / denom;
let out: Vec<f32> = block.experts[e]
.forward(&row)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
for (acc, o) in expect.iter_mut().zip(out) {
*acc += w * o;
}
}
let gate_v: f32 = block
.shared_expert_gate
.as_ref()
.unwrap()
.forward(&row)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1::<f32>()
.unwrap()[0];
let mix = 1.0 / (1.0 + (-gate_v).exp());
let shared: Vec<f32> = block
.shared_expert
.as_ref()
.unwrap()
.forward(&row)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
for (acc, s) in expect.iter_mut().zip(shared) {
*acc += mix * s;
}
for (i, (&g, &e)) in got_flat[t].iter().zip(expect.iter()).enumerate() {
assert!(
(g - e).abs() < 1e-4,
"token {t} dim {i}: got {g}, expected {e}"
);
}
}
}
/// Without a shared expert (Qwen3-30B-A3B shape) the block is pure
/// routed output; without norm_topk_prob the raw global-softmax
/// weights apply (they do NOT sum to 1 across the selected k).
#[test]
fn no_shared_expert_and_no_renorm() {
let (hidden, inter, n_exp) = (4, 2, 3);
let block = Qwen3_5MoeBlock {
gate: Linear::new(randn(&[n_exp, hidden]), None),
experts: (0..n_exp).map(|_| rand_mlp(hidden, inter)).collect(),
shared_expert: None,
shared_expert_gate: None,
num_experts_per_tok: 1,
norm_topk_prob: false,
};
let xs = randn(&[1, 1, hidden]);
let got: Vec<f32> = block
.forward(&xs)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
// Reference: the argmax expert's output scaled by its raw
// softmax probability.
let logits: Vec<f32> = block
.gate
.forward(&xs.reshape(((), hidden)).unwrap())
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
let max = logits.iter().cloned().fold(f32::MIN, f32::max);
let exps: Vec<f32> = logits.iter().map(|v| (v - max).exp()).collect();
let sum: f32 = exps.iter().sum();
let best = (0..n_exp)
.max_by(|&a, &b| exps[a].partial_cmp(&exps[b]).unwrap())
.unwrap();
let w = exps[best] / sum;
let out: Vec<f32> = block.experts[best]
.forward(&xs.reshape(((), hidden)).unwrap())
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
for (i, (&g, &o)) in got.iter().zip(out.iter()).enumerate() {
assert!(
(g - w * o).abs() < 1e-5,
"dim {i}: got {g}, expected {}",
w * o
);
}
}
}

View File

@@ -159,6 +159,19 @@ impl RotaryEmbedding {
Ok((cos, sin))
}
/// cos/sin gathered at arbitrary **per-row** positions — the
/// batched-decode path (#98), where each batch row sits at its own
/// sequence offset. Shape `(B, 1, rotary_dim/2)`: one position per
/// row, one decode token per step. [`Self::apply_cos_sin`] detects
/// the rank-3 shape and broadcasts per row instead of per position.
pub fn batch_cos_sin(&self, positions: &[usize]) -> candle_core::Result<(Tensor, Tensor)> {
let idx: Vec<u32> = positions.iter().map(|&p| p as u32).collect();
let idx = Tensor::from_vec(idx, positions.len(), self.cos.device())?;
let cos = self.cos.index_select(&idx, 0)?.unsqueeze(1)?;
let sin = self.sin.index_select(&idx, 0)?.unsqueeze(1)?;
Ok((cos, sin))
}
/// cos/sin from explicit per-token 3D position ids, shape
/// `(3, seq_len)` (axes: text, height, width). Builds each axis's
/// frequencies and blends them at the interleave index sets, so
@@ -185,7 +198,9 @@ impl RotaryEmbedding {
}
/// Apply rotary to `q`, `k` (shape `(B, H, L, head_dim)`) using
/// precomputed `cos`/`sin` of shape `(L, rotary_dim/2)`. Partial
/// precomputed `cos`/`sin` of shape `(L, rotary_dim/2)` — or, for
/// the batched-decode path (#98), `(B, L, rotary_dim/2)` with a
/// distinct position per batch row (dispatch is on rank). Partial
/// rotary: only the first `rotary_dim` dims rotate; the tail passes
/// through unchanged.
pub fn apply_cos_sin(
@@ -197,9 +212,17 @@ impl RotaryEmbedding {
) -> candle_core::Result<(Tensor, Tensor)> {
let (_, _, _seq_len, head_dim_in) = q.dims4()?;
debug_assert_eq!(head_dim_in, self.head_dim, "q head_dim mismatch");
let per_row = cos.rank() == 3;
let rope = |x: &Tensor| -> candle_core::Result<Tensor> {
if per_row {
rope_per_row(x, cos, sin)
} else {
candle_nn::rotary_emb::rope_slow(x, cos, sin)
}
};
if self.rotary_dim == self.head_dim {
let q_embed = candle_nn::rotary_emb::rope_slow(&q.contiguous()?, cos, sin)?;
let k_embed = candle_nn::rotary_emb::rope_slow(&k.contiguous()?, cos, sin)?;
let q_embed = rope(&q.contiguous()?)?;
let k_embed = rope(&k.contiguous()?)?;
Ok((q_embed, k_embed))
} else {
// Partial rotation: narrow → rotate → cat the untouched tail.
@@ -212,8 +235,8 @@ impl RotaryEmbedding {
.narrow(candle_core::D::Minus1, 0, self.rotary_dim)?
.contiguous()?;
let k_pass = k.narrow(candle_core::D::Minus1, self.rotary_dim, tail)?;
let q_rotated = candle_nn::rotary_emb::rope_slow(&q_rot, cos, sin)?;
let k_rotated = candle_nn::rotary_emb::rope_slow(&k_rot, cos, sin)?;
let q_rotated = rope(&q_rot)?;
let k_rotated = rope(&k_rot)?;
let q_embed =
Tensor::cat(&[&q_rotated, &q_pass.contiguous()?], candle_core::D::Minus1)?;
let k_embed =
@@ -223,6 +246,27 @@ impl RotaryEmbedding {
}
}
/// GLM rotate-half (same convention as candle's private
/// `rotary_emb::rotate_half`: `cat(-x2, x1)`).
fn rotate_half(x: &Tensor) -> candle_core::Result<Tensor> {
let last = x.dim(candle_core::D::Minus1)?;
let x1 = x.narrow(candle_core::D::Minus1, 0, last / 2)?;
let x2 = x.narrow(candle_core::D::Minus1, last / 2, last - last / 2)?;
Tensor::cat(&[&x2.neg()?, &x1], candle_core::D::Minus1)
}
/// Per-row rope apply for batched decode: `x` is `(B, H, L, rot)`,
/// `cos`/`sin` are `(B, L, rot/2)` — each batch row gets its own
/// position's rotation (candle's `rope_slow` only broadcasts one
/// `(L, rot/2)` table across the whole batch).
fn rope_per_row(x: &Tensor, cos: &Tensor, sin: &Tensor) -> candle_core::Result<Tensor> {
// (B, L, half) → duplicate pairs → (B, 1, L, rot) for broadcast
// over the head dim.
let cos = Tensor::cat(&[cos, cos], candle_core::D::Minus1)?.unsqueeze(1)?;
let sin = Tensor::cat(&[sin, sin], candle_core::D::Minus1)?.unsqueeze(1)?;
x.broadcast_mul(&cos)? + rotate_half(x)?.broadcast_mul(&sin)?
}
/// Compute interleaved-M-RoPE 3D position ids for a full prompt that may
/// contain image-placeholder runs, plus the decode `rope_delta`.
///
@@ -564,6 +608,49 @@ mod tests {
assert!((last[1] - (2.0 * inv[1]).cos()).abs() < 1e-5);
}
/// `batch_cos_sin` at positions [5, 9, 0] must gather exactly the
/// rows `plain_cos_sin` would produce for each position alone.
#[test]
fn batch_cos_sin_gathers_per_row_positions() {
let dev = Device::Cpu;
let rope = RotaryEmbedding::new(DType::F32, &qwen36_cfg(), &dev).unwrap();
let half = rope.inv_freq.dim(1).unwrap();
let positions = [5usize, 9, 0];
let (bc, bs) = rope.batch_cos_sin(&positions).unwrap();
assert_eq!(bc.dims(), &[3, 1, half]);
assert_eq!(bs.dims(), &[3, 1, half]);
for (row, &p) in positions.iter().enumerate() {
let (pc, ps) = rope.plain_cos_sin(p, 1).unwrap();
let dc = (bc.i(row).unwrap() - pc).unwrap().abs().unwrap();
let ds = (bs.i(row).unwrap() - ps).unwrap().abs().unwrap();
assert!(dc.max_all().unwrap().to_scalar::<f32>().unwrap() < 1e-6);
assert!(ds.max_all().unwrap().to_scalar::<f32>().unwrap() < 1e-6);
}
}
/// When every row sits at the same position, the per-row rank-3
/// apply path must reproduce the shared rank-2 (`rope_slow`) path
/// exactly — the invariant that makes the rank dispatch in
/// `apply_cos_sin` safe.
#[test]
fn per_row_apply_matches_shared_when_uniform() {
let dev = Device::Cpu;
let rope = RotaryEmbedding::new(DType::F32, &qwen36_cfg(), &dev).unwrap();
let q = Tensor::randn(0f32, 1f32, (2, 2, 1, 256), &dev).unwrap();
let k = Tensor::randn(0f32, 1f32, (2, 2, 1, 256), &dev).unwrap();
let (c2, s2) = rope.plain_cos_sin(7, 1).unwrap();
let (qa, ka) = rope.apply_cos_sin(&q, &k, &c2, &s2).unwrap();
let (c3, s3) = rope.batch_cos_sin(&[7, 7]).unwrap();
let (qb, kb) = rope.apply_cos_sin(&q, &k, &c3, &s3).unwrap();
let dq = (qa - qb).unwrap().abs().unwrap().max_all().unwrap();
let dk = (ka - kb).unwrap().abs().unwrap().max_all().unwrap();
assert!(dq.to_scalar::<f32>().unwrap() < 1e-6, "q mismatch {dq:?}");
assert!(dk.to_scalar::<f32>().unwrap() < 1e-6, "k mismatch {dk:?}");
}
#[test]
fn get_rope_index_196_is_14x14() {
let mut ids = vec![1u32]; // one text token

View File

@@ -84,6 +84,232 @@ impl KvCacheSnapshot {
}
}
/// Batched cache state assembled from per-sequence snapshots (#98).
/// Install with `Qwen3_5Model::restore_kv_cache(&self.snapshot)`; the
/// forward then runs lockstep batched decode with
/// `forward_batch_decode` using `prefix_lens[i] + step` positions and
/// the padding mask from `Qwen3_5Model::batch_decode_mask`.
pub struct BatchedKvState {
/// Per-layer `(B, …)` state: attention K/V right-padded along the
/// sequence axis to `padded_len` and `cat`ed on dim 0; GDN
/// conv/recurrent states `cat`ed on dim 0 (position-free).
pub snapshot: KvCacheSnapshot,
/// The uniform KV sequence length every row was padded to — the
/// max prefix length in the batch. Decode appends start here.
pub padded_len: usize,
/// Each row's true prefix length. Columns `[prefix_lens[i],
/// padded_len)` of row `i` are zero-padding and must stay masked.
pub prefix_lens: Vec<usize>,
}
/// Assemble per-sequence snapshots into one batched cache state.
/// `seqs` pairs each snapshot with its true token length (the caller
/// tracks prompt token counts; attention K/V lengths are validated
/// against it). All snapshots must come from single-sequence (`B=1`)
/// prefills of the same model, with `rope_delta == 0` (text-only —
/// vision requests don't batch, #98 v1).
///
/// Keys are stored post-RoPE, so right-padding does not disturb
/// position correctness: a row's cached keys keep the rotation of
/// their true positions, the garbage columns are masked, and new
/// tokens rotate at `prefix_len + step` while landing at storage
/// column `padded_len + step`.
pub fn assemble_batch(seqs: &[(&KvCacheSnapshot, usize)]) -> candle_core::Result<BatchedKvState> {
let Some((first, _)) = seqs.first() else {
candle_core::bail!("assemble_batch: empty batch");
};
let n_layers = first.layers.len();
let prefix_lens: Vec<usize> = seqs.iter().map(|&(_, len)| len).collect();
let padded_len = *prefix_lens.iter().max().expect("non-empty");
for (snap, len) in seqs {
if snap.layers.len() != n_layers {
candle_core::bail!(
"assemble_batch: snapshot layer count mismatch ({} vs {n_layers})",
snap.layers.len()
);
}
if snap.rope_delta != 0 {
candle_core::bail!(
"assemble_batch: rope_delta {} != 0 — vision-positioned sequences cannot batch",
snap.rope_delta
);
}
if *len == 0 {
candle_core::bail!("assemble_batch: zero-length sequence");
}
}
let mut layers = Vec::with_capacity(n_layers);
for li in 0..n_layers {
layers.push(assemble_layer(seqs, li, padded_len)?);
}
Ok(BatchedKvState {
snapshot: KvCacheSnapshot {
layers,
rope_delta: 0,
},
padded_len,
prefix_lens,
})
}
fn assemble_layer(
seqs: &[(&KvCacheSnapshot, usize)],
li: usize,
padded_len: usize,
) -> candle_core::Result<LayerKvSnapshot> {
match &seqs[0].0.layers[li] {
LayerKvSnapshot::Full(_) => {
let mut ks = Vec::with_capacity(seqs.len());
let mut vs = Vec::with_capacity(seqs.len());
for (row, (snap, len)) in seqs.iter().enumerate() {
let LayerKvSnapshot::Full(Some((k, v))) = &snap.layers[li] else {
candle_core::bail!(
"assemble_batch: row {row} layer {li} is not a populated \
full-attention snapshot"
);
};
let (b, _h, s, _d) = k.dims4()?;
if b != 1 {
candle_core::bail!(
"assemble_batch: row {row} layer {li} has batch dim {b}, want 1"
);
}
if s != *len {
candle_core::bail!(
"assemble_batch: row {row} layer {li} KV length {s} != declared \
sequence length {len}"
);
}
ks.push(pad_seq(k, padded_len)?);
vs.push(pad_seq(v, padded_len)?);
}
let k = Tensor::cat(&ks, 0)?;
let v = Tensor::cat(&vs, 0)?;
Ok(LayerKvSnapshot::Full(Some((k, v))))
}
LayerKvSnapshot::Linear { .. } => {
let mut convs = Vec::with_capacity(seqs.len());
let mut recs = Vec::with_capacity(seqs.len());
for (row, (snap, _)) in seqs.iter().enumerate() {
let LayerKvSnapshot::Linear {
conv_state: Some(conv),
recurrent_state: Some(rec),
} = &snap.layers[li]
else {
candle_core::bail!(
"assemble_batch: row {row} layer {li} is not a populated \
linear-attention snapshot"
);
};
convs.push(conv.clone());
recs.push(rec.clone());
}
Ok(LayerKvSnapshot::Linear {
conv_state: Some(Tensor::cat(&convs, 0)?),
recurrent_state: Some(Tensor::cat(&recs, 0)?),
})
}
}
}
/// Extract one row of a batched cache snapshot back into a contiguous
/// single-sequence snapshot — the defragment half of batch membership
/// changes (#98). A join/leave rebatches by extracting every surviving
/// row (each becomes a gap-free `B=1` snapshot of length `prefix_len +
/// steps`) and running [`assemble_batch`] over them again, so the
/// "each row has exactly one padding gap" invariant holds for the new
/// batch too.
///
/// `row` indexes the batch dim. The row's valid attention KV is its
/// prefix `[0, prefix_len)` plus the lockstep decode columns
/// `[padded_len, padded_len + steps)`; the gap between them is padding
/// and is dropped. GDN states are position-free — the row is sliced
/// out whole and **deep-copied** (the live buffers are mutated in
/// place by the CUDA kernels; see the module notes on copy semantics).
pub fn extract_row(
snap: &KvCacheSnapshot,
row: usize,
prefix_len: usize,
padded_len: usize,
steps: usize,
) -> candle_core::Result<KvCacheSnapshot> {
if prefix_len == 0 || prefix_len > padded_len {
candle_core::bail!(
"extract_row: prefix_len {prefix_len} out of range (padded_len {padded_len})"
);
}
let mut layers = Vec::with_capacity(snap.layers.len());
for (li, layer) in snap.layers.iter().enumerate() {
layers.push(match layer {
LayerKvSnapshot::Full(Some((k, v))) => {
let (b, _h, s, _d) = k.dims4()?;
if row >= b {
candle_core::bail!("extract_row: row {row} out of range (batch {b})");
}
if s != padded_len + steps {
candle_core::bail!(
"extract_row: layer {li} KV length {s} != padded_len {padded_len} + \
steps {steps}"
);
}
LayerKvSnapshot::Full(Some((
unpad_row(k, row, prefix_len, padded_len, steps)?,
unpad_row(v, row, prefix_len, padded_len, steps)?,
)))
}
LayerKvSnapshot::Full(None) => {
candle_core::bail!("extract_row: layer {li} has an empty attention cache")
}
LayerKvSnapshot::Linear {
conv_state: Some(conv),
recurrent_state: Some(rec),
} => LayerKvSnapshot::Linear {
conv_state: Some(conv.narrow(0, row, 1)?.copy()?),
recurrent_state: Some(rec.narrow(0, row, 1)?.copy()?),
},
LayerKvSnapshot::Linear { .. } => {
candle_core::bail!("extract_row: layer {li} has unpopulated GDN state")
}
});
}
Ok(KvCacheSnapshot {
layers,
rope_delta: 0,
})
}
/// Slice `row` out of a batched `(B, H, padded_len + steps, D)` K or V
/// tensor and drop its padding gap, yielding an owned contiguous
/// `(1, H, prefix_len + steps, D)` tensor.
fn unpad_row(
t: &Tensor,
row: usize,
prefix_len: usize,
padded_len: usize,
steps: usize,
) -> candle_core::Result<Tensor> {
let r = t.narrow(0, row, 1)?;
let prefix = r.narrow(2, 0, prefix_len)?;
if steps == 0 {
return prefix.contiguous()?.copy();
}
let decoded = r.narrow(2, padded_len, steps)?;
Tensor::cat(&[&prefix.contiguous()?, &decoded.contiguous()?], 2)
}
/// Right-pad a `(1, H, S, D)` K or V tensor with zeros along the
/// sequence axis to `padded_len`. Zero columns are inert: the padding
/// mask keeps every query from attending to them.
fn pad_seq(t: &Tensor, padded_len: usize) -> candle_core::Result<Tensor> {
let (b, h, s, d) = t.dims4()?;
if s == padded_len {
return Ok(t.clone());
}
let pad = Tensor::zeros((b, h, padded_len - s, d), t.dtype(), t.device())?;
Tensor::cat(&[t, &pad], 2)
}
#[cfg(test)]
mod tests {
use super::super::{Qwen3_5Model, RopeParameters, TextConfig};
@@ -119,6 +345,13 @@ mod tests {
linear_key_head_dim: 4,
linear_value_head_dim: 4,
linear_conv_kernel_dim: 4,
num_experts: 0,
num_experts_per_tok: 0,
moe_intermediate_size: 0,
shared_expert_intermediate_size: 0,
decoder_sparse_step: 1,
mlp_only_layers: Vec::new(),
norm_topk_prob: false,
}
}
@@ -201,7 +434,7 @@ mod tests {
)
.expect("build ShardedVarBuilder")
};
Qwen3_5Model::load(cfg, &vb).expect("load tiny qwen3_5 model")
Qwen3_5Model::load(cfg, &vb, "model.language_model").expect("load tiny qwen3_5 model")
}
fn forward_tokens(model: &mut Qwen3_5Model, tokens: &[u32], offset: usize) -> Vec<f32> {
@@ -271,6 +504,228 @@ mod tests {
assert!(diff < 1e-6, "second restore diverged: {diff}");
}
/// The gold test for #98 slice 1: ragged sequences decoded in one
/// lockstep batch (assembled from per-sequence snapshots, per-row
/// positions, padding mask) must match the same sequences decoded
/// sequentially, hidden-state for hidden-state at every step.
#[test]
fn batched_decode_matches_sequential() {
use candle_core::IndexOp;
let cfg = tiny_config();
let mut model = tiny_model(&cfg);
let prompts: [&[u32]; 3] = [&[1, 2, 3], &[4, 5], &[7, 7, 2, 5, 6]];
let steps: [&[u32]; 3] = [&[11, 12, 13, 14], &[9, 8, 7, 6], &[21, 22, 23, 24]];
let n_steps = 4;
// Sequential reference: each sequence decoded alone.
let mut expected: Vec<Vec<Vec<f32>>> = Vec::new(); // [row][step]
for (prompt, toks) in prompts.iter().zip(steps.iter()) {
model.clear_kv_cache();
forward_tokens(&mut model, prompt, 0);
let mut per_step = Vec::new();
for (t, tok) in toks.iter().enumerate() {
per_step.push(forward_tokens(&mut model, &[*tok], prompt.len() + t));
}
expected.push(per_step);
}
// Batched: prefill each sequence alone, snapshot, assemble.
let mut snaps = Vec::new();
for prompt in prompts.iter() {
model.clear_kv_cache();
forward_tokens(&mut model, prompt, 0);
snaps.push(model.snapshot_kv_cache().expect("snapshot"));
}
let seqs: Vec<(&super::KvCacheSnapshot, usize)> = snaps
.iter()
.zip(prompts.iter())
.map(|(s, p)| (s, p.len()))
.collect();
let batch = super::assemble_batch(&seqs).expect("assemble");
assert_eq!(batch.padded_len, 5);
assert_eq!(batch.prefix_lens, vec![3, 2, 5]);
model
.restore_kv_cache(&batch.snapshot)
.expect("install batched state");
for t in 0..n_steps {
let toks: Vec<u32> = steps.iter().map(|s| s[t]).collect();
let input = Tensor::from_vec(toks, (3, 1), &Device::Cpu).unwrap();
let positions: Vec<usize> = prompts.iter().map(|p| p.len() + t).collect();
let total_len = batch.padded_len + t + 1;
let mask = model
.batch_decode_mask(&batch.prefix_lens, batch.padded_len, total_len)
.expect("mask");
assert!(mask.is_some(), "ragged batch must be masked");
let h = model
.forward_batch_decode(&input, &positions, mask.as_ref())
.expect("batched step");
assert_eq!(h.dims()[0], 3);
for row in 0..3 {
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
let diff = max_abs_diff(&expected[row][t], &got);
assert!(diff < 1e-4, "row {row} step {t} diverged: {diff}");
}
}
}
/// Uniform-length batch: no padding → `batch_decode_mask` returns
/// `None`, and unmasked lockstep decode still matches sequential.
#[test]
fn batched_decode_uniform_lengths_needs_no_mask() {
use candle_core::IndexOp;
let cfg = tiny_config();
let mut model = tiny_model(&cfg);
let prompts: [&[u32]; 2] = [&[1, 2, 3], &[6, 5, 4]];
let toks = [13u32, 17];
let mut expected = Vec::new();
for (prompt, tok) in prompts.iter().zip(toks.iter()) {
model.clear_kv_cache();
forward_tokens(&mut model, prompt, 0);
expected.push(forward_tokens(&mut model, &[*tok], prompt.len()));
}
let mut snaps = Vec::new();
for prompt in prompts.iter() {
model.clear_kv_cache();
forward_tokens(&mut model, prompt, 0);
snaps.push(model.snapshot_kv_cache().expect("snapshot"));
}
let seqs: Vec<(&super::KvCacheSnapshot, usize)> = snaps
.iter()
.zip(prompts.iter())
.map(|(s, p)| (s, p.len()))
.collect();
let batch = super::assemble_batch(&seqs).expect("assemble");
let mask = model
.batch_decode_mask(&batch.prefix_lens, batch.padded_len, batch.padded_len + 1)
.expect("mask");
assert!(mask.is_none(), "uniform lengths must not build a mask");
model.restore_kv_cache(&batch.snapshot).expect("install");
let input = Tensor::from_vec(toks.to_vec(), (2, 1), &Device::Cpu).unwrap();
let h = model
.forward_batch_decode(&input, &[3, 3], None)
.expect("step");
for row in 0..2 {
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
let diff = max_abs_diff(&expected[row], &got);
assert!(diff < 1e-4, "row {row} diverged: {diff}");
}
}
/// Round-trip for batch membership changes (#98): decode two steps
/// in a lockstep batch, extract each row back to a contiguous
/// single-sequence snapshot, restore it alone, and continue
/// decoding at B=1 — the continuation must match a pure-sequential
/// run of the same tokens. This is the primitive a join/leave
/// rebatch is built from.
#[test]
fn extract_row_continues_like_sequential() {
use candle_core::IndexOp;
let cfg = tiny_config();
let mut model = tiny_model(&cfg);
let prompts: [&[u32]; 2] = [&[1, 2, 3], &[4, 5]];
let toks: [&[u32]; 2] = [&[11, 12, 13, 14], &[9, 8, 7, 6]];
let batched_steps = 2; // decoded in the batch
let solo_steps = 2; // decoded after extraction, B=1
// Pure-sequential reference over all four steps.
let mut expected: Vec<Vec<Vec<f32>>> = Vec::new();
for (prompt, t) in prompts.iter().zip(toks.iter()) {
model.clear_kv_cache();
forward_tokens(&mut model, prompt, 0);
let mut per_step = Vec::new();
for (i, tok) in t.iter().enumerate() {
per_step.push(forward_tokens(&mut model, &[*tok], prompt.len() + i));
}
expected.push(per_step);
}
// Prefill + assemble + two lockstep batched steps.
let mut snaps = Vec::new();
for prompt in prompts.iter() {
model.clear_kv_cache();
forward_tokens(&mut model, prompt, 0);
snaps.push(model.snapshot_kv_cache().expect("snapshot"));
}
let seqs: Vec<(&super::KvCacheSnapshot, usize)> = snaps
.iter()
.zip(prompts.iter())
.map(|(s, p)| (s, p.len()))
.collect();
let batch = super::assemble_batch(&seqs).expect("assemble");
model.restore_kv_cache(&batch.snapshot).expect("install");
for t in 0..batched_steps {
let step_toks: Vec<u32> = toks.iter().map(|s| s[t]).collect();
let input = Tensor::from_vec(step_toks, (2, 1), &Device::Cpu).unwrap();
let positions: Vec<usize> = prompts.iter().map(|p| p.len() + t).collect();
let mask = model
.batch_decode_mask(
&batch.prefix_lens,
batch.padded_len,
batch.padded_len + t + 1,
)
.expect("mask");
let h = model
.forward_batch_decode(&input, &positions, mask.as_ref())
.expect("batched step");
for row in 0..2 {
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
let diff = max_abs_diff(&expected[row][t], &got);
assert!(diff < 1e-4, "batched row {row} step {t}: {diff}");
}
}
// Extract each row from the live batched state, restore it
// alone, and continue at B=1.
let live = model.snapshot_kv_cache().expect("snapshot live batch");
for row in 0..2 {
let solo = super::extract_row(
&live,
row,
prompts[row].len(),
batch.padded_len,
batched_steps,
)
.expect("extract row");
model.restore_kv_cache(&solo).expect("restore solo");
for i in 0..solo_steps {
let t = batched_steps + i;
let got = forward_tokens(&mut model, &[toks[row][t]], prompts[row].len() + t);
let diff = max_abs_diff(&expected[row][t], &got);
assert!(diff < 1e-4, "solo row {row} step {t}: {diff}");
}
}
}
/// Mask geometry: `-inf` exactly on `[prefix_len, padded_len)` per
/// row, zero elsewhere (including the decode columns past
/// `padded_len`).
#[test]
fn batch_decode_mask_covers_only_padding_gap() {
let model = tiny_model(&tiny_config());
let m = model
.batch_decode_mask(&[3, 5], 5, 7)
.unwrap()
.expect("ragged → mask");
assert_eq!(m.dims(), &[2, 1, 1, 7]);
let flat: Vec<f32> = m.flatten_all().unwrap().to_vec1().unwrap();
let (row0, row1) = flat.split_at(7);
for (j, &v) in row0.iter().enumerate() {
if (3..5).contains(&j) {
assert_eq!(v, f32::NEG_INFINITY, "row0 col {j} must be masked");
} else {
assert_eq!(v, 0.0, "row0 col {j} must be open");
}
}
assert!(row1.iter().all(|&v| v == 0.0), "unpadded row must be open");
}
/// Restoring must fully replace the live state, not blend with it
/// — a divergent continuation after restore equals the same
/// continuation after a fresh prefill of the prefix.

View File

@@ -23,11 +23,11 @@ use candle_transformers::models::qwen3_moe as qwen3_moe_dense;
use cortex_core::harness::{Harness, HarnessHealth, ModelInfo, ModelSpec};
use cortex_core::openai::{
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse,
ChatMessage, MessageContent, Usage,
ChatMessage, CompletionTokensDetails, MessageContent, Usage,
};
use crate::wire::{
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
detect_reasoning_token_pair, detect_tool_call_token_pair, openai_chat as wire_chat,
};
use std::collections::HashMap;
@@ -320,7 +320,7 @@ pub struct LoadedModel {
/// error so an operator knows to unload+reload to recover. See
/// the 2026-05-26 beast incident where a 14k-token prefill OOM
/// silently turned every subsequent request into a stuck wait.
pub poisoned: AtomicBool,
pub poisoned: Arc<AtomicBool>,
/// Handle to the per-device CUDA worker thread for this model's
/// device. `None` for CPU loads (no context to own). VRAM queries
/// and — for CUDA loads — forward / kv-cache / drop ops route
@@ -344,7 +344,7 @@ pub struct LoadedModel {
/// shape-mismatch failure mid-prefill. Mirrors TpLoadedModel.pool
/// for the TP path (which already had this invariant by accident
/// because the pool lock covered the same window).
pub inference_lock: tokio::sync::Mutex<()>,
pub inference_lock: Arc<tokio::sync::Mutex<()>>,
/// Bounded admission scheduler (#53). Gated *before* `inference_lock`
/// so a busy model refuses overflow fast instead of growing an
/// unbounded, untimed queue of lock waiters.
@@ -401,7 +401,7 @@ pub struct LoadedModel {
/// the pre-#11 behaviour. Dropped with the model, so unload and
/// auto-recovery invalidate every entry for free (the worker-side
/// snapshots go with `Job::DropArch`).
pub prefix_cache: Option<ModelPrefixCache>,
pub prefix_cache: Option<Arc<ModelPrefixCache>>,
/// Context-limit physics (#67), captured at load. `None` for arches
/// whose KV layout we don't yet introspect (GGUF/CPU/non-qwen3_5) —
/// those fall back to the static prompt cap with no advertised limit.
@@ -412,7 +412,7 @@ pub struct LoadedModel {
/// at the end of each streaming request's prefill phase. Feeds the
/// throughput ceiling in the derived limit; falls back to the
/// configured bootstrap estimate before the first sample.
pub prefill_rate: super::context_limit::PrefillRateEma,
pub prefill_rate: Arc<super::context_limit::PrefillRateEma>,
/// Last derived input-token cap (#67), refreshed each time
/// `derived_limit` runs (i.e. on every `/models` poll). The
/// request-path enforcement reads this — `0` means "not derived yet"
@@ -425,6 +425,14 @@ pub struct LoadedModel {
/// cortex's health poller into marking the node unhealthy. Refreshed off
/// the request path: seeded at load, then by a background task.
pub last_free_mb: AtomicU64,
/// Lockstep batched decode engine (#98). `Some` when the operator
/// raised `[admission] max_in_flight` above 1 on a snapshot-capable
/// worker-path model (and `NEURON_BATCHING` isn't 0). Text chat
/// streams route through it instead of taking `inference_lock` per
/// request; the engine holds the lock while it has active slots, so
/// vision and non-streaming requests still serialize safely against
/// the batch.
pub engine: Option<super::engine::EngineHandle>,
}
impl LoadedModel {
@@ -679,6 +687,41 @@ impl ModelArch {
}
}
/// One lockstep batched decode step (#98): `(B, 1)` input, per-row
/// positions, optional padding mask. Returns `(B, 1, vocab)` — the
/// caller extracts one logits row per batch row (no
/// `squeeze_to_vocab`, which would collapse the batch dim). Only
/// the qwen3_5 arch batches; the engine only forms batches where
/// [`Self::supports_kv_snapshot`] holds, so other archs erroring
/// here is defence in depth.
pub fn forward_batch_decode(
&mut self,
input: &Tensor,
positions: &[usize],
attn_mask: Option<&Tensor>,
) -> Result<Tensor> {
match self {
ModelArch::Qwen3_5Dense(m) => Ok(m.forward_batch_decode(input, positions, attn_mask)?),
_ => anyhow::bail!("forward_batch_decode: architecture has no batched-decode support"),
}
}
/// Padding mask for a batched decode step — see
/// `Qwen3_5Model::batch_decode_mask`.
pub fn batch_decode_mask(
&self,
prefix_lens: &[usize],
padded_len: usize,
total_len: usize,
) -> Result<Option<Tensor>> {
match self {
ModelArch::Qwen3_5Dense(m) => {
Ok(m.batch_decode_mask(prefix_lens, padded_len, total_len)?)
}
_ => anyhow::bail!("batch_decode_mask: architecture has no batched-decode support"),
}
}
/// Forward step that splices vision-tower output at
/// `<|image_pad|>` token positions. Stage B2.
///
@@ -784,6 +827,48 @@ impl ModelArch {
}
}
/// Split a non-streaming completion's generated tokens into the
/// visible answer and the leading reasoning span.
///
/// Reasoning models (Qwen3 `<think>`, DeepSeek-R1, …) emit their
/// chain-of-thought *before* the answer, and the chat template injects
/// the **opening** marker into the prompt — so the generated tokens look
/// like `…reasoning… </think> …answer…` with no opening marker present
/// in the output. The streaming path drops reasoning as
/// [`InferenceEvent::ReasoningDelta`]; the non-streaming path has to do
/// the equivalent post-hoc or the chain-of-thought leaks into the
/// assistant `content` (which broke agent-zero v2.0, whose parser
/// expected the bare JSON answer, not a `<think>` preamble).
///
/// Returns `(content_ids, reasoning_token_count)`. Strategy: if the model
/// declares a reasoning marker pair and its **close** token appears in
/// `generated_ids`, everything up to and including the last close token is
/// reasoning and only the tail is the answer. When no close token was
/// generated, `prompt_opened` decides (#112): the chat template may have
/// force-opened the think block inside the generation prompt
/// (Qwen3-Next-80B-A3B-Thinking ends its prompt with
/// `<|im_start|>assistant\n<think>\n`), in which case a generation
/// truncated mid-reasoning is ALL reasoning and the visible answer is
/// empty. Otherwise (non-reasoning model, thinking disabled) the tokens
/// are returned unchanged. Splitting on the token id — not a decoded
/// `</think>` string — keeps this robust against tokenizer byte-fallback
/// and special-token handling.
fn split_off_reasoning<'a>(
generated_ids: &'a [u32],
reasoning: Option<&ReasoningTokenPair>,
prompt_opened: bool,
) -> (&'a [u32], u64) {
if let Some(pair) = reasoning {
if let Some(idx) = generated_ids.iter().rposition(|&t| t == pair.close_id) {
return (&generated_ids[idx + 1..], (idx + 1) as u64);
}
if prompt_opened {
return (&[], generated_ids.len() as u64);
}
}
(generated_ids, 0)
}
/// Squeeze any leading singleton dims off the logits tensor so the
/// caller gets a rank-1 `[vocab_size]` slice ready for sampling. Bails
/// on a non-singleton leading dim (would mean a batched forward, which
@@ -861,7 +946,8 @@ const REPEAT_LAST_N: usize = 64;
/// value. New entries land alongside a new `ModelArch` variant + a
/// dispatch branch in `load_arch_dense` (plus, for TP, a parallel
/// pattern in `tp_qwen3.rs`).
const DENSE_SUPPORTED_MODEL_TYPES: &[&str] = &["llama", "qwen3", "qwen3_5", "qwen3_moe"];
const DENSE_SUPPORTED_MODEL_TYPES: &[&str] =
&["llama", "qwen3", "qwen3_5", "qwen3_moe", "qwen3_next"];
/// Pre-flight check the operator's `config.json` against the set of
/// architectures the dense path actually knows how to build. Surfaces
@@ -916,7 +1002,7 @@ pub(crate) fn check_dense_config_supported(config_json: &str, model_id: &str) ->
/// families than the TP path because each TP-aware module is a real
/// chunk of work (`tp_qwen3.rs` is the only one shipped today).
#[cfg(feature = "cuda")]
const TP_SUPPORTED_MODEL_TYPES: &[&str] = &["qwen3", "qwen3_5"];
const TP_SUPPORTED_MODEL_TYPES: &[&str] = &["qwen3", "qwen3_5", "qwen3_next"];
/// TP-side counterpart to `check_dense_config_supported`. Gates the
/// `load_tp` path on a narrower architecture set: even though the
@@ -987,7 +1073,7 @@ fn resolve_hf_cache(explicit: Option<PathBuf>) -> Option<PathBuf> {
/// paid at most once per poisoned model.
#[derive(Debug)]
#[allow(dead_code)]
struct LogitsHealth {
pub(crate) struct LogitsHealth {
len: usize,
nan: usize,
pos_inf: usize,
@@ -1028,7 +1114,7 @@ fn logits_health(t: &Tensor) -> LogitsHealth {
/// the async caller has the values in hand. Avoids the round-trip of
/// rebuilding a Tensor just to call to_vec1 again.
#[allow(dead_code)]
fn logits_health_slice(values: &[f32]) -> LogitsHealth {
pub(crate) fn logits_health_slice(values: &[f32]) -> LogitsHealth {
let mut nan = 0usize;
let mut pos_inf = 0usize;
let mut neg_inf = 0usize;
@@ -1088,7 +1174,7 @@ fn logits_health_slice(values: &[f32]) -> LogitsHealth {
/// the TP streaming task). Matching against the full chain lets the
/// classification survive `.context("…")` and `format!("…: {e}")`
/// wrappers in the call sites.
fn is_device_fault(chain_text: &str) -> bool {
pub(crate) fn is_device_fault(chain_text: &str) -> bool {
let chain = chain_text.to_lowercase();
// Non-device patterns: shape errors are pre-kernel and don't touch
// GPU state; NaN-logits failures happen on the CPU side after the
@@ -1462,7 +1548,7 @@ async fn acquire_pool_lock<'a>(
/// Apply the repetition penalty (if any) to the prediction logits and
/// then sample. Centralises the prefill / generation-loop call sites
/// so they share identical sampling behaviour.
fn sample_with_penalty(
pub(crate) fn sample_with_penalty(
logits: &Tensor,
history: &[u32],
logits_processor: &mut LogitsProcessor,
@@ -1521,8 +1607,7 @@ fn chunked_prefill_local(
/// chunk's last position. Tensors never escape the worker.
/// `start_offset` skips a restored cached prefix, as in
/// [`chunked_prefill_local`].
#[cfg(feature = "cuda")]
async fn chunked_prefill_via_worker(
pub(crate) async fn chunked_prefill_via_worker(
worker: &super::device_worker::DeviceWorkerHandle,
handle: super::device_worker::ArchHandle,
prompt_tokens: &[u32],
@@ -1974,9 +2059,14 @@ impl CandleHarness {
);
// bf16 is the canonical distribution dtype for Qwen3 /
// Llama 3 / Qwen3 MoE. CUDA on Ada+ has hardware bf16;
// Ampere has it too. CPU emulates.
let dtype = DType::BF16;
// Llama 3 / Qwen3 MoE; CUDA on Ampere+ has hardware bf16.
// candle's CPU backend has no bf16 matmul, so the CPU
// fallback upcasts to f32 at load.
let dtype = if device_for_load.is_cuda() {
DType::BF16
} else {
DType::F32
};
// SAFETY: VarBuilder::from_mmaped_safetensors mmaps the files;
// mutation by another process while we hold the mapping is
// UB. We trust the HF cache is immutable-by-design.
@@ -2021,14 +2111,16 @@ impl CandleHarness {
device: device_for_load,
})))
}
"qwen3_5" => {
"qwen3_5" | "qwen3_next" => {
// Qwen3-Next needs a ShardedVarBuilder because its
// load functions use the sharded backend (so they
// can be reused unchanged by the future TP variant).
// With world_size=1 the backend falls through to
// the unsharded path, so there is no per-load cost.
let cfg: super::arch::qwen3_5::Config = serde_json::from_str(&cfg_text)
.context("parse Qwen3-Next (qwen3_5) config.json")?;
// `from_config_json` normalises the flat qwen3_next
// layout (#92) into the nested qwen3_5 shape.
let cfg = super::arch::qwen3_5::Config::from_config_json(&cfg_text)
.context("parse Qwen3-Next (qwen3_5/qwen3_next) config.json")?;
let sharded_vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
&safetensors_paths,
@@ -2269,6 +2361,12 @@ impl CandleHarness {
};
let prompt_len = prompt_tokens.len();
// Whether the chat template left the think block open in the
// generation prompt (#112) — decides the truncated-mid-think
// case in `split_off_reasoning`. Computed before the
// inference closure takes ownership of `prompt_tokens`.
let prompt_opened_reasoning =
prompt_opens_reasoning(&prompt_tokens, loaded.reasoning_tokens.as_ref());
let temperature = request.temperature.unwrap_or(0.7);
let top_p = request.top_p;
let max_new = request.max_tokens.unwrap_or(8192) as usize;
@@ -2340,7 +2438,7 @@ impl CandleHarness {
worker,
handle,
&prompt_tokens,
loaded.prefix_cache.as_ref(),
loaded.prefix_cache.as_deref(),
loaded.tokenizer.token_to_id("<|im_start|>"),
max_new,
temperature,
@@ -2392,7 +2490,7 @@ impl CandleHarness {
&mut guard,
&device,
&prompt_tokens,
loaded_for_cache.prefix_cache.as_ref(),
loaded_for_cache.prefix_cache.as_deref(),
im_start_id,
max_new,
temperature,
@@ -2454,21 +2552,40 @@ impl CandleHarness {
)));
};
// Strip the leading `<think>` span so the chain-of-thought
// doesn't leak into `content` (the streaming path drops it
// as ReasoningDelta; this is the non-streaming equivalent).
let (content_ids, reasoning_tokens) =
split_off_reasoning(
&generated_ids,
loaded.reasoning_tokens.as_ref(),
prompt_opened_reasoning,
);
let completion_text = loaded
.tokenizer
.decode(&generated_ids, true)
.decode(content_ids, true)
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
// The first answer token after `</think>` is usually a
// newline pair; trim it so `content` starts at the answer.
let completion_text = if reasoning_tokens > 0 {
completion_text.trim_start().to_string()
} else {
completion_text
};
let usage = Usage {
prompt_tokens: prompt_len as u64,
completion_tokens: generated_ids.len() as u64,
total_tokens: (prompt_len + generated_ids.len()) as u64,
// Reasoning accounting is streaming-only: the
// non-streaming path doesn't track `in_reasoning`
// (would require post-hoc <think> span parsing).
// Deferred — see #64.
completion_tokens_details: None,
// `reasoning_tokens` is an additive sub-count of
// `completion_tokens` (which still counts every
// generated token, reasoning included).
completion_tokens_details: (reasoning_tokens > 0)
.then_some(CompletionTokensDetails { reasoning_tokens }),
prompt_tokens_details: None,
// Non-streaming path: prefill/decode split is only
// surfaced on the streaming Finish event today (#85).
helexa_timing: None,
};
tracing::info!(
@@ -2783,7 +2900,29 @@ impl CandleHarness {
.map_err(InferenceError::from)?;
let tool_schemas = build_tool_schemas(&request);
if let (Some(worker), Some(handle)) = (loaded.worker.clone(), loaded.arch_handle) {
// Batched decode engine (#98): text streams multiplex through
// the per-model engine instead of serializing on
// inference_lock. Vision requests keep the direct path (they
// can't batch — M-RoPE positions) and serialize against the
// engine via the lock it holds while active.
if vision_route.is_none() && loaded.engine.is_some() {
let engine = loaded.engine.clone().expect("checked is_some");
engine
.submit(super::engine::EngineRequest {
prompt_tokens,
max_new,
temperature,
top_p,
seed,
eos_id,
tool_schemas,
tx,
admit,
span: span_for_task,
})
.await
.map_err(InferenceError::Other)?;
} else if let (Some(worker), Some(handle)) = (loaded.worker.clone(), loaded.arch_handle) {
#[cfg(feature = "cuda")]
{
let prompt_tokens = prompt_tokens.clone();
@@ -2800,7 +2939,7 @@ impl CandleHarness {
tokenizer,
prompt_tokens,
vision_route,
loaded_for_task.prefix_cache.as_ref(),
loaded_for_task.prefix_cache.as_deref(),
&loaded_for_task.prefill_rate,
max_new,
temperature,
@@ -2865,7 +3004,7 @@ impl CandleHarness {
&device,
&tokenizer,
&prompt_tokens,
loaded_for_task.prefix_cache.as_ref(),
loaded_for_task.prefix_cache.as_deref(),
max_new,
temperature,
top_p,
@@ -3309,6 +3448,41 @@ impl Harness for CandleHarness {
);
}
let poisoned = Arc::new(AtomicBool::new(false));
let inference_lock = Arc::new(tokio::sync::Mutex::new(()));
let prefix_cache = self.new_prefix_cache(snapshot_capable).map(Arc::new);
let prefill_rate = Arc::new(super::context_limit::PrefillRateEma::new());
// Batched decode engine (#98): spawned when the operator raised
// max_in_flight above 1 on a snapshot-capable worker-path model.
let engine = match (&worker, arch_handle) {
(Some(w), Some(h))
if snapshot_capable
&& self.admission_cfg.max_in_flight > 1
&& super::engine::batching_enabled() =>
{
tracing::info!(
model = %spec.model_id,
max_slots = self.admission_cfg.max_in_flight,
"batched decode engine enabled (#98)"
);
Some(super::engine::EngineHandle::spawn(
super::engine::EngineConfig {
model_id: spec.model_id.clone(),
worker: Arc::clone(w),
handle: h,
tokenizer: tokenizer.clone(),
prefix_cache: prefix_cache.clone(),
prefill_rate: Arc::clone(&prefill_rate),
reasoning_tokens: reasoning_tokens.clone(),
tool_call_tokens: tool_call_tokens.clone(),
poisoned: Arc::clone(&poisoned),
inference_lock: Arc::clone(&inference_lock),
max_slots: self.admission_cfg.max_in_flight,
},
))
}
_ => None,
};
let loaded = Arc::new(LoadedModel {
model_id: spec.model_id.clone(),
arch: arch_local,
@@ -3316,10 +3490,10 @@ impl Harness for CandleHarness {
device,
quant: spec.quant.clone(),
devices,
poisoned: AtomicBool::new(false),
poisoned,
worker,
arch_handle,
inference_lock: tokio::sync::Mutex::new(()),
inference_lock,
admission: super::admission::AdmissionController::new(&self.admission_cfg),
reasoning_tokens,
tool_call_tokens,
@@ -3328,11 +3502,12 @@ impl Harness for CandleHarness {
image_token_id: vision_meta.image_token_id,
image_grid_factor: vision_meta.image_grid_factor,
spec: spec.clone(),
prefix_cache: self.new_prefix_cache(snapshot_capable),
prefix_cache,
context_profile,
prefill_rate: super::context_limit::PrefillRateEma::new(),
prefill_rate,
derived_input_cap: AtomicUsize::new(0),
last_free_mb: AtomicU64::new(0),
engine,
});
if loaded.prefix_cache.is_some() {
tracing::info!(
@@ -3957,6 +4132,11 @@ impl CandleHarness {
// call — promotes the terminal finish_reason to ToolCalls
// so Anthropic clients see stop_reason: tool_use.
let mut emitted_tool_call = false;
// Prefill/decode split timers (#85). Declared outside 'work
// so the terminal Finish — built after the block exits — can
// read them; populated at the prefill→decode boundary inside.
let mut prefill_ms_measured: u32 = 0;
let mut decode_start: Option<std::time::Instant> = None;
'work: {
// Prefix-cache decision (#11): vision requests
@@ -4084,14 +4264,16 @@ impl CandleHarness {
break 'work;
}
};
let prefill_elapsed = prefill_start.elapsed();
prefill_ms_measured = prefill_elapsed.as_millis() as u32;
tp_for_task
.prefill_rate
.record(prompt_len, prefill_start.elapsed());
.record(prompt_len, prefill_elapsed);
let (post_prefill_vram_free_mb, _) = tp_for_task.query_vram().await;
tracing::info!(
model = %model_id,
prompt_len,
prefill_ms = prefill_start.elapsed().as_millis(),
prefill_ms = prefill_elapsed.as_millis(),
vram_free_mb = post_prefill_vram_free_mb,
"TP chat_completion (stream): prefill complete"
);
@@ -4116,6 +4298,8 @@ impl CandleHarness {
break 'work;
}
};
// Decode-phase timer for the Finish prefill/decode split (#85).
decode_start = Some(std::time::Instant::now());
if Some(next_token) == eos_id {
finish_reason = FinishReason::Stop;
@@ -4393,6 +4577,13 @@ impl CandleHarness {
prompt_tokens: prompt_len as u32,
completion_tokens: all_tokens.len() as u32,
reasoning_tokens: reasoning_token_count,
timing: Some(FinishTiming {
prefill_ms: prefill_ms_measured,
decode_ms: decode_start
.map(|d| d.elapsed().as_millis() as u32)
.unwrap_or(0),
prefill_tokens: prompt_len as u32,
}),
})
.await;
}
@@ -4722,19 +4913,34 @@ async fn chat_completion_tp_inner(
}
drop(pool);
// Strip the leading `<think>` span (see `split_off_reasoning` and the
// single-GPU path) so the chain-of-thought doesn't leak into `content`.
let (content_ids, reasoning_tokens) = split_off_reasoning(
&generated,
tp.reasoning_tokens.as_ref(),
prompt_opens_reasoning(&prompt_tokens, tp.reasoning_tokens.as_ref()),
);
let completion_text = tp
.tokenizer
.decode(&generated, true)
.decode(content_ids, true)
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
let completion_text = if reasoning_tokens > 0 {
completion_text.trim_start().to_string()
} else {
completion_text
};
let usage = Usage {
prompt_tokens: prompt_len as u64,
completion_tokens: generated.len() as u64,
total_tokens: (prompt_len + generated.len()) as u64,
// Reasoning accounting is streaming-only (non-streaming TP path
// doesn't track `in_reasoning`). Deferred — see #64.
completion_tokens_details: None,
// `reasoning_tokens` is an additive sub-count of `completion_tokens`.
completion_tokens_details: (reasoning_tokens > 0)
.then_some(CompletionTokensDetails { reasoning_tokens }),
prompt_tokens_details: None,
// Non-streaming path: prefill/decode split is only surfaced on
// the streaming Finish event today (#85).
helexa_timing: None,
};
tracing::info!(
@@ -4776,8 +4982,11 @@ async fn chat_completion_tp_inner(
/// stays out of this function — the wire projector in
/// [`crate::wire::openai_chat`] stamps it onto every chunk
/// downstream.
#[cfg(feature = "cuda")]
async fn emit_delta(delta: &str, tx: &mpsc::Sender<InferenceEvent>, in_reasoning: bool) -> bool {
pub(crate) async fn emit_delta(
delta: &str,
tx: &mpsc::Sender<InferenceEvent>,
in_reasoning: bool,
) -> bool {
if delta.is_empty() {
return true;
}
@@ -4813,7 +5022,7 @@ fn emit_delta_blocking(delta: &str, tx: &mpsc::Sender<InferenceEvent>, in_reason
///
/// `pair = None` short-circuits to `false` (no reasoning markers
/// configured for this model → pass-through).
fn handle_reasoning_marker(
pub(crate) fn handle_reasoning_marker(
next_token: u32,
pair: Option<&ReasoningTokenPair>,
in_reasoning: &mut bool,
@@ -4838,7 +5047,10 @@ fn handle_reasoning_marker(
/// visible text. Replaying the prompt's reasoning markers and starting
/// the loop in whatever state the prompt ends in fixes that without
/// disabling thinking. `None` pair (non-reasoning model) → false.
fn prompt_opens_reasoning(prompt_tokens: &[u32], pair: Option<&ReasoningTokenPair>) -> bool {
pub(crate) fn prompt_opens_reasoning(
prompt_tokens: &[u32],
pair: Option<&ReasoningTokenPair>,
) -> bool {
let Some(pair) = pair else { return false };
let mut open = false;
for &t in prompt_tokens {
@@ -4853,7 +5065,7 @@ fn prompt_opens_reasoning(prompt_tokens: &[u32], pair: Option<&ReasoningTokenPai
/// Outcome of checking a sampled token against the model's
/// tool-call markers.
enum ToolCallMarker {
pub(crate) enum ToolCallMarker {
/// Not a tool-call marker — caller proceeds with the normal
/// detokenize-and-emit path.
None,
@@ -4870,7 +5082,7 @@ enum ToolCallMarker {
Exit { buffer: String },
}
fn handle_tool_call_marker(
pub(crate) fn handle_tool_call_marker(
next_token: u32,
pair: Option<&ToolCallTokenPair>,
in_tool_call: &mut bool,
@@ -4908,7 +5120,8 @@ fn handle_tool_call_marker(
/// the Qwen-XML tool-call parser can coerce each `<parameter>` string
/// to its declared JSON type. An empty map (no tools, or untyped
/// params) makes the parser fall back to value-sniffing.
type ToolSchemas = std::collections::HashMap<String, std::collections::HashMap<String, String>>;
pub(crate) type ToolSchemas =
std::collections::HashMap<String, std::collections::HashMap<String, String>>;
/// Extract [`ToolSchemas`] from a request's `tools` (OpenAI shape:
/// `{type:"function", function:{name, parameters:{properties:{p:{type}}}}}`).
@@ -4952,7 +5165,7 @@ fn build_tool_schemas(request: &ChatCompletionRequest) -> ToolSchemas {
///
/// Returns `None` only when neither form yields a usable name, so the
/// caller can re-emit the raw block as text instead of swallowing it.
fn parse_tool_call_body(
pub(crate) fn parse_tool_call_body(
body: &str,
index: usize,
schemas: &ToolSchemas,
@@ -5559,7 +5772,10 @@ async fn run_inference_with_images_via_worker(
///
/// Returns `None` (run the request without storing a snapshot) when
/// the marker id is unknown or the prompt has no usable boundary.
fn stable_snapshot_cut(prompt_tokens: &[u32], im_start_id: Option<u32>) -> Option<usize> {
pub(crate) fn stable_snapshot_cut(
prompt_tokens: &[u32],
im_start_id: Option<u32>,
) -> Option<usize> {
let id = im_start_id?;
let cut = prompt_tokens.iter().rposition(|&t| t == id)? + 1;
(cut < prompt_tokens.len()).then_some(cut)
@@ -5583,8 +5799,7 @@ fn lock_prefix_cache(
/// prompt tokens already in the cache after this call — prefill
/// resumes at that offset. A failed restore drops the entry and falls
/// back to clear + full prefill.
#[cfg(feature = "cuda")]
async fn restore_or_clear_via_worker(
pub(crate) async fn restore_or_clear_via_worker(
worker: &super::device_worker::DeviceWorkerHandle,
handle: super::device_worker::ArchHandle,
prefix_cache: Option<&ModelPrefixCache>,
@@ -5633,8 +5848,7 @@ async fn restore_or_clear_via_worker(
/// `prompt_tokens`) and register it. Eviction decided by the
/// registry; evicted worker snapshots are dropped here. Best-effort —
/// a failed snapshot only costs the next request its prefill saving.
#[cfg(feature = "cuda")]
async fn store_prefix_snapshot_via_worker(
pub(crate) async fn store_prefix_snapshot_via_worker(
worker: &super::device_worker::DeviceWorkerHandle,
handle: super::device_worker::ArchHandle,
prefix_cache: Option<&ModelPrefixCache>,
@@ -6064,7 +6278,8 @@ async fn stream_inference_via_worker(
}
}
};
prefill_rate.record(prefill_prompt_len, prefill_start.elapsed());
let prefill_elapsed = prefill_start.elapsed();
prefill_rate.record(prefill_prompt_len, prefill_elapsed);
let logits = Tensor::new(logits_vec.as_slice(), &Device::Cpu)?;
let mut next_token = match sample_with_penalty(&logits, &all_tokens, &mut logits_processor) {
Ok(t) => t,
@@ -6077,6 +6292,8 @@ async fn stream_inference_via_worker(
return Err(e);
}
};
// Decode-phase timer for the Finish prefill/decode split (#85).
let decode_start = std::time::Instant::now();
// Per-token routing. `tokenizers::DecodeStream` carries five
// generic parameters (`M, N, PT, PP, D`) which makes naming
@@ -6221,6 +6438,11 @@ async fn stream_inference_via_worker(
prompt_tokens: prompt_tokens.len() as u32,
completion_tokens: all_tokens.len() as u32,
reasoning_tokens: reasoning_token_count,
timing: Some(FinishTiming {
prefill_ms: prefill_elapsed.as_millis() as u32,
decode_ms: decode_start.elapsed().as_millis() as u32,
prefill_tokens: prefill_prompt_len as u32,
}),
})
.await;
@@ -6355,6 +6577,10 @@ fn run_inference_streaming(
// See `inference_tp_stream`: promotes finish_reason to ToolCalls.
let mut emitted_tool_call = false;
// Time prefill and decode separately so the Finish event can carry
// a server-measured prefill/decode split (#85) instead of leaving
// the client to infer both from SSE chunk arrival.
let prefill_start = std::time::Instant::now();
let reused = restore_or_clear_local(arch, prefix_cache, prompt_tokens)?;
// Two-stage prefill around the retokenization-stable snapshot
// boundary — see `run_inference_via_worker`.
@@ -6373,6 +6599,8 @@ fn run_inference_streaming(
None => chunked_prefill_local(arch, device, prompt_tokens, reused)?,
};
let mut next_token = sample_with_penalty(&logits, &all_tokens, &mut logits_processor)?;
let prefill_elapsed = prefill_start.elapsed();
let decode_start = std::time::Instant::now();
// Per-token routing block, used at both the prefill-sample
// tail and the decode loop. Macros are ugly but Rust's
@@ -6481,6 +6709,11 @@ fn run_inference_streaming(
prompt_tokens: prompt_tokens.len() as u32,
completion_tokens: all_tokens.len() as u32,
reasoning_tokens: reasoning_token_count,
timing: Some(FinishTiming {
prefill_ms: prefill_elapsed.as_millis() as u32,
decode_ms: decode_start.elapsed().as_millis() as u32,
prefill_tokens: prompt_tokens.len() as u32,
}),
});
Ok(())
}
@@ -6505,6 +6738,71 @@ mod tests {
const IM_START: u32 = 999;
fn think_pair() -> ReasoningTokenPair {
ReasoningTokenPair {
open_id: 100,
close_id: 200,
open_text: "<think>".into(),
close_text: "</think>".into(),
}
}
#[test]
fn split_off_reasoning_strips_up_to_close_marker() {
// [reasoning_a, reasoning_b, </think>, answer_x, answer_y]
let ids = [10, 11, 200, 42, 43];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()), false);
assert_eq!(content, &[42, 43]);
assert_eq!(reasoning, 3); // two reasoning tokens + the close marker
}
#[test]
fn split_off_reasoning_no_close_marker_returns_all() {
// Thinking disabled / span not opened by the prompt: return as-is.
let ids = [42, 43, 44];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()), false);
assert_eq!(content, &ids);
assert_eq!(reasoning, 0);
}
#[test]
fn split_off_reasoning_no_marker_pair_is_noop() {
let ids = [1, 2, 3];
let (content, reasoning) = split_off_reasoning(&ids, None, true);
assert_eq!(content, &ids);
assert_eq!(reasoning, 0);
}
#[test]
fn split_off_reasoning_close_at_end_yields_empty_content() {
// All reasoning, answer truncated to nothing after the marker.
let ids = [10, 11, 200];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()), false);
assert!(content.is_empty());
assert_eq!(reasoning, 3);
}
#[test]
fn split_off_reasoning_prompt_opened_truncation_is_all_reasoning() {
// Template force-opened the think block (#112:
// Qwen3-Next-80B-A3B-Thinking) and generation hit max_tokens
// before emitting </think> — everything is chain-of-thought.
let ids = [10, 11, 12];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()), true);
assert!(content.is_empty());
assert_eq!(reasoning, 3);
}
#[test]
fn split_off_reasoning_splits_on_last_close_marker() {
// Defensive: if the model emits its own <think></think> pair plus
// the prompt-injected one, split on the LAST close marker.
let ids = [200, 10, 200, 42];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()), true);
assert_eq!(content, &[42]);
assert_eq!(reasoning, 3);
}
#[test]
fn stable_snapshot_cut_lands_after_last_im_start() {
// ChatML shape: [im_start, "system", ..., im_start, "user",
@@ -6580,6 +6878,20 @@ mod tests {
.expect("qwen3_5 should be in the supported set as of Stage 8c scaffold");
}
#[test]
fn check_dense_config_accepts_qwen3_next() {
// The MoE sibling family (Qwen3-Next-80B-A3B, #92) routes into
// the same qwen3_5 arch module via Config::from_config_json.
let cfg = r#"{
"model_type": "qwen3_next",
"architectures": ["Qwen3NextForCausalLM"],
"hidden_size": 2048,
"num_experts": 512
}"#;
check_dense_config_supported(cfg, "Qwen/Qwen3-Next-80B-A3B-Instruct")
.expect("qwen3_next should be in the supported set (#92)");
}
#[test]
fn check_dense_config_rejects_missing_model_type() {
let cfg = r#"{ "vocab_size": 1234 }"#;

View File

@@ -248,6 +248,46 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
let result = forward_logits(&mut state, handle, &tokens, offset);
let _ = reply.send(result);
}
Job::AssembleKvBatch {
handle,
seqs,
reply,
} => {
let result = assemble_kv_batch(&mut state, handle, &seqs);
// The replaced live cache state just freed its tensors.
if result.is_ok() {
trim_device_pool(&state);
}
let _ = reply.send(result);
}
Job::ForwardLogitsBatch {
handle,
tokens,
prefix_lens,
padded_len,
step,
reply,
} => {
let result = forward_logits_batch(
&mut state,
handle,
&tokens,
&prefix_lens,
padded_len,
step,
);
let _ = reply.send(result);
}
Job::ExtractKvRows {
handle,
rows,
padded_len,
steps,
reply,
} => {
let result = extract_kv_rows(&mut state, handle, &rows, padded_len, steps);
let _ = reply.send(result);
}
Job::EncodeImage {
handle,
pixels,
@@ -760,9 +800,14 @@ fn load_dense_inner(
);
// bf16 is the canonical distribution dtype for Qwen3 / Llama 3 /
// Qwen3 MoE. CUDA on Ada+ has hardware bf16; Ampere has it too.
// CPU emulates.
let dtype = DType::BF16;
// Qwen3 MoE; CUDA on Ampere+ has hardware bf16. candle's CPU
// backend has no bf16 matmul, so the CPU fallback (and the CPU
// test worker) upcasts to f32 at load.
let dtype = if device.is_cuda() {
DType::BF16
} else {
DType::F32
};
// SAFETY: VarBuilder::from_mmaped_safetensors mmaps the files;
// mutation by another process while we hold the mapping is UB.
// We trust the HF cache is immutable-by-design.
@@ -804,9 +849,11 @@ fn load_dense_inner(
),
)))
}
"qwen3_5" => {
let cfg: crate::harness::arch::qwen3_5::Config = serde_json::from_str(&cfg_text)
.context("parse Qwen3-Next (qwen3_5) config.json")?;
"qwen3_5" | "qwen3_next" => {
// `from_config_json` normalises the flat qwen3_next layout
// (#92) into the nested qwen3_5 shape.
let cfg = crate::harness::arch::qwen3_5::Config::from_config_json(&cfg_text)
.context("parse Qwen3-Next (qwen3_5/qwen3_next) config.json")?;
let sharded_vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
safetensors_paths,
@@ -873,8 +920,8 @@ fn tp_load_shard_inner(
&cfg, &vb, 0, world_size, comm,
)?)
}
"qwen3_5" => {
let cfg: crate::harness::tp::tp_qwen3_5::Config = serde_json::from_str(config_json)
"qwen3_5" | "qwen3_next" => {
let cfg = crate::harness::tp::tp_qwen3_5::Config::from_config_json(config_json)
.context("parse Qwen3-Next Config JSON for leader load")?;
let quant_dtype = crate::harness::tp::worker::parse_quant_string(quant)?;
TpLeaderModel::Qwen3_5(crate::harness::tp::tp_qwen3_5::TpQwen3_5ForCausalLM::load(
@@ -888,7 +935,8 @@ fn tp_load_shard_inner(
)?)
}
other => anyhow::bail!(
"TP dispatch: unsupported model_type '{other}' on leader (supported: qwen3, qwen3_5)"
"TP dispatch: unsupported model_type '{other}' on leader \
(supported: qwen3, qwen3_5, qwen3_next)"
),
};
@@ -1034,6 +1082,119 @@ fn forward_logits(
Ok(values)
}
/// Assemble stored per-sequence snapshots into a batched cache state
/// and install it as the model's live state (#98). Split-borrows the
/// snapshot map (immutable) and the model slab (mutable) — disjoint
/// fields of the worker state.
fn assemble_kv_batch(
state: &mut DeviceWorkerState,
handle: ArchHandle,
seqs: &[(KvSnapshotId, usize)],
) -> anyhow::Result<usize> {
let DeviceWorkerState {
models,
kv_snapshots,
..
} = state;
let mut pairs = Vec::with_capacity(seqs.len());
for (id, len) in seqs {
let snap = kv_snapshots.get(&(handle, id.0)).ok_or_else(|| {
anyhow::anyhow!(
"AssembleKvBatch: no snapshot {} for handle {}",
id.0,
handle.0
)
})?;
pairs.push((snap, *len));
}
let batch = crate::harness::arch::qwen3_5::snapshot::assemble_batch(&pairs)?;
let arch = models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("AssembleKvBatch: no model for handle {}", handle.0))?;
arch.restore_kv_cache(&batch.snapshot)?;
Ok(batch.padded_len)
}
/// Extract live batched-state rows into stored per-sequence snapshots
/// (#98) — see `Job::ExtractKvRows`. Captures the live state once
/// (shallow attention KV, deep-copied GDN) and slices each requested
/// row out gap-free.
fn extract_kv_rows(
state: &mut DeviceWorkerState,
handle: ArchHandle,
rows: &[(usize, usize)],
padded_len: usize,
steps: usize,
) -> anyhow::Result<Vec<(KvSnapshotId, u64)>> {
let live = state
.models
.get(&handle)
.ok_or_else(|| anyhow::anyhow!("ExtractKvRows: no model for handle {}", handle.0))?
.snapshot_kv_cache()?;
let mut out = Vec::with_capacity(rows.len());
for &(row, prefix_len) in rows {
let snap = crate::harness::arch::qwen3_5::snapshot::extract_row(
&live, row, prefix_len, padded_len, steps,
)?;
let id = KvSnapshotId(state.next_kv_snapshot_id);
state.next_kv_snapshot_id = state.next_kv_snapshot_id.wrapping_add(1);
let bytes = snap.size_bytes();
state.kv_snapshots.insert((handle, id.0), snap);
out.push((id, bytes));
}
tracing::debug!(
handle = handle.0,
rows = rows.len(),
stored = state.kv_snapshots.len(),
"device worker: batch rows extracted"
);
Ok(out)
}
/// One lockstep batched decode step (#98). Builds the `(B, 1)` input
/// on the worker's device, derives per-row positions and the padding
/// mask, and copies each row's logits back to CPU — same
/// "tensors never escape the worker" contract as `forward_logits`.
fn forward_logits_batch(
state: &mut DeviceWorkerState,
handle: ArchHandle,
tokens: &[u32],
prefix_lens: &[usize],
padded_len: usize,
step: usize,
) -> anyhow::Result<Vec<Vec<f32>>> {
use candle_core::{DType, Tensor};
let b = tokens.len();
anyhow::ensure!(b > 0, "ForwardLogitsBatch: empty batch");
anyhow::ensure!(
prefix_lens.len() == b,
"ForwardLogitsBatch: {} prefix_lens for batch of {b}",
prefix_lens.len()
);
let input = Tensor::from_vec(tokens.to_vec(), (b, 1), &state.device)?;
let arch = state
.models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("ForwardLogitsBatch: no model for handle {}", handle.0))?;
let positions: Vec<usize> = prefix_lens.iter().map(|&len| len + step).collect();
let total_len = padded_len + step + 1;
let mask = arch.batch_decode_mask(prefix_lens, padded_len, total_len)?;
let logits = arch.forward_batch_decode(&input, &positions, mask.as_ref())?;
// (B, 1, vocab) → per-row CPU Vec<f32>.
let logits = logits.to_dtype(DType::F32)?;
let (rows, _, vocab) = logits.dims3()?;
anyhow::ensure!(
rows == b,
"ForwardLogitsBatch: model returned {rows} logits rows for batch of {b}"
);
let flat: Vec<f32> = logits.flatten_all()?.to_vec1()?;
Ok(flat.chunks(vocab).map(<[f32]>::to_vec).collect())
}
/// Run the LM forward with vision-tower image splicing. Stage B3.
///
/// Encodes each image through the vision tower (`VisionTower::forward`,
@@ -1187,6 +1348,15 @@ fn drain_poisoned(job: Job, device_index: u32) {
Job::ForwardLogits { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::AssembleKvBatch { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::ForwardLogitsBatch { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::ExtractKvRows { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::EncodeImage { reply, .. } => {
let _ = reply.send(Err(err()));
}

View File

@@ -149,6 +149,50 @@ pub enum Job {
offset: usize,
reply: oneshot::Sender<Result<Vec<f32>>>,
},
/// Assemble stored per-sequence snapshots into one batched cache
/// state and install it as the model's live state (#98). `seqs`
/// pairs each snapshot id with its true token length; attention
/// K/V is right-padded to the batch max and `cat`ed on dim 0 (see
/// `arch::qwen3_5::snapshot::assemble_batch`). Replies with the
/// padded uniform KV length. The source snapshots remain stored —
/// the caller drops them via `DropKvSnapshot` when the sequences
/// leave the batch.
AssembleKvBatch {
handle: ArchHandle,
seqs: Vec<(KvSnapshotId, usize)>,
reply: oneshot::Sender<Result<usize>>,
},
/// Extract rows of the model's **live** batched cache state back
/// into contiguous single-sequence snapshots stored in the
/// worker's slab (#98) — the first half of a rebatch (join or
/// leave). `rows` pairs each batch-row index with its prefix
/// length; `padded_len`/`steps` describe the live batch geometry.
/// Replies one `(snapshot id, bytes)` per requested row, in
/// order. Compose with `AssembleKvBatch` to form the new batch,
/// then `DropKvSnapshot` the intermediates.
ExtractKvRows {
handle: ArchHandle,
/// `(batch row index, prefix_len)` per surviving sequence.
rows: Vec<(usize, usize)>,
padded_len: usize,
steps: usize,
reply: oneshot::Sender<Result<Vec<(KvSnapshotId, u64)>>>,
},
/// One lockstep batched decode step (#98): `tokens[i]` is batch
/// row i's next token, sitting at sequence position
/// `prefix_lens[i] + step`. The handler derives per-row positions
/// and the padding mask from `prefix_lens`/`padded_len` (the
/// values `AssembleKvBatch` was built from) and replies one CPU
/// `[vocab]` logits row per batch row, ready for per-slot
/// sampling on the async side.
ForwardLogitsBatch {
handle: ArchHandle,
tokens: Vec<u32>,
prefix_lens: Vec<usize>,
padded_len: usize,
step: usize,
reply: oneshot::Sender<Result<Vec<Vec<f32>>>>,
},
/// Run the LM forward with vision splicing in one round-trip.
/// Stage B3 of the vision plan.
///

Some files were not shown because too many files have changed in this diff Show More