Compare commits

..

70 Commits

Author SHA1 Message Date
abc6e605b8 test(neuron): NEURON_DEBUG_POISON hook to verify auto-recovery (#17)
Some checks failed
CI / CUDA type-check (push) Failing after 19s
build-prerelease / Resolve version stamps (push) Successful in 43s
CI / Format (push) Successful in 50s
CI / Clippy (push) Failing after 57s
build-prerelease / Build neuron-ada (push) Failing after 48s
build-prerelease / Build cortex binary (push) Successful in 5m5s
build-prerelease / Build neuron-blackwell (push) Successful in 6m38s
build-prerelease / Package cortex RPM (push) Successful in 1m27s
build-prerelease / Build neuron-ampere (push) Successful in 7m27s
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
CI / Test (push) Successful in 10m27s
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
One-shot, env-gated fault injector for beast verification: when
NEURON_DEBUG_POISON names a model, the first request for it triggers the
auto-recovery path as if a device fault had occurred — exercising
unload→reload→healthy without corrupting the GPU. Latched so it fires
exactly once (no recovery loop). No-op unless the env var is set; wired
into both the single-GPU and TP chat poison gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:08:40 +03:00
4f2957af9e feat(neuron): auto-recover poisoned models (#17 Stage 1c)
When an inference hit a device fault, the model was flagged poisoned and
every subsequent request rejected with "unload and reload the model to
recover" — until a *human* did exactly that. Now the harness rebuilds the
context automatically.

- Retain the loading `ModelSpec` on `LoadedModel`/`TpLoadedModel` (+
  `LoadedHandle::spec()`) so a poisoned model can be reloaded without an
  operator reconstructing the spec.
- A background recovery task (held via `Weak<CandleHarness>`, spawned in
  `new()` when a runtime is present) drains poisoned model ids and runs
  `unload_model` → `load_model(spec)`. Unload drops the model → cudarc
  `Comm::drop` aborts NCCL + releases the context; reload re-runs NCCL
  init + sanity inside the load path, so a successful reload yields a
  fresh, healthy model. A failed reload leaves it unloaded (next load
  retries) — never poisoned forever.
- The request-entry poison gates now `trigger_recovery` (single-flight
  per model via a `recovering` set) and return a transient "recovering,
  retry shortly" error instead of the manual-reload message. Requests
  that arrive during the brief reload gap (model absent from the registry)
  also get "recovering" rather than a misleading "not loaded".

`new()` now returns `Arc<Self>`. Recovery runs only on the background
task — never inline on the request path, which holds `inference_lock`
and would deadlock on the `models` write lock.

Stage 1c of the #17 plan (verified-healthy auto-recovery). Watchdog
(1b) + a fault-injection hook for beast verification follow. The
in-process rank-0 leader's own context fault still needs a reload that
can't rebind it (Stage 3); comm-desync + worker faults recover here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:05:02 +03:00
75cd088b61 fix(neuron): cap vision max_pixels to the pos_embed patch budget (#14)
All checks were successful
CI / CUDA type-check (push) Successful in 31s
build-prerelease / Resolve version stamps (push) Successful in 29s
CI / Format (push) Successful in 30s
CI / Clippy (push) Successful in 2m32s
build-prerelease / Build neuron-blackwell (push) Successful in 6m5s
CI / Test (push) Successful in 5m49s
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
build-prerelease / Build neuron-ampere (push) Successful in 8m11s
build-prerelease / Build neuron-ada (push) Successful in 5m40s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m4s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m2s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m57s
build-prerelease / Build cortex binary (push) Successful in 4m21s
build-prerelease / Package cortex RPM (push) Successful in 1m25s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m16s
Beast testing surfaced a real regression in the dynamic-resolution
default: a tall 808×1600 image resized (within the 1024² max_pixels) to a
90×44 patch grid = 3960 patches, exceeding the vision tower's hard
`num_position_embeddings = 2304` pos-embed budget. The per-rank
`patch count 3960 exceeds pos_embed budget 2304` error fired mid-TP-
forward and poisoned the device context, bricking the model until reload.

Hard-cap `max_pixels` to `2304 × 16² = 589_824` px (≤ 2304 patches →
≤ 576 LM tokens), clamping even the operator env override. `smart_resize`
floors the pixel count under the cap, so no resized image can ever exceed
the budget — the tower check never fires, no poison. The pos-embed grid
(48×48) is the resolution Qwen3.6 was trained at, so the cap is
principled, not just defensive. Still ~3× the old fixed 196 tokens, and
the book-cover OCR test (1176 patches) already reads full title+subtitle.

Test: a huge/tall/wide/extreme image battery stays within the 2304 patch
budget. (Per-rank-error poison robustness itself remains issue #17.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:30:47 +03:00
d311c8ca7a feat(neuron): operator pixel-budget env override + doc cleanup (#14 C5)
Some checks failed
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 38s
CI / Format (push) Successful in 45s
CI / Test (push) Failing after 58s
CI / Clippy (push) Successful in 2m41s
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
build-prerelease / Build cortex binary (push) Successful in 4m14s
build-prerelease / Package cortex RPM (push) Successful in 1m23s
build-prerelease / Build neuron-blackwell (push) Successful in 6m20s
build-prerelease / Build neuron-ampere (push) Successful in 7m18s
build-prerelease / Build neuron-ada (push) Successful in 5m10s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m7s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m5s
- PreprocessProfile::qwen3_6() reads NEURON_VISION_MIN_PIXELS /
  NEURON_VISION_MAX_PIXELS (clamped to factor² ≤ min ≤ max), matching the
  NEURON_VISION_LEGACY_* / NEURON_MROPE knob convention. Defaults remain
  256²…1024² (64…1024 LM tokens/image).
- Test: a max-resolution source caps within the token budget (can't blow
  NEURON_MAX_PROMPT_TOKENS).
- Strip stale fixed-resolution / "MRoPE gap (#15)" / 14×14 language from
  the preprocess, mod, and rope doc-comments now that resolution is
  dynamic and M-RoPE is implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:50:03 +03:00
c97a8654f5 feat(neuron): dynamic-resolution images via Qwen smart_resize (#14)
Some checks failed
CI / Clippy (push) Waiting to run
CI / Test (push) Waiting to run
CI / CUDA type-check (push) Successful in 32s
CI / Format (push) Successful in 34s
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
Replace the fixed 448×448-square preprocess with native-aspect
`smart_resize`, and thread the resulting per-image grid through the LM
so spatial structure survives non-square images (documents, screenshots,
charts, panoramas, OCR) instead of being squished into a square.

- preprocess.rs: port Qwen `smart_resize` (factor = patch×merge = 32;
  pixel budget [min,max], default 256²–1024² → 64–1024 LM tokens).
  `PreprocessProfile` drops the fixed target dims for `factor`/`min_pixels`/
  `max_pixels`; `preprocess`/`preprocess_data_uri` now return the resized
  `(h, w)`; add `resized_dims_for_uri` (decode + resize, no normalize) for
  the TP leader's token count.
- rope.rs: `compute_mrope_index`/`get_rope_index` take per-image
  `grids: &[(lm_gh, lm_gw)]` instead of assuming a square `isqrt(run)`.
  Walk image runs in order, validate `run == gh*gw`, emit row-major
  positions, resume the shared counter at `base + max(gh,gw)`. Correct
  for multiple images of differing grids interleaved with text.
- candle.rs: `VisionMeta`/`LoadedModel`/`TpLoadedModel` carry the
  `image_grid_factor` (patch×merge) instead of the constant 196; all four
  prompt-build sites compute per-image counts from each image's resized
  grid (single-GPU from the extracted `ImageInput.h/w`, TP from
  `resized_dims_for_uri`). `ModelArch` gains `vision_grid_factor`.
- single-GPU (`mod.rs`, `dispatch.rs`) and TP
  (`tp_qwen3_5.rs::prefill_with_images_chunked`, `dispatch.rs`,
  `tp/worker.rs`) thread the grids into `get_rope_index`. Each TP rank
  recomputes grids from its own deterministic preprocess — no rpc.rs
  change, single source of truth.

The vision tower itself was already grid-general (recent pos-embed
interpolation + 2D rotary fix). No patch-count cap: pos-embed is
interpolated to any grid; `max_pixels` bounds cost (O(patches²) ViT
attention + prefill) instead.

Tests: smart_resize (aspect/cap/floor/reject), `compute_mrope_index`
non-square + two-image + mismatch cases, square-grid regression guard.
Non-cuda build + clippy + full workspace tests green; TP load/dispatch
paths are cuda-gated → Gitea CUDA type-check. Operator pixel-budget
config + remaining doc cleanup follow in C5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:47:27 +03:00
dc048ffcc9 fix(neuron): vision-tower 2D positions + M-RoPE default on
All checks were successful
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 32s
CI / Format (push) Successful in 33s
CI / Clippy (push) Successful in 2m36s
build-prerelease / Build cortex binary (push) Successful in 4m48s
build-prerelease / Build neuron-blackwell (push) Successful in 5m59s
CI / Test (push) Successful in 6m35s
build-prerelease / Build neuron-ampere (push) Successful in 7m51s
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
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ada (push) Successful in 5m13s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m5s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m49s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m6s
Two fixes to the spatial handling of images, validated against the HF
transformers 4.57.1 qwen3_vl reference on beast.

**Vision tower (the real cause of poor spatial vision).** The Stage-A
tower encoded position two ways wrong, so the model saw image *content*
but not *layout* (a row of 5 people read as "a line of 23", sky
inverted), regardless of the LM-side rope:

- Learned pos-embed was a naive sequential lookup of the first
  `n_patches` rows of the 48×48 (`num_position_embeddings=2304`) grid —
  wrong stride for a 28×28 patch grid. Now bilinearly interpolates the
  grid to `gh×gw` (port of HF `fast_pos_embed_interpolate`), row-major.
- The 2D vision rotary was absent entirely. Added
  `VisionRotaryEmbedding` (θ=10000, dim=head_dim/2) applying per-patch
  `(row, col)` rotary to q/k in every ViT block via rope_slow, matching
  HF `apply_rotary_pos_emb_vision`.

Both default on; `NEURON_VISION_LEGACY_POS=1` / `NEURON_VISION_LEGACY_ROPE=1`
revert each for A/B (no rebuild). New unit tests: interpolation reduces
to the sequential lookup at the native grid; rotary row/col structure.

**M-RoPE default on.** The interleaved M-RoPE matches HF
apply_interleaved_mrope / get_rope_index exactly and A/B'd strictly ≥
plain. `NEURON_MROPE` is now a kill switch (`=0` for plain), not opt-in
— defaults should encode the model's trained behaviour, not freeze the
broken state.

Vision tower is plain candle (CPU-testable): built, clippy-clean, full
workspace tests green locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:53:07 +03:00
7ebcfba5ca fix(neuron): gate M-RoPE behind NEURON_MROPE (default off)
All checks were successful
CI / CUDA type-check (push) Successful in 33s
build-prerelease / Resolve version stamps (push) Successful in 32s
CI / Format (push) Successful in 33s
CI / Clippy (push) Successful in 2m34s
build-prerelease / Build cortex binary (push) Successful in 4m33s
build-prerelease / Build neuron-blackwell (push) Successful in 6m14s
CI / Test (push) Successful in 6m50s
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
build-prerelease / Build neuron-ampere (push) Successful in 8m12s
build-prerelease / Package cortex RPM (push) Successful in 1m23s
build-prerelease / Build neuron-ada (push) Successful in 5m9s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m59s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m3s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m2s
On beast the interleaved M-RoPE degraded image understanding rather than
fixing it: the model misread spatial layout (a horizontal row of people
described as a "diagonal receding line"), got attributes wrong, and
rambled — a "how many people" follow-up generated 4459 tokens over 3.5
minutes, past agent-0's HTTP timeout (the "fails to respond without an
error"). The interleave is evidently not numerically correct, and it
can't be validated remotely without a transformers reference.

Gate it: `get_rope_index` now returns plain sequential identity
positions unless NEURON_MROPE is truthy, so mrope_cos_sin reduces to
plain RoPE and image tokens behave exactly as pre-M-RoPE (content
recognition works; spatial layout approximate; no rambling). The real
computation moves to `compute_mrope_index` (still unit-tested). Default
off restores the working vision and unblocks agent-0; the M-RoPE code
stays in place to debug + validate before flipping the default on.

Pure non-cuda change (rope.rs); both single-GPU and TP forwards call
the gated get_rope_index unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:32:59 +03:00
825bf4e905 feat(neuron): M-RoPE Stage 4 — wire interleaved M-RoPE into the TP path
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / CUDA type-check (push) Successful in 31s
CI / Format (push) Successful in 42s
build-prerelease / Build cortex binary (push) Successful in 5m9s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
build-prerelease / Package cortex RPM (push) Successful in 1m32s
CI / Test (push) Successful in 7m19s
build-prerelease / Build neuron-ampere (push) Successful in 8m40s
build-prerelease / Build neuron-ada (push) Successful in 5m17s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m1s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m53s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m14s
CI / Clippy (push) Successful in 2m29s
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
Mirror Stage 3 into the tensor-parallel Qwen3.6 model:

- TpQwen3_5Attention / DecoderLayer take (cos, sin) instead of a scalar
  offset and apply via apply_cos_sin.
- TpQwen3_5Model gains the replicated rotary + rope_delta (reset in
  clear_kv_cache, settable). forward_inner builds the cos/sin once —
  interleaved M-RoPE from explicit position_ids (vision) or plain at
  offset+rope_delta (text/decode). forward() and forward_with_positions()
  delegate; the old single-shot forward_with_vision is gone.
- prefill_with_images_chunked now computes get_rope_index over the whole
  prompt once, stores rope_delta on the base model, and slices the
  (3, prompt_len) position tensor per chunk — so every rank assigns image
  tokens their 14×14 grid coordinates and steps in lockstep (every chunk,
  text or image, carries the M-RoPE slice because the image shifts the
  surrounding text positions).

Also build the position-id tensor as f32 directly (positions are small
integers, exact in f32) to avoid an i64→f32 cast on the GPU.

The TP forward is cuda-gated — CI CUDA type-check is the compile gate.
Non-cuda build + clippy + full workspace tests green; rope math + the
plain-RoPE-reduction invariant covered by unit tests.

Completes the interleaved-M-RoPE work for the vision spatial misread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:46:27 +03:00
4c12c7e2f0 feat(neuron): M-RoPE Stage 3 — wire interleaved M-RoPE into single-GPU
Qwen3_5Model now builds the rotary cos/sin once per forward and threads
(cos, sin) through the decoder → full-attention → rope, replacing the
scalar offset that reached RotaryEmbedding:

- vision forward computes get_rope_index over the (single-shot) prompt,
  sets rope_delta, and builds interleaved-M-RoPE cos/sin so image tokens
  carry their 14×14 grid (height/width) positions;
- text / decode take plain_cos_sin at offset + rope_delta — with
  rope_delta == 0 (no image) this is bit-for-bit the old plain RoPE, and
  the device→host id copy is skipped on the text decode hot path.

rope_delta is stored on the model and reset in clear_kv_cache, so decode
after a vision prefill resumes text positions from the image-compressed
counter. decoder.rs / full_attn.rs take (cos, sin) instead of offset;
linear-attention layers are unchanged (no RoPE). The TP path still uses
the retained apply(offset) — wired in Stage 4.

Full workspace tests green; the load-bearing invariant (M-RoPE == plain
for equal axes) keeps text unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:39:52 +03:00
ba1b5ba408 feat(neuron): M-RoPE Stage 2 — get_rope_index position-id helper
Pure function computing the interleaved-M-RoPE 3D position ids for a
prompt with image-placeholder runs, plus the decode rope_delta:
text tokens advance a single counter (all axes equal); each image run
gets [base+t, base+h, base+w] row-major over a square grid_t=1,
grid_h=grid_w=isqrt(run) (196 → 14×14); the counter resumes from
base + max(grid). rope_delta = final_counter - seq_len lets decode
resume text positions after the position-compressed image blocks.
Plus mrope_position_tensor to build the (3, seq) tensor.

Unit tests: text-only is sequential (delta 0); text+image+text matches
hand-computed grid ids + resume + delta; 196 → 14×14; non-square run
rejected; end-to-end through mrope_cos_sin tracks the height axis.

#[allow(dead_code)] until Stage 3/4 wire it into the forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:34:28 +03:00
5731f4c318 feat(neuron): M-RoPE Stage 1 — interleaved rope machinery + config
Parse + store mrope_section / mrope_interleaved in RopeParameters
(previously accepted-but-ignored). RotaryEmbedding gains:
- inv_freq + per-axis column masks (mask_t/h/w) built from mrope_section;
- plain_cos_sin(pos, seq_len): narrow the precomputed tables (text/decode);
- mrope_cos_sin(position_ids (3,seq)): per-axis freqs blended at the
  interleave columns (vision);
- apply_cos_sin(q,k,cos,sin): the rope_slow application, factored out.

The existing apply(q,k,offset) is retained (delegates to
plain_cos_sin + apply_cos_sin) so current callers are unchanged; Stages
3–4 move cos/sin construction into the model forward and thread the 3D
position ids for image tokens.

Tests: masks partition the half-dim; interleave drives the right axis
per column; and the load-bearing invariant — mrope_cos_sin reduces
bit-for-bit to plain_cos_sin when the three axes are equal (so text
inference is unchanged).

Refs the MRoPE-gap diagnosis (vision spatial misread). Pure non-cuda;
no behaviour change until wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:31:15 +03:00
fa013505d1 fix(neuron): chunked TP-vision prefill + pre-flight VRAM guard
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 29s
build-prerelease / Build cortex binary (push) Successful in 4m26s
build-prerelease / Package cortex RPM (push) Successful in 1m18s
build-prerelease / Build neuron-blackwell (push) Successful in 6m6s
build-prerelease / Build neuron-ampere (push) Successful in 8m30s
CI / Format (push) Successful in 38s
CI / CUDA type-check (push) Successful in 47s
CI / Clippy (push) Successful in 2m36s
build-prerelease / Build neuron-ada (push) Successful in 5m19s
CI / Test (push) Successful in 6m3s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m1s
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
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m32s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m47s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 59s
agent-0 sent a ~13k-token prompt + image; the TP vision prefill was
single-shot, so it tried to materialise activations for all 12,960
positions at once and OOM'd rank 1 mid-forward. Rank 1 died before
issuing its row-parallel AllReduce, stranding rank 0 on the collective
(it hung holding the pool lock). The text path survives the same size
because it chunks the prefill.

Chunk the vision prefill the same way:

- TpQwen3_5ForCausalLM::prefill_with_images_chunked encodes the image(s)
  once, then walks the pre-expanded prompt in prefill_chunk_tokens()
  windows, splicing the patch-embedding rows into whichever chunk(s)
  carry <|image_pad|> positions (pure-text chunks take the plain
  forward). Activation is bounded by the chunk, not the prompt.
- Every rank runs the identical chunk sequence (chunk_size threaded
  through GenerateStepWithImages / TpForwardLogitsWithImages /
  generate_step_with_images), so the per-chunk AllReduces stay paired
  across ranks with no extra sync — the KV cache accumulates via the
  growing offset, only the last chunk's logits are kept.

Pre-flight guard (validate_vision_prefill): even chunked, a long
prompt's KV cache can exhaust VRAM mid-forward, and on TP that hangs
the collective. Reject up front with a clean InsufficientVram when the
estimated footprint exceeds free VRAM, so a doomed request fails fast
instead of hanging the daemon. Heuristic + tunable
(NEURON_VISION_PREFILL_MB_PER_1K_TOKENS / _BASE_MB); default permissive
so the now-working 12,960-token case still passes. Applied to every
vision path (single-GPU + TP); single-GPU vision stays single-shot for
now, so the guard is its protection until it's chunked too.

Tests: pre-flight guard behaviour; RPC round-trip carries chunk_size.
The chunked forward is cuda-gated — CI CUDA type-check validates it.

Refs #16 / TP-vision. Operational note: a TP rank OOM still hangs the
daemon (needs restart); making a worker failure abort the leader's
collective is separate, broader TP hardening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:21:36 +03:00
c8bcaabc38 fix(neuron): render HF chat templates via minijinja pycompat
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 29s
CI / Format (push) Successful in 34s
CI / CUDA type-check (push) Successful in 39s
CI / Clippy (push) Successful in 2m35s
build-prerelease / Build cortex binary (push) Successful in 4m21s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
CI / Test (push) Successful in 6m47s
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
build-prerelease / Build neuron-ampere (push) Successful in 7m43s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ada (push) Successful in 5m41s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m5s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m3s
The Qwen3.6 chat_template.jinja (now loaded after the precedence fix)
failed to render in minijinja: it uses Python str methods
(content.startswith/endswith/split/rstrip/lstrip) and the raise_exception
global that HF transformers patches into its Jinja env but minijinja
doesn't provide. The render error tripped the text-only fallback, so
image requests still produced zero <|image_pad|> tokens.

Wire the standard bridge into render_chat_template:
- minijinja-contrib `pycompat::unknown_method_callback` supplies the
  Python string/list/dict methods;
- a `raise_exception` global maps to a render error (so malformed inputs
  — e.g. an image in a system message — surface cleanly).

Add the real Qwen3.6-27B chat_template.jinja (verbatim from beast's HF
cache) as a test fixture and assert it renders one <|image_pad|> for a
text+image turn — the end-to-end check that would have caught this
before deploy.

Refs #16 / TP-vision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:32:23 +03:00
7ad56c6a86 fix(neuron): load chat_template.jinja (transformers precedence)
The chat-template loader only read the `chat_template` field from
tokenizer_config.json. Qwen3.6-27B ships its vision-aware template
*only* in a standalone `chat_template.jinja` (and has no
tokenizer_config.json at all), so the loader returned None and image
requests fell back to the text-only format_qwen3_prompt — rendering
zero `<|image_pad|>` tokens and tripping
"expand_image_pad_tokens: prompt has 0 image_token_id occurrences".

load_chat_template_alongside now follows HF transformers precedence:
standalone chat_template.jinja → chat_template.json → the
chat_template field in tokenizer_config.json. Tests cover the
precedence, the text-only fallback, and that an OpenAI image_url
content part renders `<|image_pad|>` through the real template
condition (`'image_url' in item`).

Refs #16 / TP-vision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:25:30 +03:00
1b0e36c119 fix(neuron): cover TpForwardLogitsWithImages in drain_poisoned match
All checks were successful
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 37s
CI / Format (push) Successful in 37s
CI / Clippy (push) Successful in 2m41s
build-prerelease / Build cortex binary (push) Successful in 4m18s
build-prerelease / Build neuron-blackwell (push) Successful in 5m48s
build-prerelease / Package cortex RPM (push) Successful in 1m32s
CI / Test (push) Successful in 6m20s
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
build-prerelease / Build neuron-ampere (push) Successful in 8m26s
build-prerelease / Build neuron-ada (push) Successful in 5m21s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m56s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m5s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m0s
The CUDA type-check caught a non-exhaustive match: drain_poisoned()
must reply an error to every Job variant's reply channel, including the
new cuda-gated TpForwardLogitsWithImages. The non-cuda build couldn't
see it — the variant is #[cfg(feature = "cuda")], so the match is
exhaustive without it on CPU.

Refs TP-vision plan Stage 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:26:46 +03:00
ed2d09864e feat(neuron): TP-vision Stage 3 — wire TP chat + stream vision prefill
Some checks failed
CI / Format (push) Successful in 30s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 5m52s
CI / CUDA type-check (push) Failing after 50s
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
End-to-end TP-vision: an image request to a TP-loaded Qwen3.6-27B now
conditions on the image across both ranks.

- TpLoadedModel carries has_vision / image_token_id / lm_tokens_per_image,
  populated at load via the shared VisionMeta::from_config_path (same
  config.json the shards loaded from; Stage 1 materialises the replicated
  tower on every rank).
- LoadedHandle::capabilities() now advertises "vision" for TP loads with
  a tower (cortex-gateway already unions this into /v1/models via C3).
- The TP rejection guards (chat_completion_tp + inference_tp_stream) are
  now conditional on !has_vision — text-only TP models still 400 cleanly,
  vision-capable ones fall through.
- chat_completion_tp_inner and the streaming orchestration task detect
  images (request_has_images), expand <|image_pad|> to the per-image
  patch count, and run a single-shot generate_step_with_images prefill
  (every rank encodes + splices its replicated tower) before the
  unchanged decode loop. Text requests keep chunked_prefill_tp.
- extract_image_data_uris ships the source data URIs to every rank for
  identical per-rank preprocessing.

prompt_tokens now reflects the patch expansion, so usage accounting and
KV offsets match the single-GPU baseline.

TP entry points are cuda-gated (validated by CI's CUDA type-check);
capabilities() + extract_image_data_uris + VisionMeta reuse compile on
the non-cuda build. Full workspace test green.

Refs TP-vision plan Stage 3. Implements #12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:14:44 +03:00
4994b94c84 feat(neuron): TP-vision Stage 2 — per-rank image RPC + worker plumbing
Carry image content through the TP forward path so every rank encodes
and splices locally (replicated tower, no embedding broadcast).

- rpc.rs: new WorkerRequest::GenerateStepWithImages carrying the source
  image data URIs + image_token_id for the single-shot vision prefill;
  worker still replies GenerateStepOk. Round-trip test added.
- tp_qwen3_5.rs: TpQwen3_5ForCausalLM::forward_with_images — encode each
  preprocessed image through the rank's replicated tower, cat, splice,
  forward. Shared by leader and worker so every rank runs identical work.
- tp/mod.rs: TpLeaderModel::forward_with_images and
  WorkerPool::generate_step_with_images (mirrors generate_step: fan out
  GenerateStepWithImages to subprocess ranks, run the leader's image
  forward on its device worker thread, drain, combine).
- worker.rs: WorkerModel::forward_with_images + handle_generate_step_with_images
  — each subprocess rank preprocesses the same data URIs via the shared
  deterministic preprocess_data_uri, encodes, splices, forwards.
- device_worker: Job::TpForwardLogitsWithImages + tp_forward_logits_with_images
  dispatch handler + DeviceWorkerHandle::tp_forward_logits_with_images.

Determinism: every rank runs the same preprocess on the same source
URIs through the same replicated tower, so the spliced hidden state
matches across ranks — preserving the replicated-hidden-state invariant
the row-parallel AllReduce relies on, with no NCCL broadcast.

No caller yet — Stage 3 wires the TP chat/stream entry points to invoke
generate_step_with_images for image prefill. cuda-gated plumbing covered
by CI's CUDA type-check; rpc/route/forward_with_images compile on the
non-cuda build.

Refs TP-vision plan Stage 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:08:08 +03:00
9a24b05866 feat(neuron): TP-vision Stage 1 — replicated vision tower on the TP model
Load the full, unsharded model.visual.* vision tower on every TP rank
(leader + each subprocess worker mmaps the same local safetensors) when
config.vision_config is present. VisionTower::load already takes a
ShardedVarBuilder whose plain .get() returns the full replicated tensor,
so the tower loads identically regardless of world_size — no sharding,
no NCCL broadcast.

- TpQwen3_5ForCausalLM gains vision: Option<VisionTower> + image_token_id,
  plus has_vision/image_token_id/encode_image/forward_with_vision,
  mirroring the single-GPU Qwen3_5ForCausalLM wrapper.
- TpQwen3_5Model::forward_with_vision mirrors the single-GPU
  forward_inner splice: embed locally, replace rows at image_token_id
  positions, run the sharded decoder stack. Because every rank encodes
  the same pixels through its replicated tower, the spliced input
  embeddings are identical across ranks — preserving the TP
  replicated-hidden-state invariant the row-parallel AllReduce relies on.
- splice_runs is now pub(crate) and shared with the TP model.

No caller yet — Stage 2 wires the RPC/worker path that invokes
encode_image + forward_with_vision per rank. Most of this compiles on
the non-cuda build (only the cuda load variant's tower line is gated);
CI's CUDA type-check covers the rest.

Refs TP-vision plan Stage 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:00:05 +03:00
7bb033b4ed chore: untrack stray .claude/scheduled_tasks.lock and gitignore .claude/
All checks were successful
CI / CUDA type-check (push) Successful in 32s
CI / Format (push) Successful in 30s
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Clippy (push) Successful in 2m45s
build-prerelease / Build cortex binary (push) Successful in 4m28s
CI / Test (push) Successful in 6m6s
build-prerelease / Build neuron-blackwell (push) Successful in 6m11s
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
build-prerelease / Package cortex RPM (push) Successful in 1m28s
build-prerelease / Build neuron-ampere (push) Successful in 8m1s
build-prerelease / Build neuron-ada (push) Successful in 8m9s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m54s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m54s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m2s
A runtime scheduler lock was accidentally swept into the previous
commit by `git add -A`. Remove it from tracking (file stays on disk)
and ignore the whole `.claude/` dir so local agent runtime state never
lands in the repo again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:55:05 +03:00
f8c0da0ebf fix(neuron): TP-vision Stage 0 — reject image requests on the TP path
Some checks failed
build-prerelease / Resolve version stamps (push) Waiting to run
CI / Format (push) Waiting to run
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Build cortex 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-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
CI / Clippy (push) Has been cancelled
CI / Test (push) Has been cancelled
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
The TP inference path has no vision tower, and the TP dispatch in
chat_completion / inference_stream returns before the VisionUnsupported
guard runs — so an image request to a TP-loaded model (e.g. beast's
tp=2 Qwen3.6-27B) was silently dropped and answered from text alone,
the exact issue-#3 confident-hallucination pattern Stage C killed for
single-GPU.

Add the request_has_images → VisionUnsupported guard to both
chat_completion_tp and inference_tp_stream, before prefill / before the
SSE stream opens, so beast returns a clean 400 vision_unsupported. The
guard is unconditional for now (TP has no tower); Stage 3 makes it
conditional on the TP model's has_vision once real TP-vision lands.

Detection is covered by the existing request_has_images unit test; the
guard itself is cuda-gated (validated by CI's CUDA type-check).

Refs TP-vision plan Stage 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:53:56 +03:00
dd592d918d test(neuron): C2 — guard Responses→chat image translation contract
All checks were successful
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 39s
CI / Format (push) Successful in 44s
CI / Clippy (push) Successful in 2m51s
build-prerelease / Build cortex binary (push) Successful in 4m42s
build-prerelease / Build neuron-blackwell (push) Successful in 5m52s
CI / Test (push) Successful in 6m16s
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
build-prerelease / Build neuron-ampere (push) Successful in 8m12s
build-prerelease / Package cortex RPM (push) Successful in 1m26s
build-prerelease / Build neuron-ada (push) Successful in 5m34s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m59s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m2s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m1s
The Responses request translator already emits the chat `image_url`
Parts array Stage B5's vision path consumes, and the non-streaming
(`chat_completion`) and streaming (`responses_stream` → `inference_stream`,
Stage C1) Responses paths both route image content to the vision-aware
prefill — so vision works end-to-end through `/v1/responses` with no
translator change required.

Add a multi-image test asserting order preservation and that the
`detail` hint is tolerated (and dropped, since chat image_url has no
analogue), locking the translator's output to the exact
`image_url.url` shape `extract_images_from_request` walks.

Closes part of #16 (Stage C2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:57:43 +03:00
766c20ba47 feat(neuron): C1 — streaming SSE chat completion with vision
The streaming worker path now splices image embeddings on prefill,
closing the silent text-only degrade for `stream=true` image requests.

`inference_stream` gains the same vision-routing block as the
non-streaming `chat_completion`: detect `image_url` content, reject it
against text-only models with `VisionUnsupported` (before any SSE frame
is sent), preprocess each image and expand its `<|image_pad|>` sentinel
to the per-image patch count, then carry the payload through dispatch.

Rather than duplicate the 75-line `route_token!` reasoning/tool-call
state machine into a sibling streamer, `stream_inference_via_worker`
takes an `Option<(Vec<ImageInput>, u32)>`: when `Some`, prefill is a
single-shot `forward_logits_with_images` splice; when `None`, the
original chunked text-only prefill. Image embeddings are prefill-only,
so every decode step stays on the plain `forward_logits` path and the
shared decode loop is untouched. This keeps exactly one copy of the
tool-call/reasoning logic to maintain.

The Responses API streaming path (`responses_stream`) inherits vision
for free since it drives the same `inference_stream`.

Unit test covers `request_has_images` (the shared routing gate); the
real-weights SSE smoke is the manual curl on beast (cuda-integration).

Closes part of #16 (Stage C1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:57:02 +03:00
4972c7d1e7 feat(cortex-gateway): C3 — propagate vision capabilities through /v1/models
ModelEntry and CortexModelEntry gain a `capabilities: Vec<String>`
field (serde-default for back-compat). The poller copies it verbatim
from each neuron's ModelInfo.capabilities; list_models computes the
union across every node where a model is loaded so a checkpoint loaded
text-only on one neuron and text+vision on another reports both to the
fleet. Catalogue-only and mid-prewarm entries default to empty until
the catalogue gains a capabilities declaration.

Aliases inherit their target's capability union. New gateway test mocks
two nodes with differing capability arrays and asserts the unioned
/v1/models response.

Closes part of #16 (Stage C3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:49:54 +03:00
a26bb9f04b feat(deploy): capture service startup journal after each restart
After both `Start cortex.service` and `Start neuron.service`, sleep 10s
and run `journalctl --unit <unit> -I --no-pager` to record the latest
invocation's log in the workflow output. Step is guarded by
`if: always()` so a failed start still leaves a usable trace.

infra-setup.sh now adds gitea_ci to the systemd-journal group during
user provisioning, so `journalctl` works without a sudoers entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:48:56 +03:00
ea1fdf8aa6 chore(deploy): drop deploy.sh and manifest.yml now that workflow runs
First end-to-end run of the deploy workflow succeeded (gitea run #289),
so the operator-run rolling-deploy script and its YAML manifest are no
longer the source of truth — fleet topology lives in
.gitea/workflows/deploy.yml and per-host config in script/infra-setup.sh.

Per-host neuron config comments updated to point at the new sync path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:41:04 +03:00
577781de8d fix(neuron): derive Clone on ImageInput for the CUDA vision dispatch
All checks were successful
CI / CUDA type-check (push) Successful in 32s
CI / Format (push) Successful in 34s
build-prerelease / Resolve version stamps (push) Successful in 39s
CI / Clippy (push) Successful in 2m47s
build-prerelease / Build cortex binary (push) Successful in 4m34s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m58s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Build neuron-ampere (push) Successful in 8m5s
build-prerelease / Build neuron-ada (push) Successful in 8m9s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m5s
CUDA type-check in CI failed on commit 24968e9 with E0308:

  error[E0308]: mismatched types
      --> crates/neuron/src/harness/candle.rs:1707:33
   1707 |                                 images.clone(),
        |                                 ^^^^^^^^^^^^^^ expected `Vec<ImageInput>`,
                                                          found `&Vec<ImageInput>`

In Stage B5 the cuda branch of `chat_completion` matches
`&vision_route` to keep the `vision_route: Option<...>` alive for
both arms, which makes `images` bind as `&Vec<ImageInput>`. The
subsequent `images.clone()` call doesn't deep-clone because
`ImageInput` doesn't derive `Clone` — rustc falls back to cloning
the `&Vec` reference, which has the wrong type for the worker job.

The CPU build (non-cuda) compiled fine because that branch is
behind `#[cfg(feature = "cuda")]`; the cuda-check job is what
catches the regression.

Fix: derive `Clone` on `ImageInput`. The clone cost is one
pixel-buffer memcpy per image (~2.4 MiB at fixed 448×448), which
is fine on the chat-completion hot path — vision requests are
rare per second relative to text-only decode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:51:57 +03:00
24968e9233 feat(neuron): Stage B — end-to-end text+image chat for Qwen3.6
Some checks failed
build-prerelease / Resolve version stamps (push) Successful in 31s
CI / Format (push) Successful in 33s
CI / CUDA type-check (push) Failing after 46s
CI / Clippy (push) Successful in 2m37s
build-prerelease / Build cortex binary (push) Successful in 4m32s
build-prerelease / Build neuron-blackwell (push) Failing after 5m35s
CI / Test (push) Successful in 6m40s
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
build-prerelease / Build neuron-ampere (push) Failing after 7m46s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Build neuron-ada (push) Failing after 4m51s
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
Stage B of the vision plan (doc/vision-qwen3_6-spec.md). Wires
the vision tower from Stage A through to a complete non-streaming
chat completion: extract images from the request, preprocess,
encode on the worker thread, splice embeddings into the LM input
at `<|image_pad|>` positions, return coherent text response with
`prompt_tokens` reflecting patch tokens.

Closes the silent-drop class of failures from issue #3 — vision
requests against Qwen3.6 now condition the model on the image
instead of producing confident text-only hallucinations.

Streaming for vision is Stage C. Deferred items tracked under
#12 (TP-vision), #13 (27B production), #14 (dynamic resolution),
#15 (numerical validation).

What landed:

- **B1 — `Qwen3_5Model::forward_with_vision`**: text-only `forward`
  unchanged; new method takes `(input_ids, offset, image_embeds,
  image_token_id)`, embeds tokens, locates `image_token_id`
  positions, splices via the new `splice_runs` helper. MRoPE
  applies text-positions to image tokens for Stage B (spatial
  MRoPE is the issue #15 numerical-validation follow-up). 2 unit
  tests for `splice_runs` covering contiguous + non-contiguous
  runs.

- **B2 — `ModelArch::forward_with_vision` dispatch**: routes
  Qwen3_5Dense to the new method; other arches return an error.
  Defence-in-depth — the HTTP layer (B6) already rejects image
  content for non-vision models.

- **B3 — `Job::ForwardLogitsWithImages`**: new worker variant
  carrying tokens + per-image `(pixels, c, h, w)` payloads. The
  dispatcher encodes each image (device-resident), concatenates
  the resulting embeddings, calls `arch.forward_with_vision`, and
  returns CPU logits. Image embeddings never copy back to CPU —
  the "tensors don't escape the worker" invariant from the
  per-device worker refactor still holds. Poisoned-worker drain
  path handles the new variant.

- **B4 — Prompt builder**:
  - `request_has_images` detects image content cheaply.
  - `extract_images_from_request(request, profile)` walks
    `MessageContent::Parts`, decodes data URIs, runs
    `harness::preprocess::preprocess` per image, returns
    `Vec<ImageInput>` in request order.
  - `expand_image_pad_tokens(input_ids, image_token_id,
    patches_per_image)` walks the tokenized prompt and replaces
    each `<|image_pad|>` (id 248056 for Qwen3.6) with N copies
    matching the per-image patch count. 4 unit tests.
  - `VisionMeta::from_config_path` peeks `config.json` at load
    time for `image_token_id`, vision_config patch/merge sizes,
    and derives `lm_tokens_per_image` for the Stage B fixed
    resolution.

- **B5 — `chat_completion` vision routing**: detects image
  content, validates the loaded model has vision, expands the
  prompt, and calls a new `run_inference_with_images_via_worker`
  helper that does single-shot prefill + standard decode loop
  (KV cache holds the post-splice hidden states from prefill, so
  decode steps don't re-splice). Stage B skips chunked prefill
  for vision — at 448×448 fixed resolution the budget stays well
  under the activation-memory threshold. Long-vision chunking is
  Stage D follow-up.

- **B6 — `InferenceError::VisionUnsupported`**: structured 400
  with `code=vision_unsupported, model_id, suggestion` when an
  image request hits a non-vision model. Closes the agent0
  failure mode where vision requests degraded silently.

- **B7 — `ModelInfo.capabilities`**: per-model array (`["text"]`
  vs `["text", "vision"]`) in `/v1/models` and forwarded verbatim
  by cortex-gateway. Lets clients (litellm, agent0) gate
  image_url submission on the declared capability set. Optional
  in the wire format; defaults to empty for older clients.

CI gate: cargo fmt --check, cargo clippy --workspace --all-targets
-- -D warnings, cargo test --workspace (all 28 test groups ok,
124 lib tests). New unit-test counts: +2 splice_runs, +4
expand_image_pad.

Manual verification (after RPMs deploy on beast):

  curl http://hanzalova.internal:31313/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d "{\"model\":\"Qwen/Qwen3.6-27B\", \"messages\":[{\"role\":\"user\",\"content\":[
      {\"type\":\"text\",\"text\":\"What's in this image?\"},
      {\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/jpeg;base64,...\"}}
    ]}], \"max_tokens\":120}" | jq

  Expect prompt_tokens > 196 (text + 196 patch tokens) and a
  response that references actual image content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:33:00 +03:00
7df84fed8f feat(neuron): Stage A — vision tower load + preprocessor for Qwen3.6
All checks were successful
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Format (push) Successful in 28s
CI / Clippy (push) Successful in 2m35s
build-prerelease / Build cortex binary (push) Successful in 5m13s
build-prerelease / Build neuron-blackwell (push) Successful in 6m23s
build-prerelease / Build neuron-ampere (push) Successful in 7m56s
CI / Test (push) Successful in 7m11s
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
build-prerelease / Package cortex RPM (push) Successful in 1m19s
build-prerelease / Build neuron-ada (push) Successful in 5m30s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m56s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 4m25s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m1s
Stage A of the vision implementation plan
(doc/vision-qwen3_6-spec.md). Builds the vision tower scaffolding
that today's silent-drop failure mode (issue #3) needs — the
Qwen3.6 ViT loads from `model.visual.*`, runs forward producing
post-merger LM-side image embeddings, and routes through the
device worker via a new `Job::EncodeImage`. No LM splice yet —
that's Stage B.

Refs #3 (umbrella). Deferred sub-stages tracked as #12 (TP-vision),
#13 (27B production deploy), #14 (dynamic resolution), #15
(numerical validation).

What landed:

- **A0 — investigation**: pulled config.json, preprocessor_config.json,
  chat_template.jinja, and safetensors index from beast's local
  Qwen3.6-27B cache. Documented in doc/vision-qwen3_6-spec.md with
  exact tensor shapes for every `model.visual.*` weight. Confirms
  27-block ViT with `hidden_size=1152`, `patch_size=16`,
  `spatial_merge_size=2`, `out_hidden_size=5120`. Vision tower lives
  in 2 of the 15 safetensors shards.

- **A1 — deps + scaffolding**: added `image = "0.25"` (default-
  features off, PNG/JPEG/WebP/BMP/GIF) and `base64 = "0.22"` to
  crates/neuron/Cargo.toml. Created `harness::preprocess` and
  `harness::arch::qwen3_5::vision` modules.

- **A2 — preprocess.rs**: `decode_data_uri` strips
  `data:image/...;base64,...` → image bytes → `image::DynamicImage`
  (rejecting `http(s)://` URLs to avoid SSRF/recursion); `preprocess`
  resizes to a fixed `PreprocessProfile::qwen3_6()` (448×448),
  normalises to `[-1, 1]` per the model's mean/std=0.5, emits
  row-major `(3, H, W)` f32. 9 unit tests covering data URI parse,
  decode failure paths, grayscale-to-RGB promotion, and the
  exact-value normalisation contract.

- **A3 — vision.rs**: `VisionTower` struct with `patch_embed: Conv2d`,
  learned `pos_embed: Embedding`, 27 `VisionBlock`s (pre-LN +
  multi-head self-attention with fused QKV + GELU-tanh MLP +
  residuals), and `VisionMerger` (LayerNorm → 2×2 spatial concat →
  linear_fc1 → GELU-tanh → linear_fc2 to LM hidden_size).
  Includes the Conv3d→Conv2d fold trick documented at the top of
  the file — the published patch_embed.proj.weight is 5D
  `(1152, 3, 2, 16, 16)` but candle 0.10 has no Conv3d; for static
  images we sum-collapse the temporal axis. Video would need real
  Conv3d. 5 unit tests including the exact `gelu_pytorch_tanh`
  reference values from PyTorch.

- **A4 — wire vision into Qwen3_5ForCausalLM**: extended `Config`
  with optional `vision_config: Option<VisionConfig>` and
  `image_token_id`; `Qwen3_5ForCausalLM::new` now loads the vision
  tower when present, exposes `has_vision()` and `vision()` so the
  HTTP layer can advertise capability and so the encode path can
  reach it.

- **A5 — device worker `Job::EncodeImage`**: new job variant carrying
  CPU-side `(C, H, W)` pixels. Dispatch handler reconstructs the
  tensor on the worker's device, calls `arch.encode_image(image)`,
  copies the result back to CPU as flat `Vec<f32>`. Keeps the
  "tensors don't escape the worker" invariant. Poisoned-worker
  drain path handles the new variant.

- **A6 — dispatch round-trip test**: `encode_image_routes_to_dispatch_
  and_errors_on_unknown_handle` proves the channel/dispatch wiring
  works end-to-end via the CPU device worker (errors on unknown
  ArchHandle, which is the expected behaviour without a loaded
  model — real-weights validation happens in Stage B when the LM
  splice path exists).

CI gate: cargo fmt --check, cargo clippy --workspace --all-targets
-- -D warnings, cargo test --workspace (all 28 test groups ok,
zero failures). New test counts: +9 in preprocess, +5 in vision,
+1 in device_worker.

Out of scope (deferred):
- LM-side splice of image embeddings at `<|image_pad|>` positions
  → Stage B.
- Streaming SSE for vision-bearing chat completions → Stage C.
- Reject `image_url` with HTTP 400 for non-vision models /
  advertise `capabilities` in /v1/models → Stage C.
- TP-vision (#12), 27B production deploy (#13), dynamic resolution
  (#14), numerical validation (#15).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:40:47 +03:00
5c520c7e90 feat(deploy): gitea workflow for rolling RPM deploys + host bootstrap
Replace operator-run script/deploy.sh with a CI-driven rolling deploy:

- .gitea/workflows/deploy.yml fires on build-prerelease success (and is
  re-runnable via workflow_dispatch). Cortex upgrades first on
  hanzalova.internal; the three neuron hosts upgrade in parallel under
  fail-fast: false so one failing host doesn't sink the rest.
  Concurrency-grouped to serialize overlapping deploys, never cancelling
  in-flight runs (a half-applied dnf transaction is worse than a stale
  deploy).

- asset/sudoers.d/{cortex,neuron}-host.conf are the canonical source for
  the scoped privileges gitea_ci needs on each host kind, installed as
  /etc/sudoers.d/helexa_gitea_ci. URLs and = signs are backslash-escaped
  per sudoers reserved-character rules.

- script/infra-setup.sh idempotently provisions the gitea_ci user,
  installs the runner pubkey, drops in the appropriate sudoers fragment
  with visudo verification, and syncs cortex.toml / models.toml /
  per-host asset/neuron/<short>.toml — config still ships from operator
  workstations rather than CI because the first two are gitignored.

The CI-only secret is RSYNC_SSH_KEY (already configured for the repo);
the matching pubkey is ~/.ssh/id_gitea_ci.pub on the operator's box.

script/deploy.sh and asset/manifest.yml are left in place until the
first end-to-end deploy workflow run succeeds, then removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:58:23 +03:00
d0292ed377 feat(cortex): catalogue source field + scheme-qualified /models/load
Some checks failed
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 40s
CI / Format (push) Successful in 40s
CI / Test (push) Failing after 1m3s
CI / Clippy (push) Successful in 2m43s
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
build-prerelease / Build neuron-blackwell (push) Successful in 6m13s
build-prerelease / Build neuron-ampere (push) Successful in 7m31s
build-prerelease / Build neuron-ada (push) Successful in 8m16s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m56s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m21s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m44s
build-prerelease / Build cortex binary (push) Successful in 4m5s
build-prerelease / Package cortex RPM (push) Successful in 1m30s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m1s
Phase 3 of plan-source-aware-loader-preflight. Adds an optional
`source` field to `ModelProfile` and threads it through the
router's cold-load path so a profile pointing at the helexa
registry forwards `helexa:<id>` to neuron's `/models/load`
instead of leaving neuron to substitute its `default_source`
(typically `huggingface`).

Without this, an operator who declares
`source = "helexa"` in models.toml would still see neuron fetch
from HuggingFace — the catalogue → ModelSpec translation in
`profile_to_spec` was dropping the scheme on the floor.

What lands:

- `cortex-core::catalogue::ModelProfile.source: Option<String>`.
  None is the default and preserves pre-Phase-3 behaviour.
- `cortex-gateway::router::qualified_model_id(profile)` —
  small pure helper, extracted from `profile_to_spec` so it can
  be unit-tested. Empty-string `source` is treated as None so
  operators who blank out a previously-set value don't trip a
  scheme-with-no-scheme failure mode in neuron.
- `models.example.toml` documents the new field with a
  commented-out helexa-scheme example pointing back at
  neuron.example.toml's matching sources block.

Tests:

- 2 new unit tests in `cortex-core::catalogue`: source-absent
  round-trip and source-present round-trip through TOML.
- 3 new unit tests in `cortex-gateway::router`: pass-through
  when None, prefix when Some, pass-through on empty-string
  source.
- ModelProfile literal in catalogue's existing test updated to
  carry `source: None`.

CI gate: cargo fmt --check, cargo clippy --workspace
--all-targets -- -D warnings, cargo test --workspace
(24 test groups ok, zero failures).

Completes Phase 3. With Phases 1+2+3 landed:
- neuron parses `scheme:org/name`, routes per-source hf-hub
  Api with disambiguated cache.
- preflight returns structured errors before any device
  allocation.
- cortex catalogue declares per-model source jurisdiction
  and forwards it to neuron.

The registry itself (registry.helexa.ai service, MinIO,
nginx, mirror fabric) is the next moving piece — landing
under a separate project per the design discussion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:53:58 +03:00
d4e1b05956 feat(neuron,cortex-core): source-aware loader (scheme:org/name)
All checks were successful
CI / CUDA type-check (push) Successful in 46s
CI / Format (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 42s
CI / Clippy (push) Successful in 2m40s
build-prerelease / Build cortex binary (push) Successful in 4m23s
CI / Test (push) Successful in 5m28s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m39s
build-prerelease / Package cortex RPM (push) Successful in 1m19s
build-prerelease / Build neuron-ampere (push) Successful in 7m53s
build-prerelease / Build neuron-ada (push) Successful in 5m18s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m59s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m2s
Phase 1 of plan-source-aware-loader-preflight. Makes neuron's
loader treat `huggingface:org/name` and `helexa:org/name` as
first-class distinct sources with per-source endpoint + cache,
while staying backwards-compatible with bare `org/name` ids.
Zero behavior change for existing operator configs.

Motivation: helexa is adding an EU-hosted registry
(`registry.helexa.ai`) alongside HF. Both speak HF-compatible
wire format, but the bytes, jurisdiction, trust root, and cache
namespace are distinct. The loader needs to disambiguate which
registry serves a given model id, and to keep their caches from
colliding on disk when both happen to host the same `org/name`.

What lands:

- `cortex-core::source` — new module. `ModelSourceId { scheme,
  org, name }` with `FromStr` accepting both `scheme:org/name`
  and bare `org/name`. `Display` round-trips. `repo_path()`
  emits the `org/name` half for the hf-hub `Api::model(...)`
  call regardless of which scheme/endpoint we're hitting.
  Rejects malformed input with typed `ParseError` variants
  (empty scheme, missing slash, scheme with `/`, name with
  `:`, etc.).

- `neuron::config::CandleHarnessConfig` gains
  `default_source: Option<String>` and
  `sources: HashMap<String, SourceConfig>`. `SourceConfig`
  mirrors what `hf_hub::ApiBuilder` consumes: endpoint URL,
  optional `auth_env` (env var name read at startup so secrets
  stay out of TOML), and optional cache_dir. Defaults
  synthesise a `huggingface` entry pointing at
  `https://huggingface.co` with the legacy `hf_cache` field as
  its cache_dir — so existing configs that only set `hf_cache`
  keep working unchanged.

- `CandleHarness::new(bind_url, &CandleHarnessConfig)` replaces
  `CandleHarness::new(bind_url, hf_cache)`. Resolves every
  configured source's auth env var and cache dir up front so
  `hf_api_for(scheme)` is a pure HashMap lookup on the hot
  load path. Only the `huggingface` scheme gets the legacy
  `HF_HUB_CACHE`/`HF_HOME` env-var fallback chain; other
  schemes resolve to whatever the operator typed.

- `hf_api()` -> `hf_api_for(scheme)`. Builds an
  `hf_hub::Api` with the source's endpoint, cache_dir, and
  auth token. Errors with a useful message naming the
  configured schemes when an unknown scheme is requested.

- `CandleHarness::load_model` parses `spec.model_id` into a
  `ModelSourceId`, substitutes `default_source` for bare ids,
  and threads the parsed source through `preflight`,
  `resolve_files`, `resolve_dense_files`, `load_arch_gguf`,
  `load_arch_dense`, and `load_tp`. The hf-hub `Api::model()`
  call now uses `source_id.repo_path()` so registry calls hit
  the right URL shape regardless of scheme.

- `preflight()` signature gains a `&ModelSourceId` parameter
  (it's the canonical id for log lines and error display);
  `RepoFetchFailed.model_id` etc. now carry the
  scheme-qualified form so operator-visible errors echo
  exactly what was configured.

- `neuron.example.toml` documents the new
  `[harness.candle.sources.*]` table with commented-out
  examples for `huggingface` (explicit override) and `helexa`.

Tests:

- 13 new unit tests in `cortex-core::source` covering parse /
  display round-trip, default-scheme substitution semantics,
  and every `ParseError` variant.
- 6 new unit tests in `neuron::config` covering the
  `effective_sources` synth (legacy `hf_cache` carry-through,
  explicit override preservation, helexa-alongside-huggingface)
  and `effective_default_source` fallback.
- 2 new unit tests in `harness::candle::tests` covering
  multi-scheme `hf_api_for` routing, including the
  "unknown scheme" error path naming configured schemes.
- Preflight integration tests updated to construct
  `ModelSourceId` and assert against the scheme-qualified
  error form.

CI gate: cargo fmt --check, cargo clippy --workspace
--all-targets -- -D warnings, cargo test --workspace (all 24
test groups ok, zero failures).

Out of scope (Phase 3):
- Cortex catalogue `source` field — independent of Phase 1+2,
  ships when the registry comes online.
- `helexa` source endpoint itself — separate project; this
  PR adds the client-side rails only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:42:11 +03:00
61adff347a feat(neuron): preflight placement check with structured errors
Some checks failed
CI / CUDA type-check (push) Successful in 31s
CI / Format (push) Successful in 30s
build-prerelease / Resolve version stamps (push) Successful in 48s
CI / Test (push) Failing after 1m10s
CI / Clippy (push) Successful in 2m49s
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
build-prerelease / Build cortex binary (push) Successful in 4m25s
build-prerelease / Build neuron-blackwell (push) Successful in 5m53s
build-prerelease / Package cortex RPM (push) Successful in 1m20s
build-prerelease / Build neuron-ampere (push) Successful in 8m0s
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
build-prerelease / Build neuron-ada (push) Has been cancelled
Phase 2 of plan-source-aware-loader-preflight. Adds a one-RTT
placement feasibility check that runs before any device allocation,
NCCL handshake, or weight fetch. Replaces today's opaque
"fetch config.json … 404" failure mode (when an operator points
`tensor_parallel = 2` at a GGUF-only repo) with a structured
error that names the failure class and points at the fix.

What lands:

- `crates/neuron/src/harness/preflight.rs` — new module. Classifies
  a repo's siblings listing into `SourceFormat` (Gguf | DenseSafetensors
  | Mixed | Empty), applies the tp/quant feasibility table, returns a
  `PlacementPlan` on success or a typed `PreflightError` on rejection.
  `PreflightError` is `serde::Serialize` so the HTTP layer can emit
  the structured shape verbatim; it's `thiserror::Error` so log lines
  get a single-line Display when downcasting from anyhow. Includes
  best-effort Levenshtein-nearest suggestion for malformed quant names
  (the second sharp edge the HauhauCS scenario surfaced — operator
  writes `q6k` against filenames containing `Q6_K_P`, and today's
  matcher just says "no GGUF file matching quant").
- `CandleHarness::load_model` — calls `preflight(...)` first thing
  after the "already loaded" guard, before any `ensure_device_worker`
  or `resolve_*`. Failure wraps the typed error in `anyhow::Error` so
  the existing trait surface is unchanged; the HTTP handler and the
  startup logger downcast to recover the structured form.
- `crates/neuron/src/api.rs::load_model` handler — maps `PreflightError`
  to 422 Unprocessable Entity with `{"error": {"kind": "...",
  "model_id": "...", "suggestion": "..." }}`. Other failures keep
  the existing 400 + free-form `format!("{e:#}")` shape.
- `crates/neuron/src/startup.rs::load_default_models` — when the
  failure is a preflight rejection, log as `reason=<kind> detail=<msg>`
  instead of the opaque `error=<chain>`, so journalctl on beast will
  now show `reason=tp_requires_safetensors detail="repo is GGUF-only
  (8 .gguf files); TP requires dense safetensors..."` instead of
  `error=fetch config.json from HauhauCS/...: 404 Not Found`.

Tests:

- 18 unit tests in `harness/preflight.rs` covering classifier,
  quant matching, Levenshtein, error serialization, and the full
  feasibility table (gguf+tp rejected, gguf+bad-quant suggests
  nearest, gguf+good-quant ok, dense+tp ok, empty rejected, mixed
  prefers safetensors).
- 7 integration tests in `tests/preflight.rs` exercising the
  network path through an axum mock that serves hf-hub-compatible
  `/api/models/{org}/{name}/revision/main` payloads. Adds `tempfile`
  as a dev-dependency for per-test cache dirs.

Out of scope (deferred to subsequent phases):

- Phase 1 (source-aware loader plumbing — `scheme:org/name` parsing,
  per-scheme `SourceConfig`, cache disambiguation). Preflight runs
  against the single configured HuggingFace source today; the scheme
  threading lands cleanly when Phase 1 ships.
- Phase 3 (cortex catalogue source field).
- GGUF tensor-parallel loading. Preflight rejects this combination
  with `TpRequiresSafetensors`; the underlying loader gap is the
  separate `Helexa` curated-registry / heretic-rs conversation.

Refs #4-#9 architectural follow-up; no specific issue closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:24:30 +03:00
0af8c8d6e7 chore(ci): enable colored logs for readability 2026-06-01 09:06:28 +03:00
435fd10902 fix(neuron): macro-ify CUDA single-GPU route_token so DecodeStream type stays inferred
All checks were successful
CI / CUDA type-check (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 29s
CI / Format (push) Successful in 29s
CI / Clippy (push) Successful in 2m47s
build-prerelease / Build cortex binary (push) Successful in 4m27s
CI / Test (push) Successful in 5m40s
build-prerelease / Build neuron-blackwell (push) Successful in 5m47s
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
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 8m30s
build-prerelease / Build neuron-ada (push) Successful in 5m39s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m2s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m11s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m1s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m5s
Prerelease build (run 270) failed on commit cb30383 with:

  error[E0107]: struct takes 5 generic arguments but 0 generic
    arguments were supplied
       --> crates/neuron/src/harness/candle.rs:3554:41
        |
   3554 |     decode_stream: &mut tokenizers::DecodeStream<'_>,
        |                                     ^^^^^^^^^^^^

The Step-2-era refactor for #6's tool-call extraction added a
nested `async fn route_token` inside `stream_inference_via_worker`
that named `tokenizers::DecodeStream<'_>` as a parameter type.
`DecodeStream` actually has five generic parameters
(`'tok, M, N, PT, PP, D`) which makes naming it explicitly
painful — the working approach the CPU path uses is a macro,
where the body expands inline at the call site and the
decoder type stays inferred.

This commit replicates the CPU-side macro for the CUDA worker
path. Same shape, just with `.await` calls inside (macros tolerate
that since they expand inline into the enclosing async context).
Control flow uses a labelled-block + `consumer_alive` flag rather
than `return` so the macro stays generic over the surrounding
return type.

The CPU build (default-feature workspace, what `clippy` and `test`
jobs exercise) doesn't compile this `#[cfg(feature = "cuda")]`
branch, which is why local CI green-lit it. The cuda-check job
should catch this category of breakage now that #cb30383+CI-fix
landed; this commit just resolves the actual breakage on the
prerelease workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:59:56 +03:00
cb303832bc feat(neuron): render the model's chat_template with chat_template_kwargs
Some checks failed
CI / CUDA type-check (push) Failing after 58s
build-prerelease / Resolve version stamps (push) Successful in 39s
CI / Format (push) Successful in 40s
build-prerelease / Build neuron-ampere (push) Failing after 1s
CI / Clippy (push) Successful in 2m37s
build-prerelease / Build cortex binary (push) Successful in 4m47s
CI / Test (push) Successful in 6m13s
build-prerelease / Build neuron-blackwell (push) Failing after 5m34s
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
build-prerelease / Package cortex RPM (push) Successful in 1m27s
build-prerelease / Build neuron-ada (push) Failing after 7m20s
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
Closes #9.

Replaces the hardcoded `format_qwen3_prompt` ChatML glue with
`minijinja`-driven rendering of the model's own `chat_template`
from `tokenizer_config.json`. The request's `chat_template_kwargs`
flow into the Jinja context so model-specific levers
(Qwen3's `enable_thinking: false`, etc.) actually take effect.

## Implementation

- New `harness::chat_template` module with three entry points:
  - `load_chat_template_alongside(tokenizer_json_path)` — probes
    `tokenizer_config.json` in the same hf-hub snapshot directory.
    Supports both the canonical string-form `chat_template` and
    the array-form some tokenizers ship (multi-template models).
  - `render_chat_template(template, messages, tools, kwargs)` —
    renders via `minijinja`. Messages flatten into the
    `[{role, content}]` shape HF templates iterate, with
    per-message extras (`tool_calls`, `tool_call_id`) preserved.
    `tools` and `kwargs` add into the Jinja context so templates
    that reference them work without us interpreting their shape.
  - `chat_templates_enabled()` reads `NEURON_USE_CHAT_TEMPLATE`
    (default true). Falsy values force the fallback path
    everywhere — a kill switch for emergency rollback without a
    rebuild.

- `LoadedModel.chat_template: Option<String>` and the TP
  equivalent are populated once at load time. `None` (no
  tokenizer_config.json, parse error, missing field) routes the
  fallback path silently; logs go through `tracing::debug`/`warn`
  per condition.

- New `build_prompt_for_request(chat_template, request)` wraps
  the decision: when both the template is present AND the kill
  switch is off, render with kwargs from `request.extra` (looks
  up `chat_template_kwargs` and `tools` lazily). On render error
  → warn + fallback to `format_qwen3_prompt`. Wired into all four
  current prompt-build sites (single-GPU stream + non-stream, TP
  stream + non-stream).

## Dependency

`minijinja = "2"` with the `builtins`, `json`, and `serde`
features. Pure-Rust Jinja2 implementation, ~80KB compiled. Used
internally by HF's `tokenizers-rs` for its own chat templating;
the API surface we touch (`Environment::add_template` +
`Template::render(serde_value)`) is stable.

## Validation strategy

I can't byte-compare the new path's output against
`format_qwen3_prompt` for live models without GPU (CI doesn't
have one). The fallback path and kill switch are the mitigations
— a deploy can flip `NEURON_USE_CHAT_TEMPLATE=false` in the
neuron service env if the chat template renders surprisingly on
Qwen3-8B in production. The legacy formatter stays the
fail-closed default.

## Scope cuts (documented in module header)

- Tool-definition lifting from helexa-acp's system-prompt
  injection into the chat_template's native tools block is
  deferred. Today the request's `tools` array threads into the
  Jinja context, but helexa-acp continues to inject Hermes-format
  tool descriptions into the system prompt for backwards-compat
  with non-cortex endpoints.

## Tests

9 unit tests in `chat_template`: kill-switch matrix (truthy /
falsy / unset), template loading (string form, array form,
missing file, unparseable JSON, missing field), rendering
(basic conversation threading, kwargs forwarding, message-extras
threading for tool_calls).

215 workspace tests pass; clippy + fmt clean across all workspace
features (default).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:43:11 +03:00
44008358c5 feat(neuron): emit response.in_progress between created and output_item.added
Some checks failed
build-prerelease / Resolve version stamps (push) Successful in 40s
CI / Format (push) Successful in 44s
CI / Test (push) Failing after 1m5s
CI / Clippy (push) Successful in 2m36s
CI / CUDA type-check (push) Failing after 52s
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
build-prerelease / Build cortex binary (push) Successful in 4m32s
build-prerelease / Package cortex RPM (push) Successful in 1m20s
build-prerelease / Build neuron-blackwell (push) Failing after 5m42s
build-prerelease / Build neuron-ampere (push) Failing after 7m14s
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
build-prerelease / Build neuron-ada (push) Has been cancelled
Refs #7.

OpenAI's Responses API spec emits `response.in_progress` between
`response.created` and the first output-item event to mark
"request validated, model is generating". Some Responses-API
clients distinguish loading-spinner vs streaming-spinner UI based
on which event arrived last; emitting both keeps the wire shape
matched.

Carries the same shell as `response.created` (status=in_progress,
empty output, no usage yet) — both events are payload-light
bookkeeping, distinguished only by the event name.

The hosted-tool event families remaining in #7 (web_search_call,
code_interpreter_call, file_search_call, image_generation_call)
stay deferred until the underlying tools exist in neuron.

Updated `full_stream_emits_expected_event_sequence` to assert the
new event lands in position 1; downstream indexing shifted by one
across the existing test assertions. CI green, fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:30:34 +03:00
2f387f33f8 ci: export CUDA paths in cuda-check so cudarc build.rs finds nvcc
Some checks failed
build-prerelease / Build cortex binary (push) Blocked by required conditions
CI / Format (push) Successful in 34s
build-prerelease / Resolve version stamps (push) Successful in 41s
CI / Clippy (push) Failing after 1m7s
CI / Test (push) Failing after 56s
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-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
CI / CUDA type-check (push) Has been cancelled
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
act launches step shells without sourcing /etc/profile, so the
gitea_runner user's PATH lacks /usr/local/cuda-13.0/bin. cudarc's
build.rs panics with ENOENT on `nvcc --version` under the neuron
crate's cuda-version-from-build-system feature. build-prerelease.yml
already does this export — mirror it here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:28:04 +03:00
fc9a8c42a3 feat(neuron): extract <tool_call> blocks to structured tool_calls deltas
Some checks failed
build-prerelease / Build cortex binary (push) Blocked by required conditions
CI / Clippy (push) Waiting to run
CI / Test (push) Waiting to run
CI / CUDA type-check (push) Failing after 17s
build-prerelease / Resolve version stamps (push) Successful in 32s
CI / Format (push) Successful in 32s
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex 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
build-prerelease / Build neuron-blackwell (push) Has been cancelled
CI / Build cortex SRPM (push) Has been cancelled
build-prerelease / Build neuron-ampere (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
Closes #6.

Same model-agnostic seam as #8 but for tool-call markers
(`<tool_call>` / `</tool_call>` on Qwen3-Coder, Hermes-format,
DeepSeek-Coder, gpt-oss, …). Lets Zed's tool-use feature and any
other vanilla OpenAI chat client get structured `tool_calls` deltas
out of cortex without having to parse markers themselves.

## Implementation

1. **Tokenizer probe at load time** (`detect_tool_call_token_pair`
   in `wire::event`) — same shape as the reasoning-marker probe
   from #8. Both open AND close must resolve to single token ids;
   non-tool-use models get `None` and pass through unchanged.
   Stored on `LoadedModel.tool_call_tokens` and the TP analogue.

2. **New `InferenceEvent::ToolCall` variant** — carries `index`
   (call slot, per-turn counter), generated `id` (`call_<hex>_<idx>`),
   `name`, and the complete `arguments` JSON string. One event per
   parsed call.

3. **Token-level state machine** in all three streaming paths
   (CPU `run_inference_streaming`, CUDA single-GPU
   `stream_inference_via_worker`, CUDA TP `chat_completion_tp_stream`)
   layered on top of #8's reasoning routing:
   - `<tool_call>` token → enter buffering state, clear buffer.
   - Tokens while buffering → accumulate into `tool_call_buf`
     via the decoder (so multi-byte UTF-8 still buffers correctly)
     without emitting anything visible.
   - `</tool_call>` token → take the buffer, parse with
     `parse_tool_call_body` (extract `name` + `arguments`),
     emit a structured `ToolCall` event with a fresh `call_<hex>`
     id and the parsed fields.
   - On parse failure → fall back to re-emitting the original
     `<tool_call>{buf}</tool_call>` block as plain text content
     so helexa-acp's existing `ToolCallParser` repair passes still
     have a chance to recover the call.

4. **OpenAI chat projector** emits the OpenAI streaming
   `tool_calls` delta shape on `InferenceEvent::ToolCall` —
   `{tool_calls: [{index, id, type:"function",
   function:{name, arguments}}]}`. One chunk per call slot.

5. **OpenAI Responses projector** drops `ToolCall` events for
   now (Responses-side function_call event family routing tracked
   under #7); the chat path is what unblocks Zed's tool use today.

## Acceptance

- Vanilla OpenAI chat clients (Zed's tool-use feature, any other
  OpenAI-compatible tool-call consumer) get structured tool_calls
  deltas against cortex+neuron without having to parse `<tool_call>`
  markers in content.
- helexa-acp continues to work — when neuron parses cleanly, it
  consumes the structured deltas through its existing decoder.
  When the model emits malformed JSON, neuron falls back to text
  pass-through and helexa-acp's `ToolCallParser` recovers via the
  same path it always did.
- Models without tool-call markers in their tokenizer pass through
  unchanged.
- No hardcoded model knowledge — entirely driven by tokenizer
  metadata.

## Tests

2 new detection tests in `wire::event` (Qwen3-style marker
detection, no-marker case). The streaming paths themselves stay
covered by the existing chat-completions integration tests; full
end-to-end exercise of the new path requires GPU-loaded models
and lives outside the CI test surface.

215 workspace tests pass; clippy + fmt clean across the
workspace.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:26:31 +03:00
7733eecba5 feat(neuron): strip reasoning from chat completions by default
Some checks failed
CI / CUDA type-check (push) Failing after 18s
build-prerelease / Resolve version stamps (push) Successful in 32s
CI / Format (push) Successful in 32s
CI / Clippy (push) Successful in 2m36s
build-prerelease / Build cortex binary (push) Successful in 4m29s
CI / Test (push) Successful in 5m19s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m56s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 7m45s
build-prerelease / Build neuron-ada (push) Successful in 5m24s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m53s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m2s
Closes #8.

Reasoning-capable models (Qwen3, DeepSeek-R1, gpt-oss, Mistral
Magistral, …) emit `<think>...</think>` blocks inline in their
content stream. The chat-completions wire format has no slot for
reasoning, so until this change every consumer either parsed the
markers themselves (helexa-acp) or wrote the raw scratchpad
content into their UI (Zed's commit-message generator — visible
as the leaked reasoning block on every generated commit message
against benjy's Qwen3-8B).

## Implementation, model-agnostic by design

The neuron side now does token-level routing without any
hardcoded model knowledge:

1. **At load time** (`detect_reasoning_token_pair` in
   `wire::event`), probe the tokenizer's vocabulary for a known
   reasoning-marker pair: `<think>` / `</think>` (Qwen3,
   DeepSeek-R1, gpt-oss), `[THINK]` / `[/THINK]` (Mistral
   Magistral), and a couple of derivatives. Each marker must
   resolve to a single token id; if both open and close resolve,
   stash on `LoadedModel.reasoning_tokens` (similarly
   `TpLoadedModel`). Non-reasoning models get `None` and pass
   through unchanged.

2. **At inference time**, the three streaming paths
   (`run_inference_streaming` CPU, `stream_inference_via_worker`
   CUDA single-GPU, `chat_completion_tp_stream` CUDA TP) now
   check each sampled token against the pair via the new
   `handle_reasoning_marker` helper before feeding it to the
   detokeniser. Open marker → set `in_reasoning = true`, drop
   the marker. Close marker → unset, drop. Other tokens go
   through `emit_delta(_blocking)` which now picks
   `ReasoningDelta` or `TextDelta` based on state. Markers
   never appear in the streamed output.

3. **In `wire::openai_chat`**, the projector splits into:
   - `project_chat_stream` (unchanged signature; default
     behaviour — drops `ReasoningDelta`)
   - `project_chat_stream_with(rx, …, ChatProjectionConfig)` —
     when `include_thinking: true` and `reasoning_markers:
     Some(_)`, re-wraps reasoning content with the literal
     open/close marker text and emits as content deltas.
     Preserves the on-the-wire shape that helexa-acp's
     `ThinkParser` expects.

4. **HTTP handler** reads `x-include-thinking: true` (case-
   insensitive `1`/`true`/`yes`) from the request headers and
   threads it into the projection config. cortex-gateway already
   forwards arbitrary headers verbatim, so the opt-in works
   end-to-end without gateway changes.

5. **helexa-acp's `openai_chat` provider** sets
   `x-include-thinking: true` on every request so its existing
   `ThinkParser` keeps receiving the marked content stream.
   `ThinkParser` itself is unchanged — needed for endpoints that
   aren't reasoning-aware (OpenRouter, OpenAI directly, etc.).

## Acceptance

- Zed's commit-message generator (vanilla chat-completions
  client, no `x-include-thinking`) gets clean commit messages
  with no `<think>` block.
- helexa-acp sessions continue to render thinking in Zed's
  thought UI via the opt-in path.
- Models without reasoning tokens declared in their tokenizer
  pass through unchanged.
- Implementation contains zero references to "qwen3" or any
  specific model — entirely driven by tokenizer metadata.

## Tests

9 new tests in `wire::event` (token-pair detection across 4
marker conventions, edge cases) and `wire::openai_chat` (default
drop, opt-in re-wrap with multi-chunk reasoning, close-marker on
Finish, fallback when markers absent, off-switch with markers
present). All 213 workspace tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:55:04 +03:00
fdc0adb738 docs(helexa-acp): README + example config for end-user onboarding
Some checks failed
CI / CUDA type-check (push) Failing after 18s
CI / Format (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 35s
CI / Clippy (push) Successful in 2m36s
build-prerelease / Build cortex binary (push) Successful in 4m13s
CI / Test (push) Successful in 5m6s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m40s
build-prerelease / Package cortex RPM (push) Successful in 1m19s
build-prerelease / Build neuron-ampere (push) Successful in 7m53s
build-prerelease / Build neuron-ada (push) Successful in 5m12s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m55s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m4s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m0s
Stage 7. Walks a new user from "never heard of helexa-acp" to
"chatting via Zed against helexa or a public API in 10 minutes":

- crates/helexa-acp/README.md — install (from source / COPR),
  quick-start env-var path, multi-endpoint TOML, full Zed setup,
  endpoint cookbook (cortex/neuron, OpenAI, Anthropic, OpenRouter,
  LM Studio, multi-cortex), three session modes (Default / Bypass /
  Plan) with their tool tables, tool surface + path-handling rules,
  session resume, context compaction, troubleshooting for the
  five failure modes a new user is likely to hit, and architecture
  reference for contributors.

- helexa-acp.example.toml — copy-paste-and-edit starter config at
  the repo root, mirroring the existing cortex.example.toml /
  neuron.example.toml pattern.

No code changes. fmt + clippy clean as a sanity check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:25:56 +03:00
8fa1d1962e feat(helexa-acp): anthropic-messages provider
Some checks failed
CI / CUDA type-check (push) Failing after 18s
CI / Format (push) Successful in 32s
build-prerelease / Resolve version stamps (push) Successful in 35s
CI / Test (push) Failing after 59s
CI / Clippy (push) Successful in 2m28s
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
build-prerelease / Build cortex binary (push) Successful in 4m17s
build-prerelease / Build neuron-blackwell (push) Successful in 5m32s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 7m50s
build-prerelease / Build neuron-ada (push) Successful in 5m55s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m55s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m2s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m4s
Stage 6b. Third provider impl, completing the wire-format trio
(openai-chat, openai-responses, anthropic-messages). Lets a
helexa-acp endpoint configured with `wire_api = "anthropic-messages"`
drive Claude models — either against Anthropic directly or via
cortex's /v1/messages translation surface.

## Encoder (CompletionRequest → Anthropic body)

- System messages flatten to the top-level `system` field
  (concatenated with blank lines when there are multiple).
- User text → `{role:"user", content:"..."}`.
- User MultiPart (text + images) → `content` array with Anthropic's
  distinct image shape: `{type:"image", source:{type:"base64",
  media_type, data}}` — structurally different from OpenAI's
  `image_url` data URI.
- Assistant text → `{role:"assistant", content:"..."}`.
- Assistant tool_calls → `content` array with optional `{type:"text"}`
  block plus one `{type:"tool_use", id, name, input:<parsed json>}`
  per call. The internal arguments JSON string is parsed back to a
  Value before encoding (Anthropic requires the parsed form);
  malformed JSON falls back to a String input so the request body
  still serialises.
- Tool result → `{role:"user", content:[{type:"tool_result",
  tool_use_id, content}]}` per Anthropic's convention (no separate
  `tool` role).
- `max_tokens` is required by Anthropic; defaults to 8192 when the
  request doesn't specify.

## Decoder (Anthropic SSE → CompletionEvent)

Named SSE events:

- `message_start` → captures input_tokens from `usage` for the
  eventual UsageStats.
- `content_block_start` (type=text) → TextDelta (initial text, if any).
- `content_block_start` (type=tool_use) → ToolCallStart; if a
  pre-buffered `input` is present, also emits a single
  ToolCallArgsDelta.
- `content_block_start` (type=thinking, for extended-thinking
  models) → ReasoningDelta.
- `content_block_delta` (text_delta) → TextDelta.
- `content_block_delta` (input_json_delta) → ToolCallArgsDelta,
  correlated by block index.
- `content_block_delta` (thinking_delta) → ReasoningDelta.
- `message_delta` → Usage (final output_tokens) + Finish with
  stop_reason mapped: end_turn/stop_sequence → "stop", max_tokens
  → "length", tool_use → "tool_calls".
- `message_stop` → stream terminates.
- `ping` ignored (Anthropic's keep-alive).
- `error` → yields Err and ends the stream.

## Wiring

- Authentication: `x-api-key` + `anthropic-version: 2023-06-01`
  headers (not Bearer). Both ship when api_key is configured;
  servers that don't care (cortex) ignore them.
- `WireApi::AnthropicMessages` in build_provider now constructs
  the provider instead of erroring "reserved for future".
- `provider::mod.rs` registers the new module.

18 new unit tests: encoder (system collapse, multi-system concat,
default max_tokens, multipart with image, tool_use blocks, tool
results, malformed JSON arg fallback), decoder (text streaming,
tool_use lifecycle, max_tokens→length mapping, empty deltas, ping
events, error events, cancellation, malformed payload skip,
thinking blocks).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:01:59 +03:00
cad7552104 ci: clear sccache env on cuda-check so cargo doesn't try to wrap rustc
Some checks failed
CI / Test (push) Waiting to run
CI / CUDA type-check (push) Failing after 18s
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Format (push) Successful in 31s
CI / Clippy (push) Successful in 2m25s
build-prerelease / Build cortex binary (push) Successful in 5m19s
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex 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
build-prerelease / Build neuron-blackwell (push) Has been cancelled
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
build-prerelease / Build neuron-ampere (push) Has been cancelled
CI run 255 job 3 (CUDA type-check) fails with:

  error: could not execute process `*** rustc -vV` (never executed)
    Caused by: No such file or directory (os error 2)

The redacted `***` is `sccache`. The ci.yml workflow-level env block
sets `RUSTC_WRAPPER: sccache` because the generic `rust` runner has
sccache installed and routes the cache to caveman.kosherinata.internal.
The new `cuda-check` job runs on `cuda-13.0` (where nvcc lives), and
that runner doesn't carry sccache on PATH — so cargo's first action
(`sccache rustc -vV` to probe the compiler version) fails before
borrow-check even starts.

`build-prerelease.yml`, which uses the same `cuda-13.0` runner for
the actual release neuron builds, deliberately does NOT set
RUSTC_WRAPPER. That's the pattern this commit applies.

Fix: override `RUSTC_WRAPPER` (plus the SCCACHE_* and AWS_* env
locally on the job. We lose caching on the cuda-check job (it's
borrow-check-only and finishes in a couple minutes anyway), but
the gate runs.

The job's purpose — fail fast on `#[cfg(feature = "cuda")]`
borrowck errors that the default-feature gate misses — is what
matters, and that purpose was undermined by the env inheritance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:55:18 +03:00
1818dfb337 feat(helexa-acp): openai-responses provider
Some checks failed
CI / Format (push) Successful in 38s
build-prerelease / Resolve version stamps (push) Successful in 45s
CI / Clippy (push) Successful in 2m35s
CI / CUDA type-check (push) Failing after 12s
CI / Test (push) Successful in 5m54s
build-prerelease / Build cortex binary (push) Successful in 5m9s
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
build-prerelease / Package cortex RPM (push) Successful in 1m20s
build-prerelease / Build neuron-blackwell (push) Successful in 4m36s
build-prerelease / Build neuron-ampere (push) Successful in 7m11s
build-prerelease / Build neuron-ada (push) Successful in 6m33s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m55s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m56s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 59s
Stage 6a. Implements the `Provider` trait for OpenAI's Responses
API surface, parallel to the existing `OpenAIChatProvider`. Lets a
helexa-acp endpoint configured with `wire_api = "openai-responses"`
drive a `/v1/responses` server (today: neuron through cortex; later:
OpenAI directly) using the same agent-loop machinery the chat
provider already supports.

## Encoder (CompletionRequest → Responses body)

- System messages collapse into a single top-level `instructions`
  string. Multiple system messages concatenate with blank lines so
  ordering is preserved.
- User messages become `{type:"message", role:"user", content:…}`
  input items. Text content stays a bare string; MultiPart content
  (text + images, post-Stage 5) becomes a
  `[{type:"input_text"}, {type:"input_image"}]` array with images
  encoded as `data:{mime};base64,{data}` URIs — exactly the shape
  neuron's `wire::openai_responses::request_to_chat` accepts.
- Assistant text turns become an `output_text` content part inside
  a `message` item.
- Assistant tool-call turns become `function_call` input items.
- Tool result turns become `function_call_output` input items.
- `max_tokens` translates to `max_output_tokens`.

## Decoder (Responses SSE → CompletionEvent)

Reads named events on the SSE `event:` line:

- `response.output_text.delta` → `CompletionEvent::TextDelta`
- `response.output_item.added` with `type:"function_call"` →
  `CompletionEvent::ToolCallStart` (and, when the upstream
  pre-buffers fully, a single `ToolCallArgsDelta`)
- `response.function_call_arguments.delta` →
  `CompletionEvent::ToolCallArgsDelta`, correlated back to the
  tool-call slot by output_index.
- `response.completed` → `CompletionEvent::Usage` (if present) +
  `CompletionEvent::Finish` with reason mapped from `status`:
  `"completed"` → `"stop"`, `"incomplete"` → `"length"`.
- Bookkeeping events (`response.created`, `response.in_progress`,
  `*.content_part.*`, `*.output_text.done`, `*.output_item.done`,
  `*.function_call_arguments.done`, reasoning_*) are skipped.

## Wiring

- `EndpointConfig::responses_url()` joins `{base_url}/responses`.
- `WireApi::OpenAiResponses` in `build_provider` constructs the new
  provider (was previously a "reserved for future" error).
- `provider::mod.rs` registers the new module.

## Cuts (carried over from neuron-side issues)

- The decoder's `ToolCall*` handling fires correctly when the
  upstream emits `function_call` items, but the neuron candle
  harness doesn't yet (Refs #6). Real tool-call testing against
  cortex+neuron stays on the chat path until #6 lands.
- Reasoning events (`response.reasoning_*`) are deliberately
  dropped today; once neuron emits `InferenceEvent::ReasoningDelta`
  (Refs #5) the projector on the neuron side will start firing the
  reasoning event family and this decoder will need a matching
  case to route them to `CompletionEvent::ReasoningDelta`.

13 new unit tests cover encoder (system collapse, multipart user
input, assistant output_text encoding, tool-call round-trip via
function_call items) and decoder (text streaming, empty deltas
dropped, length finish, function_call lifecycle, inline-arguments
shape, cancellation, malformed payload skip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 11:30:25 +03:00
5ed1140c97 feat(cortex-gateway): proxy /v1/responses to neuron
Some checks failed
CI / CUDA type-check (push) Failing after 12s
build-prerelease / Resolve version stamps (push) Successful in 33s
CI / Format (push) Successful in 37s
CI / Clippy (push) Failing after 1m5s
build-prerelease / Build cortex binary (push) Successful in 4m26s
CI / Test (push) Successful in 5m17s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m39s
build-prerelease / Package cortex RPM (push) Successful in 1m24s
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
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
Step 3 of the Responses rollout: plain proxy route on the gateway,
no translation. Neuron speaks the Responses API natively after Step
2 (commit 957f704), so the gateway just needs the same routing
shape it uses for /v1/chat/completions — extract `model`, resolve
via router::resolve, forward verbatim.

- New `POST /v1/responses` handler in handlers.rs::responses.
- Mock neuron under tests/common/mod.rs gains a `/v1/responses`
  endpoint that mirrors the ResponsesResponse shape neuron emits.
- New integration test file `tests/responses.rs` exercises:
  - Happy path (200, body round-trips, ResponsesUsage shape).
  - Unknown model → 404 (matches chat-completions error shape).
  - Missing `model` field → 400 (same extract_model helper).

Streaming proxy works through the same path as chat completions —
the upstream Content-Type (`text/event-stream` for stream:true,
`application/json` otherwise) propagates through proxy_with_metrics
unchanged. Live-stream integration tests against a streaming mock
deferred until we exercise the path against a real neuron, since
the chat-completions streaming test already covers the proxy's
SSE forwarding mechanics.

Three new tests; clippy + fmt clean across the workspace.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 11:21:43 +03:00
957f704efa feat(neuron): OpenAI Responses API + ci cuda-check runner label
Some checks failed
build-prerelease / Package cortex RPM (push) Blocked by required conditions
CI / CUDA type-check (push) Failing after 11s
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Format (push) Successful in 32s
CI / Clippy (push) Successful in 2m31s
build-prerelease / Build cortex binary (push) Successful in 4m32s
CI / Test (push) Successful in 5m42s
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
build-prerelease / Build neuron-blackwell (push) Successful in 6m8s
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
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
Step 2 of the Responses rollout: native `/v1/responses` endpoint on
neuron that consumes the same InferenceEvent stream as
`/v1/chat/completions` but emits it as the Responses API's named
SSE event family. No gateway-side translation.

## Surface

- `cortex-core::responses` envelope types: `ResponsesRequest`,
  `ResponsesInput` (text | items), `ResponsesInputItem` (message |
  function_call | function_call_output | reasoning),
  `ResponsesContentPart` (input_text | input_image | output_text),
  `ResponsesResponse`, `ResponsesOutputItem`, `ResponsesUsage`. Plus
  a `events::*` constant module so the projector and the wire shape
  stay in sync without string-typos.

- `neuron::wire::openai_responses`:
  - `request_to_chat(req)` flattens Responses input + instructions
    into a `ChatCompletionRequest` the candle harness already
    understands. Text-only Parts collapse to a string; mixed
    text+image Parts go to chat's content-array shape; reasoning
    items drop; function_call / function_call_output round-trip
    via tool_calls / tool_call_id metadata so the surface is
    consistent for the day the harness emits tool calls.
  - `project_responses_stream(rx, meta)` reads InferenceEvents
    and emits the eight named events that compose a Responses
    stream: response.created → output_item.added → content_part.added
    → output_text.delta×N → output_text.done → content_part.done
    → output_item.done → response.completed. Synthesises start
    frames if the producer skips Start (poisoned model, early
    disconnect) so the stream stays coherent.
  - `build_response(meta, text, reason, usage)` for the
    non-streaming path.

- `CandleHarness::inference_stream(req)` extracted from
  `chat_completion_stream`, returning a typed `InferenceStream`
  (event receiver + id/created/model_id metadata). Both
  `chat_completion_stream` and the new `responses_stream` are now
  thin wrappers that pick their wire projection. TP path got the
  same treatment (`chat_completion_tp_stream` → `inference_tp_stream`).

- `POST /v1/responses` route on neuron. Non-streaming returns one
  buffered `ResponsesResponse`; streaming returns axum SSE with
  both event names and JSON data per frame (Responses, unlike
  chat completions, uses named `event:` lines). Reused
  `inference_error_response` helper hoisted out so the chat and
  responses handlers share the InferenceError → HTTP mapping.

## CI

Also bundles the `cuda-check` runner-label fix from feedback on
commit 1859777: `runs-on: rpm` doesn't ship the CUDA toolkit so
cudarc's nvcc-version build script blew up. Switched to
`runs-on: cuda-13.0` per the existing labels.

## Scope cuts (documented in the modules)

- `previous_response_id` rejected at translate time with 400
  (`code: chained_conversation_not_supported`) — stateful chained
  conversations need a persistence layer we haven't built.
- Reasoning items dropped (no Qwen3 `<think>` routing yet).
- Single output item per response (one `"message"` carrying text);
  `function_call` items reserved but not synthesised.
- Streaming events cover the core set; `response.in_progress`
  and the web_search / image_generation event families are
  out-of-scope.

22 new tests: 5 in cortex-core (envelope round-trips), 13 in
neuron::wire (request translator + projector + non-streaming
builder), 4 in neuron's tests/api.rs (route surface — 503 when no
candle, 400 on previous_response_id, 404 on missing model for
both stream and non-stream).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 11:13:44 +03:00
1859777332 ci: add cuda type-check job so CUDA-only borrowck errors fail fast
Some checks failed
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Format (push) Successful in 37s
CI / CUDA type-check (push) Failing after 3m8s
CI / Clippy (push) Successful in 2m27s
build-prerelease / Build neuron-blackwell (push) Successful in 5m46s
build-prerelease / Build cortex binary (push) Successful in 5m0s
build-prerelease / Build neuron-ampere (push) Successful in 7m39s
CI / Test (push) Successful in 5m37s
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
build-prerelease / Package cortex RPM (push) Successful in 1m33s
build-prerelease / Build neuron-ada (push) Successful in 5m12s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m9s
Run 244 caught a use-of-moved-value in a `#[cfg(feature = "cuda")]`
block that the default-feature workspace clippy/test gate had no
chance of seeing. The error appeared only when the RPM build
workflow compiled with `--features cuda` — 30+ minutes after push.

Add a `cuda-check` job to ci.yml that runs `cargo check -p neuron
--features cuda --all-targets` on the rpm runner (where nvcc /
cudarc build deps live; the generic `rust` runner doesn't have
them). Borrow-check only — we never run tests here, the runner
has no GPU. Same retry pattern as clippy/test.

Both SRPM jobs (`srpm-cortex`, `srpm-neuron`) now gate on
`cuda-check` so a CUDA build break can't reach the release pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:49:51 +03:00
6927286cab fix(neuron): clone id/model_id before TP spawn so wire projector can use them
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
CI / Format (push) Successful in 39s
build-prerelease / Resolve version stamps (push) Successful in 40s
CI / Clippy (push) Successful in 2m34s
CI / Test (push) Successful in 5m40s
build-prerelease / Build cortex binary (push) Successful in 5m16s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m49s
build-prerelease / Package cortex RPM (push) Successful in 1m25s
build-prerelease / Build neuron-ampere (push) Successful in 7m38s
build-prerelease / Build neuron-ada (push) Successful in 5m34s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
The Step 1 refactor moved the InferenceEvent receiver wrap to *after*
the orchestration spawn in chat_completion_tp_stream, but the spawn
moves both `id` and `model_id` into its async closure (used heavily
by acquire_pool_lock, NCCL ops, and tracing). Result: borrowck
error E0382 use-of-moved-value on the wire_chat::project_chat_stream
call.

The non-CUDA build doesn't exercise this branch (it lives behind
`#[cfg(feature = "cuda")]`) which is why the workspace clippy/test
gate passed locally and on the regular CI workflow. The RPM build
workflow, which compiles with --features cuda, caught it (run 244
jobs 2/3/4 against beast / ampere / ada respectively, all the same
error).

Fix: snapshot `id` and `model_id` into `projector_id` /
`projector_model_id` before the spawn, use those at the projector
call site. The originals stay free to be moved into the closure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:37:10 +03:00
302ccfb982 refactor(neuron): introduce InferenceEvent + wire projection layer
Some checks failed
build-prerelease / Resolve version stamps (push) Successful in 31s
CI / Format (push) Successful in 38s
CI / Clippy (push) Successful in 3m28s
build-prerelease / Build neuron-blackwell (push) Failing after 6m4s
build-prerelease / Build neuron-ampere (push) Failing after 7m20s
CI / Test (push) Successful in 7m29s
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
build-prerelease / Build neuron-ada (push) Failing after 4m57s
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) Successful in 4m19s
build-prerelease / Package cortex RPM (push) Successful in 1m24s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
Step 1 of the OpenAI Responses API rollout. Pure refactor — no new
endpoints, no behaviour change on the wire. Lays the seam for
emitting Responses-shaped streaming events from the same harness
output as chat completions in Step 2.

- New `neuron::wire` module tree:
  - `wire::event::InferenceEvent` — format-agnostic enum
    (Start, TextDelta, ReasoningDelta, Finish) the candle harness
    now emits as its native streaming currency.
  - `wire::event::FinishReason` — typed reason that maps cleanly
    onto OpenAI `finish_reason`, OpenAI Responses `status`, and
    Anthropic `stop_reason` strings.
  - `wire::openai_chat::project_chat_stream` — async task that
    consumes an InferenceEvent receiver and produces a
    ChatCompletionChunk receiver, stamping per-request metadata
    (id, created, model_id) onto every chunk. Output matches the
    pre-refactor wire shape bit-for-bit.

- candle.rs refactored to emit InferenceEvent on its internal
  channel through all three streaming paths (CPU
  run_inference_streaming, CUDA single-GPU stream_inference_via_worker,
  CUDA TP chat_completion_tp_stream). The streaming functions lost
  their id/created/model_id parameters since wire-format metadata
  now lives in the projector.

- emit_delta + emit_delta_blocking simplified to single-purpose
  TextDelta emitters with no wire-format coupling.

- chat_completion_stream wraps the InferenceEvent receiver in
  wire_chat::project_chat_stream before returning so the
  /v1/chat/completions HTTP handler keeps consuming
  ChatCompletionChunks unchanged. External signature preserved.

Also fixes a pre-existing helexa-acp test race (three modules each
declared their own static LOCK for HOME mutation, so cross-module
parallelism flaked tests that read HOME at runtime). Consolidated
onto a single crate-wide path_util::ENV_LOCK.

122 helexa-acp tests + 44 neuron tests pass (5 new wire projection
tests). fmt + clippy --workspace -- -D warnings clean. Ran helexa-acp
suite 3x to confirm the env race is closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:30:17 +03:00
df0abfe4d4 feat(helexa-acp): image input for vision-capable models
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 34s
CI / Format (push) Successful in 37s
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 5m4s
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
build-prerelease / Build neuron-blackwell (push) Successful in 6m2s
build-prerelease / Build neuron-ampere (push) Successful in 7m49s
build-prerelease / Build neuron-ada (push) Successful in 5m27s
build-prerelease / Build cortex binary (push) Successful in 4m16s
build-prerelease / Package cortex RPM (push) Successful in 1m19s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m2s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m10s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m47s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m2s
Stage 5. Zed clipboard/DnD images get forwarded as OpenAI
content-array messages on user turns.

- New MessageContent::MultiPart variant + MessagePart (Text|Image)
  + ImageData struct (mime_type, base64 data, optional uri).
- flatten_prompt now produces structured content: collapses to
  Text when every block is text (some upstreams treat array-form
  as vision-only and refuse on text-only models), otherwise
  produces MultiPart preserving block order.
- OpenAI encoder emits `[{type:"text",text:…}, {type:"image_url",
  image_url:{url:"data:{mime};base64,{data}"}}]` for MultiPart user
  messages. Data URIs are used over remote `uri` because they
  round-trip through every upstream we care about.
- prompt_capabilities.image = true at initialize so Zed actually
  sends image blocks.
- compaction estimates ~512 tokens per image (the middle of the
  Qwen3-VL / OpenAI detail range) so the budget tracker doesn't
  pretend images are free.
- session/load replays image-bearing user turns by surfacing the
  text parts verbatim and rendering each image as a "[image: {mime}
  ({n} bytes)]" placeholder chunk — Zed can show the prior text
  context even though re-uploading the bytes through ACP isn't
  meaningful for resume.
- 4 new tests: flatten produces MultiPart in block order, image-only
  prompts still flatten to MultiPart, encoder emits the correct
  array shape, text-only encoding stays as the string form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:43:00 +03:00
b9016571f6 feat(helexa-acp): expand ~ / $HOME and fall back to local fs on ACP read errors
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 44s
CI / Format (push) Successful in 50s
CI / Clippy (push) Successful in 2m34s
build-prerelease / Build cortex binary (push) Successful in 4m29s
CI / Test (push) Successful in 5m13s
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
build-prerelease / Package cortex RPM (push) Successful in 1m18s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
build-prerelease / Build neuron-ampere (push) Successful in 8m15s
build-prerelease / Build neuron-ada (push) Successful in 5m23s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Two related polish fixes for daily use:

- New `path_util` module expands `~`, `~/…`, `$HOME`, and `$HOME/…`
  prefixes in every tool that takes a path (read_file, write_file,
  edit_file, list_dir, bash cwd). The expansion is also applied to
  the plan-mode write gate so `~/.local/share/helexa-acp/plans/…`
  comparisons behave correctly regardless of which form the model
  emits.
- `read_file` now falls back to `std::fs::read_to_string` when ACP's
  `fs/read_text_file` errors out. Zed's workspace-scoped read was
  the source of "model can't see ~/git/architecture/generic.md"
  when the session cwd is a different project; the fallback lets
  the agent pull in shared material that lives outside the active
  workspace, the same way `list_dir` already does via local
  `std::fs::read_dir`. Local fallback honours line/limit args.

The fallback also produces a combined error message when both ACP
and local-fs reads fail, so the model sees what actually broke
rather than just the ACP-side error.

14 new unit tests cover path_util's prefix matrix, fallback
success/failure paths, and the line/limit slicing in fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:28:58 +03:00
adbc52bfcd feat(helexa-acp): model picker + session/set_model handler
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 37s
CI / Format (push) Successful in 41s
CI / Clippy (push) Successful in 2m32s
build-prerelease / Build cortex binary (push) Successful in 4m45s
CI / Test (push) Successful in 5m52s
build-prerelease / Build neuron-blackwell (push) Successful in 5m59s
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
build-prerelease / Build neuron-ampere (push) Successful in 7m21s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ada (push) Successful in 4m54s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m54s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m58s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m48s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m3s
Stage 4. Zed's model dropdown now lists every model from every
configured endpoint, and switching it routes the next prompt to a
new endpoint+model.

- Enable `unstable_session_model` on the agent-client-protocol dep
  so SessionModelState / SetSessionModelRequest / ModelInfo are
  available.
- Agent::new becomes async and calls Provider::list_models on every
  provider at startup; per-endpoint failures warn-and-skip instead
  of aborting the agent.
- With a single endpoint configured, model ids appear bare; with
  multiple endpoints every id carries the `endpoint:` prefix so the
  picker is unambiguous and parse_model_selector routes correctly.
- NewSessionResponse and LoadSessionResponse attach SessionModelState
  with the session's current model id + the aggregated catalogue.
- session/set_model: validates the requested model id against
  resolve_provider, mutates session.model_id, and persists so the
  on-disk transcript reflects the new model.

Three new aggregate_models tests cover the prefixing rule (bare vs
multi-endpoint) and warn-and-skip on a failing endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:10:16 +03:00
537a0fe7f2 feat(helexa-acp): context compaction for small-context local models
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 26s
CI / Format (push) Successful in 29s
CI / Clippy (push) Successful in 2m26s
build-prerelease / Build cortex binary (push) Successful in 5m17s
build-prerelease / Build neuron-blackwell (push) Successful in 5m51s
CI / Test (push) Successful in 5m53s
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
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 7m58s
build-prerelease / Build neuron-ada (push) Successful in 5m30s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m7s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m40s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m0s
A new src/compaction.rs module projects rolling conversation history
into a token budget before each completion. Older tool results and
assistant prose get elided to one-line markers; system prompts, user
turns, and the last KEEP_TAIL=4 messages stay verbatim. tool_call_id
pairing is preserved so OpenAI strict-schema providers keep working.

Driven by a new per-endpoint `context_window` config field (also
HELEXA_ACP_CONTEXT_WINDOW for the env-only single-endpoint case).
When set, prompt budget = context_window - max_tokens - 512_safety;
when unset, behaviour is unchanged.

Without this, a 32 K Qwen3 dies with `prompt_too_long` after the
first few read_file results pile up in history — the symptom seen
in plan-mode dogfooding on beat.

10 new unit tests cover the compaction strategy and the prompt
budget arithmetic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 08:22:01 +03:00
cbadfcf112 feat(helexa-acp): plan mode — third session mode for read-and-plan-only flows
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 37s
CI / Format (push) Successful in 36s
CI / Clippy (push) Successful in 2m44s
CI / Test (push) Successful in 5m3s
build-prerelease / Build cortex binary (push) Successful in 4m36s
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
build-prerelease / Package cortex RPM (push) Successful in 1m27s
build-prerelease / Build neuron-blackwell (push) Successful in 6m37s
build-prerelease / Build neuron-ampere (push) Successful in 8m12s
build-prerelease / Build neuron-ada (push) Successful in 5m32s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Plan mode is the most restrictive of the three session modes: bash is
disabled outright, writes are confined to a per-project plan directory
under $XDG_DATA_HOME/helexa-acp/plans/<basename>-<8hex>/, and reads /
list_dir are unrestricted. The system prompt is rebuilt at the top of
every round so a mid-turn switch into (or out of) plan mode takes
effect on the next streaming round, and plan mode appends a 3-option
menu instructing the model to stop and let the user pick how to
proceed once the plan is complete.

The project id is basename + FNV-1a-32 of the cwd so it stays stable
across runs (SipHash's DefaultHasher reseeds per process), while still
disambiguating multiple checkouts that share a final path component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 08:06:25 +03:00
3ecbb21ece fix(helexa-acp): persist per round, cancel previous prompt, log loop
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 34s
CI / Format (push) Successful in 35s
CI / Clippy (push) Successful in 2m32s
CI / Test (push) Successful in 5m8s
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
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
build-prerelease / Build neuron-ampere (push) Successful in 8m13s
build-prerelease / Build neuron-ada (push) Successful in 5m18s
build-prerelease / Build cortex binary (push) Successful in 16m12s
build-prerelease / Package cortex RPM (push) Successful in 1m15s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m2s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m39s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m3s
Three changes addressing "session stops mid-turn and disk store
doesn't update":

1. Per-round persistence. drive_prompt previously called
   store::save() once at the very end of the turn. If the loop
   stalled in a later round (long-running bash, upstream SSE that
   never finished, wedged ACP roundtrip), earlier successful
   rounds lived only in the spawned task's `new_turns` and never
   reached disk. Move the extend-history + save into a helper
   (extend_and_persist) and call it at the end of every loop
   iteration. The post-loop save catches whatever the break paths
   leave behind. Failure is logged not propagated.

2. Cancel previous in-flight prompt on new session/prompt. The
   handler used to overwrite SessionState.cancel with a fresh
   token *without firing the old one*. A wedged prior prompt would
   then live forever, holding session-state references and never
   persisting. Now we fire the existing cancel under the lock
   before installing the new token — the old task observes
   is_cancelled() on its next .await and unwinds.

3. Per-round and per-tool log lines. drive_prompt now emits:
   - INFO  prompt round: streaming { round, of, history_turns }
   - INFO  dispatch tool { tool, tool_call_id }
   - INFO  dispatch tool complete { tool_call_id, is_error }
   - INFO  prompt round complete; persisting { round, turns }
   - INFO  prompt complete { stop_reason }
   so the next hang shows up by line number in /tmp/helexa-acp.log
   instead of as silence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:29:22 +03:00
0d841a4981 feat(helexa-acp): replay session history on session/load
Some checks failed
CI / Format (push) Successful in 31s
build-prerelease / Resolve version stamps (push) Successful in 48s
CI / Test (push) Failing after 1m19s
CI / Clippy (push) Successful in 2m56s
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
build-prerelease / Build cortex binary (push) Successful in 4m17s
build-prerelease / Package cortex RPM (push) Successful in 1m26s
build-prerelease / Build neuron-blackwell (push) Successful in 5m52s
build-prerelease / Build neuron-ampere (push) Successful in 7m49s
build-prerelease / Build neuron-ada (push) Successful in 5m8s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m1s
session/list and session/load were both implemented but clicking
a session in Zed's thread picker still left the agent panel
empty. Zed (and ACP clients in general) doesn't cache the
transcript for custom agent_servers entries — it only owns
conversation state for first-party agents. For custom agents the
expectation is that session/load returns successfully and the
agent then re-emits the conversation as a stream of session/update
notifications so the client can rebuild its view.

Implement that replay path:

- handle_load_session now returns (LoadSessionResponse, Vec<Message>)
  so the caller has the history available after the in-memory
  hydration finishes.
- The session/load closure responds to the request *first*, then
  spawns a task that calls replay_history off the dispatch loop.
- replay_history walks the persisted history and emits one
  session/update per turn:
    Role::User           → UserMessageChunk(text)
    Role::Assistant text → AgentMessageChunk(text)
    Role::Assistant tool → AgentMessageChunk for any accompanying
                           text + one ToolCall card per call (with
                           kind/title/raw_input rendered the same
                           way as the live dispatch path)
    Role::Tool result    → ToolCallUpdate matching the assistant's
                           call id, status: Completed, content set
                           to the result text
    Role::System         → skipped (system prompts aren't shown)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:02:00 +03:00
0bbb9b752d feat(helexa-acp): session/list so Zed can discover sessions to resume
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 28s
CI / Format (push) Successful in 28s
CI / Clippy (push) Successful in 2m45s
build-prerelease / Build cortex binary (push) Successful in 4m41s
CI / Test (push) Successful in 4m58s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
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
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 7m36s
build-prerelease / Build neuron-ada (push) Successful in 5m40s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m3s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m40s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m3s
Stage 3b only implemented the trailing half of resume: write
sessions to disk + handle session/load. But Zed (and any ACP
client) needs `session/list` to discover *which* session belongs
to the workspace it's reopening — without it, the client only
knows how to mint new sessions and resume never fires even
though the JSON sits ready on disk.

Add the missing pieces:

- store::list / list_in_dir — enumerate {id}.json under
  sessions_dir(), optionally filter by cwd, sort recent-first.
  Skips unparseable files with a warn rather than aborting.
- store::unix_to_iso8601 — RFC 3339 formatter for
  SessionInfo.updated_at; pulls chrono in directly (already in
  the dep tree transitively).
- agent::handle_list_sessions — wires the request to the store,
  builds SessionInfo entries with derived titles (first user
  turn, truncated to 60 chars).
- agent::initialize_response — advertise
  session_capabilities.list = {} alongside the existing
  load_session: true.

Verified end-to-end against the user's real hxa-1.json
(60-turn beat conversation): `session/list` returns the entry
with cwd, derived title, and ISO 8601 timestamp.

4 new store unit tests for list filtering, missing-dir
handling, unparseable-file skipping, and ISO 8601 formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:34:41 +03:00
5aac1ffc59 feat(helexa-acp): session resume via session/load
All checks were successful
CI / Format (push) Successful in 31s
build-prerelease / Resolve version stamps (push) Successful in 40s
CI / Clippy (push) Successful in 2m37s
CI / Test (push) Successful in 4m59s
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
build-prerelease / Build cortex binary (push) Successful in 4m35s
build-prerelease / Package cortex RPM (push) Successful in 1m19s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
build-prerelease / Build neuron-ampere (push) Successful in 7m45s
build-prerelease / Build neuron-ada (push) Successful in 5m31s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m53s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m1s
Zed restarts (frequent during helexa-acp dogfooding) used to lose
every conversation because we'd ignore the load_session capability
and treat every project-reopen as a fresh session/new. Persist
sessions to disk and honour session/load so the agent panel comes
back where it left off.

Storage layout:
  $XDG_DATA_HOME/helexa-acp/sessions/{session_id}.json

Each file holds session_id, cwd, model_id, mode_id, full Message
history, plus created/updated timestamps. Atomic save via
tempfile+rename so a crash mid-write can't corrupt the store.

Touch points:

- src/store.rs (new) — sessions_dir() resolution, save/load via
  default and explicit-dir entry points (so unit tests don't have
  to race on XDG_DATA_HOME). 5 unit tests cover round-trip,
  not-found errors, atomic overwrite, tool-call/result preservation,
  and the filename sanitiser's path-traversal handling.
- src/provider/mod.rs — Serialize/Deserialize on Role, Message,
  MessageContent, ToolCall. MessageContent::Text turned into a
  struct variant ({text: ...}) so internally-tagged JSON works.
- src/agent.rs — initialize_response advertises load_session: true;
  handle_load_session reads the file, snapshots in-memory state,
  returns LoadSessionResponse with the persisted mode preselected;
  drive_prompt persists at the end of every prompt round under the
  session lock with the I/O outside the lock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:34:42 +03:00
ec2b6450b2 feat(helexa-acp): infer tool name from arg shape when model omits it
Some checks are pending
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 33s
CI / Format (push) Successful in 36s
CI / Clippy (push) Successful in 2m33s
build-prerelease / Build cortex binary (push) Successful in 4m20s
CI / Test (push) Successful in 5m4s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m40s
build-prerelease / Build neuron-ampere (push) Successful in 7m53s
build-prerelease / Build neuron-ada (push) Successful in 5m33s
build-prerelease / Package cortex RPM (push) Successful in 8m20s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m56s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m46s
Qwen3.6-27B occasionally emits a <tool_call> body with the right
arguments but no top-level `name` field — observed in the field as
mkdir-style bash calls like
  {"arguments":{"command":"mkdir -p .../doc/plan/{01-discovery,...}"}}
with no `name`. The agent had no tool to dispatch and surfaced a
Failed card; the model would then hang or retry the same shape.

Add a shape-based inference layer:

- tools::infer_tool_name(arguments) — given an `arguments` object
  alone, return Some(name) when the key set uniquely identifies one
  tool: `{command}` or `{command,cwd}` → bash, `{path,content}` →
  write_file, `{path,old_text,new_text}` → edit_file. Ambiguous
  shapes (`{path}` alone — could be read_file or list_dir) return
  None so the agent still emits a Failed card rather than guessing.
- agent::try_repair_missing_name(raw) — parses a malformed body,
  applies infer_tool_name, returns (name, args_json) on success.
- drive_prompt sweeps malformed_calls through this repair before
  the Failed-card path. Recovered calls go into tool_buckets at
  the next free index and dispatch through the normal tool loop.

10 new unit tests in tools::tests cover the inference table plus
the verbatim mkdir failure from the field log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:14:50 +03:00
a494c8d43c feat(helexa-acp): repair malformed tool calls and render failures as cards
Some checks failed
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 28s
CI / Format (push) Successful in 4m7s
CI / Test (push) Failing after 1m2s
build-prerelease / Build neuron-blackwell (push) Successful in 6m10s
CI / Clippy (push) Successful in 2m37s
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
build-prerelease / Build cortex binary (push) Successful in 4m24s
build-prerelease / Build neuron-ampere (push) Successful in 8m18s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Build neuron-ada (push) Successful in 5m23s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m54s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m56s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Two related fixes for cases where Qwen3 sometimes emits slightly-off
JSON inside <tool_call> blocks:

1. JSON repair pass in qwen3::parse_tool_call_body — strip up to
   three trailing extra `}` characters (model overshoots its closing
   braces), and hoist `name` out of `arguments` when it lands
   nested instead of as a sibling. Both observed in the field; both
   trivially repairable; both now dispatch as normal tool calls
   instead of falling back to the malformed path.

2. New CompletionEvent::MalformedToolCall variant for the cases
   repair can't fix. decode_stream now emits it instead of wrapping
   the raw body in a TextDelta, and agent.rs surfaces each one as
   a Failed SessionUpdate::ToolCall card (so Zed renders it as a
   structured failure UI element rather than dumping the body
   inline) plus a synthetic tool-call/tool-result history pair so
   the model gets clear feedback for self-correction on the next
   round.

Empty <tool_call></tool_call> blocks are now a no-op too (no
Malformed event), matching the existing empty-<think> behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:58:51 +03:00
abbedf8d8a chore(neuron): bump default max_tokens from 512 to 8192
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 44s
CI / Format (push) Successful in 45s
CI / Clippy (push) Successful in 2m41s
build-prerelease / Build neuron-blackwell (push) Successful in 5m35s
build-prerelease / Build cortex binary (push) Successful in 4m32s
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
build-prerelease / Package cortex RPM (push) Successful in 1m20s
build-prerelease / Build neuron-ampere (push) Successful in 8m6s
build-prerelease / Build neuron-ada (push) Successful in 5m19s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m55s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m3s
512 is too low for any modern coding model — clients that don't
explicitly set max_tokens get clipped responses with no diagnostic.
Bump the fallback at all four inference call sites (single-GPU
streaming + non-streaming, TP leader + non-leader) to 8192, which
fits comfortably within Qwen3-class context windows after a
typical agent prompt and lines up with what helexa-acp / a0 / curl
clients reasonably expect.

Clients that explicitly set max_tokens (now including helexa-acp
via HELEXA_ACP_MAX_TOKENS / per-endpoint TOML) override this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:38:28 +03:00
6cc14e925c feat(helexa-acp): per-endpoint max_tokens config
Some checks failed
CI / Format (push) Successful in 34s
build-prerelease / Resolve version stamps (push) Successful in 35s
CI / Clippy (push) Failing after 1m3s
CI / Test (push) Failing after 1m4s
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
build-prerelease / Build cortex 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-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
build-prerelease / Build neuron-blackwell (push) Has been cancelled
The agent was sending max_tokens: None, letting cortex/neuron pick
its own default — which trips Zed's "Output Limit Reached" on long
turns. Add a per-endpoint max_tokens option in EndpointConfig
(TOML key and HELEXA_ACP_MAX_TOKENS env var for the single-endpoint
fallback) that the agent threads into every CompletionRequest by
endpoint name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:34:23 +03:00
1c16732668 feat(helexa-acp): route Qwen3 inline <think> blocks to reasoning
Some checks failed
build-prerelease / Build cortex binary (push) Blocked by required conditions
CI / Test (push) Waiting to run
CI / Format (push) Successful in 26s
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Clippy (push) Successful in 2m40s
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex 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
build-prerelease / Build neuron-blackwell (push) Has been cancelled
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
build-prerelease / Build neuron-ampere (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
Qwen3 emits chain-of-thought as literal <think>...</think> tags
inside delta.content rather than via the separate reasoning_content
field — so without parsing the markers, the thinking shows up in
the message pane as ordinary text. Add a small ThinkParser in
qwen3.rs (same chunk-boundary discipline as ToolCallParser) and
stage it after the tool-call parser in decode_stream: text events
from the tool-call parser are fed in and split into TextDelta /
ReasoningDelta. Zed now renders thinking in its dedicated thought
UI; visible answer text stays in the message pane.

The parking-lot entry from the plan is now closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:30:25 +03:00
5a0861d639 fix(helexa-acp): forward Dispatch::Response to its awaiting router
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 39s
CI / Format (push) Successful in 41s
CI / Clippy (push) Successful in 2m31s
build-prerelease / Build cortex binary (push) Successful in 4m36s
CI / Test (push) Successful in 5m31s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m51s
build-prerelease / Package cortex RPM (push) Successful in 1m29s
build-prerelease / Build neuron-ampere (push) Successful in 7m18s
build-prerelease / Build neuron-ada (push) Successful in 5m6s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
The catch-all on_receive_dispatch handler was applying
respond_with_error to *every* Dispatch variant, including Response.
For Response variants, that call routes the error to the
ResponseRouter for the *outgoing* request — silently overwriting
the real reply from Zed with "Internal error: not implemented yet".

Every ACP roundtrip we issue (fs/read_text_file, fs/write_text_file,
session/request_permission, terminal/*) was therefore returning an
error to the tool runner regardless of what Zed actually responded.
The model saw uniformly-failing tools, gave up, and confabulated
plausible explanations.

Fix: pattern-match the Dispatch. Response → forward to its router
via respond_with_result. Request / Notification → keep the
"not implemented yet" error response as before.

Found via debug logs showing
  WARN helexa_acp::agent: unhandled ACP message method="fs/read_text_file"
right before every tool failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:16:21 +03:00
33652ac651 feat(helexa-acp): HELEXA_ACP_LOG_FILE env for editor-host logging
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 37s
CI / Format (push) Successful in 37s
CI / Clippy (push) Successful in 2m44s
CI / Test (push) Successful in 5m3s
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
build-prerelease / Build cortex binary (push) Successful in 4m36s
build-prerelease / Build neuron-blackwell (push) Successful in 6m1s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Build neuron-ampere (push) Successful in 8m23s
build-prerelease / Build neuron-ada (push) Successful in 5m26s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m48s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 6m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 59s
Editors that launch ACP agents (Zed today) don't reliably surface
the child's stderr — and `args` in an `agent_servers` config is
exec-args, not shell, so the usual `&>>` redirect trick doesn't
work. Add a HELEXA_ACP_LOG_FILE env var that, when set to an
absolute path, routes the tracing subscriber to append-write that
file (ANSI off) instead of stderr. RUST_LOG still controls levels.
Unopenable paths fall back to stderr with a warning so a typo
doesn't silence the agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:47:28 +03:00
c297a54074 chore(helexa-acp): log raw bash output and tool result snippets
All checks were successful
CI / Format (push) Successful in 36s
build-prerelease / Resolve version stamps (push) Successful in 39s
CI / Clippy (push) Successful in 2m38s
build-prerelease / Build neuron-blackwell (push) Successful in 4m34s
build-prerelease / Build cortex binary (push) Successful in 4m49s
CI / Test (push) Successful in 5m42s
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
build-prerelease / Package cortex RPM (push) Successful in 1m25s
build-prerelease / Build neuron-ampere (push) Successful in 7m46s
build-prerelease / Build neuron-ada (push) Successful in 7m38s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m58s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m49s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m0s
Diagnostic for "the tool ran but the model thinks it failed" cases.
Logs at debug level:

- exec_bash: terminal/create command + cwd, terminal/exit code/signal,
  terminal/output bytes + truncated flag + 200-char snippet.
- dispatch_tool_call: 200-char snippet of every successful result
  before it's folded back into history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:15:26 +03:00
0121a1930f feat(helexa-acp): inject and parse Qwen3 Hermes tool format
Some checks failed
CI / Format (push) Successful in 38s
build-prerelease / Resolve version stamps (push) Successful in 42s
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 5m45s
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
build-prerelease / Build cortex binary (push) Successful in 5m13s
build-prerelease / Build neuron-blackwell (push) Successful in 6m0s
build-prerelease / Package cortex RPM (push) Successful in 1m27s
build-prerelease / Build neuron-ampere (push) Successful in 7m55s
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
build-prerelease / Build neuron-ada (push) Has been cancelled
The OpenAI `tools` API field isn't load-bearing in this stack —
neuron's chat template renders only message.content, so tool
definitions sent that way never reach the model. Move both sides
of the tool conversation into the Qwen3 Hermes wire format the
model is actually trained on:

- Append a `# Tools` block to the system prompt describing every
  available function (qwen3::render_tool_block).
- Parse `<tool_call>{json}</tool_call>` markers out of the streamed
  content via a chunk-boundary-safe state machine (qwen3::ToolCallParser),
  surfacing them as the existing CompletionEvent::ToolCall* events
  so the agent loop doesn't change.
- Re-serialise assistant turns that called tools with inline
  `<tool_call>` blocks and tool results as user turns wrapped in
  `<tool_response>` (qwen3::render_assistant_with_tool_calls,
  render_tool_response).

Verified against cortex+Qwen3.6-27B: the model produces a
well-formed `<tool_call>{"name":"list_dir","arguments":{"path":"/tmp"}}</tool_call>`
in response to a Hermes-formatted prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:06:38 +03:00
13f4c36aeb chore(helexa-acp): log outgoing chat-completion body at debug level
Some checks failed
build-prerelease / Resolve version stamps (push) Successful in 39s
CI / Format (push) Successful in 47s
CI / Clippy (push) Failing after 56s
CI / Test (push) Successful in 5m43s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m22s
build-prerelease / Build cortex binary (push) Successful in 6m51s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 7m14s
build-prerelease / Build neuron-ada (push) Successful in 5m57s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m55s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m54s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m4s
Useful for diagnosing "the model isn't using tools" — confirming
that helexa-acp is in fact sending the `tools` array (and what
messages, system prompt, etc. accompany it) without having to
attach a packet capture upstream of cortex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:38:10 +03:00
4a51a54554 fix(helexa-acp): describe Stage 3 tools in the default system prompt
Some checks failed
build-prerelease / Build cortex binary (push) Blocked by required conditions
CI / Test (push) Waiting to run
build-prerelease / Resolve version stamps (push) Successful in 35s
CI / Format (push) Successful in 42s
CI / Clippy (push) Successful in 2m39s
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex 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
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
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-blackwell (push) Has been cancelled
The Stage 2 prompt told the model it had no tools, which models
trained for caution then dutifully repeat back ("Stage 2 build: no
tools available — I can't read files…"). Stage 3 ships tools in the
CompletionRequest.tools array, but the system message was still
overriding that. Update the default prompt to list the five tools
and instruct the model to use them rather than asking the user to
paste contents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:33:17 +03:00
0609f1ac5d feat(helexa-acp): add tools, session modes, and permission gating
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 36s
CI / Format (push) Successful in 39s
CI / Clippy (push) Successful in 2m38s
CI / Test (push) Successful in 5m9s
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
build-prerelease / Build neuron-blackwell (push) Successful in 5m54s
build-prerelease / Build neuron-ampere (push) Successful in 7m54s
build-prerelease / Build neuron-ada (push) Successful in 4m59s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m56s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m14s
build-prerelease / Build cortex binary (push) Successful in 4m9s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 6m47s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 3m54s
Stage 3 introduces five tools (read_file, write_file, edit_file,
list_dir, bash) backed by ACP fs/* and terminal/* calls, a
ClientOps trait so the runner is mock-testable, two session modes
(default + bypassPermissions) with session/set_mode honouring them,
and a tool-call loop in the agent that streams the model, dispatches
each call, feeds results back into history, and re-enters until the
model finishes or MAX_TOOL_ROUNDS is hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:01:32 +03:00
96fc379893 feat(helexa-acp): wire ACP agent loop for text-only conversations
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 41s
CI / Format (push) Successful in 38s
CI / Clippy (push) Successful in 2m35s
build-prerelease / Build cortex binary (push) Successful in 5m26s
CI / Test (push) Successful in 5m43s
build-prerelease / Build neuron-blackwell (push) Successful in 5m47s
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
build-prerelease / Package cortex RPM (push) Successful in 1m23s
build-prerelease / Build neuron-ampere (push) Successful in 8m13s
build-prerelease / Build neuron-ada (push) Successful in 5m28s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Stage 2 lands the agent loop on top of the Stage 1 scaffold: session
state with per-session cancellation, a system-prompt builder honouring
HELEXA_ACP_SYSTEM_PROMPT_PATH / system_prompt_path TOML, and handlers
for initialize / session/new / session/prompt / session/cancel that
stream provider output back as session/update notifications. Verified
end-to-end against cortex from Zed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:46:22 +03:00
78 changed files with 21003 additions and 885 deletions

View File

@@ -41,6 +41,7 @@ concurrency:
env: env:
CARGO_INCREMENTAL: "0" CARGO_INCREMENTAL: "0"
CARGO_TERM_COLOR: "always"
jobs: jobs:
prepare: prepare:

View File

@@ -92,10 +92,68 @@ jobs:
exit 1 exit 1
- run: sccache --show-stats - run: sccache --show-stats
# Type-check the CUDA-only code path. Borrow-check-only — we
# never run the tests here (the runner has no GPU). This catches
# the category of bug where a refactor compiles fine under the
# default feature set (which is what the `clippy` and `test` jobs
# exercise) but fails inside a `#[cfg(feature = "cuda")]` block.
# `runs-on: cuda-13.0` selects the runner that ships nvcc /
# cudarc's build prerequisites. The generic `rust` and `rpm`
# runners don't have them (the previous label `rpm` was tried
# first and tripped cudarc's `nvcc --version` build script —
# see commit history).
cuda-check:
name: CUDA type-check
runs-on: cuda-13.0
# The workflow-level env sets `RUSTC_WRAPPER: sccache` for the
# `rust` runner (where fmt/clippy/test live and sccache is
# installed). The `cuda-13.0` runner doesn't have sccache on
# PATH, so inheriting the wrapper makes cargo bail with
# `could not execute process `sccache rustc -vV` (never executed)`
# before borrow-check even starts. Clear it locally. Also clear
# SCCACHE_* so cargo doesn't try to contact the cache (the
# remote auth headers come from secrets that aren't present on
# this runner either). Lose the cache, keep the gate.
env:
RUSTC_WRAPPER: ""
SCCACHE_BUCKET: ""
SCCACHE_ENDPOINT: ""
SCCACHE_REGION: ""
SCCACHE_S3_USE_SSL: ""
AWS_ACCESS_KEY_ID: ""
AWS_SECRET_ACCESS_KEY: ""
steps:
- uses: actions/checkout@v4
- name: cargo check --features cuda (with retry)
run: |
# act launches the step shell without /etc/profile, so the
# gitea_runner user's inherited PATH lacks /usr/local/cuda-13.0/bin.
# cudarc's build.rs:157 shells out to `nvcc --version` (because
# the neuron crate enables cuda-version-from-build-system) and
# panics with ENOENT if nvcc isn't resolvable. build-prerelease.yml
# does the same export — keep them in sync.
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:-}"
for attempt in 1 2 3; do
echo "::group::cuda-check attempt ${attempt}"
if cargo check -p neuron --features cuda --all-targets; then
echo "::endgroup::"
exit 0
fi
echo "::endgroup::"
echo "cuda-check failed on attempt ${attempt}"
if [ "${attempt}" -lt 3 ]; then
sleep 5
fi
done
echo "cuda-check failed after 3 attempts"
exit 1
srpm-cortex: srpm-cortex:
name: Build cortex SRPM name: Build cortex SRPM
runs-on: rpm runs-on: rpm
needs: [fmt, clippy, test] needs: [fmt, clippy, test, cuda-check]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -155,7 +213,7 @@ jobs:
srpm-neuron: srpm-neuron:
name: Build neuron SRPM name: Build neuron SRPM
runs-on: rpm runs-on: rpm
needs: [fmt, clippy, test] needs: [fmt, clippy, test, cuda-check]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

146
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,146 @@
name: deploy
# Roll the freshly-published unstable RPMs onto the helexa fleet:
# cortex on the gateway, helexa-neuron-<flavour> on each neuron host.
#
# Triggered automatically after `build-prerelease` succeeds (by which
# point the new RPMs are live on rpm.lair.cafe/unstable), and also
# re-runnable manually from the Gitea UI.
#
# Per-host one-time setup (gitea_ci user, authorized_keys, scoped
# sudoers drop-in) lives in script/infra-setup.sh — run that once per
# host before this workflow can succeed.
on:
workflow_run:
workflows: [build-prerelease]
types: [completed]
workflow_dispatch:
# Serialize deploys. Overlapping runs would race on dnf metadata
# refresh and service-restart timing; queueing keeps the fleet
# predictable. Don't cancel an in-flight deploy — a half-applied dnf
# transaction is worse than a slightly stale deploy.
concurrency:
group: deploy
cancel-in-progress: false
env:
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
deploy-cortex:
runs-on: fedora-43
# Two trigger paths: manual dispatch always runs; workflow_run
# only runs if the upstream `build-prerelease` actually succeeded.
if: >-
${{
github.event_name == 'workflow_dispatch'
|| github.event.workflow_run.conclusion == 'success'
}}
steps:
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@hanzalova.internal 'hostname -f'
- name: Stop cortex.service
run: |
ssh gitea_ci@hanzalova.internal '
if systemctl is-active --quiet cortex.service; then
sudo /usr/bin/systemctl stop cortex.service
fi'
- name: Install / upgrade cortex from rpm.lair.cafe/unstable
run: |
ssh gitea_ci@hanzalova.internal '
if rpm -q cortex >/dev/null 2>&1; then
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y cortex
else
sudo /usr/bin/dnf install --refresh --allowerasing -y cortex
fi'
- name: Start cortex.service
run: |
ssh gitea_ci@hanzalova.internal '
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl start cortex.service'
# Wait for the service to either come up or wedge, then capture
# the latest-invocation journal. Runs even on prior failure so a
# failed start step still leaves a usable record in the deploy log.
- name: Capture cortex.service startup journal
if: always()
run: |
sleep 10
ssh gitea_ci@hanzalova.internal \
'journalctl --unit cortex.service -I --no-pager'
deploy-neurons:
needs: [deploy-cortex]
runs-on: fedora-43
strategy:
# One neuron failing must not cancel the others. Cortex is up
# already; a partial neuron deploy is strictly better than
# rolling back to zero.
fail-fast: false
matrix:
include:
- host: beast.hanzalova.internal
flavour: blackwell
- host: benjy.hanzalova.internal
flavour: ada
- host: quadbrat.hanzalova.internal
flavour: ampere
steps:
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@${{ matrix.host }} 'hostname -f'
- name: Stop neuron.service
run: |
ssh gitea_ci@${{ matrix.host }} '
if systemctl is-active --quiet neuron.service; then
sudo /usr/bin/systemctl stop neuron.service
fi'
- name: Install / upgrade helexa-neuron-${{ matrix.flavour }}
run: |
ssh gitea_ci@${{ matrix.host }} "
if rpm -q helexa-neuron-${{ matrix.flavour }} >/dev/null 2>&1; then
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-neuron-${{ matrix.flavour }}
else
sudo /usr/bin/dnf install --refresh --allowerasing -y helexa-neuron-${{ matrix.flavour }}
fi"
- name: Ensure firewalld allows helexa-neuron
run: |
ssh gitea_ci@${{ matrix.host }} '
if ! sudo /usr/bin/firewall-cmd --query-service=helexa-neuron --quiet 2>/dev/null; then
sudo /usr/bin/firewall-cmd --add-service=helexa-neuron --permanent
sudo /usr/bin/firewall-cmd --reload
fi'
- name: Start neuron.service
run: |
ssh gitea_ci@${{ matrix.host }} '
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl start neuron.service'
# Wait for the service to either come up or wedge, then capture
# the latest-invocation journal. Runs even on prior failure so a
# failed start step still leaves a usable record in the deploy log.
- name: Capture neuron.service startup journal
if: always()
run: |
sleep 10
ssh gitea_ci@${{ matrix.host }} \
'journalctl --unit neuron.service -I --no-pager'

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ cortex.toml
models.toml models.toml
doc/plan/* doc/plan/*
/target-cuda/ /target-cuda/
.claude/

148
Cargo.lock generated
View File

@@ -472,6 +472,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -668,6 +674,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -1223,6 +1235,15 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "figment" name = "figment"
version = "0.10.19" version = "0.10.19"
@@ -1731,6 +1752,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -1819,6 +1850,7 @@ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
"async-trait", "async-trait",
"chrono",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"reqwest", "reqwest",
@@ -2134,6 +2166,34 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2378,6 +2438,12 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]] [[package]]
name = "metrics" name = "metrics"
version = "0.24.3" version = "0.24.3"
@@ -2431,6 +2497,27 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minijinja"
version = "2.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f"
dependencies = [
"memo-map",
"serde",
"serde_json",
]
[[package]]
name = "minijinja-contrib"
version = "2.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99df5123c54391e2a228014c1dbbd85a3dab08a25e776c810526f2f47542b3de"
dependencies = [
"minijinja",
"serde",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -2480,6 +2567,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -2504,6 +2601,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1",
"candle-core", "candle-core",
"candle-nn", "candle-nn",
"candle-transformers", "candle-transformers",
@@ -2515,10 +2613,14 @@ dependencies = [
"futures", "futures",
"half", "half",
"hf-hub", "hf-hub",
"image",
"minijinja",
"minijinja-contrib",
"reqwest", "reqwest",
"safetensors 0.7.0", "safetensors 0.7.0",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokenizers", "tokenizers",
"tokio", "tokio",
@@ -2841,6 +2943,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -2954,6 +3069,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0"
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]] [[package]]
name = "quanta" name = "quanta"
version = "0.12.6" version = "0.12.6"
@@ -2969,6 +3090,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -4607,6 +4734,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "which" name = "which"
version = "7.0.3" version = "7.0.3"
@@ -5144,3 +5277,18 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View File

@@ -1,30 +0,0 @@
# Helexa fleet manifest.
#
# Drives rolling deploys via script/deploy.sh and serves as the source
# of truth for which hosts run cortex vs neuron, and which CUDA
# compute-capability flavour each neuron host needs.
#
# Flavour ↔ NVIDIA generation ↔ compute cap:
# ampere sm_86 (RTX 30 series — e.g. 3060)
# ada sm_89 (RTX 40 series — e.g. 4090)
# blackwell sm_120 (RTX 50 series — e.g. 5090)
#
# The flavour determines which RPM is installed on a given neuron host:
# helexa-neuron-<flavour>. Only one flavour may be installed at a time
# (the packages Conflict: with each other).
cortex:
host: hanzalova.internal
neurons:
- host: beast.hanzalova.internal
flavour: blackwell
gpu: "2x RTX 5090"
- host: benjy.hanzalova.internal
flavour: ada
gpu: "RTX 4090"
- host: quadbrat.hanzalova.internal
flavour: ampere
gpu: "RTX 3060"

View File

@@ -5,9 +5,9 @@
# invocation: `validate-neuron.sh beast.hanzalova.internal # invocation: `validate-neuron.sh beast.hanzalova.internal
# Qwen/Qwen3.6-27B q5k 2`. # Qwen/Qwen3.6-27B q5k 2`.
# #
# Synced by script/deploy.sh from asset/neuron/<short-host>.toml. Edits # Synced to /etc/neuron/neuron.toml by script/infra-setup.sh. Edits
# take effect on the next deploy.sh run (which stops + restarts the # take effect after the next deploy workflow run restarts the service
# service so default_models is re-read at activation). # (default_models is read at activation).
port = 13131 port = 13131

View File

@@ -4,7 +4,7 @@
# Qwen3-8B (bf16, ~18 GB), leaving ~6 GB for KV cache + activations on # Qwen3-8B (bf16, ~18 GB), leaving ~6 GB for KV cache + activations on
# moderate-length contexts. # moderate-length contexts.
# #
# Synced by script/deploy.sh from asset/neuron/<short-host>.toml. # Synced to /etc/neuron/neuron.toml by script/infra-setup.sh.
port = 13131 port = 13131

View File

@@ -4,7 +4,7 @@
# (bf16, ~4 GB), leaving ~7 GB for KV cache so long contexts on a small # (bf16, ~4 GB), leaving ~7 GB for KV cache so long contexts on a small
# model still have plenty of room. # model still have plenty of room.
# #
# Synced by script/deploy.sh from asset/neuron/<short-host>.toml. # Synced to /etc/neuron/neuron.toml by script/infra-setup.sh.
port = 13131 port = 13131

View File

@@ -0,0 +1,20 @@
# Install on the cortex gateway host as /etc/sudoers.d/helexa_gitea_ci
# (owner root:root, mode 0440). Required by .gitea/workflows/deploy.yml,
# which SSHes as gitea_ci@<gateway> to roll out cortex package upgrades
# and config changes.
#
# Filename convention `helexa_gitea_ci` (vs bare `gitea_ci`) so other
# helexa-org apps can drop their own sudoers files on the same host
# without overwriting this one.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/cortex/cortex.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/cortex/models.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start cortex.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop cortex.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf install --refresh --allowerasing -y cortex
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf upgrade --refresh --allowerasing -y cortex
# sudoers reserves `:` and `=` and requires `\` escaping inside command
# arguments — without it visudo errors at the first `:` in `https://`.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf config-manager addrepo --from-repofile\=https\://rpm.lair.cafe/lair-cafe-unstable.repo
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf config-manager setopt lair-cafe-unstable.enabled\=1

View File

@@ -0,0 +1,33 @@
# Install on every neuron host as /etc/sudoers.d/helexa_gitea_ci
# (owner root:root, mode 0440). Required by .gitea/workflows/deploy.yml,
# which SSHes as gitea_ci@<neuron-host> to roll out helexa-neuron-<flavour>
# package upgrades and config changes.
#
# Filename convention `helexa_gitea_ci` (vs bare `gitea_ci`) so other
# helexa-org apps can drop their own sudoers files on the same host
# without overwriting this one.
#
# All three CUDA flavours are listed because a host's flavour can change
# (e.g. GPU swap) and we don't want the sudoers file to need to change
# in lockstep. Only one flavour can be installed at a time (the packages
# Conflict: with each other), so the attack surface is bounded to "wrong
# flavour installed" — vandalism, not privilege escalation.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/neuron/neuron.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start neuron.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop neuron.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf install --refresh --allowerasing -y helexa-neuron-ampere
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-neuron-ampere
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf install --refresh --allowerasing -y helexa-neuron-ada
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-neuron-ada
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf install --refresh --allowerasing -y helexa-neuron-blackwell
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-neuron-blackwell
# sudoers reserves `:` and `=` and requires `\` escaping inside command
# arguments — without it visudo errors at the first `:` in `https://`.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf config-manager addrepo --from-repofile\=https\://rpm.lair.cafe/lair-cafe-unstable.repo
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf config-manager setopt lair-cafe-unstable.enabled\=1
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf config-manager addrepo --from-repofile\=https\://developer.download.nvidia.com/compute/cuda/repos/rhel9/x86_64/cuda-rhel9.repo
gitea_ci ALL=(root) NOPASSWD: /usr/bin/dnf install -y libcudnn9-cuda-13
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service=helexa-neuron --permanent
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload

View File

@@ -24,6 +24,17 @@ pub struct ModelProfile {
/// Neurons where this model should never be evicted. /// Neurons where this model should never be evicted.
#[serde(default)] #[serde(default)]
pub pinned_on: Vec<String>, pub pinned_on: Vec<String>,
/// Source scheme this profile's weights come from. When set, the
/// router prefixes `id` with `scheme:` before forwarding the load
/// request to neuron, ensuring the daemon fetches from the right
/// registry regardless of which entry happens to match `id`.
///
/// `None` lets neuron substitute its own `default_source` (typically
/// `huggingface`). Set to `"helexa"` when the model is hosted in
/// the helexa registry — operator-procurement-grade audit relies
/// on this being explicit per model rather than implicit.
#[serde(default)]
pub source: Option<String>,
} }
fn default_min_devices() -> u32 { fn default_min_devices() -> u32 {
@@ -140,6 +151,7 @@ mod tests {
min_devices: 2, min_devices: 2,
min_device_vram_mb: Some(24_000), min_device_vram_mb: Some(24_000),
pinned_on: vec![], pinned_on: vec![],
source: None,
} }
} }
@@ -197,6 +209,29 @@ mod tests {
assert_eq!(cat.resolve_alias("Qwen/Qwen3-8B"), "Qwen/Qwen3-8B"); assert_eq!(cat.resolve_alias("Qwen/Qwen3-8B"), "Qwen/Qwen3-8B");
} }
#[test]
fn source_defaults_to_none_when_absent_from_toml() {
let src = r#"
[[models]]
id = "Qwen/Qwen3-30B"
harness = "candle"
"#;
let cat: ModelCatalogue = toml::from_str(src).expect("parse models table");
assert!(cat.models[0].source.is_none());
}
#[test]
fn source_round_trips_through_toml() {
let src = r#"
[[models]]
id = "Helexa/Qwen3.6-27B-Uncensored"
harness = "candle"
source = "helexa"
"#;
let cat: ModelCatalogue = toml::from_str(src).expect("parse models table");
assert_eq!(cat.models[0].source.as_deref(), Some("helexa"));
}
#[test] #[test]
fn aliases_table_round_trips_through_toml() { fn aliases_table_round_trips_through_toml() {
let src = r#" let src = r#"

View File

@@ -44,6 +44,16 @@ pub struct ModelInfo {
pub status: String, pub status: String,
pub devices: Vec<u32>, pub devices: Vec<u32>,
pub vram_used_mb: Option<u64>, pub vram_used_mb: Option<u64>,
/// Modalities this loaded model supports. Today: `["text"]` for
/// text-only checkpoints, `["text", "vision"]` for vision-capable
/// ones (Stage B7 of the vision plan). Clients like litellm /
/// agent0 can gate `image_url` submission on the advertised set.
///
/// Optional in the wire format so older clients that don't read
/// it stay compatible. Default-empty for absent/older data, which
/// callers can interpret as "text".
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
} }
/// What an inference harness must do, from neuron's perspective. /// What an inference harness must do, from neuron's perspective.

View File

@@ -6,4 +6,6 @@ pub mod harness;
pub mod metrics; pub mod metrics;
pub mod node; pub mod node;
pub mod openai; pub mod openai;
pub mod responses;
pub mod source;
pub mod translate; pub mod translate;

View File

@@ -37,6 +37,12 @@ pub struct ModelEntry {
pub last_accessed: Option<DateTime<Utc>>, pub last_accessed: Option<DateTime<Utc>>,
/// Estimated VRAM usage in MB when loaded. /// Estimated VRAM usage in MB when loaded.
pub vram_estimate_mb: Option<u64>, pub vram_estimate_mb: Option<u64>,
/// Modalities the loaded model advertises (e.g. `["text", "vision"]`),
/// copied verbatim from the neuron's `ModelInfo.capabilities` at poll
/// time. Empty when the neuron reports none. `#[serde(default)]` keeps
/// older persisted/serialised entries deserialisable.
#[serde(default)]
pub capabilities: Vec<String>,
} }
/// Model lifecycle status. /// Model lifecycle status.
@@ -85,6 +91,12 @@ pub struct CortexModelEntry {
/// disjoint from) `feasible_on` depending on whether the catalogue /// disjoint from) `feasible_on` depending on whether the catalogue
/// covers this model. /// covers this model.
pub locations: Vec<ModelLocation>, pub locations: Vec<ModelLocation>,
/// Union of the modalities advertised by every neuron that has this
/// model loaded (e.g. `["text", "vision"]`). Empty for catalogue-only
/// entries with no loaded location — the catalogue profile doesn't
/// declare capabilities yet (tracked separately from C3).
#[serde(default)]
pub capabilities: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -0,0 +1,346 @@
//! OpenAI Responses API (`POST /v1/responses`) envelope types.
//!
//! This is OpenAI's newer chat surface, distinct from
//! `/v1/chat/completions` in three ways that matter for us:
//!
//! 1. **Input shape**. Instead of a `messages` array, the request
//! carries `input` — either a plain string (single user turn)
//! or an array of typed items (messages, function calls,
//! function-call outputs, reasoning blocks, …).
//! 2. **Output shape**. The response carries a single `output`
//! array of items, each typed. We always emit one
//! `OutputItem::Message` containing the assistant's reply (plus,
//! when we get there, separate `function_call` items).
//! 3. **Streaming events**. Where chat completions stream
//! structurally-identical `chat.completion.chunk` frames over
//! `data:` lines, Responses streams *named* events
//! (`response.created`, `response.output_text.delta`,
//! `response.completed`, …) over `event:` + `data:` SSE pairs.
//! The wire projector in `neuron::wire::openai_responses` builds
//! these from the same [`crate::openai`]-shaped
//! `InferenceEvent` stream the chat projector consumes.
//!
//! Scope cuts for this first cut:
//!
//! - **`previous_response_id` is rejected at parse time**. Stateful
//! chained conversations need a persistence layer we don't have.
//! - **Reasoning items are accepted-and-ignored** (no Qwen3
//! `<think>` routing yet). Audio and embedded resources are
//! rejected as unsupported.
//! - **Tool calls** (function_call / function_call_output) are
//! carried as round-trip types but the candle harness doesn't
//! emit them yet — wired so the surface is in place for the
//! day we add proper tool-call extraction.
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ── Request ──────────────────────────────────────────────────────────
/// Body of a `POST /v1/responses` request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponsesRequest {
pub model: String,
pub input: ResponsesInput,
/// System-prompt-style instructions. The Responses API
/// separates these from input so a caller doesn't have to
/// build a `system` message item by hand.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default)]
pub stream: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
/// Chained-conversation identifier. We don't store responses
/// server-side yet; if this is `Some`, the handler returns 400.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
/// Catch-all for anything we don't model yet (tools, tool_choice,
/// reasoning, response_format, …). Lets a client send a
/// forward-compatible request without our parser rejecting it.
#[serde(flatten)]
pub extra: Value,
}
/// `input` is either a single string or an array of typed 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>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesInputItem {
/// A user / assistant / system turn.
Message {
role: String,
content: ResponsesMessageContent,
},
/// Assistant emitted a tool call. Round-trip only — neuron
/// doesn't synthesise these yet.
FunctionCall {
call_id: String,
name: String,
arguments: String,
},
/// User is feeding a tool result back into the model.
FunctionCallOutput { call_id: String, output: String },
/// Reasoning items emitted by o-series models. Accepted but
/// not forwarded to the model — neuron's candle path doesn't
/// surface reasoning separately yet.
Reasoning {
#[serde(default)]
content: Vec<Value>,
},
}
/// Inside a `Message` item, content is either a plain string or an
/// array of typed parts. Mirrors the chat-completions Parts shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesMessageContent {
Text(String),
Parts(Vec<ResponsesContentPart>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesContentPart {
/// Plain text inside a user / system turn.
InputText { text: String },
/// An image. `image_url` is either a remote URL or a
/// `data:image/png;base64,…` URI; the request translator just
/// forwards the string.
InputImage {
image_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
/// Returned text inside an assistant turn — only relevant when
/// the caller is feeding an assistant turn back in to continue
/// a conversation manually (no `previous_response_id`).
OutputText {
text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<Value>,
},
}
// ── Response (non-streaming) ─────────────────────────────────────────
/// Body of a `POST /v1/responses` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponsesResponse {
pub id: String,
/// Always `"response"`.
pub object: String,
pub created_at: u64,
/// `"completed"`, `"incomplete"`, or — for the initial event of
/// a streaming response — `"in_progress"`.
pub status: String,
pub model: String,
pub output: Vec<ResponsesOutputItem>,
/// Populated on completion; `None` while streaming.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ResponsesUsage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesOutputItem {
Message {
id: String,
/// Always `"assistant"` for model output.
role: String,
/// Output content parts. We always emit a single
/// `OutputText` today; multi-part output would land here
/// once we have e.g. image generation.
content: Vec<ResponsesOutputContent>,
/// Item-level status. `"in_progress"` while streaming the
/// content parts, `"completed"` when done.
#[serde(default = "default_item_status")]
status: String,
},
/// Reserved for the day tool-call extraction lands. The wire
/// shape mirrors `ResponsesInputItem::FunctionCall`.
FunctionCall {
id: String,
call_id: String,
name: String,
arguments: String,
#[serde(default = "default_item_status")]
status: String,
},
}
fn default_item_status() -> String {
"completed".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesOutputContent {
OutputText {
text: String,
/// Citations / inline annotations. Empty today; reserved
/// for the day we wire in web search / file search.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponsesUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
}
// ── Streaming event names ────────────────────────────────────────────
/// Event names the SSE projector emits, hoisted as constants so
/// the projector and the wire shape stay in sync without
/// string-typos. The strings are dictated by OpenAI's published
/// Responses API.
pub mod events {
pub const CREATED: &str = "response.created";
/// Fired between `response.created` and the first output-item
/// event. Marks "request validated, model is generating" —
/// some clients use it to differentiate the "warming up" state
/// from "streaming tokens" in their UI.
pub const IN_PROGRESS: &str = "response.in_progress";
pub const OUTPUT_ITEM_ADDED: &str = "response.output_item.added";
pub const CONTENT_PART_ADDED: &str = "response.content_part.added";
pub const OUTPUT_TEXT_DELTA: &str = "response.output_text.delta";
pub const OUTPUT_TEXT_DONE: &str = "response.output_text.done";
pub const CONTENT_PART_DONE: &str = "response.content_part.done";
pub const OUTPUT_ITEM_DONE: &str = "response.output_item.done";
pub const COMPLETED: &str = "response.completed";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialises_input_string_form() {
let raw = r#"{"model": "m", "input": "hello"}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
match req.input {
ResponsesInput::Text(s) => assert_eq!(s, "hello"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn deserialises_input_items_form() {
let raw = r#"{
"model": "m",
"input": [
{"type": "message", "role": "user", "content": "hi"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
match req.input {
ResponsesInput::Items(items) => {
assert_eq!(items.len(), 1);
match &items[0] {
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 Items, got {other:?}"),
}
}
#[test]
fn deserialises_input_with_image() {
let raw = r#"{
"model": "m",
"input": [
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "what is this"},
{"type": "input_image", "image_url": "data:image/png;base64,AAA="}
]}
]
}"#;
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] {
ResponsesInputItem::Message {
content: ResponsesMessageContent::Parts(p),
..
} => p,
other => panic!("expected Parts, got {other:?}"),
};
assert_eq!(parts.len(), 2);
assert!(matches!(
&parts[0],
ResponsesContentPart::InputText { text } if text == "what is this"
));
assert!(matches!(
&parts[1],
ResponsesContentPart::InputImage { image_url, .. }
if image_url == "data:image/png;base64,AAA="
));
}
#[test]
fn unknown_fields_round_trip_via_extra() {
let raw = r#"{
"model": "m",
"input": "hi",
"tools": [{"type": "web_search"}],
"reasoning": {"effort": "medium"}
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
assert!(req.extra.get("tools").is_some());
assert!(req.extra.get("reasoning").is_some());
}
#[test]
fn response_round_trips_through_serde() {
let r = ResponsesResponse {
id: "resp_1".into(),
object: "response".into(),
created_at: 1700,
status: "completed".into(),
model: "m".into(),
output: vec![ResponsesOutputItem::Message {
id: "msg_1".into(),
role: "assistant".into(),
content: vec![ResponsesOutputContent::OutputText {
text: "hi there".into(),
annotations: vec![],
}],
status: "completed".into(),
}],
usage: Some(ResponsesUsage {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
}),
};
let json = serde_json::to_string(&r).unwrap();
let parsed: ResponsesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "resp_1");
assert_eq!(parsed.output.len(), 1);
}
}

View File

@@ -0,0 +1,267 @@
//! Scheme-qualified model identifiers.
//!
//! cortex/neuron historically resolves every model id through hf-hub
//! against `https://huggingface.co`. Helexa is adding an EU-hosted
//! registry (`registry.helexa.ai`) alongside HF — both speak the same
//! HF-compatible wire format, but the bytes, jurisdiction, and trust
//! root differ. Model ids therefore need a scheme:
//!
//! - `huggingface:Qwen/Qwen3.6-27B` — HF-hosted bytes
//! - `helexa:Qwen/Qwen3.6-27B-Uncensored` — helexa registry bytes
//! - `helexa:SomeOperator/CustomFinetune` — operator publishing
//! under the helexa namespace; same scheme handles all `org/name`
//! pairs hosted in that registry.
//!
//! Bare `org/name` parses with an empty scheme; the caller (typically
//! a harness) substitutes its configured default scheme so existing
//! configs keep working through the transition.
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
/// Parsed `scheme:org/name`. Bare `org/name` produces an empty scheme
/// — call `with_default_scheme` (or check `is_scheme_unset`) to
/// resolve before using.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ModelSourceId {
pub scheme: String,
pub org: String,
pub name: String,
}
/// Errors from `ModelSourceId::from_str`. Carries the offending input
/// so log lines / API errors can echo what the operator typed.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("empty model id")]
Empty,
#[error("model id '{0}' is missing the '/' between org and name")]
MissingSlash(String),
#[error("model id '{0}' has an empty scheme before ':'")]
EmptyScheme(String),
#[error("model id '{0}' has an empty org")]
EmptyOrg(String),
#[error("model id '{0}' has an empty name")]
EmptyName(String),
#[error("model id '{0}' has a scheme containing '/' which is reserved for org/name")]
SchemeContainsSlash(String),
#[error("model id '{0}' has a name containing ':' which is reserved for the scheme prefix")]
NameContainsColon(String),
}
impl ModelSourceId {
/// Construct directly from already-validated parts. Used by tests
/// and call sites that have the fields separately; the public API
/// for parsing user input is `FromStr`.
pub fn new(scheme: impl Into<String>, org: impl Into<String>, name: impl Into<String>) -> Self {
Self {
scheme: scheme.into(),
org: org.into(),
name: name.into(),
}
}
/// True when this id parsed from a bare `org/name` (no scheme
/// prefix). The harness substitutes its configured default in
/// `with_default_scheme` before resolving against a registry.
pub fn is_scheme_unset(&self) -> bool {
self.scheme.is_empty()
}
/// Substitute `default` for an empty scheme. No-op when the scheme
/// is already set. Returns self by value so it composes neatly:
/// `id.parse::<ModelSourceId>()?.with_default_scheme("huggingface")`.
pub fn with_default_scheme(mut self, default: &str) -> Self {
if self.scheme.is_empty() {
self.scheme = default.to_string();
}
self
}
/// The `org/name` half — what an hf-hub `Api::model(...)` call
/// expects regardless of which scheme/endpoint we're hitting.
pub fn repo_path(&self) -> String {
format!("{}/{}", self.org, self.name)
}
}
impl fmt::Display for ModelSourceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.scheme.is_empty() {
write!(f, "{}/{}", self.org, self.name)
} else {
write!(f, "{}:{}/{}", self.scheme, self.org, self.name)
}
}
}
impl FromStr for ModelSourceId {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Err(ParseError::Empty);
}
// Scheme split. Only the *first* colon counts — anything after
// belongs to org/name (and would be rejected separately because
// `:` isn't allowed there).
let (scheme, rest) = match s.split_once(':') {
Some((scheme, rest)) => {
if scheme.is_empty() {
return Err(ParseError::EmptyScheme(s.to_string()));
}
if scheme.contains('/') {
return Err(ParseError::SchemeContainsSlash(s.to_string()));
}
(scheme.to_string(), rest)
}
None => (String::new(), s),
};
let (org, name) = rest
.split_once('/')
.ok_or_else(|| ParseError::MissingSlash(s.to_string()))?;
if org.is_empty() {
return Err(ParseError::EmptyOrg(s.to_string()));
}
if name.is_empty() {
return Err(ParseError::EmptyName(s.to_string()));
}
if name.contains(':') {
return Err(ParseError::NameContainsColon(s.to_string()));
}
Ok(Self {
scheme,
org: org.to_string(),
name: name.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_qualified() {
let id: ModelSourceId = "huggingface:Qwen/Qwen3.6-27B".parse().unwrap();
assert_eq!(id.scheme, "huggingface");
assert_eq!(id.org, "Qwen");
assert_eq!(id.name, "Qwen3.6-27B");
assert_eq!(id.repo_path(), "Qwen/Qwen3.6-27B");
assert!(!id.is_scheme_unset());
}
#[test]
fn parses_helexa_scheme() {
let id: ModelSourceId = "helexa:SomeOperator/Qwen3.6-27B-Uncensored"
.parse()
.unwrap();
assert_eq!(id.scheme, "helexa");
assert_eq!(id.org, "SomeOperator");
assert_eq!(id.name, "Qwen3.6-27B-Uncensored");
}
#[test]
fn parses_bare_id_with_empty_scheme() {
let id: ModelSourceId = "Qwen/Qwen3-30B-A3B-Instruct".parse().unwrap();
assert_eq!(id.scheme, "");
assert_eq!(id.org, "Qwen");
assert_eq!(id.name, "Qwen3-30B-A3B-Instruct");
assert!(id.is_scheme_unset());
}
#[test]
fn substitutes_default_scheme_only_when_unset() {
let id: ModelSourceId = "Qwen/Q3".parse().unwrap();
assert_eq!(id.with_default_scheme("huggingface").scheme, "huggingface");
let id: ModelSourceId = "helexa:Qwen/Q3".parse().unwrap();
assert_eq!(
id.with_default_scheme("huggingface").scheme,
"helexa",
"default substitution must not override an explicit scheme"
);
}
#[test]
fn display_roundtrips_qualified_id() {
let s = "helexa:Helexa/Qwen3.6-27B";
let id: ModelSourceId = s.parse().unwrap();
assert_eq!(id.to_string(), s);
}
#[test]
fn display_roundtrips_bare_id() {
let s = "Qwen/Q3";
let id: ModelSourceId = s.parse().unwrap();
assert_eq!(id.to_string(), s);
}
#[test]
fn rejects_empty() {
assert_eq!("".parse::<ModelSourceId>().unwrap_err(), ParseError::Empty);
}
#[test]
fn rejects_missing_slash() {
match "Qwen".parse::<ModelSourceId>().unwrap_err() {
ParseError::MissingSlash(s) => assert_eq!(s, "Qwen"),
other => panic!("expected MissingSlash, got {other:?}"),
}
match "huggingface:Qwen".parse::<ModelSourceId>().unwrap_err() {
ParseError::MissingSlash(s) => assert_eq!(s, "huggingface:Qwen"),
other => panic!("expected MissingSlash, got {other:?}"),
}
}
#[test]
fn rejects_empty_scheme() {
match ":Qwen/Q3".parse::<ModelSourceId>().unwrap_err() {
ParseError::EmptyScheme(s) => assert_eq!(s, ":Qwen/Q3"),
other => panic!("expected EmptyScheme, got {other:?}"),
}
}
#[test]
fn rejects_scheme_with_slash() {
match "hugg/ingface:Q/N".parse::<ModelSourceId>().unwrap_err() {
ParseError::SchemeContainsSlash(s) => assert_eq!(s, "hugg/ingface:Q/N"),
other => panic!("expected SchemeContainsSlash, got {other:?}"),
}
}
#[test]
fn rejects_empty_org_or_name() {
match "huggingface:/N".parse::<ModelSourceId>().unwrap_err() {
ParseError::EmptyOrg(_) => {}
other => panic!("expected EmptyOrg, got {other:?}"),
}
match "huggingface:Q/".parse::<ModelSourceId>().unwrap_err() {
ParseError::EmptyName(_) => {}
other => panic!("expected EmptyName, got {other:?}"),
}
}
#[test]
fn rejects_name_with_colon() {
match "huggingface:Q/N:weird"
.parse::<ModelSourceId>()
.unwrap_err()
{
ParseError::NameContainsColon(s) => assert_eq!(s, "huggingface:Q/N:weird"),
other => panic!("expected NameContainsColon, got {other:?}"),
}
}
#[test]
fn serde_roundtrips_via_struct() {
// We serialize as a struct (scheme/org/name fields) so the
// shape is self-describing in API payloads. Callers that want
// the compact `scheme:org/name` string use `Display`/`FromStr`.
let id = ModelSourceId::new("helexa", "Helexa", "Qwen3.6-27B");
let json = serde_json::to_string(&id).unwrap();
let back: ModelSourceId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
}

View File

@@ -20,6 +20,7 @@ pub fn api_routes() -> Router<Arc<CortexState>> {
Router::new() Router::new()
.route("/v1/chat/completions", post(chat_completions)) .route("/v1/chat/completions", post(chat_completions))
.route("/v1/completions", post(completions)) .route("/v1/completions", post(completions))
.route("/v1/responses", post(responses))
.route("/v1/models", get(list_models)) .route("/v1/models", get(list_models))
.route("/v1/messages", post(anthropic_messages)) .route("/v1/messages", post(anthropic_messages))
.route("/health", get(health)) .route("/health", get(health))
@@ -74,6 +75,58 @@ async fn chat_completions(
.await .await
} }
/// `POST /v1/responses` — proxy to the appropriate backend node.
///
/// Same routing shape as [`chat_completions`]: extract `model` from
/// the body, resolve to a node, forward verbatim. No translation —
/// neuron speaks the Responses API natively (see
/// `crates/neuron/src/wire/openai_responses.rs`), so the gateway is
/// a pass-through. Streaming and non-streaming are handled
/// identically; the upstream `Content-Type` (text/event-stream vs.
/// application/json) propagates through the proxy.
async fn responses(
State(fleet): State<Arc<CortexState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let model_id = match extract_model(&body) {
Some(m) => m,
None => {
tracing::warn!(
handler = "responses",
"rejected: missing 'model' field in request body"
);
return error_response(400, "missing 'model' field in request body");
}
};
let route = match router::resolve(&fleet, &model_id).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
handler = "responses",
model = %model_id,
error = %e,
"route resolve failed"
);
return error_response(404, &e.to_string());
}
};
touch_model(&fleet, &route.node_name, &route.resolved_model_id).await;
let body = rewrite_model_in_body(body, &route.resolved_model_id);
proxy_with_metrics(
&fleet,
&route,
"/v1/responses",
headers,
body,
&route.resolved_model_id,
)
.await
}
/// `POST /v1/completions` — proxy completions endpoint. /// `POST /v1/completions` — proxy completions endpoint.
async fn completions( async fn completions(
State(fleet): State<Arc<CortexState>>, State(fleet): State<Arc<CortexState>>,
@@ -361,6 +414,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
loaded: false, loaded: false,
feasible_on, feasible_on,
locations: Vec::new(), locations: Vec::new(),
// Catalogue profiles don't declare capabilities yet;
// the union is filled in Pass 2 from loaded locations.
capabilities: Vec::new(),
}, },
); );
} }
@@ -385,6 +441,14 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
if was_loaded { if was_loaded {
e.loaded = true; e.loaded = true;
} }
// Union the per-node capabilities so a model loaded
// on several neurons reports every modality any of
// them advertises.
for cap in &entry.capabilities {
if !e.capabilities.contains(cap) {
e.capabilities.push(cap.clone());
}
}
}) })
.or_insert_with(|| CortexModelEntry { .or_insert_with(|| CortexModelEntry {
id: model_id.clone(), id: model_id.clone(),
@@ -396,6 +460,7 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
// feasibility; leave empty. // feasibility; leave empty.
feasible_on: Vec::new(), feasible_on: Vec::new(),
locations: vec![location], locations: vec![location],
capabilities: entry.capabilities.clone(),
}); });
} }
} }
@@ -445,6 +510,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
loaded: false, loaded: false,
feasible_on: Vec::new(), feasible_on: Vec::new(),
locations: vec![location], locations: vec![location],
// A model that's only mid-prewarm has no loaded
// location to read capabilities from yet.
capabilities: Vec::new(),
}); });
} }
} }
@@ -474,6 +542,7 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
loaded: target_entry.loaded, loaded: target_entry.loaded,
feasible_on: target_entry.feasible_on, feasible_on: target_entry.feasible_on,
locations: target_entry.locations, locations: target_entry.locations,
capabilities: target_entry.capabilities,
}, },
); );
} }

View File

@@ -107,12 +107,14 @@ async fn poll_neuron(fleet: &CortexState, name: &str, endpoint: &str) {
.and_modify(|e| { .and_modify(|e| {
e.status = status; e.status = status;
e.vram_estimate_mb = upstream.vram_used_mb; e.vram_estimate_mb = upstream.vram_used_mb;
e.capabilities = upstream.capabilities.clone();
}) })
.or_insert_with(|| ModelEntry { .or_insert_with(|| ModelEntry {
id: upstream.id.clone(), id: upstream.id.clone(),
status, status,
last_accessed: None, last_accessed: None,
vram_estimate_mb: upstream.vram_used_mb, vram_estimate_mb: upstream.vram_used_mb,
capabilities: upstream.capabilities.clone(),
}); });
} }

View File

@@ -244,6 +244,7 @@ async fn cold_load(
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: Some(chrono::Utc::now()), last_accessed: Some(chrono::Utc::now()),
vram_estimate_mb: profile.vram_mb, vram_estimate_mb: profile.vram_mb,
capabilities: Vec::new(),
}, },
); );
} }
@@ -292,7 +293,7 @@ async fn profile_to_spec(
}; };
ModelSpec { ModelSpec {
model_id: profile.id.clone(), model_id: qualified_model_id(profile),
harness: profile.harness.clone(), harness: profile.harness.clone(),
quant: profile.quant.clone(), quant: profile.quant.clone(),
tensor_parallel, tensor_parallel,
@@ -300,6 +301,22 @@ async fn profile_to_spec(
} }
} }
/// Prefix the catalogue id with the scheme when one is declared, so
/// neuron resolves the load against the right registry. Without this,
/// a profile pointing at the helexa registry would resolve via
/// neuron's `default_source` (typically `huggingface`) and fetch
/// bytes from the wrong place. Profiles that omit `source` continue
/// to pass the bare id through, preserving the pre-Phase-3 contract.
///
/// Stays at module scope (not nested in `profile_to_spec`) so the unit
/// tests can exercise it without spinning up CortexState topology.
fn qualified_model_id(profile: &ModelProfile) -> String {
match profile.source.as_deref() {
Some(scheme) if !scheme.is_empty() => format!("{scheme}:{}", profile.id),
_ => profile.id.clone(),
}
}
/// Resolve neuron's `/models/{id}/endpoint` to its inference URL and /// Resolve neuron's `/models/{id}/endpoint` to its inference URL and
/// build the final `RouteDecision`. Shared by all three priority /// build the final `RouteDecision`. Shared by all three priority
/// branches above. /// branches above.
@@ -375,7 +392,43 @@ fn rewrite_loopback_host(inference_url: &str, neuron_endpoint: &str) -> Option<S
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::rewrite_loopback_host; use super::{ModelProfile, qualified_model_id, rewrite_loopback_host};
fn bare_profile(id: &str, source: Option<&str>) -> ModelProfile {
ModelProfile {
id: id.into(),
harness: "candle".into(),
quant: None,
vram_mb: None,
min_devices: 1,
min_device_vram_mb: None,
pinned_on: vec![],
source: source.map(String::from),
}
}
#[test]
fn qualified_id_passes_through_when_source_absent() {
let p = bare_profile("Qwen/Qwen3-30B", None);
assert_eq!(qualified_model_id(&p), "Qwen/Qwen3-30B");
}
#[test]
fn qualified_id_prefixes_when_source_set() {
let p = bare_profile("Helexa/Qwen3.6-27B-Uncensored", Some("helexa"));
assert_eq!(
qualified_model_id(&p),
"helexa:Helexa/Qwen3.6-27B-Uncensored"
);
}
#[test]
fn qualified_id_passes_through_when_source_is_empty_string() {
// An empty scheme is treated as absent — neuron's default_source
// substitution kicks in.
let p = bare_profile("Qwen/Qwen3-30B", Some(""));
assert_eq!(qualified_model_id(&p), "Qwen/Qwen3-30B");
}
#[test] #[test]
fn rewrites_localhost_keeps_port_and_path() { fn rewrites_localhost_keeps_port_and_path() {

View File

@@ -74,6 +74,7 @@ async fn test_alias_resolves_in_chat_completions() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: None, vram_estimate_mb: None,
capabilities: Vec::new(),
}, },
); );
} }
@@ -154,6 +155,7 @@ async fn test_aliases_surface_in_v1_models() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: Some(2000), vram_estimate_mb: Some(2000),
capabilities: Vec::new(),
}, },
); );
} }
@@ -235,6 +237,7 @@ async fn test_alias_falls_through_for_unmapped_model() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: None, vram_estimate_mb: None,
capabilities: Vec::new(),
}, },
); );
} }

View File

@@ -44,6 +44,7 @@ pub async fn spawn_mock_neuron() -> String {
post(|Json(_body): Json<Value>| async { Json(json!({"status": "unloaded"})) }), post(|Json(_body): Json<Value>| async { Json(json!({"status": "unloaded"})) }),
) )
.route("/v1/chat/completions", post(mock_chat_completions)) .route("/v1/chat/completions", post(mock_chat_completions))
.route("/v1/responses", post(mock_responses))
.route("/v1/models", get(mock_v1_models)); .route("/v1/models", get(mock_v1_models));
tokio::spawn(async move { tokio::spawn(async move {
@@ -93,6 +94,39 @@ async fn mock_chat_completions(Json(body): Json<Value>) -> Json<Value> {
})) }))
} }
async fn mock_responses(Json(body): Json<Value>) -> Json<Value> {
let model = body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Echo the model field back and synthesise a tiny ResponsesResponse.
// Mirrors the shape neuron's /v1/responses handler emits so the
// gateway test only needs to assert the proxy round-tripped it.
Json(json!({
"id": "resp-test-001",
"object": "response",
"created_at": 1700000000_u64,
"status": "completed",
"model": model,
"output": [{
"type": "message",
"id": "msg-test-001",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "Hello from mock backend",
"annotations": []
}],
"status": "completed"
}],
"usage": {
"input_tokens": 5,
"output_tokens": 5,
"total_tokens": 10
}
}))
}
/// Spawns a mock neuron that returns SSE streaming responses for chat completions. /// Spawns a mock neuron that returns SSE streaming responses for chat completions.
pub async fn spawn_streaming_mock_neuron(chunk_count: usize, chunk_delay: Duration) -> String { pub async fn spawn_streaming_mock_neuron(chunk_count: usize, chunk_delay: Duration) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -271,6 +305,7 @@ pub async fn spawn_gateway_with_state(mock_url: &str) -> (Arc<CortexState>, Stri
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: Some(8000), vram_estimate_mb: Some(8000),
capabilities: Vec::new(),
}, },
); );
} }

View File

@@ -91,6 +91,7 @@ async fn test_evict_lru_model() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: Some(Utc::now() - chrono::Duration::hours(2)), last_accessed: Some(Utc::now() - chrono::Duration::hours(2)),
vram_estimate_mb: Some(8000), vram_estimate_mb: Some(8000),
capabilities: Vec::new(),
}, },
); );
node.models.insert( node.models.insert(
@@ -100,6 +101,7 @@ async fn test_evict_lru_model() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: Some(Utc::now()), last_accessed: Some(Utc::now()),
vram_estimate_mb: Some(8000), vram_estimate_mb: Some(8000),
capabilities: Vec::new(),
}, },
); );
} }
@@ -163,6 +165,7 @@ async fn test_eviction_increments_lifecycle_cycles() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: None, vram_estimate_mb: None,
capabilities: Vec::new(),
}, },
); );
} }

View File

@@ -118,6 +118,87 @@ async fn test_poller_updates_gateway_models_endpoint() {
} }
} }
#[tokio::test]
async fn test_models_endpoint_unions_capabilities_across_nodes() {
// C3: two neurons each have the same model loaded but advertise
// different capability sets. The gateway's /v1/models must report
// the union — a model loaded text-only on one node and
// text+vision on another is vision-capable to the fleet.
let node_a = common::spawn_mock_neuron_with_models(json!([
{"id": "shared-model", "harness": "candle", "status": "loaded", "devices": [0], "vram_used_mb": null, "capabilities": ["text"]}
]))
.await;
let node_b = common::spawn_mock_neuron_with_models(json!([
{"id": "shared-model", "harness": "candle", "status": "loaded", "devices": [1], "vram_used_mb": null, "capabilities": ["text", "vision"]}
]))
.await;
let config = GatewayConfig {
gateway: GatewaySettings {
listen: "127.0.0.1:0".into(),
metrics_listen: "127.0.0.1:0".into(),
},
eviction: EvictionSettings {
strategy: EvictionStrategy::Lru,
defrag_after_cycles: 0,
},
neurons: vec![
NeuronEndpoint {
name: "node-a".into(),
endpoint: node_a,
},
NeuronEndpoint {
name: "node-b".into(),
endpoint: node_b,
},
],
models_config: "/dev/null".into(),
};
let fleet = Arc::new(CortexState::from_config(&config));
cortex_gateway::poller::poll_once(&fleet).await;
let app = cortex_gateway::build_app(Arc::clone(&fleet));
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();
});
let client = reqwest::Client::new();
let body: serde_json::Value = client
.get(format!("http://{addr}/v1/models"))
.send()
.await
.expect("request should succeed")
.json()
.await
.unwrap();
let model = body["data"]
.as_array()
.expect("data array")
.iter()
.find(|m| m["id"] == "shared-model")
.expect("shared-model should be present");
let caps: Vec<&str> = model["capabilities"]
.as_array()
.expect("capabilities array")
.iter()
.filter_map(|c| c.as_str())
.collect();
assert!(caps.contains(&"text"), "union must include text: {caps:?}");
assert!(
caps.contains(&"vision"),
"union must include vision: {caps:?}"
);
assert_eq!(caps.len(), 2, "union must not duplicate text: {caps:?}");
// Both nodes hold the model, so two locations regardless of caps.
assert_eq!(model["locations"].as_array().unwrap().len(), 2);
}
#[tokio::test] #[tokio::test]
async fn test_poller_marks_unreachable_node_unhealthy() { async fn test_poller_marks_unreachable_node_unhealthy() {
let config = GatewayConfig { let config = GatewayConfig {
@@ -216,6 +297,7 @@ async fn test_poller_removes_stale_models() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: None, vram_estimate_mb: None,
capabilities: Vec::new(),
}, },
); );
node.models.insert( node.models.insert(
@@ -225,6 +307,7 @@ async fn test_poller_removes_stale_models() {
status: ModelStatus::Loaded, status: ModelStatus::Loaded,
last_accessed: None, last_accessed: None,
vram_estimate_mb: None, vram_estimate_mb: None,
capabilities: Vec::new(),
}, },
); );
} }

View File

@@ -0,0 +1,91 @@
//! Integration tests for the `/v1/responses` proxy route.
//!
//! The gateway forwards the request body to whichever neuron has the
//! model loaded. These tests exercise the routing decision (200 on a
//! known model, 404 on an unknown model, 400 on a missing model
//! field) and confirm the response body round-trips verbatim.
mod common;
use serde_json::json;
/// Happy path: gateway routes a `/v1/responses` request to the neuron
/// that has the model loaded, and the neuron's response body
/// arrives at the client unchanged.
#[tokio::test]
async fn test_responses_proxy() {
let mock_url = common::spawn_mock_neuron().await;
let gw_url = common::spawn_gateway(&mock_url).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{gw_url}/v1/responses"))
.header("content-type", "application/json")
.json(&json!({
"model": "test-model",
"input": "Hi"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("valid JSON response");
assert_eq!(body["id"], "resp-test-001");
assert_eq!(body["object"], "response");
assert_eq!(body["model"], "test-model");
assert_eq!(body["status"], "completed");
assert_eq!(
body["output"][0]["content"][0]["text"],
"Hello from mock backend"
);
// Usage shape is the Responses-specific (input/output_tokens),
// not the chat-completions one (prompt/completion_tokens). Asserts
// the proxy didn't accidentally route through the wrong handler.
assert_eq!(body["usage"]["total_tokens"], 10);
assert!(body["usage"].get("input_tokens").is_some());
}
/// A request that targets a model not present in the catalogue gets
/// 404 from the router. This matches the chat-completions handler's
/// behaviour — same error path, same status code, so a client can
/// share retry logic across the two routes.
#[tokio::test]
async fn test_responses_model_not_found() {
let mock_url = common::spawn_mock_neuron().await;
let gw_url = common::spawn_gateway(&mock_url).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{gw_url}/v1/responses"))
.json(&json!({
"model": "not-in-catalogue",
"input": "Hi"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
/// A request body without a `model` field can't be routed; the
/// gateway returns 400 before reaching a backend. Same as the
/// chat-completions handler — extracted via the same `extract_model`
/// helper.
#[tokio::test]
async fn test_responses_missing_model_field() {
let mock_url = common::spawn_mock_neuron().await;
let gw_url = common::spawn_gateway(&mock_url).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{gw_url}/v1/responses"))
.json(&json!({
"input": "Hi"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}

View File

@@ -16,7 +16,12 @@ to cortex (helexa's reverse-proxy / fleet gateway).
# a painless migration to a dedicated GitHub repo in the future if the # a painless migration to a dedicated GitHub repo in the future if the
# project grows beyond helexa's needs. All deps are crates.io. # project grows beyond helexa's needs. All deps are crates.io.
[dependencies] [dependencies]
agent-client-protocol = "0.12" # `unstable_session_model` flips on the SessionModelState type and the
# session/set_model RPC the model-picker dropdown in Zed needs. The
# feature is upstream-marked unstable; we accept that risk because the
# model picker is core UX and the alternative (rolling our own
# extension method) drifts further from spec each time it moves.
agent-client-protocol = { version = "0.12", features = ["unstable_session_model"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "process", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "process", "signal"] }
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -33,6 +38,10 @@ tokio-util = { version = "0.7", features = ["rt"] }
eventsource-stream = "0.2" eventsource-stream = "0.2"
async-stream = "0.3" async-stream = "0.3"
url = { version = "2", features = ["serde"] } url = { version = "2", features = ["serde"] }
# Already transitively pulled via the ACP SDK; declared directly so we
# can format ISO 8601 timestamps for `SessionInfo.updated_at` in the
# session/list response.
chrono = { version = "0.4", default-features = false, features = ["std"] }
[[bin]] [[bin]]
name = "helexa-acp" name = "helexa-acp"

546
crates/helexa-acp/README.md Normal file
View File

@@ -0,0 +1,546 @@
# helexa-acp
ACP (Agent Client Protocol) bridge for editors like
[Zed](https://zed.dev). Lets you point your editor's agent panel at
**any combination** of OpenAI-compatible, OpenAI Responses, and
Anthropic Messages endpoints — public APIs, private LAN deployments,
local Ollama / LM Studio — and switch between them per session via a
model dropdown.
The "missing ACP binary" for users who don't want to be locked into
one vendor's agent client.
```
┌───────────────────────────────────┐
│ Zed (or any ACP editor client) │
└────────────┬──────────────────────┘
│ stdio JSON-RPC (ACP)
┌─────────────────┐
│ helexa-acp │ ← one binary, multi-endpoint
└─────┬───────────┘
│ HTTP / SSE
┌────────┼─────────────┬──────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
cortex/ OpenAI Anthropic OpenRouter LM Studio
neuron Responses Messages
(self- (gpt-5,…) (Claude)
hosted)
```
## What it does
- **Speaks ACP** over stdio to editor clients (Zed today; any future
ACP client tomorrow).
- **Multi-endpoint** — one config file lists every LLM endpoint
you want available; pick one per session via the model dropdown
(`endpoint:model` selector).
- **Three wire formats**: `openai-chat` (the broadly compatible
default), `openai-responses` (newer OpenAI surface), and
`anthropic-messages` (Claude). Each is a separate provider impl
in `src/provider/`; adding a fourth (Gemini, Ollama native, …) is
one file plus a `WireApi` enum variant.
- **Built-in tools**: `read_file`, `write_file`, `edit_file`,
`list_dir`, `bash`. Permission-gated by default; the editor user
approves writes/shell per-call.
- **Three session modes**: Default (gated), Bypass Permissions
(auto-allow), and Plan (write-only-to-plan-dir, no shell).
- **Vision** — drag-drop images into the agent panel against any
vision-capable model.
- **Session resume** — multi-day conversations survive editor
restarts via on-disk transcript persistence.
- **Context compaction** — rolling history stays inside the model's
context window automatically so long sessions on small-context
local models don't fall over.
## Install
### From source
```sh
git clone https://git.lair.cafe/helexa/cortex.git
cd cortex
cargo install --path crates/helexa-acp
# Binary lands at ~/.cargo/bin/helexa-acp
```
### Pre-built RPM (Fedora 43)
```sh
dnf copr enable helexa/helexa
dnf install helexa-acp
```
The COPR project bundles helexa-acp alongside the cortex gateway
and helexa-neuron flavours; install only the package(s) you need.
## Quick start
The fastest path: env-var single-endpoint config.
```sh
export HELEXA_ACP_BASE_URL=http://hanzalova.internal:31313/v1
export HELEXA_ACP_MODEL=Qwen/Qwen3.6-27B
helexa-acp # speaks ACP over stdin/stdout; not interactive
```
Then in Zed (`~/.config/zed/settings.json`):
```jsonc
{
"agent_servers": {
"helexa": {
"command": "helexa-acp",
"args": []
}
}
}
```
Restart Zed → open the agent panel → pick "helexa" → start
chatting. Tool calls (file reads, writes, bash) prompt for
permission per-call in Default mode.
That's the minimum. The full config story below is what unlocks
the multi-endpoint dropdown.
## Multi-endpoint config
Copy `helexa-acp.example.toml` from this repo to
`$XDG_CONFIG_HOME/helexa-acp/config.toml` (typically
`~/.config/helexa-acp/config.toml`) and edit:
```toml
default_endpoint = "helexa"
[[endpoints]]
name = "helexa"
base_url = "http://hanzalova.internal:31313/v1"
wire_api = "openai-chat"
default_model = "Qwen/Qwen3.6-27B"
max_tokens = 8192
context_window = 32768
[[endpoints]]
name = "openrouter"
base_url = "https://openrouter.ai/api/v1"
wire_api = "openai-chat"
api_key_env = "OPENROUTER_API_KEY"
default_model = "anthropic/claude-opus-4"
[[endpoints]]
name = "anthropic"
base_url = "https://api.anthropic.com/v1"
wire_api = "anthropic-messages"
api_key_env = "ANTHROPIC_API_KEY"
default_model = "claude-opus-4"
```
Restart Zed. The model dropdown lists every model from every
configured endpoint with the `endpoint:model` selector
(`helexa:Qwen/Qwen3.6-27B`, `openrouter:anthropic/claude-opus-4`,
…). Switch mid-session; the next prompt routes to the new endpoint.
When only one endpoint is configured the prefix is dropped (model
ids appear bare).
### Selector syntax
The `model` field on every internal request is parsed as
`<endpoint>:<model>`:
- `openrouter:gpt-4o` → routes to the `openrouter` endpoint,
model `gpt-4o`.
- `helexa/large` → no colon → falls through to whichever endpoint
is named in `default_endpoint`, model `helexa/large`.
- `:gpt-5` → leading colon → also falls through to default.
## Endpoint cookbook
Copy-pasteable blocks. Mix and match.
### cortex / neuron (self-hosted)
```toml
[[endpoints]]
name = "helexa"
base_url = "http://hanzalova.internal:31313/v1"
wire_api = "openai-chat"
default_model = "Qwen/Qwen3.6-27B"
max_tokens = 8192
context_window = 32768
```
Use `openai-responses` instead of `openai-chat` once cortex 0.1.16+
is deployed and you want the Responses API surface (vision item
shape, structured reasoning items, etc.).
### OpenAI directly
```toml
[[endpoints]]
name = "openai"
base_url = "https://api.openai.com/v1"
wire_api = "openai-responses"
api_key_env = "OPENAI_API_KEY"
default_model = "gpt-5"
```
`openai-responses` is the right choice for current OpenAI models;
`openai-chat` works against legacy GPT-3.5/4 deployments and
anything labelled "chat completions".
### Anthropic directly
```toml
[[endpoints]]
name = "anthropic"
base_url = "https://api.anthropic.com/v1"
wire_api = "anthropic-messages"
api_key_env = "ANTHROPIC_API_KEY"
default_model = "claude-opus-4"
```
helexa-acp sends `x-api-key` + `anthropic-version: 2023-06-01`
automatically. The `api_key_env` indirection keeps your key out of
the config file.
### OpenRouter (multi-vendor proxy)
```toml
[[endpoints]]
name = "openrouter"
base_url = "https://openrouter.ai/api/v1"
wire_api = "openai-chat"
api_key_env = "OPENROUTER_API_KEY"
default_model = "anthropic/claude-opus-4"
```
OpenRouter speaks OpenAI-compat for every model it fronts, so
`openai-chat` is the right wire format regardless of the
underlying vendor.
### LM Studio (local)
```toml
[[endpoints]]
name = "lmstudio"
base_url = "http://localhost:1234/v1"
wire_api = "openai-chat"
default_model = "auto"
```
LM Studio's "auto" model id picks whatever's loaded. Same shape
works for Ollama in compat mode (`http://localhost:11434/v1`) and
vLLM.
### Multiple cortex deployments
```toml
[[endpoints]]
name = "lan"
base_url = "http://hanzalova.internal:31313/v1"
wire_api = "openai-chat"
default_model = "Qwen/Qwen3.6-27B"
[[endpoints]]
name = "cloud"
base_url = "https://cortex.example.com/v1"
wire_api = "openai-chat"
api_key_env = "CLOUD_CORTEX_KEY"
default_model = "Qwen/Qwen3-VL-8B"
```
Use the `endpoint:model` selector to switch between them mid-session.
## Zed setup
`~/.config/zed/settings.json`:
```jsonc
{
"agent_servers": {
"helexa": {
"command": "helexa-acp"
}
}
}
```
Optional environment overrides for the binary:
```jsonc
{
"agent_servers": {
"helexa": {
"command": "helexa-acp",
"env": {
"HELEXA_ACP_LOG_FILE": "/tmp/helexa-acp.log",
"RUST_LOG": "helexa_acp=debug"
}
}
}
}
```
`HELEXA_ACP_LOG_FILE` is the one you actually want — Zed doesn't
surface the agent's stderr, so without that env var debug output is
invisible. Point it at a file you can `tail -f`.
After restarting Zed: ⌘+? (or wherever your "Open Agent Panel"
binding is) → select "helexa" → the model dropdown populates from
your config → start prompting.
## Modes
Three session modes ship; the user picks via Zed's mode dropdown
on the agent panel.
| Mode | Reads | Writes | Bash | Permission prompts |
|------|-------|--------|------|--------------------|
| **Default** | ✓ | with prompt | with prompt | per call |
| **Bypass Permissions** | ✓ | ✓ | ✓ | never |
| **Plan** | ✓ | only into plan dir | disabled | never (plan-dir writes auto-allow) |
### Default
Reads are always allowed (`read_file`, `list_dir` are
unrestricted). Writes and shell commands prompt the user before
running. The intended baseline for any session where the agent
might do something you'd rather review first.
### Bypass Permissions
Auto-allow every tool call. Use for agentic loops you trust — bulk
edits across many files, scripted workflows, prepared session
templates. Never for code the agent hasn't seen before.
### Plan
The "draft an implementation plan before you write code" mode.
Available tools:
- `read_file`, `list_dir`: unrestricted (read the codebase).
- `write_file`, `edit_file`: allowed *only* under
`$XDG_DATA_HOME/helexa-acp/plans/<project-id>/`. Any path
outside that returns "plan mode: writes are restricted to …"
back to the model so it self-corrects.
- `bash`: disabled outright. Returns "plan mode: shell execution
is disabled" if attempted.
When the plan is complete, the model presents a 3-option menu:
1. **Bypass Permissions** — implement the plan now, no prompts.
2. **Default** — implement now with per-tool prompts.
3. **Plan** (stay here) — refine the plan with more guidance.
Switch the mode dropdown to your preference and reply to proceed.
## Tools
Five tools, defined in `src/tools.rs`:
| Tool | Args | Gated in Default? |
|------|------|-------------------|
| `read_file` | `path`, `line?`, `limit?` | no |
| `list_dir` | `path` | no |
| `write_file` | `path`, `content` | yes |
| `edit_file` | `path`, `old_text`, `new_text` | yes |
| `bash` | `command`, `cwd?` | yes |
### Path handling
`~`, `~/`, `$HOME`, and `$HOME/` are expanded server-side before
the path reaches ACP or local fs. Lets the model emit
`~/git/repo/file.rs` and have it Just Work.
`read_file` first tries the editor's filesystem (ACP's
`fs/read_text_file` — respects open buffers, workspace overlays,
etc.). If that fails — typically because the path is outside Zed's
workspace boundary — it falls back to `std::fs::read_to_string`.
This lets the agent pull in shared material like
`~/git/architecture/generic.md` from a different project's
session.
The fallback is logged at warn level so you can see when it kicks
in.
### Tool dispatch
Tool descriptions reach the model through a Qwen3 Hermes-format
`# Tools` block injected into the system prompt — cortex/neuron
pass the OpenAI `tools` request field through to the encoder
unread, so we work the model into emitting `<tool_call>{json}</tool_call>`
markers it then parses out of the content stream. This applies to
the helexa wire format; OpenAI / Anthropic endpoints with native
tool support would use their own paths once they're wired in.
The parser is tolerant: malformed JSON (trailing braces, missing
`name`, name nested in `arguments`) gets a repair pass; if that
fails the call surfaces as a "Malformed tool call" card in Zed and
the model gets a synthetic error result so it can self-correct.
## Session resume
helexa-acp persists every session to
`$XDG_DATA_HOME/helexa-acp/sessions/<id>.json`. Zed's `session/list`
RPC asks helexa-acp to enumerate them on workspace open;
`session/load` rehydrates and replays the transcript as
`session/update` notifications so the agent panel renders the
prior conversation.
Behaviour:
- Persisted per-round, so a mid-turn agent stall (long bash, wedged
ACP roundtrip) doesn't lose earlier rounds.
- Survives editor restart and the helexa-acp binary upgrading
between versions.
- Project-scoped: only sessions whose `cwd` matches the workspace
are listed.
To wipe history: `rm -rf $XDG_DATA_HOME/helexa-acp/sessions/`.
## Context compaction
When an endpoint sets `context_window`, helexa-acp projects the
rolling history into a token budget before each request — old
`ToolResult` content (read_file payloads are the worst offenders)
gets elided to one-line markers, preserving `tool_call_id` pairing
so the wire schema stays valid.
System prompts, user turns, and the most recent ~4 messages are
never elided. The full history stays on disk; compaction is a
per-request projection, not a destructive edit.
Set `context_window = 32768` for a 32 K Qwen3, `131072` for a
modern Claude, etc. With `max_tokens` also set, the budget is
`context_window - max_tokens - 512_safety`.
## Troubleshooting
### "default endpoint 'helexa' has no usable provider — check config"
The named default endpoint failed to construct. Usually:
- `api_key_env` references a variable that isn't set in the env
Zed launched helexa-acp with.
- The TOML's `wire_api` is misspelled (only `openai-chat`,
`openai-responses`, `anthropic-messages` are accepted).
Test by running `helexa-acp` directly from a shell — startup
errors land on stderr.
### Model dropdown is empty
Each provider's `list_models` failed at startup. Look at
`HELEXA_ACP_LOG_FILE` for "list_models failed; this endpoint's
models won't appear in the picker". Likely the endpoint URL is
wrong, the API key is invalid, or the upstream `/v1/models`
endpoint isn't responding.
The agent still works against `default_model` even when the
dropdown is empty — list-models is for picking, not routing.
### "prompt_too_long" / agent stalls mid-conversation
You hit the model's context window. Set `context_window` on the
endpoint and helexa-acp will compact before sending. The log line
`context compaction applied` confirms it's running; if it fires
but the upstream still rejects, the compaction heuristic
under-counted and the budget needs tuning down.
### Reading files outside the workspace returns "not found"
Zed's `fs/read_text_file` is workspace-scoped. helexa-acp falls
back to local `std::fs` automatically when that fails — look for
`fs/read_text_file failed; falling back to local std::fs` in the
log. If even local read fails, the file genuinely doesn't exist
or the user process lacks permissions.
### Tool calls render as text instead of structured cards
The model is emitting `<tool_call>` markers that the parser can't
decode. Two common causes:
1. The system prompt isn't reaching the model (cortex/neuron's
tool-block injection didn't fire). Confirm with
`RUST_LOG=helexa_acp=debug` and look at the outgoing
`POST /chat/completions` body.
2. The model itself is too small / undertrained to follow the
Hermes format reliably. helexa-acp has shape-based name
inference and JSON repair, but there's a floor below which
nothing helps.
### Plan-mode writes refused even inside the plan dir
The path comparison is byte-for-byte. If the model emits a path
with `~` and the plan_dir has the expanded form, expansion runs
*before* the comparison — but resolved-vs-symlinked-path
mismatches can still bite. The error message names the attempted
path and the expected prefix so you can compare directly.
## Architecture
Source layout under `crates/helexa-acp/src/`:
| File | Responsibility |
|------|----------------|
| `main.rs` | tokio + Stdio transport. Builds providers, hands off to `agent::Agent` |
| `config.rs` | TOML + env-fallback config, endpoint resolver |
| `agent.rs` | ACP handlers (initialize, session/new, session/prompt, session/cancel, session/set_mode, session/set_model, session/load, session/list), prompt loop with tool-call recursion |
| `session.rs` | Per-session state map (Arc<RwLock<HashMap<…>>>) |
| `store.rs` | On-disk session persistence, plan-dir resolution |
| `prompt.rs` | System-prompt assembly, plan-mode addendum |
| `tools.rs` | Tool schemas + shape-based name inference |
| `tool_runner.rs` | Dispatch a single tool call through ACP client RPCs; permission gate |
| `qwen3.rs` | Qwen3 Hermes tool-format parser (`<tool_call>` / `<think>` markers) |
| `compaction.rs` | Token-budget compaction for the rolling history |
| `path_util.rs` | `~` / `$HOME` expansion shared across every path-taking tool |
| `provider/openai_chat.rs` | OpenAI chat completions provider |
| `provider/openai_responses.rs` | OpenAI Responses API provider |
| `provider/anthropic_messages.rs` | Anthropic Messages API provider |
### Adding a new wire format
1. New file under `src/provider/` implementing the `Provider`
trait (encoder + SSE decoder).
2. Add a `WireApi` variant in `config.rs`.
3. Wire it into `build_provider` in `main.rs`.
4. Done — every other module is wire-format-agnostic.
### Concurrency
- `Arc<RwLock<HashMap<SessionId, Arc<Mutex<SessionState>>>>>`
per-session mutex so concurrent requests across sessions don't
contend; the map's RwLock is read-mostly.
- Every tool call dispatched serially within a session (parallel
dispatch would require Zed to handle interleaved permission
prompts).
- Provider streams are back-pressured by the consumer (bounded
mpsc channels).
### Self-contained
The crate has no workspace-internal dependencies (no
`cortex-core`, no `cortex-gateway`). Migration to a dedicated
GitHub repo for cross-platform CI / cargo-dist binaries is
Cargo.toml-only.
## Status
- Stages 16 shipped: scaffold, agent loop, tools, modes, session
resume, image input, model picker, three wire formats.
- Stage 8 (RPM + multi-platform CI) tracked in the canonical plan;
Linux x86_64 RPM ships today via the cortex monorepo's Gitea
Actions.
## Contributing
Repository: https://git.lair.cafe/helexa/cortex (`crates/helexa-acp/`).
Issues / PRs welcome. The canonical staged plan is in
`~/.claude/plans/plan-the-per-device-worker-abstract-micali.md` on
the maintainer's machine; the substages 3a3e and 6a/6b that the
canonical plan didn't anticipate are documented in commit messages.
CI: `cargo fmt --check --all`, `cargo clippy --workspace -- -D
warnings`, `cargo test --workspace` must all pass before merge.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
//! Rolling-conversation compaction for small-context local models.
//!
//! The tool-call loop in [`crate::agent`] grows the message vec it
//! sends upstream every round. On a frontier model that's fine; on a
//! 32 K Qwen3 the first few `read_file` results can push the prompt
//! past the model's context window, at which point cortex/neuron
//! refuses with `prompt_too_long` and the whole turn dies. Long-form
//! local agents are unusable without something here.
//!
//! Strategy (intentionally simple — no LLM-summarization round-trip,
//! no tokenizer dependency):
//!
//! 1. **Protect** the things the model cannot reason without:
//! - The system prompt (idx 0).
//! - Every `Role::User` turn (the user's intent — irreplaceable).
//! - The last [`KEEP_TAIL`] messages (most recent rounds stay
//! verbatim so the model can keep working on what it just
//! observed).
//! 2. **Elide** older `Role::Assistant` prose and older `Role::Tool`
//! result content. The structure stays — `tool_call_id`s, tool
//! names, and argument JSON survive intact — so OpenAI's strict
//! `tool_calls` ↔ `tool` pairing schema remains satisfied. Only
//! the *payload* shrinks to a one-line marker.
//! 3. Walk oldest→newest, recomputing the budget after each elision.
//! Stop as soon as we fit; we don't compact more than necessary.
//! 4. If we still exceed budget after eliding everything we're
//! allowed to, return what we have. The upstream will surface a
//! `prompt_too_long` error and the user can intervene; that's
//! better than silently dropping content the model needs.
//!
//! Token estimation uses a `chars / 3.5` heuristic — conservative
//! (over-estimates tokens slightly) so we compact a touch early
//! rather than a touch late.
use crate::provider::{Message, MessageContent, MessagePart, Role};
/// Most-recent N messages that are never elided. Roughly "the
/// current tool round in flight" — assistant turn that called the
/// tools + each tool result + a bit of slack.
const KEEP_TAIL: usize = 4;
/// Below this content size we don't bother eliding — the savings
/// don't outweigh the loss of detail. Roughly 6080 tokens.
const ELIDE_MIN_CHARS: usize = 256;
/// Roughly tokens-per-character for English + code mixed in. The
/// actual per-tokenizer ratio varies (GPT-4o ≈ 4 chars/token on
/// English prose, ≈ 3 chars/token on code-heavy text). We pick a
/// value on the conservative end so the budget check fires *before*
/// the upstream tokenizer says no.
const CHARS_PER_TOKEN: f32 = 3.5;
/// Per-message envelope overhead (role + JSON framing). Comes out
/// to a few tokens; tiny but it adds up across long histories.
const ENVELOPE_TOKENS: usize = 8;
/// Rough per-image token cost used by the budget estimator. Real
/// vision tokenizers vary widely (2561024 tokens for typical
/// resolutions on Qwen3-VL, OpenAI's `low`/`high` detail toggles
/// pick between ~85 and ~1000+). 512 is a defensible middle that
/// keeps compaction from treating images as free.
const IMAGE_TOKENS_APPROX: usize = 512;
/// Stats reported back from [`compact_to_budget`] for the caller to
/// log. The numbers are estimates (see [`estimate_tokens`]), so
/// don't compare them to upstream-reported token counts as if they
/// were exact.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CompactionStats {
/// Estimated tokens in the input messages.
pub original_tokens: usize,
/// Estimated tokens after compaction. Equal to `original_tokens`
/// when no compaction was needed.
pub final_tokens: usize,
/// Number of messages whose content was elided. Zero is the
/// hot path (nothing to do).
pub elided_messages: usize,
}
impl CompactionStats {
fn unchanged(tokens: usize) -> Self {
Self {
original_tokens: tokens,
final_tokens: tokens,
elided_messages: 0,
}
}
}
/// Approximate token count for one message. Sums the textual
/// payload's chars, divides by [`CHARS_PER_TOKEN`], and adds an
/// envelope constant. Cheap (no allocation) so safe to call once per
/// message per round.
pub fn estimate_tokens(msg: &Message) -> usize {
let chars = match &msg.content {
MessageContent::Text { text } => text.len(),
MessageContent::MultiPart { parts } => parts
.iter()
.map(|p| match p {
MessagePart::Text { text } => text.len(),
// Each image is one block in the context window; the
// upstream tokenizer handles the real cost (and it
// varies wildly by model — Qwen3-VL uses ~256-1024
// tokens per image depending on size). Take a
// middle estimate so the budget tracker doesn't
// pretend images are free.
MessagePart::Image(_) => IMAGE_TOKENS_APPROX * CHARS_PER_TOKEN as usize,
})
.sum(),
MessageContent::ToolCalls { text, calls } => {
let txt = text.as_deref().map(|s| s.len()).unwrap_or(0);
let calls_size: usize = calls
.iter()
.map(|c| c.name.len() + c.arguments.len() + c.id.len())
.sum();
txt + calls_size
}
MessageContent::ToolResult {
tool_call_id,
content,
} => tool_call_id.len() + content.len(),
};
((chars as f32 / CHARS_PER_TOKEN) as usize) + ENVELOPE_TOKENS
}
/// Sum of [`estimate_tokens`] across all messages.
pub fn total_tokens(messages: &[Message]) -> usize {
messages.iter().map(estimate_tokens).sum()
}
/// Project `messages` into a vec whose estimated token count fits in
/// `budget` tokens. Returns the projection plus stats about what
/// was done. When the input already fits, the projection is a clone
/// of the input and stats report zero elisions.
///
/// See module docs for the strategy and protected set.
pub fn compact_to_budget(messages: &[Message], budget: usize) -> (Vec<Message>, CompactionStats) {
let original = total_tokens(messages);
if original <= budget {
return (messages.to_vec(), CompactionStats::unchanged(original));
}
let mut out = messages.to_vec();
let len = out.len();
let tail_start = len.saturating_sub(KEEP_TAIL);
let mut elided = 0usize;
// Two passes. First pass: ToolResult contents (largest savings
// per elision — read_file payloads land here). Second pass: long
// Assistant prose. We don't interleave because eliding a long
// assistant turn before a really old read_file would do less
// good per elision; oldest-first ordering is enforced *within*
// each pass instead.
for pass in 0..2 {
for i in 1..tail_start {
if matches!(out[i].role, Role::User) {
continue;
}
let target_pass_2 = matches!(
&out[i].content,
MessageContent::Text { .. } | MessageContent::ToolCalls { .. }
);
let target_pass_1 = matches!(&out[i].content, MessageContent::ToolResult { .. });
let in_pass = (pass == 0 && target_pass_1) || (pass == 1 && target_pass_2);
if !in_pass {
continue;
}
if elide_in_place(&mut out[i]) {
elided += 1;
if total_tokens(&out) <= budget {
let final_tokens = total_tokens(&out);
return (
out,
CompactionStats {
original_tokens: original,
final_tokens,
elided_messages: elided,
},
);
}
}
}
}
let final_tokens = total_tokens(&out);
(
out,
CompactionStats {
original_tokens: original,
final_tokens,
elided_messages: elided,
},
)
}
/// Shrink one message's payload while keeping its structural role
/// (so tool_call_id pairing survives). Returns `true` when the
/// message changed.
///
/// - `ToolResult.content` → `(elided: N bytes of tool result)`
/// - `ToolCalls.text` → `(elided: N bytes of assistant prose)`
/// - `Text` (assistant) → `(elided: N bytes of assistant prose)`
///
/// Already-tiny payloads are skipped — eliding a 50-byte string
/// would *grow* it once the marker is in place.
fn elide_in_place(msg: &mut Message) -> bool {
match &mut msg.content {
MessageContent::ToolResult { content, .. } => {
if content.len() < ELIDE_MIN_CHARS {
return false;
}
*content = format!("(elided: {} bytes of tool result)", content.len());
true
}
MessageContent::ToolCalls { text, .. } => match text {
Some(t) if t.len() >= ELIDE_MIN_CHARS => {
*text = Some(format!("(elided: {} bytes of assistant prose)", t.len()));
true
}
_ => false,
},
MessageContent::Text { text } => {
if text.len() < ELIDE_MIN_CHARS {
return false;
}
*text = format!("(elided: {} bytes of assistant prose)", text.len());
true
}
MessageContent::MultiPart { .. } => {
// MultiPart messages today only exist as User turns,
// and User turns are protected by the role check in
// `compact_to_budget` — so this branch is unreachable
// for current call sites. Returning false keeps the
// unreachable path benign if a future stage starts
// emitting MultiPart on other roles.
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolCall;
fn sys(text: &str) -> Message {
Message {
role: Role::System,
content: MessageContent::Text { text: text.into() },
}
}
fn user(text: &str) -> Message {
Message {
role: Role::User,
content: MessageContent::Text { text: text.into() },
}
}
fn assistant_text(text: &str) -> Message {
Message {
role: Role::Assistant,
content: MessageContent::Text { text: text.into() },
}
}
fn assistant_calls(text: Option<&str>, name: &str, args: &str, id: &str) -> Message {
Message {
role: Role::Assistant,
content: MessageContent::ToolCalls {
text: text.map(|s| s.to_string()),
calls: vec![ToolCall {
id: id.into(),
name: name.into(),
arguments: args.into(),
}],
},
}
}
fn tool_result(id: &str, body: &str) -> Message {
Message {
role: Role::Tool,
content: MessageContent::ToolResult {
tool_call_id: id.into(),
content: body.into(),
},
}
}
#[test]
fn under_budget_is_a_no_op_clone() {
let msgs = vec![sys("you are an agent"), user("hi"), assistant_text("hello")];
let (out, stats) = compact_to_budget(&msgs, 10_000);
assert_eq!(stats.elided_messages, 0);
assert_eq!(stats.original_tokens, stats.final_tokens);
assert_eq!(out.len(), msgs.len());
// Strings unchanged.
match &out[2].content {
MessageContent::Text { text } => assert_eq!(text, "hello"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn elides_old_tool_result_before_old_assistant_prose() {
// History: sys, user, assistant_calls, big_tool_result,
// assistant_with_big_text, user, assistant_calls,
// small_tool_result.
// KEEP_TAIL=4 protects the last four; the big tool result
// sits in the prunable range and should go first because
// pass 0 (tool results) runs before pass 1 (prose).
let big_result = "X".repeat(4096);
let big_prose = "Y".repeat(2048);
let msgs = vec![
sys("preamble"),
user("first ask"),
assistant_calls(None, "read_file", r#"{"path":"/a"}"#, "c0"),
tool_result("c0", &big_result),
assistant_text(&big_prose),
user("follow up"),
assistant_calls(None, "read_file", r#"{"path":"/b"}"#, "c1"),
tool_result("c1", "short result body"),
];
let before = total_tokens(&msgs);
// Force compaction by setting budget well below current.
let budget = before / 2;
let (out, stats) = compact_to_budget(&msgs, budget);
assert!(
stats.elided_messages >= 1,
"expected at least one elision, got {stats:?}"
);
// The big tool result must be elided (oldest fat target).
match &out[3].content {
MessageContent::ToolResult { content, .. } => {
assert!(
content.starts_with("(elided:"),
"tool result not elided: {content:?}"
);
}
other => panic!("expected ToolResult, got {other:?}"),
}
// Last four messages must be untouched.
assert!(matches!(
&out[out.len() - 1].content,
MessageContent::ToolResult { content, .. } if content == "short result body"
));
}
#[test]
fn never_elides_system_or_user_turns() {
let big_user = "U".repeat(8192);
let msgs = vec![sys("preamble"), user(&big_user), assistant_text("ok")];
let budget = 10; // way below — forces all possible elision
let (out, _stats) = compact_to_budget(&msgs, budget);
// System unchanged.
match &out[0].content {
MessageContent::Text { text } => assert_eq!(text, "preamble"),
other => panic!("expected Text, got {other:?}"),
}
// User unchanged even though it's huge.
match &out[1].content {
MessageContent::Text { text } => assert_eq!(text.len(), big_user.len()),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn preserves_tool_call_id_pairing_after_elision() {
// OpenAI strict mode rejects a tool-result whose tool_call_id
// doesn't match a preceding assistant tool_call. Elision
// must not break that linkage.
let big = "Z".repeat(4096);
let msgs = vec![
sys("preamble"),
user("first"),
assistant_calls(None, "read_file", r#"{"path":"/a"}"#, "call_42"),
tool_result("call_42", &big),
// Tail messages.
user("next"),
assistant_calls(None, "read_file", r#"{"path":"/b"}"#, "call_43"),
tool_result("call_43", "ok"),
assistant_text("done"),
];
let budget = total_tokens(&msgs) / 3;
let (out, _stats) = compact_to_budget(&msgs, budget);
// The assistant call and its result both carry call_42.
let call_id = match &out[2].content {
MessageContent::ToolCalls { calls, .. } => calls[0].id.clone(),
other => panic!("expected ToolCalls, got {other:?}"),
};
match &out[3].content {
MessageContent::ToolResult { tool_call_id, .. } => {
assert_eq!(tool_call_id, &call_id, "pairing broken");
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn estimate_tokens_grows_with_content() {
let small = sys("hi");
let large = sys(&"x".repeat(10_000));
assert!(estimate_tokens(&large) > estimate_tokens(&small) * 100);
}
#[test]
fn elide_in_place_skips_short_content() {
let mut m = tool_result("c0", "tiny");
assert!(!elide_in_place(&mut m));
match m.content {
MessageContent::ToolResult { content, .. } => assert_eq!(content, "tiny"),
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn returns_best_effort_when_budget_unmeetable() {
// Single huge user message that cannot be elided. Budget 10.
// We don't error — we return what we have and let upstream
// refuse the prompt with its own error.
let big_user = "U".repeat(100_000);
let msgs = vec![sys("preamble"), user(&big_user)];
let (out, stats) = compact_to_budget(&msgs, 10);
assert_eq!(out.len(), msgs.len());
assert!(stats.final_tokens > 10, "still over budget by design");
}
}

View File

@@ -90,6 +90,22 @@ pub struct EndpointConfig {
/// unauthenticated calls. /// unauthenticated calls.
#[serde(default)] #[serde(default)]
pub api_key_env: Option<String>, pub api_key_env: Option<String>,
/// Cap on the model's output tokens per turn. `None` lets the
/// upstream pick its own default (cortex/neuron's default is
/// often small enough to trip Zed's "Output Limit Reached" on
/// long responses). Set to e.g. `32768` to let the model
/// produce longer turns. Goes into the OpenAI `max_tokens`
/// request field.
#[serde(default)]
pub max_tokens: Option<u64>,
/// Model context window in tokens (prompt + response). When set,
/// the agent compacts conversation history before each completion
/// so the prompt fits within `context_window - max_tokens - safety`
/// tokens — long sessions on small-context local models (Qwen3 at
/// 32 K) survive past the first few tool-call rounds rather than
/// dying with `prompt_too_long`. `None` disables compaction.
#[serde(default)]
pub context_window: Option<usize>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -134,7 +150,15 @@ impl EndpointConfig {
join_segments(&self.base_url, &["chat", "completions"]) join_segments(&self.base_url, &["chat", "completions"])
} }
/// `{base_url}/models`. /// `{base_url}/responses` — OpenAI Responses API endpoint.
pub fn responses_url(&self) -> Url {
join_segments(&self.base_url, &["responses"])
}
/// `{base_url}/models`. Called from `Provider::list_models`, which
/// Stage 4 wires into the model-picker dropdown; until then it's
/// reachable code with no in-tree callers.
#[allow(dead_code)]
pub fn models_url(&self) -> Url { pub fn models_url(&self) -> Url {
join_segments(&self.base_url, &["models"]) join_segments(&self.base_url, &["models"])
} }
@@ -156,7 +180,7 @@ impl Config {
/// Single-endpoint config constructed from `HELEXA_ACP_BASE_URL`, /// Single-endpoint config constructed from `HELEXA_ACP_BASE_URL`,
/// `HELEXA_ACP_MODEL`, `HELEXA_ACP_API_KEY`, /// `HELEXA_ACP_MODEL`, `HELEXA_ACP_API_KEY`,
/// `HELEXA_ACP_SYSTEM_PROMPT_PATH`. /// `HELEXA_ACP_SYSTEM_PROMPT_PATH`, `HELEXA_ACP_MAX_TOKENS`.
pub fn from_env() -> anyhow::Result<Self> { pub fn from_env() -> anyhow::Result<Self> {
let base_url = std::env::var("HELEXA_ACP_BASE_URL") let base_url = std::env::var("HELEXA_ACP_BASE_URL")
.ok() .ok()
@@ -173,6 +197,24 @@ impl Config {
.ok() .ok()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(PathBuf::from); .map(PathBuf::from);
let max_tokens = std::env::var("HELEXA_ACP_MAX_TOKENS")
.ok()
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<u64>().with_context(|| {
format!("HELEXA_ACP_MAX_TOKENS is not a positive integer ({s})")
})
})
.transpose()?;
let context_window = std::env::var("HELEXA_ACP_CONTEXT_WINDOW")
.ok()
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<usize>().with_context(|| {
format!("HELEXA_ACP_CONTEXT_WINDOW is not a positive integer ({s})")
})
})
.transpose()?;
Ok(Self { Ok(Self {
default_endpoint: Some(DEFAULT_ENDPOINT_NAME.into()), default_endpoint: Some(DEFAULT_ENDPOINT_NAME.into()),
endpoints: vec![EndpointConfig { endpoints: vec![EndpointConfig {
@@ -182,6 +224,8 @@ impl Config {
default_model: Some(default_model), default_model: Some(default_model),
api_key, api_key,
api_key_env: None, api_key_env: None,
max_tokens,
context_window,
}], }],
system_prompt_path, system_prompt_path,
}) })
@@ -294,6 +338,8 @@ mod tests {
default_model: None, default_model: None,
api_key: None, api_key: None,
api_key_env: None, api_key_env: None,
max_tokens: None,
context_window: None,
}; };
assert_eq!( assert_eq!(
ep.chat_completions_url().as_str(), ep.chat_completions_url().as_str(),

View File

@@ -2,25 +2,90 @@
//! setups (helexa, LM Studio, Ollama, OpenRouter, OpenAI, Anthropic, //! setups (helexa, LM Studio, Ollama, OpenRouter, OpenAI, Anthropic,
//! …) with a clean per-endpoint wire-format selector. //! …) with a clean per-endpoint wire-format selector.
//! //!
//! Speaks ACP over stdio to an editor client (Zed today). The //! Speaks ACP over stdio to an editor client (Zed today). Every
//! conversation is forwarded to one of the configured endpoints via //! configured endpoint produces a wire-format-specific
//! a wire-format-specific [`provider::Provider`] implementation. //! [`provider::Provider`] implementation; the agent loop in
//! The agent loop itself is provider-agnostic adding e.g. an //! [`agent::Agent`] is provider-agnostic, so adding e.g. an Anthropic
//! Anthropic /v1/messages provider doesn't touch `agent.rs`. //! /v1/messages provider doesn't touch `agent.rs`.
//! //!
//! Config: `$XDG_CONFIG_HOME/helexa-acp/config.toml` for the multi- //! Config: `$XDG_CONFIG_HOME/helexa-acp/config.toml` for the multi-
//! endpoint case; env vars (`HELEXA_ACP_BASE_URL`, etc.) for the //! endpoint case; env vars (`HELEXA_ACP_BASE_URL`, etc.) for the
//! single-endpoint case when no config file exists. //! single-endpoint case when no config file exists.
use agent_client_protocol::schema::{AgentCapabilities, InitializeRequest, InitializeResponse}; use agent_client_protocol::{Result, Stdio};
use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Result, Stdio};
use std::sync::Arc; use std::sync::Arc;
mod agent;
mod compaction;
mod config; mod config;
mod path_util;
mod prompt;
mod provider; mod provider;
mod qwen3;
mod session;
mod store;
mod tool_runner;
mod tools;
use agent::Agent;
use config::{Config, EndpointConfig, WireApi}; use config::{Config, EndpointConfig, WireApi};
use provider::{Provider, openai_chat::OpenAIChatProvider}; use provider::{
Provider, anthropic_messages::AnthropicMessagesProvider, openai_chat::OpenAIChatProvider,
openai_responses::OpenAIResponsesProvider,
};
/// Set up tracing. Logs go to stderr by default — stdout is
/// reserved for the JSON-RPC stream. Setting `HELEXA_ACP_LOG_FILE`
/// to an absolute path appends logs to that file instead, which is
/// the practical way to capture debug output when the agent runs
/// under an editor (Zed, etc.) that doesn't surface stderr.
///
/// `RUST_LOG` still controls levels (e.g. `helexa_acp=debug`).
/// ANSI colours are auto-stripped when writing to a file so the log
/// is plain text.
fn init_tracing() {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let log_file = std::env::var("HELEXA_ACP_LOG_FILE")
.ok()
.filter(|s| !s.is_empty());
match log_file {
Some(path) => match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(file) => {
tracing_subscriber::fmt()
.with_writer(std::sync::Mutex::new(file))
.with_env_filter(env_filter)
.with_ansi(false)
.init();
}
Err(e) => {
// Fall back to stderr and shout. We don't want a
// typo'd log path to silence the agent entirely.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(env_filter)
.init();
tracing::warn!(
path = %path,
error = %e,
"HELEXA_ACP_LOG_FILE could not be opened; using stderr"
);
}
},
None => {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(env_filter)
.init();
}
}
}
/// Build a provider for `endpoint` according to its declared /// Build a provider for `endpoint` according to its declared
/// `wire_api`. Future wire types (OpenAI Responses, Anthropic /// `wire_api`. Future wire types (OpenAI Responses, Anthropic
@@ -29,28 +94,14 @@ use provider::{Provider, openai_chat::OpenAIChatProvider};
fn build_provider(endpoint: EndpointConfig) -> anyhow::Result<Arc<dyn Provider>> { fn build_provider(endpoint: EndpointConfig) -> anyhow::Result<Arc<dyn Provider>> {
match endpoint.wire_api { match endpoint.wire_api {
WireApi::OpenAiChat => Ok(Arc::new(OpenAIChatProvider::new(endpoint)?)), WireApi::OpenAiChat => Ok(Arc::new(OpenAIChatProvider::new(endpoint)?)),
WireApi::OpenAiResponses => Err(anyhow::anyhow!( WireApi::OpenAiResponses => Ok(Arc::new(OpenAIResponsesProvider::new(endpoint)?)),
"endpoint '{}' wire_api 'openai-responses' is reserved for a future provider; \ WireApi::AnthropicMessages => Ok(Arc::new(AnthropicMessagesProvider::new(endpoint)?)),
use 'openai-chat' for now or wait for the OpenAIResponsesProvider impl",
endpoint.name
)),
WireApi::AnthropicMessages => Err(anyhow::anyhow!(
"endpoint '{}' wire_api 'anthropic-messages' is reserved for a future provider",
endpoint.name
)),
} }
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Logs go to stderr — stdout is reserved for the JSON-RPC stream. init_tracing();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cfg = Config::load() let cfg = Config::load()
.map_err(|e| agent_client_protocol::util::internal_error(format!("config: {e:#}")))?; .map_err(|e| agent_client_protocol::util::internal_error(format!("config: {e:#}")))?;
@@ -86,36 +137,9 @@ async fn main() -> Result<()> {
} }
} }
} }
if providers.is_empty() {
return Err(agent_client_protocol::util::internal_error(
"no usable endpoints — check config",
));
}
Agent let agent = Agent::new(&cfg, providers)
.builder()
.name("helexa-acp")
.on_receive_request(
async move |initialize: InitializeRequest, responder, _connection| {
// Phase 1 wiring — capabilities only. Real session
// handling lands in the next iteration (agent.rs).
responder.respond(
InitializeResponse::new(initialize.protocol_version)
.agent_capabilities(AgentCapabilities::new()),
)
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_dispatch(
async move |message: Dispatch, cx: ConnectionTo<Client>| {
tracing::warn!(method = ?message.method(), "unhandled ACP message");
message.respond_with_error(
agent_client_protocol::util::internal_error("not implemented yet"),
cx,
)
},
agent_client_protocol::on_receive_dispatch!(),
)
.connect_to(Stdio::new())
.await .await
.map_err(|e| agent_client_protocol::util::internal_error(format!("agent: {e:#}")))?;
agent.serve(Stdio::new()).await
} }

View File

@@ -0,0 +1,192 @@
//! Path expansion shared across every tool that takes a path.
//!
//! Models often emit shell-style paths like `~/git/repo/file.rs` or
//! `$HOME/notes.md`. ACP's `fs/read_text_file` and friends — and our
//! own local `std::fs` reads — both want a real absolute path; the
//! `~` / `$HOME` forms reach them as literal strings and the open
//! fails. The tool schemas already document "absolute path" but in
//! practice the model slips up often enough that handling it
//! server-side is the difference between "works" and "the agent is
//! brittle".
//!
//! Scope is deliberately small:
//!
//! - `~` and `~/` (current user only — `~user` lookups would require
//! pulling in passwd parsing).
//! - `$HOME` and `$HOME/`.
//!
//! Any other shell variable (`$PWD`, `${HOME}`, …) passes through
//! unchanged. The shell already expands them inside `bash` tool
//! commands; for the file-tool argument fields, we deliberately
//! limit the set so the behaviour is predictable.
//!
//! Falls back to the input path verbatim when `HOME` is unset
//! (stripped-down container env). That preserves the "no surprise
//! mutations" rule — never invent a path the caller didn't ask for.
use std::path::{Path, PathBuf};
/// Process-global lock for tests that mutate `HOME`. Anyone in the
/// crate touching `HOME` must hold this for the duration of the
/// read-modify-restore window — otherwise concurrent `cargo test`
/// workers race and flake.
///
/// Only built into the test binaries. Production code never mutates
/// env vars.
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Expand `~`, `~/`, `$HOME`, and `$HOME/` prefixes against the
/// current user's home directory. All other inputs pass through
/// unchanged.
///
/// Returns the input verbatim if `HOME` isn't set in the env.
pub fn expand_path(input: &Path) -> PathBuf {
let Some(s) = input.to_str() else {
return input.to_path_buf();
};
let Ok(home) = std::env::var("HOME") else {
return input.to_path_buf();
};
let home = PathBuf::from(home);
if s == "~" || s == "$HOME" {
return home;
}
if let Some(rest) = s.strip_prefix("~/") {
return home.join(rest);
}
if let Some(rest) = s.strip_prefix("$HOME/") {
return home.join(rest);
}
input.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
/// Set HOME for the duration of the test. Tests using this run
/// serially under the crate-wide [`ENV_LOCK`] because env
/// mutation isn't thread-safe — `cargo test` parallel workers
/// would race without it.
fn with_home<F: FnOnce()>(home: &str, body: F) {
let _g = ENV_LOCK.lock().unwrap();
let prior = std::env::var("HOME").ok();
// SAFETY: tests touch process-global env. The mutex
// serialises access; sub-threads in other test modules
// touching HOME aren't expected (none in this crate).
unsafe {
std::env::set_var("HOME", home);
}
body();
unsafe {
match prior {
Some(p) => std::env::set_var("HOME", p),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn expands_tilde_slash() {
with_home("/home/me", || {
assert_eq!(
expand_path(Path::new("~/git/repo/file.rs")),
PathBuf::from("/home/me/git/repo/file.rs")
);
});
}
#[test]
fn expands_bare_tilde() {
with_home("/home/me", || {
assert_eq!(expand_path(Path::new("~")), PathBuf::from("/home/me"));
});
}
#[test]
fn expands_dollar_home_slash() {
with_home("/home/me", || {
assert_eq!(
expand_path(Path::new("$HOME/notes.md")),
PathBuf::from("/home/me/notes.md")
);
});
}
#[test]
fn expands_bare_dollar_home() {
with_home("/home/me", || {
assert_eq!(expand_path(Path::new("$HOME")), PathBuf::from("/home/me"));
});
}
#[test]
fn absolute_path_passes_through() {
with_home("/home/me", || {
assert_eq!(
expand_path(Path::new("/etc/hostname")),
PathBuf::from("/etc/hostname")
);
});
}
#[test]
fn relative_path_passes_through() {
with_home("/home/me", || {
assert_eq!(
expand_path(Path::new("src/main.rs")),
PathBuf::from("src/main.rs")
);
});
}
#[test]
fn tilde_user_form_not_expanded() {
// ~other is shell sugar for /home/other and would require
// passwd parsing to resolve. Out of scope — pass it
// through and let the open fail with a clear error.
with_home("/home/me", || {
assert_eq!(
expand_path(Path::new("~other/x")),
PathBuf::from("~other/x")
);
});
}
#[test]
fn no_home_env_passes_through() {
// Share the same crate-wide lock as `with_home` — otherwise
// a parallel test setting HOME races this clear-and-assert
// window.
let _g = ENV_LOCK.lock().unwrap();
let prior = std::env::var("HOME").ok();
// SAFETY: serialised by LOCK above.
unsafe {
std::env::remove_var("HOME");
}
assert_eq!(
expand_path(Path::new("~/git/repo")),
PathBuf::from("~/git/repo")
);
unsafe {
if let Some(p) = prior {
std::env::set_var("HOME", p);
}
}
}
#[test]
fn dollar_other_var_not_expanded() {
with_home("/home/me", || {
assert_eq!(
expand_path(Path::new("$PWD/file")),
PathBuf::from("$PWD/file")
);
assert_eq!(
expand_path(Path::new("${HOME}/file")),
PathBuf::from("${HOME}/file")
);
});
}
}

View File

@@ -0,0 +1,274 @@
//! System prompt assembly.
//!
//! The system message has two parts:
//!
//! 1. A short human-readable preamble (working directory, style
//! instructions). Either the built-in [`DEFAULT_PROMPT`] or a
//! user-supplied file at `HELEXA_ACP_SYSTEM_PROMPT_PATH` /
//! `system_prompt_path`. `{cwd}` is substituted in both.
//! 2. A `# Tools` block in Qwen3 Hermes format (see [`crate::qwen3`])
//! describing the available functions. This is what makes the
//! model actually call them — neuron/cortex don't honour the
//! OpenAI `tools` API field, so the tool list has to live in the
//! prompt itself.
use agent_client_protocol::schema::SessionModeId;
use anyhow::Context;
use std::path::Path;
use crate::provider::ToolSpec;
use crate::qwen3;
use crate::session::MODE_PLAN;
const DEFAULT_PROMPT: &str = "\
You are helexa-acp, a coding assistant working inside an editor.
Working directory: {cwd}
Use the tools described below whenever the user's request involves
looking at or modifying files, or running commands. Do not ask the
user to paste file contents you could read yourself. All file paths
must be absolute. Writes and shell commands may prompt the user for
permission depending on the session mode.
Be concise; the user is reading your output in an editor pane.";
/// Build the system prompt for a session.
///
/// - `cwd`: session working directory (substituted for `{cwd}` in
/// the preamble — both the default and any user-supplied template).
/// - `override_path`: path to a user-supplied template, already
/// resolved by [`crate::config::Config`]. The `# Tools` block is
/// appended *after* the user's template so a custom preamble
/// still gets the tool descriptions the model needs.
/// - `tools`: the tools to advertise. Empty list → no `# Tools`
/// block is appended at all.
/// - `mode`: current session mode. When the mode is [`MODE_PLAN`]
/// a plan-mode addendum describing the restrictions and the
/// completion menu is appended *after* the `# Tools` block so it
/// is the last thing the model reads before user input.
/// - `plan_dir`: resolved plan directory for the cwd. Only consulted
/// when `mode == MODE_PLAN`. `None` means the plan directory could
/// not be resolved (no `HOME` / `XDG_DATA_HOME`) — the addendum
/// still renders but with a placeholder so the model knows to
/// surface the error to the user rather than guess a path.
pub fn build_system_prompt(
cwd: &Path,
override_path: Option<&Path>,
tools: &[ToolSpec],
mode: &SessionModeId,
plan_dir: Option<&Path>,
) -> anyhow::Result<String> {
let template = match override_path {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("read system prompt from {}", path.display()))?,
None => DEFAULT_PROMPT.to_string(),
};
let mut prompt = template.replace("{cwd}", &cwd.display().to_string());
prompt.push_str(&qwen3::render_tool_block(tools));
if mode.0.as_ref() == MODE_PLAN {
prompt.push_str(&render_plan_mode_block(plan_dir));
}
Ok(prompt)
}
/// Plan-mode instruction block. Tells the model:
///
/// 1. Where it may write — only inside `plan_dir`.
/// 2. What it may *not* do — bash is disabled; writes outside
/// `plan_dir` are refused by the runtime.
/// 3. How to finish — emit the 3-option menu so the user can
/// switch modes and either kick off implementation (with or
/// without permission prompts) or keep iterating on the plan.
fn render_plan_mode_block(plan_dir: Option<&Path>) -> String {
let plan_path = plan_dir
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<plan directory could not be resolved — tell the user>".to_string());
format!(
"\n\n# Plan mode\n\
\n\
You are in **plan mode**. Your task is to draft a written\n\
implementation plan for the user; you must NOT modify any\n\
project files or run shell commands.\n\
\n\
Rules in plan mode:\n\
\n\
- `read_file` and `list_dir` are unrestricted — use them to\n\
explore the codebase as needed.\n\
- `write_file` and `edit_file` are allowed ONLY under the\n\
plan directory: `{plan_path}`. The runtime will refuse any\n\
write outside it.\n\
- `bash` is disabled. Do not call it.\n\
\n\
Write the plan as one or more Markdown files under\n\
`{plan_path}`. Use descriptive filenames\n\
(`01-overview.md`, `02-data-model.md`, etc.). It is fine to\n\
iterate — overwrite the file when you refine a section.\n\
\n\
When the plan is complete, do NOT begin implementation.\n\
Instead, end your turn with this menu, verbatim, so the\n\
user can choose how to proceed:\n\
\n\
---\n\
**Plan complete.** To proceed, switch the session mode in\n\
the agent dropdown and send a follow-up message:\n\
\n\
1. **Bypass Permissions** — implement the plan now, skipping\n\
per-tool permission prompts.\n\
2. **Default** — implement the plan now, prompting before\n\
each write or shell command.\n\
3. **Plan** (stay here) — refine the plan; reply with the\n\
change you want and I will revise it.\n\
---\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{MODE_DEFAULT, MODE_PLAN};
use std::io::Write;
fn default_mode() -> SessionModeId {
SessionModeId::new(MODE_DEFAULT)
}
fn plan_mode() -> SessionModeId {
SessionModeId::new(MODE_PLAN)
}
#[test]
fn default_prompt_substitutes_cwd() {
let prompt =
build_system_prompt(Path::new("/home/me/proj"), None, &[], &default_mode(), None)
.unwrap();
assert!(
prompt.contains("/home/me/proj"),
"cwd not interpolated: {prompt}"
);
assert!(prompt.contains("helexa-acp"));
assert!(
!prompt.contains("{cwd}"),
"left-over placeholder in default prompt"
);
// With no tools, the # Tools block is absent.
assert!(!prompt.contains("# Tools"));
// Default mode does not get the plan-mode addendum.
assert!(!prompt.contains("# Plan mode"));
}
#[test]
fn tools_are_appended_in_hermes_format() {
let spec = ToolSpec {
name: "read_file".into(),
description: "Read a file.".into(),
parameters: serde_json::json!({"type":"object","properties":{}, "required":[]}),
};
let prompt =
build_system_prompt(Path::new("/x"), None, &[spec], &default_mode(), None).unwrap();
assert!(prompt.contains("# Tools"));
assert!(prompt.contains("<tools>"));
assert!(prompt.contains("\"name\":\"read_file\""));
assert!(prompt.contains("<tool_call>"));
}
#[test]
fn override_path_is_read_and_templated() {
let mut tmp = tempfile_in_target("prompt.txt");
tmp.write_all(b"custom prompt for {cwd} only").unwrap();
tmp.flush().unwrap();
let path = tmp.path().to_path_buf();
drop(tmp);
let prompt = build_system_prompt(
Path::new("/etc"),
Some(path.as_path()),
&[],
&default_mode(),
None,
)
.expect("read override");
assert_eq!(prompt, "custom prompt for /etc only");
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_override_path_errors() {
let err = build_system_prompt(
Path::new("/tmp"),
Some(Path::new("/definitely/not/a/real/path")),
&[],
&default_mode(),
None,
)
.unwrap_err();
assert!(format!("{err:#}").contains("read system prompt"));
}
#[test]
fn plan_mode_addendum_includes_plan_dir_and_menu() {
let plan_dir = Path::new("/home/me/.local/share/helexa-acp/plans/proj-deadbeef");
let prompt = build_system_prompt(
Path::new("/home/me/proj"),
None,
&[],
&plan_mode(),
Some(plan_dir),
)
.unwrap();
assert!(prompt.contains("# Plan mode"));
assert!(
prompt.contains(plan_dir.to_str().unwrap()),
"plan dir not interpolated: {prompt}"
);
// The 3-option menu must be present so the model emits it verbatim.
assert!(prompt.contains("Bypass Permissions"));
assert!(prompt.contains("**Default**"));
assert!(prompt.contains("3. **Plan**"));
// Bash disabled instruction must be present.
assert!(prompt.contains("`bash` is disabled"));
}
#[test]
fn plan_mode_addendum_handles_unresolved_plan_dir() {
let prompt =
build_system_prompt(Path::new("/home/me/proj"), None, &[], &plan_mode(), None).unwrap();
assert!(prompt.contains("# Plan mode"));
assert!(prompt.contains("could not be resolved"));
}
/// Tiny temp-file helper that doesn't pull in the `tempfile` crate.
/// Writes under `target/` so it's cleaned up by `cargo clean`.
fn tempfile_in_target(name: &str) -> TempHandle {
let base = std::env::var("CARGO_TARGET_TMPDIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let _ = std::fs::create_dir_all(&base);
let pid = std::process::id();
let path = base.join(format!("helexa-acp-{pid}-{name}"));
let file = std::fs::File::create(&path).expect("create temp file");
TempHandle { file, path }
}
struct TempHandle {
file: std::fs::File,
path: std::path::PathBuf,
}
impl TempHandle {
fn path(&self) -> &Path {
&self.path
}
}
impl Write for TempHandle {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.file.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.file.flush()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,21 +11,15 @@
//! Day-1 provider: [`openai_chat::OpenAIChatProvider`]. Day-N //! Day-1 provider: [`openai_chat::OpenAIChatProvider`]. Day-N
//! providers slot in without touching `agent.rs`. //! providers slot in without touching `agent.rs`.
// Many fields and variants in the public surface here aren't read yet:
// the agent loop that consumes `CompletionEvent`s and constructs
// `CompletionRequest`s lands in the next commit. They're not
// speculative — the unit tests in `provider::openai_chat::tests`
// already verify the encoder/decoder produces them. Once `agent.rs`
// arrives this allow comes off.
#![allow(dead_code)]
use async_trait::async_trait; use async_trait::async_trait;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
pub mod anthropic_messages;
pub mod openai_chat; pub mod openai_chat;
pub mod openai_responses;
/// Provider-agnostic LLM endpoint. Implementations translate between /// Provider-agnostic LLM endpoint. Implementations translate between
/// [`CompletionRequest`] / [`CompletionEvent`] and whatever wire /// [`CompletionRequest`] / [`CompletionEvent`] and whatever wire
@@ -38,8 +32,9 @@ pub trait Provider: Send + Sync {
fn name(&self) -> &str; fn name(&self) -> &str;
/// List models available at this endpoint. Used to build the /// List models available at this endpoint. Used to build the
/// model-picker dropdown in editor clients. Should return quickly /// model-picker dropdown in editor clients (Stage 4). Should
/// (cache if necessary). /// return quickly (cache if necessary).
#[allow(dead_code)]
async fn list_models(&self) -> anyhow::Result<Vec<ModelInfo>>; async fn list_models(&self) -> anyhow::Result<Vec<ModelInfo>>;
/// Run a chat completion. Returns a stream of provider-agnostic /// Run a chat completion. Returns a stream of provider-agnostic
@@ -52,7 +47,10 @@ pub trait Provider: Send + Sync {
) -> anyhow::Result<BoxStream<'static, anyhow::Result<CompletionEvent>>>; ) -> anyhow::Result<BoxStream<'static, anyhow::Result<CompletionEvent>>>;
} }
/// One model exposed by a provider. /// One model exposed by a provider. Constructed by `list_models` —
/// Stage 4 is when the agent loop starts consuming it for the
/// model-picker dropdown.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo { pub struct ModelInfo {
pub id: String, pub id: String,
@@ -77,13 +75,14 @@ pub struct CompletionRequest {
pub max_tokens: Option<u64>, pub max_tokens: Option<u64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message { pub struct Message {
pub role: Role, pub role: Role,
pub content: MessageContent, pub content: MessageContent,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role { pub enum Role {
System, System,
User, User,
@@ -91,28 +90,75 @@ pub enum Role {
/// Tool result message. Provider impls turn this into whatever /// Tool result message. Provider impls turn this into whatever
/// shape the upstream wire format wants (OpenAI uses /// shape the upstream wire format wants (OpenAI uses
/// `role: "tool"` + `tool_call_id`; Anthropic uses content blocks). /// `role: "tool"` + `tool_call_id`; Anthropic uses content blocks).
/// Stage 3 (tools) constructs this; Stage 2 never does.
Tool, Tool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageContent { pub enum MessageContent {
Text(String), /// Plain text turn (system / user / assistant). Struct variant
/// Assistant turn that called one or more tools. /// rather than newtype so the persisted JSON has an explicit
/// `text` field — that lets us use internal tagging on the
/// enum, which is incompatible with newtype-of-primitive
/// variants.
Text { text: String },
/// Mixed text + image user turn. Stage 5 introduces this when
/// Zed sends an `ImageContent` block alongside the user's prompt.
/// Providers that don't support vision should down-convert by
/// dropping image parts and concatenating text parts.
MultiPart { parts: Vec<MessagePart> },
/// Assistant turn that called one or more tools. Stage 3 starts
/// constructing this when the provider stream yields a
/// `ToolCallStart` / `ToolCallArgsDelta` sequence.
ToolCalls { ToolCalls {
/// Optional text the assistant said alongside the tool calls. /// Optional text the assistant said alongside the tool calls.
text: Option<String>, text: Option<String>,
calls: Vec<ToolCall>, calls: Vec<ToolCall>,
}, },
/// Tool result. `tool_call_id` matches the assistant's call id. /// Tool result. `tool_call_id` matches the assistant's call id.
/// Stage 3 constructs this after the tool runner finishes.
ToolResult { ToolResult {
tool_call_id: String, tool_call_id: String,
content: String, content: String,
}, },
} }
#[derive(Debug, Clone)] /// One part of a [`MessageContent::MultiPart`] message.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagePart {
Text { text: String },
Image(ImageData),
}
/// Inline image attachment. `data` is base64-encoded raw image
/// bytes; the encoder constructs an `image_url` data URI from it
/// at request time. `uri` carries any pointer the client supplied
/// (e.g. `file:///tmp/x.png`) — we keep it on the message for
/// debugging / future providers but the OpenAI encoder ignores it
/// when `data` is present (data wins, since it round-trips through
/// every wire format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageData {
pub mime_type: String,
/// Base64-encoded image bytes (no `data:` prefix, no padding
/// stripped — exactly what `ImageContent.data` carried).
pub data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall { pub struct ToolCall {
/// Provider-assigned id that ties the call to its result. /// Provider-assigned id that ties the call to its result. The
/// Qwen3 wire format we use today doesn't carry this on the
/// model side (calls and results are matched positionally inside
/// a turn), so the field looks unused in the prod build — but it
/// flows through to `MessageContent::ToolResult.tool_call_id` for
/// history bookkeeping and a future strict-OpenAI backend will
/// consume it directly.
#[allow(dead_code)]
pub id: String, pub id: String,
pub name: String, pub name: String,
/// JSON-encoded arguments. Kept as a string because providers /// JSON-encoded arguments. Kept as a string because providers
@@ -138,22 +184,44 @@ pub enum CompletionEvent {
/// (e.g. Qwen3 with `<think>` tags surfaced as a separate stream, /// (e.g. Qwen3 with `<think>` tags surfaced as a separate stream,
/// or OpenAI reasoning models). /// or OpenAI reasoning models).
ReasoningDelta(String), ReasoningDelta(String),
/// A new tool call has started. /// A new tool call has started. Stage 2 ignores the payload; the
/// agent loop in Stage 3 reads `index` to correlate with
/// [`Self::ToolCallArgsDelta`], `id` for the eventual tool-result
/// turn, and `name` to dispatch the runner.
#[allow(dead_code)]
ToolCallStart { ToolCallStart {
index: usize, index: usize,
id: String, id: String,
name: String, name: String,
}, },
/// More argument bytes for a tool call already announced via /// More argument bytes for a tool call already announced via
/// [`Self::ToolCallStart`]. /// [`Self::ToolCallStart`]. Stage 2 ignores; Stage 3 accumulates
/// the bytes by `index` until the call's arguments are complete.
#[allow(dead_code)]
ToolCallArgsDelta { index: usize, args_delta: String }, ToolCallArgsDelta { index: usize, args_delta: String },
/// A `<tool_call>` block whose JSON couldn't be parsed even with
/// the qwen3 module's repair attempts. The agent surfaces this
/// as a Failed `SessionUpdate::ToolCall` card with the raw body
/// visible (so the editor renders structured failure UI rather
/// than dumping the body inline in the message pane), and feeds
/// a synthetic tool-error message back into history so the
/// model can self-correct on the next round.
MalformedToolCall { raw: String },
/// Stream finished. Carries the upstream `finish_reason` if it /// Stream finished. Carries the upstream `finish_reason` if it
/// gave one (`"stop"`, `"length"`, `"tool_calls"`, …). /// gave one (`"stop"`, `"length"`, `"tool_calls"`, …).
Finish { reason: Option<String> }, Finish { reason: Option<String> },
/// Final usage stats, if the provider supplied them. /// Final usage stats, if the provider supplied them. Stage 2
/// matches the variant to drop it; Stage 6b (token metrics) is
/// when the payload starts being read.
#[allow(dead_code)]
Usage(UsageStats), Usage(UsageStats),
} }
/// Token accounting reported by the provider at the end of a stream.
/// Stage 2 doesn't surface usage anywhere — the stable `PromptResponse`
/// has no usage field, and the unstable variant is gated. Stage 6b
/// turns these on with Prometheus metrics.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct UsageStats { pub struct UsageStats {
pub prompt_tokens: u64, pub prompt_tokens: u64,

View File

@@ -14,8 +14,8 @@ use serde_json::{Value, json};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use super::{ use super::{
CompletionEvent, CompletionRequest, Message, MessageContent, ModelInfo, Provider, Role, CompletionEvent, CompletionRequest, ImageData, Message, MessageContent, MessagePart, ModelInfo,
ToolCall, ToolSpec, UsageStats, Provider, Role, ToolSpec, UsageStats,
}; };
use crate::config::EndpointConfig; use crate::config::EndpointConfig;
@@ -96,9 +96,29 @@ impl Provider for OpenAIChatProvider {
cancel: CancellationToken, cancel: CancellationToken,
) -> anyhow::Result<BoxStream<'static, anyhow::Result<CompletionEvent>>> { ) -> anyhow::Result<BoxStream<'static, anyhow::Result<CompletionEvent>>> {
let body = encode_request(&request); let body = encode_request(&request);
// Diagnostics for "the model isn't using tools" issues:
// at debug level we log the full body so an operator can
// confirm `tools` is in the request and inspect message
// shapes. Stays at debug because chat history can be large.
tracing::debug!(
endpoint = %self.endpoint.name,
url = %self.endpoint.chat_completions_url(),
body = %serde_json::to_string(&body).unwrap_or_else(|_| "<unserializable>".into()),
"POST /chat/completions"
);
let mut req = self let mut req = self
.http .http
.post(self.endpoint.chat_completions_url()) .post(self.endpoint.chat_completions_url())
// Tell reasoning-aware servers (neuron after issue #8)
// to include the model's `<think>` markers in the
// stream rather than stripping them. helexa-acp's
// ThinkParser routes the marked content to Zed's
// thought UI; without this header neuron would
// default to clean content (the right choice for
// naïve clients like Zed's commit-message generator
// but wrong for us). Servers that don't recognise
// the header ignore it harmlessly.
.header("x-include-thinking", "true")
.json(&body); .json(&body);
if let Some(key) = &self.api_key { if let Some(key) = &self.api_key {
req = req.bearer_auth(key); req = req.bearer_auth(key);
@@ -126,6 +146,7 @@ impl Provider for OpenAIChatProvider {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::provider::ToolCall;
use futures::stream; use futures::stream;
use url::Url; use url::Url;
@@ -137,6 +158,8 @@ mod tests {
default_model: None, default_model: None,
api_key: None, api_key: None,
api_key_env: None, api_key_env: None,
max_tokens: None,
context_window: None,
} }
} }
@@ -147,11 +170,13 @@ mod tests {
messages: vec![ messages: vec![
Message { Message {
role: Role::System, role: Role::System,
content: MessageContent::Text("you are helpful".into()), content: MessageContent::Text {
text: "you are helpful".into(),
},
}, },
Message { Message {
role: Role::User, role: Role::User,
content: MessageContent::Text("hi".into()), content: MessageContent::Text { text: "hi".into() },
}, },
], ],
tools: vec![], tools: vec![],
@@ -174,6 +199,71 @@ mod tests {
assert_eq!(body["stream_options"]["include_usage"], true); assert_eq!(body["stream_options"]["include_usage"], true);
} }
#[test]
fn encodes_user_multipart_with_image_as_content_array() {
let req = CompletionRequest {
model: "vl".into(),
messages: vec![Message {
role: Role::User,
content: MessageContent::MultiPart {
parts: vec![
MessagePart::Text {
text: "what's in this?".into(),
},
MessagePart::Image(ImageData {
mime_type: "image/png".into(),
data: "iVBORw0KGgo=".into(),
uri: None,
}),
],
},
}],
tools: vec![],
temperature: None,
top_p: None,
max_tokens: None,
};
let body = encode_request(&req);
let msg = &body["messages"][0];
assert_eq!(msg["role"], "user");
let content = msg["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "what's in this?");
assert_eq!(content[1]["type"], "image_url");
assert_eq!(
content[1]["image_url"]["url"],
"data:image/png;base64,iVBORw0KGgo="
);
}
#[test]
fn encodes_text_only_user_message_as_string() {
// Regression: even though we accept MultiPart now, the
// string form must stay the encoded shape for plain text
// messages — some upstreams treat array-form as a vision
// request and refuse on text-only models.
let req = CompletionRequest {
model: "m".into(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Text {
text: "plain".into(),
},
}],
tools: vec![],
temperature: None,
top_p: None,
max_tokens: None,
};
let body = encode_request(&req);
assert_eq!(body["messages"][0]["content"], "plain");
assert!(
body["messages"][0]["content"].as_array().is_none(),
"text-only must not become array form"
);
}
#[test] #[test]
fn encodes_tool_call_round_trip() { fn encodes_tool_call_round_trip() {
let req = CompletionRequest { let req = CompletionRequest {
@@ -208,19 +298,40 @@ mod tests {
max_tokens: None, max_tokens: None,
}; };
let body = encode_request(&req); let body = encode_request(&req);
// Tool defs flow through: // Tool defs flow through as a courtesy to any future
// strict-OpenAI backend; today's Qwen3 path puts them in
// the prompt instead.
let tools = body["tools"].as_array().unwrap(); let tools = body["tools"].as_array().unwrap();
assert_eq!(tools[0]["function"]["name"], "read_file"); assert_eq!(tools[0]["function"]["name"], "read_file");
// Assistant tool_calls flow through:
// Qwen3 wire shape for the assistant turn: tool calls are
// inline in `content` as `<tool_call>{…}</tool_call>` blocks,
// *not* in a structured `tool_calls` field.
let asst = &body["messages"][0]; let asst = &body["messages"][0];
assert_eq!(asst["role"], "assistant"); assert_eq!(asst["role"], "assistant");
assert_eq!(asst["tool_calls"][0]["id"], "call_1"); assert!(
assert_eq!(asst["tool_calls"][0]["function"]["name"], "read_file"); asst.get("tool_calls").is_none(),
// Tool result flows through: "tool_calls should not be set"
);
let content = asst["content"].as_str().expect("content is a string");
assert!(
content.starts_with("calling read_file\n<tool_call>"),
"content was: {content}"
);
assert!(content.contains(r#""name":"read_file""#));
assert!(content.contains(r#""path":"/tmp/a.txt""#));
assert!(content.ends_with("</tool_call>"));
// Qwen3 wire shape for the tool result: a user-role turn
// wrapped in `<tool_response>`. No `role: "tool"`.
let tool = &body["messages"][1]; let tool = &body["messages"][1];
assert_eq!(tool["role"], "tool"); assert_eq!(tool["role"], "user");
assert_eq!(tool["tool_call_id"], "call_1"); assert!(tool.get("tool_call_id").is_none());
assert_eq!(tool["content"], "file contents"); let tool_content = tool["content"].as_str().expect("content is a string");
assert_eq!(
tool_content,
"<tool_response>\nfile contents\n</tool_response>"
);
} }
/// Build a fake eventsource stream from canned SSE `data:` lines. /// Build a fake eventsource stream from canned SSE `data:` lines.
@@ -264,6 +375,99 @@ mod tests {
assert_eq!(events.len(), 4); assert_eq!(events.len(), 4);
} }
#[tokio::test]
async fn decodes_qwen3_inline_think_block_to_reasoning_deltas() {
// Qwen3-shaped output: a `<think>…</think>` block lives
// inside `delta.content`. The decoder should route bytes
// inside the block to ReasoningDelta and the surrounding
// content to TextDelta. Marker boundaries split across
// chunks to exercise the parser's prefix-hold logic.
let sse = fake_sse(vec![
r#"{"choices":[{"delta":{"content":"<thi"}}]}"#,
r#"{"choices":[{"delta":{"content":"nk>internal reasoning</thi"}}]}"#,
r#"{"choices":[{"delta":{"content":"nk>visible answer"}}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#,
"[DONE]",
]);
let events: Vec<_> = decode_stream(sse, CancellationToken::new())
.collect::<Vec<_>>()
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let text: String = events
.iter()
.filter_map(|e| match e {
CompletionEvent::TextDelta(t) => Some(t.as_str()),
_ => None,
})
.collect();
let reasoning: String = events
.iter()
.filter_map(|e| match e {
CompletionEvent::ReasoningDelta(r) => Some(r.as_str()),
_ => None,
})
.collect();
assert_eq!(text, "visible answer");
assert_eq!(reasoning, "internal reasoning");
assert!(matches!(
events.last(),
Some(CompletionEvent::Finish { reason }) if reason.as_deref() == Some("stop")
));
}
#[tokio::test]
async fn decodes_qwen3_inline_tool_call_from_content_stream() {
// Qwen3-shaped output: `<tool_call>{…}</tool_call>` inside
// ordinary `delta.content`, split across multiple chunks at
// arbitrary byte boundaries.
let sse = fake_sse(vec![
r#"{"choices":[{"delta":{"content":"sure, let me read it.\n<too"}}]}"#,
r#"{"choices":[{"delta":{"content":"l_call>\n{\"name\":\"read_file\","}}]}"#,
r#"{"choices":[{"delta":{"content":"\"arguments\":{\"path\":\"/etc/hostname\"}}\n</tool_call>"}}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#,
"[DONE]",
]);
let events: Vec<_> = decode_stream(sse, CancellationToken::new())
.collect::<Vec<_>>()
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
// Concatenated text deltas should equal the leading prose
// (everything before `<tool_call>`).
let text: String = events
.iter()
.filter_map(|e| match e {
CompletionEvent::TextDelta(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(text, "sure, let me read it.\n");
// Exactly one structured tool call.
assert!(matches!(
events.iter().find(|e| matches!(e, CompletionEvent::ToolCallStart { .. })),
Some(CompletionEvent::ToolCallStart { index: 0, name, .. }) if name == "read_file"
));
let args: Vec<&str> = events
.iter()
.filter_map(|e| match e {
CompletionEvent::ToolCallArgsDelta { args_delta, .. } => Some(args_delta.as_str()),
_ => None,
})
.collect();
assert_eq!(args.len(), 1);
assert!(args[0].contains(r#""path":"/etc/hostname""#));
// Finish reason still propagates.
assert!(matches!(
events.last(),
Some(CompletionEvent::Finish { reason }) if reason.as_deref() == Some("stop")
));
}
#[tokio::test] #[tokio::test]
async fn decodes_tool_call_progressively() { async fn decodes_tool_call_progressively() {
let sse = fake_sse(vec![ let sse = fake_sse(vec![
@@ -377,44 +581,42 @@ fn encode_request(req: &CompletionRequest) -> Value {
fn encode_message(m: &Message) -> Value { fn encode_message(m: &Message) -> Value {
match (m.role, &m.content) { match (m.role, &m.content) {
(Role::System, MessageContent::Text(s)) => json!({"role": "system", "content": s}), (Role::System, MessageContent::Text { text }) => {
(Role::User, MessageContent::Text(s)) => json!({"role": "user", "content": s}), json!({"role": "system", "content": text})
(Role::Assistant, MessageContent::Text(s)) => json!({"role": "assistant", "content": s}),
(Role::Assistant, MessageContent::ToolCalls { text, calls }) => {
let calls_json: Vec<Value> = calls
.iter()
.map(|c| {
json!({
"id": c.id,
"type": "function",
"function": {
"name": c.name,
"arguments": c.arguments,
} }
}) (Role::User, MessageContent::Text { text }) => json!({"role": "user", "content": text}),
}) (Role::User, MessageContent::MultiPart { parts }) => json!({
.collect(); "role": "user",
"content": encode_user_parts(parts),
}),
(Role::Assistant, MessageContent::Text { text }) => {
json!({"role": "assistant", "content": text})
}
// Qwen3 wire shape: assistant turns that called tools come
// back to the model with `<tool_call>{…}</tool_call>` blocks
// inline in `content`, *not* via the structured `tool_calls`
// field. Using the OpenAI shape here would invisibly drop
// the tool calls from the model's context the next round,
// because neuron's chat template only renders `content`.
(Role::Assistant, MessageContent::ToolCalls { text, calls }) => {
json!({ json!({
"role": "assistant", "role": "assistant",
"content": text.clone().unwrap_or_default(), "content": crate::qwen3::render_assistant_with_tool_calls(text.as_deref(), calls),
"tool_calls": calls_json,
}) })
} }
// Qwen3 convention: tool results live in a *user* turn
// wrapped in `<tool_response>…</tool_response>`. The model
// wasn't trained on a separate `role: "tool"`.
( (
Role::Tool, Role::Tool,
MessageContent::ToolResult { MessageContent::ToolResult {
tool_call_id, tool_call_id: _,
content, content,
}, },
) => json!({ ) => json!({
"role": "tool", "role": "user",
"tool_call_id": tool_call_id, "content": crate::qwen3::render_tool_response(content),
"content": content,
}), }),
// Mismatched (role, content) combinations shouldn't happen
// — the agent constructs them in pairs. If they do, degrade
// gracefully to a plain text turn so the request still goes
// out rather than crashing the conversation.
(role, content) => { (role, content) => {
tracing::warn!( tracing::warn!(
?role, ?role,
@@ -426,6 +628,38 @@ fn encode_message(m: &Message) -> Value {
} }
} }
/// Encode a [`MessageContent::MultiPart`] user message as the OpenAI
/// chat content-array form:
///
/// ```jsonc
/// [
/// {"type": "text", "text": "describe this:"},
/// {"type": "image_url", "image_url": {"url": "data:image/png;base64,…"}}
/// ]
/// ```
///
/// Images use a `data:` URI built from `mime_type` + base64 `data`.
/// `uri` is intentionally ignored here — the inline data form
/// round-trips through every upstream we care about (cortex's
/// model loaders read the bytes directly, OpenAI accepts both
/// data and remote URLs but data is portable). Sticking to one
/// shape keeps wire-level surprises down.
fn encode_user_parts(parts: &[MessagePart]) -> Value {
let items: Vec<Value> = parts
.iter()
.map(|p| match p {
MessagePart::Text { text } => json!({ "type": "text", "text": text }),
MessagePart::Image(ImageData {
mime_type, data, ..
}) => json!({
"type": "image_url",
"image_url": { "url": format!("data:{mime_type};base64,{data}") },
}),
})
.collect();
Value::Array(items)
}
fn role_str(r: Role) -> &'static str { fn role_str(r: Role) -> &'static str {
match r { match r {
Role::System => "system", Role::System => "system",
@@ -437,7 +671,15 @@ fn role_str(r: Role) -> &'static str {
fn content_as_text(c: &MessageContent) -> String { fn content_as_text(c: &MessageContent) -> String {
match c { match c {
MessageContent::Text(s) => s.clone(), MessageContent::Text { text } => text.clone(),
MessageContent::MultiPart { parts } => parts
.iter()
.filter_map(|p| match p {
MessagePart::Text { text } => Some(text.as_str()),
MessagePart::Image(_) => None,
})
.collect::<Vec<_>>()
.join("\n\n"),
MessageContent::ToolCalls { text, .. } => text.clone().unwrap_or_default(), MessageContent::ToolCalls { text, .. } => text.clone().unwrap_or_default(),
MessageContent::ToolResult { content, .. } => content.clone(), MessageContent::ToolResult { content, .. } => content.clone(),
} }
@@ -551,17 +793,30 @@ where
{ {
async_stream::stream! { async_stream::stream! {
// Track which (index) tool calls we've already announced. The // Track which (index) tool calls we've already announced. The
// OpenAI stream emits the id and name only on the first delta // For structured OpenAI tool calls (the canonical wire
// for each tool call; later deltas just carry argument bytes. // format) we still want to dedupe ToolCallStart events per
// index — only the first chunk for a given index carries the
// id and name. This stays alongside the qwen3 text-stream
// parser below; backends that *do* emit structured
// tool_calls (a future strict-OpenAI endpoint) just keep
// working without going through the Qwen3 path.
let mut announced: std::collections::HashSet<usize> = Default::default(); let mut announced: std::collections::HashSet<usize> = Default::default();
// Qwen3 wire path: tool calls come through `delta.content` as
// literal `<tool_call>{…}</tool_call>` blocks. The parser
// splits content into plain-text passthrough and
// structured tool-call events, holding back only the suffix
// bytes that could be the start of a marker.
let mut qwen_parser = crate::qwen3::ToolCallParser::new();
// Same shape, second stage: take the plain-text events out
// of the tool-call parser and split off `<think>…</think>`
// blocks into ReasoningDelta so Zed can render them in its
// dedicated thought UI rather than the message pane.
let mut think_parser = crate::qwen3::ThinkParser::new();
let mut sse = Box::pin(sse); let mut sse = Box::pin(sse);
loop { loop {
tokio::select! { tokio::select! {
// `biased;` checks `cancel.cancelled()` first on every
// poll — without it, a pre-cancelled token loses to a
// ready SSE chunk, and a mid-stream cancellation could
// still consume one more chunk before noticing.
biased; biased;
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
tracing::debug!("openai_chat: cancellation requested, ending stream"); tracing::debug!("openai_chat: cancellation requested, ending stream");
@@ -595,13 +850,55 @@ where
if let Some(text) = choice.delta.content if let Some(text) = choice.delta.content
&& !text.is_empty() && !text.is_empty()
{ {
yield Ok(CompletionEvent::TextDelta(text)); for ev in qwen_parser.feed(&text) {
match ev {
crate::qwen3::ParserEvent::Text(t) if !t.is_empty() => {
for tev in think_parser.feed(&t) {
match tev {
crate::qwen3::ThinkEvent::Text(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::TextDelta(s));
}
crate::qwen3::ThinkEvent::Reasoning(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::ReasoningDelta(s));
}
_ => {}
}
}
}
crate::qwen3::ParserEvent::Text(_) => {}
crate::qwen3::ParserEvent::Start { index, name } => {
yield Ok(CompletionEvent::ToolCallStart {
index,
id: format!("call_{index}"),
name,
});
}
crate::qwen3::ParserEvent::Args { index, args_json } => {
yield Ok(CompletionEvent::ToolCallArgsDelta {
index,
args_delta: args_json,
});
}
crate::qwen3::ParserEvent::Malformed { raw } => {
tracing::warn!(raw = %raw, "qwen3: malformed <tool_call> block; surfacing as Failed tool card");
yield Ok(CompletionEvent::MalformedToolCall { raw });
}
}
}
} }
if let Some(reasoning) = choice.delta.reasoning_content if let Some(reasoning) = choice.delta.reasoning_content
&& !reasoning.is_empty() && !reasoning.is_empty()
{ {
yield Ok(CompletionEvent::ReasoningDelta(reasoning)); yield Ok(CompletionEvent::ReasoningDelta(reasoning));
} }
// Pass-through for backends that *do* emit
// structured tool_calls (a future strict
// OpenAI endpoint). Today cortex never
// populates this, so this branch stays cold.
for tc in choice.delta.tool_calls { for tc in choice.delta.tool_calls {
let idx = tc.index; let idx = tc.index;
if announced.insert(idx) { if announced.insert(idx) {
@@ -628,6 +925,66 @@ where
} }
} }
if let Some(reason) = choice.finish_reason { if let Some(reason) = choice.finish_reason {
// Flush any tail bytes from the qwen
// parser before announcing the finish so
// the agent's stop-reason logic sees the
// complete picture (in particular, any
// trailing <tool_call> block that
// arrived without a close tag).
for ev in qwen_parser.finish() {
match ev {
crate::qwen3::ParserEvent::Text(t) if !t.is_empty() => {
for tev in think_parser.feed(&t) {
match tev {
crate::qwen3::ThinkEvent::Text(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::TextDelta(s));
}
crate::qwen3::ThinkEvent::Reasoning(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::ReasoningDelta(s));
}
_ => {}
}
}
}
crate::qwen3::ParserEvent::Text(_) => {}
crate::qwen3::ParserEvent::Start { index, name } => {
yield Ok(CompletionEvent::ToolCallStart {
index,
id: format!("call_{index}"),
name,
});
}
crate::qwen3::ParserEvent::Args { index, args_json } => {
yield Ok(CompletionEvent::ToolCallArgsDelta {
index,
args_delta: args_json,
});
}
crate::qwen3::ParserEvent::Malformed { raw } => {
tracing::warn!(raw = %raw, "qwen3: unterminated <tool_call> at stream end");
yield Ok(CompletionEvent::MalformedToolCall { raw });
}
}
}
// Flush the think parser too — any
// unclosed <think> at stream end becomes
// a final ReasoningDelta rather than
// being lost.
for tev in think_parser.finish() {
match tev {
crate::qwen3::ThinkEvent::Text(s) if !s.is_empty() => {
yield Ok(CompletionEvent::TextDelta(s));
}
crate::qwen3::ThinkEvent::Reasoning(s) if !s.is_empty() => {
yield Ok(CompletionEvent::ReasoningDelta(s));
}
_ => {}
}
}
yield Ok(CompletionEvent::Finish { reason: Some(reason) }); yield Ok(CompletionEvent::Finish { reason: Some(reason) });
} }
} }

View File

@@ -0,0 +1,987 @@
//! OpenAI Responses API (`POST /v1/responses`) provider.
//!
//! Mirror image of [`super::openai_chat`]: same `Provider` trait
//! impl, same back-pressured SSE decoder, but speaking OpenAI's
//! newer Responses surface instead of chat completions.
//!
//! Differences from the chat provider, all contained in this file:
//!
//! - **Request encoding**: history flattens into an `input` array
//! of typed items (`message`, `function_call`, `function_call_output`)
//! plus a top-level `instructions` field for the system prompt.
//! Multi-part user content stays in the same `[{type:"input_text"},
//! {type:"input_image"}]` shape neuron's `request_to_chat` already
//! accepts.
//! - **Streaming decoder**: events are named (`response.created`,
//! `response.output_text.delta`, `response.completed`, …) carried
//! on the SSE `event:` line. The chat path's `[DONE]` terminator
//! doesn't apply; the stream ends after `response.completed`.
//! - **Tool calls** plumb through the `response.output_item.added`
//! (item type `function_call`) → `response.function_call_arguments.delta`
//! → `response.function_call_arguments.done` event sequence. The
//! neuron candle harness doesn't synthesize these yet (tracked as
//! issue #6), but the decoder is wired so the day the upstream
//! does, downstream `CompletionEvent::ToolCall*` plumbing just
//! works.
//!
//! Tool-name handling: the model knows its tool descriptions via
//! the [`crate::qwen3`] system-prompt block exactly the way the chat
//! provider does. We don't echo them in the request body because
//! neuron currently ignores `tools` on /v1/responses (same as on
//! /v1/chat/completions). Once neuron honours request-side tool
//! definitions, both providers add them in the same place.
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, stream::BoxStream};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use tokio_util::sync::CancellationToken;
use super::{
CompletionEvent, CompletionRequest, Message, MessageContent, MessagePart, ModelInfo, Provider,
Role, UsageStats,
};
use crate::config::EndpointConfig;
pub struct OpenAIResponsesProvider {
endpoint: EndpointConfig,
#[allow(dead_code)] // Read in `complete()`'s HTTP path; tests don't stand up a server.
api_key: Option<String>,
#[allow(dead_code)]
http: reqwest::Client,
}
impl OpenAIResponsesProvider {
pub fn new(endpoint: EndpointConfig) -> anyhow::Result<Self> {
let api_key = endpoint.resolve_api_key()?;
let http = reqwest::Client::builder()
// Same generous timeout as the chat provider: cortex may
// need to cold-load a model before serving the first
// chunk, which can be tens of seconds. Cancellation
// handles early termination, not timeout.
.timeout(std::time::Duration::from_secs(600))
.build()?;
Ok(Self {
endpoint,
api_key,
http,
})
}
}
#[async_trait]
impl Provider for OpenAIResponsesProvider {
fn name(&self) -> &str {
&self.endpoint.name
}
async fn list_models(&self) -> anyhow::Result<Vec<ModelInfo>> {
let mut req = self.http.get(self.endpoint.models_url());
if let Some(key) = &self.api_key {
req = req.bearer_auth(key);
}
let resp = req
.send()
.await
.map_err(|e| anyhow::anyhow!("{} list_models: {e}", self.endpoint.name))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"{} list_models returned {}: {}",
self.endpoint.name,
status,
body
);
}
let body: WireModelsResponse = resp.json().await?;
Ok(body
.data
.into_iter()
.map(|m| ModelInfo {
id: m.id,
display_name: None,
})
.collect())
}
async fn complete(
&self,
request: CompletionRequest,
cancel: CancellationToken,
) -> anyhow::Result<BoxStream<'static, anyhow::Result<CompletionEvent>>> {
let body = encode_request(&request);
tracing::debug!(
endpoint = %self.endpoint.name,
url = %self.endpoint.responses_url(),
body = %serde_json::to_string(&body).unwrap_or_else(|_| "<unserializable>".into()),
"POST /responses"
);
let mut req = self.http.post(self.endpoint.responses_url()).json(&body);
if let Some(key) = &self.api_key {
req = req.bearer_auth(key);
}
let resp = req
.send()
.await
.map_err(|e| anyhow::anyhow!("{} responses send: {e}", self.endpoint.name))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"{} responses returned {}: {}",
self.endpoint.name,
status,
body
);
}
let sse = resp.bytes_stream().eventsource();
let stream = decode_stream(sse, cancel);
Ok(Box::pin(stream))
}
}
// ── Request encoding ─────────────────────────────────────────────────
fn encode_request(req: &CompletionRequest) -> Value {
// Pull the system messages out of history into a single
// `instructions` string — the Responses API expects them there,
// not inline as an `input` item. Multiple system messages
// concatenate with blank lines so we don't lose ordering.
let mut instructions: Vec<String> = Vec::new();
let mut input_items: Vec<Value> = Vec::new();
for msg in &req.messages {
if msg.role == Role::System
&& let MessageContent::Text { text } = &msg.content
{
instructions.push(text.clone());
continue;
}
if let Some(item) = encode_message_as_input_item(msg) {
input_items.push(item);
}
}
let mut body = json!({
"model": req.model,
"input": input_items,
"stream": true,
});
if let Value::Object(map) = &mut body {
if !instructions.is_empty() {
map.insert(
"instructions".into(),
Value::String(instructions.join("\n\n")),
);
}
if let Some(t) = req.temperature {
map.insert("temperature".into(), json!(t));
}
if let Some(p) = req.top_p {
map.insert("top_p".into(), json!(p));
}
if let Some(m) = req.max_tokens {
// Responses calls it `max_output_tokens`; preserve the
// semantic (response cap) when we translate.
map.insert("max_output_tokens".into(), json!(m));
}
}
body
}
fn encode_message_as_input_item(msg: &Message) -> Option<Value> {
match (msg.role, &msg.content) {
(Role::System, _) => None, // handled out-of-band as `instructions`
(Role::User, MessageContent::Text { text }) => Some(json!({
"type": "message",
"role": "user",
"content": text,
})),
(Role::User, MessageContent::MultiPart { parts }) => Some(json!({
"type": "message",
"role": "user",
"content": encode_user_parts(parts),
})),
(Role::Assistant, MessageContent::Text { text }) => Some(json!({
"type": "message",
"role": "assistant",
"content": [{
"type": "output_text",
"text": text,
"annotations": [],
}],
})),
(Role::Assistant, MessageContent::ToolCalls { text, calls }) => {
// Assistant turns that called tools become a sequence of
// items: an optional `message` (any prose alongside the
// call) followed by one `function_call` per call. Mirrors
// OpenAI Responses' "each item is one structural slot"
// shape.
//
// We can't return multiple items from one call site, so
// we encode this by side-stuffing additional items into a
// single composite value and have the caller flatten —
// but that complicates the API. Easier: build the array
// ourselves in the caller path. For now, emit just the
// function_calls (the assistant's prose lives in the next
// turn's chat history anyway because the model isn't
// looking back at its own previous narration). If the
// text is non-empty AND we have calls, we lose the text;
// qwen3 rarely emits prose alongside tool calls so this
// is a deliberate simplification — revisit if it bites.
let _ = text;
// Take the first call only for the moment; multi-call
// turns would need the caller-flattening above.
let call = calls.first()?;
Some(json!({
"type": "function_call",
"call_id": call.id,
"name": call.name,
"arguments": call.arguments,
}))
}
(
Role::Tool,
MessageContent::ToolResult {
tool_call_id,
content,
},
) => Some(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": content,
})),
(role, content) => {
tracing::warn!(
?role,
?content,
"openai_responses: unexpected (role, content) shape"
);
None
}
}
}
fn encode_user_parts(parts: &[MessagePart]) -> Value {
let items: Vec<Value> = parts
.iter()
.map(|p| match p {
MessagePart::Text { text } => json!({"type": "input_text", "text": text}),
MessagePart::Image(img) => json!({
"type": "input_image",
"image_url": format!("data:{};base64,{}", img.mime_type, img.data),
}),
})
.collect();
Value::Array(items)
}
// ── Wire types ──────────────────────────────────────────────────────
#[allow(dead_code)] // fields read only when list_models runs against a real endpoint
#[derive(Debug, Deserialize)]
struct WireModelsResponse {
data: Vec<WireModelObject>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct WireModelObject {
id: String,
}
// SSE event payload shapes. We only model the fields we care about;
// `#[serde(default)]` + `Option` everywhere else lets the upstream
// add optional fields without breaking deserialise.
#[derive(Debug, Deserialize, Serialize)]
struct OutputItemAddedEvent {
#[serde(default)]
output_index: u32,
item: OutputItem,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OutputItem {
Message {
#[serde(default)]
id: Option<String>,
},
FunctionCall {
#[serde(default)]
id: Option<String>,
#[serde(default)]
call_id: Option<String>,
#[serde(default)]
name: Option<String>,
/// Some upstreams populate `arguments` already on the
/// `output_item.added` event for a fully-buffered tool call
/// (i.e. when the model finalised the call before the SSE
/// flush). Capture it so we can emit a single args delta.
#[serde(default)]
arguments: Option<String>,
},
/// `reasoning`, `web_search_call`, etc. We capture-and-ignore
/// any item we don't model; the decoder still emits the
/// outer events correctly.
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutputTextDeltaEvent {
#[serde(default)]
item_id: Option<String>,
#[serde(default)]
output_index: u32,
#[serde(default)]
delta: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct FunctionCallArgumentsDeltaEvent {
#[serde(default)]
item_id: Option<String>,
#[serde(default)]
output_index: u32,
#[serde(default)]
delta: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct ResponseCompletedEvent {
response: ResponseShell,
}
#[derive(Debug, Deserialize, Serialize)]
struct ResponseShell {
#[serde(default)]
status: Option<String>,
#[serde(default)]
usage: Option<WireUsage>,
}
#[derive(Debug, Deserialize, Serialize)]
struct WireUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
#[serde(default)]
total_tokens: u64,
}
// ── Streaming decoder ───────────────────────────────────────────────
/// Translate the named-event Responses SSE into the provider-agnostic
/// [`CompletionEvent`] stream the agent loop expects. The decoder
/// holds per-stream state — output_index → tool-call-index plus
/// the next available tool-call slot — so it can fire
/// `ToolCallStart` exactly once per item.
fn decode_stream<S>(
sse: S,
cancel: CancellationToken,
) -> impl Stream<Item = anyhow::Result<CompletionEvent>>
where
S: Stream<
Item = Result<
eventsource_stream::Event,
eventsource_stream::EventStreamError<reqwest::Error>,
>,
> + Send
+ 'static,
{
async_stream::stream! {
let mut sse = Box::pin(sse);
// Maps an output_index that's a function_call to the tool-call
// slot we hand downstream. Lets us correlate later
// `function_call_arguments.delta` events back to the index
// we already announced on `output_item.added`.
let mut tool_index_by_output: HashMap<u32, usize> = HashMap::new();
let mut next_tool_index: usize = 0;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => {
tracing::debug!("openai_responses: cancellation requested, ending stream");
break;
}
next = sse.next() => {
let Some(event) = next else { break };
let event = match event {
Ok(e) => e,
Err(e) => {
yield Err(anyhow::anyhow!("SSE transport: {e}"));
break;
}
};
// Event name lives on `event.event`; data is JSON.
let event_name = event.event.as_str();
let data = event.data.as_str();
match event_name {
"response.output_text.delta" => {
match serde_json::from_str::<OutputTextDeltaEvent>(data) {
Ok(d) if !d.delta.is_empty() => {
yield Ok(CompletionEvent::TextDelta(d.delta));
}
Ok(_) => {}
Err(e) => {
tracing::warn!(
error = %e,
raw = %data,
"openai_responses: failed to parse output_text.delta; skipping"
);
}
}
}
"response.output_item.added" => {
match serde_json::from_str::<OutputItemAddedEvent>(data) {
Ok(ev) => {
if let OutputItem::FunctionCall {
id,
call_id,
name,
arguments,
} = ev.item
{
let idx = next_tool_index;
next_tool_index += 1;
tool_index_by_output.insert(ev.output_index, idx);
// Prefer the user-facing
// `call_id` (what gets paired
// with tool results) over the
// internal item `id` when
// both are present. Falls
// back to a synthetic id so
// history bookkeeping never
// breaks.
let final_id = call_id
.or(id)
.unwrap_or_else(|| format!("call_{idx}"));
let final_name = name.unwrap_or_default();
yield Ok(CompletionEvent::ToolCallStart {
index: idx,
id: final_id,
name: final_name,
});
// Some upstreams attach the
// fully-buffered arguments on
// the `output_item.added`
// event itself (rare; happens
// when the model finalised
// before the SSE flush).
// Emit as a single args
// delta if present.
if let Some(args) = arguments
&& !args.is_empty()
{
yield Ok(CompletionEvent::ToolCallArgsDelta {
index: idx,
args_delta: args,
});
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
raw = %data,
"openai_responses: failed to parse output_item.added; skipping"
);
}
}
}
"response.function_call_arguments.delta" => {
match serde_json::from_str::<FunctionCallArgumentsDeltaEvent>(data) {
Ok(ev) => {
let Some(&idx) = tool_index_by_output.get(&ev.output_index)
else {
// Args delta for an item we
// never saw an `output_item.added`
// for. Could happen if the
// upstream reordered events;
// log + skip.
tracing::warn!(
output_index = ev.output_index,
"openai_responses: function_call_arguments.delta for unknown output_index"
);
continue;
};
if !ev.delta.is_empty() {
yield Ok(CompletionEvent::ToolCallArgsDelta {
index: idx,
args_delta: ev.delta,
});
}
}
Err(e) => {
tracing::warn!(
error = %e,
raw = %data,
"openai_responses: failed to parse function_call_arguments.delta; skipping"
);
}
}
}
"response.completed" => {
// Final event. Pull usage + status off
// the response shell. Status maps:
// "completed" → no special handling
// (caller treats as EndTurn),
// "incomplete" → length stop.
let (reason, usage) =
match serde_json::from_str::<ResponseCompletedEvent>(data) {
Ok(ev) => {
let reason = match ev.response.status.as_deref() {
Some("incomplete") => Some("length".to_string()),
_ => Some("stop".to_string()),
};
let usage = ev.response.usage.map(|u| UsageStats {
prompt_tokens: u.input_tokens,
completion_tokens: u.output_tokens,
total_tokens: u.total_tokens,
});
(reason, usage)
}
Err(e) => {
tracing::warn!(
error = %e,
raw = %data,
"openai_responses: failed to parse response.completed; ending stream with EndTurn"
);
(Some("stop".to_string()), None)
}
};
if let Some(u) = usage {
yield Ok(CompletionEvent::Usage(u));
}
yield Ok(CompletionEvent::Finish { reason });
break;
}
// Bookkeeping events we don't need to surface:
// response.created, response.in_progress,
// response.content_part.added/.done,
// response.output_text.done,
// response.output_item.done,
// response.function_call_arguments.done,
// response.reasoning_*. Logged at debug for
// wire-tracing.
other => {
tracing::trace!(
event = other,
"openai_responses: bookkeeping event"
);
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolCall;
use crate::provider::{ImageData, MessagePart};
use futures::stream;
use url::Url;
fn ep() -> EndpointConfig {
EndpointConfig {
name: "test".into(),
base_url: Url::parse("http://localhost:9999/v1").unwrap(),
wire_api: crate::config::WireApi::OpenAiResponses,
default_model: None,
api_key: None,
api_key_env: None,
max_tokens: None,
context_window: None,
}
}
// ── encode_request ──────────────────────────────────────────────
#[test]
fn system_messages_collapse_to_instructions() {
let req = CompletionRequest {
model: "m".into(),
messages: vec![
Message {
role: Role::System,
content: MessageContent::Text {
text: "you are helpful".into(),
},
},
Message {
role: Role::User,
content: MessageContent::Text { text: "hi".into() },
},
],
tools: vec![],
temperature: Some(0.7),
top_p: None,
max_tokens: Some(256),
};
let body = encode_request(&req);
assert_eq!(body["model"], "m");
assert_eq!(body["instructions"], "you are helpful");
assert_eq!(body["stream"], true);
assert_eq!(body["max_output_tokens"], 256);
assert_eq!(body["temperature"], 0.7);
let input = body["input"].as_array().unwrap();
// System message NOT echoed in input — it's only in
// instructions.
assert_eq!(input.len(), 1);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[0]["role"], "user");
assert_eq!(input[0]["content"], "hi");
}
#[test]
fn multiple_system_messages_concatenate() {
let req = CompletionRequest {
model: "m".into(),
messages: vec![
Message {
role: Role::System,
content: MessageContent::Text {
text: "first".into(),
},
},
Message {
role: Role::System,
content: MessageContent::Text {
text: "second".into(),
},
},
Message {
role: Role::User,
content: MessageContent::Text { text: "hi".into() },
},
],
tools: vec![],
temperature: None,
top_p: None,
max_tokens: None,
};
let body = encode_request(&req);
assert_eq!(body["instructions"], "first\n\nsecond");
}
#[test]
fn user_multipart_becomes_input_parts_array() {
let req = CompletionRequest {
model: "vl".into(),
messages: vec![Message {
role: Role::User,
content: MessageContent::MultiPart {
parts: vec![
MessagePart::Text {
text: "what's in this?".into(),
},
MessagePart::Image(ImageData {
mime_type: "image/png".into(),
data: "AAA=".into(),
uri: None,
}),
],
},
}],
tools: vec![],
temperature: None,
top_p: None,
max_tokens: None,
};
let body = encode_request(&req);
let content = &body["input"][0]["content"].as_array().unwrap().clone();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "input_text");
assert_eq!(content[0]["text"], "what's in this?");
assert_eq!(content[1]["type"], "input_image");
assert_eq!(content[1]["image_url"], "data:image/png;base64,AAA=");
}
#[test]
fn assistant_text_becomes_output_text_content_part() {
let req = CompletionRequest {
model: "m".into(),
messages: vec![
Message {
role: Role::User,
content: MessageContent::Text { text: "hi".into() },
},
Message {
role: Role::Assistant,
content: MessageContent::Text {
text: "hello there".into(),
},
},
Message {
role: Role::User,
content: MessageContent::Text {
text: "more".into(),
},
},
],
tools: vec![],
temperature: None,
top_p: None,
max_tokens: None,
};
let body = encode_request(&req);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 3);
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["role"], "assistant");
assert_eq!(input[1]["content"][0]["type"], "output_text");
assert_eq!(input[1]["content"][0]["text"], "hello there");
}
#[test]
fn tool_calls_and_results_round_trip_via_function_call_items() {
let req = CompletionRequest {
model: "m".into(),
messages: vec![
Message {
role: Role::Assistant,
content: MessageContent::ToolCalls {
text: None,
calls: vec![ToolCall {
id: "call_42".into(),
name: "read_file".into(),
arguments: r#"{"path":"/etc/hostname"}"#.into(),
}],
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult {
tool_call_id: "call_42".into(),
content: "host".into(),
},
},
],
tools: vec![],
temperature: None,
top_p: None,
max_tokens: None,
};
let body = encode_request(&req);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 2);
assert_eq!(input[0]["type"], "function_call");
assert_eq!(input[0]["call_id"], "call_42");
assert_eq!(input[0]["name"], "read_file");
assert_eq!(input[0]["arguments"], r#"{"path":"/etc/hostname"}"#);
assert_eq!(input[1]["type"], "function_call_output");
assert_eq!(input[1]["call_id"], "call_42");
assert_eq!(input[1]["output"], "host");
}
// ── decode_stream ───────────────────────────────────────────────
fn sse_event(name: &str, data: &str) -> eventsource_stream::Event {
eventsource_stream::Event {
id: String::new(),
retry: None,
event: name.into(),
data: data.into(),
}
}
async fn collect_events(
items: Vec<eventsource_stream::Event>,
) -> Vec<anyhow::Result<CompletionEvent>> {
let sse = stream::iter(
items
.into_iter()
.map(Ok::<_, eventsource_stream::EventStreamError<reqwest::Error>>),
);
let decoded = decode_stream(sse, CancellationToken::new());
decoded.collect().await
}
#[tokio::test]
async fn decodes_text_then_finish() {
let events = collect_events(vec![
sse_event("response.created", "{}"),
sse_event(
"response.output_text.delta",
r#"{"item_id":"msg_1","output_index":0,"delta":"hel"}"#,
),
sse_event(
"response.output_text.delta",
r#"{"item_id":"msg_1","output_index":0,"delta":"lo"}"#,
),
sse_event(
"response.completed",
r#"{"response":{"status":"completed","usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}"#,
),
])
.await;
let events: Vec<CompletionEvent> = events.into_iter().map(|r| r.unwrap()).collect();
let mut iter = events.into_iter();
assert!(matches!(iter.next(), Some(CompletionEvent::TextDelta(t)) if t == "hel"));
assert!(matches!(iter.next(), Some(CompletionEvent::TextDelta(t)) if t == "lo"));
assert!(matches!(iter.next(), Some(CompletionEvent::Usage(u)) if u.total_tokens == 5));
assert!(matches!(
iter.next(),
Some(CompletionEvent::Finish { reason: Some(r) }) if r == "stop"
));
assert!(iter.next().is_none());
}
#[tokio::test]
async fn empty_delta_is_dropped() {
let events = collect_events(vec![
sse_event(
"response.output_text.delta",
r#"{"item_id":"m","output_index":0,"delta":""}"#,
),
sse_event(
"response.completed",
r#"{"response":{"status":"completed"}}"#,
),
])
.await;
let mut completion_events = events.into_iter().map(|r| r.unwrap());
// First event MUST be the Finish — the empty delta dropped.
assert!(matches!(
completion_events.next(),
Some(CompletionEvent::Finish { .. })
));
}
#[tokio::test]
async fn incomplete_status_maps_to_length_finish_reason() {
let events = collect_events(vec![sse_event(
"response.completed",
r#"{"response":{"status":"incomplete"}}"#,
)])
.await;
let events: Vec<CompletionEvent> = events.into_iter().map(|r| r.unwrap()).collect();
assert!(matches!(
events.last(),
Some(CompletionEvent::Finish { reason: Some(r) }) if r == "length"
));
}
#[tokio::test]
async fn function_call_items_emit_toolcall_events() {
let events = collect_events(vec![
sse_event(
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"function_call","id":"item_1","call_id":"call_xyz","name":"read_file"}}"#,
),
sse_event(
"response.function_call_arguments.delta",
r#"{"item_id":"item_1","output_index":0,"delta":"{\"path"}"#,
),
sse_event(
"response.function_call_arguments.delta",
r#"{"item_id":"item_1","output_index":0,"delta":"\":\"/etc/hostname\"}"}"#,
),
sse_event("response.completed", r#"{"response":{"status":"completed"}}"#),
])
.await;
let events: Vec<CompletionEvent> = events.into_iter().map(|r| r.unwrap()).collect();
let mut iter = events.into_iter();
assert!(matches!(
iter.next(),
Some(CompletionEvent::ToolCallStart { index: 0, ref id, ref name })
if id == "call_xyz" && name == "read_file"
));
assert!(matches!(
iter.next(),
Some(CompletionEvent::ToolCallArgsDelta { index: 0, ref args_delta })
if args_delta == r#"{"path"#
));
assert!(matches!(
iter.next(),
Some(CompletionEvent::ToolCallArgsDelta { index: 0, ref args_delta })
if args_delta == r#"":"/etc/hostname"}"#
));
assert!(matches!(iter.next(), Some(CompletionEvent::Finish { .. })));
}
#[tokio::test]
async fn function_call_added_with_inline_arguments_emits_single_args_delta() {
// Some upstreams (rare) include the fully-buffered arguments
// on the `output_item.added` event when the model finalised
// the call before SSE flush. Verify both ToolCallStart and a
// single args delta fire.
let events = collect_events(vec![
sse_event(
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_a","name":"f","arguments":"{\"x\":1}"}}"#,
),
sse_event("response.completed", r#"{"response":{"status":"completed"}}"#),
])
.await;
let events: Vec<CompletionEvent> = events.into_iter().map(|r| r.unwrap()).collect();
let mut iter = events.into_iter();
assert!(matches!(
iter.next(),
Some(CompletionEvent::ToolCallStart { .. })
));
assert!(matches!(
iter.next(),
Some(CompletionEvent::ToolCallArgsDelta { index: 0, ref args_delta })
if args_delta == r#"{"x":1}"#
));
assert!(matches!(iter.next(), Some(CompletionEvent::Finish { .. })));
}
#[tokio::test]
async fn cancellation_ends_stream_promptly() {
// Hand the decoder an empty stream + a triggered cancellation
// token; it should terminate without yielding anything.
let sse = stream::iter(Vec::<
Result<eventsource_stream::Event, eventsource_stream::EventStreamError<reqwest::Error>>,
>::new());
let cancel = CancellationToken::new();
cancel.cancel();
let decoded = decode_stream(sse, cancel);
let events: Vec<_> = decoded.collect().await;
assert!(events.is_empty());
}
#[tokio::test]
async fn malformed_event_payload_is_skipped() {
let events = collect_events(vec![
sse_event("response.output_text.delta", "{not valid json"),
sse_event(
"response.output_text.delta",
r#"{"item_id":"m","output_index":0,"delta":"ok"}"#,
),
sse_event(
"response.completed",
r#"{"response":{"status":"completed"}}"#,
),
])
.await;
let events: Vec<CompletionEvent> = events.into_iter().map(|r| r.unwrap()).collect();
// First text delta dropped; second one fires.
assert!(
events
.iter()
.any(|e| matches!(e, CompletionEvent::TextDelta(t) if t == "ok"))
);
// No errors yielded (parse failures are warn-and-skip).
assert!(
events
.iter()
.all(|e| !matches!(e, CompletionEvent::Finish { reason: None }))
);
}
#[test]
fn provider_construction_is_cheap() {
let _ = OpenAIResponsesProvider::new(ep()).unwrap();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
//! Per-session state for the ACP agent loop.
//!
//! Concurrency:
//!
//! - [`SessionStore`] is an `Arc<RwLock<HashMap<SessionId, …>>>`. The map
//! itself is read-mostly: it changes only on `session/new` and never
//! shrinks during Stage 2, so an `RwLock` keeps concurrent reads
//! contention-free.
//! - Each session is wrapped in its own `Arc<Mutex<SessionState>>`. Holding
//! one session's lock doesn't block requests against any other session,
//! which matters once a client opens multiple sessions in parallel.
//!
//! All operations hold a lock only long enough to copy out (or mutate) the
//! state they need — never across an `await` that drives the upstream
//! provider stream.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use agent_client_protocol::schema::{SessionId, SessionModeId};
use tokio::sync::{Mutex, RwLock};
use tokio_util::sync::CancellationToken;
use crate::provider::Message;
/// Mode id advertised as the gated default. Writes / bash prompt for
/// permission via `session/request_permission`.
pub const MODE_DEFAULT: &str = "default";
/// Mode id advertised as "auto-allow everything". Matches the
/// favorite name (`bypassPermissions`) Zed clients tend to reference.
pub const MODE_BYPASS: &str = "bypassPermissions";
/// Mode id for read-and-plan-only operation. The model may read files
/// and list directories freely, may write *only* into the per-project
/// plan directory under `$XDG_DATA_HOME/helexa-acp/plans/<project-id>/`,
/// and cannot run shell commands. Designed for "draft the
/// implementation plan, then I'll review and let you execute" flows.
pub const MODE_PLAN: &str = "plan";
/// State carried for a single ACP session.
///
/// Mutated under `Mutex<SessionState>`; never share a clone across
/// tasks expecting to see the same `cancel` token — clone the token
/// explicitly when handing it to the streaming task.
#[derive(Debug)]
pub struct SessionState {
/// Conversation history in chronological order (user / assistant
/// turns). The system prompt is *not* stored here — it's built
/// fresh per request so any cwd / config changes take effect.
pub history: Vec<Message>,
/// Working directory the client opened the session against. Used
/// by [`crate::prompt::build_system_prompt`] and (Stage 3) by
/// filesystem tools.
pub cwd: PathBuf,
/// Currently-selected model id. Format is either a bare model id
/// (resolved against the default endpoint) or `endpoint:model`.
/// Mutated by `session/set_model` in Stage 4; Stage 2 sets it
/// once at session creation and never changes it.
pub model_id: String,
/// Cancellation handle for the in-flight prompt, if any. A fresh
/// token is installed at the start of every `session/prompt`
/// request; `session/cancel` fires this one. Between prompts the
/// token is "spent" — firing it does nothing — which is fine,
/// `session/cancel` is a no-op when there's nothing to cancel.
pub cancel: CancellationToken,
/// Permission gating mode. Stage 3 advertises two ids in
/// `NewSessionResponse.modes`: [`MODE_DEFAULT`] (writes / bash
/// prompt the user) and [`MODE_BYPASS`] (auto-allow). Mutated by
/// `session/set_mode`.
pub mode_id: SessionModeId,
}
impl SessionState {
pub fn new(cwd: PathBuf, model_id: String) -> Self {
Self {
history: Vec::new(),
cwd,
model_id,
cancel: CancellationToken::new(),
mode_id: SessionModeId::new(MODE_DEFAULT),
}
}
}
/// Concurrent map of live sessions.
///
/// Cloning is cheap (`Arc` bump). Pass clones into every handler that
/// needs session access; never hold a clone across an `.await` that
/// could outlive the request.
pub type SessionStore = Arc<RwLock<HashMap<SessionId, Arc<Mutex<SessionState>>>>>;
/// Fresh, empty session store.
pub fn new_store() -> SessionStore {
Arc::new(RwLock::new(HashMap::new()))
}
/// Look up a session by id. Returns `None` if no such session is registered.
pub async fn get(store: &SessionStore, id: &SessionId) -> Option<Arc<Mutex<SessionState>>> {
store.read().await.get(id).cloned()
}
/// Register a fresh session. Overwrites any prior entry with the same id
/// (which should never happen — ids are uniquely generated by the agent).
pub async fn insert(store: &SessionStore, id: SessionId, state: SessionState) {
store.write().await.insert(id, Arc::new(Mutex::new(state)));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{MessageContent, Role};
fn id(s: &str) -> SessionId {
SessionId::new(s)
}
#[tokio::test]
async fn insert_then_get_round_trip() {
let store = new_store();
let state = SessionState::new(PathBuf::from("/tmp"), "m".into());
insert(&store, id("s1"), state).await;
let got = get(&store, &id("s1")).await.expect("session present");
let locked = got.lock().await;
assert_eq!(locked.cwd, PathBuf::from("/tmp"));
assert_eq!(locked.model_id, "m");
assert!(locked.history.is_empty());
}
#[tokio::test]
async fn missing_session_is_none() {
let store = new_store();
assert!(get(&store, &id("nope")).await.is_none());
}
#[tokio::test]
async fn history_is_per_session() {
let store = new_store();
insert(
&store,
id("a"),
SessionState::new(PathBuf::from("/a"), "m".into()),
)
.await;
insert(
&store,
id("b"),
SessionState::new(PathBuf::from("/b"), "m".into()),
)
.await;
// Appending to a's history must not affect b's.
get(&store, &id("a"))
.await
.unwrap()
.lock()
.await
.history
.push(Message {
role: Role::User,
content: MessageContent::Text {
text: "hello".into(),
},
});
assert_eq!(
get(&store, &id("a"))
.await
.unwrap()
.lock()
.await
.history
.len(),
1
);
assert_eq!(
get(&store, &id("b"))
.await
.unwrap()
.lock()
.await
.history
.len(),
0
);
}
}

View File

@@ -0,0 +1,462 @@
//! On-disk session persistence for `session/load` support.
//!
//! Storage layout:
//!
//! ```text
//! $XDG_DATA_HOME/helexa-acp/sessions/{session_id}.json
//! ```
//!
//! (Fallback to `~/.local/share/helexa-acp/sessions/` when
//! `$XDG_DATA_HOME` is unset.) One JSON file per session. Writes
//! happen at the end of every `session/prompt` round through
//! [`save`], using tempfile-plus-rename so a crash mid-write can't
//! corrupt the store. Reads happen on `session/load` via [`load`].
//!
//! No compaction, no rotation: files accumulate until the user
//! cleans them up. That's deliberate — disk is cheap, and the
//! resume-on-restart workflow matters more than tidiness. The
//! [`SESSIONS_DIRNAME`] subdirectory is created lazily on first
//! save so an unprivileged install path never errors at startup.
use std::path::PathBuf;
use std::time::SystemTime;
use agent_client_protocol::schema::SessionId;
use serde::{Deserialize, Serialize};
use crate::provider::Message;
const APP_DIRNAME: &str = "helexa-acp";
const SESSIONS_DIRNAME: &str = "sessions";
const PLANS_DIRNAME: &str = "plans";
/// The shape persisted to disk for one session. Only what we can't
/// rebuild from the running config goes in here: the conversation
/// history, the mode toggle, the model id, and the cwd-at-creation.
///
/// `created_at` / `updated_at` are seconds-since-epoch — cheap to
/// compare, no third-party time crate, and stable across runs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedSession {
pub session_id: String,
pub cwd: PathBuf,
pub model_id: String,
pub mode_id: String,
pub history: Vec<Message>,
pub created_at: u64,
pub updated_at: u64,
}
/// Resolve the directory that holds session JSON files. Honors
/// `$XDG_DATA_HOME`; falls back to `~/.local/share/helexa-acp/sessions/`.
/// Returns `None` if neither is resolvable (no `HOME` set — possible
/// in stripped-down container environments).
pub fn sessions_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".local").join("share"))
})?;
Some(base.join(APP_DIRNAME).join(SESSIONS_DIRNAME))
}
/// Atomic save into the default sessions directory.
pub fn save(session: &PersistedSession) -> anyhow::Result<()> {
let dir = sessions_dir()
.ok_or_else(|| anyhow::anyhow!("can't resolve XDG_DATA_HOME or HOME for session store"))?;
save_to_dir(&dir, session)
}
/// Load from the default sessions directory.
pub fn load(session_id: &SessionId) -> anyhow::Result<PersistedSession> {
let dir = sessions_dir()
.ok_or_else(|| anyhow::anyhow!("can't resolve XDG_DATA_HOME or HOME for session store"))?;
load_from_dir(&dir, session_id)
}
/// Atomic save into an explicit directory. Writes to
/// `{id}.json.tmp` then renames over `{id}.json`. Creates the
/// target directory if it doesn't exist. Split from [`save`] so
/// unit tests can target a per-test scratch dir without mutating
/// process-global env vars.
pub fn save_to_dir(dir: &std::path::Path, session: &PersistedSession) -> anyhow::Result<()> {
std::fs::create_dir_all(dir).map_err(|e| anyhow::anyhow!("create {}: {e}", dir.display()))?;
let safe = sanitize_id(&session.session_id);
let final_path = dir.join(format!("{safe}.json"));
let tmp_path = dir.join(format!("{safe}.json.tmp"));
let json = serde_json::to_string_pretty(session)?;
std::fs::write(&tmp_path, json)
.map_err(|e| anyhow::anyhow!("write {}: {e}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &final_path)
.map_err(|e| anyhow::anyhow!("rename → {}: {e}", final_path.display()))?;
Ok(())
}
/// Load from an explicit directory. Returns a friendly error
/// message when the session id has no file on disk so the caller
/// can map it to a clean ACP error response.
pub fn load_from_dir(
dir: &std::path::Path,
session_id: &SessionId,
) -> anyhow::Result<PersistedSession> {
let safe = sanitize_id(session_id.0.as_ref());
let path = dir.join(format!("{safe}.json"));
let bytes = std::fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!("no persisted session at {}", path.display())
} else {
anyhow::anyhow!("read {}: {e}", path.display())
}
})?;
let session: PersistedSession = serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))?;
Ok(session)
}
/// List all persisted sessions, optionally filtered by `cwd`. Used
/// by the `session/list` handler so a client (Zed) can find the
/// session that belongs to the workspace it's reopening.
///
/// `filter_cwd = None` returns every session on disk. `Some(path)`
/// returns only sessions whose persisted `cwd` is exactly equal.
///
/// Files that fail to parse are skipped with a warning rather than
/// aborting the whole list — one corrupt session shouldn't make
/// the resume picker unusable.
pub fn list(filter_cwd: Option<&std::path::Path>) -> anyhow::Result<Vec<PersistedSession>> {
let dir = sessions_dir()
.ok_or_else(|| anyhow::anyhow!("can't resolve XDG_DATA_HOME or HOME for session store"))?;
list_in_dir(&dir, filter_cwd)
}
/// Explicit-dir variant for tests, mirroring [`save_to_dir`] /
/// [`load_from_dir`].
pub fn list_in_dir(
dir: &std::path::Path,
filter_cwd: Option<&std::path::Path>,
) -> anyhow::Result<Vec<PersistedSession>> {
let read = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(anyhow::anyhow!("read_dir {}: {e}", dir.display())),
};
let mut out = Vec::new();
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
match std::fs::read(&path).and_then(|bytes| {
serde_json::from_slice::<PersistedSession>(&bytes).map_err(std::io::Error::other)
}) {
Ok(session) => {
if let Some(want) = filter_cwd
&& session.cwd != want
{
continue;
}
out.push(session);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"store: skipping unparseable session file"
);
}
}
}
// Most-recent first by updated_at.
out.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
Ok(out)
}
/// Seconds-since-epoch, saturating to 0 if the system clock is
/// behind epoch (which shouldn't happen but the type system
/// requires a fallible read).
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Root directory for plan-mode artefacts. Mirrors [`sessions_dir`]
/// but under `…/helexa-acp/plans/` so plans and conversation
/// transcripts are siblings, not nested.
pub fn plans_root() -> Option<PathBuf> {
sessions_dir().and_then(|s| s.parent().map(|p| p.join(PLANS_DIRNAME)))
}
/// Per-project plan directory:
/// `$XDG_DATA_HOME/helexa-acp/plans/<project-id>/`. The id derives
/// from the session's cwd so plans for the same project survive
/// across cwd-changes (a `/home/foo/git/bar` ↔ symlinked
/// `/srv/checkout/bar` would technically diverge, accepted as a
/// won't-fix corner case).
pub fn plan_dir_for(cwd: &std::path::Path) -> Option<PathBuf> {
plans_root().map(|root| root.join(project_id_for(cwd)))
}
/// Deterministic, human-readable project identifier. Format:
/// `<basename>-<8-hex>` where the 8-hex suffix is FNV-1a of the
/// full path. Basename keeps the path skim-readable when poking
/// around `$XDG_DATA_HOME` by hand; the hash suffix disambiguates
/// repos that share a final path component (e.g. multiple
/// `/.../checkout/beat` checkouts).
///
/// FNV-1a rather than `std::collections::hash::DefaultHasher`
/// because the latter (SipHash) reseeds per process, so it'd give
/// us a different project_id on every run.
pub fn project_id_for(cwd: &std::path::Path) -> String {
let basename = cwd
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let sanitised: String = basename
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let hash = fnv1a_32(cwd.to_string_lossy().as_bytes());
format!("{sanitised}-{hash:08x}")
}
/// FNV-1a (32-bit). Deterministic, no third-party crate. Used for
/// project ids only — not cryptographic.
fn fnv1a_32(bytes: &[u8]) -> u32 {
let mut h: u32 = 0x811c_9dc5;
for b in bytes {
h ^= u32::from(*b);
h = h.wrapping_mul(0x0100_0193);
}
h
}
/// Format seconds-since-epoch as an ISO 8601 / RFC 3339 string
/// (`YYYY-MM-DDTHH:MM:SSZ`) for `SessionInfo.updated_at`. Returns
/// `None` for values outside the representable range, in which
/// case the caller should omit the field.
pub fn unix_to_iso8601(secs: u64) -> Option<String> {
use chrono::TimeZone;
let dt = chrono::Utc.timestamp_opt(secs as i64, 0).single()?;
Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
/// Strip anything that isn't a safe filename character so a
/// mischievous (or just unconventional) session id can't escape
/// the sessions directory.
fn sanitize_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{MessageContent, Role};
/// Unique scratch dir per test invocation. We use this dir
/// directly with the `*_to_dir` / `*_from_dir` functions so
/// the tests never mutate `$XDG_DATA_HOME` — that env var
/// would race across the parallel test harness.
fn unique_dir() -> PathBuf {
let base = std::env::var("CARGO_TARGET_TMPDIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let dir = base.join(format!("helexa-acp-store-test-{pid}-{nanos}"));
std::fs::create_dir_all(&dir).expect("create test dir");
dir
}
fn sample(id: &str) -> PersistedSession {
PersistedSession {
session_id: id.into(),
cwd: PathBuf::from("/home/me/proj"),
model_id: "Qwen/Qwen3.6-27B".into(),
mode_id: "default".into(),
history: vec![
Message {
role: Role::User,
content: MessageContent::Text {
text: "hello".into(),
},
},
Message {
role: Role::Assistant,
content: MessageContent::Text { text: "hi".into() },
},
],
created_at: 1_700_000_000,
updated_at: 1_700_000_001,
}
}
#[test]
fn round_trip_save_then_load() {
let dir = unique_dir();
save_to_dir(&dir, &sample("hxa-1")).expect("save");
let loaded = load_from_dir(&dir, &SessionId::new("hxa-1")).expect("load");
assert_eq!(loaded.session_id, "hxa-1");
assert_eq!(loaded.cwd, PathBuf::from("/home/me/proj"));
assert_eq!(loaded.history.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_session_errors_with_not_found_message() {
let dir = unique_dir();
let err = load_from_dir(&dir, &SessionId::new("nope")).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no persisted session"),
"want NotFound, got: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_overwrites_existing_atomically() {
let dir = unique_dir();
save_to_dir(&dir, &sample("hxa-1")).expect("save");
let mut updated = sample("hxa-1");
updated.history.push(Message {
role: Role::User,
content: MessageContent::Text {
text: "third turn".into(),
},
});
updated.updated_at = 1_700_000_500;
save_to_dir(&dir, &updated).expect("re-save");
let loaded = load_from_dir(&dir, &SessionId::new("hxa-1")).expect("load");
assert_eq!(loaded.history.len(), 3);
assert_eq!(loaded.updated_at, 1_700_000_500);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_then_load_preserves_tool_calls_and_results() {
use crate::provider::ToolCall;
let dir = unique_dir();
let mut session = sample("hxa-2");
session.history.push(Message {
role: Role::Assistant,
content: MessageContent::ToolCalls {
text: Some("calling".into()),
calls: vec![ToolCall {
id: "call_0".into(),
name: "read_file".into(),
arguments: r#"{"path":"/etc/hostname"}"#.into(),
}],
},
});
session.history.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult {
tool_call_id: "call_0".into(),
content: "host".into(),
},
});
save_to_dir(&dir, &session).expect("save");
let loaded = load_from_dir(&dir, &SessionId::new("hxa-2")).expect("load");
assert_eq!(loaded.history.len(), 4);
match &loaded.history[2].content {
MessageContent::ToolCalls { calls, .. } => {
assert_eq!(calls[0].name, "read_file");
}
other => panic!("expected ToolCalls, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_filters_by_cwd_and_sorts_recent_first() {
let dir = unique_dir();
let mut a = sample("a");
a.cwd = PathBuf::from("/home/me/proj-x");
a.updated_at = 1_700_000_010;
let mut b = sample("b");
b.cwd = PathBuf::from("/home/me/proj-x");
b.updated_at = 1_700_000_020;
let mut c = sample("c");
c.cwd = PathBuf::from("/home/me/elsewhere");
c.updated_at = 1_700_000_030;
save_to_dir(&dir, &a).unwrap();
save_to_dir(&dir, &b).unwrap();
save_to_dir(&dir, &c).unwrap();
let proj_x = PathBuf::from("/home/me/proj-x");
let list = list_in_dir(&dir, Some(&proj_x)).unwrap();
let ids: Vec<&str> = list.iter().map(|s| s.session_id.as_str()).collect();
// Filtered to proj-x; b before a because b is more recent.
assert_eq!(ids, vec!["b", "a"]);
let all = list_in_dir(&dir, None).unwrap();
assert_eq!(all.len(), 3);
// Global list still sorted recent-first across all cwds.
assert_eq!(all[0].session_id, "c");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_returns_empty_for_missing_dir() {
let dir = unique_dir().join("does-not-exist");
let list = list_in_dir(&dir, None).unwrap();
assert!(list.is_empty());
}
#[test]
fn list_skips_unparseable_files() {
let dir = unique_dir();
save_to_dir(&dir, &sample("good")).unwrap();
std::fs::write(dir.join("garbage.json"), b"{not valid json").unwrap();
let list = list_in_dir(&dir, None).unwrap();
// Garbage skipped; good survives.
assert_eq!(list.len(), 1);
assert_eq!(list[0].session_id, "good");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn iso8601_formats_unix_seconds() {
// 2024-01-01T00:00:00Z is 1704067200 unix seconds.
assert_eq!(
unix_to_iso8601(1_704_067_200),
Some("2024-01-01T00:00:00Z".into())
);
assert_eq!(unix_to_iso8601(0), Some("1970-01-01T00:00:00Z".into()));
}
#[test]
fn sanitize_id_rejects_path_traversal() {
// `../../etc/passwd` — 6 non-alnum chars before "etc"
// (`.`, `.`, `/`, `.`, `.`, `/`), one between, none
// after, none before nothing. Every disallowed char
// collapses to `_`.
assert_eq!(sanitize_id("../../etc/passwd"), "______etc_passwd");
assert_eq!(sanitize_id("ok-name_42"), "ok-name_42");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
//! Tool schemas sent to the upstream model on every completion.
//!
//! These are the OpenAI-function-style declarations the LLM sees in
//! `CompletionRequest.tools`; the runtime dispatch happens in
//! [`crate::tool_runner`]. Keeping declarations and execution in
//! separate modules makes it easy to add a tool without touching the
//! runner, and vice versa.
//!
//! Stage 3 ships five: filesystem read / write / edit, directory
//! listing, and `bash`. Image generation, web fetch, MCP-derived
//! tools, etc. are out of scope here.
use serde_json::json;
use crate::provider::ToolSpec;
pub const READ_FILE: &str = "read_file";
pub const WRITE_FILE: &str = "write_file";
pub const EDIT_FILE: &str = "edit_file";
pub const LIST_DIR: &str = "list_dir";
pub const BASH: &str = "bash";
/// Build the static tool list passed to the model on every prompt.
/// Cheap — the JSON Schema fragments are constructed each call but
/// the bodies are small constants. If this ever shows up in a
/// profile we can `OnceLock` the Vec.
pub fn all_tools() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: READ_FILE.to_string(),
description: "Read the contents of a text file. Returns the file's text.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file."
},
"line": {
"type": "integer",
"description": "Optional 1-based line number to start reading from.",
"minimum": 1
},
"limit": {
"type": "integer",
"description": "Optional maximum number of lines to read.",
"minimum": 1
}
},
"required": ["path"],
"additionalProperties": false
}),
},
ToolSpec {
name: WRITE_FILE.to_string(),
description: "Write text content to a file, replacing any existing contents. \
Creates the file (and parent directories) if needed."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file."
},
"content": {
"type": "string",
"description": "Full new contents of the file."
}
},
"required": ["path", "content"],
"additionalProperties": false
}),
},
ToolSpec {
name: EDIT_FILE.to_string(),
description: "Replace one exact substring in a file with another. \
Fails if `old_text` does not appear in the file, or appears more than once. \
Use multiple edit_file calls for multiple edits."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file."
},
"old_text": {
"type": "string",
"description": "Exact text fragment to replace. Must be unique within the file."
},
"new_text": {
"type": "string",
"description": "Replacement text."
}
},
"required": ["path", "old_text", "new_text"],
"additionalProperties": false
}),
},
ToolSpec {
name: LIST_DIR.to_string(),
description:
"List the entries of a directory. Returns names and a (f|d|l) kind per entry."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the directory."
}
},
"required": ["path"],
"additionalProperties": false
}),
},
ToolSpec {
name: BASH.to_string(),
description: "Run a shell command via `sh -c`. \
Returns combined stdout+stderr and the exit status. \
The command runs in the session's working directory unless `cwd` is given."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command line, evaluated by `sh -c`."
},
"cwd": {
"type": "string",
"description": "Optional absolute path to run the command from."
}
},
"required": ["command"],
"additionalProperties": false
}),
},
]
}
/// Try to infer which tool was intended from the shape of an
/// `arguments` object alone. Used by the agent when the model
/// emits a `<tool_call>` whose JSON has the right arguments but a
/// missing or invalid top-level `name` field — a recurring
/// Qwen3.6-27B failure mode.
///
/// Returns `Some(name)` only when the argument keys uniquely match
/// exactly one tool in the catalogue. Ambiguous shapes (`{path}`
/// alone could be either [`READ_FILE`] or [`LIST_DIR`]) return
/// `None` so the caller surfaces a Failed-card and lets the model
/// retry rather than guessing wrong.
///
/// Inference table (key set → tool):
///
/// | Keys | Tool |
/// |---------------------------------------|--------------|
/// | `{command}` or `{command, cwd}` | `bash` |
/// | `{path, content}` | `write_file` |
/// | `{path, old_text, new_text}` | `edit_file` |
/// | `{path}` / `{path, line}` / `{path, line, limit}` | *ambiguous* — None |
/// | (anything else) | None |
pub fn infer_tool_name(arguments: &serde_json::Value) -> Option<&'static str> {
let obj = arguments.as_object()?;
let keys: std::collections::HashSet<&str> = obj.keys().map(|s| s.as_str()).collect();
// `command` is unique to bash. Allow the optional `cwd` arg
// alongside but nothing else (any unrecognised keys → bail and
// let the model retry rather than misroute).
if keys.contains("command") && keys.iter().all(|k| matches!(*k, "command" | "cwd")) {
return Some(BASH);
}
// `content` is unique to write_file.
if keys.contains("content") && keys.contains("path") && keys.len() == 2 {
return Some(WRITE_FILE);
}
// `old_text` + `new_text` are unique to edit_file.
if keys.contains("old_text")
&& keys.contains("new_text")
&& keys.contains("path")
&& keys.len() == 3
{
return Some(EDIT_FILE);
}
// `{path}` / `{path, line}` / `{path, line, limit}` overlap
// between read_file (file contents) and list_dir (directory
// contents). No safe inference — refuse.
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_tools_has_five_named_entries() {
let tools = all_tools();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
vec![READ_FILE, WRITE_FILE, EDIT_FILE, LIST_DIR, BASH]
);
}
#[test]
fn infer_bash_from_command_only() {
let args = serde_json::json!({"command": "ls /tmp"});
assert_eq!(infer_tool_name(&args), Some(BASH));
}
#[test]
fn infer_bash_from_command_and_cwd() {
let args = serde_json::json!({"command": "ls", "cwd": "/tmp"});
assert_eq!(infer_tool_name(&args), Some(BASH));
}
#[test]
fn infer_bash_from_mkdir_like_real_failure() {
// Lifted verbatim from the agent failure that motivated
// this helper (helexa-acp.log @ 10:03:11).
let args = serde_json::json!({
"command": "mkdir -p /home/grenade/git/beat/beat/doc/plan/{01-discovery,02-segmentation,03-description,04-summary,05-output}"
});
assert_eq!(infer_tool_name(&args), Some(BASH));
}
#[test]
fn infer_write_file() {
let args = serde_json::json!({"path": "/tmp/x", "content": "hi"});
assert_eq!(infer_tool_name(&args), Some(WRITE_FILE));
}
#[test]
fn infer_edit_file() {
let args = serde_json::json!({
"path": "/tmp/x", "old_text": "a", "new_text": "b"
});
assert_eq!(infer_tool_name(&args), Some(EDIT_FILE));
}
#[test]
fn refuse_ambiguous_path_only() {
let args = serde_json::json!({"path": "/tmp/x"});
assert_eq!(infer_tool_name(&args), None);
}
#[test]
fn refuse_ambiguous_path_with_optionals() {
// read_file accepts these optionals; list_dir doesn't —
// but Qwen wouldn't reliably emit them either, so we
// can't use their presence to disambiguate. Refuse.
let args = serde_json::json!({"path": "/tmp/x", "line": 1, "limit": 50});
assert_eq!(infer_tool_name(&args), None);
}
#[test]
fn refuse_command_with_extra_unknown_keys() {
// Defence in depth: an unrecognised key alongside
// `command` means we don't really know what tool the
// model wanted; refuse rather than guess.
let args = serde_json::json!({"command": "ls", "extra": "?"});
assert_eq!(infer_tool_name(&args), None);
}
#[test]
fn refuse_empty_args() {
let args = serde_json::json!({});
assert_eq!(infer_tool_name(&args), None);
}
#[test]
fn refuse_non_object_args() {
let args = serde_json::json!("not an object");
assert_eq!(infer_tool_name(&args), None);
}
#[test]
fn every_tool_has_an_object_parameter_schema() {
for tool in all_tools() {
let ty = tool.parameters.get("type").and_then(|v| v.as_str());
assert_eq!(
ty,
Some("object"),
"tool {} parameters.type must be \"object\"",
tool.name
);
assert!(
tool.parameters.get("properties").is_some(),
"tool {} missing properties",
tool.name
);
assert!(
tool.parameters.get("required").is_some(),
"tool {} missing required list",
tool.name
);
}
}
}

View File

@@ -76,15 +76,36 @@ cudarc = { version = "0.19", optional = true, default-features = false, features
half = { version = "2.5", optional = true } half = { version = "2.5", optional = true }
tokenizers = { version = "0.22", default-features = false, features = ["onig"] } tokenizers = { version = "0.22", default-features = false, features = ["onig"] }
hf-hub = { version = "0.4", features = ["tokio"] } hf-hub = { version = "0.4", features = ["tokio"] }
# Jinja-compatible template renderer for the model's chat template
# (standalone `chat_template.jinja` or `tokenizer_config.json::chat_template`).
# Hugging Face's chat templates lean on Python string semantics; we
# bridge them with `minijinja-contrib`'s `pycompat` callback (str
# methods like `startswith`/`split`/`strip`) plus a `raise_exception`
# global. Features: `builtins` for `is defined` / `default`; `json`
# for `tojson`; `serde` so we can hand it a serde_json::Value context.
minijinja = { version = "2", features = ["builtins", "json", "serde"] }
# Python-compatibility shim: the Qwen3-VL / Qwen3.6 template uses
# `content.startswith(...)`, `.endswith(...)`, `.split(...)`,
# `.rstrip(...)`, `.lstrip(...)` — Python str methods minijinja doesn't
# implement natively. `pycompat::unknown_method_callback` supplies them.
minijinja-contrib = { version = "2", features = ["pycompat"] }
# Direct dep on `safetensors` (re-exported by candle but its `TensorView` # Direct dep on `safetensors` (re-exported by candle but its `TensorView`
# / `slice::IndexOp` types are public-but-not-re-exported). Used by the # / `slice::IndexOp` types are public-but-not-re-exported). Used by the
# tp `fused_load` module to read per-rank slices of fused QKV tensors # tp `fused_load` module to read per-rank slices of fused QKV tensors
# without materialising the full tensor on device. # without materialising the full tensor on device.
safetensors = "0.7" safetensors = "0.7"
# Vision capability for Qwen3.6 (Stage A of the vision plan in
# doc/vision-qwen3_6-spec.md). `image` decodes PNG/JPEG/etc from
# the bytes embedded in `data:image/...;base64,...` content parts;
# `base64` does the URI decode. Default-features off on `image` to
# avoid pulling in audio/video formats we don't need.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp", "gif"] }
base64 = "0.22"
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["test-util"] } tokio = { workspace = true, features = ["test-util"] }
reqwest.workspace = true reqwest.workspace = true
tempfile = "3"
[build-dependencies] [build-dependencies]
# Used by `build.rs` to compile `src/cuda/*.cu` into `libneuroncuda.a` # Used by `build.rs` to compile `src/cuda/*.cu` into `libneuroncuda.a`

View File

@@ -3,7 +3,9 @@
use crate::activation::ActivationTracker; use crate::activation::ActivationTracker;
use crate::harness::HarnessRegistry; use crate::harness::HarnessRegistry;
use crate::harness::candle::{CandleHarness, InferenceError}; use crate::harness::candle::{CandleHarness, InferenceError};
use crate::harness::preflight::PreflightError;
use crate::health::HealthCache; use crate::health::HealthCache;
use crate::wire::{openai_chat, openai_responses};
use axum::Router; use axum::Router;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -12,11 +14,13 @@ use axum::response::{IntoResponse, Json};
use axum::routing::{get, post}; use axum::routing::{get, post};
use cortex_core::discovery::{DiscoveryResponse, HealthResponse}; use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
use cortex_core::harness::ModelSpec; use cortex_core::harness::ModelSpec;
use cortex_core::openai::ChatCompletionRequest; use cortex_core::openai::{ChatCompletionRequest, MessageContent};
use cortex_core::responses::{ResponsesRequest, ResponsesUsage};
use futures::stream::{self, StreamExt}; use futures::stream::{self, StreamExt};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
@@ -44,6 +48,7 @@ pub fn neuron_routes() -> Router<Arc<NeuronState>> {
.route("/models/unload", post(unload_model)) .route("/models/unload", post(unload_model))
.route("/models/{model_id}/endpoint", get(model_endpoint)) .route("/models/{model_id}/endpoint", get(model_endpoint))
.route("/v1/chat/completions", post(chat_completions)) .route("/v1/chat/completions", post(chat_completions))
.route("/v1/responses", post(responses))
} }
async fn discovery_handler(State(state): State<Arc<NeuronState>>) -> Json<DiscoveryResponse> { async fn discovery_handler(State(state): State<Arc<NeuronState>>) -> Json<DiscoveryResponse> {
@@ -80,6 +85,24 @@ async fn load_model(
match registry.load_model(&spec).await { match registry.load_model(&spec).await {
Ok(()) => Json(json!({"status": "loaded"})).into_response(), Ok(()) => Json(json!({"status": "loaded"})).into_response(),
Err(e) => { Err(e) => {
// If the underlying failure is a structured preflight
// rejection, surface it as 422 Unprocessable Entity with
// the typed JSON body. The kind/model_id/suggestion/etc.
// fields let cortex (and operators reading the response
// directly) act on the failure without parsing free text.
if let Some(pf) = e.downcast_ref::<PreflightError>() {
tracing::warn!(
model = %spec.model_id,
reason = preflight_kind(pf),
detail = %pf,
"load_model rejected by preflight"
);
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": pf })),
)
.into_response();
}
// Log the full anyhow chain server-side so journalctl shows // Log the full anyhow chain server-side so journalctl shows
// the underlying failure (hf-hub timeout, permission denied, // the underlying failure (hf-hub timeout, permission denied,
// disk full, etc.) without needing to inspect the HTTP // disk full, etc.) without needing to inspect the HTTP
@@ -98,6 +121,18 @@ async fn load_model(
} }
} }
/// Short kebab-case tag for a preflight failure, used as a structured
/// log field for journalctl-side filtering. Mirrors the same helper in
/// `startup.rs`; duplicated to keep the module surfaces independent.
fn preflight_kind(err: &PreflightError) -> &'static str {
match err {
PreflightError::RepoFetchFailed { .. } => "repo_fetch_failed",
PreflightError::EmptyRepo { .. } => "empty_repo",
PreflightError::TpRequiresSafetensors { .. } => "tp_requires_safetensors",
PreflightError::QuantNotFound { .. } => "quant_not_found",
}
}
async fn unload_model( async fn unload_model(
State(state): State<Arc<NeuronState>>, State(state): State<Arc<NeuronState>>,
Json(body): Json<Value>, Json(body): Json<Value>,
@@ -144,6 +179,7 @@ async fn model_endpoint(
/// `ChatCompletionResponse`. /// `ChatCompletionResponse`.
async fn chat_completions( async fn chat_completions(
State(state): State<Arc<NeuronState>>, State(state): State<Arc<NeuronState>>,
headers: axum::http::HeaderMap,
Json(req): Json<ChatCompletionRequest>, Json(req): Json<ChatCompletionRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let Some(candle) = state.candle.as_ref().map(Arc::clone) else { let Some(candle) = state.candle.as_ref().map(Arc::clone) else {
@@ -154,8 +190,23 @@ async fn chat_completions(
.into_response(); .into_response();
}; };
// Reasoning-content opt-in. Off by default → naïve clients
// (Zed's commit-message generator, vanilla OpenAI clients)
// never see `<think>` blocks. On when the caller sends
// `x-include-thinking: true` (helexa-acp does this so its
// own ThinkParser keeps working unchanged).
let include_thinking = headers
.get("x-include-thinking")
.and_then(|v| v.to_str().ok())
.map(|s| matches!(s.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
let chat_config = openai_chat::ChatProjectionConfig {
include_thinking,
reasoning_markers: None, // filled in from the loaded model inside candle
};
if req.stream.unwrap_or(false) { if req.stream.unwrap_or(false) {
match candle.chat_completion_stream(req).await { match candle.chat_completion_stream_with(req, chat_config).await {
Ok(rx) => { Ok(rx) => {
// Each chunk → one SSE `data: {json}` line. After the // Each chunk → one SSE `data: {json}` line. After the
// channel closes, append the OpenAI [DONE] terminator. // channel closes, append the OpenAI [DONE] terminator.
@@ -199,6 +250,18 @@ async fn chat_completions(
})), })),
) )
.into_response(), .into_response(),
Err(InferenceError::VisionUnsupported { model_id }) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!(
"model '{model_id}' does not support image input"
),
"code": "vision_unsupported",
"model_id": model_id,
"suggestion": "load a vision-capable model or remove image_url content parts",
})),
)
.into_response(),
Err(InferenceError::Other(e)) => ( Err(InferenceError::Other(e)) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("{e:#}")})), Json(json!({"error": format!("{e:#}")})),
@@ -238,6 +301,18 @@ async fn chat_completions(
})), })),
) )
.into_response(), .into_response(),
Err(InferenceError::VisionUnsupported { model_id }) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!(
"model '{model_id}' does not support image input"
),
"code": "vision_unsupported",
"model_id": model_id,
"suggestion": "load a vision-capable model or remove image_url content parts",
})),
)
.into_response(),
Err(InferenceError::Other(e)) => ( Err(InferenceError::Other(e)) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("{e:#}")})), Json(json!({"error": format!("{e:#}")})),
@@ -246,3 +321,199 @@ async fn chat_completions(
} }
} }
} }
/// OpenAI Responses API (`POST /v1/responses`). Translates the
/// Responses-shaped request into a chat-completions one the candle
/// harness already understands, then re-projects the harness's
/// event stream into the Responses event family.
async fn responses(
State(state): State<Arc<NeuronState>>,
Json(req): Json<ResponsesRequest>,
) -> impl IntoResponse {
let Some(candle) = state.candle.as_ref().map(Arc::clone) else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "candle harness not enabled on this neuron"})),
)
.into_response();
};
let stream_requested = req.stream;
let model_id = req.model.clone();
let response_id = mint_response_id();
let message_item_id = mint_message_item_id();
// Translate Responses → chat completions. The only failure
// mode today is `previous_response_id` set, which we reject
// with 400 — stateful conversations need a persistence layer
// we haven't built.
let mut chat_req = match openai_responses::request_to_chat(req) {
Ok(r) => r,
Err(openai_responses::TranslateError::ChainedConversationNotSupported) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "previous_response_id is not supported on this neuron",
"code": "chained_conversation_not_supported"
})),
)
.into_response();
}
};
chat_req.stream = Some(stream_requested);
if stream_requested {
match candle
.responses_stream(chat_req, response_id, message_item_id)
.await
{
Ok(rx) => {
// Each ResponseStreamFrame → one SSE event carrying
// both an event name and JSON data. The Responses
// API doesn't use a `[DONE]` terminator — clients
// see the `response.completed` event as the end of
// the stream.
let body_stream = ReceiverStream::new(rx).map(|frame| {
let body = serde_json::to_string(&frame.data).unwrap_or_else(|_| "{}".into());
Ok::<_, Infallible>(Event::default().event(frame.event_name).data(body))
});
Sse::new(body_stream)
.keep_alive(KeepAlive::default())
.into_response()
}
Err(e) => inference_error_response(e),
}
} else {
// Non-streaming: drive the existing chat completion path
// and translate the result. We don't currently re-tokenise
// to compute usage; the harness returns it via the chat
// response and we pass it through.
match candle.chat_completion(chat_req).await {
Ok(chat_resp) => {
// Extract the assistant text (chat completions
// always emits one choice on the candle path).
let text = chat_resp
.choices
.first()
.map(|c| match &c.message.content {
MessageContent::Text(t) => t.clone(),
MessageContent::Parts(_) => {
// Candle output is always text today;
// a Parts response would be surprising.
// Empty-string fallback is safer than
// a panic.
String::new()
}
})
.unwrap_or_default();
let finish = chat_resp
.choices
.first()
.and_then(|c| c.finish_reason.as_deref())
.map(finish_reason_from_str)
.unwrap_or(crate::wire::FinishReason::Stop);
let usage = chat_resp.usage.as_ref().map(|u| ResponsesUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.prompt_tokens + u.completion_tokens,
});
let meta = openai_responses::ResponseMeta {
response_id: mint_response_id(),
created_at: unix_now_secs(),
model_id,
message_item_id: mint_message_item_id(),
};
let _ = chat_resp; // make the borrow-checker happy if `text` consumed it
let resp = openai_responses::build_response(&meta, text, finish, usage);
Json(resp).into_response()
}
Err(e) => inference_error_response(e),
}
}
}
fn finish_reason_from_str(s: &str) -> crate::wire::FinishReason {
use crate::wire::FinishReason;
match s {
"length" => FinishReason::Length,
"tool_calls" => FinishReason::ToolCalls,
_ => FinishReason::Stop,
}
}
/// Centralised mapping from [`InferenceError`] to an HTTP response.
/// Lifted out so the chat-completions and responses handlers stay
/// readable and changes to error-code semantics happen in one spot.
fn inference_error_response(err: InferenceError) -> axum::response::Response {
match err {
InferenceError::ModelNotLoaded(id) => (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("model '{id}' not loaded on this neuron")})),
)
.into_response(),
InferenceError::PromptTooLong { prompt_len, max } => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!("prompt has {prompt_len} tokens but max is {max}"),
"code": "prompt_too_long",
"prompt_len": prompt_len,
"max": max,
})),
)
.into_response(),
InferenceError::InsufficientVram {
free_mb,
required_mb,
} => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": format!(
"insufficient free VRAM: {free_mb} MiB free, need at least {required_mb} MiB"
),
"code": "insufficient_vram",
"free_mb": free_mb,
"required_mb": required_mb,
})),
)
.into_response(),
InferenceError::VisionUnsupported { model_id } => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!(
"model '{model_id}' does not support image input"
),
"code": "vision_unsupported",
"model_id": model_id,
"suggestion": "load a vision-capable model or remove image_url content parts",
})),
)
.into_response(),
InferenceError::Other(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("{e:#}")})),
)
.into_response(),
}
}
fn mint_response_id() -> String {
format!("resp_{:x}", unix_subsec_nanos())
}
fn mint_message_item_id() -> String {
format!("msg_{:x}", unix_subsec_nanos())
}
fn unix_now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn unix_subsec_nanos() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}

View File

@@ -6,8 +6,18 @@ use figment::{
providers::{Env, Format, Toml}, providers::{Env, Format, Toml},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Default scheme name applied to bare `org/name` model ids when no
/// `[harness.candle.default_source]` is set. Keeps existing operator
/// configs (which know nothing about schemes) working unchanged.
pub const DEFAULT_SOURCE_SCHEME: &str = "huggingface";
/// Endpoint URL for the default huggingface source, used when no
/// `[harness.candle.sources.huggingface]` is configured.
pub const DEFAULT_HF_ENDPOINT: &str = "https://huggingface.co";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NeuronConfig { pub struct NeuronConfig {
#[serde(default = "default_port")] #[serde(default = "default_port")]
@@ -37,8 +47,88 @@ pub struct HarnessSettings {
pub struct CandleHarnessConfig { pub struct CandleHarnessConfig {
/// HuggingFace cache directory for model weights. /// HuggingFace cache directory for model weights.
/// When unset, defers to hf-hub's default (~/.cache/huggingface). /// When unset, defers to hf-hub's default (~/.cache/huggingface).
///
/// Retained for back-compat — operators with existing
/// `hf_cache = "..."` configs continue to work. Treated as the
/// `huggingface` source's cache_dir when a sources table isn't
/// provided.
#[serde(default)] #[serde(default)]
pub hf_cache: Option<PathBuf>, pub hf_cache: Option<PathBuf>,
/// Default source scheme applied to bare `org/name` model ids
/// (those without an explicit `scheme:` prefix). When unset, falls
/// back to `DEFAULT_SOURCE_SCHEME` ("huggingface").
#[serde(default)]
pub default_source: Option<String>,
/// Per-scheme source endpoints. Each entry maps a scheme name
/// (`huggingface`, `helexa`, an operator's mirror tag, …) to its
/// endpoint URL, optional auth env var, and optional cache
/// directory.
///
/// When absent or missing the `huggingface` key, the loader
/// synthesises a `huggingface` entry pointing at
/// `https://huggingface.co` with `hf_cache` (above) as its
/// cache_dir. This keeps single-source configs ergonomic.
#[serde(default)]
pub sources: HashMap<String, SourceConfig>,
}
/// Per-scheme source configuration. Mirrors the shape `hf_hub::ApiBuilder`
/// needs: endpoint URL, optional auth token (read from an env var so
/// secrets stay out of the config file), and optional cache directory
/// disambiguated per source to prevent mirror-vs-canonical collisions.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourceConfig {
/// Base URL of the registry. Must speak the HF-compatible wire
/// format (siblings listing at
/// `/api/models/{org}/{name}[/revision/{rev}]`, blob fetch at
/// `/{org}/{name}/resolve/{rev}/{path}`).
pub endpoint: String,
/// Environment variable name to read for the bearer token used
/// against this source. `None` = anonymous. Reading from env
/// (vs. literal token in the config) keeps secrets out of TOML.
#[serde(default)]
pub auth_env: Option<String>,
/// Cache directory for this source. The hf-hub
/// `models--{org}--{name}/snapshots/...` tree lives directly
/// under this path, so distinct sources serving the same
/// `org/name` cannot collide on disk.
///
/// `None` means "share the harness `hf_cache` directory" — only
/// safe when the operator has exactly one source configured.
#[serde(default)]
pub cache_dir: Option<PathBuf>,
}
impl CandleHarnessConfig {
/// Resolve the effective sources map for this config, synthesising
/// a `huggingface` entry from legacy fields (`hf_cache`) when the
/// operator hasn't supplied a sources table. Idempotent.
///
/// Returns a fresh map rather than mutating self so the original
/// (operator-typed) config can still be serialized back to TOML
/// for diagnostics.
pub fn effective_sources(&self) -> HashMap<String, SourceConfig> {
let mut out = self.sources.clone();
out.entry(DEFAULT_SOURCE_SCHEME.to_string())
.or_insert_with(|| SourceConfig {
endpoint: DEFAULT_HF_ENDPOINT.to_string(),
auth_env: Some("HF_TOKEN".to_string()),
cache_dir: self.hf_cache.clone(),
});
out
}
/// Effective default scheme. Falls back to `DEFAULT_SOURCE_SCHEME`
/// when the operator hasn't pinned one.
pub fn effective_default_source(&self) -> &str {
self.default_source
.as_deref()
.unwrap_or(DEFAULT_SOURCE_SCHEME)
}
} }
fn default_port() -> u16 { fn default_port() -> u16 {
@@ -65,3 +155,109 @@ impl Default for NeuronConfig {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effective_sources_synthesises_huggingface_when_absent() {
let cfg = CandleHarnessConfig::default();
let sources = cfg.effective_sources();
assert!(sources.contains_key("huggingface"));
let hf = &sources["huggingface"];
assert_eq!(hf.endpoint, DEFAULT_HF_ENDPOINT);
assert_eq!(hf.auth_env.as_deref(), Some("HF_TOKEN"));
assert!(hf.cache_dir.is_none());
}
#[test]
fn effective_sources_carries_legacy_hf_cache_into_synth_entry() {
// Existing operator configs only set `hf_cache = "/archive3/..."`
// — the synth must pick that up so the loader keeps using the
// operator's storage.
let cfg = CandleHarnessConfig {
hf_cache: Some(PathBuf::from("/archive3/llm-cache")),
..Default::default()
};
let sources = cfg.effective_sources();
assert_eq!(
sources["huggingface"].cache_dir.as_deref(),
Some(Path::new("/archive3/llm-cache"))
);
}
#[test]
fn effective_sources_preserves_explicit_huggingface_entry() {
// When an operator types out `[harness.candle.sources.huggingface]`
// explicitly, we must not clobber it with the synth defaults.
let mut sources = HashMap::new();
sources.insert(
"huggingface".to_string(),
SourceConfig {
endpoint: "https://huggingface.example.org".into(),
auth_env: Some("MY_TOKEN".into()),
cache_dir: Some(PathBuf::from("/operator-cache")),
},
);
let cfg = CandleHarnessConfig {
hf_cache: Some(PathBuf::from("/legacy-cache")),
sources,
..Default::default()
};
let effective = cfg.effective_sources();
assert_eq!(
effective["huggingface"].endpoint,
"https://huggingface.example.org"
);
assert_eq!(
effective["huggingface"].auth_env.as_deref(),
Some("MY_TOKEN")
);
assert_eq!(
effective["huggingface"].cache_dir.as_deref(),
Some(Path::new("/operator-cache"))
);
}
#[test]
fn effective_sources_includes_helexa_alongside_synth_huggingface() {
let mut sources = HashMap::new();
sources.insert(
"helexa".to_string(),
SourceConfig {
endpoint: "https://registry.helexa.ai".into(),
auth_env: Some("HELEXA_TOKEN".into()),
cache_dir: Some(PathBuf::from("/archive3/llm-cache/helexa")),
},
);
let cfg = CandleHarnessConfig {
hf_cache: Some(PathBuf::from("/archive3/llm-cache/huggingface")),
sources,
..Default::default()
};
let effective = cfg.effective_sources();
assert_eq!(effective.len(), 2);
assert_eq!(effective["helexa"].endpoint, "https://registry.helexa.ai");
// huggingface still gets synth-derived from legacy hf_cache.
assert_eq!(
effective["huggingface"].cache_dir.as_deref(),
Some(Path::new("/archive3/llm-cache/huggingface"))
);
}
#[test]
fn effective_default_source_falls_back() {
let cfg = CandleHarnessConfig::default();
assert_eq!(cfg.effective_default_source(), DEFAULT_SOURCE_SCHEME);
}
#[test]
fn effective_default_source_honours_explicit() {
let cfg = CandleHarnessConfig {
default_source: Some("helexa".into()),
..Default::default()
};
assert_eq!(cfg.effective_default_source(), "helexa");
}
}

View File

@@ -93,12 +93,13 @@ impl Qwen3_5DecoderLayer {
&mut self, &mut self,
x: &Tensor, x: &Tensor,
attn_mask: Option<&Tensor>, attn_mask: Option<&Tensor>,
offset: usize, cos: &Tensor,
sin: &Tensor,
) -> candle_core::Result<Tensor> { ) -> candle_core::Result<Tensor> {
let h = self.input_layernorm.forward(x)?; let h = self.input_layernorm.forward(x)?;
let attn_out = match &mut self.attention { let attn_out = match &mut self.attention {
AttentionKind::Full(attn) => attn.forward(&h, attn_mask, offset)?, AttentionKind::Full(attn) => attn.forward(&h, attn_mask, cos, sin)?,
// Linear attention ignores attn_mask + offset; its causal // Linear attention ignores attn_mask + rope; its causal
// structure is baked into the recurrent state lifecycle. // structure is baked into the recurrent state lifecycle.
AttentionKind::Linear(net) => net.forward(&h)?, AttentionKind::Linear(net) => net.forward(&h)?,
}; };

View File

@@ -96,7 +96,8 @@ impl Qwen3_5Attention {
&mut self, &mut self,
x: &Tensor, x: &Tensor,
attn_mask: Option<&Tensor>, attn_mask: Option<&Tensor>,
offset: usize, cos: &Tensor,
sin: &Tensor,
) -> candle_core::Result<Tensor> { ) -> candle_core::Result<Tensor> {
let (b, l, _) = x.dims3()?; let (b, l, _) = x.dims3()?;
@@ -131,8 +132,9 @@ impl Qwen3_5Attention {
.transpose(1, 2)? .transpose(1, 2)?
.contiguous()?; .contiguous()?;
// 3. RoPE on q, k. // 3. RoPE on q, k (cos/sin built once per forward by the model —
let (q, k) = self.rotary.apply(&q, &k, offset)?; // interleaved M-RoPE for image tokens, plain for text).
let (q, k) = self.rotary.apply_cos_sin(&q, &k, cos, sin)?;
// 4. KV cache. // 4. KV cache.
let (k, v) = self.kv_cache.append(&k, &v)?; let (k, v) = self.kv_cache.append(&k, &v)?;

View File

@@ -737,6 +737,8 @@ mod tests {
rope_theta: 10000.0, rope_theta: 10000.0,
partial_rotary_factor: 1.0, partial_rotary_factor: 1.0,
rope_type: None, rope_type: None,
mrope_section: Vec::new(),
mrope_interleaved: false,
}, },
rms_norm_eps: 1e-6, rms_norm_eps: 1e-6,
tie_word_embeddings: false, tie_word_embeddings: false,

View File

@@ -78,6 +78,7 @@ pub mod linear_attn;
pub mod mlp; pub mod mlp;
pub mod rmsnorm; pub mod rmsnorm;
pub mod rope; pub mod rope;
pub mod vision;
use decoder::Qwen3_5DecoderLayer; use decoder::Qwen3_5DecoderLayer;
use rmsnorm::Qwen3_5RmsNorm; use rmsnorm::Qwen3_5RmsNorm;
@@ -99,6 +100,20 @@ pub struct Config {
pub model_type: String, pub model_type: String,
/// The text-side hyperparameters. Everything we actually need. /// The text-side hyperparameters. Everything we actually need.
pub text_config: TextConfig, pub text_config: TextConfig,
/// Vision tower hyperparameters. Present on multimodal
/// checkpoints (e.g. Qwen/Qwen3.6-27B); absent on text-only
/// variants. When present, `Qwen3_5ForCausalLM::new` loads the
/// vision tower alongside the language model so vision-bearing
/// requests can splice image embeddings at `<|image_pad|>` token
/// positions.
#[serde(default)]
pub vision_config: Option<vision::VisionConfig>,
/// Token id the chat template emits per image patch group.
/// Mirrors the LM tokenizer's `<|image_pad|>` id (248056 for
/// Qwen3.6). The runtime locates these in the prompt and splices
/// in `VisionTower::forward` output. `None` for text-only models.
#[serde(default)]
pub image_token_id: Option<u32>,
} }
/// Inner config (the `text_config` block). Mirrors the Qwen3 layout /// Inner config (the `text_config` block). Mirrors the Qwen3 layout
@@ -176,11 +191,12 @@ fn default_hidden_act() -> String {
} }
/// Nested `rope_parameters` block from a Qwen3-Next `config.json`. /// Nested `rope_parameters` block from a Qwen3-Next `config.json`.
/// `mrope_section` and `mrope_interleaved` are accepted via the ///
/// `#[serde(default)]` flatten-tolerance below but ignored — we treat /// For text-only inference the three MRoPE position grids carry
/// MRoPE as plain RoPE for text-only inference (the three position /// identical ids, so the interleave is a no-op and plain RoPE applies.
/// grids carry identical ids when there's no vision input, so the /// For vision inputs `mrope_section` + `mrope_interleaved` drive the
/// interleaving is a no-op). /// per-axis (text/height/width) rotary used by image tokens — see
/// `rope.rs`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct RopeParameters { pub struct RopeParameters {
/// Base for the inverse-frequency computation. Qwen3.6: 10_000_000. /// Base for the inverse-frequency computation. Qwen3.6: 10_000_000.
@@ -196,6 +212,16 @@ pub struct RopeParameters {
/// implemented here. /// implemented here.
#[serde(default)] #[serde(default)]
pub rope_type: Option<String>, pub rope_type: Option<String>,
/// MRoPE per-axis section sizes `[text, height, width]` — e.g.
/// `[11, 11, 10]` for Qwen3.6, summing to the rotary half-dim.
/// Empty for models that don't declare MRoPE (→ plain RoPE).
#[serde(default)]
pub mrope_section: Vec<usize>,
/// Whether the three MRoPE axes are interleaved per-frequency
/// (Qwen3-VL / Qwen3.6 style, `true`) rather than block-concatenated
/// (Qwen2-VL style, `false`).
#[serde(default)]
pub mrope_interleaved: bool,
} }
fn default_rope_theta() -> f64 { fn default_rope_theta() -> f64 {
@@ -206,6 +232,80 @@ fn default_partial_rotary_factor() -> f32 {
1.0 1.0
} }
/// Splice rows from `img` into `h` at `positions`. Stage B helper.
///
/// `h`: `(1, L, hidden)` — the LM's input embedding tensor after
/// `embed_tokens.forward`.
/// `img`: `(N_img, hidden)` — image embeddings, one row per
/// `<|image_pad|>` token in the prompt. Must already be in `h.dtype()`.
/// `positions`: indices into the `L` axis where image rows go;
/// `positions.len() == N_img`.
///
/// Approach: group `positions` into contiguous runs (because the chat
/// template emits `<|vision_start|><|image_pad|>×N<|vision_end|>` —
/// the pad tokens for each image land in one contiguous span), then
/// `slice_assign` per run. For typical Qwen3.6 requests this is one
/// or two runs per image; `slice_assign` does one tensor copy per
/// run, which is cheap relative to the decoder forward pass.
pub(crate) fn splice_runs(
h: &Tensor,
img: &Tensor,
positions: &[u32],
) -> candle_core::Result<Tensor> {
debug_assert!(
!positions.is_empty(),
"splice_runs precondition: non-empty positions"
);
let hidden = h.dim(2)?;
let mut out = h.clone();
let mut img_offset = 0_usize;
let mut run_start = positions[0] as usize;
let mut run_end_exclusive = run_start + 1;
for &p in &positions[1..] {
let p = p as usize;
if p == run_end_exclusive {
run_end_exclusive = p + 1;
} else {
apply_run(
&mut out,
img,
&mut img_offset,
run_start,
run_end_exclusive,
hidden,
)?;
run_start = p;
run_end_exclusive = p + 1;
}
}
apply_run(
&mut out,
img,
&mut img_offset,
run_start,
run_end_exclusive,
hidden,
)?;
Ok(out)
}
fn apply_run(
out: &mut Tensor,
img: &Tensor,
img_offset: &mut usize,
run_start: usize,
run_end_exclusive: usize,
hidden: usize,
) -> candle_core::Result<()> {
let run_len = run_end_exclusive - run_start;
let slice = img
.narrow(0, *img_offset, run_len)?
.reshape((1, run_len, hidden))?;
*out = out.slice_assign(&[0..1, run_start..run_end_exclusive, 0..hidden], &slice)?;
*img_offset += run_len;
Ok(())
}
/// Qwen3-Next base transformer (embedding + decoder stack + final /// Qwen3-Next base transformer (embedding + decoder stack + final
/// norm). Public so a TP variant in `harness/tp/tp_qwen3_5.rs` can /// norm). Public so a TP variant in `harness/tp/tp_qwen3_5.rs` can
/// also build on it later — for now only `Qwen3_5ForCausalLM` is the /// also build on it later — for now only `Qwen3_5ForCausalLM` is the
@@ -214,6 +314,16 @@ pub struct Qwen3_5Model {
embed_tokens: Embedding, embed_tokens: Embedding,
layers: Vec<Qwen3_5DecoderLayer>, layers: Vec<Qwen3_5DecoderLayer>,
norm: Qwen3_5RmsNorm, norm: Qwen3_5RmsNorm,
/// Shared with every full-attention layer; the model uses it to
/// build the per-forward cos/sin (interleaved M-RoPE for image
/// tokens, plain for text) once, which the layers then apply.
rotary: Arc<RotaryEmbedding>,
/// `offset + rope_delta` is the text-axis position during decode.
/// 0 for text-only; set from `get_rope_index` during a vision
/// prefill (image tokens compress the position space, so text after
/// the image resumes from a smaller counter than the sequence
/// index). Reset in `clear_kv_cache`.
rope_delta: i64,
device: Device, device: Device,
dtype: DType, dtype: DType,
} }
@@ -265,6 +375,8 @@ impl Qwen3_5Model {
embed_tokens, embed_tokens,
layers, layers,
norm, norm,
rotary,
rope_delta: 0,
device, device,
dtype, dtype,
}) })
@@ -278,6 +390,9 @@ impl Qwen3_5Model {
for l in &mut self.layers { for l in &mut self.layers {
l.clear_kv_cache(); l.clear_kv_cache();
} }
// New request → no image-compressed position offset until the
// next vision prefill sets one.
self.rope_delta = 0;
} }
fn causal_mask(&self, b: usize, tgt: usize, offset: usize) -> candle_core::Result<Tensor> { fn causal_mask(&self, b: usize, tgt: usize, offset: usize) -> candle_core::Result<Tensor> {
@@ -289,8 +404,98 @@ impl Qwen3_5Model {
} }
pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> { pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
self.forward_inner(input, offset, None, None, &[])
}
/// Forward with image-embedding splice. Stage B of the vision plan.
///
/// `input_ids`: `(1, L)` token ids — same shape the text-only
/// `forward` accepts (single-batch; multi-batch vision is not in
/// scope today).
/// `image_embeds`: `(N_image_tokens, hidden_size)` — concatenation
/// of every image's post-merger embedding (`VisionTower::forward`
/// output), in the same order images appear in the input. The
/// caller has already done the per-image patch-count expansion of
/// `<|image_pad|>` tokens in `input_ids`, so `N_image_tokens`
/// equals the number of `image_token_id` positions in `input_ids`.
/// `image_token_id`: the sentinel token (e.g. 248056 for Qwen3.6).
///
/// The splice replaces the LM's text-side embedding at each
/// `image_token_id` position with the corresponding row from
/// `image_embeds`. After the splice the decoder runs the interleaved
/// M-RoPE path: `grids` carries each image's post-merge LM grid
/// `(lm_gh, lm_gw)` so `get_rope_index` assigns image tokens their 2D
/// coordinates (dynamic resolution, #14).
pub fn forward_with_vision(
&mut self,
input_ids: &Tensor,
offset: usize,
image_embeds: &Tensor,
image_token_id: u32,
grids: &[(usize, usize)],
) -> candle_core::Result<Tensor> {
self.forward_inner(
input_ids,
offset,
Some(image_embeds),
Some(image_token_id),
grids,
)
}
fn forward_inner(
&mut self,
input: &Tensor,
offset: usize,
image_embeds: Option<&Tensor>,
image_token_id: Option<u32>,
grids: &[(usize, usize)],
) -> candle_core::Result<Tensor> {
let (b, l) = input.dims2()?; let (b, l) = input.dims2()?;
let mut h = self.embed_tokens.forward(input)?; let mut h = self.embed_tokens.forward(input)?;
// Vision path: splice image embeddings at `image_token_id`
// positions and build interleaved M-RoPE cos/sin so image tokens
// carry their 2D (lm_gh × lm_gw) grid coordinates. Text / decode skip the
// device→host id copy entirely and take the plain-RoPE fast path
// — bit-for-bit the pre-M-RoPE behaviour when `rope_delta == 0`.
let (cos, sin) = if let (Some(img), Some(tok_id)) = (image_embeds, image_token_id) {
// Token ids on CPU — reused for the splice + position ids.
let ids: Vec<u32> = input.flatten_all()?.to_vec1()?;
let mut positions: Vec<u32> = Vec::with_capacity(img.dim(0)?);
for (idx, id) in ids.iter().enumerate() {
if *id == tok_id {
positions.push(idx as u32);
}
}
let n_img_tokens = img.dim(0)?;
if positions.len() != n_img_tokens {
candle_core::bail!(
"forward_with_vision: prompt has {} image-token positions but \
image_embeds carries {} tokens — call build_prompt_for_request to \
ensure the per-image patch-count expansion has been applied",
positions.len(),
n_img_tokens,
);
}
if !positions.is_empty() {
// Cast image_embeds to the LM's dtype, then splice the
// contiguous `<|image_pad|>` runs in place.
let img = img.to_dtype(self.dtype)?;
h = splice_runs(&h, &img, &positions)?;
}
let (text, height, width, delta) = rope::get_rope_index(&ids, tok_id, grids)
.map_err(|e| candle_core::Error::Msg(format!("get_rope_index: {e}")))?;
self.rope_delta = delta;
let pos = rope::mrope_position_tensor(&text, &height, &width, &self.device)?;
self.rotary.mrope_cos_sin(&pos)?
} else {
let base = (offset as i64 + self.rope_delta).max(0) as usize;
self.rotary.plain_cos_sin(base, l)?
};
// Causal mask only needed for L > 1 prefill; full-attention // Causal mask only needed for L > 1 prefill; full-attention
// layers consume it via broadcast_add. Linear-attention layers // layers consume it via broadcast_add. Linear-attention layers
// ignore the mask. // ignore the mask.
@@ -300,7 +505,7 @@ impl Qwen3_5Model {
Some(self.causal_mask(b, l, offset)?) Some(self.causal_mask(b, l, offset)?)
}; };
for layer in &mut self.layers { for layer in &mut self.layers {
h = layer.forward(&h, causal.as_ref(), offset)?; h = layer.forward(&h, causal.as_ref(), &cos, &sin)?;
} }
self.norm.forward(&h) self.norm.forward(&h)
} }
@@ -309,6 +514,15 @@ impl Qwen3_5Model {
pub struct Qwen3_5ForCausalLM { pub struct Qwen3_5ForCausalLM {
base: Qwen3_5Model, base: Qwen3_5Model,
lm_head: Linear, lm_head: Linear,
/// Vision tower (Stage A4). `None` for text-only checkpoints or
/// when the operator has opted out. When present, the harness's
/// `Job::EncodeImage` dispatch path runs `vision.forward(image)`
/// and the LM forward (Stage B) splices the result at
/// `image_token_id` positions in the input embedding stream.
vision: Option<vision::VisionTower>,
/// Mirrors `Config::image_token_id`. Cached here so the runtime
/// doesn't have to round-trip through the parsed config struct.
image_token_id: Option<u32>,
} }
impl Qwen3_5ForCausalLM { impl Qwen3_5ForCausalLM {
@@ -324,7 +538,52 @@ impl Qwen3_5ForCausalLM {
.with_context(|| format!("load '{}/lm_head/weight'", vb.prefix()))?; .with_context(|| format!("load '{}/lm_head/weight'", vb.prefix()))?;
Linear::new(weight, None) Linear::new(weight, None)
}; };
Ok(Self { base, lm_head }) // Stage A4: load the vision tower when the config carries a
// `vision_config` block and the safetensors actually carry
// `model.visual.*` weights. The `Option<VisionConfig>` on the
// config makes this a single-source-of-truth decision —
// text-only checkpoints just leave `vision_config` unset and
// get `None` here without any extra plumbing.
let vision = if let Some(vcfg) = config.vision_config.clone() {
tracing::info!(
depth = vcfg.depth,
hidden_size = vcfg.hidden_size,
"loading qwen3_5 vision tower"
);
Some(
vision::VisionTower::load(vcfg, vb.pp("model.visual"))
.context("load qwen3_5 vision tower (model.visual.*)")?,
)
} else {
None
};
Ok(Self {
base,
lm_head,
vision,
image_token_id: config.image_token_id,
})
}
/// True when this checkpoint loaded a vision tower. Used by the
/// HTTP layer to advertise vision capability in `/v1/models` and
/// to reject image-bearing requests against text-only loads with
/// a clean 400.
pub fn has_vision(&self) -> bool {
self.vision.is_some()
}
/// Vision tower handle, if loaded. The device-worker
/// `EncodeImage` job dispatches to `vision.forward(image)`.
pub fn vision(&self) -> Option<&vision::VisionTower> {
self.vision.as_ref()
}
/// `<|image_pad|>` token id from `config.json`, when known.
/// The Stage B prompt-builder uses this to count expansion targets
/// and the LM forward uses it to locate splice positions.
pub fn image_token_id(&self) -> Option<u32> {
self.image_token_id
} }
/// `input`: token-id tensor of shape `(B, L)`. Returns logits at /// `input`: token-id tensor of shape `(B, L)`. Returns logits at
@@ -337,6 +596,25 @@ impl Qwen3_5ForCausalLM {
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head) hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
} }
/// 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
/// `image_token_id` positions before the decoder stack runs.
pub fn forward_with_vision(
&mut self,
input: &Tensor,
offset: usize,
image_embeds: &Tensor,
image_token_id: u32,
grids: &[(usize, usize)],
) -> candle_core::Result<Tensor> {
let (_, l) = input.dims2()?;
let hidden =
self.base
.forward_with_vision(input, offset, image_embeds, image_token_id, grids)?;
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
}
pub fn clear_kv_cache(&mut self) { pub fn clear_kv_cache(&mut self) {
self.base.clear_kv_cache(); self.base.clear_kv_cache();
} }
@@ -394,4 +672,50 @@ mod tests {
assert_eq!(cfg.text_config.rope_parameters.rope_theta, 10_000_000.0); 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); assert!((cfg.text_config.rope_parameters.partial_rotary_factor - 0.25).abs() < 1e-6);
} }
/// `splice_runs` replaces (1, L, H) embedding rows at the given
/// positions with rows from a (N_img, H) image-embedding tensor,
/// in the order positions are supplied.
#[test]
fn splice_runs_replaces_at_contiguous_positions() {
use candle_core::{DType, Device};
let dev = Device::Cpu;
// (1, L=5, H=2) text embeddings — encoded as floats so the
// assertion can spot the change without dtype conversion.
let h_vals: Vec<f32> = vec![
10., 11., // pos 0
20., 21., // pos 1
30., 31., // pos 2
40., 41., // pos 3
50., 51., // pos 4
];
let h = Tensor::from_vec(h_vals, (1, 5, 2), &dev).unwrap();
// Two image embeddings to splice at positions 1 and 2 (a
// contiguous run — single image emitting two patch tokens).
let img_vals: Vec<f32> = vec![-1., -2., -3., -4.];
let img = Tensor::from_vec(img_vals, (2, 2), &dev).unwrap();
let out = splice_runs(&h, &img, &[1, 2]).unwrap();
let flat: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert_eq!(flat, vec![10., 11., -1., -2., -3., -4., 40., 41., 50., 51.]);
let _ = DType::F32;
}
/// Non-contiguous positions: two images at positions [1] and [3]
/// each contributing one patch. `splice_runs` should iterate
/// runs and place the corresponding image rows.
#[test]
fn splice_runs_handles_non_contiguous_runs() {
use candle_core::Device;
let dev = Device::Cpu;
let h_vals: Vec<f32> = vec![1., 1., 2., 2., 3., 3., 4., 4., 5., 5.];
let h = Tensor::from_vec(h_vals, (1, 5, 2), &dev).unwrap();
let img_vals: Vec<f32> = vec![-1., -2., -3., -4.];
let img = Tensor::from_vec(img_vals, (2, 2), &dev).unwrap();
let out = splice_runs(&h, &img, &[1, 3]).unwrap();
let flat: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert_eq!(flat, vec![1., 1., -1., -2., 3., 3., -3., -4., 5., 5.]);
}
} }

View File

@@ -1,19 +1,27 @@
//! Rotary position embedding for Qwen3-Next's full-attention layers. //! Rotary position embedding for Qwen3-Next's full-attention layers.
//! //!
//! Qwen3.6 ships with MRoPE (multimodal RoPE) machinery in the //! Qwen3.6 declares **interleaved M-RoPE** (multimodal RoPE): the
//! reference Python — three position grids interleaved per //! rotary half-dimension is split across three position axes —
//! `mrope_section`. For text-only inference all three grids carry the //! `[text, height, width]` per `mrope_section` (`[11,11,10]` for
//! same position ids and the interleave is a no-op, so this module //! Qwen3.6) — interleaved per-frequency. For **text** every token's
//! implements the plain (non-mrope) flavour: the standard inv_freq //! three axes carry the same position id, so the interleave is a no-op
//! cosine/sine tables driven by `rope_theta` and `head_dim`. //! and this reduces exactly to plain RoPE. For **image** tokens the
//! height/width axes carry the patch's 2D grid coordinates, which is
//! how the model reads the 14×14 patch layout (without it, all patches
//! share a height position and the image reads as vertical repetition).
//! //!
//! Rotation flavour: **GLM-style** rotate-half (the second half of the //! Two cos/sin builders feed a shared [`RotaryEmbedding::apply`]:
//! head dim is negated and swapped into the first). The reference //! - [`RotaryEmbedding::plain_cos_sin`] narrows the precomputed tables
//! Python uses `apply_rotary_pos_emb` with `rotate_half`; candle's //! at a scalar position — the text / decode fast path.
//! `rope_slow` is the matching helper. //! - [`RotaryEmbedding::mrope_cos_sin`] builds per-token cos/sin from a
//! `(3, seq)` position-id tensor, blending the three axes' frequencies
//! at the interleave index sets — the vision-prefill path.
//!
//! Rotation flavour: **GLM-style** rotate-half (candle's `rope_slow`),
//! matching the reference Python's `apply_rotary_pos_emb` + `rotate_half`.
use anyhow::Result; use anyhow::Result;
use candle_core::{DType, Device, Tensor}; use candle_core::{DType, Device, IndexOp, Tensor};
use super::TextConfig; use super::TextConfig;
@@ -21,6 +29,18 @@ use super::TextConfig;
pub struct RotaryEmbedding { pub struct RotaryEmbedding {
sin: Tensor, sin: Tensor,
cos: Tensor, cos: Tensor,
/// Inverse frequencies, shape `(1, rotary_dim/2)`. Retained (beyond
/// the precomputed `sin`/`cos` tables) so [`Self::mrope_cos_sin`] can
/// build cos/sin from arbitrary per-axis position ids.
inv_freq: Tensor,
/// Per-axis column masks over the rotary half-dim, shape `(1, half)`,
/// f32 0/1. `mask_t + mask_h + mask_w` partitions the columns; a
/// column belongs to exactly one axis. For a non-MRoPE config
/// `mask_t` is all-ones and the others all-zero (→ plain RoPE).
mask_t: Tensor,
mask_h: Tensor,
mask_w: Tensor,
dtype: DType,
/// Number of dims at the head's leading edge that the rotation /// Number of dims at the head's leading edge that the rotation
/// covers. The remaining `head_dim - rotary_dim` dims pass through /// covers. The remaining `head_dim - rotary_dim` dims pass through
/// unchanged. Qwen3-Next uses `partial_rotary_factor = 0.25`, so /// unchanged. Qwen3-Next uses `partial_rotary_factor = 0.25`, so
@@ -29,6 +49,52 @@ pub struct RotaryEmbedding {
head_dim: usize, head_dim: usize,
} }
/// Build the per-axis 0/1 column masks over the rotary half-dim from
/// `mrope_section`. Returns `(temporal, height, width)` each length
/// `half`. Temporal is the complement of height width, so the three
/// masks always partition `0..half` and reduce to all-temporal (plain
/// RoPE) when no usable section is given.
fn mrope_masks(
half: usize,
section: &[usize],
interleaved: bool,
) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
let mut mh = vec![0f32; half];
let mut mw = vec![0f32; half];
if section.len() == 3 {
if interleaved {
// Qwen3-VL: height at columns 1,4,7,… ; width at 2,5,8,… ;
// temporal keeps 0,3,6,… — each `take`n from `mrope_section`.
for i in (1..half).step_by(3).take(section[1]) {
mh[i] = 1.0;
}
for i in (2..half).step_by(3).take(section[2]) {
mw[i] = 1.0;
}
} else {
// Qwen2-VL: contiguous blocks [text | height | width].
let h_start = section[0].min(half);
let h_end = (section[0] + section[1]).min(half);
for m in mh.iter_mut().take(h_end).skip(h_start) {
*m = 1.0;
}
for m in mw.iter_mut().take(half).skip(h_end) {
*m = 1.0;
}
}
}
let mt: Vec<f32> = (0..half)
.map(|i| {
if mh[i] == 0.0 && mw[i] == 0.0 {
1.0
} else {
0.0
}
})
.collect();
(mt, mh, mw)
}
impl RotaryEmbedding { impl RotaryEmbedding {
pub fn new(dtype: DType, cfg: &TextConfig, dev: &Device) -> Result<Self> { pub fn new(dtype: DType, cfg: &TextConfig, dev: &Device) -> Result<Self> {
let head_dim = cfg.head_dim; let head_dim = cfg.head_dim;
@@ -52,44 +118,88 @@ impl RotaryEmbedding {
.step_by(2) .step_by(2)
.map(|i| 1f32 / rope.rope_theta.powf(i as f64 / rotary_dim as f64) as f32) .map(|i| 1f32 / rope.rope_theta.powf(i as f64 / rotary_dim as f64) as f32)
.collect(); .collect();
let n = inv_freq.len(); let half = inv_freq.len();
let inv_freq = Tensor::from_vec(inv_freq, (1, n), dev)?.to_dtype(DType::F32)?; let inv_freq = Tensor::from_vec(inv_freq, (1, half), dev)?.to_dtype(DType::F32)?;
let t = Tensor::arange(0u32, max_seq_len as u32, dev)? let t = Tensor::arange(0u32, max_seq_len as u32, dev)?
.to_dtype(DType::F32)? .to_dtype(DType::F32)?
.reshape((max_seq_len, 1))?; .reshape((max_seq_len, 1))?;
let freqs = t.matmul(&inv_freq)?; let freqs = t.matmul(&inv_freq)?;
// MRoPE axis masks. `sum(mrope_section)` should equal `half`;
// warn-tolerant: any shortfall just stays on the temporal axis.
let (mt, mh, mw) = mrope_masks(half, &rope.mrope_section, rope.mrope_interleaved);
let mask_t = Tensor::from_vec(mt, (1, half), dev)?;
let mask_h = Tensor::from_vec(mh, (1, half), dev)?;
let mask_w = Tensor::from_vec(mw, (1, half), dev)?;
Ok(Self { Ok(Self {
sin: freqs.sin()?.to_dtype(dtype)?, sin: freqs.sin()?.to_dtype(dtype)?,
cos: freqs.cos()?.to_dtype(dtype)?, cos: freqs.cos()?.to_dtype(dtype)?,
inv_freq,
mask_t,
mask_h,
mask_w,
dtype,
rotary_dim, rotary_dim,
head_dim, head_dim,
}) })
} }
/// Apply RoPE to q, k. /// cos/sin for a contiguous run of `seq_len` positions starting at
/// /// `pos`, by narrowing the precomputed tables. The text / decode
/// `q`, `k` shape: `(B, H, L, head_dim)`. `offset` is the index /// path (all three MRoPE axes equal → plain RoPE). Shape
/// into the cached cos/sin table — the position of the first token /// `(seq_len, rotary_dim/2)`.
/// in the current step. pub fn plain_cos_sin(
/// &self,
/// When `rotary_dim < head_dim` the rotation is applied only to the pos: usize,
/// first `rotary_dim` dims of each head; the tail passes through seq_len: usize,
/// unchanged (matches the reference Python's ) -> candle_core::Result<(Tensor, Tensor)> {
/// `apply_rotary_pos_emb` with non-trivial `partial_rotary_factor`). let cos = self.cos.narrow(0, pos, seq_len)?;
pub fn apply( let sin = self.sin.narrow(0, pos, seq_len)?;
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
/// every rotary frequency slot is driven by exactly one axis.
/// Reduces exactly to [`Self::plain_cos_sin`] when the three axes are
/// equal. Returns cos/sin of shape `(seq_len, rotary_dim/2)`.
pub fn mrope_cos_sin(&self, position_ids: &Tensor) -> candle_core::Result<(Tensor, Tensor)> {
let pos = position_ids.to_dtype(DType::F32)?;
let (axes, seq_len) = pos.dims2()?;
debug_assert_eq!(axes, 3, "mrope position_ids must have 3 axes");
// Per-axis freqs: pos[a] (seq,1) @ inv_freq (1,half) → (seq,half).
let ft = pos.i(0)?.reshape((seq_len, 1))?.matmul(&self.inv_freq)?;
let fh = pos.i(1)?.reshape((seq_len, 1))?.matmul(&self.inv_freq)?;
let fw = pos.i(2)?.reshape((seq_len, 1))?.matmul(&self.inv_freq)?;
// Blend: each column belongs to exactly one axis (masks partition
// the half-dim), so this picks the right axis per frequency slot.
let blended = ft
.broadcast_mul(&self.mask_t)?
.add(&fh.broadcast_mul(&self.mask_h)?)?
.add(&fw.broadcast_mul(&self.mask_w)?)?;
let cos = blended.cos()?.to_dtype(self.dtype)?;
let sin = blended.sin()?.to_dtype(self.dtype)?;
Ok((cos, sin))
}
/// Apply rotary to `q`, `k` (shape `(B, H, L, head_dim)`) using
/// precomputed `cos`/`sin` of shape `(L, rotary_dim/2)`. Partial
/// rotary: only the first `rotary_dim` dims rotate; the tail passes
/// through unchanged.
pub fn apply_cos_sin(
&self, &self,
q: &Tensor, q: &Tensor,
k: &Tensor, k: &Tensor,
offset: usize, cos: &Tensor,
sin: &Tensor,
) -> candle_core::Result<(Tensor, Tensor)> { ) -> candle_core::Result<(Tensor, Tensor)> {
let (_, _, seq_len, head_dim_in) = q.dims4()?; let (_, _, _seq_len, head_dim_in) = q.dims4()?;
debug_assert_eq!(head_dim_in, self.head_dim, "q head_dim mismatch"); debug_assert_eq!(head_dim_in, self.head_dim, "q head_dim mismatch");
let cos = self.cos.narrow(0, offset, seq_len)?;
let sin = self.sin.narrow(0, offset, seq_len)?;
if self.rotary_dim == self.head_dim { if self.rotary_dim == self.head_dim {
// Full rotation. let q_embed = candle_nn::rotary_emb::rope_slow(&q.contiguous()?, cos, sin)?;
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 k_embed = candle_nn::rotary_emb::rope_slow(&k.contiguous()?, &cos, &sin)?;
Ok((q_embed, k_embed)) Ok((q_embed, k_embed))
} else { } else {
// Partial rotation: narrow → rotate → cat the untouched tail. // Partial rotation: narrow → rotate → cat the untouched tail.
@@ -102,8 +212,8 @@ impl RotaryEmbedding {
.narrow(candle_core::D::Minus1, 0, self.rotary_dim)? .narrow(candle_core::D::Minus1, 0, self.rotary_dim)?
.contiguous()?; .contiguous()?;
let k_pass = k.narrow(candle_core::D::Minus1, self.rotary_dim, tail)?; 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 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 k_rotated = candle_nn::rotary_emb::rope_slow(&k_rot, cos, sin)?;
let q_embed = let q_embed =
Tensor::cat(&[&q_rotated, &q_pass.contiguous()?], candle_core::D::Minus1)?; Tensor::cat(&[&q_rotated, &q_pass.contiguous()?], candle_core::D::Minus1)?;
let k_embed = let k_embed =
@@ -112,3 +222,358 @@ impl RotaryEmbedding {
} }
} }
} }
/// Compute interleaved-M-RoPE 3D position ids for a full prompt that may
/// contain image-placeholder runs, plus the decode `rope_delta`.
///
/// Mirrors the reference `get_rope_index`:
/// - text tokens advance a single running counter `c`, all three axes
/// equal (`[c, c, c]`);
/// - each contiguous run of `image_token_id` is one image; its tokens get
/// `[base + t, base + h, base + w]` in row-major (t outer, h, w inner),
/// where `base` is the counter at the run's start; after the run the
/// counter resumes from `base + max(grid_t, grid_h, grid_w)`.
///
/// Returns `(text_pos, height_pos, width_pos, rope_delta)`, each pos `Vec`
/// length `input_ids.len()`. `rope_delta = final_counter - seq_len`: add it
/// to a plain decode offset so text resumes from the counter after the
/// (position-compressed) image blocks.
///
/// Whether interleaved M-RoPE for image tokens is enabled. Default
/// **on** — Qwen3.6 was trained with interleaved M-RoPE, and this
/// implementation matches the HF `apply_interleaved_mrope` /
/// `get_rope_index` reference exactly (verified column-for-column). The
/// env var is a **kill switch**: `NEURON_MROPE=0` falls back to plain
/// sequential positions for image tokens (the pre-M-RoPE behaviour).
pub(crate) fn mrope_enabled() -> bool {
std::env::var("NEURON_MROPE")
.map(|v| {
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
})
.unwrap_or(true)
}
/// Position ids for the forward path. Gated by [`mrope_enabled`]: when
/// off, returns plain sequential identity positions on all three axes
/// (`mrope_cos_sin` then reduces exactly to plain RoPE), restoring the
/// pre-M-RoPE behaviour without touching the rest of the forward.
pub(crate) fn get_rope_index(
input_ids: &[u32],
image_token_id: u32,
grids: &[(usize, usize)],
) -> Result<MRopeIndex> {
if !mrope_enabled() {
let seq: Vec<i64> = (0..input_ids.len() as i64).collect();
return Ok((seq.clone(), seq.clone(), seq, 0));
}
compute_mrope_index(input_ids, image_token_id, grids)
}
/// The real interleaved-M-RoPE position-id computation (always active in
/// unit tests; gated behind [`get_rope_index`] at runtime).
///
/// `grids` carries the post-merge LM grid `(lm_gh, lm_gw)` for each image
/// run, in prompt order — a run length alone cannot recover its
/// factorisation, so the grids must be passed (#14 dynamic resolution).
/// Each image is a still frame (`grid_t = 1`); its tokens get
/// `[base, base + hh, base + ww]` row-major and the shared counter
/// resumes at `base + max(lm_gh, lm_gw)`. Multi-image is correct because
/// the counter threads across images and interleaved text.
pub(crate) fn compute_mrope_index(
input_ids: &[u32],
image_token_id: u32,
grids: &[(usize, usize)],
) -> Result<MRopeIndex> {
let n = input_ids.len();
let mut text = Vec::with_capacity(n);
let mut height = Vec::with_capacity(n);
let mut width = Vec::with_capacity(n);
let mut counter: i64 = 0;
let mut i = 0;
let mut k = 0; // index into `grids`, one per image run
while i < n {
if input_ids[i] == image_token_id {
let start = i;
while i < n && input_ids[i] == image_token_id {
i += 1;
}
let run = i - start;
let (grid_h, grid_w) = *grids.get(k).ok_or_else(|| {
anyhow::anyhow!(
"get_rope_index: image run #{k} (len {run}) has no matching grid \
({} grids supplied)",
grids.len()
)
})?;
k += 1;
if grid_h * grid_w != run {
anyhow::bail!(
"get_rope_index: image run #{} length {run} != grid {grid_h}×{grid_w} = {}",
k - 1,
grid_h * grid_w
);
}
let base = counter;
for hh in 0..grid_h {
for ww in 0..grid_w {
text.push(base); // grid_t = 1 → temporal axis const
height.push(base + hh as i64);
width.push(base + ww as i64);
}
}
counter = base + grid_h.max(grid_w) as i64;
} else {
text.push(counter);
height.push(counter);
width.push(counter);
counter += 1;
i += 1;
}
}
if k != grids.len() {
anyhow::bail!(
"get_rope_index: prompt has {k} image run(s) but {} grid(s) were supplied",
grids.len()
);
}
let delta = counter - n as i64;
Ok((text, height, width, delta))
}
/// `(text_pos, height_pos, width_pos, rope_delta)` returned by
/// [`get_rope_index`]; the three vectors combine into the `(3, seq)`
/// MRoPE position-id tensor.
pub(crate) type MRopeIndex = (Vec<i64>, Vec<i64>, Vec<i64>, i64);
/// Build the `(3, seq)` position-id tensor consumed by
/// [`RotaryEmbedding::mrope_cos_sin`] from the three axis vectors.
///
/// Built directly as **f32** (positions are small integers, exact in
/// f32 well past any context length): the freqs matmul needs float
/// anyway, and this avoids an i64 tensor / i64→f32 cast on the GPU.
pub(crate) fn mrope_position_tensor(
text: &[i64],
height: &[i64],
width: &[i64],
dev: &Device,
) -> candle_core::Result<Tensor> {
let seq = text.len();
let mut flat = Vec::with_capacity(3 * seq);
flat.extend(text.iter().map(|&x| x as f32));
flat.extend(height.iter().map(|&x| x as f32));
flat.extend(width.iter().map(|&x| x as f32));
Tensor::from_vec(flat, (3, seq), dev)
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::IndexOp;
/// A TextConfig stub with Qwen3.6's rope params (head_dim 256,
/// partial 0.25 → rotary_dim 64 → half 32; section [11,11,10]).
fn qwen36_cfg() -> TextConfig {
serde_json::from_value(serde_json::json!({
"hidden_size": 5120,
"num_hidden_layers": 1,
"num_attention_heads": 64,
"num_key_value_heads": 8,
"head_dim": 256,
"intermediate_size": 1,
"vocab_size": 10,
"rms_norm_eps": 1e-6,
"max_position_embeddings": 64,
"layer_types": ["full_attention"],
"rope_parameters": {
"rope_theta": 10000000.0,
"partial_rotary_factor": 0.25,
"mrope_section": [11, 11, 10],
"mrope_interleaved": true
}
}))
.expect("cfg")
}
#[test]
fn mrope_masks_partition_the_half_dim() {
let (mt, mh, mw) = mrope_masks(32, &[11, 11, 10], true);
// Each column belongs to exactly one axis.
for i in 0..32 {
let s = mt[i] + mh[i] + mw[i];
assert_eq!(s, 1.0, "column {i} covered {s} times");
}
assert_eq!(mt.iter().sum::<f32>(), 11.0);
assert_eq!(mh.iter().sum::<f32>(), 11.0);
assert_eq!(mw.iter().sum::<f32>(), 10.0);
// Interleave: temporal 0,3,…; height 1,4,…; width 2,5,…
assert_eq!(mt[0], 1.0);
assert_eq!(mh[1], 1.0);
assert_eq!(mw[2], 1.0);
assert_eq!(mt[3], 1.0);
}
/// The load-bearing invariant: when all three position axes are
/// equal (text), `mrope_cos_sin` must reproduce `plain_cos_sin`
/// bit-for-bit — i.e. M-RoPE is a no-op for text, so text inference
/// is unchanged.
#[test]
fn mrope_reduces_to_plain_for_equal_axes() {
let dev = Device::Cpu;
let rope = RotaryEmbedding::new(DType::F32, &qwen36_cfg(), &dev).unwrap();
// positions 5,6,7 on all three axes.
let base: Vec<i64> = vec![5, 6, 7];
let pos =
Tensor::from_vec([base.clone(), base.clone(), base].concat(), (3, 3), &dev).unwrap();
let (mc, ms) = rope.mrope_cos_sin(&pos).unwrap();
let (pc, ps) = rope.plain_cos_sin(5, 3).unwrap();
let dcos = (mc - pc).unwrap().abs().unwrap().max_all().unwrap();
let dsin = (ms - ps).unwrap().abs().unwrap().max_all().unwrap();
assert!(
dcos.to_scalar::<f32>().unwrap() < 1e-6,
"cos mismatch {dcos:?}"
);
assert!(
dsin.to_scalar::<f32>().unwrap() < 1e-6,
"sin mismatch {dsin:?}"
);
}
/// Hand-checked interleave: a width-axis column (index 2) must track
/// the WIDTH position, while a temporal column (index 0) tracks the
/// TEXT position, even when the axes differ.
#[test]
fn mrope_blends_axes_at_interleave_columns() {
let dev = Device::Cpu;
let rope = RotaryEmbedding::new(DType::F32, &qwen36_cfg(), &dev).unwrap();
let half = rope.inv_freq.dim(1).unwrap();
let inv: Vec<f32> = rope.inv_freq.i(0).unwrap().to_vec1().unwrap();
// One token: text=10, height=3, width=7 — all distinct.
let pos = Tensor::from_vec(vec![10i64, 3, 7], (3, 1), &dev).unwrap();
let (cos, _sin) = rope.mrope_cos_sin(&pos).unwrap();
let cos_row: Vec<f32> = cos.i(0).unwrap().to_vec1().unwrap();
assert_eq!(cos_row.len(), half);
// Column 0 (temporal) → text pos 10. Column 1 (height) → 3.
// Column 2 (width) → 7.
assert!((cos_row[0] - (10.0 * inv[0]).cos()).abs() < 1e-5);
assert!((cos_row[1] - (3.0 * inv[1]).cos()).abs() < 1e-5);
assert!((cos_row[2] - (7.0 * inv[2]).cos()).abs() < 1e-5);
assert!((cos_row[3] - (10.0 * inv[3]).cos()).abs() < 1e-5);
}
#[test]
fn get_rope_index_text_only_is_sequential() {
let (t, h, w, delta) = compute_mrope_index(&[1, 2, 3, 4], 99, &[]).unwrap();
assert_eq!(t, vec![0, 1, 2, 3]);
assert_eq!(h, vec![0, 1, 2, 3]);
assert_eq!(w, vec![0, 1, 2, 3]);
assert_eq!(delta, 0, "no image → delta 0 → plain decode positions");
}
#[test]
fn get_rope_index_text_image_text() {
// [text, image(2x2 run of 4), text]. image_token = 99, grid (2,2).
let ids = [1u32, 99, 99, 99, 99, 2];
let (t, h, w, delta) = compute_mrope_index(&ids, 99, &[(2, 2)]).unwrap();
// token 0: text → 0. image base=1, grid 2x2:
// t all = 1; h = base+row = [1,1,2,2]; w = base+col = [1,2,1,2].
// resume from base + max(2,2) = 3. trailing text → 3.
assert_eq!(t, vec![0, 1, 1, 1, 1, 3]);
assert_eq!(h, vec![0, 1, 1, 2, 2, 3]);
assert_eq!(w, vec![0, 1, 2, 1, 2, 3]);
// final counter = 4, seq_len = 6 → delta = -2 (the 4 image tokens
// advanced the counter by only 2).
assert_eq!(delta, -2);
// Decode after the prompt (offset = 6) → text position 6 + (-2) = 4.
assert_eq!(6 + delta, 4);
}
#[test]
fn get_rope_index_nonsquare_single_image() {
// text + image(2 rows × 3 cols = 6 tokens). grid (2,3).
let ids = [1u32, 99, 99, 99, 99, 99, 99];
let (t, h, w, delta) = compute_mrope_index(&ids, 99, &[(2, 3)]).unwrap();
// base = 1; row-major h = [0,0,0,1,1,1]+1, w = [0,1,2,0,1,2]+1.
assert_eq!(t, vec![0, 1, 1, 1, 1, 1, 1]);
assert_eq!(h, vec![0, 1, 1, 1, 2, 2, 2]);
assert_eq!(w, vec![0, 1, 2, 3, 1, 2, 3]);
// resume from base + max(2,3) = 4; seq_len 7, counter 4 → delta -3.
assert_eq!(delta, 4 - 7);
}
#[test]
fn get_rope_index_two_images_different_grids() {
// img(2x2)=4, text, img(1x3)=3. grids [(2,2),(1,3)].
let ids = [99, 99, 99, 99, 7, 99, 99, 99];
let (t, h, w, delta) = compute_mrope_index(&ids, 99, &[(2, 2), (1, 3)]).unwrap();
// img1 base=0 → t=0, h=[0,0,1,1], w=[0,1,0,1]; resume max(2,2)=2.
// text at counter 2. img2 base=3 → t=3, h=[3,3,3], w=[3,4,5];
// resume 3+max(1,3)=6.
assert_eq!(t, vec![0, 0, 0, 0, 2, 3, 3, 3]);
assert_eq!(h, vec![0, 0, 1, 1, 2, 3, 3, 3]);
assert_eq!(w, vec![0, 1, 0, 1, 2, 3, 4, 5]);
assert_eq!(delta, 6 - 8);
}
#[test]
fn get_rope_index_on_by_default() {
// With NEURON_MROPE unset (default ON), the runtime path returns
// the real interleaved-M-RoPE positions. (NEURON_MROPE=0 would fall
// back to identity; not asserted here since it depends on env.)
let (t, h, w, _delta) = get_rope_index(&[1, 99, 99, 99, 99, 2], 99, &[(2, 2)]).unwrap();
assert_eq!(t, vec![0, 1, 1, 1, 1, 3]);
assert_eq!(h, vec![0, 1, 1, 2, 2, 3]);
assert_eq!(w, vec![0, 1, 2, 1, 2, 3]);
}
#[test]
fn get_rope_index_grid_mismatches_error() {
// run length != grid product.
assert!(compute_mrope_index(&[99u32; 6], 99, &[(2, 2)]).is_err());
// too few grids for the number of image runs.
assert!(compute_mrope_index(&[99, 99, 7, 99], 99, &[(1, 2)]).is_err());
// too many grids.
assert!(compute_mrope_index(&[99, 99], 99, &[(1, 2), (1, 1)]).is_err());
}
#[test]
fn position_tensor_round_trips_through_mrope_cos_sin() {
// get_rope_index → (3,seq) tensor → mrope_cos_sin, and confirm an
// image token's height column tracks its grid row (not the text
// counter), i.e. the end-to-end position plumbing is wired right.
let dev = Device::Cpu;
let rope = RotaryEmbedding::new(DType::F32, &qwen36_cfg(), &dev).unwrap();
let ids = [1u32, 99, 99, 99, 99]; // text + 2x2 image
let (t, h, w, _d) = compute_mrope_index(&ids, 99, &[(2, 2)]).unwrap();
let pos = mrope_position_tensor(&t, &h, &w, &dev).unwrap();
assert_eq!(pos.dims(), &[3, 5]);
let (cos, _sin) = rope.mrope_cos_sin(&pos).unwrap();
assert_eq!(cos.dims(), &[5, rope.inv_freq.dim(1).unwrap()]);
let inv: Vec<f32> = rope.inv_freq.i(0).unwrap().to_vec1().unwrap();
// Last image token (index 4): grid (h=1, w=1) → base 1 → h=2, w=2.
// Height column (index 1) must track h-position 2, not text.
let last: Vec<f32> = cos.i(4).unwrap().to_vec1().unwrap();
assert!((last[1] - (2.0 * inv[1]).cos()).abs() < 1e-5);
}
#[test]
fn get_rope_index_196_is_14x14() {
let mut ids = vec![1u32]; // one text token
ids.extend(std::iter::repeat_n(99u32, 196));
let (t, h, w, _delta) = compute_mrope_index(&ids, 99, &[(14, 14)]).unwrap();
// image base = 1. Last image token (index 196) is grid (h=13,w=13).
assert_eq!(*t.last().unwrap(), 1, "grid_t=1 → temporal const at base");
assert_eq!(h[1], 1, "first image row at base");
assert_eq!(w[1], 1, "first image col at base");
assert_eq!(h[196], 1 + 13, "last image row = base + 13");
assert_eq!(w[196], 1 + 13, "last image col = base + 13");
}
}

View File

@@ -0,0 +1,835 @@
//! Qwen3.6 vision tower.
//!
//! 27 pre-norm ViT blocks with **LayerNorm** (with biases — not the
//! `(1+w)·x` RmsNorm the language model uses), fused QKV attention,
//! GELU-tanh MLP. Followed by a `merger` that LayerNorms each
//! 1152-dim vision token, spatially 2×2-merges them into 4608-dim
//! groups, and projects to the LM's 5120-dim hidden via
//! `linear_fc1 → GELU → linear_fc2`.
//!
//! Architecture spec sourced from beast's cached Qwen3.6-27B
//! safetensors header (Stage A0, see
//! `doc/vision-qwen3_6-spec.md`). All weight shapes confirmed
//! from the live `.safetensors` headers, not inferred.
//!
//! **Conv3d wrinkle.** The published `patch_embed.proj.weight` is 5D
//! `[1152, 3, 2, 16, 16]` — a 3D conv with kernel
//! `(t=2, h=16, w=16)`. Candle 0.10 has no Conv3d. For static images
//! we get away with a trick: when the temporal patch size is 2 and we
//! duplicate the still image along the temporal axis (`T = 2`,
//! frame_0 == frame_1), the Conv3d output equals a Conv2d run with
//! the *sum* of the two temporal weight slices:
//!
//! ```text
//! output = W_0 · frame_0 + W_1 · frame_1 + bias
//! = (W_0 + W_1) · frame + bias (static image)
//! ```
//!
//! So at load we sum-collapse the temporal axis and use a 4D
//! `Conv2d` kernel. Video support would have to do the real Conv3d
//! (different frames mean the trick fails) — tracked alongside the
//! dynamic-resolution work in issue #14.
//!
//! Forward signature (Stage A — no LM splice yet):
//!
//! ```text
//! fn forward(&self, image: &Tensor) -> Result<Tensor>
//! ```
//!
//! `image` is `(3, H, W)` f32, normalised by `preprocess::preprocess`.
//! Returns `(N_lm_tokens, out_hidden_size)` post-merger tokens ready
//! to splice into the LM's input embeddings at `<|image_pad|>`
//! positions. For Qwen3.6 at 448×448 → 28×28 patches → 14×14 = 196
//! LM tokens of dim 5120.
use anyhow::{Context, Result};
use candle_core::{D, DType, Device, IndexOp, Module, Tensor};
use candle_nn::var_builder::ShardedVarBuilder;
use candle_nn::{Conv2d, Conv2dConfig, Embedding, LayerNorm, Linear};
use serde::Deserialize;
fn env_truthy(name: &str) -> bool {
std::env::var(name)
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// Legacy escape hatch: when set, use the original Stage-A sequential
/// `pos_embed` lookup instead of the bilinear grid interpolation.
/// Default off (interpolation on) — for A/B comparison only.
fn vision_legacy_pos() -> bool {
env_truthy("NEURON_VISION_LEGACY_POS")
}
/// Legacy escape hatch: when set, skip the 2D vision rotary in the ViT
/// attention (the original Stage-A behaviour). Default off (rotary on)
/// — for A/B comparison only.
fn vision_legacy_rope() -> bool {
env_truthy("NEURON_VISION_LEGACY_ROPE")
}
/// Qwen3.6 vision tower hyperparameters. Mirrors the `vision_config`
/// block of `config.json`. Only the fields we actually need are
/// captured; serde tolerates the rest.
#[derive(Debug, Clone, Deserialize)]
pub struct VisionConfig {
/// Number of ViT blocks (`depth: 27` for Qwen3.6).
pub depth: usize,
/// Vision-token dimension throughout the tower (1152 for Qwen3.6).
pub hidden_size: usize,
/// MLP intermediate dim (4304).
pub intermediate_size: usize,
/// Attention head count (16). `head_dim = hidden_size / num_heads`.
pub num_heads: usize,
/// Number of slots in the learned position embedding (2304).
/// Caps the maximum image patch count.
pub num_position_embeddings: usize,
/// Spatial patch edge in pixels (16).
pub patch_size: usize,
/// Temporal kernel depth in the patch embed (2 for Qwen3.6 — we
/// collapse this into a single Conv2d for static-image inference;
/// see the module-level Conv3d wrinkle).
pub temporal_patch_size: usize,
/// Patches grouped per LM token by the merger (2 → 2×2 = 4
/// patches per LM token).
pub spatial_merge_size: usize,
/// Vision input channels (3, RGB).
pub in_channels: usize,
/// Merger output dim — matches the LM's `hidden_size` (5120 for
/// Qwen3.6). The merger projects from vision dim → LM dim.
pub out_hidden_size: usize,
}
const LAYER_NORM_EPS: f64 = 1e-6;
/// Number of LM tokens emitted by the merger per vision-token group.
const LM_TOKENS_PER_MERGE_GROUP: usize = 1;
/// One ViT block: pre-LN → attn → residual; pre-LN → MLP → residual.
struct VisionBlock {
norm1: LayerNorm,
qkv: Linear,
proj: Linear,
norm2: LayerNorm,
fc1: Linear,
fc2: Linear,
num_heads: usize,
head_dim: usize,
}
impl VisionBlock {
fn load(cfg: &VisionConfig, vb: &ShardedVarBuilder) -> Result<Self> {
let h = cfg.hidden_size;
let head_dim = h / cfg.num_heads;
let norm1 = layer_norm(vb.pp("norm1"), h)?;
let qkv = linear(vb.pp("attn.qkv"), h, 3 * h)?;
let proj = linear(vb.pp("attn.proj"), h, h)?;
let norm2 = layer_norm(vb.pp("norm2"), h)?;
let fc1 = linear(vb.pp("mlp.linear_fc1"), h, cfg.intermediate_size)?;
let fc2 = linear(vb.pp("mlp.linear_fc2"), cfg.intermediate_size, h)?;
Ok(Self {
norm1,
qkv,
proj,
norm2,
fc1,
fc2,
num_heads: cfg.num_heads,
head_dim,
})
}
/// `x`: `(N, hidden_size)` un-batched. `rotary`: optional
/// `(cos, sin)` each `(N, head_dim/2)` — the 2D vision rotary applied
/// to q/k. Returns same shape.
fn forward(&self, x: &Tensor, rotary: Option<&(Tensor, Tensor)>) -> Result<Tensor> {
let attn_in = self.norm1.forward(x)?;
let attn_out = self.attention(&attn_in, rotary)?;
let x = x.add(&attn_out)?;
let mlp_in = self.norm2.forward(&x)?;
let mlp_out = self.fc2.forward(&gelu_tanh(&self.fc1.forward(&mlp_in)?)?)?;
x.add(&mlp_out).map_err(Into::into)
}
/// Multi-head self-attention over the patch sequence. No causal
/// mask — every patch attends to every other patch. When `rotary` is
/// given, the 2D vision rotary (row/col position) is applied to q, k
/// before the scores, matching HF `apply_rotary_pos_emb_vision`
/// (`rope_slow` is the same rotate-half form).
fn attention(&self, x: &Tensor, rotary: Option<&(Tensor, Tensor)>) -> Result<Tensor> {
let (n, hidden) = x.dims2()?;
// qkv: (N, 3*hidden). Split into Q, K, V each (N, hidden).
let qkv = self.qkv.forward(x)?;
let qkv = qkv.reshape((n, 3, self.num_heads, self.head_dim))?;
// Transpose to (3, num_heads, N, head_dim) for per-head views.
let qkv = qkv.permute((1, 2, 0, 3))?.contiguous()?;
let q = qkv.i(0)?;
let k = qkv.i(1)?;
let v = qkv.i(2)?;
// 2D vision rotary on q, k (full head_dim; rotate-half form).
let (q, k) = match rotary {
Some((cos, sin)) => {
let q = candle_nn::rotary_emb::rope_slow(&q.unsqueeze(0)?, cos, sin)?.squeeze(0)?;
let k = candle_nn::rotary_emb::rope_slow(&k.unsqueeze(0)?, cos, sin)?.squeeze(0)?;
(q, k)
}
None => (q, k),
};
let scale = 1.0 / (self.head_dim as f64).sqrt();
// (num_heads, N, head_dim) @ (num_heads, head_dim, N) -> (num_heads, N, N)
let scores = q.matmul(&k.transpose(D::Minus2, D::Minus1)?)?;
let scores = (scores * scale)?;
let probs = candle_nn::ops::softmax_last_dim(&scores)?;
// (num_heads, N, N) @ (num_heads, N, head_dim) -> (num_heads, N, head_dim)
let out = probs.matmul(&v)?;
// Merge heads back: (N, num_heads, head_dim) -> (N, hidden).
let out = out.permute((1, 0, 2))?.contiguous()?.reshape((n, hidden))?;
self.proj.forward(&out).map_err(Into::into)
}
}
/// `merger`: LayerNorm per token → spatial 2×2 merge (concat 4
/// adjacent tokens into one 4608-dim vector) → fc1 → GELU-tanh →
/// fc2. Output dim is the LM's hidden_size.
struct VisionMerger {
norm: LayerNorm,
fc1: Linear,
fc2: Linear,
merge_input_dim: usize,
spatial_merge_size: usize,
}
impl VisionMerger {
fn load(cfg: &VisionConfig, vb: &ShardedVarBuilder) -> Result<Self> {
let h = cfg.hidden_size;
let merge = cfg.spatial_merge_size;
let merge_input_dim = h * merge * merge;
let norm = layer_norm(vb.pp("norm"), h)?;
let fc1 = linear(vb.pp("linear_fc1"), merge_input_dim, merge_input_dim)?;
let fc2 = linear(vb.pp("linear_fc2"), merge_input_dim, cfg.out_hidden_size)?;
Ok(Self {
norm,
fc1,
fc2,
merge_input_dim,
spatial_merge_size: merge,
})
}
/// `tokens`: `(grid_h, grid_w, hidden_size)`. The merger reshapes
/// each `merge×merge` block of adjacent patches into a single
/// concatenated vector, then projects.
///
/// `grid_h` and `grid_w` must both be multiples of
/// `spatial_merge_size`. Returns
/// `(grid_h/merge × grid_w/merge, out_hidden_size)`.
fn forward(&self, tokens: &Tensor) -> Result<Tensor> {
let (gh, gw, h) = tokens.dims3()?;
let m = self.spatial_merge_size;
anyhow::ensure!(
gh.is_multiple_of(m) && gw.is_multiple_of(m),
"merger expects spatial dims divisible by merge_size={m}; got ({gh}, {gw})"
);
let tokens = self.norm.forward(tokens)?;
// (gh, gw, h) -> (gh/m, m, gw/m, m, h) -> (gh/m, gw/m, m, m, h)
// -> flatten last three -> (gh/m, gw/m, m*m*h) -> (N_lm, merge_input_dim)
let out_h = gh / m;
let out_w = gw / m;
let merged = tokens
.reshape((out_h, m, out_w, m, h))?
.permute((0, 2, 1, 3, 4))?
.contiguous()?
.reshape((out_h * out_w, self.merge_input_dim))?;
let hidden = self.fc2.forward(&gelu_tanh(&self.fc1.forward(&merged)?)?)?;
Ok(hidden)
}
}
/// 2D rotary position embedding for the vision tower. Each patch's
/// `head_dim` rotates by its `(row, col)` grid coordinates: the first
/// half of the rotary freqs are driven by the row position, the second
/// half by the column. Mirrors HF `Qwen3VLVisionRotaryEmbedding` +
/// `rot_pos_emb` (θ = 10000, `dim = head_dim/2`).
struct VisionRotaryEmbedding {
/// `(half,)` f32, `half = head_dim/4` freqs per spatial axis.
inv_freq: Vec<f32>,
}
impl VisionRotaryEmbedding {
fn new(head_dim: usize) -> Self {
// HF: Qwen3VLVisionRotaryEmbedding(head_dim // 2), theta 10000.
let dim = head_dim / 2;
let theta = 10000f32;
let inv_freq = (0..dim)
.step_by(2)
.map(|i| 1f32 / theta.powf(i as f32 / dim as f32))
.collect();
Self { inv_freq }
}
/// cos/sin for a `gh×gw` patch grid in **row-major** order. Returns
/// `(cos, sin)` each `(gh*gw, head_dim/2)`: per patch, the row-axis
/// freqs `row·inv_freq` followed by the col-axis freqs `col·inv_freq`
/// (then `rope_slow` duplicates them across the full head_dim).
fn cos_sin(
&self,
gh: usize,
gw: usize,
dev: &Device,
dtype: DType,
) -> candle_core::Result<(Tensor, Tensor)> {
let half = self.inv_freq.len();
let n = gh * gw;
let mut data = Vec::with_capacity(n * 2 * half);
for hi in 0..gh {
for wi in 0..gw {
for &f in &self.inv_freq {
data.push(hi as f32 * f);
}
for &f in &self.inv_freq {
data.push(wi as f32 * f);
}
}
}
let freqs = Tensor::from_vec(data, (n, 2 * half), dev)?;
let cos = freqs.cos()?.to_dtype(dtype)?;
let sin = freqs.sin()?.to_dtype(dtype)?;
Ok((cos, sin))
}
}
/// The vision tower itself.
pub struct VisionTower {
/// Sum-collapsed temporal kernel (Conv2d, see module doc).
patch_embed: Conv2d,
pos_embed: Embedding,
rotary: VisionRotaryEmbedding,
blocks: Vec<VisionBlock>,
merger: VisionMerger,
config: VisionConfig,
dtype: DType,
device: Device,
}
impl VisionTower {
/// Load from a `ShardedVarBuilder` rooted at the safetensors
/// `model.visual.` prefix. Caller is responsible for the `pp` —
/// see `Qwen3_5ForCausalLM::new` (Stage A4).
pub fn load(cfg: VisionConfig, vb: ShardedVarBuilder) -> Result<Self> {
let dtype = vb.dtype();
let device = vb.device().clone();
// patch_embed.proj is published as 5D Conv3d weight; we
// sum-collapse the temporal axis (size = temporal_patch_size)
// to get a 4D Conv2d kernel. This is exact for the static-
// image case where T = temporal_patch_size frames are
// identical (i.e. the input was duplicated along T).
let raw_weight = vb
.pp("patch_embed.proj")
.get(
(
cfg.hidden_size,
cfg.in_channels,
cfg.temporal_patch_size,
cfg.patch_size,
cfg.patch_size,
),
"weight",
)
.context("load model.visual.patch_embed.proj.weight (5D Conv3d kernel)")?;
// Sum along the temporal axis (dim 2) — see module doc-comment.
let folded = raw_weight.sum(2)?; // -> (hidden, in_channels, patch, patch)
let proj_bias = vb
.pp("patch_embed.proj")
.get(cfg.hidden_size, "bias")
.context("load model.visual.patch_embed.proj.bias")?;
let conv_cfg = Conv2dConfig {
stride: cfg.patch_size,
..Default::default()
};
let patch_embed = Conv2d::new(folded, Some(proj_bias), conv_cfg);
let pos_embed_weight = vb
.pp("pos_embed")
.get((cfg.num_position_embeddings, cfg.hidden_size), "weight")
.context("load model.visual.pos_embed.weight")?;
let pos_embed = Embedding::new(pos_embed_weight, cfg.hidden_size);
let rotary = VisionRotaryEmbedding::new(cfg.hidden_size / cfg.num_heads);
let blocks_vb = vb.pp("blocks");
let mut blocks = Vec::with_capacity(cfg.depth);
for i in 0..cfg.depth {
blocks.push(
VisionBlock::load(&cfg, &blocks_vb.pp(i))
.with_context(|| format!("load vision block {i}"))?,
);
}
let merger = VisionMerger::load(&cfg, &vb.pp("merger")).context("load vision merger")?;
Ok(Self {
patch_embed,
pos_embed,
rotary,
blocks,
merger,
config: cfg,
dtype,
device,
})
}
pub fn config(&self) -> &VisionConfig {
&self.config
}
/// Number of LM tokens this tower emits for an `(H, W)` pixel
/// image after the merger. Equal to
/// `(H / patch_size / spatial_merge_size) * (W / patch_size / spatial_merge_size)`.
pub fn lm_tokens_for(&self, h: u32, w: u32) -> usize {
let m = self.config.spatial_merge_size;
let patch = self.config.patch_size;
let gh = (h as usize) / patch / m;
let gw = (w as usize) / patch / m;
gh * gw * LM_TOKENS_PER_MERGE_GROUP
}
/// Bilinearly interpolate the learned `pos_embed` grid (a
/// `num_grid_per_side × num_grid_per_side` table, 48×48 for Qwen3.6)
/// onto the actual `gh × gw` patch grid, in **row-major** patch
/// order. Port of the HF `fast_pos_embed_interpolate`: for each patch
/// at fractional grid coord `(linspace(0, ngrid-1, gh)[hi],
/// linspace(0, ngrid-1, gw)[wi])`, blend the 4 surrounding grid
/// entries by bilinear weights. Returns `(gh*gw, hidden)` in
/// `self.dtype`.
fn interpolated_pos_embed(&self, gh: usize, gw: usize) -> Result<Tensor> {
let ngrid = (self.config.num_position_embeddings as f64).sqrt().round() as usize;
anyhow::ensure!(
ngrid * ngrid == self.config.num_position_embeddings,
"num_position_embeddings {} is not a perfect square",
self.config.num_position_embeddings
);
// Evenly-spaced fractional indices into the [0, ngrid-1] grid.
let lin = |n: usize| -> Vec<f64> {
if n <= 1 {
vec![0.0]
} else {
let step = (ngrid - 1) as f64 / (n - 1) as f64;
(0..n).map(|i| i as f64 * step).collect()
}
};
let hs = lin(gh);
let ws = lin(gw);
let n = gh * gw;
// Four corner index sets + bilinear weight sets, row-major.
let mut idx: [Vec<u32>; 4] = [
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
];
let mut wts: [Vec<f32>; 4] = [
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
];
for &hv in &hs {
let hf = hv as usize; // floor (hv >= 0)
let hc = (hf + 1).min(ngrid - 1);
let dh = (hv - hf as f64) as f32;
for &wv in &ws {
let wf = wv as usize;
let wc = (wf + 1).min(ngrid - 1);
let dw = (wv - wf as f64) as f32;
idx[0].push((hf * ngrid + wf) as u32);
wts[0].push((1.0 - dh) * (1.0 - dw));
idx[1].push((hf * ngrid + wc) as u32);
wts[1].push((1.0 - dh) * dw);
idx[2].push((hc * ngrid + wf) as u32);
wts[2].push(dh * (1.0 - dw));
idx[3].push((hc * ngrid + wc) as u32);
wts[3].push(dh * dw);
}
}
let mut acc: Option<Tensor> = None;
for corner in 0..4 {
let idx_t = Tensor::from_vec(std::mem::take(&mut idx[corner]), (n,), &self.device)?;
let emb = self.pos_embed.forward(&idx_t)?; // (n, hidden), pos_embed dtype
let wt = Tensor::from_vec(std::mem::take(&mut wts[corner]), (n, 1), &self.device)?
.to_dtype(self.dtype)?;
let term = emb.broadcast_mul(&wt)?;
acc = Some(match acc {
Some(a) => a.add(&term)?,
None => term,
});
}
Ok(acc.expect("4 corners accumulated"))
}
/// Encode one image.
///
/// `image`: row-major `(3, H, W)` f32 tensor on `self.device`,
/// already normalised by `preprocess::preprocess`. Both `H` and
/// `W` must be multiples of `patch_size * spatial_merge_size`.
///
/// Returns `(N_lm, out_hidden_size)` — LM-side image tokens
/// ready to splice into the language model's input embeddings.
pub fn forward(&self, image: &Tensor) -> Result<Tensor> {
let (c, h, w) = image.dims3()?;
anyhow::ensure!(
c == self.config.in_channels,
"image must have {} channels, got {c}",
self.config.in_channels
);
let patch = self.config.patch_size;
anyhow::ensure!(
h.is_multiple_of(patch) && w.is_multiple_of(patch),
"image dims must be multiples of patch_size={patch}; got ({h}, {w})"
);
let gh = h / patch;
let gw = w / patch;
let n_patches = gh * gw;
anyhow::ensure!(
n_patches <= self.config.num_position_embeddings,
"patch count {n_patches} exceeds pos_embed budget {}",
self.config.num_position_embeddings
);
// Add batch axis for conv: (1, 3, H, W) → (1, hidden, gh, gw)
// → (hidden, gh, gw) → permute to (gh, gw, hidden) → flatten to (N, hidden)
let x = image.unsqueeze(0)?.to_dtype(self.dtype)?;
let x = self.patch_embed.forward(&x)?;
let x = x.squeeze(0)?;
let x = x.permute((1, 2, 0))?.contiguous()?;
let x = x.reshape((n_patches, self.config.hidden_size))?;
// Learned absolute position embeddings. The `pos_embed` table is
// a `num_position_embeddings = num_grid_per_side²` learned grid
// (48×48 for Qwen3.6); for a `gh×gw` patch grid the reference
// (`fast_pos_embed_interpolate`) bilinearly interpolates that
// grid to `gh×gw`. The legacy path (a naive sequential lookup of
// the first `n_patches` rows) mis-maps the grid stride and
// scrambles spatial structure — kept only behind
// `NEURON_VISION_LEGACY_POS=1` for A/B comparison.
let pos = if vision_legacy_pos() {
let positions = Tensor::arange(0u32, n_patches as u32, &self.device)?;
self.pos_embed.forward(&positions)?
} else {
self.interpolated_pos_embed(gh, gw)?
};
let mut x = x.add(&pos)?;
// 2D vision rotary (row/col per patch), computed once and applied
// in every block's attention. Legacy escape hatch skips it.
let rotary = if vision_legacy_rope() {
None
} else {
Some(self.rotary.cos_sin(gh, gw, &self.device, self.dtype)?)
};
let rotary_ref = rotary.as_ref();
for (i, block) in self.blocks.iter().enumerate() {
x = block
.forward(&x, rotary_ref)
.with_context(|| format!("vision block {i}"))?;
}
// (n_patches, hidden) → (gh, gw, hidden) for the merger.
let x = x.reshape((gh, gw, self.config.hidden_size))?;
self.merger.forward(&x)
}
}
/// Manually load a candle_nn LayerNorm from a ShardedVarBuilder.
/// candle_nn's `layer_norm` builder takes `crate::VarBuilder`, not
/// `ShardedVarBuilder`, so the existing arch modules in this crate
/// uniformly do the manual load + struct construction pattern (see
/// `full_attn::load_linear_no_bias`). We follow suit here.
fn layer_norm(vb: ShardedVarBuilder, size: usize) -> Result<LayerNorm> {
let weight = vb
.get(size, "weight")
.with_context(|| format!("load LayerNorm.weight at '{}'", vb.prefix()))?;
let bias = vb
.get(size, "bias")
.with_context(|| format!("load LayerNorm.bias at '{}'", vb.prefix()))?;
Ok(LayerNorm::new(weight, bias, LAYER_NORM_EPS))
}
/// Manually load a candle_nn Linear (with bias) from a
/// ShardedVarBuilder. Same rationale as `layer_norm` above.
fn linear(vb: ShardedVarBuilder, in_dim: usize, out_dim: usize) -> Result<Linear> {
let weight = vb
.get((out_dim, in_dim), "weight")
.with_context(|| format!("load Linear.weight at '{}'", vb.prefix()))?;
let bias = vb
.get(out_dim, "bias")
.with_context(|| format!("load Linear.bias at '{}'", vb.prefix()))?;
Ok(Linear::new(weight, Some(bias)))
}
/// PyTorch's `gelu_pytorch_tanh` approximation — what the Qwen3.6
/// vision tower's `hidden_act` specifies. candle's `Tensor::gelu`
/// uses the exact erf-based GELU, so we compute the tanh
/// approximation explicitly:
///
/// ```text
/// gelu_tanh(x) = 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
/// ```
fn gelu_tanh(x: &Tensor) -> Result<Tensor> {
// sqrt(2 / pi) = 0.7978845608028654
const COEFF: f64 = 0.7978845608028654;
const KAPPA: f64 = 0.044715;
let x3 = x.powf(3.0)?;
let inner = (x + (x3 * KAPPA)?)?;
let inner = (inner * COEFF)?;
let t = inner.tanh()?;
let one_plus_t = (t + 1.0)?;
let out = (x * 0.5)?;
let out = out.broadcast_mul(&one_plus_t)?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::{DType, Device};
/// Build a tiny VisionConfig usable on CPU with random weights.
/// Match the Qwen3.6 shape relations (depth-N stack, hidden mod
/// num_heads, intermediate_size > hidden_size) but with small
/// dims so tests run in milliseconds.
fn tiny_config() -> VisionConfig {
VisionConfig {
depth: 2,
hidden_size: 32,
intermediate_size: 64,
num_heads: 4,
num_position_embeddings: 64,
patch_size: 4,
temporal_patch_size: 2,
spatial_merge_size: 2,
in_channels: 3,
out_hidden_size: 48,
}
}
/// Hand-construct a VisionTower with random weights. This is the
/// same trick `linear_attn::tests::forward_smoke_with_tiny_dimensions`
/// uses — bypass the safetensors-backed `ShardedVarBuilder` path
/// (which can't be built from in-memory tensors) and assemble the
/// struct fields directly. The real `VisionTower::load` is
/// exercised by the cuda-integration smoke test in Stage A6.
fn tiny_tower(cfg: &VisionConfig) -> VisionTower {
let device = Device::Cpu;
let dtype = DType::F32;
let zeros = |shape: &[usize]| Tensor::zeros(shape, dtype, &device).unwrap();
let ones = |shape: &[usize]| Tensor::ones(shape, dtype, &device).unwrap();
let randn = |shape: &[usize]| Tensor::randn(0_f32, 0.02, shape, &device).unwrap();
let patch_embed = Conv2d::new(
randn(&[
cfg.hidden_size,
cfg.in_channels,
cfg.patch_size,
cfg.patch_size,
]),
Some(zeros(&[cfg.hidden_size])),
Conv2dConfig {
stride: cfg.patch_size,
..Default::default()
},
);
let pos_embed = Embedding::new(
randn(&[cfg.num_position_embeddings, cfg.hidden_size]),
cfg.hidden_size,
);
let mut blocks = Vec::with_capacity(cfg.depth);
for _ in 0..cfg.depth {
let head_dim = cfg.hidden_size / cfg.num_heads;
blocks.push(VisionBlock {
norm1: LayerNorm::new(
ones(&[cfg.hidden_size]),
zeros(&[cfg.hidden_size]),
LAYER_NORM_EPS,
),
qkv: Linear::new(
randn(&[3 * cfg.hidden_size, cfg.hidden_size]),
Some(zeros(&[3 * cfg.hidden_size])),
),
proj: Linear::new(
randn(&[cfg.hidden_size, cfg.hidden_size]),
Some(zeros(&[cfg.hidden_size])),
),
norm2: LayerNorm::new(
ones(&[cfg.hidden_size]),
zeros(&[cfg.hidden_size]),
LAYER_NORM_EPS,
),
fc1: Linear::new(
randn(&[cfg.intermediate_size, cfg.hidden_size]),
Some(zeros(&[cfg.intermediate_size])),
),
fc2: Linear::new(
randn(&[cfg.hidden_size, cfg.intermediate_size]),
Some(zeros(&[cfg.hidden_size])),
),
num_heads: cfg.num_heads,
head_dim,
});
}
let merge_input_dim = cfg.hidden_size * cfg.spatial_merge_size * cfg.spatial_merge_size;
let merger = VisionMerger {
norm: LayerNorm::new(
ones(&[cfg.hidden_size]),
zeros(&[cfg.hidden_size]),
LAYER_NORM_EPS,
),
fc1: Linear::new(
randn(&[merge_input_dim, merge_input_dim]),
Some(zeros(&[merge_input_dim])),
),
fc2: Linear::new(
randn(&[cfg.out_hidden_size, merge_input_dim]),
Some(zeros(&[cfg.out_hidden_size])),
),
merge_input_dim,
spatial_merge_size: cfg.spatial_merge_size,
};
let rotary = VisionRotaryEmbedding::new(cfg.hidden_size / cfg.num_heads);
VisionTower {
patch_embed,
pos_embed,
rotary,
blocks,
merger,
config: cfg.clone(),
dtype,
device,
}
}
#[test]
fn forward_with_random_weights_produces_finite_output() {
let cfg = tiny_config();
let tower = tiny_tower(&cfg);
// 16×16 image at patch_size=4 → 4×4 patches → after 2×2
// merge → 2×2 = 4 LM tokens of dim out_hidden_size.
let image = Tensor::randn(0_f32, 1.0, (3, 16, 16), &Device::Cpu).unwrap();
let out = tower.forward(&image).expect("forward");
let (n_lm, hidden) = out.dims2().unwrap();
assert_eq!(n_lm, 4);
assert_eq!(hidden, cfg.out_hidden_size);
// No NaN/Inf
let values: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert!(
values.iter().all(|v| v.is_finite()),
"forward must produce finite values"
);
}
#[test]
fn interpolated_pos_embed_reduces_to_sequential_at_native_grid() {
// When the patch grid equals the pos_embed grid (gh=gw=ngrid),
// linspace(0,ngrid-1,ngrid) is the integer ladder, so every patch
// lands exactly on a grid node (dh=dw=0, corner-0 weight 1) and
// the bilinear result is the raw pos_embed rows in row-major
// order — i.e. identical to the legacy sequential lookup.
let cfg = tiny_config();
let tower = tiny_tower(&cfg);
let ngrid = (cfg.num_position_embeddings as f64).sqrt() as usize; // 8
let interp = tower.interpolated_pos_embed(ngrid, ngrid).unwrap();
let seq = tower
.pos_embed
.forward(&Tensor::arange(0u32, (ngrid * ngrid) as u32, &Device::Cpu).unwrap())
.unwrap();
let a: Vec<f32> = interp.flatten_all().unwrap().to_vec1().unwrap();
let b: Vec<f32> = seq.flatten_all().unwrap().to_vec1().unwrap();
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(b.iter()) {
assert!((x - y).abs() < 1e-5, "interp {x} vs seq {y}");
}
}
#[test]
fn vision_rotary_row_col_structure() {
// head_dim 8 → rotary dim 4 → inv_freq over [0,2] → 2 freqs/axis.
let rot = VisionRotaryEmbedding::new(8);
assert_eq!(rot.inv_freq.len(), 2);
let (cos, sin) = rot.cos_sin(2, 2, &Device::Cpu, DType::F32).unwrap();
assert_eq!(cos.dims(), &[4, 4]); // 4 patches, head_dim/2 = 4 cols
// Patch (0,0): all freqs 0 → cos 1, sin 0.
let s0: Vec<f32> = sin.i(0).unwrap().to_vec1().unwrap();
assert!(s0.iter().all(|&s| s.abs() < 1e-6));
// Patch index 2 = grid (1,0): row=1 drives the first half, col=0
// leaves the second half at zero.
let s2: Vec<f32> = sin.i(2).unwrap().to_vec1().unwrap();
assert!(s2[0].abs() > 1e-6, "row half must be non-zero");
assert!(
s2[2].abs() < 1e-6 && s2[3].abs() < 1e-6,
"col half must be zero"
);
}
#[test]
fn lm_token_count_matches_grid() {
let cfg = tiny_config();
let tower = tiny_tower(&cfg);
// 16x16 image → 4x4 patches → 2x2 = 4 LM tokens
assert_eq!(tower.lm_tokens_for(16, 16), 4);
// 32x32 image → 8x8 patches → 4x4 = 16 LM tokens
assert_eq!(tower.lm_tokens_for(32, 32), 16);
}
#[test]
fn rejects_image_with_dims_not_multiple_of_patch() {
let cfg = tiny_config();
let tower = tiny_tower(&cfg);
let image = Tensor::randn(0_f32, 1.0, (3, 17, 17), &Device::Cpu).unwrap();
let err = tower.forward(&image).unwrap_err();
assert!(format!("{err:#}").contains("patch_size"));
}
#[test]
fn rejects_image_with_wrong_channel_count() {
let cfg = tiny_config();
let tower = tiny_tower(&cfg);
let image = Tensor::randn(0_f32, 1.0, (4, 16, 16), &Device::Cpu).unwrap();
let err = tower.forward(&image).unwrap_err();
assert!(format!("{err:#}").contains("channels"));
}
#[test]
fn gelu_tanh_matches_known_values() {
// Reference values for gelu_pytorch_tanh from PyTorch:
// gelu_tanh(0.0) = 0.0
// gelu_tanh(1.0) ≈ 0.8411920071
// gelu_tanh(-1.0) ≈ -0.1588079929
let x = Tensor::new(&[0.0_f32, 1.0, -1.0], &Device::Cpu).unwrap();
let y = gelu_tanh(&x).unwrap();
let v: Vec<f32> = y.to_vec1().unwrap();
assert!((v[0]).abs() < 1e-6, "gelu_tanh(0) ≈ 0, got {}", v[0]);
assert!(
(v[1] - 0.841_192_f32).abs() < 1e-5,
"gelu_tanh(1) ≈ 0.84119, got {}",
v[1]
);
assert!(
(v[2] - -0.158_808_f32).abs() < 1e-5,
"gelu_tanh(-1) ≈ -0.15881, got {}",
v[2]
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,562 @@
//! Chat-template rendering for the model-supplied Jinja templates
//! HuggingFace tokenizers ship in `tokenizer_config.json`.
//!
//! ## Background
//!
//! Every modern open-weight model bundles a `chat_template` field
//! in its `tokenizer_config.json` — a Jinja2 template string that
//! converts a sequence of `{role, content}` messages into the
//! exact prompt the model was trained on. Examples:
//!
//! - Qwen3-Coder: `<|im_start|>{role}\n{content}<|im_end|>\n…`
//! with conditional `enable_thinking` handling that injects an
//! empty `<think>\n\n</think>` block when set false.
//! - DeepSeek-R1: similar im_start framing with different special-
//! token names.
//! - Mistral / Magistral: a `[INST]` / `[/INST]` framing.
//! - Claude / Llama: another shape again.
//!
//! Rendering the model's own template is the only way to get the
//! *exact* prompt format the model was trained on plus the
//! model-specific kwargs (`enable_thinking`, `tools`, …) without
//! hardcoding per-model logic. The alternative — neuron's previous
//! `format_qwen3_prompt` — was a hardcoded Qwen3 ChatML glue that
//! ignored kwargs entirely.
//!
//! ## Scope
//!
//! This module is request-side only: it builds the prompt string
//! the tokenizer ingests before inference. The reasoning- and
//! tool-call-marker token routing (issues #6, #8) is response-side
//! and stays in `wire::openai_chat` / the streaming inference
//! loops.
//!
//! ## Fallback
//!
//! When the model's `tokenizer_config.json` is missing, doesn't
//! parse, lacks a `chat_template`, or renders an error, the caller
//! falls back to `format_qwen3_prompt`. The
//! `NEURON_USE_CHAT_TEMPLATE=false` env var is a global kill
//! switch — if a deploy goes sideways and the renderer is to
//! blame, an operator can flip the env and restart neuron without
//! shipping a new build.
use anyhow::{Context, Result};
use cortex_core::openai::{ChatMessage, MessageContent};
use minijinja::{Environment, Error as MjError, ErrorKind as MjErrorKind, Value as MjValue};
use serde_json::Value;
use std::path::Path;
/// Environment variable that, when set to `false`/`0`/`no`,
/// forces every model to skip its `chat_template` and fall back
/// to `format_qwen3_prompt`. Default (unset) is "use chat
/// templates where available".
pub const KILL_SWITCH_ENV: &str = "NEURON_USE_CHAT_TEMPLATE";
/// Read the global kill switch. `true` means chat templates are
/// enabled; `false` forces the fallback path everywhere.
pub fn chat_templates_enabled() -> bool {
match std::env::var(KILL_SWITCH_ENV).ok().as_deref() {
Some(s) => !matches!(
s.trim().to_ascii_lowercase().as_str(),
"false" | "0" | "no" | "off"
),
None => true,
}
}
/// Probe for the model's chat template in the same directory the
/// tokenizer was loaded from, following HuggingFace `transformers`
/// precedence: a standalone `chat_template.jinja` (then
/// `chat_template.json`) wins over the `chat_template` field in
/// `tokenizer_config.json`.
///
/// This matters for multimodal models: Qwen3-VL / Qwen3.6 ship their
/// vision-aware template (the one that emits
/// `<|vision_start|><|image_pad|><|vision_end|>` per image) **only** in
/// `chat_template.jinja`, and may not ship a `tokenizer_config.json` at
/// all. Reading `tokenizer_config.json` alone returned `None`, which
/// dropped image content into the text-only `format_qwen3_prompt`
/// fallback — so image requests rendered zero `<|image_pad|>` tokens
/// and the vision path bailed on the count mismatch.
pub fn load_chat_template_alongside(tokenizer_json_path: &Path) -> Option<String> {
let parent = tokenizer_json_path.parent()?;
// 1. Standalone Jinja file — raw template text, highest priority.
let jinja_path = parent.join("chat_template.jinja");
match std::fs::read_to_string(&jinja_path) {
Ok(text) if !text.trim().is_empty() => {
tracing::info!(
path = %jinja_path.display(),
"chat_template: loaded standalone chat_template.jinja"
);
return Some(text);
}
Ok(_) => {
tracing::warn!(
path = %jinja_path.display(),
"chat_template: chat_template.jinja present but empty; trying other sources"
);
}
Err(_) => {} // absent — fall through, common case
}
// 2. Standalone JSON file — `{"chat_template": "..."}` form.
let json_path = parent.join("chat_template.json");
if json_path.exists()
&& let Some(t) = load_chat_template_from(&json_path)
{
tracing::info!(
path = %json_path.display(),
"chat_template: loaded standalone chat_template.json"
);
return Some(t);
}
// 3. The `chat_template` field inside tokenizer_config.json.
let config_path = parent.join("tokenizer_config.json");
load_chat_template_from(&config_path)
}
/// Best-effort load of `chat_template` from a HuggingFace
/// `tokenizer_config.json`. Returns `None` when the file is
/// absent, doesn't parse, or lacks the `chat_template` field —
/// in all of those cases the caller falls back to
/// `format_qwen3_prompt`. Warnings are logged so an operator can
/// see why the fallback fired.
pub fn load_chat_template_from(path: &Path) -> Option<String> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::debug!(
path = %path.display(),
error = %e,
"chat_template: tokenizer_config.json absent or unreadable; falling back"
);
return None;
}
};
let value: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"chat_template: tokenizer_config.json failed to parse; falling back"
);
return None;
}
};
// Some tokenizer_config.json files carry `chat_template` as an
// array of `{name, template}` objects (multi-template models —
// tool-use variant, default variant). For now we pick the first
// entry; future iterations could honour a name hint.
match value.get("chat_template") {
Some(Value::String(s)) => Some(s.clone()),
Some(Value::Array(arr)) => {
for entry in arr {
if let Some(t) = entry.get("template").and_then(|v| v.as_str()) {
return Some(t.to_string());
}
}
tracing::warn!(
path = %path.display(),
"chat_template: array form had no usable template entry; falling back"
);
None
}
_ => None,
}
}
/// Render the chat template into the prompt the model expects.
///
/// `template` is the raw Jinja string from `tokenizer_config.json`.
/// `messages` is the conversation in order. `kwargs` is the
/// `chat_template_kwargs` object the client supplied on the
/// request (or `Value::Null` when absent). The function expands
/// the kwargs into the Jinja context alongside the standard
/// `messages` and `add_generation_prompt` variables HF templates
/// expect.
///
/// `tools` is the request's `tools` array (or `Value::Null`).
/// Some chat templates iterate it to emit native tool definitions
/// (Qwen3-Coder's tool-use template, Mistral's [TOOL_DEFINITIONS]
/// frame). We forward whatever the client sent without
/// interpretation.
pub fn render_chat_template(
template: &str,
messages: &[ChatMessage],
tools: &Value,
kwargs: &Value,
) -> Result<String> {
let mut env = Environment::new();
// HF chat templates are authored against Python's Jinja2 with its
// string semantics. Bridge the two so real model templates render:
//
// - `pycompat::unknown_method_callback` supplies Python str/list/dict
// methods minijinja lacks natively (`startswith`, `endswith`,
// `split`, `rstrip`, `lstrip`, …) — the Qwen3.6 template uses
// several in its think-block and tool-response handling.
// - `raise_exception` is the global HF templates call to reject
// malformed inputs (e.g. an image in a system message). Map it to
// a render error so the caller falls back / surfaces it.
env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
env.add_function(
"raise_exception",
|msg: String| -> Result<MjValue, MjError> {
Err(MjError::new(MjErrorKind::InvalidOperation, msg))
},
);
// Compile the template against a fixed name so error messages
// surface "chat_template" rather than `<template>`.
env.add_template("chat_template", template)
.context("compile chat_template")?;
let tmpl = env.get_template("chat_template").unwrap();
// Convert our internal ChatMessage shape into the
// `[{role, content}]` shape HF templates iterate. Text content
// becomes a string; Parts becomes an array of content blocks.
// The HF templates handle both shapes via `content is string`
// checks or content-array iteration.
let messages_json: Vec<Value> = messages
.iter()
.map(|m| {
let content_value = match &m.content {
MessageContent::Text(s) => Value::String(s.clone()),
MessageContent::Parts(parts) => Value::Array(parts.clone()),
};
let mut obj = serde_json::Map::new();
obj.insert("role".into(), Value::String(m.role.clone()));
obj.insert("content".into(), content_value);
// Forward extras (e.g. tool_calls on assistant turns,
// tool_call_id on tool result turns). HF templates that
// need them read e.g. `message.tool_calls`.
if let Value::Object(extras) = &m.extra {
for (k, v) in extras {
obj.insert(k.clone(), v.clone());
}
}
Value::Object(obj)
})
.collect();
// Build the kwargs context. Add base bindings the template
// expects (`messages`, `add_generation_prompt`, `tools`) plus
// anything the caller passed in `chat_template_kwargs`. Caller
// kwargs override the defaults so `add_generation_prompt: false`
// from the request actually wins.
let mut ctx_map = serde_json::Map::new();
ctx_map.insert("messages".into(), Value::Array(messages_json));
ctx_map.insert("add_generation_prompt".into(), Value::Bool(true));
if !tools.is_null() {
ctx_map.insert("tools".into(), tools.clone());
}
if let Value::Object(kwargs_obj) = kwargs {
for (k, v) in kwargs_obj {
ctx_map.insert(k.clone(), v.clone());
}
}
// `Template::render` takes any Serialize value; serde_json's
// `Value` implements it natively, so we pass the assembled
// context object directly without going through the
// `context!` macro (which expects minijinja-native values).
tmpl.render(Value::Object(ctx_map))
.context("render chat_template")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Reproduces the Qwen3.6 vision template's image-insertion
/// condition against the OpenAI `image_url` content-part shape our
/// renderer forwards. Confirms minijinja's `'image_url' in item`
/// matches a serde_json object that carries that key — i.e. the
/// template *can* emit `<|image_pad|>` for our parts.
#[test]
fn image_url_part_renders_image_pad() {
// Condition copied from doc/vision-qwen3_6-spec.md (lines 8-18
// of the real chat_template.jinja).
let template = "{%- for message in messages -%}\
{%- if message.content is string -%}\
{{ message.content }}\
{%- else -%}\
{%- for item in message.content -%}\
{%- if 'image' in item or 'image_url' in item or item.type == 'image' -%}\
<|vision_start|><|image_pad|><|vision_end|>\
{%- elif item.type == 'text' -%}\
{{ item.text }}\
{%- endif -%}\
{%- endfor -%}\
{%- endif -%}\
{%- endfor -%}";
let messages = vec![ChatMessage {
role: "user".into(),
content: MessageContent::Parts(vec![
json!({"type": "text", "text": "what is this?"}),
json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA="}}),
]),
extra: Value::Object(Default::default()),
}];
let out = render_chat_template(template, &messages, &Value::Null, &Value::Null)
.expect("render should succeed");
assert!(
out.contains("<|image_pad|>"),
"expected the image_url part to emit <|image_pad|>; rendered: {out:?}"
);
}
/// `chat_template.jinja` must win over `tokenizer_config.json`'s
/// `chat_template` field — the transformers precedence Qwen3.6
/// relies on (its vision template ships only in the `.jinja` file).
#[test]
fn standalone_jinja_template_takes_precedence() {
let dir = std::env::temp_dir().join(format!(
"neuron_ct_precedence_{}_{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("chat_template.jinja"), "FROM_JINJA").unwrap();
std::fs::write(
dir.join("tokenizer_config.json"),
r#"{"chat_template": "FROM_CONFIG"}"#,
)
.unwrap();
// tokenizer_json_path is the sibling the loader takes a parent of.
let got = load_chat_template_alongside(&dir.join("tokenizer.json"));
std::fs::remove_dir_all(&dir).ok();
assert_eq!(got.as_deref(), Some("FROM_JINJA"));
}
/// With no standalone file, fall back to the tokenizer_config.json
/// field — the text-only path stays unchanged.
#[test]
fn falls_back_to_tokenizer_config_when_no_standalone() {
let dir = std::env::temp_dir().join(format!(
"neuron_ct_fallback_{}_{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("tokenizer_config.json"),
r#"{"chat_template": "FROM_CONFIG"}"#,
)
.unwrap();
let got = load_chat_template_alongside(&dir.join("tokenizer.json"));
std::fs::remove_dir_all(&dir).ok();
assert_eq!(got.as_deref(), Some("FROM_CONFIG"));
}
/// The *actual* Qwen3.6-27B `chat_template.jinja` (verbatim from
/// beast's HF cache) must render in minijinja and emit exactly one
/// `<|image_pad|>` for a text+image user turn. This is the real
/// end-to-end check the unit tests above only approximate — it
/// catches any minijinja incompatibility (namespace, macros,
/// reverse slice, string methods) before it reaches production.
#[test]
fn real_qwen3_6_template_renders_one_image_pad() {
let template = include_str!("testdata/qwen3_6_chat_template.jinja");
let messages = vec![ChatMessage {
role: "user".into(),
content: MessageContent::Parts(vec![
json!({"type": "text", "text": "what is this?"}),
json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA="}}),
]),
extra: Value::Object(Default::default()),
}];
let out = render_chat_template(template, &messages, &Value::Null, &Value::Null)
.expect("real Qwen3.6 template should render in minijinja");
let pads = out.matches("<|image_pad|>").count();
assert_eq!(
pads, 1,
"expected exactly one <|image_pad|>; rendered:\n{out}"
);
assert!(out.contains("<|vision_start|>") && out.contains("<|vision_end|>"));
}
fn user_msg(text: &str) -> ChatMessage {
ChatMessage {
role: "user".into(),
content: MessageContent::Text(text.into()),
extra: Value::Object(Default::default()),
}
}
fn assistant_msg(text: &str) -> ChatMessage {
ChatMessage {
role: "assistant".into(),
content: MessageContent::Text(text.into()),
extra: Value::Object(Default::default()),
}
}
/// Minimal Qwen3-style template — enough surface to confirm
/// our renderer threads role + content correctly without
/// loading a real model's tokenizer_config.json.
const QWEN3_LIKE: &str = "{%- for message in messages -%}\
<|im_start|>{{ message.role }}\n{{ message.content }}<|im_end|>\n\
{%- endfor -%}\
{%- if add_generation_prompt -%}<|im_start|>assistant\n{%- endif -%}";
#[test]
fn renders_basic_conversation() {
let prompt = render_chat_template(
QWEN3_LIKE,
&[user_msg("hello"), assistant_msg("hi"), user_msg("bye")],
&Value::Null,
&Value::Null,
)
.unwrap();
// Structural assertions — the exact whitespace produced
// by a given template is a Jinja-trim concern that varies
// per real chat_template. What matters is that every
// turn's role + content thread through in order, and that
// the generation cue lands at the end.
assert!(
prompt.contains("<|im_start|>user\nhello<|im_end|>"),
"first user turn missing: {prompt}"
);
assert!(
prompt.contains("<|im_start|>assistant\nhi<|im_end|>"),
"assistant turn missing: {prompt}"
);
assert!(
prompt.contains("<|im_start|>user\nbye<|im_end|>"),
"second user turn missing: {prompt}"
);
assert!(
prompt.ends_with("<|im_start|>assistant")
|| prompt.ends_with("<|im_start|>assistant\n"),
"generation cue missing at end: {prompt}"
);
}
#[test]
fn kwargs_are_threaded_into_template_context() {
// Replica of Qwen3's enable_thinking branch in
// simplified form. When the kwarg is false, the model's
// template injects an empty `<think>...</think>` block
// before the generation cue — pre-filling the model's
// reasoning slot with "no thinking" so the model emits
// the answer directly.
let template = "{%- if enable_thinking is defined and enable_thinking is false -%}\
NO_THINK\
{%- else -%}\
THINK_OK\
{%- endif -%}";
let r_disabled = render_chat_template(
template,
&[],
&Value::Null,
&json!({ "enable_thinking": false }),
)
.unwrap();
assert_eq!(r_disabled, "NO_THINK");
let r_default = render_chat_template(template, &[], &Value::Null, &Value::Null).unwrap();
assert_eq!(r_default, "THINK_OK");
}
#[test]
fn missing_template_field_returns_none() {
let tmp = std::env::temp_dir().join("neuron-test-tokenizer-missing-field.json");
std::fs::write(&tmp, r#"{"some_other_field": 1}"#).unwrap();
assert!(load_chat_template_from(&tmp).is_none());
let _ = std::fs::remove_file(tmp);
}
#[test]
fn load_template_from_string_field() {
let tmp = std::env::temp_dir().join("neuron-test-tokenizer-string.json");
std::fs::write(
&tmp,
r#"{"chat_template": "hello {{ messages[0].content }}"}"#,
)
.unwrap();
let t = load_chat_template_from(&tmp).expect("template loaded");
assert!(t.contains("messages[0].content"));
let _ = std::fs::remove_file(tmp);
}
#[test]
fn load_template_from_array_form() {
// Some HF models ship `chat_template` as `[{name, template}, ...]`.
let tmp = std::env::temp_dir().join("neuron-test-tokenizer-array.json");
std::fs::write(
&tmp,
r#"{"chat_template": [{"name": "default", "template": "ARR"}]}"#,
)
.unwrap();
let t = load_chat_template_from(&tmp).expect("template loaded");
assert_eq!(t, "ARR");
let _ = std::fs::remove_file(tmp);
}
#[test]
fn missing_file_returns_none_quietly() {
let absent = std::path::PathBuf::from("/definitely/not/a/real/path.json");
assert!(load_chat_template_from(&absent).is_none());
}
#[test]
fn unparseable_returns_none() {
let tmp = std::env::temp_dir().join("neuron-test-tokenizer-garbage.json");
std::fs::write(&tmp, b"{not valid json").unwrap();
assert!(load_chat_template_from(&tmp).is_none());
let _ = std::fs::remove_file(tmp);
}
#[test]
fn kill_switch_recognises_truthy_falsy_values() {
// Test against the actual env var so callers see the
// same behaviour as production. Serialise via a
// mutex — see path_util.rs for the pattern.
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap();
let prior = std::env::var(KILL_SWITCH_ENV).ok();
unsafe {
std::env::remove_var(KILL_SWITCH_ENV);
}
assert!(chat_templates_enabled());
for value in ["false", "0", "no", "off", "FALSE", " no "] {
unsafe { std::env::set_var(KILL_SWITCH_ENV, value) };
assert!(!chat_templates_enabled(), "value {value:?} should disable");
}
for value in ["true", "1", "yes", ""] {
unsafe { std::env::set_var(KILL_SWITCH_ENV, value) };
assert!(chat_templates_enabled(), "value {value:?} should enable");
}
unsafe {
match prior {
Some(p) => std::env::set_var(KILL_SWITCH_ENV, p),
None => std::env::remove_var(KILL_SWITCH_ENV),
}
}
}
#[test]
fn message_extras_thread_through_for_tool_calls() {
// HF templates read assistant.tool_calls and tool
// turns' tool_call_id. Confirm our extras flatten into
// the message object the template iterates.
let mut extras = serde_json::Map::new();
extras.insert(
"tool_calls".into(),
json!([{"id": "t1", "function": {"name": "x", "arguments": "{}"}}]),
);
let msg = ChatMessage {
role: "assistant".into(),
content: MessageContent::Text(String::new()),
extra: Value::Object(extras),
};
let template = "{{ messages[0].tool_calls[0].id }}";
let rendered = render_chat_template(template, &[msg], &Value::Null, &Value::Null).unwrap();
assert_eq!(rendered, "t1");
}
}

View File

@@ -16,10 +16,11 @@
use crate::harness::candle::ModelArch; use crate::harness::candle::ModelArch;
#[cfg(feature = "cuda")] #[cfg(feature = "cuda")]
use crate::harness::device_worker::jobs::TpHandle; use crate::harness::device_worker::jobs::TpHandle;
use crate::harness::device_worker::jobs::{ArchHandle, Job}; use crate::harness::device_worker::jobs::{ArchHandle, ImageInput, Job};
#[cfg(feature = "cuda")] #[cfg(feature = "cuda")]
use crate::harness::tp::TpLeaderModel; use crate::harness::tp::TpLeaderModel;
use crate::harness::tp::nccl_state::NcclState; use crate::harness::tp::nccl_state::NcclState;
use anyhow::Context as _;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -158,6 +159,35 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
let result = forward_logits(&mut state, handle, &tokens, offset); let result = forward_logits(&mut state, handle, &tokens, offset);
let _ = reply.send(result); let _ = reply.send(result);
} }
Job::EncodeImage {
handle,
pixels,
c,
h,
w,
reply,
} => {
let result = encode_image(&mut state, handle, pixels, c, h, w);
let _ = reply.send(result);
}
Job::ForwardLogitsWithImages {
handle,
tokens,
offset,
images,
image_token_id,
reply,
} => {
let result = forward_logits_with_images(
&mut state,
handle,
&tokens,
offset,
images,
image_token_id,
);
let _ = reply.send(result);
}
Job::NcclInit { Job::NcclInit {
cfg, cfg,
comm_id_hex, comm_id_hex,
@@ -232,6 +262,27 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
let result = tp_forward_logits(&mut state, handle, &tokens, offset); let result = tp_forward_logits(&mut state, handle, &tokens, offset);
let _ = reply.send(result); let _ = reply.send(result);
} }
#[cfg(feature = "cuda")]
Job::TpForwardLogitsWithImages {
handle,
tokens,
offset,
image_token_id,
image_data_uris,
chunk_size,
reply,
} => {
let result = tp_forward_logits_with_images(
&mut state,
handle,
&tokens,
offset,
image_token_id,
&image_data_uris,
chunk_size,
);
let _ = reply.send(result);
}
// Handled by the matches!() check above; reaching here // Handled by the matches!() check above; reaching here
// means a Shutdown slipped past which is a bug. // means a Shutdown slipped past which is a bug.
Job::Shutdown => unreachable!("Shutdown should break above"), Job::Shutdown => unreachable!("Shutdown should break above"),
@@ -704,6 +755,61 @@ fn tp_forward_logits(
Ok(values) Ok(values)
} }
/// Image-bearing leader forward (rank 0). Preprocesses each source
/// `image_data_uris` entry through the same deterministic
/// `preprocess_data_uri` every rank runs, uploads to the leader's
/// device, encodes + splices + forwards via
/// `TpLeaderModel::forward_with_images`, and copies the `[vocab]`
/// logits to CPU. Mirrors the single-GPU `forward_logits_with_images`
/// but on the TP leader's replicated tower.
#[cfg(feature = "cuda")]
fn tp_forward_logits_with_images(
state: &mut DeviceWorkerState,
handle: TpHandle,
tokens: &[u32],
offset: usize,
image_token_id: u32,
image_data_uris: &[String],
chunk_size: usize,
) -> anyhow::Result<Vec<f32>> {
use crate::harness::preprocess::{PreprocessProfile, preprocess_data_uri};
use candle_core::{DType, Tensor};
if image_data_uris.is_empty() {
anyhow::bail!("TpForwardLogitsWithImages dispatched with zero images");
}
// Preprocess every image into a device-resident (C, H, W) tensor at
// its native-aspect resized dims (#14). Same `smart_resize` + decode
// path the subprocess workers run, so the encoded embeddings — and
// the per-image grids derived from these dims — match across ranks
// bit-for-bit.
let profile = PreprocessProfile::qwen3_6();
let mut pixels: Vec<Tensor> = Vec::with_capacity(image_data_uris.len());
for (idx, uri) in image_data_uris.iter().enumerate() {
let (px, h, w) = preprocess_data_uri(uri, &profile)
.with_context(|| format!("preprocess image[{idx}] (TP leader)"))?;
let t = Tensor::from_vec(px, (3, h as usize, w as usize), &state.device)?;
pixels.push(t);
}
let model = state.tp_models.get_mut(&handle).ok_or_else(|| {
anyhow::anyhow!(
"TpForwardLogitsWithImages: no model for handle {}",
handle.0
)
})?;
// Chunked prefill (encode once, splice per chunk) — bounded
// activation, in lockstep with the subprocess ranks.
let logits =
model.prefill_with_images_chunked(tokens, offset, &pixels, image_token_id, chunk_size)?;
let logits = logits.squeeze(0)?.squeeze(0)?;
let logits = logits.to_dtype(DType::F32)?.flatten_all()?;
let values = logits.to_vec1::<f32>()?;
Ok(values)
}
/// Forward step + copy the `[vocab]` logits to a CPU `Vec<f32>` ready /// Forward step + copy the `[vocab]` logits to a CPU `Vec<f32>` ready
/// for sampling on the async caller. The model's `device()` (CUDA or /// for sampling on the async caller. The model's `device()` (CUDA or
/// CPU) determines where the kernel runs; this fn doesn't care. /// CPU) determines where the kernel runs; this fn doesn't care.
@@ -740,6 +846,119 @@ fn forward_logits(
Ok(values) Ok(values)
} }
/// Run the LM forward with vision-tower image splicing. Stage B3.
///
/// Encodes each image through the vision tower (`VisionTower::forward`,
/// dispatched via `ModelArch::encode_image`), concatenates the
/// resulting embeddings into a single `(N_total, hidden)` tensor, and
/// passes it to `ModelArch::forward_with_vision` along with the
/// prompt-expanded `tokens`. Image embeddings never leave the device.
///
/// Returns CPU `[vocab]` logits — same shape contract as
/// `ForwardLogits` so the async sampler doesn't have to branch on the
/// presence of images.
fn forward_logits_with_images(
state: &mut DeviceWorkerState,
handle: ArchHandle,
tokens: &[u32],
offset: usize,
images: Vec<ImageInput>,
image_token_id: u32,
) -> anyhow::Result<Vec<f32>> {
use candle_core::{DType, Tensor};
if images.is_empty() {
anyhow::bail!("ForwardLogitsWithImages dispatched with zero images");
}
let arch = state.models.get_mut(&handle).ok_or_else(|| {
anyhow::anyhow!("ForwardLogitsWithImages: no model for handle {}", handle.0)
})?;
// pixel→LM-grid divisor (patch×merge) for this tower; each image's
// LM grid is (h/factor, w/factor) (#14 dynamic resolution).
let factor = arch.vision_grid_factor().ok_or_else(|| {
anyhow::anyhow!("ForwardLogitsWithImages: loaded model has no vision tower")
})?;
// Encode every image on the worker's device, collecting per-image
// post-merger embeddings as device-resident tensors plus their LM
// grids (for the interleaved-M-RoPE position ids).
let mut per_image: Vec<Tensor> = Vec::with_capacity(images.len());
let mut grids: Vec<(usize, usize)> = Vec::with_capacity(images.len());
for (idx, img) in images.into_iter().enumerate() {
anyhow::ensure!(
img.pixels.len() == img.c * img.h * img.w,
"ForwardLogitsWithImages: image[{idx}] pixels length {} does not match shape ({}, {}, {})",
img.pixels.len(),
img.c,
img.h,
img.w,
);
grids.push((img.h / factor, img.w / factor));
let image = Tensor::from_vec(img.pixels, (img.c, img.h, img.w), &state.device)?;
let embed = arch
.encode_image(&image)
.with_context(|| format!("encode image[{idx}]"))?;
per_image.push(embed);
}
// Concatenate per-image embeddings along the patch axis →
// (sum_of_patches, hidden). `Tensor::cat` keeps the result
// device-resident.
let image_embeds = Tensor::cat(&per_image.iter().collect::<Vec<_>>(), 0)?;
let input = Tensor::new(tokens, &state.device)?.unsqueeze(0)?;
let logits = arch.forward_with_vision(&input, offset, &image_embeds, image_token_id, &grids)?;
let values = logits
.to_dtype(DType::F32)?
.flatten_all()?
.to_vec1::<f32>()?;
Ok(values)
}
/// Run the vision tower on a single preprocessed image. Stage A5.
///
/// `pixels` is a row-major `(c, h, w)` f32 image that the async-side
/// `harness::preprocess` produced. We reconstruct the tensor on the
/// worker's device (the same device the model was loaded against),
/// call `arch.encode_image`, and copy the resulting
/// `(N_lm_tokens, hidden_size)` embedding back to CPU f32.
///
/// Returns the flattened embedding as a `Vec<f32>` — the caller knows
/// the LM-side token count from `VisionTower::lm_tokens_for(h, w)`
/// and reshapes accordingly. Stage B introduces a device-resident
/// embedding-slab variant that avoids this round-trip when the next
/// forward call needs the result.
fn encode_image(
state: &mut DeviceWorkerState,
handle: ArchHandle,
pixels: Vec<f32>,
c: usize,
h: usize,
w: usize,
) -> anyhow::Result<Vec<f32>> {
use candle_core::{DType, Tensor};
anyhow::ensure!(
pixels.len() == c * h * w,
"EncodeImage: pixels length {} does not match shape ({c}, {h}, {w})",
pixels.len()
);
let image = Tensor::from_vec(pixels, (c, h, w), &state.device)?;
let arch = state
.models
.get(&handle)
.ok_or_else(|| anyhow::anyhow!("EncodeImage: no model for handle {}", handle.0))?;
let embed = arch.encode_image(&image)?;
let values = embed
.to_dtype(DType::F32)?
.flatten_all()?
.to_vec1::<f32>()?;
Ok(values)
}
/// Reply to a job with the poisoned-worker error. Used when the worker /// Reply to a job with the poisoned-worker error. Used when the worker
/// has flipped into drain-only mode after a CUDA driver error. /// has flipped into drain-only mode after a CUDA driver error.
/// ///
@@ -773,6 +992,12 @@ fn drain_poisoned(job: Job, device_index: u32) {
Job::ForwardLogits { reply, .. } => { Job::ForwardLogits { reply, .. } => {
let _ = reply.send(Err(err())); let _ = reply.send(Err(err()));
} }
Job::EncodeImage { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::ForwardLogitsWithImages { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::NcclInit { reply, .. } => { Job::NcclInit { reply, .. } => {
let _ = reply.send(crate::harness::tp::rpc::WorkerResponse::Error { let _ = reply.send(crate::harness::tp::rpc::WorkerResponse::Error {
kind: "device_worker_poisoned".into(), kind: "device_worker_poisoned".into(),
@@ -801,6 +1026,10 @@ fn drain_poisoned(job: Job, device_index: u32) {
Job::TpForwardLogits { reply, .. } => { Job::TpForwardLogits { reply, .. } => {
let _ = reply.send(Err(err())); let _ = reply.send(Err(err()));
} }
#[cfg(feature = "cuda")]
Job::TpForwardLogitsWithImages { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::Shutdown => { Job::Shutdown => {
// Filtered by the matches!() guard in run(); reaching // Filtered by the matches!() guard in run(); reaching
// here would be a logic error. // here would be a logic error.

View File

@@ -28,6 +28,29 @@ pub struct ArchHandle(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TpHandle(pub u64); pub struct TpHandle(pub u64);
/// One image payload for `Job::ForwardLogitsWithImages` /
/// `Job::EncodeImage`. Pixels are row-major `(c, h, w)` f32 — the
/// shape `harness::preprocess::preprocess` produces. Carries the
/// shape inline since `Vec<f32>` is rank-1.
///
/// `Clone` so the vision-aware dispatch in `chat_completion` can
/// match `&vision_route` (carrying borrowed images) and still hand
/// owned `Vec<ImageInput>` to the worker job. The clone cost is one
/// pixel-buffer memcpy per image — now variable with dynamic resolution
/// (#14): `3 × h × w × 4` bytes, up to ~6.3 MiB at the default 1024²
/// `max_pixels` budget.
///
/// `h`/`w` are the **resized** dims (factor-aligned), so the per-image LM
/// grid is `(h/factor, w/factor)` — derived downstream for the splice
/// and the interleaved-M-RoPE position ids.
#[derive(Clone)]
pub struct ImageInput {
pub pixels: Vec<f32>,
pub c: usize,
pub h: usize,
pub w: usize,
}
/// One unit of work for the device worker. /// One unit of work for the device worker.
/// ///
/// Phase 1 had only `QueryVram` and `Shutdown`. Phase 2 adds the /// Phase 1 had only `QueryVram` and `Shutdown`. Phase 2 adds the
@@ -94,6 +117,58 @@ pub enum Job {
offset: usize, offset: usize,
reply: oneshot::Sender<Result<Vec<f32>>>, reply: oneshot::Sender<Result<Vec<f32>>>,
}, },
/// Run the LM forward with vision splicing in one round-trip.
/// Stage B3 of the vision plan.
///
/// Inputs:
/// - `tokens`: prompt-expanded token ids (the caller has already
/// replaced each `<|image_pad|>` with N copies per the
/// per-image patch count, so `tokens` already contains exactly
/// `sum(n_i)` `image_token_id` entries across all images).
/// - `offset`: KV-cache position (same contract as `ForwardLogits`).
/// - `images`: one entry per image — preprocessed pixels plus the
/// `(c, h, w)` shape. Images are encoded on the worker via the
/// model's vision tower (`VisionTower::forward`), concatenated
/// in order, and spliced into the LM input embeddings at
/// `image_token_id` positions.
/// - `image_token_id`: the sentinel token (248056 for Qwen3.6).
///
/// Returns flat CPU `[vocab]` logits, same as `ForwardLogits`.
/// Image embeddings stay device-resident — they're never copied
/// to CPU. The "tensors don't escape the worker" invariant holds.
ForwardLogitsWithImages {
handle: ArchHandle,
tokens: Vec<u32>,
offset: usize,
images: Vec<ImageInput>,
image_token_id: u32,
reply: oneshot::Sender<Result<Vec<f32>>>,
},
/// Encode one image through the model's vision tower. Stage A5 of
/// the vision plan (`doc/vision-qwen3_6-spec.md`).
///
/// `pixels` is the CPU-side preprocessed image tensor in row-major
/// `(C, H, W)` f32 layout — what `harness::preprocess::preprocess`
/// produces. `c`, `h`, `w` carry the shape since `Vec<f32>` itself
/// is rank-1. The handler reconstructs the tensor on the worker's
/// device, runs `VisionTower::forward`, copies the resulting
/// `(N_lm_tokens, hidden_size)` embedding back to CPU as a flat
/// `Vec<f32>` (the caller knows the expected shape from
/// `VisionTower::lm_tokens_for(h, w) * hidden_size`).
///
/// Mirrors the `ForwardLogits` "tensors don't escape" invariant —
/// device-side image embeddings are dropped at handler return.
/// Stage B will introduce a follow-up variant that keeps the
/// embeddings device-resident and references them from the next
/// `ForwardLogits` call, avoiding the round-trip copy.
EncodeImage {
handle: ArchHandle,
pixels: Vec<f32>,
c: usize,
h: usize,
w: usize,
reply: oneshot::Sender<Result<Vec<f32>>>,
},
/// Initialize the leader's NCCL communicator. The worker's /// Initialize the leader's NCCL communicator. The worker's
/// `NcclState` mints the `Comm` here so its underlying /// `NcclState` mints the `Comm` here so its underlying
/// `ncclComm_t` and `CudaContext` live on the same thread as /// `ncclComm_t` and `CudaContext` live on the same thread as
@@ -161,6 +236,24 @@ pub enum Job {
offset: usize, offset: usize,
reply: oneshot::Sender<Result<Vec<f32>>>, reply: oneshot::Sender<Result<Vec<f32>>>,
}, },
/// Image-bearing leader (rank 0) forward for the single-shot vision
/// prefill. The handler preprocesses each `image_data_uris` entry
/// (the same deterministic path every rank runs), encodes through
/// the leader's replicated tower, splices at `image_token_id`, and
/// returns CPU-side `[vocab]` logits. Image tensors never escape the
/// worker thread. Caller fans out `GenerateStepWithImages` to the
/// subprocess ranks and drains them; only the leader forward moves
/// here.
#[cfg(feature = "cuda")]
TpForwardLogitsWithImages {
handle: TpHandle,
tokens: Vec<u32>,
offset: usize,
image_token_id: u32,
image_data_uris: Vec<String>,
chunk_size: usize,
reply: oneshot::Sender<Result<Vec<f32>>>,
},
/// Tell the worker to break its dispatch loop and exit. Any jobs /// Tell the worker to break its dispatch loop and exit. Any jobs
/// queued after this in the channel reply `Err` to their oneshot /// queued after this in the channel reply `Err` to their oneshot
/// senders (the senders are dropped on the worker's exit, which /// senders (the senders are dropped on the worker's exit, which

View File

@@ -313,6 +313,90 @@ impl DeviceWorkerHandle {
} }
} }
/// Forward with image-aware splicing in one round-trip. Stage B3.
///
/// Encodes each image on the worker thread (device-resident), then
/// runs the LM forward with the embeddings spliced at
/// `image_token_id` positions. Returns CPU `[vocab]` logits, same
/// shape as `forward_logits`. Image embeddings never copy back to
/// CPU.
pub async fn forward_logits_with_images(
&self,
handle: ArchHandle,
tokens: Vec<u32>,
offset: usize,
images: Vec<crate::harness::device_worker::jobs::ImageInput>,
image_token_id: u32,
) -> Result<Vec<f32>, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::ForwardLogitsWithImages {
handle,
tokens,
offset,
images,
image_token_id,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// Encode a preprocessed image through the model's vision tower
/// and return the resulting LM-side image embeddings as a
/// flattened CPU `Vec<f32>`. Stage A5.
///
/// `pixels` is the row-major `(c, h, w)` f32 image —
/// `harness::preprocess::preprocess` produces this exact shape.
/// The caller knows the expected output length from
/// `VisionTower::lm_tokens_for(h, w) * hidden_size` and reshapes
/// accordingly.
pub async fn encode_image(
&self,
handle: ArchHandle,
pixels: Vec<f32>,
c: usize,
h: usize,
w: usize,
) -> Result<Vec<f32>, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::EncodeImage {
handle,
pixels,
c,
h,
w,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// Initialise the leader's NCCL communicator. The reply uses /// Initialise the leader's NCCL communicator. The reply uses
/// `WorkerResponse` (same shape subprocess workers use over stdio /// `WorkerResponse` (same shape subprocess workers use over stdio
/// RPC) so `WorkerPool::init_nccl`'s aggregation treats leader + /// RPC) so `WorkerPool::init_nccl`'s aggregation treats leader +
@@ -488,6 +572,50 @@ impl DeviceWorkerHandle {
} }
} }
/// Image-bearing TP leader forward (single-shot vision prefill).
/// Routes `Job::TpForwardLogitsWithImages` onto the worker thread;
/// the handler preprocesses + encodes + splices + forwards and
/// returns CPU-side `[vocab]` logits. The `WorkerPool` fans the
/// matching `GenerateStepWithImages` out to subprocess ranks so the
/// row-parallel collectives complete.
#[cfg(feature = "cuda")]
#[allow(clippy::too_many_arguments)]
pub async fn tp_forward_logits_with_images(
&self,
handle: TpHandle,
tokens: Vec<u32>,
offset: usize,
image_token_id: u32,
image_data_uris: Vec<String>,
chunk_size: usize,
) -> Result<Vec<f32>, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::TpForwardLogitsWithImages {
handle,
tokens,
offset,
image_token_id,
image_data_uris,
chunk_size,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// Send `Job::Shutdown` and join the thread. Idempotent — calling /// Send `Job::Shutdown` and join the thread. Idempotent — calling
/// twice is a no-op the second time. /// twice is a no-op the second time.
pub fn shutdown(&self) -> anyhow::Result<()> { pub fn shutdown(&self) -> anyhow::Result<()> {
@@ -569,6 +697,37 @@ mod tests {
handle.shutdown().expect("shutdown ok"); handle.shutdown().expect("shutdown ok");
} }
/// Stage A5: confirm the EncodeImage job round-trips through the
/// worker channel. We don't have a real loaded model in the slab
/// here, so the dispatch handler returns the
/// "no model for handle" error — which is exactly what we want to
/// see, since it proves the message routed through the channel
/// and reached the handler. Real-weights validation lives in the
/// Stage A7 / Stage B post-deploy smoke on beast.
#[tokio::test]
async fn encode_image_routes_to_dispatch_and_errors_on_unknown_handle() {
use crate::harness::device_worker::jobs::ArchHandle;
let handle = DeviceWorkerHandle::spawn(0).expect("spawn ok");
let fake_arch = ArchHandle(99_999);
// (3, 4, 4) fake image — minimal payload, gets reconstructed
// on the worker before the handler errors out on the unknown
// ArchHandle lookup.
let pixels = vec![0.0_f32; 3 * 4 * 4];
let result = handle.encode_image(fake_arch, pixels, 3, 4, 4).await;
match result {
Err(WorkerError::Job(e)) => {
let msg = format!("{e:#}");
assert!(
msg.contains("EncodeImage: no model for handle"),
"expected unknown-handle error, got: {msg}"
);
}
other => panic!("expected Job(Err), got {other:?}"),
}
handle.shutdown().expect("shutdown ok");
}
#[tokio::test] #[tokio::test]
async fn shutdown_drains_pending_jobs() { async fn shutdown_drains_pending_jobs() {
let handle = DeviceWorkerHandle::spawn(0).expect("spawn ok"); let handle = DeviceWorkerHandle::spawn(0).expect("spawn ok");

View File

@@ -2,7 +2,10 @@
pub mod arch; pub mod arch;
pub mod candle; pub mod candle;
pub mod chat_template;
pub mod device_worker; pub mod device_worker;
pub mod preflight;
pub mod preprocess;
pub mod tp; pub mod tp;
use anyhow::Result; use anyhow::Result;
@@ -111,10 +114,8 @@ impl HarnessRegistry {
for config in configs { for config in configs {
match config.name.as_str() { match config.name.as_str() {
"candle" => { "candle" => {
let harness = Arc::new(candle::CandleHarness::new( let harness =
bind_url.to_string(), candle::CandleHarness::new(bind_url.to_string(), &settings.candle);
settings.candle.hf_cache.clone(),
));
registry.candle = Some(Arc::clone(&harness)); registry.candle = Some(Arc::clone(&harness));
registry.harnesses.insert("candle".into(), harness); registry.harnesses.insert("candle".into(), harness);
} }

View File

@@ -0,0 +1,591 @@
//! Placement feasibility check that runs before any device allocation,
//! NCCL handshake, or weight download.
//!
//! The loader path in `candle.rs` historically discovers an
//! incompatibility *after* it has already started fetching files —
//! "fetch config.json from HauhauCS/...: 404 Not Found" surfaces hours
//! after operators set `tensor_parallel = 2` on a GGUF-only repo, with
//! no hint about what's actually wrong. Preflight closes that gap:
//!
//! 1. one `repo.info()` round-trip (siblings listing, no blob fetch)
//! 2. classify the repo: GGUF-only, dense safetensors, mixed, empty
//! 3. apply the feasibility table against the requested
//! `ModelSpec` (tp_size, quant)
//! 4. return a structured `PreflightError` the API layer can map to
//! 422 + JSON, or `Ok(PlacementPlan)` carrying the decisions the
//! downstream load path needs (which GGUF file to fetch, etc.).
//!
//! Phase 2 of plan-source-aware-loader-preflight. The Phase 1 scheme
//! work — `ModelSourceId` and per-scheme `SourceConfig` — is a
//! separate PR; preflight runs against the single configured
//! HuggingFace source for now and the scheme threading drops in
//! cleanly when Phase 1 lands.
use cortex_core::harness::ModelSpec;
use cortex_core::source::ModelSourceId;
use hf_hub::api::tokio::Api;
use serde::Serialize;
/// What the repo's siblings listing tells us about how to load it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SourceFormat {
/// Only GGUF files present. Single-GPU load path. `quants` is the
/// lowercased filename list so the operator can be told what's
/// actually available when their `quant=` choice doesn't match.
Gguf { quants: Vec<String> },
/// Dense safetensors (single-file or sharded via index.json).
/// Goes through `load_arch_dense` on single-GPU, or `load_tp` (with
/// optional in-situ quantization) when `tensor_parallel > 1`.
DenseSafetensors { sharded: bool },
/// Both safetensors and GGUF present — prefer the dense path
/// because it composes with TP and ISQ. We surface the GGUF
/// filenames anyway so operators with a strong preference can
/// see they exist.
Mixed { gguf_quants: Vec<String> },
/// No recognised weight files. Either a tokenizer-only repo
/// (e.g. some base-model repos that only host `tokenizer.json` and
/// expect the operator to use a `-GGUF` sibling repo) or a
/// genuinely empty entry.
Empty,
}
/// Output of `preflight` for a load that can proceed. Carries the
/// decisions downstream resolve_* paths would otherwise re-derive.
#[derive(Debug, Clone, Serialize)]
pub struct PlacementPlan {
pub model_id: String,
pub format: SourceFormat,
pub tp_size: u32,
/// Filename of the GGUF to fetch, populated when `format` is
/// `Gguf` and a single-GPU load was requested. None for the
/// dense/TP path.
pub picked_quant_file: Option<String>,
}
/// Structured failure modes. Each variant carries the fields the API
/// layer needs to produce an actionable 422 body.
#[derive(Debug, Clone, Serialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PreflightError {
/// `repo.info()` failed. Captures the underlying cause as a string
/// so the operator log shows whether it's auth, 404, or transport.
#[error("failed to fetch repo info for '{model_id}': {cause}")]
RepoFetchFailed { model_id: String, cause: String },
/// The repo exists but has no recognised weight files.
#[error(
"repo '{model_id}' has no recognised weight files (no .gguf, no .safetensors); \
a tokenizer-only repo cannot be loaded directly"
)]
EmptyRepo { model_id: String },
/// Operator asked for `tensor_parallel > 1` on a GGUF-only repo.
/// The TP path requires safetensors+config for in-situ
/// quantization; GGUF-TP isn't implemented (see CLAUDE.md).
#[error(
"cannot load '{model_id}' with tensor_parallel={tp_size}: repo is GGUF-only \
({} .gguf files); TP requires dense safetensors. {suggestion}",
gguf_quants.len()
)]
TpRequiresSafetensors {
model_id: String,
tp_size: u32,
gguf_quants: Vec<String>,
suggestion: String,
},
/// Operator asked for a GGUF quant whose substring doesn't match
/// any filename in the repo. `nearest` is a best-effort Levenshtein
/// suggestion against the available quant names.
#[error(
"no GGUF file in '{model_id}' matches quant '{requested}'; \
available: {available:?}{}",
nearest.as_ref().map(|n| format!("; did you mean '{n}'?")).unwrap_or_default()
)]
QuantNotFound {
model_id: String,
requested: String,
available: Vec<String>,
nearest: Option<String>,
},
}
/// Run the placement check.
///
/// One network round-trip (`repo.info()`); no blob fetches. Returns
/// `Ok(PlacementPlan)` when the requested combination is feasible, or
/// a structured `PreflightError` describing what's wrong.
///
/// `api` must already be configured for the scheme `source_id` belongs
/// to — caller (typically `CandleHarness::load_model`) builds it via
/// `hf_api_for(&source_id.scheme)`. Only the `org/name` portion of the
/// id is sent to the registry.
pub async fn preflight(
api: &Api,
source_id: &ModelSourceId,
spec: &ModelSpec,
) -> Result<PlacementPlan, PreflightError> {
let repo = api.model(source_id.repo_path());
let info = repo
.info()
.await
.map_err(|e| PreflightError::RepoFetchFailed {
model_id: source_id.to_string(),
cause: format!("{e}"),
})?;
let filenames: Vec<&str> = info.siblings.iter().map(|s| s.rfilename.as_str()).collect();
let format = classify(&filenames);
let tp_size = spec.tensor_parallel.unwrap_or(1);
match (&format, tp_size, spec.quant.as_deref()) {
// No weights at all — nothing to do.
(SourceFormat::Empty, _, _) => Err(PreflightError::EmptyRepo {
model_id: source_id.to_string(),
}),
// GGUF-only + TP: not supported. Today's HauhauCS failure.
(SourceFormat::Gguf { quants }, tp, _) if tp > 1 => {
Err(PreflightError::TpRequiresSafetensors {
model_id: source_id.to_string(),
tp_size: tp,
gguf_quants: quants.clone(),
suggestion: format!(
"Set tensor_parallel=1 and pick a quant from {quants:?}, \
or use a dense safetensors release of this model."
),
})
}
// GGUF-only + single-GPU: pick the file that matches the
// operator's quant. Empty quant matches the first GGUF.
(SourceFormat::Gguf { quants }, _, requested) => {
let picked = pick_gguf_file(&filenames, requested.unwrap_or(""));
match picked {
Some(fname) => Ok(PlacementPlan {
model_id: source_id.to_string(),
format: format.clone(),
tp_size,
picked_quant_file: Some(fname),
}),
None => Err(PreflightError::QuantNotFound {
model_id: source_id.to_string(),
requested: requested.unwrap_or("").to_string(),
available: quants.clone(),
nearest: nearest_quant(requested.unwrap_or(""), quants),
}),
}
}
// Dense or mixed: dense path handles both single-GPU and TP.
// The architecture compatibility check stays where it is —
// `check_dense_config_supported` runs once `config.json` is
// on disk, since it needs the parsed JSON.
(SourceFormat::DenseSafetensors { .. } | SourceFormat::Mixed { .. }, _, _) => {
Ok(PlacementPlan {
model_id: source_id.to_string(),
format: format.clone(),
tp_size,
picked_quant_file: None,
})
}
}
}
/// Classify a siblings file list into a `SourceFormat`. Pulled out so
/// the unit tests can exercise it against fixture JSON without
/// spinning up an Api.
pub fn classify(filenames: &[&str]) -> SourceFormat {
let mut gguf_quants: Vec<String> = filenames
.iter()
.filter(|f| f.to_lowercase().ends_with(".gguf"))
.map(|f| f.to_lowercase())
.collect();
gguf_quants.sort();
gguf_quants.dedup();
let has_safetensors = filenames.iter().any(|f| f.ends_with(".safetensors"));
let sharded = filenames
.iter()
.any(|f| f.ends_with("model.safetensors.index.json"));
match (has_safetensors, gguf_quants.is_empty()) {
(true, true) => SourceFormat::DenseSafetensors { sharded },
(true, false) => SourceFormat::Mixed { gguf_quants },
(false, false) => SourceFormat::Gguf {
quants: gguf_quants,
},
(false, true) => SourceFormat::Empty,
}
}
/// Mirror of the quant-matching logic in `candle.rs::resolve_files` so
/// preflight picks the same file the downstream loader would. Empty
/// quant returns the first `.gguf` (any quant). Lowercased substring
/// match otherwise.
fn pick_gguf_file(filenames: &[&str], quant_lc: &str) -> Option<String> {
filenames
.iter()
.filter(|f| f.to_lowercase().ends_with(".gguf"))
.find(|f| quant_lc.is_empty() || f.to_lowercase().contains(quant_lc))
.map(|f| f.to_string())
}
/// Best-effort suggestion when the operator's quant name doesn't
/// substring-match any filename. Extracts the quant-ish token from
/// each `.gguf` filename and picks the one with the smallest
/// Levenshtein distance to the requested string. Returns None when
/// the input is empty or no candidates exist.
fn nearest_quant(requested: &str, candidates: &[String]) -> Option<String> {
if requested.is_empty() || candidates.is_empty() {
return None;
}
// Pull the "Q6_K_P"/"IQ4_XS"-ish token out of each filename for a
// fairer comparison. Filenames look like
// `Qwen3.6-27B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf`, so the
// quant is the last `-`-separated segment before the extension,
// lowercased.
let tokens: Vec<(String, String)> = candidates
.iter()
.map(|f| (extract_quant_token(f), f.clone()))
.collect();
let req_lc = requested.to_lowercase();
tokens
.into_iter()
.min_by_key(|(token, _)| levenshtein(&req_lc, token))
.map(|(token, _)| token)
}
fn extract_quant_token(filename: &str) -> String {
let stem = filename
.rsplit_once('.')
.map(|(s, _)| s)
.unwrap_or(filename);
let token = stem.rsplit('-').next().unwrap_or(stem);
token.to_lowercase()
}
/// Iterative Levenshtein. Small inputs (quant names are <=12 chars),
/// no need for the `levenshtein` crate.
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let (m, n) = (a.len(), b.len());
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr = vec![0usize; n + 1];
for i in 1..=m {
curr[0] = i;
for j in 1..=n {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(model_id: &str, tp: Option<u32>, quant: Option<&str>) -> ModelSpec {
ModelSpec {
model_id: model_id.into(),
harness: "candle".into(),
quant: quant.map(String::from),
tensor_parallel: tp,
devices: None,
}
}
#[test]
fn classify_gguf_only() {
let files = [
"README.md",
".gitattributes",
"Qwen3.6-27B-Q6_K_P.gguf",
"Qwen3.6-27B-Q4_K_P.gguf",
];
match classify(&files) {
SourceFormat::Gguf { quants } => {
assert_eq!(quants.len(), 2);
assert!(quants.iter().any(|q| q.contains("q6_k_p")));
}
other => panic!("expected Gguf, got {other:?}"),
}
}
#[test]
fn classify_dense_sharded() {
let files = [
"config.json",
"tokenizer.json",
"model.safetensors.index.json",
"model-00001-of-00002.safetensors",
"model-00002-of-00002.safetensors",
];
assert_eq!(
classify(&files),
SourceFormat::DenseSafetensors { sharded: true }
);
}
#[test]
fn classify_dense_single_file() {
let files = ["config.json", "tokenizer.json", "model.safetensors"];
assert_eq!(
classify(&files),
SourceFormat::DenseSafetensors { sharded: false }
);
}
#[test]
fn classify_mixed() {
let files = [
"config.json",
"tokenizer.json",
"model.safetensors",
"model-Q4_K_M.gguf",
];
match classify(&files) {
SourceFormat::Mixed { gguf_quants } => {
assert_eq!(gguf_quants, vec!["model-q4_k_m.gguf"]);
}
other => panic!("expected Mixed, got {other:?}"),
}
}
#[test]
fn classify_empty() {
let files = ["README.md", "tokenizer.json"];
assert_eq!(classify(&files), SourceFormat::Empty);
}
#[test]
fn pick_gguf_substring_match() {
let files = ["model-Q4_K_M.gguf", "model-Q6_K.gguf", "model-Q8_0.gguf"];
assert_eq!(
pick_gguf_file(&files, "q6_k"),
Some("model-Q6_K.gguf".into())
);
}
#[test]
fn pick_gguf_empty_returns_first() {
let files = ["model-Q4_K_M.gguf", "model-Q6_K.gguf"];
assert_eq!(pick_gguf_file(&files, ""), Some("model-Q4_K_M.gguf".into()));
}
#[test]
fn pick_gguf_no_match() {
let files = ["model-Q4_K_M.gguf", "model-Q6_K.gguf"];
assert_eq!(pick_gguf_file(&files, "iq2_xs"), None);
}
#[test]
fn nearest_quant_suggests_close_match() {
// Today's HauhauCS scenario: operator wrote "q6k", actual
// filename token is "q6_k_p". Should suggest the latter.
let candidates = vec![
"qwen-q4_k_p.gguf".to_string(),
"qwen-q5_k_p.gguf".to_string(),
"qwen-q6_k_p.gguf".to_string(),
"qwen-q8_k_p.gguf".to_string(),
];
assert_eq!(nearest_quant("q6k", &candidates), Some("q6_k_p".into()));
}
#[test]
fn nearest_quant_empty_input() {
assert_eq!(nearest_quant("", &[]), None);
assert_eq!(nearest_quant("q6k", &[]), None);
assert_eq!(nearest_quant("", &["model-q4.gguf".into()]), None);
}
#[test]
fn extract_quant_handles_typical_filenames() {
assert_eq!(extract_quant_token("Qwen3.6-27B-Q6_K_P.gguf"), "q6_k_p");
assert_eq!(extract_quant_token("model-IQ4_XS.gguf"), "iq4_xs");
assert_eq!(extract_quant_token("simple.gguf"), "simple");
}
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("q6k", "q6_k_p"), 3);
assert_eq!(levenshtein("q6k", "q4_k_p"), 4);
}
// Higher-level preflight tests below exercise the full feasibility
// table via a thin wrapper that bypasses the network — we hand it
// a pre-built `SourceFormat` and request shape, then drive the
// same decision logic. The end-to-end test with a mock HTTP
// server lives in tests/preflight.rs (integration).
/// Mirror of the `match` in `preflight()` but takes a classified
/// `SourceFormat` directly. Lets us unit-test the feasibility
/// table without making the API trait object-safe / boxable.
fn decide(
spec: &ModelSpec,
format: &SourceFormat,
filenames: &[&str],
) -> Result<PlacementPlan, PreflightError> {
// Tests parse spec.model_id with the default scheme so the
// assertions can keep comparing against bare "org/name".
let source_id: ModelSourceId = spec
.model_id
.parse::<ModelSourceId>()
.expect("test spec.model_id must parse");
let tp_size = spec.tensor_parallel.unwrap_or(1);
match (format, tp_size, spec.quant.as_deref()) {
(SourceFormat::Empty, _, _) => Err(PreflightError::EmptyRepo {
model_id: source_id.to_string(),
}),
(SourceFormat::Gguf { quants }, tp, _) if tp > 1 => {
Err(PreflightError::TpRequiresSafetensors {
model_id: source_id.to_string(),
tp_size: tp,
gguf_quants: quants.clone(),
suggestion: format!(
"Set tensor_parallel=1 and pick a quant from {quants:?}, \
or use a dense safetensors release of this model."
),
})
}
(SourceFormat::Gguf { quants }, _, requested) => {
let picked = pick_gguf_file(filenames, requested.unwrap_or(""));
match picked {
Some(fname) => Ok(PlacementPlan {
model_id: source_id.to_string(),
format: format.clone(),
tp_size,
picked_quant_file: Some(fname),
}),
None => Err(PreflightError::QuantNotFound {
model_id: source_id.to_string(),
requested: requested.unwrap_or("").to_string(),
available: quants.clone(),
nearest: nearest_quant(requested.unwrap_or(""), quants),
}),
}
}
(SourceFormat::DenseSafetensors { .. } | SourceFormat::Mixed { .. }, _, _) => {
Ok(PlacementPlan {
model_id: source_id.to_string(),
format: format.clone(),
tp_size,
picked_quant_file: None,
})
}
}
}
#[test]
fn feasibility_gguf_tp_rejected() {
let files = ["Qwen-Q6_K_P.gguf", "Qwen-Q4_K_P.gguf"];
let fmt = classify(&files);
let s = spec("HauhauCS/Qwen3.6", Some(2), Some("q6k"));
match decide(&s, &fmt, &files).unwrap_err() {
PreflightError::TpRequiresSafetensors {
model_id,
tp_size,
gguf_quants,
..
} => {
assert_eq!(model_id, "HauhauCS/Qwen3.6");
assert_eq!(tp_size, 2);
assert_eq!(gguf_quants.len(), 2);
}
other => panic!("expected TpRequiresSafetensors, got {other:?}"),
}
}
#[test]
fn feasibility_gguf_single_gpu_bad_quant() {
let files = [
"Qwen-Q4_K_P.gguf",
"Qwen-Q5_K_P.gguf",
"Qwen-Q6_K_P.gguf",
"Qwen-Q8_K_P.gguf",
];
let fmt = classify(&files);
let s = spec("HauhauCS/Qwen3.6", Some(1), Some("q6k"));
match decide(&s, &fmt, &files).unwrap_err() {
PreflightError::QuantNotFound {
requested,
nearest,
available,
..
} => {
assert_eq!(requested, "q6k");
assert_eq!(nearest.as_deref(), Some("q6_k_p"));
assert_eq!(available.len(), 4);
}
other => panic!("expected QuantNotFound, got {other:?}"),
}
}
#[test]
fn feasibility_gguf_single_gpu_good_quant() {
let files = ["Qwen-Q4_K_M.gguf", "Qwen-Q6_K.gguf"];
let fmt = classify(&files);
let s = spec("Qwen/Q-GGUF", Some(1), Some("q6_k"));
let plan = decide(&s, &fmt, &files).unwrap();
assert_eq!(plan.picked_quant_file.as_deref(), Some("Qwen-Q6_K.gguf"));
}
#[test]
fn feasibility_dense_tp_ok() {
let files = [
"config.json",
"tokenizer.json",
"model.safetensors.index.json",
"model-00001-of-00002.safetensors",
];
let fmt = classify(&files);
let s = spec("Qwen/Q3-30B", Some(2), Some("q5k"));
let plan = decide(&s, &fmt, &files).unwrap();
assert_eq!(plan.tp_size, 2);
assert!(plan.picked_quant_file.is_none());
assert!(matches!(
plan.format,
SourceFormat::DenseSafetensors { sharded: true }
));
}
#[test]
fn feasibility_empty_rejected() {
let files = ["README.md", "tokenizer.json"];
let fmt = classify(&files);
let s = spec("Empty/Repo", Some(1), None);
match decide(&s, &fmt, &files).unwrap_err() {
PreflightError::EmptyRepo { model_id } => assert_eq!(model_id, "Empty/Repo"),
other => panic!("expected EmptyRepo, got {other:?}"),
}
}
#[test]
fn error_serialization_carries_kind_field() {
let err = PreflightError::TpRequiresSafetensors {
model_id: "x/y".into(),
tp_size: 2,
gguf_quants: vec!["q6_k_p".into()],
suggestion: "...".into(),
};
let v: serde_json::Value = serde_json::to_value(&err).unwrap();
assert_eq!(v["kind"], "tp_requires_safetensors");
assert_eq!(v["model_id"], "x/y");
assert_eq!(v["tp_size"], 2);
}
}

View File

@@ -0,0 +1,441 @@
//! Image preprocessing for vision-capable models.
//!
//! Decodes `data:image/...;base64,...` URIs from OpenAI-style
//! `image_url` content parts into the patch tensors a candle vision
//! tower expects. Resolution is **dynamic** (#14): each image is
//! resized to its native aspect via Qwen `smart_resize` — a
//! factor-aligned `(h, w)` whose pixel count lands in the profile's
//! `[min_pixels, max_pixels]` budget — so the LM token count varies per
//! image (`(h/factor) × (w/factor)`).
//!
//! Spec reference: `doc/vision-qwen3_6-spec.md` — preprocessor
//! section.
//!
//! Normalisation: pixel value `p ∈ [0, 255]` becomes
//! `(p/255 - mean) / std`. Qwen3.6's preprocessor_config.json
//! specifies `image_mean = image_std = [0.5, 0.5, 0.5]`, which
//! simplifies to `2p/255 - 1` mapping `[0,255]` → `[-1, 1]`. We
//! still parameterise mean/std so the same code generalises to other
//! VL families (Qwen2-VL uses imagenet stats, for instance).
//!
//! Pipeline (per image):
//! 1. data: URI → base64 decode → bytes
//! 2. bytes → image::DynamicImage (PNG/JPEG/WebP/etc)
//! 3. smart_resize to a native-aspect, factor-aligned H×W (pixel space)
//! 4. RGB→f32, normalise per mean/std
//! 5. layout to (C, H, W) tensor
//!
//! Patchification (cutting the HxW tensor into `patch_size` blocks)
//! happens inside the vision tower's `patch_embed` conv, so this
//! module stops at "preprocessed RGB f32 tensor."
use anyhow::{Context, Result, anyhow};
use base64::Engine;
use image::DynamicImage;
use image::imageops::FilterType;
/// Preprocessing target. Captures the resize policy (Qwen `smart_resize`
/// factor + pixel budget) and the channel-wise normalisation constants
/// from the model's `preprocessor_config.json`. Images are resized to
/// their **native aspect** — a factor-aligned `(h, w)` whose pixel count
/// lands in `[min_pixels, max_pixels]` — not a fixed square (#14).
#[derive(Debug, Clone)]
pub struct PreprocessProfile {
/// Both output dims are multiples of this. For Qwen3.6 it is
/// `patch_size(16) × spatial_merge_size(2) = 32`, so the post-merge
/// LM grid is exactly `(h/factor, w/factor)`.
pub factor: u32,
/// Lower pixel bound — tiny images are upscaled to at least this.
pub min_pixels: u32,
/// Upper pixel bound — large images are downscaled to at most this.
/// Caps per-image LM tokens (`max_pixels / factor²`) and the
/// O(patches²) ViT attention cost.
pub max_pixels: u32,
pub image_mean: [f32; 3],
pub image_std: [f32; 3],
}
/// The Qwen3.6 vision tower rejects any image whose **patch** count
/// exceeds its learned pos-embed budget (`num_position_embeddings =
/// 2304 = 48²`; see `vision.rs`). At `patch_size = 16` that is
/// `2304 × 16² = 589_824` source pixels. `max_pixels` is hard-capped to
/// this so `smart_resize` can never produce an over-budget grid — a
/// per-rank "patch count exceeds pos_embed budget" error mid-TP-forward
/// would otherwise poison the device context. The pos-embed grid is the
/// resolution Qwen3.6 was trained at, so this cap is principled, not just
/// defensive.
const QWEN3_6_MAX_PIXELS_CAP: u32 = 2304 * 16 * 16; // 589_824 → ≤ 2304 patches → ≤ 576 LM tokens
/// Default pixel budget for Qwen3.6: `256²` (64 LM tokens) up to the
/// pos-embed cap (576 LM tokens). Generous for documents/OCR, bounded
/// for serving. Operators lower it with `NEURON_VISION_MIN_PIXELS` /
/// `NEURON_VISION_MAX_PIXELS` (the upper bound is still clamped to the
/// cap above — raising it past the budget would poison the model).
const QWEN3_6_MIN_PIXELS: u32 = 65_536;
fn env_pixels(name: &str, default: u32) -> u32 {
std::env::var(name)
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.unwrap_or(default)
}
impl PreprocessProfile {
/// Profile for Qwen3.6. Native-aspect `smart_resize` (factor 32),
/// normalise to `[-1, 1]` via mean=std=0.5. Pixel budget defaults to
/// [`QWEN3_6_MIN_PIXELS`]…[`QWEN3_6_MAX_PIXELS_CAP`], overridable via
/// `NEURON_VISION_MIN_PIXELS` / `NEURON_VISION_MAX_PIXELS`. Clamped
/// sane: `factor² ≤ min ≤ max`, and `max ≤` the pos-embed cap (so the
/// vision tower never rejects a resized image and poisons the context).
pub fn qwen3_6() -> Self {
let factor = 32u32;
let f2 = factor * factor;
let min_pixels = env_pixels("NEURON_VISION_MIN_PIXELS", QWEN3_6_MIN_PIXELS)
.max(f2)
.min(QWEN3_6_MAX_PIXELS_CAP);
let max_pixels = env_pixels("NEURON_VISION_MAX_PIXELS", QWEN3_6_MAX_PIXELS_CAP)
.min(QWEN3_6_MAX_PIXELS_CAP)
.max(min_pixels);
Self {
factor,
min_pixels,
max_pixels,
image_mean: [0.5, 0.5, 0.5],
image_std: [0.5, 0.5, 0.5],
}
}
/// The factor-aligned `(h, w)` this profile would resize a source
/// `src_h × src_w` image to. Pure integer policy — no pixel work.
pub fn resized_dims(&self, src_h: u32, src_w: u32) -> Result<(u32, u32)> {
smart_resize(src_h, src_w, self.factor, self.min_pixels, self.max_pixels)
}
}
/// Qwen `smart_resize`: the smallest `factor`-aligned `(h_bar, w_bar)`
/// that preserves aspect ratio as closely as possible while keeping the
/// pixel count within `[min_pixels, max_pixels]`. Direct port of the
/// canonical Qwen2-VL / Qwen3-VL image-processor function (so neuron's
/// grid matches what the model was trained on).
///
/// Returns `(height, width)`. Errors if the aspect ratio exceeds 200:1
/// (degenerate input — a 1-pixel-tall strip), matching upstream.
pub fn smart_resize(
height: u32,
width: u32,
factor: u32,
min_pixels: u32,
max_pixels: u32,
) -> Result<(u32, u32)> {
let h = height.max(1) as f64;
let w = width.max(1) as f64;
let ratio = h.max(w) / h.min(w);
if ratio > 200.0 {
anyhow::bail!(
"image aspect ratio {ratio:.1}:1 exceeds the 200:1 limit ({height}×{width}); \
refusing to resize"
);
}
let f = factor as f64;
let (minp, maxp) = (min_pixels as f64, max_pixels as f64);
// round-to-nearest-factor (may be 0 for sub-factor inputs; the
// min-pixels branch below grows it back up).
let mut h_bar = (h / f).round() * f;
let mut w_bar = (w / f).round() * f;
if h_bar * w_bar > maxp {
let beta = (h * w / maxp).sqrt();
h_bar = f.max((h / beta / f).floor() * f);
w_bar = f.max((w / beta / f).floor() * f);
} else if h_bar * w_bar < minp {
let beta = (minp / (h * w)).sqrt();
h_bar = (h * beta / f).ceil() * f;
w_bar = (w * beta / f).ceil() * f;
}
Ok((h_bar as u32, w_bar as u32))
}
/// Decode a `data:image/...;base64,...` URI into an in-memory image.
///
/// Accepts the OpenAI Chat Completions `image_url` shape — a string
/// URL with `data:` scheme and base64 payload. The MIME type is read
/// from the URI for diagnostics but `image::load_from_memory` sniffs
/// the format from the bytes themselves, so the MIME is advisory.
///
/// Bare `http(s)://` URLs are explicitly rejected here — fetching
/// them from a vision-model server is a fingerprintable behaviour
/// (server-side request forgery, infinite recursion if the URL
/// points at the gateway itself, etc.). Clients that want remote
/// images can fetch them and pass base64 themselves.
pub fn decode_data_uri(uri: &str) -> Result<DynamicImage> {
let after_scheme = uri
.strip_prefix("data:")
.ok_or_else(|| anyhow!("image_url must use data: scheme; got {uri:.40}…"))?;
let (meta, payload) = after_scheme
.split_once(',')
.ok_or_else(|| anyhow!("malformed data URI: missing ',' separator"))?;
if !meta.contains(";base64") {
anyhow::bail!(
"data URI must use base64 encoding (got '{meta}'); raw URL-encoded payloads not supported"
);
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(payload.trim())
.context("base64-decode image data URI payload")?;
image::load_from_memory(&bytes).context("decode image bytes (PNG/JPEG/WebP/etc)")
}
/// Resize and normalise an image into a `(3, H, W)` row-major
/// `Vec<f32>` ready to hand to the vision tower's `patch_embed`
/// conv.
///
/// Uses bilinear resampling — Qwen2-VL's reference uses bicubic, but
/// bilinear is what the candle ecosystem standardises on and is
/// faster on CPU. Quality difference is marginal for downstream
/// vision-encoder consumption. The numerical-validation issue (#15)
/// will quantify any discrepancy.
pub fn preprocess(img: &DynamicImage, profile: &PreprocessProfile) -> Result<(Vec<f32>, u32, u32)> {
let (h_bar, w_bar) = profile.resized_dims(img.height(), img.width())?;
let rgb = img
.resize_exact(w_bar, h_bar, FilterType::Triangle)
.to_rgb8();
let h = h_bar as usize;
let w = w_bar as usize;
let mut out = vec![0.0_f32; 3 * h * w];
// Row-major (C, H, W). Candle's Conv2d expects NCHW, so this is
// the natural layout — the caller stacks `n` of these along the
// batch axis as needed.
for c in 0..3 {
let mean = profile.image_mean[c];
let std = profile.image_std[c];
for y in 0..h {
for x in 0..w {
let pixel = rgb.get_pixel(x as u32, y as u32);
let raw = pixel[c] as f32 / 255.0;
out[c * h * w + y * w + x] = (raw - mean) / std;
}
}
}
Ok((out, h_bar, w_bar))
}
/// Combined helper: decode + preprocess in one call. Returns the
/// `(3, h, w)` row-major pixels plus the resized `(h, w)` — the caller
/// needs the dims to build the tensor and to derive the LM token grid
/// `(h/factor, w/factor)`. Most call sites use this; the two-step path
/// exists for callers (tests, future video preprocessing) that need the
/// intermediate `DynamicImage`.
pub fn preprocess_data_uri(uri: &str, profile: &PreprocessProfile) -> Result<(Vec<f32>, u32, u32)> {
let img = decode_data_uri(uri)?;
preprocess(&img, profile)
}
/// Resized `(h, w)` for a data-URI image **without** running the pixel
/// normalisation — decode header + `smart_resize` only. Lets a caller
/// that just needs the LM token count (e.g. the TP leader expanding the
/// prompt) avoid materialising the full pixel tensor twice.
pub fn resized_dims_for_uri(uri: &str, profile: &PreprocessProfile) -> Result<(u32, u32)> {
let img = decode_data_uri(uri)?;
profile.resized_dims(img.height(), img.width())
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
/// A 1×1 red PNG, hand-built. Matches the well-known smallest
/// valid PNG we use in tests/curl examples elsewhere.
const ONE_BY_ONE_RED_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
fn red_png_uri() -> String {
format!("data:image/png;base64,{ONE_BY_ONE_RED_PNG_B64}")
}
#[test]
fn decodes_well_formed_png_data_uri() {
let img = decode_data_uri(&red_png_uri()).expect("decode 1x1 png");
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
#[test]
fn rejects_non_data_scheme() {
let err = decode_data_uri("https://example.com/cat.jpg")
.expect_err("http(s) URLs must be rejected");
assert!(format!("{err:#}").contains("data:"));
}
#[test]
fn rejects_malformed_uri_without_comma() {
let err = decode_data_uri("data:image/png;base64").unwrap_err();
assert!(format!("{err:#}").contains("','"));
}
#[test]
fn rejects_non_base64_payload() {
let err = decode_data_uri("data:image/png,raw-bytes-here").unwrap_err();
assert!(format!("{err:#}").contains("base64"));
}
#[test]
fn rejects_bad_base64_payload() {
let err = decode_data_uri("data:image/png;base64,not!valid!base64!").unwrap_err();
assert!(format!("{err:#}").contains("base64"));
}
#[test]
fn rejects_garbage_image_bytes() {
// Valid base64 ("Hello World!"), invalid image bytes.
let err = decode_data_uri("data:image/png;base64,SGVsbG8gV29ybGQh").unwrap_err();
assert!(
format!("{err:#}").contains("decode image"),
"should fail at image-decode step"
);
}
#[test]
fn preprocess_red_image_produces_correct_shape_and_values() {
let profile = PreprocessProfile::qwen3_6();
// Build a tiny pure-red image directly, skipping data: URI
// decoding so this test isolates the resize+normalise path.
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(2, 2, Rgb([255, 0, 0]));
let dyn_img = DynamicImage::ImageRgb8(img);
let (out, h_bar, w_bar) = preprocess(&dyn_img, &profile).expect("preprocess");
let h = h_bar as usize;
let w = w_bar as usize;
assert_eq!(out.len(), 3 * h * w);
// Dims are factor-aligned and at least the min-pixel floor.
assert_eq!(h_bar % profile.factor, 0);
assert_eq!(w_bar % profile.factor, 0);
assert!(h * w >= profile.min_pixels as usize);
// After mean=0.5, std=0.5: red channel (255/255=1.0) → (1.0 - 0.5)/0.5 = 1.0
// green/blue (0.0) → (0.0 - 0.5)/0.5 = -1.0
assert!(
(out[0] - 1.0).abs() < 1e-5,
"R[0] should be 1.0, got {}",
out[0]
);
assert!((out[h * w] - (-1.0)).abs() < 1e-5, "G[0] should be -1.0");
assert!(
(out[2 * h * w] - (-1.0)).abs() < 1e-5,
"B[0] should be -1.0"
);
// All values are finite
assert!(out.iter().all(|v| v.is_finite()), "no NaN/Inf in output");
}
#[test]
fn preprocess_data_uri_end_to_end() {
let profile = PreprocessProfile::qwen3_6();
let (out, h, w) = preprocess_data_uri(&red_png_uri(), &profile).expect("e2e preprocess");
assert_eq!(out.len(), 3 * h as usize * w as usize);
assert!(out.iter().all(|v| v.is_finite()));
// resized_dims_for_uri agrees with the full preprocess.
let (h2, w2) = resized_dims_for_uri(&red_png_uri(), &profile).expect("dims");
assert_eq!((h, w), (h2, w2));
}
#[test]
fn preprocess_grayscale_image_promotes_to_rgb() {
let profile = PreprocessProfile::qwen3_6();
// 1x1 grayscale = 200 → after conversion to RGB, all three
// channels equal 200, normalised → (200/255 - 0.5)/0.5 ≈ 0.569
let gray = DynamicImage::ImageLuma8(ImageBuffer::from_pixel(1, 1, image::Luma([200])));
let (out, h_bar, w_bar) = preprocess(&gray, &profile).expect("preprocess");
let expected = ((200.0 / 255.0) - 0.5) / 0.5;
let h = h_bar as usize;
let w = w_bar as usize;
for c in 0..3 {
let v = out[c * h * w];
assert!(
(v - expected).abs() < 1e-3,
"channel {c}: expected {expected}, got {v}"
);
}
}
#[test]
fn smart_resize_keeps_factor_aligned_square_in_budget() {
// 448×448 sits inside [65536, 1048576] and is factor-aligned →
// unchanged. (Regression guard for the old fixed-res sweet spot.)
let (h, w) = smart_resize(448, 448, 32, 65_536, 1_048_576).unwrap();
assert_eq!((h, w), (448, 448));
}
#[test]
fn smart_resize_preserves_aspect_and_caps_at_max() {
// 3000×4000 (landscape) → downscaled under max_pixels, aspect kept.
let (h, w) = smart_resize(3000, 4000, 32, 65_536, 1_048_576).unwrap();
assert_eq!(h % 32, 0);
assert_eq!(w % 32, 0);
assert!(
(h as u64) * (w as u64) <= 1_048_576,
"must respect max_pixels"
);
assert!(w > h, "landscape orientation preserved");
// aspect ≈ 4000/3000 = 1.333; allow a factor-rounding tolerance.
let ar = w as f64 / h as f64;
assert!((ar - 4.0 / 3.0).abs() < 0.15, "aspect ~4:3, got {ar:.3}");
}
#[test]
fn smart_resize_floors_tiny_image_at_min() {
// 16×16 → upscaled to at least min_pixels, factor-aligned.
let (h, w) = smart_resize(16, 16, 32, 65_536, 1_048_576).unwrap();
assert_eq!(h % 32, 0);
assert_eq!(w % 32, 0);
assert!((h as u64) * (w as u64) >= 65_536, "must respect min_pixels");
}
#[test]
fn smart_resize_tall_nonsquare_stays_nonsquare() {
// A tall screenshot keeps portrait orientation.
let (h, w) = smart_resize(2000, 500, 32, 65_536, 1_048_576).unwrap();
assert!(h > w, "portrait orientation preserved");
assert_eq!(h % 32, 0);
assert_eq!(w % 32, 0);
}
#[test]
fn smart_resize_rejects_extreme_aspect() {
let err = smart_resize(1, 500, 32, 65_536, 1_048_576).unwrap_err();
assert!(format!("{err:#}").contains("200:1"));
}
#[test]
fn qwen3_6_never_exceeds_pos_embed_patch_budget() {
// The pos-embed cap must hold for huge, tall, wide, and extreme
// images — exceeding 2304 patches errors mid-tower and poisons
// the device context, so this invariant is load-bearing.
let p = PreprocessProfile::qwen3_6();
for (sh, sw) in [
(8000u32, 6000u32),
(808, 1600),
(4000, 400),
(1, 199),
(16, 16),
] {
let (h, w) = p.resized_dims(sh, sw).unwrap();
let patches = (h / 16) * (w / 16);
assert!(
patches <= 2304,
"{sh}x{sw} → {h}x{w} = {patches} patches exceeds the 2304 budget"
);
}
}
#[test]
fn qwen3_6_default_budget_bounds_lm_tokens() {
// A huge source image caps at max_pixels → the per-image LM token
// count stays within budget (so it can't blow NEURON_MAX_PROMPT_TOKENS).
let p = PreprocessProfile::qwen3_6();
let (h, w) = p.resized_dims(8000, 6000).unwrap();
let lm_tokens = (h / p.factor) * (w / p.factor);
let budget = p.max_pixels / (p.factor * p.factor);
assert!(
lm_tokens <= budget,
"max-res image LM tokens {lm_tokens} must stay within budget {budget}"
);
}
}

View File

@@ -0,0 +1,154 @@
{%- set image_count = namespace(value=0) %}
{%- set video_count = namespace(value=0) %}
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
{%- if content is string %}
{{- content }}
{%- elif content is iterable and content is not mapping %}
{%- for item in content %}
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain images.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set image_count.value = image_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Picture ' ~ image_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
{%- elif 'video' in item or item.type == 'video' %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain videos.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set video_count.value = video_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Video ' ~ video_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
{%- elif 'text' in item %}
{{- item.text }}
{%- else %}
{{- raise_exception('Unexpected item type in content.') }}
{%- endif %}
{%- endfor %}
{%- elif content is none or content is undefined %}
{{- '' }}
{%- else %}
{{- raise_exception('Unexpected content type.') }}
{%- endif %}
{%- endmacro %}
{%- if not messages %}
{{- raise_exception('No messages provided.') }}
{%- endif %}
{%- if tools and tools is iterable and tools is not mapping %}
{{- '<|im_start|>system\n' }}
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{{- tool | tojson }}
{%- endfor %}
{{- "\n</tools>" }}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
{%- if messages[0].role == 'system' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{%- if content %}
{{- '\n\n' + content }}
{%- endif %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if messages[0].role == 'system' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" %}
{%- set content = render_content(message.content, false)|trim %}
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if ns.multi_step_tool %}
{{- raise_exception('No user query found in messages.') }}
{%- endif %}
{%- for message in messages %}
{%- set content = render_content(message.content, true)|trim %}
{%- if message.role == "system" %}
{%- if not loop.first %}
{{- raise_exception('System message must be at the beginning.') }}
{%- endif %}
{%- elif message.role == "user" %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{%- set reasoning_content = '' %}
{%- if message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- else %}
{%- if '</think>' in content %}
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
{%- endif %}
{%- endif %}
{%- set reasoning_content = reasoning_content|trim %}
{%- if (preserve_thinking is defined and preserve_thinking is true) or (loop.index0 > ns.last_query_index) %}
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.function is defined %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{%- if loop.first %}
{%- if content|trim %}
{{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- else %}
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- endif %}
{%- else %}
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- endif %}
{%- if tool_call.arguments is defined %}
{%- for args_name, args_value in tool_call.arguments|items %}
{{- '<parameter=' + args_name + '>\n' }}
{%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %}
{{- args_value }}
{{- '\n</parameter>\n' }}
{%- endfor %}
{%- endif %}
{{- '</function>\n</tool_call>' }}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{%- if loop.previtem and loop.previtem.role != "tool" %}
{{- '<|im_start|>user' }}
{%- endif %}
{{- '\n<tool_response>\n' }}
{{- content }}
{{- '\n</tool_response>' }}
{%- if not loop.last and loop.nextitem.role != "tool" %}
{{- '<|im_end|>\n' }}
{%- elif loop.last %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- else %}
{{- raise_exception('Unexpected message role.') }}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}

View File

@@ -62,6 +62,30 @@ impl TpLeaderModel {
} }
} }
/// Chunked image prefill on rank 0. Only the vision-capable
/// `qwen3_5` arch supports it; the dense `qwen3` arch has no tower.
pub fn prefill_with_images_chunked(
&mut self,
tokens: &[u32],
base_offset: usize,
image_pixels: &[candle_core::Tensor],
image_token_id: u32,
chunk_size: usize,
) -> candle_core::Result<candle_core::Tensor> {
match self {
TpLeaderModel::Qwen3_5(m) => m.prefill_with_images_chunked(
tokens,
base_offset,
image_pixels,
image_token_id,
chunk_size,
),
TpLeaderModel::Qwen3(_) => {
candle_core::bail!("prefill_with_images_chunked: qwen3 (dense) has no vision tower")
}
}
}
pub fn clear_kv_cache(&mut self) { pub fn clear_kv_cache(&mut self) {
match self { match self {
TpLeaderModel::Qwen3(m) => m.clear_kv_cache(), TpLeaderModel::Qwen3(m) => m.clear_kv_cache(),
@@ -687,6 +711,134 @@ impl WorkerPool {
} }
} }
/// Image-bearing variant of [`Self::generate_step`] for the
/// single-shot vision prefill. Identical fan-out / leader-forward /
/// drain shape, but every rank runs the encode + splice path:
///
/// - subprocess workers get `GenerateStepWithImages` (carrying the
/// source `image_data_uris`); each preprocesses + encodes through
/// its replicated tower and splices locally;
/// - the leader runs the same encode + splice + forward on its
/// device worker thread via `tp_forward_logits_with_images`.
///
/// The row-parallel `AllReduce`s synchronise the ranks exactly as in
/// the text path. Because the tower is replicated and the preprocess
/// is deterministic, every rank's spliced hidden state matches — no
/// embedding broadcast. Only used for prefill; decode reuses
/// `generate_step`.
#[cfg(feature = "cuda")]
#[allow(clippy::too_many_arguments)]
pub async fn generate_step_with_images(
&mut self,
model_id: &str,
leader_handle: super::device_worker::TpHandle,
tokens: Vec<u32>,
offset: usize,
image_token_id: u32,
image_data_uris: Vec<String>,
chunk_size: usize,
) -> Result<Vec<f32>> {
let step_start = std::time::Instant::now();
let tokens_len = tokens.len();
tracing::debug!(
model = %model_id,
tokens = tokens_len,
offset,
images = image_data_uris.len(),
chunk_size,
"WorkerPool::generate_step_with_images: fan-out"
);
// 1. Fan-out the image-bearing prefill to subprocess workers.
for w in &mut self.workers {
w.send_only(&WorkerRequest::GenerateStepWithImages {
model_id: model_id.to_string(),
tokens: tokens.clone(),
offset,
image_token_id,
image_data_uris: image_data_uris.clone(),
chunk_size,
})
.await?;
}
// 2. Leader's image forward on its device worker thread. The
// AllReduce CustomOps block until every worker issues the
// matching collective; CPU-side logits keep the device tensor
// from escaping the worker thread.
let leader_start = std::time::Instant::now();
let leader_result = self
.leader_worker
.tp_forward_logits_with_images(
leader_handle,
tokens,
offset,
image_token_id,
image_data_uris,
chunk_size,
)
.await;
let leader_ok = leader_result.is_ok();
let leader_ms = leader_start.elapsed().as_millis();
if !leader_ok {
let detail = leader_result
.as_ref()
.err()
.map(|e| format!("{e:#}"))
.unwrap_or_default();
tracing::warn!(
model = %model_id,
tokens = tokens_len,
offset,
leader_ms,
error = %detail,
"WorkerPool::generate_step_with_images: leader forward failed"
);
}
// 3. ALWAYS drain worker responses, regardless of the leader's
// outcome, so stale GenerateStepOk replies don't poison the
// next request's recv (same invariant as generate_step).
let worker_errors = drain_workers(&mut self.workers, |r| match r {
WorkerResponse::GenerateStepOk => Ok(()),
WorkerResponse::Error { kind, message } => Err(format!("[{kind}]: {message}")),
other => Err(format!("expected GenerateStepOk, got {other:?}")),
})
.await;
tracing::debug!(
model = %model_id,
leader_ms,
leader_ok,
errors = worker_errors.len(),
total_ms = step_start.elapsed().as_millis(),
"WorkerPool::generate_step_with_images: workers drained"
);
match leader_result {
Ok(values) => {
if worker_errors.is_empty() {
Ok(values)
} else {
anyhow::bail!(
"GenerateStepWithImages: leader succeeded but workers failed: {}",
worker_errors.join("; ")
)
}
}
Err(e) => {
if worker_errors.is_empty() {
Err(anyhow::Error::new(e)
.context("GenerateStepWithImages: leader forward failed"))
} else {
Err(anyhow::Error::new(e).context(format!(
"GenerateStepWithImages: leader forward failed and workers also failed: {}",
worker_errors.join("; ")
)))
}
}
}
}
/// Reset the KV cache for `model_id` on every rank. Called at the /// Reset the KV cache for `model_id` on every rank. Called at the
/// start of every inference so a fresh request doesn't attend over /// start of every inference so a fresh request doesn't attend over
/// the previous one's tokens. /// the previous one's tokens.

View File

@@ -88,6 +88,33 @@ pub enum WorkerRequest {
offset: usize, offset: usize,
}, },
/// Like `GenerateStep` but the prefill carries image content. Every
/// rank preprocesses the same `image_data_uris` through its
/// *replicated* vision tower, splices the resulting patch embeddings
/// at `image_token_id` positions, and runs the forward — the
/// row-parallel `AllReduce`s still synchronise every rank. Because
/// the tower is replicated and `preprocess_data_uri` is
/// deterministic, the spliced hidden state is identical on every
/// rank, so no embedding broadcast is needed. Sent only for the
/// (single-shot) image-bearing prefill; decode steps use plain
/// `GenerateStep`. Worker replies with the same `GenerateStepOk`.
GenerateStepWithImages {
model_id: String,
tokens: Vec<u32>,
offset: usize,
/// `<|image_pad|>` sentinel id (248056 for Qwen3.6); splice
/// target in the expanded token stream.
image_token_id: u32,
/// Source image data URIs (`data:image/...;base64,...`), one per
/// image in prompt order. Each rank decodes + preprocesses these
/// identically; tens of KB each, so cheap over the stdin pipe.
image_data_uris: Vec<String>,
/// Prefill chunk size (tokens). Sent explicitly so every rank
/// walks the prompt in identical windows and the per-chunk
/// row-parallel collectives stay paired across ranks.
chunk_size: usize,
},
/// Reset the KV cache for this model on this rank. Sent at the /// Reset the KV cache for this model on this rank. Sent at the
/// start of every inference so a fresh request doesn't accidentally /// start of every inference so a fresh request doesn't accidentally
/// attend over the previous one's tokens. /// attend over the previous one's tokens.
@@ -191,6 +218,33 @@ mod tests {
assert_eq!(wire, r#"{"op":"init","comm_id":"deadbeef"}"#); assert_eq!(wire, r#"{"op":"init","comm_id":"deadbeef"}"#);
} }
#[test]
fn request_generate_step_with_images_round_trip() {
let req = WorkerRequest::GenerateStepWithImages {
model_id: "Qwen/Qwen3.6-27B".into(),
tokens: vec![1, 2, 248056, 3],
offset: 0,
image_token_id: 248056,
image_data_uris: vec!["data:image/png;base64,AAA=".into()],
chunk_size: 512,
};
let wire = serde_json::to_string(&req).unwrap();
assert!(wire.contains(r#""op":"generate_step_with_images""#));
match roundtrip(&req) {
WorkerRequest::GenerateStepWithImages {
tokens,
image_token_id,
image_data_uris,
..
} => {
assert_eq!(tokens, vec![1, 2, 248056, 3]);
assert_eq!(image_token_id, 248056);
assert_eq!(image_data_uris.len(), 1);
}
other => panic!("expected GenerateStepWithImages, got {other:?}"),
}
}
#[test] #[test]
fn request_shutdown_round_trip() { fn request_shutdown_round_trip() {
assert_eq!( assert_eq!(

View File

@@ -46,6 +46,8 @@ use super::tp_linear::{ColumnParallelLinear, RowParallelLinear};
use crate::harness::arch::qwen3_5::linear_attn::repeat_interleave; use crate::harness::arch::qwen3_5::linear_attn::repeat_interleave;
use crate::harness::arch::qwen3_5::rmsnorm::{Qwen3_5RmsNorm, Qwen3_5RmsNormGated, l2norm}; use crate::harness::arch::qwen3_5::rmsnorm::{Qwen3_5RmsNorm, Qwen3_5RmsNormGated, l2norm};
use crate::harness::arch::qwen3_5::rope::RotaryEmbedding; use crate::harness::arch::qwen3_5::rope::RotaryEmbedding;
use crate::harness::arch::qwen3_5::splice_runs;
use crate::harness::arch::qwen3_5::vision::VisionTower;
pub use crate::harness::arch::qwen3_5::{Config, TextConfig}; pub use crate::harness::arch::qwen3_5::{Config, TextConfig};
// ─── linear-attention (Gated DeltaNet) ────────────────────────────── // ─── linear-attention (Gated DeltaNet) ──────────────────────────────
@@ -524,7 +526,8 @@ impl TpQwen3_5Attention {
&mut self, &mut self,
x: &Tensor, x: &Tensor,
attn_mask: Option<&Tensor>, attn_mask: Option<&Tensor>,
offset: usize, cos: &Tensor,
sin: &Tensor,
) -> candle_core::Result<Tensor> { ) -> candle_core::Result<Tensor> {
let (b, l, _) = x.dims3()?; let (b, l, _) = x.dims3()?;
@@ -557,7 +560,7 @@ impl TpQwen3_5Attention {
.transpose(1, 2)? .transpose(1, 2)?
.contiguous()?; .contiguous()?;
let (q, k) = self.rotary.apply(&q, &k, offset)?; let (q, k) = self.rotary.apply_cos_sin(&q, &k, cos, sin)?;
let (k, v) = self.kv_cache.append(&k, &v)?; let (k, v) = self.kv_cache.append(&k, &v)?;
let k = repeat_kv(k, self.num_kv_groups)?.contiguous()?; let k = repeat_kv(k, self.num_kv_groups)?.contiguous()?;
let v = repeat_kv(v, self.num_kv_groups)?.contiguous()?; let v = repeat_kv(v, self.num_kv_groups)?.contiguous()?;
@@ -805,11 +808,12 @@ impl TpQwen3_5DecoderLayer {
&mut self, &mut self,
x: &Tensor, x: &Tensor,
attn_mask: Option<&Tensor>, attn_mask: Option<&Tensor>,
offset: usize, cos: &Tensor,
sin: &Tensor,
) -> candle_core::Result<Tensor> { ) -> candle_core::Result<Tensor> {
let h = self.input_layernorm.forward(x)?; let h = self.input_layernorm.forward(x)?;
let attn_out = match &mut self.attention { let attn_out = match &mut self.attention {
TpAttentionKind::Full(attn) => attn.forward(&h, attn_mask, offset)?, TpAttentionKind::Full(attn) => attn.forward(&h, attn_mask, cos, sin)?,
TpAttentionKind::Linear(net) => net.forward(&h)?, TpAttentionKind::Linear(net) => net.forward(&h)?,
}; };
let x = (x + attn_out)?; let x = (x + attn_out)?;
@@ -832,6 +836,15 @@ pub struct TpQwen3_5Model {
embed_tokens: Embedding, embed_tokens: Embedding,
layers: Vec<TpQwen3_5DecoderLayer>, layers: Vec<TpQwen3_5DecoderLayer>,
norm: Qwen3_5RmsNorm, norm: Qwen3_5RmsNorm,
/// Replicated rotary, shared with every full-attention layer. The
/// model builds the per-forward cos/sin (interleaved M-RoPE for image
/// tokens, plain for text) once and the layers apply it. Identical on
/// every rank, so per-rank position ids stay consistent.
rotary: Arc<RotaryEmbedding>,
/// `offset + rope_delta` is the text-axis decode position; set from
/// `get_rope_index` during a vision prefill, reset in `clear_kv_cache`.
/// See `Qwen3_5Model::rope_delta`.
rope_delta: i64,
device: Device, device: Device,
dtype: DType, dtype: DType,
} }
@@ -898,6 +911,8 @@ impl TpQwen3_5Model {
embed_tokens, embed_tokens,
layers, layers,
norm, norm,
rotary,
rope_delta: 0,
device, device,
dtype, dtype,
}) })
@@ -954,6 +969,8 @@ impl TpQwen3_5Model {
embed_tokens, embed_tokens,
layers, layers,
norm, norm,
rotary,
rope_delta: 0,
device, device,
dtype, dtype,
}) })
@@ -967,6 +984,14 @@ impl TpQwen3_5Model {
for l in &mut self.layers { for l in &mut self.layers {
l.clear_kv_cache(); l.clear_kv_cache();
} }
self.rope_delta = 0;
}
/// Set the decode `rope_delta` computed by `get_rope_index` during a
/// vision prefill, so decode after the image resumes text positions
/// from the image-compressed counter.
pub fn set_rope_delta(&mut self, delta: i64) {
self.rope_delta = delta;
} }
fn causal_mask(&self, b: usize, tgt: usize, offset: usize) -> candle_core::Result<Tensor> { fn causal_mask(&self, b: usize, tgt: usize, offset: usize) -> candle_core::Result<Tensor> {
@@ -978,15 +1003,88 @@ impl TpQwen3_5Model {
} }
pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> { pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
self.forward_inner(input, offset, None, None, None)
}
/// 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). Used by
/// `TpQwen3_5ForCausalLM::prefill_with_images_chunked`, which
/// computes the positions once over the whole prompt and slices them
/// per chunk so every rank steps in lockstep.
pub fn forward_with_positions(
&mut self,
input: &Tensor,
offset: usize,
position_ids: &Tensor,
image_embeds: Option<&Tensor>,
image_token_id: Option<u32>,
) -> candle_core::Result<Tensor> {
self.forward_inner(
input,
offset,
image_embeds,
image_token_id,
Some(position_ids),
)
}
/// Shared forward. Splices image embeddings at `image_token_id`
/// positions when present, then builds the rotary cos/sin — from the
/// explicit `position_ids` (interleaved M-RoPE, vision) when given,
/// else plain positions at `offset + rope_delta` (text / decode) —
/// and runs the sharded decoder stack. The TP replicated-hidden-state
/// invariant holds because every rank encodes the same pixels and
/// computes the same positions.
fn forward_inner(
&mut self,
input: &Tensor,
offset: usize,
image_embeds: Option<&Tensor>,
image_token_id: Option<u32>,
position_ids: Option<&Tensor>,
) -> candle_core::Result<Tensor> {
let (b, l) = input.dims2()?; let (b, l) = input.dims2()?;
let mut h = self.embed_tokens.forward(input)?; let mut h = self.embed_tokens.forward(input)?;
if let (Some(img), Some(tok_id)) = (image_embeds, image_token_id) {
let ids: Vec<u32> = input.flatten_all()?.to_vec1()?;
let mut positions: Vec<u32> = Vec::with_capacity(img.dim(0)?);
for (idx, id) in ids.iter().enumerate() {
if *id == tok_id {
positions.push(idx as u32);
}
}
let n_img_tokens = img.dim(0)?;
if positions.len() != n_img_tokens {
candle_core::bail!(
"TP forward: chunk has {} image-token positions but image_embeds carries \
{} tokens — patch-count expansion / chunk slicing mismatch",
positions.len(),
n_img_tokens,
);
}
if !positions.is_empty() {
let img = img.to_dtype(self.dtype)?;
h = splice_runs(&h, &img, &positions)?;
}
}
let (cos, sin) = match position_ids {
Some(pos) => self.rotary.mrope_cos_sin(pos)?,
None => {
let base = (offset as i64 + self.rope_delta).max(0) as usize;
self.rotary.plain_cos_sin(base, l)?
}
};
let causal = if l == 1 { let causal = if l == 1 {
None None
} else { } else {
Some(self.causal_mask(b, l, offset)?) Some(self.causal_mask(b, l, offset)?)
}; };
for layer in &mut self.layers { for layer in &mut self.layers {
h = layer.forward(&h, causal.as_ref(), offset)?; h = layer.forward(&h, causal.as_ref(), &cos, &sin)?;
} }
self.norm.forward(&h) self.norm.forward(&h)
} }
@@ -995,6 +1093,41 @@ impl TpQwen3_5Model {
pub struct TpQwen3_5ForCausalLM { pub struct TpQwen3_5ForCausalLM {
base: TpQwen3_5Model, base: TpQwen3_5Model,
lm_head: super::tp_linear::MaybeQuantLinear, lm_head: super::tp_linear::MaybeQuantLinear,
/// Replicated vision tower (TP-vision). Loaded on every rank from
/// the full, unsharded `model.visual.*` weights; `None` for
/// text-only checkpoints. Each rank encodes the same image
/// independently — no sharding, no broadcast — which keeps the
/// spliced input embeddings identical across ranks (the
/// replicated-hidden-state invariant the sharded layers rely on).
vision: Option<VisionTower>,
/// `<|image_pad|>` sentinel id (mirrors `Config::image_token_id`);
/// the splice target for `forward_with_vision`.
image_token_id: Option<u32>,
}
/// Load the replicated vision tower from the unsharded `model.visual.*`
/// weights when the config carries a `vision_config` block. Shared by
/// the cuda and non-cuda `load` variants. `vb.pp("model.visual")`
/// resolves against the same full safetensors every rank mmaps; plain
/// `.get()` on a `ShardedVarBuilder` returns the full (replicated)
/// tensor, so this loads identically regardless of `world_size`.
fn load_replicated_vision_tower(
config: &Config,
vb: &ShardedVarBuilder,
) -> Result<Option<VisionTower>> {
match config.vision_config.clone() {
Some(vcfg) => {
tracing::info!(
depth = vcfg.depth,
hidden_size = vcfg.hidden_size,
"loading qwen3_5 vision tower (TP replicated)"
);
let tower = VisionTower::load(vcfg, vb.pp("model.visual"))
.context("load qwen3_5 vision tower (model.visual.*) [TP replicated]")?;
Ok(Some(tower))
}
None => Ok(None),
}
} }
impl TpQwen3_5ForCausalLM { impl TpQwen3_5ForCausalLM {
@@ -1012,7 +1145,14 @@ impl TpQwen3_5ForCausalLM {
let cfg = &config.text_config; let cfg = &config.text_config;
let base = TpQwen3_5Model::load(cfg, vb, mmap, rank, world_size, comm, quant)?; let base = TpQwen3_5Model::load(cfg, vb, mmap, rank, world_size, comm, quant)?;
let lm_head = build_lm_head(cfg, vb, &base, quant)?; let lm_head = build_lm_head(cfg, vb, &base, quant)?;
let model = Self { base, lm_head }; let vision = load_replicated_vision_tower(&config, vb)?;
let image_token_id = config.image_token_id;
let model = Self {
base,
lm_head,
vision,
image_token_id,
};
log_construction_complete(cfg, rank, world_size, quant, model.device()); log_construction_complete(cfg, rank, world_size, quant, model.device());
Ok(model) Ok(model)
} }
@@ -1029,17 +1169,198 @@ impl TpQwen3_5ForCausalLM {
let cfg = &config.text_config; let cfg = &config.text_config;
let base = TpQwen3_5Model::load(cfg, vb, mmap, rank, world_size, quant)?; let base = TpQwen3_5Model::load(cfg, vb, mmap, rank, world_size, quant)?;
let lm_head = build_lm_head(cfg, vb, &base, quant)?; let lm_head = build_lm_head(cfg, vb, &base, quant)?;
let model = Self { base, lm_head }; let vision = load_replicated_vision_tower(&config, vb)?;
let image_token_id = config.image_token_id;
let model = Self {
base,
lm_head,
vision,
image_token_id,
};
log_construction_complete(cfg, rank, world_size, quant, model.device()); log_construction_complete(cfg, rank, world_size, quant, model.device());
Ok(model) Ok(model)
} }
/// True when this TP load materialised a replicated vision tower.
/// Drives capability advertising and the Stage 3 vision dispatch.
pub fn has_vision(&self) -> bool {
self.vision.is_some()
}
/// `<|image_pad|>` sentinel id, when known.
pub fn image_token_id(&self) -> Option<u32> {
self.image_token_id
}
/// Encode one preprocessed `(C, H, W)` image into LM-side patch
/// embeddings `(N_lm, hidden)` via this rank's replicated tower.
/// Errors when loaded without a vision tower.
pub fn encode_image(&self, image: &Tensor) -> Result<Tensor> {
self.vision
.as_ref()
.ok_or_else(|| {
anyhow::anyhow!(
"encode_image: this TP Qwen3.6 load has no vision tower \
(config.json::vision_config absent or weights missing)"
)
})?
.forward(image)
}
pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> { pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
let (_, l) = input.dims2()?; let (_, l) = input.dims2()?;
let hidden = self.base.forward(input, offset)?; let hidden = self.base.forward(input, offset)?;
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head) hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
} }
/// Forward for a vision-prefill chunk (optional image splice +
/// explicit interleaved-M-RoPE `position_ids`). Mirrors `forward`
/// but routes through `TpQwen3_5Model::forward_with_positions`.
pub fn forward_with_positions(
&mut self,
input: &Tensor,
offset: usize,
position_ids: &Tensor,
image_embeds: Option<&Tensor>,
image_token_id: Option<u32>,
) -> candle_core::Result<Tensor> {
let (_, l) = input.dims2()?;
let hidden = self.base.forward_with_positions(
input,
offset,
position_ids,
image_embeds,
image_token_id,
)?;
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
}
/// End-to-end image prefill on one rank: encode each preprocessed
/// `(C, H, W)` pixel tensor through this rank's replicated tower,
/// concatenate the per-image embeddings along the patch axis, and
/// forward with the splice. Shared by the leader (`TpLeaderModel`)
/// and the subprocess worker (`WorkerModel`) so every rank runs the
/// identical encode → splice → forward and keeps the replicated
/// hidden state in lockstep. Returns last-position logits
/// `(B, 1, vocab)`, same contract as `forward`.
/// Encode every preprocessed `(C,H,W)` image once through this
/// rank's replicated tower and concatenate along the patch axis →
/// `(sum_patches, hidden)`. Done once per prefill, not per chunk.
fn encode_images_concat(&self, image_pixels: &[Tensor]) -> candle_core::Result<Tensor> {
let mut per_image = Vec::with_capacity(image_pixels.len());
for (idx, img) in image_pixels.iter().enumerate() {
let embed = self
.encode_image(img)
.map_err(|e| candle_core::Error::Msg(format!("encode image[{idx}]: {e:#}")))?;
per_image.push(embed);
}
Tensor::cat(&per_image.iter().collect::<Vec<_>>(), 0)
}
/// Chunked image prefill on one rank. Encodes the image(s) once,
/// then walks the (pre-expanded) prompt in `chunk_size`-token
/// windows — exactly like the text `chunked_prefill_tp` — splicing
/// the patch embeddings into whichever chunk(s) carry `<|image_pad|>`
/// positions. Activation memory is bounded by the chunk, not the
/// full prompt, so a long vision context no longer single-shot-OOMs.
///
/// Every rank runs the identical chunk sequence (same `tokens.len()`
/// and `chunk_size`), so the row-parallel `AllReduce`s pair up
/// chunk-by-chunk across ranks with no extra synchronisation. The KV
/// cache accumulates across chunks via the growing offset; only the
/// final chunk's last-position logits are returned (intermediate
/// chunks just populate the cache, same as the text path).
pub fn prefill_with_images_chunked(
&mut self,
tokens: &[u32],
base_offset: usize,
image_pixels: &[Tensor],
image_token_id: u32,
chunk_size: usize,
) -> candle_core::Result<Tensor> {
if image_pixels.is_empty() {
candle_core::bail!("prefill_with_images_chunked: called with zero images");
}
if tokens.is_empty() {
candle_core::bail!("prefill_with_images_chunked: empty prompt");
}
let chunk_size = chunk_size.max(1);
let device = self.device().clone();
let image_embeds = self.encode_images_concat(image_pixels)?;
// Each image's LM grid (lm_gh, lm_gw) = (h/factor, w/factor),
// factor = patch×merge. Recomputed per rank from this rank's own
// pixel tensors — deterministic, so every rank's grids (and hence
// M-RoPE positions) match without crossing the RPC (#14).
let factor = self
.vision
.as_ref()
.map(|v| {
let c = v.config();
c.patch_size * c.spatial_merge_size
})
.ok_or_else(|| {
candle_core::Error::Msg(
"prefill_with_images_chunked: loaded without a vision tower".into(),
)
})?;
let grids: Vec<(usize, usize)> = image_pixels
.iter()
.map(|t| {
let (_, h, w) = t.dims3()?;
Ok::<(usize, usize), candle_core::Error>((h / factor, w / factor))
})
.collect::<candle_core::Result<Vec<_>>>()?;
// Interleaved-M-RoPE 3D position ids for the whole prompt,
// computed once and sliced per chunk so every rank assigns image
// tokens their grid coordinates (and text after an image resumes
// from the compressed counter). `rope_delta` is stored on the base
// model for the decode that follows this prefill. Every chunk —
// text or image — uses the M-RoPE slice, because each image shifts
// the positions of the text around it.
let (text, height, width, delta) =
crate::harness::arch::qwen3_5::rope::get_rope_index(tokens, image_token_id, &grids)
.map_err(|e| candle_core::Error::Msg(format!("get_rope_index: {e}")))?;
self.base.set_rope_delta(delta);
let full_pos = crate::harness::arch::qwen3_5::rope::mrope_position_tensor(
&text, &height, &width, &device,
)?;
let mut last_logits: Option<Tensor> = None;
// Rows of `image_embeds` already spliced by earlier chunks. The
// `<|image_pad|>` run is contiguous, so chunks consume embedding
// rows in order.
let mut img_off = 0usize;
let mut start = 0usize;
while start < tokens.len() {
let end = (start + chunk_size).min(tokens.len());
let chunk = &tokens[start..end];
let input = Tensor::new(chunk, &device)?.unsqueeze(0)?;
let pos_slice = full_pos.narrow(1, start, end - start)?;
let n_here = chunk.iter().filter(|&&t| t == image_token_id).count();
let logits = if n_here == 0 {
self.forward_with_positions(&input, base_offset + start, &pos_slice, None, None)?
} else {
// Splice the next `n_here` patch rows at this chunk's
// local image-pad positions.
let rows = image_embeds.narrow(0, img_off, n_here)?;
img_off += n_here;
self.forward_with_positions(
&input,
base_offset + start,
&pos_slice,
Some(&rows),
Some(image_token_id),
)?
};
last_logits = Some(logits);
start = end;
}
last_logits
.ok_or_else(|| candle_core::Error::Msg("prefill_with_images_chunked: no chunks".into()))
}
pub fn clear_kv_cache(&mut self) { pub fn clear_kv_cache(&mut self) {
self.base.clear_kv_cache(); self.base.clear_kv_cache();
} }

View File

@@ -47,6 +47,34 @@ impl WorkerModel {
} }
} }
/// Chunked image prefill on this rank. Only the vision-capable
/// `qwen3_5` arch has a replicated tower; the dense `qwen3` arch
/// errors. The returned logits are discarded by the caller (the
/// leader samples from its own rank-0 copy) — the value is the NCCL
/// collectives the forward issues, chunk by chunk in lockstep with
/// the leader.
fn prefill_with_images_chunked(
&mut self,
tokens: &[u32],
base_offset: usize,
image_pixels: &[candle_core::Tensor],
image_token_id: u32,
chunk_size: usize,
) -> candle_core::Result<candle_core::Tensor> {
match self {
WorkerModel::Qwen3_5(m) => m.prefill_with_images_chunked(
tokens,
base_offset,
image_pixels,
image_token_id,
chunk_size,
),
WorkerModel::Qwen3(_) => {
candle_core::bail!("prefill_with_images_chunked: qwen3 (dense) has no vision tower")
}
}
}
fn clear_kv_cache(&mut self) { fn clear_kv_cache(&mut self) {
match self { match self {
WorkerModel::Qwen3(m) => m.clear_kv_cache(), WorkerModel::Qwen3(m) => m.clear_kv_cache(),
@@ -167,6 +195,21 @@ impl WorkerState {
tokens, tokens,
offset, offset,
} => self.handle_generate_step(&model_id, tokens, offset), } => self.handle_generate_step(&model_id, tokens, offset),
WorkerRequest::GenerateStepWithImages {
model_id,
tokens,
offset,
image_token_id,
image_data_uris,
chunk_size,
} => self.handle_generate_step_with_images(
&model_id,
tokens,
offset,
image_token_id,
image_data_uris,
chunk_size,
),
WorkerRequest::ClearKvCache { model_id } => self.handle_clear_kv_cache(&model_id), WorkerRequest::ClearKvCache { model_id } => self.handle_clear_kv_cache(&model_id),
WorkerRequest::UnloadModel { model_id } => self.handle_unload_model(&model_id), WorkerRequest::UnloadModel { model_id } => self.handle_unload_model(&model_id),
WorkerRequest::Shutdown => WorkerResponse::Bye, WorkerRequest::Shutdown => WorkerResponse::Bye,
@@ -418,6 +461,117 @@ impl WorkerState {
} }
} }
/// Image-bearing prefill on this rank. Preprocesses each source data
/// URI through the same deterministic `preprocess_data_uri` the
/// leader runs, encodes through this rank's replicated tower, and
/// splices + forwards. The logits are discarded (the leader samples
/// from rank 0); the row-parallel `AllReduce`s are the point.
#[cfg(feature = "cuda")]
fn handle_generate_step_with_images(
&mut self,
model_id: &str,
tokens: Vec<u32>,
offset: usize,
image_token_id: u32,
image_data_uris: Vec<String>,
chunk_size: usize,
) -> WorkerResponse {
use crate::harness::preprocess::{PreprocessProfile, preprocess_data_uri};
use candle_core::Tensor;
if image_data_uris.is_empty() {
return WorkerResponse::Error {
kind: "bad_request".into(),
message: "GenerateStepWithImages with zero images".into(),
};
}
let Some(model) = self.models.get_mut(model_id) else {
return WorkerResponse::Error {
kind: "model_not_loaded".into(),
message: format!("model '{model_id}' not loaded on rank {}", self.config.rank),
};
};
let device = model.device().clone();
// Preprocess each image identically to the leader so the encoded
// embeddings — and thus the spliced hidden state and per-image
// grids — match across ranks. Native-aspect `smart_resize` (#14);
// deterministic, so each rank derives the same dims.
let profile = PreprocessProfile::qwen3_6();
let mut pixels: Vec<Tensor> = Vec::with_capacity(image_data_uris.len());
for (idx, uri) in image_data_uris.iter().enumerate() {
let (px, h, w) = match preprocess_data_uri(uri, &profile) {
Ok(p) => p,
Err(e) => {
return WorkerResponse::Error {
kind: "bad_request".into(),
message: format!("preprocess image[{idx}]: {e:#}"),
};
}
};
match Tensor::from_vec(px, (3, h as usize, w as usize), &device) {
Ok(t) => pixels.push(t),
Err(e) => {
return WorkerResponse::Error {
kind: "forward_failed".into(),
message: format!("build image[{idx}] tensor: {e}"),
};
}
}
}
let start = std::time::Instant::now();
tracing::debug!(
rank = self.config.rank,
model = %model_id,
tokens = tokens.len(),
offset,
images = pixels.len(),
chunk_size,
"worker GenerateStepWithImages: chunked prefill starting"
);
// Drop the logits — the leader samples from its own rank-0 copy.
// The chunked prefill builds its own per-chunk input tensors.
if let Err(e) =
model.prefill_with_images_chunked(&tokens, offset, &pixels, image_token_id, chunk_size)
{
tracing::warn!(
rank = self.config.rank,
model = %model_id,
elapsed_ms = start.elapsed().as_millis(),
error = %e,
"worker GenerateStepWithImages: forward failed"
);
return WorkerResponse::Error {
kind: "forward_failed".into(),
message: format!("TP image forward: {e}"),
};
}
tracing::debug!(
rank = self.config.rank,
model = %model_id,
elapsed_ms = start.elapsed().as_millis(),
"worker GenerateStepWithImages: forward done"
);
WorkerResponse::GenerateStepOk
}
#[cfg(not(feature = "cuda"))]
fn handle_generate_step_with_images(
&mut self,
_model_id: &str,
_tokens: Vec<u32>,
_offset: usize,
_image_token_id: u32,
_image_data_uris: Vec<String>,
_chunk_size: usize,
) -> WorkerResponse {
WorkerResponse::Error {
kind: "cuda_feature_not_enabled".into(),
message: "GenerateStepWithImages requires --features cuda".into(),
}
}
#[cfg(feature = "cuda")] #[cfg(feature = "cuda")]
fn handle_clear_kv_cache(&mut self, model_id: &str) -> WorkerResponse { fn handle_clear_kv_cache(&mut self, model_id: &str) -> WorkerResponse {
let Some(model) = self.models.get_mut(model_id) else { let Some(model) = self.models.get_mut(model_id) else {

View File

@@ -6,3 +6,4 @@ pub mod discovery;
pub mod harness; pub mod harness;
pub mod health; pub mod health;
pub mod startup; pub mod startup;
pub mod wire;

View File

@@ -7,6 +7,7 @@
use crate::activation::ActivationTracker; use crate::activation::ActivationTracker;
use crate::harness::HarnessRegistry; use crate::harness::HarnessRegistry;
use crate::harness::preflight::PreflightError;
use cortex_core::harness::ModelSpec; use cortex_core::harness::ModelSpec;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::signal; use tokio::signal;
@@ -53,6 +54,20 @@ pub async fn load_default_models(
Err(e) => { Err(e) => {
let rendered = format!("{e:#}"); let rendered = format!("{e:#}");
activation.fail_loading(&spec.model_id, &rendered).await; activation.fail_loading(&spec.model_id, &rendered).await;
// When the underlying failure is a preflight rejection,
// pull the structured fields out so journalctl shows
// `reason=tp_requires_safetensors detail="..."` instead
// of an opaque "fetch config.json … 404". The operator
// can act on the structured form directly.
if let Some(pf) = e.downcast_ref::<PreflightError>() {
tracing::warn!(
model = %spec.model_id,
reason = preflight_kind(pf),
detail = %pf,
elapsed_ms = start.elapsed().as_millis() as u64,
"failed to load default model, continuing"
);
} else {
tracing::warn!( tracing::warn!(
model = %spec.model_id, model = %spec.model_id,
error = %rendered, error = %rendered,
@@ -62,9 +77,22 @@ pub async fn load_default_models(
} }
} }
} }
}
activation.mark_ready().await; activation.mark_ready().await;
} }
/// Short kebab-case tag for a preflight failure. Used as a structured
/// log field so journalctl filtering can match on the failure class
/// (`reason=tp_requires_safetensors`, `reason=quant_not_found`, etc.).
fn preflight_kind(err: &PreflightError) -> &'static str {
match err {
PreflightError::RepoFetchFailed { .. } => "repo_fetch_failed",
PreflightError::EmptyRepo { .. } => "empty_repo",
PreflightError::TpRequiresSafetensors { .. } => "tp_requires_safetensors",
PreflightError::QuantNotFound { .. } => "quant_not_found",
}
}
/// Future that resolves on SIGINT (Ctrl-C) or SIGTERM (systemd stop). /// Future that resolves on SIGINT (Ctrl-C) or SIGTERM (systemd stop).
/// ///
/// Wired into `axum::serve(...).with_graceful_shutdown(shutdown_signal())` /// Wired into `axum::serve(...).with_graceful_shutdown(shutdown_signal())`

View File

@@ -0,0 +1,306 @@
//! Format-agnostic inference event stream.
//!
//! The candle harness emits a sequence of these for every streaming
//! request. Wire-format projections in sibling modules
//! ([`super::openai_chat`], the eventual `openai_responses` /
//! `anthropic_messages` projections) read this stream and produce
//! the chunks / events their HTTP clients expect.
//!
//! Design notes:
//!
//! - [`Start`] carries no token of its own. It only signals "the
//! model has accepted the prompt and is about to begin emitting
//! text". OpenAI chat materialises this as a `role: assistant`
//! chunk; OpenAI Responses as the `response.created` +
//! `response.output_item.added` pair; Anthropic as
//! `message_start`. All three of those would otherwise have to
//! peek at the *first* token to know when to emit, which couples
//! the wire layer to the producer's pacing.
//! - [`TextDelta`] is *visible* output. Reasoning / `<think>`
//! blocks go through a future [`ReasoningDelta`] variant once
//! the harness learns to split them (today they pass through as
//! plain text inside `TextDelta`; helexa-acp picks them apart on
//! the consumer side).
//! - [`Finish`] is the only place a stream is allowed to end
//! cleanly. Projections rely on this to emit final usage
//! bookkeeping; absence means the producer crashed and the
//! consumer should treat the stream as truncated.
//!
//! [`Start`]: InferenceEvent::Start
//! [`TextDelta`]: InferenceEvent::TextDelta
//! [`Finish`]: InferenceEvent::Finish
/// One unit of output from the inference loop.
///
/// Producers send these on an `mpsc::Sender<InferenceEvent>`;
/// projection layers in sibling modules consume them and emit
/// wire-format-specific frames downstream.
#[derive(Debug, Clone)]
pub enum InferenceEvent {
/// The producer has accepted the prompt and is about to emit
/// the first token. Sent at most once per stream.
Start,
/// A piece of visible assistant text. Multiple deltas
/// concatenate into the complete reply.
TextDelta(String),
/// Reasoning / scratchpad text the model emitted inside a
/// `<think>` block (or equivalent). The harness routes
/// content between marker tokens here so wire projectors can
/// decide what to do with it (chat completions drops by
/// default; Responses API has a dedicated event family).
ReasoningDelta(String),
/// A tool call has been parsed out of a `<tool_call>{json}</tool_call>`
/// block. Carries the parsed name + arguments JSON string
/// (Anthropic / OpenAI projectors emit their own wire shape
/// from this).
///
/// `index` is the call slot — incremented per tool call in a
/// turn so wire formats that order calls by index
/// (OpenAI chat completions) can correlate.
ToolCall {
index: usize,
id: String,
name: String,
/// Complete JSON arguments string. The model could in
/// principle stream these token-by-token, but our
/// extraction buffers the whole block until `</tool_call>`
/// arrives and emits exactly one event per call.
arguments: String,
},
/// The stream is complete. Carries the reason so wire formats
/// that use it (OpenAI's `finish_reason`, Anthropic's
/// `stop_reason`) can render it without re-parsing.
Finish { reason: FinishReason },
}
/// Why a stream stopped. Stays small on purpose — anything that
/// doesn't map cleanly to one of these collapses to [`Stop`].
///
/// Mappings to wire formats:
///
/// | variant | OpenAI `finish_reason` | OpenAI Responses `status` | Anthropic `stop_reason` |
/// |---------|------------------------|---------------------------|-------------------------|
/// | `Stop` | `"stop"` | `"completed"` | `"end_turn"` |
/// | `Length`| `"length"` | `"incomplete"` | `"max_tokens"` |
/// | `ToolCalls` | `"tool_calls"` | `"completed"` | `"tool_use"` |
///
/// [`Stop`]: FinishReason::Stop
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FinishReason {
/// Model emitted EOS naturally.
Stop,
/// Hit `max_tokens` before EOS.
Length,
/// Stopped because the model called a tool and is waiting for
/// the result. Not yet emitted by the candle harness —
/// reserved for the day tool-call extraction lands.
#[allow(dead_code)]
ToolCalls,
}
impl FinishReason {
/// String form used by OpenAI chat completions and OpenAI
/// completions. Wire modules can call this directly or do their
/// own mapping for non-string formats.
pub fn as_openai_str(self) -> &'static str {
match self {
FinishReason::Stop => "stop",
FinishReason::Length => "length",
FinishReason::ToolCalls => "tool_calls",
}
}
}
/// Open/close token IDs for the reasoning marker a loaded model uses
/// (or `None` for non-reasoning models). The harness reads this once
/// at load time from the tokenizer's added-tokens table, then the
/// inference loop checks `next_token` against the pair to flip
/// between [`InferenceEvent::TextDelta`] and
/// [`InferenceEvent::ReasoningDelta`].
///
/// `open` and `close` text are kept alongside the IDs so wire
/// projectors that want to re-emit the literal markers (the
/// opt-in `include_thinking` path on chat completions) don't have
/// to reach back into the tokenizer for the strings.
#[derive(Debug, Clone)]
pub struct ReasoningTokenPair {
pub open_id: u32,
pub close_id: u32,
pub open_text: String,
pub close_text: String,
}
/// Known reasoning-marker conventions. Each is a `(open, close)`
/// pair of literal token strings. Each modern reasoning model
/// declares its markers in the tokenizer's `added_tokens` table;
/// at load time we probe for whichever pair the loaded tokenizer
/// has and stash both IDs.
///
/// Ordering matters only for tie-breaking when a model declares
/// multiple pairs (shouldn't happen in practice); the first hit
/// wins.
const KNOWN_REASONING_MARKERS: &[(&str, &str)] = &[
// Qwen3, DeepSeek-R1, gpt-oss, and most other open-weight
// reasoning models.
("<think>", "</think>"),
// Mistral Magistral.
("[THINK]", "[/THINK]"),
// Some older derivatives; harmless to probe.
("<thought>", "</thought>"),
("<reasoning>", "</reasoning>"),
];
/// Open/close token IDs for the model's tool-call marker
/// convention (or `None` for models that don't emit structured
/// tool calls). Same shape as [`ReasoningTokenPair`]: probed once
/// at load time, consumed by the inference loop to switch between
/// "emit visible deltas" and "buffer JSON for the next tool
/// call".
#[derive(Debug, Clone)]
pub struct ToolCallTokenPair {
pub open_id: u32,
pub close_id: u32,
pub open_text: String,
pub close_text: String,
}
/// Tool-call marker conventions. Open-weight tool-use models
/// converged on `<tool_call>` / `</tool_call>` (Qwen3-Coder /
/// -Instruct, the Hermes function-call format, DeepSeek-Coder,
/// gpt-oss). The pair lives alongside the reasoning markers in
/// the same `added_tokens` table.
const KNOWN_TOOL_CALL_MARKERS: &[(&str, &str)] = &[("<tool_call>", "</tool_call>")];
/// Probe a tokenizer for known tool-call marker pairs. Mirrors
/// [`detect_reasoning_token_pair`] — both open AND close must
/// resolve for the pair to be returned. `None` means the model
/// doesn't emit structured tool calls (or its tokenizer split
/// the markers across tokens).
pub fn detect_tool_call_token_pair<F>(token_to_id: F) -> Option<ToolCallTokenPair>
where
F: Fn(&str) -> Option<u32>,
{
for (open_text, close_text) in KNOWN_TOOL_CALL_MARKERS {
let open_id = token_to_id(open_text);
let close_id = token_to_id(close_text);
if let (Some(open_id), Some(close_id)) = (open_id, close_id) {
return Some(ToolCallTokenPair {
open_id,
close_id,
open_text: (*open_text).into(),
close_text: (*close_text).into(),
});
}
}
None
}
/// Inspect a tokenizer for known reasoning-marker pairs and return
/// the first match. The tokenizer types this trait is defined over
/// just need to expose `token_to_id(&str) -> Option<u32>` so this
/// stays decoupled from the candle crate — the production caller
/// passes a `tokenizers::Tokenizer`, but tests can fake one.
///
/// Returns `None` when no known marker pair is fully declared
/// (both open AND close token ids must resolve). That's the
/// pass-through case — non-reasoning models, or reasoning models
/// whose tokenizer split the markers across multiple tokens (rare
/// in practice; modern reasoning tokenizers list them as
/// `added_tokens`).
pub fn detect_reasoning_token_pair<F>(token_to_id: F) -> Option<ReasoningTokenPair>
where
F: Fn(&str) -> Option<u32>,
{
for (open_text, close_text) in KNOWN_REASONING_MARKERS {
let open_id = token_to_id(open_text);
let close_id = token_to_id(close_text);
if let (Some(open_id), Some(close_id)) = (open_id, close_id) {
return Some(ReasoningTokenPair {
open_id,
close_id,
open_text: (*open_text).into(),
close_text: (*close_text).into(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn lookup<'a>(map: &'a HashMap<&'static str, u32>) -> impl Fn(&str) -> Option<u32> + 'a {
|s| map.get(s).copied()
}
#[test]
fn detects_qwen3_style_think_markers() {
let mut m = HashMap::new();
m.insert("<think>", 151648);
m.insert("</think>", 151649);
let pair = detect_reasoning_token_pair(lookup(&m)).expect("pair detected");
assert_eq!(pair.open_id, 151648);
assert_eq!(pair.close_id, 151649);
assert_eq!(pair.open_text, "<think>");
assert_eq!(pair.close_text, "</think>");
}
#[test]
fn detects_mistral_magistral_markers() {
let mut m = HashMap::new();
m.insert("[THINK]", 100);
m.insert("[/THINK]", 101);
let pair = detect_reasoning_token_pair(lookup(&m)).expect("pair detected");
assert_eq!(pair.open_text, "[THINK]");
}
#[test]
fn returns_none_when_only_open_marker_present() {
// A pathological tokenizer that has `<think>` but not
// `</think>` shouldn't half-detect. Pass-through.
let mut m = HashMap::new();
m.insert("<think>", 1);
assert!(detect_reasoning_token_pair(lookup(&m)).is_none());
}
#[test]
fn returns_none_for_non_reasoning_tokenizer() {
let m: HashMap<&'static str, u32> = HashMap::new();
assert!(detect_reasoning_token_pair(lookup(&m)).is_none());
}
#[test]
fn detects_tool_call_markers() {
let mut m = HashMap::new();
m.insert("<tool_call>", 151657);
m.insert("</tool_call>", 151658);
let pair = detect_tool_call_token_pair(lookup(&m)).expect("pair detected");
assert_eq!(pair.open_id, 151657);
assert_eq!(pair.close_id, 151658);
assert_eq!(pair.open_text, "<tool_call>");
assert_eq!(pair.close_text, "</tool_call>");
}
#[test]
fn returns_none_for_non_tool_use_tokenizer() {
let m: HashMap<&'static str, u32> = HashMap::new();
assert!(detect_tool_call_token_pair(lookup(&m)).is_none());
}
#[test]
fn first_match_wins_when_multiple_pairs_declared() {
// Hypothetical tokenizer with both Qwen-style AND Mistral-style
// markers — the `<think>` pair is earlier in the convention
// table so it wins.
let mut m = HashMap::new();
m.insert("<think>", 1);
m.insert("</think>", 2);
m.insert("[THINK]", 3);
m.insert("[/THINK]", 4);
let pair = detect_reasoning_token_pair(lookup(&m)).unwrap();
assert_eq!(pair.open_id, 1);
assert_eq!(pair.close_id, 2);
}
}

View File

@@ -0,0 +1,27 @@
//! Wire-format projection layer.
//!
//! The candle harness produces a single, format-agnostic stream of
//! [`InferenceEvent`]s. Each wire format (OpenAI chat completions,
//! OpenAI Responses, Anthropic messages, …) lives in its own module
//! under `wire::` and projects that event stream into the chunks /
//! events its HTTP clients expect.
//!
//! The benefit over translating *between* wire shapes (OpenAI chat
//! → Anthropic, etc.) is that we never have to reason about a
//! wire-N → wire-M conversion: every translation is wire-N ↔ the
//! internal event currency, and the projections are independent. A
//! new wire format adds a new file under `wire::`; nothing else
//! needs to know about it.
//!
//! Today: [`openai_chat`]. Stage 2 adds `openai_responses`. Stage 3
//! could add a native Anthropic projection that replaces the
//! gateway-side translation.
pub mod event;
pub mod openai_chat;
pub mod openai_responses;
pub use event::{
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
detect_reasoning_token_pair, detect_tool_call_token_pair,
};

View File

@@ -0,0 +1,558 @@
//! OpenAI chat completions projection.
//!
//! Reads [`InferenceEvent`]s from a receiver and produces
//! [`ChatCompletionChunk`]s in the shape `POST /v1/chat/completions`
//! clients expect on its streaming SSE response. The HTTP handler in
//! [`crate::api`] wraps the resulting receiver in axum's
//! `Sse::new(...)` adapter; nothing in this module touches HTTP
//! framing or `data:` lines.
//!
//! Per the OpenAI streaming spec, three chunk shapes appear:
//!
//! 1. **Role chunk** — `delta: { "role": "assistant" }`, no content,
//! sent once at stream start. We emit this on [`InferenceEvent::Start`].
//! 2. **Content chunks** — `delta: { "content": "<text>" }`, one per
//! [`InferenceEvent::TextDelta`].
//! 3. **Final chunk** — empty `delta`, `finish_reason` populated.
//! Emitted on [`InferenceEvent::Finish`].
//!
//! `usage` stays `None` on every chunk; the legacy candle paths
//! never surfaced usage on the streaming endpoint and we keep that
//! behaviour bit-for-bit so existing clients see no diff.
//!
//! Back-pressure: the projection task awaits both `rx.recv()` and
//! `tx.send()`. A slow consumer fills the output channel → the
//! task blocks on send → it stops reading from the input → the
//! producer blocks on its own send. The bounded channels
//! propagate without us writing any logic.
use cortex_core::openai::{ChatCompletionChunk, ChunkChoice};
use serde_json::json;
use tokio::sync::mpsc;
use super::event::{FinishReason, InferenceEvent, ReasoningTokenPair};
/// Output channel buffer size. Mirrors the input side's bound; one
/// event maps to at most one chunk, so equal capacity keeps the
/// two ends in sync without surprising memory growth.
const CHUNK_CHANNEL_CAPACITY: usize = 32;
/// Per-stream config for the chat projector. Used by the
/// production handler to thread per-request choices (currently:
/// whether to surface reasoning content) into the projection
/// without bloating the function signature.
#[derive(Debug, Clone, Default)]
pub struct ChatProjectionConfig {
/// When `true`, reasoning content is re-wrapped with the
/// model's literal open/close markers and emitted as content
/// deltas — preserving the on-the-wire shape that
/// reasoning-aware clients like helexa-acp's `ThinkParser`
/// expect.
///
/// When `false` (the default), [`InferenceEvent::ReasoningDelta`]s
/// are dropped entirely so consumers that don't know about
/// reasoning (Zed's commit-message generator, any vanilla
/// OpenAI client) don't have model-internal scratchpad
/// material leaking into their UI. The chat-completions wire
/// format has no slot for reasoning, so the default chooses
/// the safer-for-naïve-clients behaviour.
pub include_thinking: bool,
/// Open/close marker strings to re-emit when `include_thinking`
/// is set. Sourced from the loaded model's
/// [`ReasoningTokenPair`]; `None` for non-reasoning models or
/// when the caller doesn't have the pair handy (in which case
/// `include_thinking` becomes equivalent to dropping reasoning
/// because there's nothing to wrap).
pub reasoning_markers: Option<ReasoningTokenPair>,
}
/// Project an [`InferenceEvent`] receiver into a
/// [`ChatCompletionChunk`] receiver. Spawns one tokio task that
/// owns the input receiver for the stream's lifetime and exits
/// when either side closes.
///
/// `id`, `created`, and `model_id` are stamped into every emitted
/// chunk so the receiver can stay generic (decoupled from
/// per-request metadata).
pub fn project_chat_stream(
rx: mpsc::Receiver<InferenceEvent>,
id: String,
created: u64,
model_id: String,
) -> mpsc::Receiver<ChatCompletionChunk> {
// Default config: include_thinking off, no marker rewrap.
project_chat_stream_with(rx, id, created, model_id, ChatProjectionConfig::default())
}
/// Same as [`project_chat_stream`] but with a per-stream config
/// (currently controlling reasoning surfacing). Production
/// callers that need the opt-in path call this directly; the
/// shorter wrapper above stays as the no-config convenience.
pub fn project_chat_stream_with(
mut rx: mpsc::Receiver<InferenceEvent>,
id: String,
created: u64,
model_id: String,
config: ChatProjectionConfig,
) -> mpsc::Receiver<ChatCompletionChunk> {
let (tx, out_rx) = mpsc::channel::<ChatCompletionChunk>(CHUNK_CHANNEL_CAPACITY);
tokio::spawn(async move {
// Track whether the previous event was inside a reasoning
// block — used to decide when to emit the literal close
// marker on the include_thinking re-wrap path. When this
// flips from true → false (a TextDelta or Finish lands
// after one or more ReasoningDeltas), we emit the close
// marker exactly once.
let mut was_in_reasoning = false;
while let Some(event) = rx.recv().await {
// Close-marker insertion: if we're leaving a reasoning
// chain, emit the literal close marker before the
// current event.
if was_in_reasoning && !matches!(event, InferenceEvent::ReasoningDelta(_)) {
if let Some(marker) = config
.include_thinking
.then_some(())
.and(config.reasoning_markers.as_ref())
{
let chunk = content_chunk(&id, created, &model_id, &marker.close_text);
if tx.send(chunk).await.is_err() {
return;
}
}
was_in_reasoning = false;
}
let chunks = match event {
InferenceEvent::Start => vec![role_chunk(&id, created, &model_id)],
InferenceEvent::TextDelta(text) => {
if text.is_empty() {
// DecodeStream is buffering a multi-byte
// codepoint; don't bother sending an empty
// chunk downstream.
continue;
}
vec![content_chunk(&id, created, &model_id, &text)]
}
InferenceEvent::ReasoningDelta(text) => {
if !config.include_thinking {
// Default path — reasoning has no slot in
// chat completions, so it's dropped. Naïve
// clients (Zed commit-message generator,
// any vanilla OpenAI client) get clean
// output.
continue;
}
let Some(markers) = config.reasoning_markers.as_ref() else {
// Caller asked to include thinking but
// didn't supply markers — best we can do
// is emit the content as visible text.
// Skip the wrap entirely.
if text.is_empty() {
continue;
}
let chunk = content_chunk(&id, created, &model_id, &text);
if tx.send(chunk).await.is_err() {
return;
}
continue;
};
// First chunk of a reasoning block → open
// marker prelude. Subsequent reasoning deltas
// in the same block reuse `was_in_reasoning`
// to skip the prelude.
let mut chunks = Vec::new();
if !was_in_reasoning {
chunks.push(content_chunk(&id, created, &model_id, &markers.open_text));
}
if !text.is_empty() {
chunks.push(content_chunk(&id, created, &model_id, &text));
}
was_in_reasoning = true;
chunks
}
InferenceEvent::ToolCall {
index,
id: call_id,
name,
arguments,
} => {
// OpenAI streaming shape for tool calls:
// `delta.tool_calls[]` with id + function.name
// on the first chunk per index, then
// function.arguments deltas. We have the
// complete arguments buffered already, so one
// delta carries everything.
vec![tool_call_chunk(
&id, created, &model_id, index, &call_id, &name, &arguments,
)]
}
InferenceEvent::Finish { reason } => {
vec![final_chunk(&id, created, &model_id, reason)]
}
};
for chunk in chunks {
if tx.send(chunk).await.is_err() {
// Consumer hung up; nothing more to do.
return;
}
}
}
});
out_rx
}
fn role_chunk(id: &str, created: u64, model_id: &str) -> ChatCompletionChunk {
ChatCompletionChunk {
id: id.into(),
object: "chat.completion.chunk".into(),
created,
model: model_id.into(),
choices: vec![ChunkChoice {
index: 0,
delta: json!({ "role": "assistant" }),
finish_reason: None,
extra: serde_json::Value::Object(Default::default()),
}],
usage: None,
extra: serde_json::Value::Object(Default::default()),
}
}
fn content_chunk(id: &str, created: u64, model_id: &str, text: &str) -> ChatCompletionChunk {
ChatCompletionChunk {
id: id.into(),
object: "chat.completion.chunk".into(),
created,
model: model_id.into(),
choices: vec![ChunkChoice {
index: 0,
delta: json!({ "content": text }),
finish_reason: None,
extra: serde_json::Value::Object(Default::default()),
}],
usage: None,
extra: serde_json::Value::Object(Default::default()),
}
}
/// OpenAI chat streaming shape for a tool call. One chunk per
/// call slot, carrying id + name + the complete arguments JSON.
/// Mirrors the format real OpenAI emits on the streaming path,
/// minus the per-token arguments-streaming complication (we have
/// the whole buffer already after the model finishes the
/// `<tool_call>...</tool_call>` block).
fn tool_call_chunk(
id: &str,
created: u64,
model_id: &str,
index: usize,
call_id: &str,
name: &str,
arguments: &str,
) -> ChatCompletionChunk {
ChatCompletionChunk {
id: id.into(),
object: "chat.completion.chunk".into(),
created,
model: model_id.into(),
choices: vec![ChunkChoice {
index: 0,
delta: json!({
"tool_calls": [{
"index": index,
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": arguments,
}
}],
}),
finish_reason: None,
extra: serde_json::Value::Object(Default::default()),
}],
usage: None,
extra: serde_json::Value::Object(Default::default()),
}
}
fn final_chunk(
id: &str,
created: u64,
model_id: &str,
reason: FinishReason,
) -> ChatCompletionChunk {
ChatCompletionChunk {
id: id.into(),
object: "chat.completion.chunk".into(),
created,
model: model_id.into(),
choices: vec![ChunkChoice {
index: 0,
delta: serde_json::Value::Object(Default::default()),
finish_reason: Some(reason.as_openai_str().to_string()),
extra: serde_json::Value::Object(Default::default()),
}],
usage: None,
extra: serde_json::Value::Object(Default::default()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Drain the projection's output into a Vec for assertion.
async fn collect(mut rx: mpsc::Receiver<ChatCompletionChunk>) -> Vec<ChatCompletionChunk> {
let mut out = Vec::new();
while let Some(chunk) = rx.recv().await {
out.push(chunk);
}
out
}
#[tokio::test]
async fn empty_event_stream_yields_no_chunks() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
drop(tx);
let out = collect(project_chat_stream(rx, "id-1".into(), 1700, "m".into())).await;
assert!(out.is_empty());
}
#[tokio::test]
async fn start_text_finish_produces_three_chunks() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream(rx, "id-1".into(), 1700, "m".into());
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::TextDelta("hello".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
assert_eq!(out.len(), 3);
assert_eq!(out[0].choices[0].delta["role"], "assistant");
assert_eq!(out[1].choices[0].delta["content"], "hello");
assert_eq!(out[2].choices[0].finish_reason.as_deref(), Some("stop"));
// Every chunk carries the stamped metadata.
for chunk in &out {
assert_eq!(chunk.id, "id-1");
assert_eq!(chunk.created, 1700);
assert_eq!(chunk.model, "m");
assert_eq!(chunk.object, "chat.completion.chunk");
}
}
#[tokio::test]
async fn empty_text_delta_is_dropped() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream(rx, "id".into(), 1, "m".into());
tx.send(InferenceEvent::TextDelta(String::new()))
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
assert!(out.is_empty(), "empty deltas must not produce chunks");
}
#[tokio::test]
async fn finish_length_maps_to_openai_string() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream(rx, "id".into(), 1, "m".into());
tx.send(InferenceEvent::Finish {
reason: FinishReason::Length,
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
assert_eq!(out.len(), 1);
assert_eq!(out[0].choices[0].finish_reason.as_deref(), Some("length"));
}
#[tokio::test]
async fn reasoning_delta_is_dropped_in_chat_projection() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream(rx, "id".into(), 1, "m".into());
tx.send(InferenceEvent::ReasoningDelta("<think>".into()))
.await
.unwrap();
tx.send(InferenceEvent::TextDelta("real".into()))
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
assert_eq!(out.len(), 1);
assert_eq!(out[0].choices[0].delta["content"], "real");
}
fn pair() -> ReasoningTokenPair {
ReasoningTokenPair {
open_id: 0,
close_id: 1,
open_text: "<think>".into(),
close_text: "</think>".into(),
}
}
#[tokio::test]
async fn include_thinking_rewraps_reasoning_with_literal_markers() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out_rx = project_chat_stream_with(
rx,
"id".into(),
1,
"m".into(),
ChatProjectionConfig {
include_thinking: true,
reasoning_markers: Some(pair()),
},
);
tx.send(InferenceEvent::ReasoningDelta("first ".into()))
.await
.unwrap();
tx.send(InferenceEvent::ReasoningDelta("second".into()))
.await
.unwrap();
tx.send(InferenceEvent::TextDelta("answer".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
// Expected sequence: open marker → reasoning content (2 chunks)
// → close marker → visible answer → final chunk.
let contents: Vec<&str> = out
.iter()
.filter_map(|c| c.choices[0].delta["content"].as_str())
.collect();
assert_eq!(
contents,
vec!["<think>", "first ", "second", "</think>", "answer"]
);
assert_eq!(
out.last().unwrap().choices[0].finish_reason.as_deref(),
Some("stop")
);
}
#[tokio::test]
async fn include_thinking_closes_marker_at_finish_when_no_trailing_text() {
// Edge case: stream ends inside a reasoning block (model
// hit max_tokens mid-thought, no visible answer ever).
// The Finish event still triggers the close marker so the
// stream is balanced.
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream_with(
rx,
"id".into(),
1,
"m".into(),
ChatProjectionConfig {
include_thinking: true,
reasoning_markers: Some(pair()),
},
);
tx.send(InferenceEvent::ReasoningDelta("thinking...".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Length,
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
let contents: Vec<&str> = out
.iter()
.filter_map(|c| c.choices[0].delta["content"].as_str())
.collect();
assert_eq!(contents, vec!["<think>", "thinking...", "</think>"]);
assert_eq!(
out.last().unwrap().choices[0].finish_reason.as_deref(),
Some("length")
);
}
#[tokio::test]
async fn include_thinking_without_markers_emits_content_directly() {
// Defensive: if the caller asks for thinking but the
// model declared no markers, we still emit the content
// rather than dropping it. Better to leak than to lose.
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream_with(
rx,
"id".into(),
1,
"m".into(),
ChatProjectionConfig {
include_thinking: true,
reasoning_markers: None,
},
);
tx.send(InferenceEvent::ReasoningDelta("raw".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
let contents: Vec<&str> = out
.iter()
.filter_map(|c| c.choices[0].delta["content"].as_str())
.collect();
assert_eq!(contents, vec!["raw"]);
}
#[tokio::test]
async fn include_thinking_off_drops_reasoning_even_with_markers() {
// Default behaviour even when markers happen to be
// configured. The flag is the gate, not the marker
// presence.
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream_with(
rx,
"id".into(),
1,
"m".into(),
ChatProjectionConfig {
include_thinking: false,
reasoning_markers: Some(pair()),
},
);
tx.send(InferenceEvent::ReasoningDelta("hidden".into()))
.await
.unwrap();
tx.send(InferenceEvent::TextDelta("visible".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
let contents: Vec<&str> = out
.iter()
.filter_map(|c| c.choices[0].delta["content"].as_str())
.collect();
assert_eq!(contents, vec!["visible"]);
}
}

View File

@@ -0,0 +1,918 @@
//! OpenAI Responses API projection.
//!
//! Two responsibilities:
//!
//! 1. **Translate request shape**: [`request_to_chat`] flattens
//! [`ResponsesRequest`]'s typed `input` items + `instructions`
//! into the [`ChatCompletionRequest`] the candle harness already
//! knows how to run. The Responses-specific shape stops at this
//! function — everything downstream is the same chat path the
//! `/v1/chat/completions` route exercises.
//!
//! 2. **Project event stream**: [`project_responses_stream`] reads
//! [`InferenceEvent`]s from the harness and emits the named SSE
//! events the Responses API client expects
//! (`response.created`, `response.output_text.delta`,
//! `response.completed`, …) along with their JSON payloads.
//! The HTTP handler in [`crate::api`] reads
//! `(event_name, data)` tuples off the receiver and stamps them
//! onto axum SSE frames.
//!
//! Scope cuts (carried over from [`cortex_core::responses`]):
//!
//! - `previous_response_id` is rejected by [`request_to_chat`]
//! with [`TranslateError::ChainedConversationNotSupported`].
//! - `Reasoning` input items are dropped (no equivalent in chat).
//! - `FunctionCall` / `FunctionCallOutput` items round-trip but the
//! harness never emits tool calls today; the synthesis paths are
//! in place so the surface is ready when it does.
use cortex_core::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
use cortex_core::responses::{
ResponsesContentPart, ResponsesInput, ResponsesInputItem, ResponsesMessageContent,
ResponsesOutputContent, ResponsesOutputItem, ResponsesRequest, ResponsesResponse,
ResponsesUsage, events,
};
use serde_json::{Value, json};
use tokio::sync::mpsc;
use super::event::{FinishReason, InferenceEvent};
/// Per-request metadata that has to be stamped into every emitted
/// event. The projector spawns a task that owns one of these.
#[derive(Debug, Clone)]
pub struct ResponseMeta {
pub response_id: String,
pub created_at: u64,
pub model_id: String,
/// Item id used inside `output[0]` (the message). All
/// `content_part.*` and `output_text.*` events reference this
/// so the consumer knows which item the delta belongs to.
pub message_item_id: String,
}
/// Reasons [`request_to_chat`] refuses a request.
#[derive(Debug, thiserror::Error)]
pub enum TranslateError {
#[error(
"previous_response_id is not supported on this neuron; chained \
conversations require server-side state we don't store yet"
)]
ChainedConversationNotSupported,
}
/// Flatten a [`ResponsesRequest`] into the chat-completions shape
/// the candle harness already knows how to drive. Keeps the
/// Responses-specific machinery contained to a single function so
/// the harness stays format-agnostic.
///
/// Semantics:
///
/// - `instructions` (if set) becomes a leading `system` message.
/// - `input: "<string>"` becomes a single `user` message.
/// - `input: [items]` flattens each item:
/// - `Message { role, content }` → one `ChatMessage`.
/// - `FunctionCall` → an `assistant` turn whose `extra.tool_calls`
/// carries the call (chat-completions-shaped). The harness
/// doesn't act on tool_calls today, but the shape stays
/// consistent with what chat would expect.
/// - `FunctionCallOutput` → a `tool` role message with the
/// output text. Matches OpenAI's chat convention.
/// - `Reasoning` items are dropped (no equivalent in chat).
/// - Text parts within an array `content` collapse to a single
/// string; image parts get rendered as a chat-style content
/// array `[{type:"text"}, {type:"image_url"}]` so the chat
/// handler's existing vision path applies.
pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, TranslateError> {
if req.previous_response_id.is_some() {
return Err(TranslateError::ChainedConversationNotSupported);
}
let mut messages: Vec<ChatMessage> = Vec::new();
if let Some(instructions) = req.instructions
&& !instructions.is_empty()
{
messages.push(ChatMessage {
role: "system".into(),
content: MessageContent::Text(instructions),
extra: Value::Object(Default::default()),
});
}
match req.input {
ResponsesInput::Text(text) => {
messages.push(ChatMessage {
role: "user".into(),
content: MessageContent::Text(text),
extra: Value::Object(Default::default()),
});
}
ResponsesInput::Items(items) => {
for item in items {
if let Some(msg) = input_item_to_chat(item) {
messages.push(msg);
}
}
}
}
Ok(ChatCompletionRequest {
model: req.model,
messages,
temperature: req.temperature,
top_p: req.top_p,
max_tokens: req.max_output_tokens,
stream: Some(req.stream),
extra: Value::Object(Default::default()),
})
}
fn input_item_to_chat(item: ResponsesInputItem) -> Option<ChatMessage> {
match item {
ResponsesInputItem::Message { role, content } => Some(ChatMessage {
role,
content: message_content_to_chat(content),
extra: Value::Object(Default::default()),
}),
ResponsesInputItem::FunctionCall {
call_id,
name,
arguments,
} => {
// Express the call in chat-completions shape via
// `extra.tool_calls`. The harness ignores it today but
// the shape is consistent for the day it doesn't.
let mut extra = serde_json::Map::new();
extra.insert(
"tool_calls".into(),
json!([{
"id": call_id,
"type": "function",
"function": { "name": name, "arguments": arguments },
}]),
);
Some(ChatMessage {
role: "assistant".into(),
content: MessageContent::Text(String::new()),
extra: Value::Object(extra),
})
}
ResponsesInputItem::FunctionCallOutput { call_id, output } => {
let mut extra = serde_json::Map::new();
extra.insert("tool_call_id".into(), Value::String(call_id));
Some(ChatMessage {
role: "tool".into(),
content: MessageContent::Text(output),
extra: Value::Object(extra),
})
}
// Reasoning items don't have a chat-completions equivalent
// we can faithfully forward. Silently drop — the alternative
// is rejecting a well-formed request, which is worse UX.
ResponsesInputItem::Reasoning { .. } => None,
}
}
fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
match content {
ResponsesMessageContent::Text(s) => MessageContent::Text(s),
ResponsesMessageContent::Parts(parts) => {
// Collapse to a string when every part is text; emit
// the chat content-array shape only when an image is
// present (some upstreams treat the array form as a
// vision-only signal and reject it for text-only
// models).
let has_image = parts
.iter()
.any(|p| matches!(p, ResponsesContentPart::InputImage { .. }));
if !has_image {
let joined = parts
.into_iter()
.filter_map(|p| match p {
ResponsesContentPart::InputText { text }
| ResponsesContentPart::OutputText { text, .. } => Some(text),
ResponsesContentPart::InputImage { .. } => None,
})
.collect::<Vec<_>>()
.join("\n\n");
return MessageContent::Text(joined);
}
let mut out: Vec<Value> = Vec::with_capacity(parts.len());
for p in parts {
match p {
ResponsesContentPart::InputText { text }
| ResponsesContentPart::OutputText { text, .. } => {
out.push(json!({ "type": "text", "text": text }));
}
ResponsesContentPart::InputImage { image_url, .. } => {
out.push(json!({
"type": "image_url",
"image_url": { "url": image_url },
}));
}
}
}
MessageContent::Parts(out)
}
}
}
// ── Streaming projection ─────────────────────────────────────────────
/// One frame the projector emits. The HTTP handler maps each into
/// an axum `Sse::Event` with both an `event:` name and a `data:`
/// JSON payload — Responses, unlike chat completions, uses named
/// SSE events.
#[derive(Debug, Clone)]
pub struct ResponseStreamFrame {
pub event_name: &'static str,
pub data: Value,
}
/// Project an [`InferenceEvent`] receiver into a stream of
/// [`ResponseStreamFrame`]s. The emitted sequence per stream is:
///
/// 1. `response.created` — shell with `status: "in_progress"`.
/// 2. `response.output_item.added` — empty message item.
/// 3. `response.content_part.added` — empty `output_text` part.
/// 4. `response.output_text.delta` × N — token-by-token text.
/// 5. `response.output_text.done` — full accumulated text.
/// 6. `response.content_part.done` — full part payload.
/// 7. `response.output_item.done` — full message item.
/// 8. `response.completed` — final response with `status:"completed"`.
///
/// Empty TextDeltas (the harness's incomplete-UTF-8 buffering) are
/// dropped. `ReasoningDelta`s have no representation in the
/// Responses API spec we model yet, so they're dropped too.
pub fn project_responses_stream(
rx: mpsc::Receiver<InferenceEvent>,
meta: ResponseMeta,
) -> mpsc::Receiver<ResponseStreamFrame> {
let (tx, out_rx) = mpsc::channel::<ResponseStreamFrame>(64);
tokio::spawn(async move {
run_projection(rx, meta, tx).await;
});
out_rx
}
async fn run_projection(
mut rx: mpsc::Receiver<InferenceEvent>,
meta: ResponseMeta,
tx: mpsc::Sender<ResponseStreamFrame>,
) {
let mut accumulated = String::new();
let mut finish: Option<FinishReason> = None;
let mut emitted_start = false;
while let Some(event) = rx.recv().await {
match event {
InferenceEvent::Start => {
emitted_start = true;
if !emit_start_frames(&tx, &meta).await {
return;
}
}
InferenceEvent::TextDelta(text) => {
if text.is_empty() {
continue;
}
accumulated.push_str(&text);
let frame = ResponseStreamFrame {
event_name: events::OUTPUT_TEXT_DELTA,
data: json!({
"item_id": meta.message_item_id,
"output_index": 0,
"content_index": 0,
"delta": text,
}),
};
if tx.send(frame).await.is_err() {
return;
}
}
InferenceEvent::ReasoningDelta(_) => {
// No representation in our Responses model yet.
// Stage where it'd land: a `response.reasoning_*`
// event family alongside `response.output_text.*`.
}
InferenceEvent::ToolCall { .. } => {
// Responses-side tool-call routing not wired yet
// (would emit response.function_call_arguments.*
// events). Drop for now; the chat-completions
// projector handles tool calls. Future work
// tracked in #7 alongside the in_progress event.
}
InferenceEvent::Finish { reason } => {
finish = Some(reason);
}
}
}
// Producers can drop without ever sending Start (e.g. early
// poisoned-model error). Synthesize the open frames so the
// consumer at least sees a coherent shell before completed.
if !emitted_start && !emit_start_frames(&tx, &meta).await {
return;
}
let reason = finish.unwrap_or(FinishReason::Stop);
let _ = emit_finish_frames(&tx, &meta, &accumulated, reason).await;
}
async fn emit_start_frames(tx: &mpsc::Sender<ResponseStreamFrame>, meta: &ResponseMeta) -> bool {
let shell = response_shell(meta, "in_progress", &[], None);
let frames = [
ResponseStreamFrame {
event_name: events::CREATED,
data: json!({ "response": shell.clone() }),
},
// `response.in_progress` carries the same shell as
// `response.created` — both report the "in_progress"
// status and both are payload-light bookkeeping events.
// The distinction is meaningful to clients that
// differentiate "request validated" from "model is
// generating" in their UI (loading spinner vs streaming
// spinner). OpenAI's own Responses SSE emits them as a
// pair; matching the wire shape avoids subtle client
// breakage.
ResponseStreamFrame {
event_name: events::IN_PROGRESS,
data: json!({ "response": shell }),
},
ResponseStreamFrame {
event_name: events::OUTPUT_ITEM_ADDED,
data: json!({
"output_index": 0,
"item": empty_message_item(&meta.message_item_id),
}),
},
ResponseStreamFrame {
event_name: events::CONTENT_PART_ADDED,
data: json!({
"item_id": meta.message_item_id,
"output_index": 0,
"content_index": 0,
"part": { "type": "output_text", "text": "", "annotations": [] },
}),
},
];
for frame in frames {
if tx.send(frame).await.is_err() {
return false;
}
}
true
}
async fn emit_finish_frames(
tx: &mpsc::Sender<ResponseStreamFrame>,
meta: &ResponseMeta,
full_text: &str,
reason: FinishReason,
) -> bool {
let status = finish_to_status(reason);
let full_part = json!({
"type": "output_text",
"text": full_text,
"annotations": [],
});
let full_item = json!({
"type": "message",
"id": meta.message_item_id,
"role": "assistant",
"content": [full_part.clone()],
"status": status,
});
let frames = [
ResponseStreamFrame {
event_name: events::OUTPUT_TEXT_DONE,
data: json!({
"item_id": meta.message_item_id,
"output_index": 0,
"content_index": 0,
"text": full_text,
}),
},
ResponseStreamFrame {
event_name: events::CONTENT_PART_DONE,
data: json!({
"item_id": meta.message_item_id,
"output_index": 0,
"content_index": 0,
"part": full_part,
}),
},
ResponseStreamFrame {
event_name: events::OUTPUT_ITEM_DONE,
data: json!({
"output_index": 0,
"item": full_item.clone(),
}),
},
ResponseStreamFrame {
event_name: events::COMPLETED,
data: json!({
"response": response_shell(meta, status, &[full_item], None)
}),
},
];
for frame in frames {
if tx.send(frame).await.is_err() {
return false;
}
}
true
}
fn response_shell(
meta: &ResponseMeta,
status: &str,
output: &[Value],
usage: Option<&ResponsesUsage>,
) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("id".into(), Value::String(meta.response_id.clone()));
obj.insert("object".into(), Value::String("response".into()));
obj.insert("created_at".into(), json!(meta.created_at));
obj.insert("status".into(), Value::String(status.into()));
obj.insert("model".into(), Value::String(meta.model_id.clone()));
obj.insert("output".into(), Value::Array(output.to_vec()));
if let Some(u) = usage {
obj.insert(
"usage".into(),
json!({
"input_tokens": u.input_tokens,
"output_tokens": u.output_tokens,
"total_tokens": u.total_tokens,
}),
);
}
Value::Object(obj)
}
fn empty_message_item(item_id: &str) -> Value {
json!({
"type": "message",
"id": item_id,
"role": "assistant",
"content": [],
"status": "in_progress",
})
}
fn finish_to_status(reason: FinishReason) -> &'static str {
match reason {
FinishReason::Stop | FinishReason::ToolCalls => "completed",
FinishReason::Length => "incomplete",
}
}
// ── Non-streaming helpers ────────────────────────────────────────────
/// Collect a chat-completions response into a non-streaming
/// [`ResponsesResponse`]. Used by the `/v1/responses` handler when
/// the request doesn't set `stream: true`.
pub fn build_response(
meta: &ResponseMeta,
full_text: String,
reason: FinishReason,
usage: Option<ResponsesUsage>,
) -> ResponsesResponse {
let status = finish_to_status(reason).to_string();
ResponsesResponse {
id: meta.response_id.clone(),
object: "response".into(),
created_at: meta.created_at,
status: status.clone(),
model: meta.model_id.clone(),
output: vec![ResponsesOutputItem::Message {
id: meta.message_item_id.clone(),
role: "assistant".into(),
content: vec![ResponsesOutputContent::OutputText {
text: full_text,
annotations: vec![],
}],
status,
}],
usage,
}
}
#[cfg(test)]
mod tests {
use super::*;
use cortex_core::openai::MessageContent;
fn meta() -> ResponseMeta {
ResponseMeta {
response_id: "resp_1".into(),
created_at: 1700,
model_id: "m".into(),
message_item_id: "msg_1".into(),
}
}
// ── request translator ──────────────────────────────────────────
#[test]
fn translates_text_input_to_single_user_message() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Text("hi".into()),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
assert_eq!(chat.messages.len(), 1);
assert_eq!(chat.messages[0].role, "user");
assert!(matches!(
&chat.messages[0].content,
MessageContent::Text(t) if t == "hi"
));
}
#[test]
fn instructions_become_leading_system_message() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Text("hi".into()),
instructions: Some("you are helpful".into()),
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
assert_eq!(chat.messages.len(), 2);
assert_eq!(chat.messages[0].role, "system");
assert!(matches!(
&chat.messages[0].content,
MessageContent::Text(t) if t == "you are helpful"
));
assert_eq!(chat.messages[1].role, "user");
}
#[test]
fn rejects_previous_response_id() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Text("hi".into()),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: Some("resp_prev".into()),
extra: Value::Object(Default::default()),
};
assert!(matches!(
request_to_chat(req),
Err(TranslateError::ChainedConversationNotSupported)
));
}
#[test]
fn translates_input_items_to_chat_messages() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![
ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Text("first".into()),
},
ResponsesInputItem::Message {
role: "assistant".into(),
content: ResponsesMessageContent::Text("reply".into()),
},
ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Text("second".into()),
},
]),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
assert_eq!(chat.messages.len(), 3);
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["user", "assistant", "user"]);
}
#[test]
fn image_input_translates_to_chat_parts_array() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
text: "what is this?".into(),
},
ResponsesContentPart::InputImage {
image_url: "data:image/png;base64,AAA=".into(),
detail: None,
},
]),
}]),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
let parts = match &chat.messages[0].content {
MessageContent::Parts(p) => p.clone(),
other => panic!("expected Parts, got {other:?}"),
};
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["type"], "text");
assert_eq!(parts[1]["type"], "image_url");
assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,AAA=");
}
#[test]
fn multiple_images_translate_in_order_and_tolerate_detail() {
// C2: a Responses request carrying several InputImage parts
// (with `detail` set) must translate to a chat Parts array that
// preserves image order and the `image_url.url` shape the chat
// vision path (`extract_images_from_request`) walks. The
// `detail` hint has no chat-completions analogue we forward, so
// it's dropped — but it must not break translation.
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
text: "compare these".into(),
},
ResponsesContentPart::InputImage {
image_url: "data:image/png;base64,FIRST".into(),
detail: Some("high".into()),
},
ResponsesContentPart::InputImage {
image_url: "data:image/png;base64,SECOND".into(),
detail: None,
},
]),
}]),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
let parts = match &chat.messages[0].content {
MessageContent::Parts(p) => p.clone(),
other => panic!("expected Parts, got {other:?}"),
};
// text + two images, in input order.
assert_eq!(parts.len(), 3);
assert_eq!(parts[0]["type"], "text");
assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,FIRST");
assert_eq!(parts[2]["image_url"]["url"], "data:image/png;base64,SECOND");
// `detail` is not forwarded into the chat image_url object.
assert!(parts[1]["image_url"].get("detail").is_none());
}
#[test]
fn text_only_parts_collapse_to_string() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
text: "first".into(),
},
ResponsesContentPart::InputText {
text: "second".into(),
},
]),
}]),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
assert!(matches!(
&chat.messages[0].content,
MessageContent::Text(t) if t == "first\n\nsecond"
));
}
#[test]
fn reasoning_items_are_silently_dropped() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![
ResponsesInputItem::Reasoning { content: vec![] },
ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Text("hi".into()),
},
]),
instructions: None,
stream: false,
max_output_tokens: None,
temperature: None,
top_p: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
let chat = request_to_chat(req).unwrap();
assert_eq!(chat.messages.len(), 1);
assert_eq!(chat.messages[0].role, "user");
}
// ── streaming projector ─────────────────────────────────────────
async fn collect(mut rx: mpsc::Receiver<ResponseStreamFrame>) -> Vec<ResponseStreamFrame> {
let mut out = Vec::new();
while let Some(f) = rx.recv().await {
out.push(f);
}
out
}
#[tokio::test]
async fn full_stream_emits_expected_event_sequence() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out = project_responses_stream(rx, meta());
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::TextDelta("hel".into()))
.await
.unwrap();
tx.send(InferenceEvent::TextDelta("lo".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
})
.await
.unwrap();
drop(tx);
let frames = collect(out).await;
let names: Vec<&str> = frames.iter().map(|f| f.event_name).collect();
assert_eq!(
names,
vec![
events::CREATED,
events::IN_PROGRESS,
events::OUTPUT_ITEM_ADDED,
events::CONTENT_PART_ADDED,
events::OUTPUT_TEXT_DELTA,
events::OUTPUT_TEXT_DELTA,
events::OUTPUT_TEXT_DONE,
events::CONTENT_PART_DONE,
events::OUTPUT_ITEM_DONE,
events::COMPLETED,
]
);
// The two deltas should carry the right text. Indices
// shifted by one after IN_PROGRESS inserted between
// CREATED and OUTPUT_ITEM_ADDED.
assert_eq!(frames[4].data["delta"], "hel");
assert_eq!(frames[5].data["delta"], "lo");
// The done event has the full accumulated text.
assert_eq!(frames[6].data["text"], "hello");
// Completed event carries the full message item.
let completed = &frames[9].data["response"];
assert_eq!(completed["status"], "completed");
let output = completed["output"].as_array().unwrap();
assert_eq!(output.len(), 1);
assert_eq!(output[0]["content"][0]["text"], "hello");
}
#[tokio::test]
async fn length_finish_maps_to_incomplete_status() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out = project_responses_stream(rx, meta());
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Length,
})
.await
.unwrap();
drop(tx);
let frames = collect(out).await;
let completed = frames
.iter()
.find(|f| f.event_name == events::COMPLETED)
.unwrap();
assert_eq!(completed.data["response"]["status"], "incomplete");
}
#[tokio::test]
async fn synthesises_start_frames_when_producer_skips_start() {
// A producer that drops without sending Start (poisoned
// model, immediate disconnect, …) should still produce a
// coherent stream — the projector synthesises the
// mandatory header frames before COMPLETED so the
// consumer never sees an output_text.done without a
// matching content_part.added.
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out = project_responses_stream(rx, meta());
drop(tx);
let frames = collect(out).await;
let names: Vec<&str> = frames.iter().map(|f| f.event_name).collect();
assert!(names.contains(&events::CREATED));
assert!(names.contains(&events::COMPLETED));
assert!(names.contains(&events::OUTPUT_TEXT_DONE));
}
#[tokio::test]
async fn empty_text_deltas_are_dropped() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out = project_responses_stream(rx, meta());
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::TextDelta(String::new()))
.await
.unwrap();
tx.send(InferenceEvent::TextDelta("real".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
})
.await
.unwrap();
drop(tx);
let frames = collect(out).await;
let delta_count = frames
.iter()
.filter(|f| f.event_name == events::OUTPUT_TEXT_DELTA)
.count();
assert_eq!(delta_count, 1, "empty delta must not produce a frame");
}
// ── non-streaming builder ───────────────────────────────────────
#[test]
fn build_response_produces_completed_message_with_usage() {
let r = build_response(
&meta(),
"hello".into(),
FinishReason::Stop,
Some(ResponsesUsage {
input_tokens: 5,
output_tokens: 1,
total_tokens: 6,
}),
);
assert_eq!(r.status, "completed");
match &r.output[0] {
ResponsesOutputItem::Message {
role,
content,
status,
..
} => {
assert_eq!(role, "assistant");
assert_eq!(status, "completed");
match &content[0] {
ResponsesOutputContent::OutputText { text, .. } => {
assert_eq!(text, "hello");
}
}
}
other => panic!("expected Message, got {other:?}"),
}
let u = r.usage.unwrap();
assert_eq!(u.total_tokens, 6);
}
#[test]
fn build_response_length_yields_incomplete_status() {
let r = build_response(&meta(), "trunc".into(), FinishReason::Length, None);
assert_eq!(r.status, "incomplete");
}
}

View File

@@ -322,3 +322,168 @@ async fn test_chat_completions_streaming_model_not_loaded() {
.unwrap(); .unwrap();
assert_eq!(resp.status(), 404); assert_eq!(resp.status(), 404);
} }
// ── /v1/responses ────────────────────────────────────────────────────
/// `/v1/responses` returns 503 when no candle harness is registered —
/// matches the chat-completions error shape so a client can swap
/// endpoints without re-handling 503s.
#[tokio::test]
async fn test_responses_no_candle_harness() {
let registry = HarnessRegistry::new();
let health_cache = Arc::new(HealthCache::new());
let state = Arc::new(NeuronState {
discovery: fake_discovery(),
health_cache,
registry: RwLock::new(registry),
candle: None,
activation: Arc::new(ActivationTracker::new(&[])),
});
let app = api::neuron_routes().with_state(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();
});
let url = format!("http://{addr}");
let resp = reqwest::Client::new()
.post(format!("{url}/v1/responses"))
.json(&json!({"model": "anything", "input": "hi"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 503);
}
/// `previous_response_id` is rejected at translate time with 400 —
/// we don't store responses server-side yet, so chained
/// conversations can't be honoured.
#[tokio::test]
async fn test_responses_rejects_previous_response_id() {
use cortex_core::harness::HarnessConfig;
use neuron::config::HarnessSettings;
let registry = HarnessRegistry::from_configs(
&[HarnessConfig {
name: "candle".into(),
}],
"http://localhost:0",
&HarnessSettings::default(),
);
let candle = registry.candle();
let health_cache = Arc::new(HealthCache::new());
let state = Arc::new(NeuronState {
discovery: fake_discovery(),
health_cache,
registry: RwLock::new(registry),
candle,
activation: Arc::new(ActivationTracker::new(&[])),
});
let app = api::neuron_routes().with_state(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();
});
let url = format!("http://{addr}");
let resp = reqwest::Client::new()
.post(format!("{url}/v1/responses"))
.json(&json!({
"model": "anything",
"input": "hi",
"previous_response_id": "resp_prev_42"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["code"], "chained_conversation_not_supported");
}
/// `/v1/responses` returns 404 when the model isn't loaded — same
/// surface as chat completions.
#[tokio::test]
async fn test_responses_model_not_loaded() {
use cortex_core::harness::HarnessConfig;
use neuron::config::HarnessSettings;
let registry = HarnessRegistry::from_configs(
&[HarnessConfig {
name: "candle".into(),
}],
"http://localhost:0",
&HarnessSettings::default(),
);
let candle = registry.candle();
let health_cache = Arc::new(HealthCache::new());
let state = Arc::new(NeuronState {
discovery: fake_discovery(),
health_cache,
registry: RwLock::new(registry),
candle,
activation: Arc::new(ActivationTracker::new(&[])),
});
let app = api::neuron_routes().with_state(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();
});
let url = format!("http://{addr}");
let resp = reqwest::Client::new()
.post(format!("{url}/v1/responses"))
.json(&json!({"model": "not-loaded", "input": "hi"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
/// Same model-not-loaded surface on the streaming path. The
/// stream is opened only after model lookup succeeds, so a
/// missing model fails fast with a non-SSE 404 response.
#[tokio::test]
async fn test_responses_streaming_model_not_loaded() {
use cortex_core::harness::HarnessConfig;
use neuron::config::HarnessSettings;
let registry = HarnessRegistry::from_configs(
&[HarnessConfig {
name: "candle".into(),
}],
"http://localhost:0",
&HarnessSettings::default(),
);
let candle = registry.candle();
let health_cache = Arc::new(HealthCache::new());
let state = Arc::new(NeuronState {
discovery: fake_discovery(),
health_cache,
registry: RwLock::new(registry),
candle,
activation: Arc::new(ActivationTracker::new(&[])),
});
let app = api::neuron_routes().with_state(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();
});
let url = format!("http://{addr}");
let resp = reqwest::Client::new()
.post(format!("{url}/v1/responses"))
.json(&json!({
"model": "not-loaded",
"input": "hi",
"stream": true
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
}

View File

@@ -0,0 +1,285 @@
//! End-to-end preflight tests against a mock HF-compatible server.
//!
//! Unit tests in `harness/preflight.rs` exercise the classifier and
//! feasibility table against synthetic file lists. These tests close
//! the loop: spawn an axum server that returns a `RepoInfo`-shaped
//! JSON payload at `/api/models/{org}/{name}`, point `hf_hub::Api` at
//! it, and assert `preflight()` returns the expected outcome.
use axum::Router;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use cortex_core::harness::ModelSpec;
use cortex_core::source::ModelSourceId;
use neuron::harness::preflight::{PreflightError, SourceFormat, preflight};
use serde_json::{Value, json};
use std::sync::Arc;
use std::sync::Mutex;
/// Per-test mock state: a map from `{org}/{name}` to the JSON body the
/// mock server returns at the corresponding `/api/models/{org}/{name}`
/// endpoint. `None` means "respond 404".
type MockBodies = Arc<Mutex<std::collections::HashMap<String, Option<Value>>>>;
async fn spawn_mock(bodies: MockBodies) -> String {
// hf-hub 0.4 calls /api/models/{org}/{name}/revision/main for
// `repo.info()`. We route both shapes so the test stays robust
// to a future hf-hub upgrade that drops the `/revision/main`
// suffix.
let app = Router::new()
.route("/api/models/{org}/{name}", get(model_info))
.route(
"/api/models/{org}/{name}/revision/{rev}",
get(model_info_rev),
)
.with_state(bodies);
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();
});
format!("http://{addr}")
}
async fn model_info(
Path((org, name)): Path<(String, String)>,
axum::extract::State(bodies): axum::extract::State<MockBodies>,
) -> impl IntoResponse {
respond(&format!("{org}/{name}"), &bodies)
}
async fn model_info_rev(
Path((org, name, _rev)): Path<(String, String, String)>,
axum::extract::State(bodies): axum::extract::State<MockBodies>,
) -> impl IntoResponse {
respond(&format!("{org}/{name}"), &bodies)
}
fn respond(key: &str, bodies: &MockBodies) -> axum::response::Response {
let entry = bodies.lock().unwrap().get(key).cloned();
match entry {
Some(Some(body)) => Json(body).into_response(),
Some(None) | None => (StatusCode::NOT_FOUND, "not found").into_response(),
}
}
fn build_api(endpoint: &str, cache_dir: &std::path::Path) -> hf_hub::api::tokio::Api {
hf_hub::api::tokio::ApiBuilder::new()
.with_endpoint(endpoint.to_string())
.with_cache_dir(cache_dir.to_path_buf())
.build()
.expect("build hf-hub Api")
}
fn siblings(filenames: &[&str]) -> Value {
json!({
"sha": "0000000000000000000000000000000000000000",
"siblings": filenames.iter().map(|f| json!({ "rfilename": f })).collect::<Vec<_>>(),
})
}
fn spec(model_id: &str, tp: Option<u32>, quant: Option<&str>) -> ModelSpec {
ModelSpec {
model_id: model_id.into(),
harness: "candle".into(),
quant: quant.map(String::from),
tensor_parallel: tp,
devices: None,
}
}
/// Build a `ModelSourceId` from a bare `org/name` test input,
/// substituting the default scheme so the mock route key matches.
fn sid(model_id: &str) -> ModelSourceId {
model_id
.parse::<ModelSourceId>()
.expect("test model_id parses")
.with_default_scheme("huggingface")
}
#[tokio::test]
async fn preflight_gguf_tp_rejected_over_http() {
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
bodies.lock().unwrap().insert(
"HauhauCS/Qwen3.6".to_string(),
Some(siblings(&[
"README.md",
".gitattributes",
"Qwen3.6-Q4_K_P.gguf",
"Qwen3.6-Q6_K_P.gguf",
"Qwen3.6-Q8_K_P.gguf",
])),
);
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
let s = spec("HauhauCS/Qwen3.6", Some(2), Some("q6k"));
let err = preflight(&api, &sid(&s.model_id), &s).await.unwrap_err();
match err {
PreflightError::TpRequiresSafetensors {
model_id,
tp_size,
gguf_quants,
..
} => {
// Scheme prefix surfaces in error display now that
// preflight is source-aware.
assert_eq!(model_id, "huggingface:HauhauCS/Qwen3.6");
assert_eq!(tp_size, 2);
assert_eq!(gguf_quants.len(), 3);
}
other => panic!("expected TpRequiresSafetensors, got {other:?}"),
}
}
#[tokio::test]
async fn preflight_gguf_quant_suggestion_over_http() {
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
bodies.lock().unwrap().insert(
"HauhauCS/Qwen3.6".to_string(),
Some(siblings(&[
"Qwen3.6-Q4_K_P.gguf",
"Qwen3.6-Q5_K_P.gguf",
"Qwen3.6-Q6_K_P.gguf",
"Qwen3.6-Q8_K_P.gguf",
])),
);
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
let s = spec("HauhauCS/Qwen3.6", Some(1), Some("q6k"));
let err = preflight(&api, &sid(&s.model_id), &s).await.unwrap_err();
match err {
PreflightError::QuantNotFound {
requested,
nearest,
available,
..
} => {
assert_eq!(requested, "q6k");
assert_eq!(nearest.as_deref(), Some("q6_k_p"));
assert_eq!(available.len(), 4);
}
other => panic!("expected QuantNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn preflight_dense_safetensors_tp_ok() {
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
bodies.lock().unwrap().insert(
"Qwen/Q3-30B".to_string(),
Some(siblings(&[
"config.json",
"tokenizer.json",
"tokenizer_config.json",
"model.safetensors.index.json",
"model-00001-of-00006.safetensors",
"model-00002-of-00006.safetensors",
"model-00003-of-00006.safetensors",
])),
);
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
let s = spec("Qwen/Q3-30B", Some(2), Some("q5k"));
let plan = preflight(&api, &sid(&s.model_id), &s)
.await
.expect("dense+tp should succeed");
assert_eq!(plan.tp_size, 2);
assert!(plan.picked_quant_file.is_none());
assert!(matches!(
plan.format,
SourceFormat::DenseSafetensors { sharded: true }
));
}
#[tokio::test]
async fn preflight_gguf_single_gpu_good_quant() {
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
bodies.lock().unwrap().insert(
"HauhauCS/Qwen3.6".to_string(),
Some(siblings(&["Qwen3.6-Q4_K_P.gguf", "Qwen3.6-Q6_K_P.gguf"])),
);
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
let s = spec("HauhauCS/Qwen3.6", Some(1), Some("q6_k_p"));
let plan = preflight(&api, &sid(&s.model_id), &s)
.await
.expect("good quant should succeed");
assert_eq!(plan.tp_size, 1);
assert_eq!(
plan.picked_quant_file.as_deref(),
Some("Qwen3.6-Q6_K_P.gguf")
);
}
#[tokio::test]
async fn preflight_repo_fetch_failed_on_404() {
// Mock server has no entry for this id → 404, exercising the
// RepoFetchFailed path (the same shape today's HauhauCS scenario
// would have produced if we'd added preflight before the cache
// download was attempted).
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
let s = spec("DoesNot/Exist", Some(1), None);
let err = preflight(&api, &sid(&s.model_id), &s).await.unwrap_err();
assert!(
matches!(err, PreflightError::RepoFetchFailed { .. }),
"expected RepoFetchFailed, got {err:?}"
);
}
#[tokio::test]
async fn preflight_empty_repo_rejected() {
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
bodies.lock().unwrap().insert(
"Empty/Repo".to_string(),
Some(siblings(&["README.md", "tokenizer.json"])),
);
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
let s = spec("Empty/Repo", Some(1), None);
let err = preflight(&api, &sid(&s.model_id), &s).await.unwrap_err();
assert!(
matches!(err, PreflightError::EmptyRepo { .. }),
"expected EmptyRepo, got {err:?}"
);
}
#[tokio::test]
async fn preflight_mixed_repo_prefers_safetensors() {
let cache = tempfile::tempdir().expect("tempdir");
let bodies: MockBodies = Arc::new(Mutex::new(Default::default()));
bodies.lock().unwrap().insert(
"Mixed/Repo".to_string(),
Some(siblings(&[
"config.json",
"tokenizer.json",
"model.safetensors",
"model-Q4_K_M.gguf",
])),
);
let endpoint = spawn_mock(bodies).await;
let api = build_api(&endpoint, cache.path());
// TP=2 + quant should succeed via the dense path even though a
// GGUF is present — the dense path handles ISQ.
let s = spec("Mixed/Repo", Some(2), Some("q5k"));
let plan = preflight(&api, &sid(&s.model_id), &s)
.await
.expect("mixed should succeed");
assert!(matches!(plan.format, SourceFormat::Mixed { .. }));
}

176
doc/vision-qwen3_6-spec.md Normal file
View File

@@ -0,0 +1,176 @@
# Qwen3.6-27B vision specification (Stage A0)
Sourced from beast's local cache on 2026-06-01:
`/archive3/llm-cache/models--Qwen--Qwen3.6-27B/snapshots/6a9e13bd6fc8f0983b9b99948120bc37f49c13e9/`.
Single source of truth for Stages AD of the vision plan in
`~/.claude/plans/foamy-twirling-catmull.md`. Umbrella issue:
[#3](https://git.lair.cafe/helexa/cortex/issues/3).
---
## Top-level shape
The model is a unified text+vision architecture (`Qwen3_5ForConditionalGeneration`,
`model_type: qwen3_5`) with three weight sections under a single safetensors
index. Counts from `model.safetensors.index.json`:
| Prefix | Tensors | Role |
|---|---|---|
| `model.language_model.*` | 850 | LM (currently loaded) |
| `model.visual.*` | 333 | Vision tower (currently filtered out at `arch/qwen3_5/mod.rs:228-230`) |
| `mtp.*` | 15 | Multi-token-prediction heads (filtered, out of scope) |
| `lm_head.weight` | 1 | LM head |
Vision tensors live in shards `model-00007-of-00015.safetensors` and
`model-00008-of-00015.safetensors` (2 of the 15 safetensors). Loading just
these two for vision-tower-only smoke tests is feasible.
## Vision tower architecture (`model.visual.*`)
From `config.json::vision_config`:
```
depth: 27 (transformer blocks)
hidden_size: 1152 (vision token dim)
num_heads: 16 (per-block self-attention)
intermediate_size: 4304 (MLP hidden)
patch_size: 16 (16×16 spatial patches)
temporal_patch_size: 2 (video frame pairing; irrelevant for stills)
spatial_merge_size: 2 (2×2 spatial merge in the merger → 4 patches/LM token)
num_position_embeddings: 2304 (learned pos embed slots — max patch sequence length)
in_channels: 3 (RGB)
hidden_act: gelu_pytorch_tanh (GELU with tanh approximation, not exact GELU)
out_hidden_size: 5120 (= LM hidden_size, merger output dim)
deepstack_visual_indexes: [] (no deep-stack visual indexes)
```
### Module inventory (per-block and global)
Global:
- `model.visual.patch_embed.proj.{weight, bias}` — Conv2d (3 → 1152, kernel 16×16, stride 16). Turns image patches into tokens.
- `model.visual.pos_embed.weight` — Learned position embedding, shape `(2304, 1152)`.
- `model.visual.merger.{norm, linear_fc1, linear_fc2}` — The projector that merges 2×2 patches and projects to LM hidden_size (1152 → 5120). All weights have biases.
Per block (×27, named `model.visual.blocks.{0..26}`):
- `norm1.{weight, bias}`**LayerNorm** before attention (with bias — not RmsNorm).
- `attn.qkv.{weight, bias}` — Fused QKV linear (1152 → 3·1152 = 3456).
- `attn.proj.{weight, bias}` — Attention output projection (1152 → 1152).
- `norm2.{weight, bias}` — LayerNorm before MLP.
- `mlp.linear_fc1.{weight, bias}` — MLP up-projection (1152 → 4304).
- `mlp.linear_fc2.{weight, bias}` — MLP down-projection (4304 → 1152).
Pattern matches a standard ViT block with **pre-norm** layout (norm → attn → residual, norm → MLP → residual). Activation between fc1/fc2 is GELU-tanh-approx per `hidden_act`. No attention masking inside the vision tower (all patches attend to each other).
### Forward signature (target)
```
VisionTower::forward(
patches: Tensor [N, in_channels, patch_size, patch_size], # CPU-preprocessed RGB float patches
grid_thw: Option<(usize, usize, usize)>, # (t, h, w) patch grid for position lookup
) -> Tensor [N / (spatial_merge_size²), out_hidden_size] # = (N/4, 5120) for static images
```
Note: the merger consumes 4 spatially-adjacent patches and emits 1 LM token. So an image producing 64×64 = 4096 patches yields 1024 LM-side image tokens.
## Image preprocessor (`preprocessor_config.json`)
```json
{
"size": { "longest_edge": 16777216, "shortest_edge": 65536 },
"patch_size": 16,
"temporal_patch_size": 2,
"merge_size": 2,
"image_mean": [0.5, 0.5, 0.5],
"image_std": [0.5, 0.5, 0.5],
"processor_class": "Qwen3VLProcessor",
"image_processor_type": "Qwen2VLImageProcessorFast"
}
```
Reading:
- `image_mean = image_std = 0.5` → normalisation is simply `(x/255 - 0.5) / 0.5 = 2*x/255 - 1`, mapping `[0,255]``[-1, 1]`. No imagenet-style mean/std.
- `size.{shortest_edge, longest_edge}` are **pixel counts**, not edge lengths. The `Qwen2VLImageProcessorFast` recipe picks a resolution within `[65,536 = 256², 16,777,216 = 4096²]` total pixels, snapping `h` and `w` to multiples of `patch_size × spatial_merge_size = 32` pixels.
- Stage A ships **fixed resolution**: pick a target pixel count (e.g. 448×448 = 200,704 px → 28×28 patches → 14×14 LM tokens after merger). Variable resolution deferred to issue [#14](https://git.lair.cafe/helexa/cortex/issues/14).
## Chat template (`chat_template.jinja`)
Image insertion (lines 818 of the template):
```jinja
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
...
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
```
Per image, the template emits **one `<|image_pad|>` token** flanked by `<|vision_start|>` and `<|vision_end|>` sentinels. The runtime must:
1. Render the template (preserving the single `<|image_pad|>` per image).
2. For each image, replace its single `<|image_pad|>` with N copies, where N is the number of LM tokens that image produces after the vision tower + merger (= `patches / spatial_merge_size²`).
3. Tokenize the expanded string → `input_ids`.
4. At forward time, locate positions where `input_ids == image_token_id` (248056) and splice in the vision tower's merger output.
Token IDs (top of `config.json`):
- `vision_start_token_id`: 248053
- `vision_end_token_id`: 248054
- `image_token_id`: 248056
- `video_token_id`: 248057 (out of scope)
- `bos_token_id`: 248044
- `eos_token_id`: 248044, 248046 (per `generation_config.json`)
System messages cannot contain images (template raises). Other template-side details:
- `add_vision_id` (jinja arg, default false): emits `'Picture N: '` prefixes when true.
- `preserve_thinking` (jinja arg, default false): keeps `<think>` blocks from prior assistant turns in the rendered prompt.
- `enable_thinking` (jinja arg, default true): emits `<think>\n` (or skips it) at the end of the generation prompt.
The existing chat-template renderer in `crates/neuron/src/harness/chat_template.rs` already passes `MessageContent::Parts` to the Jinja context as a `Value::Array`; the template's `is iterable` branch (line 6 of the template) handles them. **The path is structurally in place** — Stage B just needs to do the `<|image_pad|>` expansion + token-position-aware splice.
## LM-side considerations
The LM's RoPE config uses **multi-axis RoPE (MRoPE)**:
```
rope_parameters: {
mrope_interleaved: true,
mrope_section: [11, 11, 10], # text + height + width components
partial_rotary_factor: 0.25,
rope_theta: 10000000,
rope_type: "default"
}
```
MRoPE encodes spatial position alongside text position so the LM attention layers can reason about image-token spatial structure. The LM's existing forward path *may or may not* already implement this — the qwen3_5 module's doc-comment notes "numerical correctness vs the reference Python is not yet validated." Verifying MRoPE behaviour in the language model is out of Stage A scope (vision tower only) but will be required in Stage B (LM splice) and is tracked under the numerical-validation issue [#15](https://git.lair.cafe/helexa/cortex/issues/15).
`max_position_embeddings = 262144` (256 K context), so context-length limits are not a constraint for vision.
## Iteration target decision
The vision tower has its own self-contained weight tree and is small (~333 tensors in 2 shards, hidden_size 1152 vs LM's 5120). For Stage A specifically (vision-tower-only smoke), we **don't need a smaller iteration model** — we can:
- Build the Rust `VisionTower` struct against the spec above.
- Run unit tests with random tensor weights matching the exact shapes → assert forward produces correct output shape with finite values.
- Optionally: a CUDA-integration test that loads just the 2 vision shards from beast's cache (or on a smaller GPU like quadbrat's Ampere) and runs encode on a real image. Doesn't require loading the 27B LM at all.
This sidesteps the "develop against a smaller VL model" question for Stage A. Stage B (LM splice → end-to-end chat with vision) is where iteration speed becomes pressing; revisit there. The default scope pick 2a (smaller iteration model) is therefore deferred to Stage B planning — issue [#13](https://git.lair.cafe/helexa/cortex/issues/13) covers deployment validation regardless.
## Concrete Stage A1+ inputs
- Add deps to `crates/neuron/Cargo.toml`:
- `image = "0.25"`
- `base64 = "0.22"`
- Stage A2 preprocessor target resolution (fixed): **448×448 → 28×28 patches → 14×14 = 196 image tokens per image**. This balances minimum-patch-count for cheap tests against the model's expected input range.
- Stage A3 module structure: one `VisionTower` struct holding `patch_embed: Conv2d`, `pos_embed: Embedding`, `blocks: Vec<VisionBlock>`, `merger: Merger`. `VisionBlock` carries `norm1`, `norm2`, `attn`, `mlp`. Hand-roll using candle primitives.
- Stage A4 weight loading: extend `Qwen3_5ForCausalLM::new()` to construct `Some(VisionTower::new(vb.pp("model.visual"), config))` when `vision_config` is present in the parsed config.
- Stage A5 worker job: `Job::EncodeImage { handle, patches: Vec<f32>, patch_shape: (usize, usize, usize, usize, usize), reply: oneshot<Result<Vec<f32>>> }`. Patch shape = `(N, C, T, H, W)` where T=1 for static images.
## What this doc does NOT settle (deferred to issues)
- Numerical correctness of `VisionTower` output vs Python transformers
→ issue [#15](https://git.lair.cafe/helexa/cortex/issues/15).
- Variable image resolution
→ issue [#14](https://git.lair.cafe/helexa/cortex/issues/14).
- TP-vision (multi-rank vision tower)
→ issue [#12](https://git.lair.cafe/helexa/cortex/issues/12).
- 27B production deployment
→ issue [#13](https://git.lair.cafe/helexa/cortex/issues/13).

85
helexa-acp.example.toml Normal file
View File

@@ -0,0 +1,85 @@
# helexa-acp.example.toml — example configuration
#
# Copy to $XDG_CONFIG_HOME/helexa-acp/config.toml (typically
# ~/.config/helexa-acp/config.toml) and adjust for your environment.
#
# helexa-acp is the ACP (Agent Client Protocol) bridge that connects
# editors like Zed to multiple LLM endpoints. Each endpoint speaks a
# specific wire format (openai-chat, openai-responses, or
# anthropic-messages); helexa-acp picks the right provider at runtime
# based on the `wire_api` field.
#
# Selecting a model from the editor follows the `endpoint:model`
# syntax — e.g. `openrouter:anthropic/claude-opus-4` routes the
# request to the `openrouter` endpoint with model
# `anthropic/claude-opus-4`. A bare `<model>` (no colon) falls
# through to whichever endpoint is named in `default_endpoint`.
default_endpoint = "helexa"
# Optional: override the built-in system prompt with a file of your own.
# When unset, helexa-acp uses a concise coder prompt from src/prompt.rs.
# `{cwd}` in the file gets substituted with the session's working
# directory at request time.
# system_prompt_path = "/home/me/.config/helexa-acp/system-prompt.md"
# ── helexa (cortex/neuron, self-hosted) ────────────────────────────
#
# The canonical default. Drives cortex's reverse-proxy / fleet
# gateway, which routes to whichever neuron has the model loaded.
# `openai-chat` works against any cortex deployment; for vision
# models or reasoning surface, switch to `openai-responses` (cortex
# 0.1.16+).
[[endpoints]]
name = "helexa"
base_url = "http://hanzalova.internal:31313/v1"
wire_api = "openai-chat"
default_model = "Qwen/Qwen3.6-27B"
max_tokens = 8192
# Compaction kicks in when the rolling history grows past this token
# budget. Set to your model's context window. Disable by removing
# the field entirely.
context_window = 32768
# ── OpenRouter (proxy for OpenAI/Anthropic/Google/etc.) ────────────
[[endpoints]]
name = "openrouter"
base_url = "https://openrouter.ai/api/v1"
wire_api = "openai-chat"
api_key_env = "OPENROUTER_API_KEY"
default_model = "anthropic/claude-opus-4"
# ── OpenAI directly (Responses API) ────────────────────────────────
#
# Use `openai-responses` for the o-series and any model that
# benefits from the newer Responses API surface (web search,
# computer use, reasoning effort, etc.).
[[endpoints]]
name = "openai"
base_url = "https://api.openai.com/v1"
wire_api = "openai-responses"
api_key_env = "OPENAI_API_KEY"
default_model = "gpt-5"
# ── Anthropic directly ─────────────────────────────────────────────
[[endpoints]]
name = "anthropic"
base_url = "https://api.anthropic.com/v1"
wire_api = "anthropic-messages"
api_key_env = "ANTHROPIC_API_KEY"
default_model = "claude-opus-4"
# ── Local LM Studio / Ollama (compat mode) ─────────────────────────
#
# Most local-LLM servers expose OpenAI-compatible chat completions.
# Use `wire_api = "openai-chat"` and point at the local port.
# [[endpoints]]
# name = "lmstudio"
# base_url = "http://localhost:1234/v1"
# wire_api = "openai-chat"
# default_model = "auto"

View File

@@ -7,7 +7,8 @@
# returns and what the router can cold-load on demand. # returns and what the router can cold-load on demand.
# #
# Field reference: # Field reference:
# id - HuggingFace model id, exact match. # id - Repo id in the source registry (e.g. "Qwen/Qwen3.6-27B").
# Exact match.
# harness - which engine handles inference (currently "candle"). # harness - which engine handles inference (currently "candle").
# quant - GGUF quantisation tag for the file in the HF repo # quant - GGUF quantisation tag for the file in the HF repo
# (e.g. "Q4_K_M"). Omit/empty for the dense # (e.g. "Q4_K_M"). Omit/empty for the dense
@@ -20,6 +21,11 @@
# pinned_on - optional whitelist of neuron names. Non-empty # pinned_on - optional whitelist of neuron names. Non-empty
# narrows feasibility to just those neurons and # narrows feasibility to just those neurons and
# protects the model from LRU eviction there. # protects the model from LRU eviction there.
# source - optional source scheme ("huggingface", "helexa",
# operator mirror tag). When set, cortex forwards
# the load to neuron as `scheme:id` so the daemon
# fetches from the right registry. Omit to let
# neuron substitute its own `default_source`.
# Tensor-parallel target — needs a neuron with at least 2 large GPUs. # Tensor-parallel target — needs a neuron with at least 2 large GPUs.
# The example pins to a specific neuron name; adjust or remove the # The example pins to a specific neuron name; adjust or remove the
@@ -49,6 +55,20 @@ vram_mb = 500
min_devices = 1 min_devices = 1
min_device_vram_mb = 4000 min_device_vram_mb = 4000
# Helexa registry model — `source` pins this entry to the helexa
# scheme so cortex forwards `helexa:Helexa/Qwen3.6-27B-Uncensored` to
# neuron's /models/load. Requires the neuron config to declare a
# matching [harness.candle.sources.helexa] entry pointing at the
# helexa registry endpoint (see neuron.example.toml).
#
# [[models]]
# id = "Helexa/Qwen3.6-27B-Uncensored"
# harness = "candle"
# source = "helexa"
# vram_mb = 54000
# min_devices = 2
# min_device_vram_mb = 24000
# -- Tier aliases ------------------------------------------------------------ # -- Tier aliases ------------------------------------------------------------
# Optional. Clients can request inference against an alias (e.g. # Optional. Clients can request inference against an alias (e.g.
# `model: "helexa/small"` in /v1/chat/completions) and cortex # `model: "helexa/small"` in /v1/chat/completions) and cortex

View File

@@ -22,7 +22,9 @@ name = "candle"
# HuggingFace cache directory for model weights. # HuggingFace cache directory for model weights.
# #
# Resolution order (first hit wins): # Resolution order (first hit wins):
# 1. `hf_cache` here in this file. # 1. `hf_cache` here in this file (applies to the synth `huggingface`
# source only — see [harness.candle.sources.*] below for explicit
# per-source paths).
# 2. `HF_HUB_CACHE` env var — same convention as the Python # 2. `HF_HUB_CACHE` env var — same convention as the Python
# `huggingface_hub` library, so an existing cache directory shared # `huggingface_hub` library, so an existing cache directory shared
# with other tooling can be reused without per-tool config. # with other tooling can be reused without per-tool config.
@@ -36,6 +38,32 @@ name = "candle"
# Environment=HF_HUB_CACHE=/archive/hf-cache # Environment=HF_HUB_CACHE=/archive/hf-cache
# hf_cache = "/var/lib/neuron/hf-cache" # hf_cache = "/var/lib/neuron/hf-cache"
# Default scheme applied to bare `org/name` model ids (those without a
# `scheme:` prefix). Defaults to "huggingface" when unset. Set to
# "helexa" to make `default_models = [{ model_id = "Helexa/Foo" }]`
# resolve via the helexa registry without prefixing every entry.
# default_source = "huggingface"
# Per-scheme source endpoints. Each scheme maps to an HF-compatible
# registry. The `huggingface` source is auto-synthesised pointing at
# `https://huggingface.co` when omitted; declare it explicitly here to
# override the endpoint, auth env, or cache dir.
#
# [harness.candle.sources.huggingface]
# endpoint = "https://huggingface.co"
# auth_env = "HF_TOKEN" # optional bearer token via env var
# cache_dir = "/archive3/llm-cache/huggingface"
#
# Add helexa (or any operator-run mirror speaking the HF-compatible
# wire format) by adding another sources entry. Caches are
# disambiguated per scheme so a mirror serving the same `org/name` as
# HF cannot collide on disk.
#
# [harness.candle.sources.helexa]
# endpoint = "https://registry.helexa.ai"
# auth_env = "HELEXA_TOKEN"
# cache_dir = "/archive3/llm-cache/helexa"
# -- Default models ---------------------------------------------------------- # -- Default models ----------------------------------------------------------
# Models listed here are loaded automatically when the neuron service # Models listed here are loaded automatically when the neuron service
# activates. Loading is sequential — a slow or failing entry doesn't # activates. Loading is sequential — a slow or failing entry doesn't

View File

@@ -1,303 +0,0 @@
#!/bin/env bash
#
# Rolling deploy across the helexa fleet, driven by asset/manifest.yml.
# Installs / upgrades cortex on the gateway host and the appropriate
# helexa-neuron-<flavour> package on each neuron host, then restarts
# their services.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
MANIFEST="${REPO_DIR}/asset/manifest.yml"
if [[ ! -f "${MANIFEST}" ]]; then
echo "fatal: manifest not found at ${MANIFEST}" >&2
exit 1
fi
# Parse the manifest with yq. NOTE: this expects the pip-installed yq
# (a jq wrapper using jq syntax) — `pip install yq`. The Fedora rpm
# `yq` is mikefarah/yq and uses different (yaml-native) syntax; if a
# host has that one instead these queries will fail.
cortex_host=$(yq -r '.cortex.host' "${MANIFEST}")
# Emit one TAB-separated 'host\tflavour' line per neuron.
mapfile -t neuron_entries < <(
yq -r '.neurons[] | .host + "\t" + .flavour' "${MANIFEST}"
)
# Return the installed package's "version-release" string, or
# "(not installed)" when rpm reports the package as absent. Capture
# rpm's output into a variable so its "package X is not installed"
# stdout message (rpm writes that to stdout, not stderr, when -q fails)
# doesn't leak into the result.
installed_nvr() {
local host="$1" pkg="$2"
local nvr
if nvr=$(ssh "${host}" "rpm -q --qf '%{version}-%{release}' ${pkg} 2>/dev/null"); then
echo "${nvr}"
else
echo "(not installed)"
fi
}
# Ensure the rpm.lair.cafe unstable repo is configured AND enabled on
# the remote host.
#
# The upstream .repo file at https://rpm.lair.cafe/lair-cafe-unstable.repo
# ships with `enabled=0` so a host that just fetched it won't start
# pulling unstable packages by accident. We have to explicitly flip
# enabled=1 via `dnf config-manager setopt`. Both addrepo and setopt
# are idempotent.
#
# Non-fatal — if either step fails the subsequent `dnf install` will
# surface a clearer diagnostic on its own.
ensure_lair_repo() {
local host="$1"
if ! ssh "${host}" "test -f /etc/yum.repos.d/lair-cafe-unstable.repo" 2>/dev/null; then
echo "[${host}] adding rpm.lair.cafe unstable repo"
if ! ssh "${host}" sudo dnf config-manager addrepo \
--from-repofile=https://rpm.lair.cafe/lair-cafe-unstable.repo \
>/dev/null 2>&1; then
echo "[${host}] WARNING: failed to add lair.cafe repo file (proceeding anyway)"
return 0
fi
fi
# The .repo file ships enabled=0; flip it on. Cheap, idempotent.
if ! ssh "${host}" sudo dnf config-manager setopt \
lair-cafe-unstable.enabled=1 >/dev/null 2>&1; then
echo "[${host}] WARNING: failed to enable lair-cafe-unstable (proceeding anyway)"
fi
}
# Ensure libcudnn.so.9 is resolvable on the remote host so the
# neuron binary (built with --features cudnn) doesn't fail at startup
# with "cannot open shared object file: No such file or directory".
#
# Probes ldconfig first — if cuDNN was installed manually (.tar/.run
# install), it'll be cached by ldconfig and we don't touch it.
# Otherwise adds NVIDIA's RHEL9 CUDA repo (the Fedora 43 CUDA repo
# doesn't ship cuDNN packages — only the RHEL9 one does) and installs
# libcudnn9-cuda-13.
ensure_cudnn_runtime() {
local host="$1"
if ssh "${host}" "ldconfig -p | grep -q libcudnn.so.9" 2>/dev/null; then
return 0
fi
echo "[${host}] installing cuDNN runtime"
if ! ssh "${host}" "test -f /etc/yum.repos.d/cuda-rhel9-x86_64.repo" 2>/dev/null; then
if ! ssh "${host}" sudo dnf config-manager addrepo \
--from-repofile=https://developer.download.nvidia.com/compute/cuda/repos/rhel9/x86_64/cuda-rhel9.repo \
>/dev/null 2>&1; then
echo "[${host}] WARNING: failed to add rhel9 CUDA repo (proceeding anyway)"
fi
fi
if ! ssh "${host}" sudo dnf install -y libcudnn9-cuda-13 >/dev/null 2>&1; then
echo "[${host}] WARNING: failed to install libcudnn9-cuda-13"
echo "[${host}] neuron may fail to start; install cuDNN manually if so"
fi
}
# True when the named package needs to be installed or upgraded on the
# remote host — either it's not present, or a newer version exists in
# the repo. False only when the installed version is current.
#
# `dnf check-update <pkg>` returns 0 when the package isn't installed
# at all (there's nothing to update), so we have to probe with rpm -q
# first to distinguish "absent" from "current". Other dnf failures
# collapse into "needs update" so the subsequent install step surfaces
# the real diagnostic rather than this check swallowing it.
needs_update() {
local host="$1" pkg="$2"
# Not installed → needs work.
if ! ssh "${host}" "rpm -q ${pkg}" >/dev/null 2>&1; then
return 0
fi
# Installed; ask dnf whether the repo has something newer.
if ssh "${host}" sudo dnf check-update --refresh -q "${pkg}" >/dev/null 2>&1; then
return 1
else
return 0
fi
}
# True if the named package is currently installed on the remote host.
# Used to decide between `dnf install` (fresh) and `dnf upgrade` (stale):
# dnf5's `install` is a no-op when the package is already present at
# any version — it does NOT auto-upgrade to the latest available — so
# the wrong command silently leaves the host on an old build.
is_installed() {
local host="$1" pkg="$2"
ssh "${host}" "rpm -q ${pkg}" >/dev/null 2>&1
}
# Install or upgrade the named package on the remote, picking the
# right dnf verb based on the installed-or-not state. Returns 0 with
# dnf's combined stdout/stderr captured in __DNF_OUTPUT__ on success,
# and 1 with the same captured output on failure.
__DNF_OUTPUT__=""
install_or_upgrade() {
local host="$1" pkg="$2"
local cmd
if is_installed "${host}" "${pkg}"; then
cmd="upgrade"
else
cmd="install"
fi
if __DNF_OUTPUT__=$(
ssh "${host}" sudo dnf "${cmd}" --refresh --allowerasing -y "${pkg}" 2>&1
); then
return 0
else
return 1
fi
}
# ---------------------------------------------------------------------------
# cortex (gateway)
# ---------------------------------------------------------------------------
ensure_lair_repo "${cortex_host}"
cortex_nvr=$(installed_nvr "${cortex_host}" cortex)
if needs_update "${cortex_host}" cortex; then
echo "[${cortex_host}] cortex update available (current: ${cortex_nvr})"
# Stop the service only if the unit file exists — fresh installs
# don't have it, and `systemctl stop` on a missing unit returns
# non-zero, which would otherwise short-circuit the install branch
# under set -e.
if ssh "${cortex_host}" "[ ! -f /usr/lib/systemd/system/cortex.service ] || sudo systemctl stop cortex.service"; then
echo "[${cortex_host}] stopped cortex service"
if install_or_upgrade "${cortex_host}" cortex; then
cortex_nvr=$(installed_nvr "${cortex_host}" cortex)
echo "[${cortex_host}] installed/upgraded cortex to ${cortex_nvr}"
else
echo "[${cortex_host}] failed to install/upgrade cortex:"
echo "${__DNF_OUTPUT__}" | sed "s/^/[${cortex_host}] /"
fi
else
echo "[${cortex_host}] failed to stop cortex service"
fi
else
echo "[${cortex_host}] cortex is up to date (${cortex_nvr})"
ssh "${cortex_host}" sudo systemctl stop cortex.service || true
fi
# Sync cortex.toml whether the package was upgraded or not — the config
# can change without a package bump.
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
--chmod 644 \
"${REPO_DIR}/cortex.toml" \
"${cortex_host}:/etc/cortex/cortex.toml"; then
echo "[${cortex_host}] sync'd cortex.toml"
else
echo "[${cortex_host}] failed to sync cortex.toml"
fi
# Sync models.toml on the same lifecycle as cortex.toml — operator-owned,
# gitignored, drives /v1/models catalogue × topology resolution.
if [[ -f "${REPO_DIR}/models.toml" ]]; then
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
--chmod 644 \
"${REPO_DIR}/models.toml" \
"${cortex_host}:/etc/cortex/models.toml"; then
echo "[${cortex_host}] sync'd models.toml"
else
echo "[${cortex_host}] failed to sync models.toml"
fi
else
echo "[${cortex_host}] no local models.toml — leaving /etc/cortex/models.toml untouched"
fi
ssh "${cortex_host}" sudo systemctl daemon-reload
if ssh "${cortex_host}" systemctl is-active --quiet cortex.service; then
echo "[${cortex_host}] cortex service is active"
elif ssh "${cortex_host}" sudo systemctl start cortex.service; then
echo "[${cortex_host}] started cortex service"
else
echo "[${cortex_host}] failed to start cortex service"
fi
# ---------------------------------------------------------------------------
# neuron (per-host, flavour from manifest)
# ---------------------------------------------------------------------------
for entry in "${neuron_entries[@]}"; do
IFS=$'\t' read -r neuron_host neuron_flavour <<< "${entry}"
package="helexa-neuron-${neuron_flavour}"
# First dot-component of the host keys the per-host config file
# under asset/neuron/<short>.toml. A host listed in the manifest
# without a corresponding config still deploys (the package's
# default /etc/neuron/neuron.toml stays in place; no pre-warm).
short_host="${neuron_host%%.*}"
host_config="${REPO_DIR}/asset/neuron/${short_host}.toml"
ensure_lair_repo "${neuron_host}"
ensure_cudnn_runtime "${neuron_host}"
neuron_nvr=$(installed_nvr "${neuron_host}" "${package}")
# Stop the service unconditionally before any reconfig step.
# `default_models` is read at activation, so a config change without
# a bounce silently leaves the host on the previous pre-warm set.
# Same shape as the cortex flow above. The `[ ! -f … ]` guard skips
# the stop on a fresh install where the unit file isn't there yet.
if ssh "${neuron_host}" "[ ! -f /usr/lib/systemd/system/neuron.service ] || sudo systemctl stop neuron.service"; then
echo "[${neuron_host}] stopped neuron service"
else
echo "[${neuron_host}] failed to stop neuron service (continuing)"
fi
if needs_update "${neuron_host}" "${package}"; then
echo "[${neuron_host}] ${package} update available (current: ${neuron_nvr})"
# --allowerasing lets dnf swap out a previously-installed
# bare helexa-neuron or a different flavour without manual
# intervention. The Conflicts: clauses in the spec ensure
# only one flavour is ever resident.
if install_or_upgrade "${neuron_host}" "${package}"; then
neuron_nvr=$(installed_nvr "${neuron_host}" "${package}")
echo "[${neuron_host}] installed/upgraded ${package} to ${neuron_nvr}"
# Ensure firewalld allows neuron port
ssh "${neuron_host}" "sudo firewall-cmd --query-service=helexa-neuron --quiet 2>/dev/null || sudo firewall-cmd --add-service=helexa-neuron --permanent && sudo firewall-cmd --reload" 2>/dev/null || true
else
echo "[${neuron_host}] failed to install ${package}:"
echo "${__DNF_OUTPUT__}" | sed "s/^/[${neuron_host}] /"
fi
else
echo "[${neuron_host}] ${package} is up to date (${neuron_nvr})"
fi
# Sync per-host neuron.toml — drives default_models pre-warm so
# `/v1/models` on the gateway exposes the host's headline model
# immediately after the service comes back up. Missing per-host
# config leaves the package's installed neuron.toml untouched.
if [[ -f "${host_config}" ]]; then
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
--chmod 644 \
"${host_config}" \
"${neuron_host}:/etc/neuron/neuron.toml"; then
echo "[${neuron_host}] sync'd asset/neuron/${short_host}.toml"
else
echo "[${neuron_host}] failed to sync neuron.toml"
fi
else
echo "[${neuron_host}] no asset/neuron/${short_host}.toml — leaving /etc/neuron/neuron.toml untouched"
fi
if ssh "${neuron_host}" "sudo systemctl daemon-reload && sudo systemctl start neuron.service"; then
echo "[${neuron_host}] started neuron service"
else
echo "[${neuron_host}] failed to start neuron service"
fi
done

151
script/infra-setup.sh Executable file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env bash
#
# One-time setup for the gitea_ci deploy-user on every host that the
# .gitea/workflows/deploy.yml workflow targets:
# - create the gitea_ci system user (if missing)
# - install the runner's pubkey into ~gitea_ci/.ssh/authorized_keys
# - install the appropriate /etc/sudoers.d/helexa_gitea_ci sudoers
# drop-in (cortex flavour on the gateway, neuron flavour on each
# neuron host)
#
# Idempotent — safe to re-run after fleet changes. Continues past
# unreachable hosts so a single offline node doesn't block the rest.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_path="$(cd "${script_dir}/.." && pwd)"
cortex_host=hanzalova.internal
neuron_hosts=(
beast.hanzalova.internal
benjy.hanzalova.internal
quadbrat.hanzalova.internal
)
pubkey="${HOME}/.ssh/id_gitea_ci.pub"
if [[ ! -f "${pubkey}" ]]; then
echo "fatal: ${pubkey} not found" >&2
echo " generate with: ssh-keygen -t ed25519 -f ${pubkey%.pub} -C gitea_ci" >&2
exit 1
fi
# Provision gitea_ci on every host (cortex + all neurons).
#
# Quoting matters here: "${cortex_host} ${neuron_hosts[@]}" inside a
# single pair of quotes collapses the scalar and the first array
# element into one space-joined word, which then word-splits when
# referenced unquoted in `ssh ${host}` — and ssh interprets the second
# hostname as the remote command. Separate quoting fixes it.
for host in "${cortex_host}" "${neuron_hosts[@]}"; do
echo "==> ${host}"
if ! ssh "${host}" '
set -eu
if id -u gitea_ci >/dev/null 2>&1; then
echo " gitea_ci user already present"
else
sudo useradd --system --create-home \
--home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
echo " gitea_ci user created"
fi
# `sudo install` runs as root (not as gitea_ci), which avoids
# the "sudo: unknown user gitea_ci" failure seen immediately
# after useradd — NSS caching lags briefly and `sudo -u` cant
# resolve the just-created user, but `install -o` does its
# own fresh lookup.
sudo install -d -o gitea_ci -g gitea_ci -m 0700 \
/var/lib/gitea_ci/.ssh
# Grant journal read access so the deploy workflow can capture
# `journalctl -u <unit> -I` after a service start without
# needing a sudoers entry. Idempotent — usermod -aG on an
# already-member is a no-op.
sudo usermod -aG systemd-journal gitea_ci
'; then
echo " failed to provision gitea_ci — skipping ${host}"
continue
fi
if rsync \
--archive \
--compress \
--chown gitea_ci:gitea_ci \
--chmod 0600 \
--rsync-path 'sudo rsync' \
"${pubkey}" \
"${host}:/var/lib/gitea_ci/.ssh/authorized_keys"; then
echo " authorized_keys synced"
else
echo " failed to sync authorized_keys"
fi
done
# Install /etc/sudoers.d/helexa_gitea_ci on a host and verify the
# resulting file parses, so a typo cant lock root out.
install_sudoers() {
local host="$1" template="$2"
echo "==> ${host}: installing /etc/sudoers.d/helexa_gitea_ci"
if ! rsync \
--archive \
--compress \
--chown root:root \
--chmod 0440 \
--rsync-path 'sudo rsync' \
"${template}" \
"${host}:/etc/sudoers.d/helexa_gitea_ci"; then
echo " failed to sync ${template##*/}"
return
fi
if ssh "${host}" 'sudo visudo -cf /etc/sudoers.d/helexa_gitea_ci' \
>/dev/null; then
echo " installed and verified"
else
echo " WARNING: visudo rejected the installed file — review on ${host}"
fi
}
install_sudoers "${cortex_host}" \
"${repo_path}/asset/sudoers.d/cortex-host.conf"
for neuron_host in "${neuron_hosts[@]}"; do
install_sudoers "${neuron_host}" \
"${repo_path}/asset/sudoers.d/neuron-host.conf"
done
# Push application config to the fleet. The deploy workflow is
# scoped to package install + service restart; config changes ride
# along with this script instead, since:
# - cortex.toml and models.toml are gitignored (operator-owned, may
# include secrets), so CI never sees them
# - asset/neuron/<short>.toml is tracked but iterating locally is
# faster than pushing a commit and waiting for build-prerelease
# to roll over
# Missing source files are skipped silently — re-run after editing.
sync_config() {
local host="$1" src="$2" dst="$3"
if [[ ! -f "${src}" ]]; then
echo " ${src##*/} not present locally — skipping"
return
fi
if rsync \
--archive \
--compress \
--chown root:root \
--chmod 0644 \
--rsync-path 'sudo rsync' \
"${src}" \
"${host}:${dst}"; then
echo " ${src##*/}${host}:${dst}"
else
echo " failed to sync ${src##*/} to ${host}"
fi
}
echo "==> ${cortex_host}: syncing gateway configs"
sync_config "${cortex_host}" "${repo_path}/cortex.toml" /etc/cortex/cortex.toml
sync_config "${cortex_host}" "${repo_path}/models.toml" /etc/cortex/models.toml
for neuron_host in "${neuron_hosts[@]}"; do
short="${neuron_host%%.*}"
echo "==> ${neuron_host}: syncing per-host neuron config"
sync_config "${neuron_host}" \
"${repo_path}/asset/neuron/${short}.toml" \
/etc/neuron/neuron.toml
done