Compare commits

...

16 Commits

Author SHA1 Message Date
4cb52e3144 Merge pull request 'feat(neuron): server-measured prefill/decode timing on Finish (#85)' (#100) from feat/85-prefill-decode-timing into main
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Waiting to run
build-prerelease / Lint (fmt + clippy) (push) Has been cancelled
build-prerelease / Test (push) Has been cancelled
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build helexa-upstream binary (push) Has been cancelled
build-prerelease / Build neuron-blackwell (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-upstream RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-27 09:08:15 +00:00
6f956dfda3 fix(neuron): hoist TP prefill/decode timers out of 'work block (#85)
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m32s
CI / Clippy (push) Successful in 3m17s
CI / Test (push) Successful in 7m6s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 39s
CI / CUDA type-check (pull_request) Successful in 1m49s
CI / Clippy (pull_request) Successful in 2m58s
CI / Test (pull_request) Successful in 7m3s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
The TP streaming producer builds its Finish event after the 'work
labelled block exits (inside `if failure.is_none()`), but prefill_elapsed
and decode_start were declared inside that block — so the CUDA type-check
failed with E0425 (the CPU build doesn't compile this cfg(cuda) path).

Hoist `prefill_ms_measured: u32` and `decode_start: Option<Instant>`
above the block; populate them at the prefill→decode boundary inside;
read them at the terminal Finish. The worker and local producers were
unaffected (their timers already share the Finish scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
2026-06-27 11:58:26 +03:00
6e0f15c888 feat(neuron): server-measured prefill/decode timing on Finish (#85)
Some checks failed
CI / CUDA type-check (push) Failing after 1m40s
CI / Format (push) Successful in 42s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 6m16s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The harness emitted only token counts on InferenceEvent::Finish; all
timing was client-side SSE arrival, so bench "TTFT" conflated tokenize
+ prefill and decode tok/s was a window estimate.

Add FinishTiming { prefill_ms, decode_ms, prefill_tokens } to the
Finish event, populated by all three streaming producers (TP, worker,
and local CPU paths), and surface it on the OpenAI chat
`usage.helexa_timing` extension so helexa-bench can compute true
prefill vs decode tok/s. cortex forwards usage verbatim, so the field
survives proxying. Non-streaming and Responses paths carry None for
now (bench reads the streaming chat path).

Keystone for the Performance observability epic (#83).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrJ4i3pfLRSTM76o3ofnVq
2026-06-27 11:41:17 +03:00
66eb9f558f Merge pull request 'fix(neuron): surface reasoning_tokens in non-streaming /v1/responses usage' (#82) from fix/responses-usage-reasoning-tokens into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 47s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m4s
build-prerelease / Build neuron-ampere (push) Successful in 2m14s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m41s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m57s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m52s
build-prerelease / Test (push) Successful in 6m41s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
2026-06-26 18:30:05 +00:00
f96a2e7ed3 fix(neuron): surface reasoning_tokens in non-streaming /v1/responses usage
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m42s
CI / Test (push) Successful in 6m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 36s
CI / CUDA type-check (pull_request) Successful in 1m35s
CI / Clippy (pull_request) Successful in 2m54s
CI / Test (pull_request) Successful in 6m33s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
The non-streaming responses handler hardcoded `output_tokens_details: None`,
so the reasoning sub-count that the chat path now computes (via
`split_off_reasoning`, which strips the `<think>` span and counts it into
`completion_tokens_details.reasoning_tokens`) never reached the Responses-API
usage object. The streaming responses path already emits it.

Carry `chat usage.completion_tokens_details.reasoning_tokens` through to
`ResponsesUsage.output_tokens_details.reasoning_tokens`, so streaming and
non-streaming `/v1/responses` report reasoning accounting identically.
`output_tokens` still counts every generated token (reasoning included);
`reasoning_tokens` is the additive sub-count, per OpenAI's shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLyKaJVFDAYnAGiLvVrK8K
2026-06-26 21:21:59 +03:00
b17b555a3d Merge pull request 'fix(neuron): strip &lt;think&gt; reasoning from non-streaming completions' (#81) from fix/responses-nonstreaming-reasoning-leak into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 46s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m3s
build-prerelease / Build neuron-ampere (push) Successful in 2m12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m6s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m40s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m51s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m47s
build-prerelease / Test (push) Successful in 6m26s
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 54s
2026-06-26 17:47:45 +00:00
13daf95514 fix(neuron): strip <think> reasoning from non-streaming completions
All checks were successful
CI / Format (push) Successful in 39s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 2m47s
CI / Test (push) Successful in 5m51s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 40s
CI / CUDA type-check (pull_request) Successful in 1m38s
CI / Clippy (pull_request) Successful in 2m55s
CI / Test (pull_request) Successful in 6m20s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
The non-streaming inference path (single-GPU `chat_completion` and the TP
`chat_completion_tp_inner`) returned the model's full decode — reasoning
preamble + `</think>` + answer — as the assistant `content`. The streaming
path already drops reasoning (emits it as ReasoningDelta, which the chat and
Responses projectors discard), so the two transports disagreed: a streaming
client saw the clean answer, a non-streaming client saw the chain-of-thought
glued to the front of it.

This is exactly what broke agent-zero v2.0 on `/v1/responses` (non-streaming):
the model produced a correct in-band JSON answer after `</think>`, but a0's
parser saw the `<think>` preamble first and rejected the turn as "misformat,
no valid tool request found". a0 v2.1 happens to tolerate the preamble, but
any strict non-streaming client (and a0 v2.0) does not — and reasoning tokens
leaking into `content` also misreport as visible output.

Add `split_off_reasoning(generated_ids, reasoning_pair)`: if the model
declares a reasoning marker pair and its close token (`</think>`) appears in
the output, return only the tokens after the last close marker as content and
count the rest as reasoning. The chat template injects the *opening* marker
into the prompt, so the generated tokens carry the close marker but not the
open one — splitting on the close-token id (not a decoded string) is robust to
tokenizer byte-fallback. Non-reasoning models, thinking-disabled requests, and
generations truncated mid-reasoning have no close token and pass through
unchanged.

Both non-streaming sites now decode only the answer span and populate
`completion_tokens_details.reasoning_tokens` (the streaming-only accounting
gap noted at the call sites, #64). Unit tests cover the strip, no-marker,
no-pair, close-at-end, and multiple-close cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLyKaJVFDAYnAGiLvVrK8K
2026-06-26 20:39:41 +03:00
319b01e0b2 Merge pull request 'fix(neuron): accept bare {role,content} input on /v1/responses (agent-zero)' (#80) from fix/responses-easy-message-input into main
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 49s
build-prerelease / Build neuron-blackwell (push) Successful in 1m29s
build-prerelease / Build helexa-bench binary (push) Successful in 2m21s
build-prerelease / Build cortex binary (push) Successful in 3m23s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m50s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m39s
build-prerelease / Test (push) Successful in 6m43s
build-prerelease / Package cortex RPM (push) Successful in 1m30s
build-prerelease / Build neuron-ada (push) Successful in 2m7s
build-prerelease / Build neuron-ampere (push) Successful in 2m15s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m42s
build-prerelease / Package helexa-upstream RPM (push) Successful in 1m20s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m42s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m46s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 51s
2026-06-26 16:53:08 +00:00
6731adca51 fix(neuron): accept bare {role,content} input on /v1/responses (agent-zero)
All checks were successful
CI / Format (push) Successful in 41s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 2m45s
CI / Test (push) Successful in 6m14s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 39s
CI / CUDA type-check (pull_request) Successful in 1m36s
CI / Clippy (pull_request) Successful in 3m18s
CI / Test (pull_request) Successful in 7m6s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
agent-zero (via litellm) drives the OpenAI Responses API and sends
`input` items in the "easy input message" form — bare `{role, content}`
objects with NO `type` field. Our `ResponsesInputItem` is internally
tagged (`#[serde(tag="type")]`), so every such item failed the untagged
`ResponsesInput` deserialize and axum's `Json` extractor returned 422:

    OpenAIException - Failed to deserialize the JSON body into the target
    type: data did not match any variant of untagged enum ResponsesInput

This was a *total* failure for agent-zero (both the main model on beast
and the utility model on benjy), confirmed by on-wire capture of 15 live
requests: 36/36 input items were bare easy-messages. Other clients
(/v1/chat/completions, /v1/messages) were unaffected — only the
Responses path was exercised this strictly, for the first time.

Make `input`-item parsing match OpenAI's real tolerance, mirroring the
forward-compat `extra: Value` already at the top level of the request:

- New `ResponsesInputElement` wraps the existing typed item enum with
  two more shapes: `EasyMessage { role, content }` (bare, no type;
  `content` optional so an assistant turn with `content: null` parses)
  and `Other(Value)` — a catch-all so a single unmodeled item can never
  again 422 the whole request. The typed enum is unchanged.
- `ResponsesContentPart` gains a `#[serde(other)] Unknown` arm (e.g.
  `refusal`, audio) — dropped in translation, not rejected.
- `FunctionCallOutput.output` is now `Value` (string OR array of content
  parts, per OpenAI) so a structured tool result isn't lost.
- Translator handles all three element shapes; easy-messages translate
  exactly like typed messages, `Other` and unknown parts are dropped.

Tests cover the bare-message, null-content, unknown-item, unknown-part,
and array-tool-output shapes, validated against the 15 captured bodies.

Tools forwarding + native function_call projection on the Responses path
is deliberately a follow-up (Round 2), gated on observing how agent-zero
consumes responses once unblocked (in-band JSON vs native tool calls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLyKaJVFDAYnAGiLvVrK8K
2026-06-26 19:45:22 +03:00
7e11a7688c Merge feat/F6-beta-polish: public-beta banner + same-origin nginx + deploy (F6)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 39s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m17s
build-prerelease / Build neuron-blackwell (push) Successful in 1m43s
build-prerelease / Build neuron-ada (push) Successful in 2m15s
build-prerelease / Build neuron-ampere (push) Successful in 2m17s
build-prerelease / Build cortex binary (push) Successful in 2m50s
build-prerelease / Build helexa-bench binary (push) Successful in 2m25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m1s
build-prerelease / Build helexa-upstream binary (push) Successful in 4m8s
build-prerelease / Test (push) Successful in 6m28s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m18s
build-prerelease / Package helexa-upstream RPM (push) Successful in 1m19s
build-prerelease / Package cortex RPM (push) Successful in 1m24s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 58s
2026-06-23 12:13:35 +03:00
5600575ba2 Merge feat/B7-upstream-packaging: RPM + systemd + CI for helexa-upstream (B7)
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Has been cancelled
build-prerelease / Lint (fmt + clippy) (push) Has been cancelled
build-prerelease / Test (push) Has been cancelled
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build helexa-upstream binary (push) Has been cancelled
build-prerelease / Build neuron-blackwell (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-upstream RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 12:13:25 +03:00
bc7476bf1b feat(F6): public-beta polish — banner, same-origin nginx, deploy notes
All checks were successful
CI / CUDA type-check (push) Successful in 1m36s
CI / Format (push) Successful in 35s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 7m14s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Final beta-readiness pass for helexa.ai.

- BetaBanner: a slim, dismissible (session-scoped) public-beta strip above
  the header, shown only when VITE_PUBLIC_BETA=true; theme-aware styling;
  beta.{tag,message,dismiss} i18n keys across all languages.
- deploy/nginx.conf: edge config serving the built SPA and reverse-proxying
  both backends on the SAME ORIGIN — `/` SPA history fallback, `/v1`+
  `/health` → helexa-router (SSE: proxy_buffering off, 300s read), `/api/`
  → helexa-upstream `/web/v1/`. No CORS; the user's key is a first-party
  bearer.
- README: a deploy section (build → /var/www → nginx) reiterating
  no-server-side-chat-history.

Validated: lint, typecheck, build, i18n:check all green. Explicit-path
commit; no node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 12:08:33 +03:00
5a8f6bc7b3 Merge feat/F5-auth-chat: authenticated chat + key usage (F5)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 35s
build-prerelease / Build neuron-blackwell (push) Successful in 1m41s
build-prerelease / Build helexa-bench binary (push) Successful in 2m7s
build-prerelease / Build neuron-ampere (push) Successful in 2m23s
build-prerelease / Build neuron-ada (push) Successful in 2m23s
build-prerelease / Build cortex binary (push) Successful in 3m12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m30s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m19s
build-prerelease / Package cortex RPM (push) Successful in 1m23s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m52s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m53s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m53s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 12:06:13 +03:00
452d7d9b3d feat(B7): packaging + CI for helexa-upstream
All checks were successful
CI / CUDA type-check (push) Successful in 1m32s
CI / Format (push) Successful in 57s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 8m0s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Ships the mesh authority as an RPM on the same prebuilt-binary pipeline as
helexa-bench.

- rpm/helexa-upstream-prerelease.spec: wraps the CI-built binary; installs
  the systemd unit, sysusers, firewalld service (tcp/8090), and
  /etc/helexa-upstream/helexa-upstream.toml (config noreplace).
- data/helexa-upstream.{service,sysusers.conf,firewalld.xml}: Type=simple
  unit (serve --config), dedicated system user with a StateDirectory home,
  inbound 8090 (/authz/v1 + /web/v1). PostgreSQL is reached outbound; the
  schema migrates on startup.
- build-prerelease.yml: build-upstream + package-upstream jobs with
  change-detection over crates/helexa-upstream/ (UPSTREAM_RE), gated into
  publish. SQLX_OFFLINE=true is set defensively — helexa-upstream uses the
  sqlx runtime query API (no compile-time macros), so it builds with no
  database and no .sqlx cache; DB integration tests stay gated behind
  UPSTREAM_TEST_DATABASE_URL.

Validated: workflow YAML parses, rpmspec expands, and
`SQLX_OFFLINE=true cargo build --release -p helexa-upstream` succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 12:00:41 +03:00
21eb211d6a Merge feat/B6-served-usage: served-usage ledger + reconciliation (B6, #58)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Package cortex RPM (push) Blocked by required conditions
build-prerelease / Package helexa-bench RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Successful in 1m25s
build-prerelease / Build neuron-ada (push) Successful in 2m3s
build-prerelease / Build neuron-ampere (push) Successful in 2m10s
build-prerelease / Build helexa-bench binary (push) Successful in 2m13s
build-prerelease / Build cortex binary (push) Successful in 3m25s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m45s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m51s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m50s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:56:06 +03:00
f4117224fc feat(B6): served-usage ledger + reconciliation (#58)
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m53s
CI / Test (push) Successful in 6m41s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Operators are now metered for the tokens they serve on behalf of mesh
accounts, and the upstream rolls that up for compensation.

cortex-gateway:
- served_usage.rs: an in-process per-(account,key,UTC-day) served-token
  counter, incremented in metering::usage_sink alongside spend/settle for
  every authenticated request. A flush task (spawned in run() when
  [upstream].enabled, mirroring poller/evictor) POSTs ABSOLUTE cumulative
  counters to upstream on [upstream].served_usage_report_interval_secs.
- The no-limit infra key is the operator's local key with hard_cap=None
  (already supported) — it's metered for served-usage but never budget-
  refused and never hits upstream.

helexa-upstream:
- POST /authz/v1/served-usage: upserts rows keyed by (operator_id from the
  client bearer, account, key, period) with
  GREATEST(existing, incoming) — monotonic + idempotent, so re-sends,
  races, and a restarted cortex's lower counter never regress the total.
- reconcile.rs + `helexa-upstream reconcile` CLI: rolls up unreconciled
  served_usage per operator/period (SUM::bigint), stamps reconciled_at,
  prints the totals. Payout mechanism out of scope.

Validated against a throwaway Postgres: monotonic upsert (100→250, a 50
re-send stays 250, same-value idempotent) and reconcile rollup +
stamp-once; cortex counter unit test for per-principal accumulation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:45:57 +03:00
64 changed files with 1476 additions and 50 deletions

View File

@@ -66,6 +66,7 @@ jobs:
build_cortex: ${{ steps.changes.outputs.build_cortex }}
build_neuron: ${{ steps.changes.outputs.build_neuron }}
build_bench: ${{ steps.changes.outputs.build_bench }}
build_upstream: ${{ steps.changes.outputs.build_upstream }}
check_rust: ${{ steps.changes.outputs.check_rust }}
steps:
- uses: actions/checkout@v4
@@ -104,6 +105,7 @@ jobs:
BUILD_CORTEX=true
BUILD_NEURON=true
BUILD_BENCH=true
BUILD_UPSTREAM=true
CHECK_RUST=true
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
@@ -149,6 +151,7 @@ jobs:
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
# Any Rust change (incl. crates not packaged here, e.g.
# helexa-acp) still needs lint+test on main.
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
@@ -156,10 +159,12 @@ jobs:
CORTEX_BASE=$(base_for cortex)
NEURON_BASE=$(base_for helexa-neuron-blackwell)
BENCH_BASE=$(base_for helexa-bench)
UPSTREAM_BASE=$(base_for helexa-upstream)
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then
BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
CHECK_RUST=true
else
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
@@ -170,8 +175,9 @@ jobs:
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
# fmt + clippy + test moved here from ci.yml for main pushes so the
# two workflows stop queueing against each other (ci.yml's checks
@@ -303,6 +309,45 @@ jobs:
path: artifacts/helexa-bench
retention-days: 1
build-upstream:
name: Build helexa-upstream binary
timeout-minutes: 25
needs: prepare
if: needs.prepare.outputs.build_upstream == 'true'
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
runs-on: rust
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: sccache
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
SCCACHE_REGION: auto
SCCACHE_S3_USE_SSL: "false"
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
# helexa-upstream uses the sqlx runtime query API (no compile-time
# query macros), so it builds without a database or a .sqlx cache.
# Set OFFLINE defensively so a stray macro can never reach for a DB.
SQLX_OFFLINE: "true"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Build helexa-upstream (release, sccache escalation)
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
- name: Stage binary
run: |
mkdir --parents artifacts
cp target/release/helexa-upstream artifacts/helexa-upstream
./artifacts/helexa-upstream --version || true
- uses: actions/upload-artifact@v3
with:
name: upstream-fc43
path: artifacts/helexa-upstream
retention-days: 1
build-neuron:
name: Build neuron-${{ matrix.flavour }}
timeout-minutes: 35
@@ -459,6 +504,44 @@ jobs:
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-upstream:
name: Package helexa-upstream RPM
timeout-minutes: 20
needs: [prepare, build-upstream]
runs-on: rpm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/download-artifact@v3
with:
name: upstream-fc43
path: artifacts/
- name: Build RPM
run: |
set -eux
rm -f ~/.rpmmacros
rpmdev-setuptree
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
cp LICENSE ~/rpmbuild/SOURCES/
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
--undefine dist \
--define "dist .fc43"
- uses: actions/upload-artifact@v3
with:
name: rpm-upstream-fc43
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-neuron:
name: Package helexa-neuron-${{ matrix.flavour }} RPM
timeout-minutes: 20
@@ -508,7 +591,7 @@ jobs:
publish:
name: Publish to rpm.lair.cafe (unstable)
timeout-minutes: 25
needs: [lint, test, package-cortex, package-neuron, package-bench]
needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
# Runs when at least one package was built and nothing failed.
# lint/test may be skipped (docs-only refs never get here because
# no packages build), but a real failure in any blocks the
@@ -518,10 +601,11 @@ jobs:
!cancelled()
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped')
&& (needs.test.result == 'success' || needs.test.result == 'skipped')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
&& needs.package-cortex.result != 'failure'
&& needs.package-neuron.result != 'failure'
&& needs.package-bench.result != 'failure'
&& needs.package-upstream.result != 'failure'
}}
runs-on: rpm
concurrency:

View File

@@ -105,3 +105,5 @@ enabled = false
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
# bearer = "replace-with-operator-client-secret"
# timeout_secs = 5
# How often to flush served-usage counters to upstream for reconciliation (#58).
# served_usage_report_interval_secs = 60

View File

@@ -48,11 +48,18 @@ pub struct UpstreamClientConfig {
/// Per-call timeout (seconds) to upstream.
#[serde(default = "default_upstream_timeout")]
pub timeout_secs: u64,
/// How often (seconds) to flush served-usage counters to upstream for
/// reconciliation (#58).
#[serde(default = "default_served_usage_interval")]
pub served_usage_report_interval_secs: u64,
}
fn default_upstream_timeout() -> u64 {
5
}
fn default_served_usage_interval() -> u64 {
60
}
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
/// source of truth (#50). Accounts, keys, and hard caps live here; the

View File

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

View File

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

View File

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

View File

@@ -322,7 +322,11 @@ async fn anthropic_messages(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Err(env) => return crate::error::envelope_response(env),
}
}
@@ -802,7 +806,11 @@ async fn proxy_with_metrics(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Err(env) => return crate::error::envelope_response(env),
}
}

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,9 @@ pub struct CortexState {
/// Whether to reject unauthenticated requests (#49). Read by the auth
/// middleware once it lands.
pub require_auth: bool,
/// Per-principal served-token tally (#58), reported to upstream for
/// operator reconciliation by the flush task when upstream is enabled.
pub served_usage: Arc<crate::served_usage::ServedUsage>,
}
impl CortexState {
@@ -73,6 +76,7 @@ impl CortexState {
.expect("failed to build HTTP client"),
entitlements,
require_auth: config.entitlements.require_auth,
served_usage: Arc::new(crate::served_usage::ServedUsage::new()),
}
}
}

View File

@@ -60,6 +60,7 @@ fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvid
url: url.to_string(),
bearer: "client-secret".into(),
timeout_secs: 5,
served_usage_report_interval_secs: 60,
});
ChainedEntitlementProvider::new(local, upstream)
}

View File

@@ -22,7 +22,7 @@ use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use axum::{Extension, Json, Router};
use cortex_core::error_envelope::OpenAiError;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
@@ -41,6 +41,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
.route("/authz/v1/settle", post(settle))
.route("/authz/v1/release", post(release))
.route("/authz/v1/snapshot", post(snapshot))
.route("/authz/v1/served-usage", post(served_usage))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
client_auth,
@@ -274,6 +275,61 @@ async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -
}
}
// ── served-usage report (#58) ───────────────────────────────────────
#[derive(Deserialize)]
struct ServedUsageReport {
rows: Vec<ServedUsageRow>,
}
#[derive(Deserialize)]
struct ServedUsageRow {
account_id: String,
key_id: String,
period: String, // YYYY-MM-DD
served_tokens: i64,
}
/// `POST /authz/v1/served-usage` — a cortex reports the absolute served-token
/// counters it has accrued for the current period. Upsert is monotonic
/// (`GREATEST`) so re-sends and races are idempotent and never regress.
/// `operator_id` comes from the validated client bearer (request extension).
async fn served_usage(
State(state): State<AppState>,
Extension(operator): Extension<OperatorId>,
Json(req): Json<ServedUsageReport>,
) -> Response {
for row in &req.rows {
let (Ok(account_id), Ok(key_id)) = (
Uuid::parse_str(&row.account_id),
Uuid::parse_str(&row.key_id),
) else {
continue; // skip malformed ids rather than fail the whole batch
};
let Ok(period) = chrono::NaiveDate::parse_from_str(&row.period, "%Y-%m-%d") else {
continue;
};
let res = sqlx::query(
"INSERT INTO served_usage (operator_id, account_id, key_id, period, served_tokens) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (operator_id, account_id, key_id, period) \
DO UPDATE SET served_tokens = GREATEST(served_usage.served_tokens, EXCLUDED.served_tokens)",
)
.bind(&operator.0)
.bind(account_id)
.bind(key_id)
.bind(period)
.bind(row.served_tokens.max(0))
.execute(&state.pool)
.await;
if let Err(e) = res {
tracing::error!(error = %e, "served-usage upsert failed");
return envelope_response(OpenAiError::service_unavailable("authority error", Some(5)));
}
}
StatusCode::NO_CONTENT.into_response()
}
fn bad_request(msg: &str) -> Response {
envelope_response(OpenAiError::new(
400,

View File

@@ -20,6 +20,7 @@ pub mod email;
pub mod error;
pub mod handlers;
pub mod ledger;
pub mod reconcile;
pub mod state;
pub mod topup;
pub mod web;

View File

@@ -36,6 +36,12 @@ enum Commands {
#[arg(long)]
denomination: Option<String>,
},
/// Roll up not-yet-reconciled served usage per operator/period (#58),
/// stamp it reconciled, and print the totals. Payout is out of scope.
Reconcile {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
}
#[tokio::main]
@@ -75,6 +81,18 @@ async fn main() -> Result<()> {
println!("{code}");
}
}
Commands::Reconcile { config } => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
for r in &rollup {
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
}
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
}
}
Ok(())

View File

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

View File

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

View File

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

View File

@@ -23,11 +23,11 @@ use candle_transformers::models::qwen3_moe as qwen3_moe_dense;
use cortex_core::harness::{Harness, HarnessHealth, ModelInfo, ModelSpec};
use cortex_core::openai::{
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse,
ChatMessage, MessageContent, Usage,
ChatMessage, CompletionTokensDetails, MessageContent, Usage,
};
use crate::wire::{
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
detect_reasoning_token_pair, detect_tool_call_token_pair, openai_chat as wire_chat,
};
use std::collections::HashMap;
@@ -784,6 +784,39 @@ impl ModelArch {
}
}
/// Split a non-streaming completion's generated tokens into the
/// visible answer and the leading reasoning span.
///
/// Reasoning models (Qwen3 `<think>`, DeepSeek-R1, …) emit their
/// chain-of-thought *before* the answer, and the chat template injects
/// the **opening** marker into the prompt — so the generated tokens look
/// like `…reasoning… </think> …answer…` with no opening marker present
/// in the output. The streaming path drops reasoning as
/// [`InferenceEvent::ReasoningDelta`]; the non-streaming path has to do
/// the equivalent post-hoc or the chain-of-thought leaks into the
/// assistant `content` (which broke agent-zero v2.0, whose parser
/// expected the bare JSON answer, not a `<think>` preamble).
///
/// Returns `(content_ids, reasoning_token_count)`. Strategy: if the model
/// declares a reasoning marker pair and its **close** token appears in
/// `generated_ids`, everything up to and including the last close token is
/// reasoning and only the tail is the answer. Otherwise (non-reasoning
/// model, thinking disabled, or a generation truncated mid-reasoning) the
/// tokens are returned unchanged. Splitting on the token id — not a
/// decoded `</think>` string — keeps this robust against tokenizer
/// byte-fallback and special-token handling.
fn split_off_reasoning<'a>(
generated_ids: &'a [u32],
reasoning: Option<&ReasoningTokenPair>,
) -> (&'a [u32], u64) {
if let Some(pair) = reasoning
&& let Some(idx) = generated_ids.iter().rposition(|&t| t == pair.close_id)
{
return (&generated_ids[idx + 1..], (idx + 1) as u64);
}
(generated_ids, 0)
}
/// Squeeze any leading singleton dims off the logits tensor so the
/// caller gets a rank-1 `[vocab_size]` slice ready for sampling. Bails
/// on a non-singleton leading dim (would mean a batched forward, which
@@ -2454,21 +2487,36 @@ impl CandleHarness {
)));
};
// Strip the leading `<think>` span so the chain-of-thought
// doesn't leak into `content` (the streaming path drops it
// as ReasoningDelta; this is the non-streaming equivalent).
let (content_ids, reasoning_tokens) =
split_off_reasoning(&generated_ids, loaded.reasoning_tokens.as_ref());
let completion_text = loaded
.tokenizer
.decode(&generated_ids, true)
.decode(content_ids, true)
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
// The first answer token after `</think>` is usually a
// newline pair; trim it so `content` starts at the answer.
let completion_text = if reasoning_tokens > 0 {
completion_text.trim_start().to_string()
} else {
completion_text
};
let usage = Usage {
prompt_tokens: prompt_len as u64,
completion_tokens: generated_ids.len() as u64,
total_tokens: (prompt_len + generated_ids.len()) as u64,
// Reasoning accounting is streaming-only: the
// non-streaming path doesn't track `in_reasoning`
// (would require post-hoc <think> span parsing).
// Deferred — see #64.
completion_tokens_details: None,
// `reasoning_tokens` is an additive sub-count of
// `completion_tokens` (which still counts every
// generated token, reasoning included).
completion_tokens_details: (reasoning_tokens > 0)
.then_some(CompletionTokensDetails { reasoning_tokens }),
prompt_tokens_details: None,
// Non-streaming path: prefill/decode split is only
// surfaced on the streaming Finish event today (#85).
helexa_timing: None,
};
tracing::info!(
@@ -3957,6 +4005,11 @@ impl CandleHarness {
// call — promotes the terminal finish_reason to ToolCalls
// so Anthropic clients see stop_reason: tool_use.
let mut emitted_tool_call = false;
// Prefill/decode split timers (#85). Declared outside 'work
// so the terminal Finish — built after the block exits — can
// read them; populated at the prefill→decode boundary inside.
let mut prefill_ms_measured: u32 = 0;
let mut decode_start: Option<std::time::Instant> = None;
'work: {
// Prefix-cache decision (#11): vision requests
@@ -4084,14 +4137,16 @@ impl CandleHarness {
break 'work;
}
};
let prefill_elapsed = prefill_start.elapsed();
prefill_ms_measured = prefill_elapsed.as_millis() as u32;
tp_for_task
.prefill_rate
.record(prompt_len, prefill_start.elapsed());
.record(prompt_len, prefill_elapsed);
let (post_prefill_vram_free_mb, _) = tp_for_task.query_vram().await;
tracing::info!(
model = %model_id,
prompt_len,
prefill_ms = prefill_start.elapsed().as_millis(),
prefill_ms = prefill_elapsed.as_millis(),
vram_free_mb = post_prefill_vram_free_mb,
"TP chat_completion (stream): prefill complete"
);
@@ -4116,6 +4171,8 @@ impl CandleHarness {
break 'work;
}
};
// Decode-phase timer for the Finish prefill/decode split (#85).
decode_start = Some(std::time::Instant::now());
if Some(next_token) == eos_id {
finish_reason = FinishReason::Stop;
@@ -4393,6 +4450,13 @@ impl CandleHarness {
prompt_tokens: prompt_len as u32,
completion_tokens: all_tokens.len() as u32,
reasoning_tokens: reasoning_token_count,
timing: Some(FinishTiming {
prefill_ms: prefill_ms_measured,
decode_ms: decode_start
.map(|d| d.elapsed().as_millis() as u32)
.unwrap_or(0),
prefill_tokens: prompt_len as u32,
}),
})
.await;
}
@@ -4722,19 +4786,31 @@ async fn chat_completion_tp_inner(
}
drop(pool);
// Strip the leading `<think>` span (see `split_off_reasoning` and the
// single-GPU path) so the chain-of-thought doesn't leak into `content`.
let (content_ids, reasoning_tokens) =
split_off_reasoning(&generated, tp.reasoning_tokens.as_ref());
let completion_text = tp
.tokenizer
.decode(&generated, true)
.decode(content_ids, true)
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
let completion_text = if reasoning_tokens > 0 {
completion_text.trim_start().to_string()
} else {
completion_text
};
let usage = Usage {
prompt_tokens: prompt_len as u64,
completion_tokens: generated.len() as u64,
total_tokens: (prompt_len + generated.len()) as u64,
// Reasoning accounting is streaming-only (non-streaming TP path
// doesn't track `in_reasoning`). Deferred — see #64.
completion_tokens_details: None,
// `reasoning_tokens` is an additive sub-count of `completion_tokens`.
completion_tokens_details: (reasoning_tokens > 0)
.then_some(CompletionTokensDetails { reasoning_tokens }),
prompt_tokens_details: None,
// Non-streaming path: prefill/decode split is only surfaced on
// the streaming Finish event today (#85).
helexa_timing: None,
};
tracing::info!(
@@ -6064,7 +6140,8 @@ async fn stream_inference_via_worker(
}
}
};
prefill_rate.record(prefill_prompt_len, prefill_start.elapsed());
let prefill_elapsed = prefill_start.elapsed();
prefill_rate.record(prefill_prompt_len, prefill_elapsed);
let logits = Tensor::new(logits_vec.as_slice(), &Device::Cpu)?;
let mut next_token = match sample_with_penalty(&logits, &all_tokens, &mut logits_processor) {
Ok(t) => t,
@@ -6077,6 +6154,8 @@ async fn stream_inference_via_worker(
return Err(e);
}
};
// Decode-phase timer for the Finish prefill/decode split (#85).
let decode_start = std::time::Instant::now();
// Per-token routing. `tokenizers::DecodeStream` carries five
// generic parameters (`M, N, PT, PP, D`) which makes naming
@@ -6221,6 +6300,11 @@ async fn stream_inference_via_worker(
prompt_tokens: prompt_tokens.len() as u32,
completion_tokens: all_tokens.len() as u32,
reasoning_tokens: reasoning_token_count,
timing: Some(FinishTiming {
prefill_ms: prefill_elapsed.as_millis() as u32,
decode_ms: decode_start.elapsed().as_millis() as u32,
prefill_tokens: prefill_prompt_len as u32,
}),
})
.await;
@@ -6355,6 +6439,10 @@ fn run_inference_streaming(
// See `inference_tp_stream`: promotes finish_reason to ToolCalls.
let mut emitted_tool_call = false;
// Time prefill and decode separately so the Finish event can carry
// a server-measured prefill/decode split (#85) instead of leaving
// the client to infer both from SSE chunk arrival.
let prefill_start = std::time::Instant::now();
let reused = restore_or_clear_local(arch, prefix_cache, prompt_tokens)?;
// Two-stage prefill around the retokenization-stable snapshot
// boundary — see `run_inference_via_worker`.
@@ -6373,6 +6461,8 @@ fn run_inference_streaming(
None => chunked_prefill_local(arch, device, prompt_tokens, reused)?,
};
let mut next_token = sample_with_penalty(&logits, &all_tokens, &mut logits_processor)?;
let prefill_elapsed = prefill_start.elapsed();
let decode_start = std::time::Instant::now();
// Per-token routing block, used at both the prefill-sample
// tail and the decode loop. Macros are ugly but Rust's
@@ -6481,6 +6571,11 @@ fn run_inference_streaming(
prompt_tokens: prompt_tokens.len() as u32,
completion_tokens: all_tokens.len() as u32,
reasoning_tokens: reasoning_token_count,
timing: Some(FinishTiming {
prefill_ms: prefill_elapsed.as_millis() as u32,
decode_ms: decode_start.elapsed().as_millis() as u32,
prefill_tokens: prompt_tokens.len() as u32,
}),
});
Ok(())
}
@@ -6505,6 +6600,60 @@ mod tests {
const IM_START: u32 = 999;
fn think_pair() -> ReasoningTokenPair {
ReasoningTokenPair {
open_id: 100,
close_id: 200,
open_text: "<think>".into(),
close_text: "</think>".into(),
}
}
#[test]
fn split_off_reasoning_strips_up_to_close_marker() {
// [reasoning_a, reasoning_b, </think>, answer_x, answer_y]
let ids = [10, 11, 200, 42, 43];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
assert_eq!(content, &[42, 43]);
assert_eq!(reasoning, 3); // two reasoning tokens + the close marker
}
#[test]
fn split_off_reasoning_no_close_marker_returns_all() {
// Thinking disabled / model never closed the span: return as-is.
let ids = [42, 43, 44];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
assert_eq!(content, &ids);
assert_eq!(reasoning, 0);
}
#[test]
fn split_off_reasoning_no_marker_pair_is_noop() {
let ids = [1, 2, 3];
let (content, reasoning) = split_off_reasoning(&ids, None);
assert_eq!(content, &ids);
assert_eq!(reasoning, 0);
}
#[test]
fn split_off_reasoning_close_at_end_yields_empty_content() {
// All reasoning, answer truncated to nothing after the marker.
let ids = [10, 11, 200];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
assert!(content.is_empty());
assert_eq!(reasoning, 3);
}
#[test]
fn split_off_reasoning_splits_on_last_close_marker() {
// Defensive: if the model emits its own <think></think> pair plus
// the prompt-injected one, split on the LAST close marker.
let ids = [200, 10, 200, 42];
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
assert_eq!(content, &[42]);
assert_eq!(reasoning, 3);
}
#[test]
fn stable_snapshot_cut_lands_after_last_im_start() {
// ChatML shape: [im_start, "system", ..., im_start, "user",

View File

@@ -84,9 +84,38 @@ pub enum InferenceEvent {
/// `output_tokens_details.reasoning_tokens` (responses).
/// Zero for non-reasoning models.
reasoning_tokens: u32,
/// Server-measured prefill/decode timing for the request, or
/// `None` on paths that don't measure it (CPU fallback that
/// doesn't instrument, tests). Streaming projectors surface
/// this as a `helexa_timing` extension on the OpenAI `usage`
/// object so the bench harness can compute true prefill vs
/// decode tok/s instead of inferring both from client-side
/// SSE arrival (#85).
timing: Option<FinishTiming>,
},
}
/// Server-measured timing for one completed inference, attached to
/// [`InferenceEvent::Finish`]. The whole point is to separate the two
/// phases the client cannot tell apart from chunk-arrival timing:
/// prefill (tokenize + prompt forward pass, ending at the first
/// sampled token) and decode (every subsequent token through EOS /
/// `max_tokens`).
#[derive(Debug, Clone, Copy)]
pub struct FinishTiming {
/// Wall-clock of the prefill phase in milliseconds: from the start
/// of the prompt forward pass(es) to the first sampled token.
pub prefill_ms: u32,
/// Wall-clock of the decode phase in milliseconds: from the first
/// sampled token to stream end.
pub decode_ms: u32,
/// Prompt tokens submitted to the prefill forward pass — the
/// denominator for prefill tok/s. With prefix-KV-cache hits (#11)
/// the elapsed `prefill_ms` drops while this stays the full prompt
/// length, so a high implied rate is itself the cache-hit signal.
pub prefill_tokens: u32,
}
/// Why a stream stopped. Stays small on purpose — anything that
/// doesn't map cleanly to one of these collapses to [`Stop`].
///

View File

@@ -22,6 +22,6 @@ pub mod openai_chat;
pub mod openai_responses;
pub use event::{
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
detect_reasoning_token_pair, detect_tool_call_token_pair,
};

View File

@@ -26,11 +26,13 @@
//! producer blocks on its own send. The bounded channels
//! propagate without us writing any logic.
use cortex_core::openai::{ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, Usage};
use cortex_core::openai::{
ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, HelexaTiming, Usage,
};
use serde_json::json;
use tokio::sync::mpsc;
use super::event::{FinishReason, InferenceEvent, ReasoningTokenPair};
use super::event::{FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair};
/// Output channel buffer size. Mirrors the input side's bound; one
/// event maps to at most one chunk, so equal capacity keeps the
@@ -193,12 +195,14 @@ pub fn project_chat_stream_with(
prompt_tokens,
completion_tokens,
reasoning_tokens,
timing,
} => {
// The finish_reason chunk, then an OpenAI-style
// usage-only chunk (`choices: []`, `usage` populated).
// Clients (opencode) read this to track context size;
// cortex's Anthropic translator also picks `usage` up
// for its `message_delta`.
// for its `message_delta`. `timing` rides along as the
// `helexa_timing` usage extension for the bench harness (#85).
vec![
final_chunk(&id, created, &model_id, reason),
usage_chunk(
@@ -208,6 +212,7 @@ pub fn project_chat_stream_with(
prompt_tokens,
completion_tokens,
reasoning_tokens,
timing,
),
]
}
@@ -334,6 +339,7 @@ fn usage_chunk(
prompt_tokens: u32,
completion_tokens: u32,
reasoning_tokens: u32,
timing: Option<FinishTiming>,
) -> ChatCompletionChunk {
ChatCompletionChunk {
id: id.into(),
@@ -351,6 +357,14 @@ fn usage_chunk(
reasoning_tokens: reasoning_tokens as u64,
}),
prompt_tokens_details: None,
// helexa extension (#85): server-measured prefill/decode
// timing for the bench harness. Omitted on paths that don't
// measure it so standard clients see unchanged JSON.
helexa_timing: timing.map(|t| HelexaTiming {
prefill_ms: t.prefill_ms as u64,
decode_ms: t.decode_ms as u64,
prefill_tokens: t.prefill_tokens as u64,
}),
}),
extra: serde_json::Value::Object(Default::default()),
}
@@ -391,6 +405,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -413,6 +428,45 @@ mod tests {
}
}
#[tokio::test]
async fn finish_timing_surfaces_on_usage_chunk() {
// O1 (#85) wire contract: a Finish carrying FinishTiming must
// surface as `usage.helexa_timing` on the trailing usage chunk,
// which is what the bench harness reads to compute true prefill
// vs decode tok/s. Absent timing must leave it None.
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
let out_rx = project_chat_stream(rx, "id-1".into(), 1700, "m".into());
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 128,
completion_tokens: 64,
reasoning_tokens: 0,
timing: Some(FinishTiming {
prefill_ms: 200,
decode_ms: 1500,
prefill_tokens: 128,
}),
})
.await
.unwrap();
drop(tx);
let out = collect(out_rx).await;
let usage = out
.iter()
.find_map(|c| c.usage.as_ref())
.expect("usage chunk present");
let timing = usage
.helexa_timing
.as_ref()
.expect("helexa_timing populated when Finish carried timing");
assert_eq!(timing.prefill_ms, 200);
assert_eq!(timing.decode_ms, 1500);
assert_eq!(timing.prefill_tokens, 128);
}
#[tokio::test]
async fn empty_text_delta_is_dropped() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
@@ -434,6 +488,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -496,6 +551,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -547,6 +603,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -592,6 +649,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -635,6 +693,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -662,6 +721,7 @@ mod tests {
prompt_tokens: 42,
completion_tokens: 5,
reasoning_tokens: 2,
timing: None,
})
.await
.unwrap();
@@ -695,6 +755,7 @@ mod tests {
prompt_tokens: 10,
completion_tokens: 7,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();

View File

@@ -29,9 +29,9 @@
use cortex_core::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
use cortex_core::responses::{
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputItem,
ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem, ResponsesRequest,
ResponsesResponse, ResponsesUsage, events,
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputElement,
ResponsesInputItem, ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem,
ResponsesRequest, ResponsesResponse, ResponsesUsage, events,
};
use serde_json::{Value, json};
use tokio::sync::mpsc;
@@ -109,8 +109,26 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
});
}
ResponsesInput::Items(items) => {
for item in items {
if let Some(msg) = input_item_to_chat(item) {
for element in items {
let msg = match element {
ResponsesInputElement::Typed(item) => input_item_to_chat(item),
// Bare `{role, content}` (OpenAI EasyInputMessage —
// what litellm/agent-zero emit). `content: null`
// (e.g. an assistant turn carrying only tool calls)
// collapses to an empty string so the turn is kept.
ResponsesInputElement::EasyMessage { role, content } => Some(ChatMessage {
role,
content: content
.map(message_content_to_chat)
.unwrap_or_else(|| MessageContent::Text(String::new())),
extra: Value::Object(Default::default()),
}),
// Forward-compat: an item shape we don't model.
// Dropped rather than rejected (see
// `ResponsesInputElement::Other`).
ResponsesInputElement::Other(_) => None,
};
if let Some(msg) = msg {
messages.push(msg);
}
}
@@ -159,11 +177,18 @@ fn input_item_to_chat(item: ResponsesInputItem) -> Option<ChatMessage> {
})
}
ResponsesInputItem::FunctionCallOutput { call_id, output } => {
// `output` is either a plain string or an array of content
// parts. Render a string as-is; anything else to compact
// JSON so the tool result text reaches the model intact.
let output_text = match output {
Value::String(s) => s,
other => other.to_string(),
};
let mut extra = serde_json::Map::new();
extra.insert("tool_call_id".into(), Value::String(call_id));
Some(ChatMessage {
role: "tool".into(),
content: MessageContent::Text(output),
content: MessageContent::Text(output_text),
extra: Value::Object(extra),
})
}
@@ -192,7 +217,9 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
.filter_map(|p| match p {
ResponsesContentPart::InputText { text }
| ResponsesContentPart::OutputText { text, .. } => Some(text),
ResponsesContentPart::InputImage { .. } => None,
ResponsesContentPart::InputImage { .. } | ResponsesContentPart::Unknown => {
None
}
})
.collect::<Vec<_>>()
.join("\n\n");
@@ -211,6 +238,7 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
"image_url": { "url": image_url },
}));
}
ResponsesContentPart::Unknown => {}
}
}
MessageContent::Parts(out)
@@ -309,6 +337,9 @@ async fn run_projection(
prompt_tokens,
completion_tokens,
reasoning_tokens,
// Responses-side `helexa_timing` surfacing not wired yet;
// the bench harness reads timing off the chat path (#85).
timing: _,
} => {
finish = Some(reason);
// Surface usage on the streaming `response.completed`
@@ -535,6 +566,18 @@ mod tests {
use super::*;
use cortex_core::openai::MessageContent;
/// Wrap typed items as `input` elements. Most translator tests
/// exercise the typed path; the bare easy-message and unknown-item
/// paths have dedicated tests below.
fn typed_items(items: Vec<ResponsesInputItem>) -> ResponsesInput {
ResponsesInput::Items(
items
.into_iter()
.map(ResponsesInputElement::Typed)
.collect(),
)
}
fn meta() -> ResponseMeta {
ResponseMeta {
response_id: "resp_1".into(),
@@ -614,7 +657,7 @@ mod tests {
fn translates_input_items_to_chat_messages() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![
input: typed_items(vec![
ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Text("first".into()),
@@ -646,7 +689,7 @@ mod tests {
fn image_input_translates_to_chat_parts_array() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
input: typed_items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
@@ -687,7 +730,7 @@ mod tests {
// it's dropped — but it must not break translation.
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
input: typed_items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
@@ -729,7 +772,7 @@ mod tests {
fn text_only_parts_collapse_to_string() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
input: typed_items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
@@ -759,7 +802,7 @@ mod tests {
fn reasoning_items_are_silently_dropped() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![
input: typed_items(vec![
ResponsesInputItem::Reasoning { content: vec![] },
ResponsesInputItem::Message {
role: "user".into(),
@@ -779,6 +822,74 @@ mod tests {
assert_eq!(chat.messages[0].role, "user");
}
#[test]
fn bare_easy_messages_translate_like_typed_messages() {
// The agent-zero / litellm shape: bare `{role, content}` items
// with no `type`. Deserialize from raw JSON (not hand-built)
// so this exercises the real parse path end to end.
let raw = r#"{
"model": "Qwen/Qwen3.6-27B",
"store": true,
"input": [
{"role": "system", "content": "be terse"},
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
{"role": "user", "content": "alpha"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let chat = request_to_chat(req).unwrap();
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["system", "assistant", "user"]);
assert!(matches!(
&chat.messages[2].content,
MessageContent::Text(t) if t == "alpha"
));
}
#[test]
fn null_content_and_unknown_items_survive_translation() {
// An assistant turn with `content: null` is kept (empty text);
// an unmodeled item type is dropped, not rejected.
let raw = r#"{
"model": "m",
"input": [
{"role": "assistant", "content": null},
{"type": "item_reference", "id": "x"},
{"role": "user", "content": "go"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let chat = request_to_chat(req).unwrap();
// assistant(null) kept, item_reference dropped, user kept.
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["assistant", "user"]);
assert!(matches!(
&chat.messages[0].content,
MessageContent::Text(t) if t.is_empty()
));
}
#[test]
fn function_call_output_array_renders_to_text() {
// OpenAI allows `function_call_output.output` to be an array of
// content parts; the tool result must reach the model as text.
let raw = r#"{
"model": "m",
"input": [
{"type": "function_call_output", "call_id": "c1",
"output": [{"type": "output_text", "text": "42"}]}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let chat = request_to_chat(req).unwrap();
assert_eq!(chat.messages.len(), 1);
assert_eq!(chat.messages[0].role, "tool");
match &chat.messages[0].content {
MessageContent::Text(t) => assert!(t.contains("42"), "got {t:?}"),
other => panic!("expected text, got {other:?}"),
}
}
// ── streaming projector ─────────────────────────────────────────
async fn collect(mut rx: mpsc::Receiver<ResponseStreamFrame>) -> Vec<ResponseStreamFrame> {
@@ -806,6 +917,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -856,6 +968,7 @@ mod tests {
prompt_tokens: 30,
completion_tokens: 12,
reasoning_tokens: 4,
timing: None,
})
.await
.unwrap();
@@ -886,6 +999,7 @@ mod tests {
prompt_tokens: 8,
completion_tokens: 3,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -910,6 +1024,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();
@@ -956,6 +1071,7 @@ mod tests {
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
timing: None,
})
.await
.unwrap();

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>helexa-upstream</short>
<description>helexa-upstream — mesh account + budget authority API (/authz/v1 for cortex, /web/v1 for the frontend)</description>
<port protocol="tcp" port="8090"/>
</service>

View File

@@ -0,0 +1,3 @@
g helexa-upstream - -
u helexa-upstream - "helexa-upstream authority" /var/lib/helexa-upstream /sbin/nologin
m helexa-upstream helexa-upstream

View File

@@ -0,0 +1,22 @@
[Unit]
Description=helexa-upstream — mesh account + budget authority (accounts, API keys, allocation ledger, top-up codes)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/helexa-upstream serve --config /etc/helexa-upstream/helexa-upstream.toml
# HTTP authority for cortex (/authz/v1) and the frontend (/web/v1); restart
# unconditionally if it ever exits. It connects out to PostgreSQL (the
# system of record) and runs schema migrations on startup.
Restart=always
RestartSec=10
User=helexa-upstream
Group=helexa-upstream
# Service user home; no local state (PostgreSQL holds everything), but
# StateDirectory gives the user a writable, correctly-owned home.
StateDirectory=helexa-upstream
StateDirectoryMode=0755
[Install]
WantedBy=multi-user.target

View File

@@ -32,3 +32,22 @@ F0 scaffold. Theming + i18n (33 languages, usage-ordered selector), the
`/mission` page, the chat workspace (Dexie + streaming), and the account
dashboard land in subsequent phases — see
`~/.claude/plans/we-need-to-plan-modular-graham.md`.
## Deploy (public beta)
Build the SPA and serve it from edge nginx on the **same origin** as the
two backends — so the browser makes no cross-origin request (no CORS) and
the user's API key rides as a first-party bearer.
```sh
npm ci && npm run build # → dist/
sudo cp -r dist/* /var/www/helexa.ai/
sudo cp deploy/nginx.conf /etc/nginx/conf.d/helexa.ai.conf # adjust upstreams + TLS
sudo nginx -t && sudo systemctl reload nginx
```
`deploy/nginx.conf` routes `/` → SPA (history fallback), `/v1` + `/health`
→ helexa-router, and `/api/` → helexa-upstream `/web/v1/`. Set
`VITE_PUBLIC_BETA=true` at build time for the beta banner. There is **no
server-side chat history**: conversations live only in the browser
(IndexedDB).

View File

@@ -0,0 +1,60 @@
# helexa.ai — edge nginx for the public beta.
#
# Serves the built SPA (helexa.ai/dist) and reverse-proxies the two
# backends on the SAME ORIGIN, so the browser never makes a cross-origin
# request: no CORS, and the user's API key rides as a first-party bearer.
#
# / → static SPA (history fallback to index.html)
# /v1, /health → helexa-router (OpenAI-compatible inference data-plane)
# /api/ → helexa-upstream /web/v1/ (account control-plane)
#
# TLS is terminated here (certs omitted — wire up certbot / your CA). The
# upstream hosts below are examples; point them at your router/upstream.
upstream helexa_router { server 127.0.0.1:8088; }
upstream helexa_upstream { server 127.0.0.1:8090; }
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name helexa.ai;
# ssl_certificate /etc/letsencrypt/live/helexa.ai/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/helexa.ai/privkey.pem;
root /var/www/helexa.ai;
index index.html;
# Long-cache fingerprinted assets; never cache the HTML shell.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Inference data-plane → router. Streaming (SSE): disable buffering so
# tokens reach the browser as they arrive.
location /v1/ {
proxy_pass http://helexa_router;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
}
location = /health {
proxy_pass http://helexa_router;
}
# Account control-plane → upstream /web/v1/ (strip the /api prefix).
location /api/ {
proxy_pass http://helexa_upstream/web/v1/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SPA history fallback: anything else serves index.html.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -409,3 +409,15 @@ pre {
font-size: 0.8rem;
}
}
/* Public-beta banner (F6) — slim accent strip above the header. */
.beta-banner {
background: var(--bs-warning-bg-subtle, #fff3cd);
color: var(--bs-warning-text-emphasis, #664d03);
border-bottom: 1px solid var(--bs-warning-border-subtle, #ffe69c);
}
[data-bs-theme="dark"] .beta-banner {
background: rgba(255, 193, 7, 0.12);
color: #ffda6a;
border-bottom-color: rgba(255, 193, 7, 0.25);
}

View File

@@ -4,6 +4,7 @@ import AuthProvider from "./auth/AuthProvider";
import RequireAuth from "./auth/RequireAuth";
import Header from "./components/Header";
import Footer from "./components/Footer";
import BetaBanner from "./components/BetaBanner";
import Mission from "./pages/Mission";
import Chat from "./pages/Chat";
import Login from "./pages/auth/Login";
@@ -24,6 +25,7 @@ export default function App() {
<BrowserRouter>
<AuthProvider>
<div className="d-flex flex-column min-vh-100">
<BetaBanner />
<Header />
<Routes>
<Route path="/" element={<Chat />} />

View File

@@ -0,0 +1,35 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
/**
* Slim public-beta notice shown above the header when VITE_PUBLIC_BETA is
* set. Dismissible for the session (sessionStorage) so it doesn't nag, but
* returns on the next visit while the beta lasts.
*/
const SHOWN = import.meta.env.VITE_PUBLIC_BETA === "true";
const DISMISS_KEY = "helexa.betaDismissed";
export default function BetaBanner() {
const { t } = useTranslation("common");
const [hidden, setHidden] = useState(
() => sessionStorage.getItem(DISMISS_KEY) === "1",
);
if (!SHOWN || hidden) return null;
return (
<div className="beta-banner d-flex align-items-center justify-content-center gap-2 px-3 py-1 small">
<span>
<strong>{t("beta.tag")}</strong> {t("beta.message")}
</span>
<button
type="button"
className="btn-close btn-close-white ms-2"
style={{ fontSize: "0.6rem" }}
aria-label={t("beta.dismiss")}
onClick={() => {
sessionStorage.setItem(DISMISS_KEY, "1");
setHidden(true);
}}
/>
</div>
);
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -59,5 +59,10 @@
},
"footer": {
"copyright": "© {{year}} helexa.ai"
},
"beta": {
"tag": "Public beta",
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
"dismiss": "Dismiss"
}
}

View File

@@ -0,0 +1,109 @@
# Prebuilt-binary spec for helexa-upstream.
#
# Wraps a pre-built `helexa-upstream` binary produced by an upstream CI
# job and packages it for rpm.lair.cafe. The %build phase is a no-op.
# helexa-upstream is a pure-Rust, non-CUDA daemon: the mesh account +
# budget authority. It serves an inbound HTTP API on tcp/8090 (/authz/v1
# for cortex, /web/v1 for the frontend) — hence the firewalld service —
# and connects out to PostgreSQL (the system of record), running schema
# migrations on startup.
#
# Required defines at rpmbuild time:
# upstream_version e.g. "0.1.16"
# upstream_prerelease e.g. "0.1.20260518140530.gitabcdef0"
# ^^^^^^^^^^^^^^^^^^ ^^^^^^^^
# commit time (sec) commit sha
# (used as Release; the timestamp prefix
# keeps same-day builds strictly ordered.)
%global _build_id_links none
%global debug_package %{nil}
%global __strip /usr/bin/true
%{!?upstream_version: %global upstream_version 0.0.0}
%if 0%{?upstream_prerelease:1}
%global upstream_release %{upstream_prerelease}
%else
%global upstream_release 1
%endif
Name: helexa-upstream
Version: %{upstream_version}
Release: %{upstream_release}%{?dist}
Summary: Mesh account + budget authority for helexa (prebuilt)
License: GPL-3.0-or-later
URL: https://git.lair.cafe/helexa/helexa
Source0: helexa-upstream
Source1: helexa-upstream.service
Source2: helexa-upstream-sysusers.conf
Source3: helexa-upstream.example.toml
Source4: LICENSE
Source5: helexa-upstream-firewalld.xml
Requires: firewalld-filesystem
ExclusiveArch: x86_64
Requires(pre): shadow-utils
Requires: systemd
Provides: user(helexa-upstream)
%description
helexa-upstream is the mesh-level authority: it issues user accounts and
API keys, holds the allocation ledger (free grant + redeemable top-up
codes), enforces per-key budgets via a transactional reserve/settle
contract, and reconciles served usage for operator compensation. cortex
validates locally-unrecognised keys against its /authz/v1 surface
(fail-closed); the helexa.ai frontend drives account self-service via
/web/v1. PostgreSQL is the system of record; the schema is migrated on
startup.
%prep
cp %{SOURCE0} ./helexa-upstream
cp %{SOURCE1} .
cp %{SOURCE2} .
cp %{SOURCE3} .
cp %{SOURCE4} .
cp %{SOURCE5} .
%build
# Already built in the upstream CI build job.
%install
install -Dm755 helexa-upstream %{buildroot}%{_bindir}/helexa-upstream
install -Dm644 helexa-upstream.service %{buildroot}%{_unitdir}/helexa-upstream.service
install -Dm644 helexa-upstream-sysusers.conf %{buildroot}%{_sysusersdir}/helexa-upstream.conf
install -Dm644 helexa-upstream-firewalld.xml %{buildroot}%{_prefix}/lib/firewalld/services/helexa-upstream.xml
install -dm755 %{buildroot}%{_sysconfdir}/helexa-upstream
install -Dm644 helexa-upstream.example.toml %{buildroot}%{_sysconfdir}/helexa-upstream/helexa-upstream.toml
%pre
getent group helexa-upstream >/dev/null || groupadd -r helexa-upstream
getent passwd helexa-upstream >/dev/null || \
useradd -r -g helexa-upstream -d /var/lib/helexa-upstream -s /sbin/nologin \
-c "helexa-upstream authority" helexa-upstream
%post
%systemd_post helexa-upstream.service
%preun
%systemd_preun helexa-upstream.service
%postun
%systemd_postun_with_restart helexa-upstream.service
%files
%license LICENSE
%{_bindir}/helexa-upstream
%{_unitdir}/helexa-upstream.service
%{_sysusersdir}/helexa-upstream.conf
%{_prefix}/lib/firewalld/services/helexa-upstream.xml
%dir %{_sysconfdir}/helexa-upstream
%config(noreplace) %{_sysconfdir}/helexa-upstream/helexa-upstream.toml
%changelog
* Mon Jun 23 2026 Gitea Actions <actions@git.lair.cafe> - %{upstream_version}-%{upstream_release}
- Prerelease build from upstream CI binary.