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