Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf911b9967
|
||
|
|
a1b4584f30
|
||
|
|
b403a51059
|
||
|
|
0bf40d6c1b
|
||
|
|
f1533724f7
|
||
|
|
4a72d73c67
|
||
|
|
43e873f889
|
||
|
|
05e02b8895
|
||
|
|
2c09f87105
|
||
|
|
8fc0ef8fc6
|
||
|
|
98b43919c0
|
||
|
|
8540cc8ef9
|
||
|
|
d4629f253a
|
||
|
|
9fa3fceabc
|
||
|
|
e44e095f60
|
||
|
|
45e03893f1
|
||
|
|
7c488b976b
|
||
|
|
f16a6fdade
|
||
|
|
46e4e3b39b
|
||
|
|
b0da2d35d5
|
||
|
|
19243d317e
|
||
|
|
16d473227d
|
||
|
|
d7b407cca6
|
||
|
|
902f5460bf
|
||
|
|
821ca116aa
|
||
|
|
03fe47e91b
|
||
|
|
ae994e3ca5
|
||
|
|
f31b599f30
|
||
|
|
d94b780e5b
|
||
|
|
af34741aab
|
||
|
|
e048743592
|
||
|
|
df5766396c
|
||
|
|
f5fd678247
|
||
|
|
acef01bc4c
|
@@ -262,7 +262,7 @@ jobs:
|
||||
# fast instead of triggering a slower uncached rebuild; only a real
|
||||
# sccache fault drops the cache. See script/ci-cargo-escalate.sh.
|
||||
- name: Clippy (sccache escalation)
|
||||
run: script/ci-cargo-escalate.sh cargo clippy --workspace -- -D warnings
|
||||
run: script/ci-cargo-escalate.sh cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
|
||||
+51
-5
@@ -101,14 +101,34 @@ jobs:
|
||||
echo "changed files:"
|
||||
printf '%s\n' "${files}" | sed 's/^/ /'
|
||||
|
||||
# Inert for Rust: the SPA, and documentation. Markdown is
|
||||
# matched only at the repo root and under doc/ — a .md inside
|
||||
# crates/ is left to fall through, since `include_str!` could
|
||||
# make one load-bearing without this file knowing.
|
||||
# Inert for Rust: the SPA, documentation, and fleet config.
|
||||
# Markdown is matched only at the repo root and under doc/ — a
|
||||
# .md inside crates/ is left to fall through, since
|
||||
# `include_str!` could make one load-bearing without this file
|
||||
# knowing.
|
||||
#
|
||||
# Fleet config (models.toml, asset/neuron/<host>.toml,
|
||||
# asset/helexa-bench/<host>.toml) is read by a *running*
|
||||
# daemon, never by a build script or an `include_str!`, so it
|
||||
# cannot change a Rust result. Deploying one is a config rsync
|
||||
# plus a service restart — neuron re-reads it and reloads the
|
||||
# model — so spending ~25 minutes on fmt, clippy, test and a
|
||||
# CUDA type-check to prove a one-line TOML edit compiles is
|
||||
# pure latency (#291).
|
||||
#
|
||||
# The `config` job below stays ungated and still validates
|
||||
# these files, so narrowing here removes the meritless work
|
||||
# without removing the check that matters. build-prerelease
|
||||
# already reaches the same conclusion on main: none of its
|
||||
# per-package path regexes match these files, so a config-only
|
||||
# push builds no binaries.
|
||||
rust_relevant="$(printf '%s\n' "${files}" \
|
||||
| grep -v '^helexa\.ai/' \
|
||||
| grep -v '^doc/' \
|
||||
| grep -vE '^[^/]+\.md$' \
|
||||
| grep -vE '^asset/neuron/[^/]+\.toml$' \
|
||||
| grep -vE '^asset/helexa-bench/[^/]+\.toml$' \
|
||||
| grep -vE '^models\.toml$' \
|
||||
|| true)"
|
||||
if [ -n "${rust_relevant}" ]; then
|
||||
echo "rust=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -137,6 +157,27 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fmt --check --all
|
||||
|
||||
# models.toml and asset/neuron/<host>.toml both describe how a model is
|
||||
# served, and until #283 nothing forced them to agree. #252 is the bill
|
||||
# for that: models.toml omitted `quant`, so a cortex cold-load served
|
||||
# bf16 and could not prefill, while the same model was fine when the
|
||||
# host loaded it from its own config. The sampling override (#283) is a
|
||||
# second field with the same hazard, so the gate lands with it.
|
||||
#
|
||||
# Deliberately ungated by the `changes` classifier: it is a
|
||||
# sub-second Python run with no build, and the whole point is that it
|
||||
# cannot be skipped on the push that introduces the drift. A config
|
||||
# change is neither `rust` nor `web`, so gating it on either would let
|
||||
# a pure-TOML push through unchecked — precisely the push that breaks
|
||||
# this.
|
||||
config:
|
||||
name: Config consistency
|
||||
timeout-minutes: 5
|
||||
runs-on: fedora-43
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 script/check-config-consistency.py
|
||||
|
||||
web:
|
||||
name: Web (lint + typecheck + i18n + build)
|
||||
timeout-minutes: 20
|
||||
@@ -179,8 +220,13 @@ jobs:
|
||||
# in sync with build-prerelease.yml): a signal death (rustc SIGSEGV
|
||||
# / OOM-kill) keeps the cache and fails fast instead of an uncached
|
||||
# rebuild; only a real sccache fault drops the cache.
|
||||
# --all-targets so `#[cfg(test)]` modules, tests/, benches/ and
|
||||
# examples/ are linted too (#286). Without it clippy sees only lib
|
||||
# and bin targets, so `-D warnings` never gated a single line of
|
||||
# test code — which is how two tests lost their `#[test]` attribute
|
||||
# and stopped running unnoticed.
|
||||
- name: Clippy (sccache escalation)
|
||||
run: script/ci-cargo-escalate.sh cargo clippy --workspace -- -D warnings
|
||||
run: script/ci-cargo-escalate.sh cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
|
||||
+165
-18
@@ -37,7 +37,31 @@ env:
|
||||
${{ secrets.RSYNC_SSH_KEY }}
|
||||
|
||||
jobs:
|
||||
# Gate the whole rollout on the two config files agreeing about every
|
||||
# model they both describe. models.toml (cortex, cold loads) and
|
||||
# asset/neuron/<host>.toml (resident models) each say how a model is
|
||||
# served — quant, and since #283 the sampling override. When they
|
||||
# disagree the model is served one way cold and another way resident,
|
||||
# which is #252.
|
||||
#
|
||||
# Deliberately a *gate*, not a step inside deploy-cortex: an
|
||||
# inconsistent pair must stop the neuron rollout too, and stopping it
|
||||
# before anything restarts is much cheaper than after (a beast restart
|
||||
# costs ~5-6 min of 27B cold-load).
|
||||
config-check:
|
||||
name: Config consistency
|
||||
runs-on: fedora-43
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| github.event.workflow_run.conclusion == 'success'
|
||||
}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 script/check-config-consistency.py
|
||||
|
||||
deploy-cortex:
|
||||
needs: [config-check]
|
||||
runs-on: fedora-43
|
||||
# Two trigger paths: manual dispatch always runs; workflow_run
|
||||
# only runs if the upstream `build-prerelease` actually succeeded.
|
||||
@@ -47,6 +71,7 @@ jobs:
|
||||
|| github.event.workflow_run.conclusion == 'success'
|
||||
}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: SSH init
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
@@ -65,6 +90,33 @@ jobs:
|
||||
echo "inv=$(ssh gitea_ci@hanzalova.internal \
|
||||
'systemctl show -P InvocationID cortex.service' || true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The catalogue is tracked (#283) precisely so it can be rolled out
|
||||
# here rather than hand-copied. Sync it *before* the package step,
|
||||
# so the restart that step performs is the one that picks it up —
|
||||
# and report whether it changed, because a catalogue edit with no
|
||||
# new RPM must still restart cortex or the change never takes
|
||||
# effect.
|
||||
#
|
||||
# Compared by digest rather than rsync's own change detection so the
|
||||
# answer is available to the shell that decides on the restart.
|
||||
- name: Sync gateway catalogue (models.toml)
|
||||
id: catalogue
|
||||
run: |
|
||||
local_sum=$(sha256sum models.toml | cut -d' ' -f1)
|
||||
remote_sum=$(ssh gitea_ci@hanzalova.internal \
|
||||
'sha256sum /etc/cortex/models.toml 2>/dev/null | cut -d" " -f1' || true)
|
||||
if [ "${local_sum}" = "${remote_sum}" ]; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
echo "models.toml unchanged (${local_sum})"
|
||||
else
|
||||
rsync --archive --compress \
|
||||
--chown root:root --chmod 0644 \
|
||||
--rsync-path 'sudo rsync' \
|
||||
models.toml gitea_ci@hanzalova.internal:/etc/cortex/models.toml
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
echo "models.toml updated: ${remote_sum:-<absent>} -> ${local_sum}"
|
||||
fi
|
||||
|
||||
# Gating compares `rpm -q` against the packages.json manifest the
|
||||
# publish job maintains — NOT unprivileged `dnf check-update`,
|
||||
# which proved unreliable as the gitea_ci user (hung on metadata
|
||||
@@ -75,6 +127,11 @@ jobs:
|
||||
ssh gitea_ci@hanzalova.internal 'bash -s' <<'DEPLOY'
|
||||
set -eu
|
||||
pkg=cortex
|
||||
# Default to 0 if the sync step somehow produced no output:
|
||||
# `set -u` plus `[ "" -eq 0 ]` would abort the deploy on an
|
||||
# arithmetic error rather than on anything meaningful.
|
||||
config_changed="${{ steps.catalogue.outputs.changed }}"
|
||||
config_changed="${config_changed:-0}"
|
||||
# `rpm -q` prints "package X is not installed" on STDOUT and exits
|
||||
# 1, so `|| echo not-installed` never fires — it captures the
|
||||
# message text instead. Test the exit code.
|
||||
@@ -90,18 +147,31 @@ jobs:
|
||||
p = max(cands, key=lambda p: p.get("buildTime", 0))
|
||||
print(p["version"] + "-" + p["release"])
|
||||
' "${pkg}" 2>/dev/null || true)
|
||||
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
|
||||
echo "${pkg}-${installed} already current — leaving service untouched"
|
||||
# Skip only when BOTH the package and the catalogue are
|
||||
# unchanged — a models.toml edit (a new sampling override, a
|
||||
# corrected quant) must restart cortex even with no new RPM,
|
||||
# exactly as the neuron drop-in does below.
|
||||
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ] \
|
||||
&& [ "${config_changed}" -eq 0 ]; then
|
||||
echo "${pkg}-${installed} already current; models.toml unchanged — leaving service untouched"
|
||||
exit 0
|
||||
fi
|
||||
echo "installed=${installed} published=${latest:-unknown} — deploying"
|
||||
pkg_changed=1
|
||||
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
|
||||
pkg_changed=0
|
||||
fi
|
||||
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
|
||||
if systemctl is-active --quiet cortex.service; then
|
||||
sudo /usr/bin/systemctl stop cortex.service
|
||||
fi
|
||||
if rpm -q "${pkg}" >/dev/null 2>&1; then
|
||||
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y cortex
|
||||
else
|
||||
sudo /usr/bin/dnf install --refresh --allowerasing -y cortex
|
||||
# A catalogue-only change still restarts, but must not spend a
|
||||
# dnf metadata refresh proving the package is already current.
|
||||
if [ "${pkg_changed}" -eq 1 ]; then
|
||||
if rpm -q "${pkg}" >/dev/null 2>&1; then
|
||||
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y cortex
|
||||
else
|
||||
sudo /usr/bin/dnf install --refresh --allowerasing -y cortex
|
||||
fi
|
||||
fi
|
||||
sudo /usr/bin/systemctl daemon-reload
|
||||
# enable --now: start the service AND enable it for boot so the
|
||||
@@ -142,7 +212,7 @@ jobs:
|
||||
CAPTURE
|
||||
|
||||
deploy-neurons:
|
||||
needs: [deploy-cortex]
|
||||
needs: [config-check, deploy-cortex]
|
||||
runs-on: fedora-43
|
||||
strategy:
|
||||
# One neuron failing must not cancel the others. Cortex is up
|
||||
@@ -177,6 +247,7 @@ jobs:
|
||||
load_timeout: 300
|
||||
max_prompt_tokens: 16384
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: SSH init
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
@@ -192,6 +263,42 @@ jobs:
|
||||
echo "inv=$(ssh gitea_ci@${{ matrix.host }} \
|
||||
'systemctl show -P InvocationID neuron.service' || true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# asset/neuron/<short>.toml is this host's neuron.toml. It carries
|
||||
# the resident-model specs — including the #283 sampling override,
|
||||
# which is inert until the daemon reloads the model, so a change
|
||||
# here has to restart the neuron the same way the context-cap
|
||||
# drop-in does.
|
||||
#
|
||||
# Synced before the package step so the restart that step performs
|
||||
# is the one that picks it up. Digest-compared rather than trusting
|
||||
# rsync's own change detection, because the restart decision needs
|
||||
# the answer and a needless neuron restart costs ~5-6 min of 27B
|
||||
# cold-load (#1).
|
||||
- name: Sync neuron config (${{ matrix.host }})
|
||||
id: neuronconf
|
||||
run: |
|
||||
short="${{ matrix.host }}"; short="${short%%.*}"
|
||||
src="asset/neuron/${short}.toml"
|
||||
if [ ! -f "${src}" ]; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
echo "no ${src} in the repo — leaving this host's config alone"
|
||||
exit 0
|
||||
fi
|
||||
local_sum=$(sha256sum "${src}" | cut -d' ' -f1)
|
||||
remote_sum=$(ssh gitea_ci@${{ matrix.host }} \
|
||||
'sha256sum /etc/neuron/neuron.toml 2>/dev/null | cut -d" " -f1' || true)
|
||||
if [ "${local_sum}" = "${remote_sum}" ]; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
echo "${src} unchanged (${local_sum})"
|
||||
else
|
||||
rsync --archive --compress \
|
||||
--chown root:root --chmod 0644 \
|
||||
--rsync-path 'sudo rsync' \
|
||||
"${src}" gitea_ci@${{ matrix.host }}:/etc/neuron/neuron.toml
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
echo "${src} updated: ${remote_sum:-<absent>} -> ${local_sum}"
|
||||
fi
|
||||
|
||||
# See deploy-cortex for why gating uses the publish manifest and
|
||||
# not unprivileged `dnf check-update`.
|
||||
- name: Deploy helexa-neuron-${{ matrix.flavour }} (skips when already current)
|
||||
@@ -200,6 +307,9 @@ jobs:
|
||||
set -eu
|
||||
pkg=helexa-neuron-${{ matrix.flavour }}
|
||||
max_prompt_tokens="${{ matrix.max_prompt_tokens }}"
|
||||
# A neuron.toml change counts as a config change for the
|
||||
# restart decision below, exactly like the drop-in.
|
||||
neuron_toml_changed="${{ steps.neuronconf.outputs.changed }}"
|
||||
|
||||
# ── Desired per-model systemd drop-in ─────────────────────────
|
||||
# model.conf carries NEURON_MAX_PROMPT_TOKENS so the context cap
|
||||
@@ -208,7 +318,7 @@ jobs:
|
||||
# deploy-managed value wins over any manual local override of the
|
||||
# same variable. See doc/context-limits.md.
|
||||
conf=/etc/systemd/system/neuron.service.d/model.conf
|
||||
config_changed=0
|
||||
config_changed=${neuron_toml_changed:-0}
|
||||
if [ -n "${max_prompt_tokens}" ]; then
|
||||
desired=$(printf '%s\n%s\n%s\n%s' \
|
||||
"# Managed by .gitea/workflows/deploy.yml - do not edit by hand." \
|
||||
@@ -242,13 +352,17 @@ jobs:
|
||||
# Skip only when BOTH the package and the drop-in are unchanged —
|
||||
# a context-cap change must restart the neuron even with no new RPM.
|
||||
if [ "${pkg_changed}" -eq 0 ] && [ "${config_changed}" -eq 0 ]; then
|
||||
echo "${pkg}-${installed} current; NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens:-<unset>} unchanged — leaving service untouched"
|
||||
echo "${pkg}-${installed} current; NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens:-<unset>} and neuron.toml unchanged — leaving service untouched"
|
||||
exit 0
|
||||
fi
|
||||
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
|
||||
|
||||
# Write the drop-in (staged in gitea_ci's dir, installed root-owned).
|
||||
if [ "${config_changed}" -eq 1 ]; then
|
||||
# Guarded on max_prompt_tokens as well as config_changed: since
|
||||
# a neuron.toml change also sets config_changed, an entry with
|
||||
# no max_prompt_tokens would otherwise reach this with `desired`
|
||||
# unset.
|
||||
if [ "${config_changed}" -eq 1 ] && [ -n "${max_prompt_tokens}" ]; then
|
||||
printf '%s\n' "${desired}" > /var/lib/gitea_ci/model.conf
|
||||
sudo /usr/bin/install -o root -g root -m 0644 -D /var/lib/gitea_ci/model.conf "${conf}"
|
||||
rm -f /var/lib/gitea_ci/model.conf
|
||||
@@ -534,6 +648,7 @@ jobs:
|
||||
CAPTURE
|
||||
|
||||
deploy-bench:
|
||||
needs: [config-check]
|
||||
runs-on: fedora-43
|
||||
if: >-
|
||||
${{
|
||||
@@ -541,6 +656,7 @@ jobs:
|
||||
|| github.event.workflow_run.conclusion == 'success'
|
||||
}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: SSH init
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
@@ -556,6 +672,28 @@ jobs:
|
||||
echo "inv=$(ssh gitea_ci@bob.hanzalova.internal \
|
||||
'systemctl show -P InvocationID helexa-bench.service' || true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Same contract as the gateway and neuron configs: the repo is the
|
||||
# only writer, so a config edit must reach the host and restart the
|
||||
# service even when no new RPM landed.
|
||||
- name: Sync bench config
|
||||
id: benchconf
|
||||
run: |
|
||||
src="asset/helexa-bench/bob.toml"
|
||||
local_sum=$(sha256sum "${src}" | cut -d' ' -f1)
|
||||
remote_sum=$(ssh gitea_ci@bob.hanzalova.internal \
|
||||
'sha256sum /etc/helexa-bench/helexa-bench.toml 2>/dev/null | cut -d" " -f1' || true)
|
||||
if [ "${local_sum}" = "${remote_sum}" ]; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
echo "${src} unchanged (${local_sum})"
|
||||
else
|
||||
rsync --archive --compress \
|
||||
--chown root:root --chmod 0644 \
|
||||
--rsync-path 'sudo rsync' \
|
||||
"${src}" gitea_ci@bob.hanzalova.internal:/etc/helexa-bench/helexa-bench.toml
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
echo "${src} updated: ${remote_sum:-<absent>} -> ${local_sum}"
|
||||
fi
|
||||
|
||||
# See deploy-cortex for why gating uses the publish manifest and
|
||||
# not unprivileged `dnf check-update`.
|
||||
- name: Deploy helexa-bench (skips when already current)
|
||||
@@ -563,6 +701,8 @@ jobs:
|
||||
ssh gitea_ci@bob.hanzalova.internal 'bash -s' <<'DEPLOY'
|
||||
set -eu
|
||||
pkg=helexa-bench
|
||||
config_changed="${{ steps.benchconf.outputs.changed }}"
|
||||
config_changed="${config_changed:-0}"
|
||||
# `rpm -q` prints "package X is not installed" on STDOUT and exits
|
||||
# 1, so `|| echo not-installed` never fires — it captures the
|
||||
# message text instead. Test the exit code.
|
||||
@@ -578,18 +718,25 @@ jobs:
|
||||
p = max(cands, key=lambda p: p.get("buildTime", 0))
|
||||
print(p["version"] + "-" + p["release"])
|
||||
' "${pkg}" 2>/dev/null || true)
|
||||
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
|
||||
echo "${pkg}-${installed} already current — leaving service untouched"
|
||||
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ] \
|
||||
&& [ "${config_changed}" -eq 0 ]; then
|
||||
echo "${pkg}-${installed} already current; config unchanged — leaving service untouched"
|
||||
exit 0
|
||||
fi
|
||||
echo "installed=${installed} published=${latest:-unknown} — deploying"
|
||||
pkg_changed=1
|
||||
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
|
||||
pkg_changed=0
|
||||
fi
|
||||
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
|
||||
if systemctl is-active --quiet helexa-bench.service; then
|
||||
sudo /usr/bin/systemctl stop helexa-bench.service
|
||||
fi
|
||||
if rpm -q "${pkg}" >/dev/null 2>&1; then
|
||||
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-bench
|
||||
else
|
||||
sudo /usr/bin/dnf install --refresh --allowerasing -y helexa-bench
|
||||
if [ "${pkg_changed}" -eq 1 ]; then
|
||||
if rpm -q "${pkg}" >/dev/null 2>&1; then
|
||||
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-bench
|
||||
else
|
||||
sudo /usr/bin/dnf install --refresh --allowerasing -y helexa-bench
|
||||
fi
|
||||
fi
|
||||
sudo /usr/bin/systemctl daemon-reload
|
||||
# enable --now: start the service AND enable it for boot so the
|
||||
|
||||
@@ -9,7 +9,6 @@ helexa.ai/.env.local
|
||||
.idea/
|
||||
.vscode/
|
||||
cortex.toml
|
||||
models.toml
|
||||
doc/plan/*
|
||||
/target-cuda/
|
||||
.claude/
|
||||
|
||||
@@ -167,7 +167,7 @@ doc-comment; touch points (the `Job` enum, the dispatch handlers, the
|
||||
cargo build --release # build all crates
|
||||
cargo run -p cortex-cli -- serve # run the gateway
|
||||
cargo test # run all tests
|
||||
cargo clippy --workspace # lint
|
||||
cargo clippy --workspace --all-targets # lint (test code included)
|
||||
```
|
||||
|
||||
## CI
|
||||
@@ -177,7 +177,7 @@ pass before merging:
|
||||
|
||||
```sh
|
||||
cargo fmt --check --all # formatting
|
||||
cargo clippy --workspace -- -D warnings # lint (warnings are errors)
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lint (warnings are errors)
|
||||
cargo test --workspace # tests
|
||||
```
|
||||
|
||||
@@ -191,7 +191,8 @@ Work each change on its own branch; `main` stays releasable.
|
||||
|
||||
1. Implement on a feature branch (`fix/<issue>-…`, `feat/<issue>-…`).
|
||||
2. Run the CI triad locally (`cargo fmt --check --all`,
|
||||
`cargo clippy --workspace -- -D warnings`, `cargo test --workspace`).
|
||||
`cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo test --workspace`).
|
||||
Local builds are **CPU-only** — the `#[cfg(feature = "cuda")]` neuron/TP
|
||||
paths do NOT compile locally. The branch CI's **CUDA type-check** job is
|
||||
the only thing that validates them, so for any neuron change the push to
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
|
||||
#
|
||||
# Edits made directly to the deployed copy on the host are reverted by
|
||||
# the next deploy, and until then the fleet runs something no commit
|
||||
# describes. Change it here, commit, let the pipeline ship it.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# helexa-bench config for bob.hanzalova.internal.
|
||||
#
|
||||
# Synced to /etc/helexa-bench/helexa-bench.toml by script/infra-setup.sh
|
||||
@@ -15,6 +22,20 @@ iteration_pause_secs = 2
|
||||
request_timeout_secs = 600
|
||||
db_path = "/var/lib/helexa-bench/bench.sqlite"
|
||||
|
||||
# Identity bench presents to neurons (#288). Without it bench is
|
||||
# anonymous, and since #262 anonymous callers are served only from
|
||||
# leftover capacity — capped below max_in_flight and parked at the class
|
||||
# gate. That moved beast's concurrency:8 from 168.9 tok/s / 0.53s
|
||||
# ttft_p95 to 104.6 / 11.77s the day #262 landed and held there, so every
|
||||
# number since measured the yield policy, not serving capacity.
|
||||
#
|
||||
# A dedicated pair, not a borrowed one: bench then holds its own
|
||||
# fair-share allocation (#54) and cannot starve interactive traffic —
|
||||
# the failure #262 existed to fix.
|
||||
[bench.principal]
|
||||
account_id = "helexa-bench"
|
||||
key_id = "fleet-benchmark"
|
||||
|
||||
[scenarios]
|
||||
prompt_sizes = [128, 4096]
|
||||
max_tokens = 256
|
||||
@@ -22,6 +43,10 @@ max_tokens = 256
|
||||
# carries p95-under-concurrency data before the F3 A/B gate (#94) needs
|
||||
# it for comparison. Levels mirror the real a0/hermes/opencode fan-out.
|
||||
concurrency_levels = [2, 4, 8]
|
||||
# Non-streaming bursts (#285/#288). Kept to the top level: it is where
|
||||
# serialization shows most starkly and each level costs a full burst per
|
||||
# sample per build.
|
||||
concurrency_nonstreaming_levels = [8]
|
||||
concurrency_prompt_tokens = 512
|
||||
|
||||
# Capability probes (#91) — the reasoning/planning axis the speed
|
||||
|
||||
+38
-1
@@ -1,3 +1,10 @@
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
|
||||
#
|
||||
# Edits made directly to the deployed copy on the host are reverted by
|
||||
# the next deploy, and until then the fleet runs something no commit
|
||||
# describes. Change it here, commit, let the pipeline ship it.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# neuron.toml for beast.hanzalova.internal
|
||||
#
|
||||
# 2x RTX 5090 (32 GB each) — TP-2 capable. Pre-warms Qwen3.6-27B with
|
||||
@@ -22,7 +29,24 @@ name = "candle"
|
||||
# count. NEURON_BATCHING=0 (systemd drop-in) is the kill switch;
|
||||
# removing this section reverts to batch-1 serialization.
|
||||
[harness.candle.admission]
|
||||
max_in_flight = 8
|
||||
# 2, not 8 (#291). max_in_flight is both the admission bound and the
|
||||
# batch engine's slot count, and the KV budget is divided by it at load.
|
||||
# At 8 that gave each slot 18,852 tokens out of 150,816 — against a
|
||||
# 131,072-token advertised context, which needs 4 GB of KV per card on
|
||||
# its own and 32 GB across eight slots, on a 31 GB card already holding
|
||||
# ~21 GB of weights. The advertised context and the slot count were
|
||||
# incompatible by roughly 7x, and an agentic session ran in a ~19k
|
||||
# window while seven idle slots held reservations.
|
||||
#
|
||||
# Seven days of paired request spans on this host: 89.6% of busy time
|
||||
# was ONE request in flight, 93.6% was two or fewer, and utilisation was
|
||||
# 4.9%. Most of what little concurrency exists is helexa-bench's own
|
||||
# synthetic bursts. Two slots cover 93.6% of observed load and give each
|
||||
# ~75k tokens; the batch engine is still worth 1.79x there (5.24x at
|
||||
# eight is throughput nobody is asking this host for). Anything above
|
||||
# two queues on admission (#53) with Retry-After rather than being
|
||||
# refused.
|
||||
max_in_flight = 2
|
||||
|
||||
[[default_models]]
|
||||
# Qwen3.8-27B is the resident flagship (#251). Architecturally identical
|
||||
@@ -35,3 +59,16 @@ harness = "candle"
|
||||
quant = "q6k"
|
||||
tensor_parallel = 2
|
||||
devices = [0, 1]
|
||||
|
||||
# Sampling override (#283) — MUST match the `[models.sampling]` block on
|
||||
# this model in models.toml. Two files describe one model; only
|
||||
# script/check-config-consistency.py forces them to agree, and #252 is
|
||||
# what happens when nothing does.
|
||||
#
|
||||
# The model's own generation_config.json says temperature = 1.0, which
|
||||
# measured a 20% structural-defect rate on ~2k-token code generation
|
||||
# against 0/60 at <= 0.6 (Fisher exact one-sided p = 0.0031). Precedence
|
||||
# is request > operator > model, so a caller naming a temperature still
|
||||
# gets what it asked for.
|
||||
[default_models.sampling]
|
||||
temperature = 0.6
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
|
||||
#
|
||||
# Edits made directly to the deployed copy on the host are reverted by
|
||||
# the next deploy, and until then the fleet runs something no commit
|
||||
# describes. Change it here, commit, let the pipeline ship it.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# neuron.toml for benjy.hanzalova.internal
|
||||
#
|
||||
# 1x RTX 4090 (24 GB) — largest single-GPU host on the fleet. Pre-warms
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
|
||||
#
|
||||
# Edits made directly to the deployed copy on the host are reverted by
|
||||
# the next deploy, and until then the fleet runs something no commit
|
||||
# describes. Change it here, commit, let the pipeline ship it.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# neuron.toml for quadbrat.hanzalova.internal
|
||||
#
|
||||
# 1x RTX 3060 (12 GB) — small / quantised tier. Pre-warms Qwen3-1.7B
|
||||
|
||||
+8
-1
@@ -1,4 +1,10 @@
|
||||
import type { Dimensions, ReportRow, RunRow, SeriesPoint } from "./types";
|
||||
import type {
|
||||
Dimensions,
|
||||
MeasurementRegime,
|
||||
ReportRow,
|
||||
RunRow,
|
||||
SeriesPoint,
|
||||
} from "./types";
|
||||
|
||||
// Empty default → `fetch('/api/...')` hits the dev proxy (vite.config.ts)
|
||||
// or the same origin. For a separately-hosted build, set VITE_API_BASE to
|
||||
@@ -15,6 +21,7 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
|
||||
export const getDimensions = () => getJson<Dimensions>("/api/dimensions");
|
||||
export const getSummary = () => getJson<ReportRow[]>("/api/summary");
|
||||
export const getRegimes = () => getJson<MeasurementRegime[]>("/api/regimes");
|
||||
|
||||
// host is resolved server-side (each model maps to one host today), so the
|
||||
// public UI selects by model + scenario alone.
|
||||
|
||||
+288
-86
@@ -11,8 +11,8 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getDimensions, getSeries } from "../api";
|
||||
import type { Dimensions, SeriesPoint } from "../types";
|
||||
import { getDimensions, getRegimes, getSeries } from "../api";
|
||||
import type { Dimensions, MeasurementRegime, SeriesPoint } from "../types";
|
||||
import { BASELINE_SOURCE, baselineFor } from "../baseline";
|
||||
|
||||
function Picker({
|
||||
@@ -40,11 +40,95 @@ function Picker({
|
||||
);
|
||||
}
|
||||
|
||||
type SeriesDef = {
|
||||
key: string;
|
||||
name: string;
|
||||
stroke: string;
|
||||
dashed?: boolean;
|
||||
};
|
||||
|
||||
/** One titled chart over the shared build timeline.
|
||||
*
|
||||
* Every panel draws the same x-axis and the same regime divider, so a
|
||||
* reader can line a change up across metrics — which is the whole point
|
||||
* of having more than two of them. */
|
||||
/** A vertical rule at a build where the metric changed meaning. */
|
||||
type Rule = { at: string; label: string; detail?: string };
|
||||
|
||||
function MetricChart({
|
||||
title,
|
||||
hint,
|
||||
data,
|
||||
lines,
|
||||
rules,
|
||||
unit,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
data: Record<string, unknown>[];
|
||||
lines: SeriesDef[];
|
||||
rules?: Rule[];
|
||||
unit?: string;
|
||||
}) {
|
||||
const hasAny = data.some((d) => lines.some((l) => d[l.key] != null));
|
||||
if (!hasAny) return null;
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4">
|
||||
{title}
|
||||
{unit ? <span className="text-muted fw-normal"> ({unit})</span> : null}
|
||||
</h5>
|
||||
{hint && <p className="text-muted small mb-2">{hint}</p>}
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 24, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{(rules ?? [])
|
||||
// Only draw a rule whose build is actually on this x-axis;
|
||||
// recharts would otherwise place it at the origin and imply
|
||||
// the boundary sits before all the data.
|
||||
.filter((r) => data.some((d) => d.label === r.at))
|
||||
.map((r) => (
|
||||
<ReferenceLine
|
||||
key={`${r.at}-${r.label}`}
|
||||
x={r.at}
|
||||
stroke="#bbb"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: r.label,
|
||||
position: "top",
|
||||
fill: "#999",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{lines.map((l) => (
|
||||
<Line
|
||||
key={l.key}
|
||||
type="monotone"
|
||||
dataKey={l.key}
|
||||
name={l.name}
|
||||
stroke={l.stroke}
|
||||
strokeDasharray={l.dashed ? "5 5" : undefined}
|
||||
connectNulls
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Trends() {
|
||||
const [dims, setDims] = useState<Dimensions | null>(null);
|
||||
const [model, setModel] = useState("");
|
||||
const [scenario, setScenario] = useState("");
|
||||
const [series, setSeries] = useState<SeriesPoint[]>([]);
|
||||
const [regimes, setRegimes] = useState<MeasurementRegime[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,6 +139,10 @@ export default function Trends() {
|
||||
if (d.scenarios[0]) setScenario(d.scenarios[0]);
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
// A missing/older API just means no rules — not a page failure.
|
||||
getRegimes()
|
||||
.then(setRegimes)
|
||||
.catch(() => setRegimes([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,10 +156,7 @@ export default function Trends() {
|
||||
// Prepend the pre-helexa-bench baseline (dashed, separate keys) so it
|
||||
// anchors the timeline without being merged into the live line. Different
|
||||
// measurement regime — see baseline.ts / doc/benchmarks.md.
|
||||
const base = useMemo(
|
||||
() => baselineFor(model, scenario),
|
||||
[model, scenario],
|
||||
);
|
||||
const base = useMemo(() => baselineFor(model, scenario), [model, scenario]);
|
||||
const data = useMemo(
|
||||
() => [
|
||||
...base.map((p) => ({
|
||||
@@ -85,6 +170,14 @@ export default function Trends() {
|
||||
ttft: p.ttft_s_median,
|
||||
decode: p.decode_tps_median,
|
||||
total: p.total_s_median,
|
||||
ttftP95: p.ttft_p95_s_median,
|
||||
queueWait: p.queue_wait_ms_median,
|
||||
rejected: p.rejected_median,
|
||||
prefillTps: p.prefill_tps_median,
|
||||
reasoning: p.reasoning_tokens_median,
|
||||
cached: p.cached_tokens_median,
|
||||
completion: p.completion_tokens_median,
|
||||
tpot: p.tpot_p95_ms_median,
|
||||
})),
|
||||
],
|
||||
[series, base],
|
||||
@@ -95,6 +188,55 @@ export default function Trends() {
|
||||
const firstLive = series[0]?.git_sha;
|
||||
const showDivider = base.length > 0 && series.length > 0;
|
||||
|
||||
// The build where the measuring identity changed, derived from the
|
||||
// data rather than declared — a constant would go stale the moment
|
||||
// the principal is reconfigured (#288).
|
||||
const identityShift = useMemo(() => {
|
||||
for (let i = 1; i < series.length; i++) {
|
||||
const prev = series[i - 1].principal ?? "anonymous";
|
||||
const cur = series[i].principal ?? "anonymous";
|
||||
if (prev !== cur) {
|
||||
return {
|
||||
at: series[i].git_sha,
|
||||
label: `measured as ${cur === "anonymous" ? "anonymous" : "identified"}`,
|
||||
detail:
|
||||
"The identity bench measured under changed here. Anonymous " +
|
||||
"samples are subject to the #262 yield policy and understate " +
|
||||
"capacity, so points either side are not comparable (#288).",
|
||||
} as Rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [series]);
|
||||
|
||||
/** Declared boundaries touching `metric`, plus the regime-independent
|
||||
* ones that apply to every panel. */
|
||||
const rulesFor = (metric: string): Rule[] => {
|
||||
const out: Rule[] = [];
|
||||
if (showDivider && firstLive) {
|
||||
out.push({ at: firstLive, label: "bench.py → helexa-bench" });
|
||||
}
|
||||
for (const r of regimes) {
|
||||
if (r.affects.includes(metric)) {
|
||||
out.push({ at: r.first_sha, label: r.label, detail: r.detail });
|
||||
}
|
||||
}
|
||||
if (identityShift) out.push(identityShift);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Anonymous and identified samples are not comparable once #262 is in
|
||||
// the build: an anonymous caller is capped below max_in_flight and
|
||||
// parked at the class gate, so it characterises the yield policy
|
||||
// rather than serving capacity. Say so rather than letting someone
|
||||
// read a step change as an engine regression (#288).
|
||||
const identities = useMemo(
|
||||
() => new Set(series.map((p) => p.principal ?? "anonymous")),
|
||||
[series],
|
||||
);
|
||||
const mixedIdentity = identities.size > 1;
|
||||
const anyAnonymous = identities.has("anonymous");
|
||||
|
||||
if (err) return <Alert variant="danger">{err}</Alert>;
|
||||
if (!dims) return <Spinner animation="border" />;
|
||||
|
||||
@@ -122,6 +264,25 @@ export default function Trends() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mixedIdentity && (
|
||||
<Alert variant="warning" className="py-2">
|
||||
<strong>Mixed measurement identity.</strong> Some builds were
|
||||
sampled anonymously and some under a principal. Since{" "}
|
||||
<code>#262</code> an anonymous caller is capped below{" "}
|
||||
<code>max_in_flight</code> and yields to identified traffic, so
|
||||
those points measure the admission policy rather than serving
|
||||
capacity. A step change across that boundary is the instrument,
|
||||
not the engine — see <code>#288</code>.
|
||||
</Alert>
|
||||
)}
|
||||
{!mixedIdentity && anyAnonymous && (
|
||||
<Alert variant="secondary" className="py-2 small">
|
||||
Sampled anonymously. Since <code>#262</code> anonymous callers are
|
||||
served from leftover capacity, so these numbers understate what an
|
||||
authenticated caller gets (<code>#288</code>).
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data.length === 0 ? (
|
||||
<Alert variant="info">No data for this selection yet.</Alert>
|
||||
) : (
|
||||
@@ -133,87 +294,128 @@ export default function Trends() {
|
||||
see <code>doc/benchmarks.md</code>.
|
||||
</p>
|
||||
)}
|
||||
<h5 className="mt-3">decode tok/s (higher is better)</h5>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 24, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{showDivider && firstLive && (
|
||||
<ReferenceLine
|
||||
x={firstLive}
|
||||
stroke="#bbb"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: "bench.py → helexa-bench",
|
||||
position: "top",
|
||||
fill: "#999",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="decode"
|
||||
name="decode tok/s"
|
||||
stroke="#0d6efd"
|
||||
connectNulls
|
||||
/>
|
||||
{base.length > 0 && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="baseDecode"
|
||||
name="baseline (bench.py · gateway)"
|
||||
stroke="#888"
|
||||
strokeDasharray="5 5"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<h5 className="mt-4">TTFT seconds (lower is better)</h5>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 24, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{showDivider && firstLive && (
|
||||
<ReferenceLine
|
||||
x={firstLive}
|
||||
stroke="#bbb"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: "bench.py → helexa-bench",
|
||||
position: "top",
|
||||
fill: "#999",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ttft"
|
||||
name="TTFT (s)"
|
||||
stroke="#dc3545"
|
||||
connectNulls
|
||||
/>
|
||||
{base.length > 0 && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="baseTtft"
|
||||
name="baseline (bench.py · gateway)"
|
||||
stroke="#888"
|
||||
strokeDasharray="5 5"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<MetricChart
|
||||
title="decode tok/s"
|
||||
unit="higher is better"
|
||||
data={data}
|
||||
rules={rulesFor("decode")}
|
||||
lines={[
|
||||
{ key: "decode", name: "decode tok/s", stroke: "#0d6efd" },
|
||||
...(base.length > 0
|
||||
? [
|
||||
{
|
||||
key: "baseDecode",
|
||||
name: "baseline (bench.py · gateway)",
|
||||
stroke: "#888",
|
||||
dashed: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricChart
|
||||
title="prefill tok/s"
|
||||
unit="higher is better"
|
||||
hint="The other half of serving speed, derived from prefill_tokens / prefill_ms. A prefix-cache hit shortens prefill_ms while the token count stays whole, so a high rate here is itself the cache-hit signal."
|
||||
data={data}
|
||||
rules={rulesFor("prefillTps")}
|
||||
lines={[
|
||||
{ key: "prefillTps", name: "prefill tok/s", stroke: "#20c997" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricChart
|
||||
title="TTFT"
|
||||
unit="seconds, lower is better"
|
||||
hint="Median and p95 together on purpose. Under concurrency the median is dominated by whichever streams were admitted immediately; the p95 is the one that moves when a caller is made to wait. Charting only the median is how a 0.53 s → 11.77 s tail went unnoticed for a week."
|
||||
data={data}
|
||||
rules={rulesFor("ttft")}
|
||||
lines={[
|
||||
{ key: "ttft", name: "TTFT median (s)", stroke: "#dc3545" },
|
||||
{ key: "ttftP95", name: "TTFT p95 (s)", stroke: "#fd7e14" },
|
||||
...(base.length > 0
|
||||
? [
|
||||
{
|
||||
key: "baseTtft",
|
||||
name: "baseline (bench.py · gateway)",
|
||||
stroke: "#888",
|
||||
dashed: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricChart
|
||||
title="inter-token gap p95"
|
||||
unit="ms, lower is better"
|
||||
hint="Stream smoothness. decode tok/s is a mean over the whole window, so a stream that stalls and then catches up is indistinguishable from one that never stalled — this is the number a user feels."
|
||||
data={data}
|
||||
rules={rulesFor("tpot")}
|
||||
lines={[
|
||||
{ key: "tpot", name: "inter-token p95 (ms)", stroke: "#6f42c1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricChart
|
||||
title="admission"
|
||||
unit="queue wait ms · requests shed"
|
||||
hint="Separates “the server is slow” from “you were queued behind someone”. Queue wait is TTFT minus server-measured prefill; rejected counts honest backpressure rather than silent failures."
|
||||
data={data}
|
||||
rules={rulesFor("queueWait")}
|
||||
lines={[
|
||||
{ key: "queueWait", name: "queue wait (ms)", stroke: "#d63384" },
|
||||
{ key: "rejected", name: "rejected (count)", stroke: "#adb5bd" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricChart
|
||||
title="tokens per sample"
|
||||
unit="counts"
|
||||
hint="Cost, not speed. Reasoning tokens are the dominant driver on a reasoning model and move independently of every rate above — a template or sampling change can double what the model thinks before answering while the speed charts stay flat. Cached tokens are why prefill timing varies between otherwise identical samples."
|
||||
data={data}
|
||||
rules={rulesFor("completion")}
|
||||
lines={[
|
||||
{ key: "completion", name: "completion tokens", stroke: "#0dcaf0" },
|
||||
{ key: "reasoning", name: "reasoning tokens", stroke: "#ffc107" },
|
||||
{ key: "cached", name: "cached prompt tokens", stroke: "#198754" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
// Explain every rule actually drawn for this selection. The
|
||||
// chart label is a name; without the reason, a reader still
|
||||
// has to go and rediscover what it meant — which is the cost
|
||||
// this whole feature exists to remove.
|
||||
const shown = [
|
||||
...regimes
|
||||
.filter((r) => series.some((p) => p.git_sha === r.first_sha))
|
||||
.map((r) => ({ at: r.first_sha, label: r.label, detail: r.detail })),
|
||||
...(identityShift ? [identityShift] : []),
|
||||
];
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-4 pt-3 border-top">
|
||||
<p className="text-muted small mb-2">
|
||||
<strong>Dashed rules mark measurement-regime changes</strong> —
|
||||
builds where a number changed meaning rather than value. A step
|
||||
across one is the instrument, not the engine.
|
||||
</p>
|
||||
<dl className="row small text-muted mb-0">
|
||||
{shown.map((r) => (
|
||||
<div key={`${r.at}-${r.label}`} className="mb-2">
|
||||
<dt className="fw-semibold">
|
||||
<code>{r.at}</code> — {r.label}
|
||||
</dt>
|
||||
<dd className="mb-0">{r.detail}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -41,6 +41,17 @@ export interface SeriesPoint {
|
||||
ttft_s_median: number | null;
|
||||
decode_tps_median: number | null;
|
||||
total_s_median: number | null;
|
||||
ttft_p95_s_median: number | null;
|
||||
queue_wait_ms_median: number | null;
|
||||
rejected_median: number | null;
|
||||
prefill_tps_median: number | null;
|
||||
reasoning_tokens_median: number | null;
|
||||
cached_tokens_median: number | null;
|
||||
completion_tokens_median: number | null;
|
||||
tpot_p95_ms_median: number | null;
|
||||
/** Identity the samples were taken under, or null if anonymous.
|
||||
* Anonymous and identified points are not comparable (#288). */
|
||||
principal: string | null;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
@@ -67,3 +78,13 @@ export interface RunRow {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A build at which a metric changed meaning rather than value (#288).
|
||||
* Served from the API so the UI and `report` cite one list. */
|
||||
export interface MeasurementRegime {
|
||||
first_sha: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
/** Series keys this affects, as Trends.tsx names them. */
|
||||
affects: string[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Model catalogue — profiles describing how to serve each model.
|
||||
|
||||
use crate::discovery::DeviceInfo;
|
||||
use crate::harness::{ModelCost, ModelLimit};
|
||||
use crate::harness::{ModelCost, ModelLimit, SamplingOverride};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -57,6 +57,16 @@ pub struct ModelProfile {
|
||||
/// on this being explicit per model rather than implicit.
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Operator sampling override forwarded to neuron on cold load
|
||||
/// (#283). The counterpart to the same field in a host's
|
||||
/// `[[default_models]]` — a model that can be both cold-loaded by
|
||||
/// cortex and resident on a host must carry the same value in both,
|
||||
/// which `script/check-config-consistency.py` enforces in CI.
|
||||
///
|
||||
/// This is the #252 shape of hazard: two config files describing one
|
||||
/// model, with nothing forcing them to agree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sampling: Option<SamplingOverride>,
|
||||
|
||||
// ── Enrichment (issue #62) ────────────────────────────────
|
||||
/// Per-model token budget. When present, advertised in `/v1/models`
|
||||
@@ -240,6 +250,7 @@ mod tests {
|
||||
pinned_on: vec![],
|
||||
residency_priority: None,
|
||||
source: None,
|
||||
sampling: None,
|
||||
limit: None,
|
||||
cost: None,
|
||||
capabilities: vec![],
|
||||
|
||||
@@ -26,7 +26,50 @@ pub struct HarnessHealth {
|
||||
pub uptime_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Operator-set sampling defaults for one model (#283).
|
||||
///
|
||||
/// A model's `generation_config.json` is the model authors' statement of
|
||||
/// intent, and honouring it (#272) is right by default. It is not always
|
||||
/// right for the workload the operator serves: `Qwen/Qwen3.8-27B`
|
||||
/// publishes `temperature = 1.0`, which measured a 20% structural-defect
|
||||
/// rate on ~2k-token code generation against 0/60 at `<= 0.6`
|
||||
/// (#283, p = 0.0031).
|
||||
///
|
||||
/// This is the operator's explicit, visible correction to that default —
|
||||
/// deliberately *not* a heuristic that inspects the model and guesses.
|
||||
/// A temperature guesser is a footgun; a number in a config file the
|
||||
/// operator wrote is not.
|
||||
///
|
||||
/// Precedence is **request > operator > model > built-in fallback**: the
|
||||
/// override replaces what the model published, but a caller that names a
|
||||
/// value still wins, because an explicit API parameter that is silently
|
||||
/// ignored is a contract break.
|
||||
///
|
||||
/// Every field is optional and overlays independently — setting
|
||||
/// `temperature` alone leaves the model's `top_p`/`top_k` in force.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SamplingOverride {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub top_k: Option<usize>,
|
||||
}
|
||||
|
||||
impl SamplingOverride {
|
||||
/// True when the operator set nothing — an empty `[sampling]` table
|
||||
/// must behave exactly as no table at all.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.temperature.is_none() && self.top_p.is_none() && self.top_k.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Specification for loading a model through a harness.
|
||||
///
|
||||
/// Doubles as the `[[default_models]]` entry shape in `neuron.toml`, so
|
||||
/// a field added here is available to both the load API and the
|
||||
/// operator's per-host config without a second type to keep in step.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelSpec {
|
||||
pub model_id: String,
|
||||
@@ -34,6 +77,15 @@ pub struct ModelSpec {
|
||||
pub quant: Option<String>,
|
||||
pub tensor_parallel: Option<u32>,
|
||||
pub devices: Option<Vec<u32>>,
|
||||
/// Operator sampling override (#283). Travels with the *model*, not
|
||||
/// the request: neuron is the only place sampling is resolved, so
|
||||
/// the override has to reach it whether the load came from cortex's
|
||||
/// catalogue or from this host's own `[[default_models]]`.
|
||||
///
|
||||
/// `#[serde(default)]` so a cortex or neuron predating this field
|
||||
/// still round-trips the spec.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sampling: Option<SamplingOverride>,
|
||||
}
|
||||
|
||||
/// Per-model token budget advertised by the catalogue or neuron.
|
||||
@@ -76,6 +128,53 @@ fn is_zero(v: &usize) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
/// What each reasoning effort level costs, in reasoning tokens (#223).
|
||||
///
|
||||
/// OpenAI-shaped clients can only send `minimal|low|medium|high` — the
|
||||
/// ladder is their entire vocabulary, and they cannot express a token
|
||||
/// count. So the server picks the numbers, and has to say what they are:
|
||||
/// a client choosing `low` with no idea whether that buys 2k tokens or
|
||||
/// 20k is guessing, which is the failure #274 exists to end.
|
||||
///
|
||||
/// Ordered rungs rather than a map, so the ladder reads in the order a
|
||||
/// client would climb it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReasoningBudgetRung {
|
||||
/// The effort level as the caller spells it on the wire.
|
||||
///
|
||||
/// Since #290 these are the rungs the **model's own chat template**
|
||||
/// accepts, discovered by rendering it, rather than a ladder the
|
||||
/// operator invented. `Qwen/Qwen3.8-27B` accepts exactly `low`,
|
||||
/// `medium` and `xhigh` and raises on anything else; we previously
|
||||
/// advertised `minimal`/`low`/`medium`/`high`, two of which the
|
||||
/// template rejects and which omitted the model's own default.
|
||||
///
|
||||
/// Advertise only rungs the template accepts: an invented name is a
|
||||
/// name a caller can send and the model will reject. Note that a
|
||||
/// client need not read this at all — pi-ai takes its levels from a
|
||||
/// static `thinkingLevelMap` in the operator's config — so this is
|
||||
/// a statement of truth, not a control surface we can rely on.
|
||||
pub effort: String,
|
||||
/// Backstop reasoning-token cap for this rung, when the deployment
|
||||
/// sets one.
|
||||
///
|
||||
/// `None` is the normal case and means "named rung, no cap": effort
|
||||
/// is expressed to the model through its template, and the number of
|
||||
/// tokens it then spends is the model's business. A cap is a safety
|
||||
/// net against a runaway think block (#223), not the mechanism by
|
||||
/// which effort is selected — enforcing effort by truncation is what
|
||||
/// #290 fixed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tokens: Option<usize>,
|
||||
/// Whether the model applies this rung when the caller names none.
|
||||
///
|
||||
/// Taken from the template's own `|default(...)`. Without it a
|
||||
/// caller that says nothing cannot tell what it is getting — on
|
||||
/// Qwen3.8 that silently means `xhigh`.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub default: bool,
|
||||
}
|
||||
|
||||
/// Operator-set pricing, **USD per 1,000,000 tokens, as JSON numbers**
|
||||
/// (`float`) — the models.dev/opencode `cost` convention, which is what
|
||||
/// helexa's primary client reads. NOT per-token, NOT decimal strings (that
|
||||
@@ -166,6 +265,11 @@ pub struct ModelInfo {
|
||||
/// from its own routing table.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub servable: Option<ModelServability>,
|
||||
/// The reasoning-effort ladder this deployment honours (#223).
|
||||
/// Absent when the model does not reason, or the harness has no
|
||||
/// budget to offer.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reasoning_budget: Vec<ReasoningBudgetRung>,
|
||||
}
|
||||
|
||||
/// Why a loaded model can or cannot be served right now (#245).
|
||||
|
||||
@@ -44,6 +44,12 @@ pub struct NodeState {
|
||||
/// just one whose total topology could. Empty until the first
|
||||
/// /health poll.
|
||||
pub device_health: Vec<crate::discovery::DeviceHealth>,
|
||||
/// The reasoning-effort ladder this neuron honours (#223), copied
|
||||
/// from its `/models` reply at poll time. Per-node rather than
|
||||
/// per-model because it is host configuration — every model on a
|
||||
/// neuron reports the same rungs — and duplicating it onto each
|
||||
/// model entry would invite the two to disagree.
|
||||
pub reasoning_budget: Vec<crate::harness::ReasoningBudgetRung>,
|
||||
}
|
||||
|
||||
/// A model registered on a node, with its runtime status.
|
||||
@@ -188,10 +194,19 @@ pub struct CortexModelEntry {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_model_len: Option<usize>,
|
||||
/// The same window under the two names generic OpenAI clients look
|
||||
/// for (#278). pi-ai's provider discovery, for one, reads
|
||||
/// `context_window` then `context_length` and nothing else — so a
|
||||
/// client probing cortex found no window at all and fell back to a
|
||||
/// number its operator had to invent by hand.
|
||||
/// for (#278) — a client that probes for one of these rather than
|
||||
/// `max_model_len` would otherwise find no window at all and fall
|
||||
/// back to a number its operator had to invent by hand.
|
||||
///
|
||||
/// Publishing them is cheap and harmless; do not assume any
|
||||
/// *specific* client consumes them. An earlier version of this
|
||||
/// comment credited pi-ai's provider discovery, which was wrong:
|
||||
/// pi requires `contextWindow` as a static field on its model type
|
||||
/// and reads none of these keys at runtime (checked against 0.82.1
|
||||
/// and 0.84.3). The misattribution mattered — it made an operator's
|
||||
/// hand-set 80k window look like our advertisement failing to land,
|
||||
/// when in fact nothing was reading it and the number had to be
|
||||
/// maintained by hand regardless.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_window: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -203,6 +218,10 @@ pub struct CortexModelEntry {
|
||||
/// `limit.output_ceiling`, falling back to `limit.output` (#278).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<usize>,
|
||||
/// What each effort level buys, in reasoning tokens (#223) — so a
|
||||
/// client picking `low` knows what it asked for.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reasoning_budget: Vec<crate::harness::ReasoningBudgetRung>,
|
||||
}
|
||||
|
||||
impl CortexModelEntry {
|
||||
@@ -217,12 +236,16 @@ impl CortexModelEntry {
|
||||
self.context_window = self.limit.as_ref().map(|l| l.context);
|
||||
self.context_length = self.limit.as_ref().map(|l| l.context);
|
||||
self.max_input_tokens = self.limit.as_ref().and_then(|l| l.input);
|
||||
// The ceiling, not the reserve (#278): this field is what
|
||||
// OpenAI-ecosystem clients read to decide what to ask for, and
|
||||
// pi-ai/DSH turns it straight into a per-request cap. Advertising
|
||||
// the reserve here handed a reasoning model less budget than its
|
||||
// own think block needs. Falls back to the reserve for a neuron
|
||||
// too old to publish a ceiling.
|
||||
// The ceiling, not the reserve (#278): a client that reads this
|
||||
// field turns it into the cap it sends on each request, so
|
||||
// advertising the reserve handed a reasoning model less budget
|
||||
// than its own think block needs. Falls back to the reserve for
|
||||
// a neuron too old to publish a ceiling.
|
||||
//
|
||||
// Observed with dsh. pi is *not* an example: its `maxTokens` is
|
||||
// a required static field in models.json and it reads nothing
|
||||
// from here — an operator has to copy this number across by
|
||||
// hand, and gets no error if they copy it wrong.
|
||||
self.max_output_tokens = self.limit.as_ref().map(|l| {
|
||||
if l.output_ceiling > 0 {
|
||||
l.output_ceiling
|
||||
|
||||
@@ -215,11 +215,46 @@ pub struct ResponsesResponse {
|
||||
pub status: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponsesOutputItem>,
|
||||
/// Why a `status: "incomplete"` response stopped short. Required in
|
||||
/// practice, not optional decoration: a client cannot tell an
|
||||
/// honest truncation from a protocol fault without it, and at least
|
||||
/// one treats the difference as fatal.
|
||||
///
|
||||
/// pi-ai's `mapStopReason` maps
|
||||
/// `incomplete` + `reason == "max_output_tokens"` to
|
||||
/// `stopReason: "length"`, and `incomplete` with no reason to
|
||||
/// `stopReason: "error"` ("Response incomplete without a provider
|
||||
/// reason"). Its agent loop then either continues — failing any
|
||||
/// truncated tool calls safely and letting the model recover on the
|
||||
/// next turn — or halts outright. Omitting this field cost a live
|
||||
/// agentic session its whole run.
|
||||
///
|
||||
/// `None` for `completed` and `in_progress`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub incomplete_details: Option<IncompleteDetails>,
|
||||
/// Populated on completion; `None` while streaming.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<ResponsesUsage>,
|
||||
}
|
||||
|
||||
/// Why a response is `incomplete`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct IncompleteDetails {
|
||||
/// OpenAI spells the output-cap case `"max_output_tokens"`; that is
|
||||
/// the only reason we produce today. `content_filter` is the other
|
||||
/// value the upstream API defines.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl IncompleteDetails {
|
||||
/// The response ran into the caller's output budget.
|
||||
pub fn max_output_tokens() -> Self {
|
||||
Self {
|
||||
reason: "max_output_tokens".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponsesOutputItem {
|
||||
@@ -327,6 +362,24 @@ pub mod events {
|
||||
pub const FUNCTION_CALL_ARGUMENTS_DELTA: &str = "response.function_call_arguments.delta";
|
||||
pub const FUNCTION_CALL_ARGUMENTS_DONE: &str = "response.function_call_arguments.done";
|
||||
pub const COMPLETED: &str = "response.completed";
|
||||
/// Terminal frame for a response that stopped short. Carries the
|
||||
/// same `response` payload as [`COMPLETED`], with
|
||||
/// `status: "incomplete"` and `incomplete_details.reason`.
|
||||
///
|
||||
/// A client keyed on the event name — rather than on
|
||||
/// `response.status` — sees no terminal event at all if a truncated
|
||||
/// response is announced as `response.completed`, and waits out its
|
||||
/// idle timeout.
|
||||
pub const INCOMPLETE: &str = "response.incomplete";
|
||||
/// Terminal frame for a response that did not finish because the
|
||||
/// server failed — a poisoned device, an OOM, a producer that died
|
||||
/// mid-stream. Carries `status: "failed"` and an `error` object.
|
||||
///
|
||||
/// The alternative is worse than it looks: defaulting a missing
|
||||
/// finish to a clean stop reports a crash as a complete answer, and
|
||||
/// the caller believes a truncated or empty reply was the model's
|
||||
/// considered response.
|
||||
pub const FAILED: &str = "response.failed";
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -531,6 +584,7 @@ mod tests {
|
||||
}],
|
||||
status: "completed".into(),
|
||||
}],
|
||||
incomplete_details: None,
|
||||
usage: Some(ResponsesUsage {
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
@@ -543,5 +597,36 @@ mod tests {
|
||||
let parsed: ResponsesResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.id, "resp_1");
|
||||
assert_eq!(parsed.output.len(), 1);
|
||||
assert!(
|
||||
!json.contains("incomplete_details"),
|
||||
"a completed response must not carry an incomplete reason"
|
||||
);
|
||||
}
|
||||
|
||||
/// The field pi-ai's `mapStopReason` keys on. Absent, it maps
|
||||
/// `incomplete` to `stopReason: "error"` and its agent loop halts;
|
||||
/// present with `max_output_tokens`, it maps to `"length"` and the
|
||||
/// loop continues.
|
||||
#[test]
|
||||
fn an_incomplete_response_serialises_its_reason() {
|
||||
let r = ResponsesResponse {
|
||||
id: "resp_2".into(),
|
||||
object: "response".into(),
|
||||
created_at: 1700,
|
||||
status: "incomplete".into(),
|
||||
model: "m".into(),
|
||||
output: vec![],
|
||||
incomplete_details: Some(IncompleteDetails::max_output_tokens()),
|
||||
usage: None,
|
||||
};
|
||||
let v: serde_json::Value = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v["status"], "incomplete");
|
||||
assert_eq!(v["incomplete_details"]["reason"], "max_output_tokens");
|
||||
|
||||
let parsed: ResponsesResponse = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(
|
||||
parsed.incomplete_details,
|
||||
Some(IncompleteDetails::max_output_tokens())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,28 @@ pub fn anthropic_to_openai(req: MessagesRequest) -> ChatCompletionRequest {
|
||||
if let Some(tc) = tool_choice {
|
||||
obj.insert("tool_choice".into(), tc);
|
||||
}
|
||||
// Anthropic's extended-thinking budget is the one quantitative
|
||||
// reasoning control any of our wire formats offers (#223).
|
||||
// Normalised onto `reasoning.max_tokens`, the shape the harness
|
||||
// resolves, so all three inlets converge on one field instead of
|
||||
// each generation loop learning three spellings.
|
||||
let budget = obj
|
||||
.get("thinking")
|
||||
.filter(|t| t.get("type").and_then(Value::as_str) != Some("disabled"))
|
||||
.and_then(|t| t.get("budget_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|n| *n > 0);
|
||||
if let Some(budget) = budget {
|
||||
let reasoning = obj
|
||||
.entry("reasoning")
|
||||
.or_insert_with(|| Value::Object(Default::default()));
|
||||
if !reasoning.is_object() {
|
||||
*reasoning = Value::Object(Default::default());
|
||||
}
|
||||
if let Some(r) = reasoning.as_object_mut() {
|
||||
r.entry("max_tokens").or_insert_with(|| Value::from(budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatCompletionRequest {
|
||||
|
||||
@@ -800,6 +800,8 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
// Catalogue-seeded: no neuron has reported rungs yet.
|
||||
reasoning_budget: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -841,6 +843,13 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
// so a client never overflows the most-constrained
|
||||
// deployment that might serve it.
|
||||
e.limit = tightest_limit(e.limit.take(), entry.limit.clone());
|
||||
// The reasoning ladder this neuron honours (#223).
|
||||
// First non-empty wins: a catalogue-seeded entry
|
||||
// starts with none, and neurons serving the same
|
||||
// model are configured alike.
|
||||
if e.reasoning_budget.is_empty() {
|
||||
e.reasoning_budget = node.reasoning_budget.clone();
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| CortexModelEntry {
|
||||
id: model_id.clone(),
|
||||
@@ -862,6 +871,8 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
// What this neuron says each effort level buys (#223).
|
||||
reasoning_budget: node.reasoning_budget.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -925,6 +936,8 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
// What this neuron says each effort level buys (#223).
|
||||
reasoning_budget: node.reasoning_budget.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -964,6 +977,8 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
// Catalogue-seeded: no neuron has reported rungs yet.
|
||||
reasoning_budget: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ mod tests {
|
||||
// Requested output dominates; prompt estimate is small for a tiny body.
|
||||
let body = br#"{"model":"m","max_tokens":1000}"#;
|
||||
let est = reservation_estimate(body, Some(8192));
|
||||
assert!(est >= 1000 && est < 1100, "est was {est}");
|
||||
assert!((1000..1100).contains(&est), "est was {est}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -130,6 +130,17 @@ async fn poll_neuron(fleet: &CortexState, name: &str, endpoint: &str) {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.json::<Vec<ModelInfo>>().await {
|
||||
Ok(models) => {
|
||||
// The reasoning-effort ladder is host configuration:
|
||||
// every model on a neuron reports the same rungs, so
|
||||
// it is held once per node rather than copied onto
|
||||
// each model entry where the two could drift (#223).
|
||||
// Taken from the first model that offers one — a
|
||||
// non-reasoning model advertises none.
|
||||
node.reasoning_budget = models
|
||||
.iter()
|
||||
.map(|m| m.reasoning_budget.clone())
|
||||
.find(|rungs| !rungs.is_empty())
|
||||
.unwrap_or_default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for upstream in &models {
|
||||
seen.insert(upstream.id.clone());
|
||||
|
||||
@@ -535,6 +535,10 @@ async fn profile_to_spec(
|
||||
quant: profile.quant.clone(),
|
||||
tensor_parallel,
|
||||
devices: Some(devices),
|
||||
// The catalogue's operator override rides along with the load
|
||||
// (#283), so a cold-loaded model samples the same way as one
|
||||
// this host was configured to hold resident.
|
||||
sampling: profile.sampling.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +646,7 @@ mod tests {
|
||||
pinned_on: vec![],
|
||||
residency_priority: None,
|
||||
source: source.map(String::from),
|
||||
sampling: None,
|
||||
limit: None,
|
||||
cost: None,
|
||||
capabilities: vec![],
|
||||
|
||||
@@ -66,6 +66,9 @@ impl CortexState {
|
||||
endpoint: nc.endpoint.clone(),
|
||||
healthy: false,
|
||||
models: HashMap::new(),
|
||||
// Filled from the neuron's /models reply at the
|
||||
// first poll (#223).
|
||||
reasoning_budget: Vec::new(),
|
||||
lifecycle_cycles: 0,
|
||||
last_poll: None,
|
||||
discovery: None,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Integration tests for budget enforcement (#52) — the A0 seatbelt.
|
||||
//!
|
||||
//! A reservation over the key's hard cap is refused *before* neuron is hit,
|
||||
//! with the #63 code matching the cap-window semantics (rate_limit_exceeded
|
||||
//! + Retry-After for a resetting window, insufficient_quota for a hard
|
||||
//! balance). Spend never exceeds the cap. No 402, ever.
|
||||
//! with the #63 code matching the cap-window semantics: `rate_limit_exceeded`
|
||||
//! plus `Retry-After` for a resetting window, `insufficient_quota` for a hard
|
||||
//! balance. Spend never exceeds the cap. No 402, ever.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::Path;
|
||||
|
||||
@@ -69,6 +69,21 @@ capabilities = ["text"]
|
||||
let mut nodes = fleet.nodes.write().await;
|
||||
let node = nodes.get_mut("mock-node").expect("node exists");
|
||||
node.healthy = true;
|
||||
// The rungs the neuron reported at poll time. Since #290 these
|
||||
// come from the model's own chat template rather than operator
|
||||
// configuration, so the fixture uses the shape a real reasoning
|
||||
// model ships: Qwen3.8-27B accepts exactly low/medium/xhigh and
|
||||
// defaults to xhigh.
|
||||
node.reasoning_budget = ["low", "medium", "xhigh"]
|
||||
.into_iter()
|
||||
.map(|effort| cortex_core::harness::ReasoningBudgetRung {
|
||||
default: effort == "xhigh",
|
||||
effort: effort.into(),
|
||||
// No cap: effort is expressed to the model through its
|
||||
// template, not enforced by truncating its thinking.
|
||||
tokens: None,
|
||||
})
|
||||
.collect();
|
||||
node.models.insert(
|
||||
"test-model".into(),
|
||||
ModelEntry {
|
||||
@@ -150,14 +165,51 @@ capabilities = ["text"]
|
||||
// vLLM-convention probes (Hermes Agent) auto-detect the window.
|
||||
assert_eq!(entry["max_model_len"], 49152);
|
||||
assert_eq!(entry["max_input_tokens"], 40960);
|
||||
// The ceiling, not the reserve (#278). pi-ai's discovery — and every
|
||||
// client like it — turns this field into the cap it sends on each
|
||||
// request; advertising the 8192 reserve told a reasoning model's
|
||||
// harness to stop below the cost of a single think block.
|
||||
// The ceiling, not the reserve (#278). A client that reads this
|
||||
// field turns it into the cap it sends on each request; advertising
|
||||
// the 8192 reserve told a reasoning model's harness to stop below
|
||||
// the cost of a single think block. Observed with dsh — pi takes
|
||||
// `maxTokens` from static config and reads nothing here.
|
||||
assert_eq!(entry["max_output_tokens"], 32768);
|
||||
// The window under the two names generic clients actually read.
|
||||
// pi-ai looks for `context_window` then `context_length` and gives
|
||||
// up if neither is present — `max_model_len` is invisible to it.
|
||||
// The rungs the model itself accepts (#290). A caller can only send
|
||||
// a rung name, so a name we invent is one the template will reject.
|
||||
// Publishing the true set is correct regardless of who reads it —
|
||||
// pi-ai, for one, takes its levels from static config instead.
|
||||
let rungs = entry["reasoning_budget"]
|
||||
.as_array()
|
||||
.expect("reasoning_budget advertised for a reasoning model");
|
||||
let names: Vec<&str> = rungs
|
||||
.iter()
|
||||
.map(|r| r["effort"].as_str().expect("effort"))
|
||||
.collect();
|
||||
assert_eq!(names, ["low", "medium", "xhigh"]);
|
||||
// Which rung applies when the caller names none. Without this a
|
||||
// silent caller cannot tell what it is getting — on Qwen3.8 that is
|
||||
// xhigh, the most expensive rung on the ladder.
|
||||
let default_rung: Vec<&str> = rungs
|
||||
.iter()
|
||||
.filter(|r| r["default"].as_bool().unwrap_or(false))
|
||||
.map(|r| r["effort"].as_str().expect("effort"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
default_rung,
|
||||
["xhigh"],
|
||||
"the model's own default must be visible"
|
||||
);
|
||||
// A named rung carries no token count: effort reaches the model
|
||||
// through its template, and enforcing it by truncation is the defect
|
||||
// #290 fixed.
|
||||
assert!(
|
||||
rungs.iter().all(|r| r.get("tokens").is_none()),
|
||||
"a named rung must not masquerade as a token budget"
|
||||
);
|
||||
|
||||
// The window under the two names a generic client may read, for one
|
||||
// that probes these rather than `max_model_len`. Publish all three;
|
||||
// do not assume a particular client consumes any of them. pi, in
|
||||
// particular, reads none — `contextWindow` is a required static
|
||||
// field on its model type (checked against 0.82.1 and 0.84.3), so
|
||||
// its window is hand-maintained whatever we advertise.
|
||||
assert_eq!(entry["context_window"], 49152);
|
||||
assert_eq!(entry["context_length"], 49152);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn api_routes(state: ApiState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/dimensions", get(dimensions))
|
||||
.route("/api/regimes", get(regimes))
|
||||
.route("/api/summary", get(summary))
|
||||
.route("/api/scaling", get(scaling))
|
||||
.route("/api/concurrency", get(concurrency))
|
||||
@@ -65,6 +66,14 @@ async fn health(State(s): State<ApiState>) -> Result<Json<serde_json::Value>, Ap
|
||||
Ok(Json(json!({ "status": "ok", "run_count": count })))
|
||||
}
|
||||
|
||||
/// Declared measurement-regime boundaries (#288).
|
||||
///
|
||||
/// Static — no store access — but served from the API rather than
|
||||
/// hardcoded in the frontend so `report` and the UI cite one list.
|
||||
async fn regimes() -> Json<&'static [crate::regime::MeasurementRegime]> {
|
||||
Json(crate::regime::REGIMES)
|
||||
}
|
||||
|
||||
async fn dimensions(State(s): State<ApiState>) -> Result<Json<crate::store::Dimensions>, ApiError> {
|
||||
let store = s.lock().await;
|
||||
store.dimensions().map(Json).map_err(err500)
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::config::{TargetConfig, TargetKind};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use cortex_core::build_info::BuildInfo;
|
||||
use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
|
||||
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
||||
use cortex_core::harness::{ModelInfo, ModelSpec};
|
||||
use cortex_core::openai::ModelsResponse;
|
||||
use std::time::Duration;
|
||||
@@ -19,10 +20,49 @@ pub struct TargetClient {
|
||||
|
||||
impl TargetClient {
|
||||
pub fn new(request_timeout: Duration) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(request_timeout)
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
Self::with_principal(request_timeout, None)
|
||||
}
|
||||
|
||||
/// Build a client that stamps `principal`'s identity headers on every
|
||||
/// request (#288).
|
||||
///
|
||||
/// Set on the client rather than at each call site so no scenario can
|
||||
/// be added later that silently measures as anonymous — which is the
|
||||
/// failure this fixes, and it was invisible for a week.
|
||||
///
|
||||
/// `None` keeps the historical anonymous behaviour, which is correct
|
||||
/// for `openai` targets (the headers mean nothing to a foreign
|
||||
/// engine) and for a fresh install with no principal configured.
|
||||
pub fn with_principal(
|
||||
request_timeout: Duration,
|
||||
principal: Option<&crate::config::PrincipalSettings>,
|
||||
) -> Result<Self> {
|
||||
let mut builder = reqwest::Client::builder().timeout(request_timeout);
|
||||
if let Some(p) = principal {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
HEADER_ACCOUNT_ID,
|
||||
reqwest::header::HeaderValue::from_str(&p.account_id)
|
||||
.context("principal.account_id is not a valid header value")?,
|
||||
);
|
||||
headers.insert(
|
||||
HEADER_KEY_ID,
|
||||
reqwest::header::HeaderValue::from_str(&p.key_id)
|
||||
.context("principal.key_id is not a valid header value")?,
|
||||
);
|
||||
tracing::info!(
|
||||
account_id = %p.account_id,
|
||||
key_id = %p.key_id,
|
||||
"bench authenticates: measurements reflect identified-caller capacity (#288)"
|
||||
);
|
||||
builder = builder.default_headers(headers);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"bench has no [bench.principal]: requests are anonymous, so since #262 \
|
||||
they measure the anonymous-yield policy rather than serving capacity (#288)"
|
||||
);
|
||||
}
|
||||
let http = builder.build().context("building HTTP client")?;
|
||||
Ok(TargetClient { http })
|
||||
}
|
||||
|
||||
@@ -169,6 +209,14 @@ impl TargetClient {
|
||||
quant: None,
|
||||
tensor_parallel: (info.devices.len() > 1).then_some(info.devices.len() as u32),
|
||||
devices: Some(info.devices.clone()),
|
||||
// `GET /models` does not report the operator sampling
|
||||
// override (#283), so a spec reconstructed here cannot carry
|
||||
// it — same limitation as `quant` directly above. Benign
|
||||
// while this is only used to re-load a model the host
|
||||
// already has a `[[default_models]]` entry for, since that
|
||||
// entry supplies the override; it would matter if this were
|
||||
// ever used to load a model the host has no config for.
|
||||
sampling: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,6 +284,7 @@ impl TargetClient {
|
||||
// A generic OpenAI endpoint says nothing about
|
||||
// serviceability, and absent means "no opinion".
|
||||
servable: None,
|
||||
reasoning_budget: Vec::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -67,6 +67,44 @@ pub struct BenchSettings {
|
||||
/// SQLite system-of-record path.
|
||||
#[serde(default = "default_db_path")]
|
||||
pub db_path: String,
|
||||
/// Identity bench presents to a neuron (#288).
|
||||
///
|
||||
/// Without this bench is *anonymous*, and since #262 anonymous
|
||||
/// callers are served only from capacity left over once identified
|
||||
/// traffic is satisfied — they are capped below `max_in_flight` and
|
||||
/// park at the class gate ahead of every other gate. Measured on
|
||||
/// beast, that moved `concurrency:8` from 168.9 tok/s / 0.53 s
|
||||
/// ttft_p95 to 104.6 / 11.77 s the day #262 landed, and held there.
|
||||
///
|
||||
/// So an unauthenticated bench does not measure serving capacity; it
|
||||
/// measures the yield policy. Real traffic arrives through cortex
|
||||
/// with a principal and is subject to none of it.
|
||||
///
|
||||
/// Deliberately not an admission *exemption*: bench should be
|
||||
/// subject to the same rules as any caller, or it measures something
|
||||
/// no user experiences — the same class of error in the other
|
||||
/// direction. Give it a principal and let it queue like everyone
|
||||
/// else.
|
||||
#[serde(default)]
|
||||
pub principal: Option<PrincipalSettings>,
|
||||
}
|
||||
|
||||
/// The account/key pair bench stamps on inference requests (#288).
|
||||
///
|
||||
/// These are the headers cortex asserts after a bearer resolves (#49).
|
||||
/// Bench talks to neurons **directly** over the WireGuard mesh, so it
|
||||
/// stamps them itself — the same trust model the link already relies on,
|
||||
/// since a neuron accepts them from cortex on exactly that basis.
|
||||
///
|
||||
/// Give bench its *own* pair rather than borrowing a real caller's: it
|
||||
/// then holds its own fair-share allocation (#54) and cannot starve
|
||||
/// interactive traffic, which is the failure #262 was fixing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrincipalSettings {
|
||||
/// Stamped as `x-helexa-account-id`.
|
||||
pub account_id: String,
|
||||
/// Stamped as `x-helexa-key-id`.
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
impl Default for BenchSettings {
|
||||
@@ -77,6 +115,7 @@ impl Default for BenchSettings {
|
||||
iteration_pause_secs: default_iter_pause(),
|
||||
request_timeout_secs: default_timeout(),
|
||||
db_path: default_db_path(),
|
||||
principal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +149,18 @@ pub struct ScenarioConfig {
|
||||
/// deliberately, e.g. `concurrency_levels = [2, 4, 8]`.
|
||||
#[serde(default)]
|
||||
pub concurrency_levels: Vec<u32>,
|
||||
/// Concurrency levels to run **non-streaming** (#285). Separate from
|
||||
/// `concurrency_levels` because the two shapes are not
|
||||
/// interchangeable: streaming multiplexes through the batch engine,
|
||||
/// non-streaming did not until #285 and serialized completely — a
|
||||
/// measured 1.00x aggregate throughput at every level against 3.98x
|
||||
/// streamed.
|
||||
///
|
||||
/// Defaults to the top level only. The signal is starkest there and
|
||||
/// each extra level costs a full burst per sample per build; an
|
||||
/// operator who wants the whole curve can list it.
|
||||
#[serde(default = "default_concurrency_nonstreaming_levels")]
|
||||
pub concurrency_nonstreaming_levels: Vec<u32>,
|
||||
/// Square image sizes (px per side) — one `image:<px>` scenario per
|
||||
/// entry (#203), run only against models advertising the `image`
|
||||
/// capability. Defaults to `[1024]`.
|
||||
@@ -148,6 +199,7 @@ impl Default for ScenarioConfig {
|
||||
prompt_sizes: default_prompt_sizes(),
|
||||
max_tokens: default_max_tokens(),
|
||||
concurrency_levels: Vec::new(),
|
||||
concurrency_nonstreaming_levels: Vec::new(),
|
||||
concurrency_prompt_tokens: default_concurrency_prompt_tokens(),
|
||||
image_sizes: default_image_sizes(),
|
||||
capability_probes: Vec::new(),
|
||||
@@ -215,6 +267,10 @@ fn default_timeout() -> u64 {
|
||||
fn default_db_path() -> String {
|
||||
"/var/lib/helexa-bench/bench.sqlite".to_string()
|
||||
}
|
||||
fn default_concurrency_nonstreaming_levels() -> Vec<u32> {
|
||||
vec![8]
|
||||
}
|
||||
|
||||
fn default_api_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -243,6 +299,44 @@ fn default_capability_max_tokens() -> u64 {
|
||||
// figment's, not ours, so suppress the lint here.
|
||||
#[allow(clippy::result_large_err)]
|
||||
mod tests {
|
||||
|
||||
/// #288: a config with no `[bench.principal]` must still parse — the
|
||||
/// anonymous behaviour is the historical one and correct for
|
||||
/// `openai` targets — but a configured one must round-trip, because
|
||||
/// a silently-dropped principal reverts the measurement to the
|
||||
/// anonymous-yield path without changing a visible number.
|
||||
#[test]
|
||||
fn principal_is_optional_and_round_trips() {
|
||||
let parse = |t: &str| -> BenchConfig {
|
||||
figment::Figment::new()
|
||||
.merge(figment::providers::Toml::string(t))
|
||||
.extract()
|
||||
.expect("parse")
|
||||
};
|
||||
let none = parse("[bench]\nsamples_per_version = 3\n");
|
||||
assert!(none.bench.principal.is_none());
|
||||
|
||||
let some = parse(
|
||||
"[bench]\nsamples_per_version = 3\n\n\
|
||||
[bench.principal]\naccount_id = \"acct\"\nkey_id = \"k1\"\n",
|
||||
);
|
||||
let p = some.bench.principal.expect("principal");
|
||||
assert_eq!((p.account_id.as_str(), p.key_id.as_str()), ("acct", "k1"));
|
||||
}
|
||||
|
||||
/// The non-streaming burst defaults on (#285). If it defaulted off,
|
||||
/// the path that serialized completely would stay unmeasured on every
|
||||
/// fleet that had not opted in — which is how it went unnoticed.
|
||||
#[test]
|
||||
fn nonstreaming_concurrency_defaults_to_the_top_level() {
|
||||
let cfg: BenchConfig = figment::Figment::new()
|
||||
.merge(figment::providers::Toml::string(
|
||||
"[scenarios]\nprompt_sizes = [128]\n",
|
||||
))
|
||||
.extract()
|
||||
.expect("parse");
|
||||
assert_eq!(cfg.scenarios.concurrency_nonstreaming_levels, vec![8]);
|
||||
}
|
||||
use super::*;
|
||||
use figment::Jail;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod regime;
|
||||
pub mod report;
|
||||
pub mod scenario;
|
||||
pub mod store;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Measurement-regime boundaries — builds where a number changed
|
||||
//! meaning rather than value.
|
||||
//!
|
||||
//! A trend line silently spans them, and every reader re-derives the
|
||||
//! same thing: that a cliff was the instrument, not the engine. Three
|
||||
//! have already cost real investigation time:
|
||||
//!
|
||||
//! - beast's `concurrency:8` fell 168.9 → 104.6 tok/s with `ttft_p95`
|
||||
//! rising 0.53 → 11.77 s, and it read as a 38% throughput regression
|
||||
//! until the one commit in the window turned out to be an admission
|
||||
//! *policy* change that bench, being anonymous, was subject to (#288).
|
||||
//! - quadbrat's `capability:debug-reason` TTFT fell 88.8 → 0.1 s, which
|
||||
//! read as a latency win and was a redefinition of when the clock
|
||||
//! stops.
|
||||
//! - the same redefinition put decode rates at ~250 tok/s on a 3060
|
||||
//! beforehand, by dividing reasoning-inclusive token counts by a
|
||||
//! visible-content-only window.
|
||||
//!
|
||||
//! So the boundaries are declared, not remembered. Declared here rather
|
||||
//! than in the frontend because `report` renders the same series into
|
||||
//! `benchmarks.md` and needs the same caveats.
|
||||
//!
|
||||
//! ## What belongs here
|
||||
//!
|
||||
//! Only changes to **what a metric means**. A change that made the
|
||||
//! server genuinely faster, slower, or differently behaved does *not*
|
||||
//! belong — that is the signal these lines exist to keep readable. #280
|
||||
//! (fetch the model's own chat template) moved quadbrat's
|
||||
//! `completion_tokens` from 4096 to 1053 by ending a truncation loop;
|
||||
//! that is a real behavioural change and stays unmarked.
|
||||
//!
|
||||
//! The identified/anonymous split is deliberately absent too: it is
|
||||
//! recorded per-row in `runs.principal`, so the UI derives it from the
|
||||
//! data instead of trusting a constant to stay true.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// A build at which one or more metrics changed meaning.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MeasurementRegime {
|
||||
/// Short SHA of the first build measured under the new semantics.
|
||||
/// Matched against `SeriesPoint::git_sha`, so a boundary whose build
|
||||
/// never appears in a selection simply does not draw.
|
||||
pub first_sha: &'static str,
|
||||
/// Terse label for the chart rule.
|
||||
pub label: &'static str,
|
||||
/// What changed, and why the step is not a performance result.
|
||||
pub detail: &'static str,
|
||||
/// Series keys the change affects, as the API/UI name them. A
|
||||
/// boundary only draws on panels carrying one of these — marking
|
||||
/// every panel would train people to ignore the rules.
|
||||
pub affects: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// The declared boundaries, oldest first.
|
||||
pub const REGIMES: &[MeasurementRegime] = &[
|
||||
MeasurementRegime {
|
||||
first_sha: "92dddc0",
|
||||
label: "#262 anonymous callers yield",
|
||||
detail: "Anonymous traffic became servable only from capacity left over \
|
||||
once identified traffic is satisfied — capped below max_in_flight \
|
||||
and parked at the class gate. An anonymous bench therefore \
|
||||
characterises the yield policy rather than serving capacity. \
|
||||
Steps here are admission policy, not the engine (#288).",
|
||||
affects: &["decode", "ttft", "ttftP95", "queueWait", "rejected"],
|
||||
},
|
||||
MeasurementRegime {
|
||||
first_sha: "0fe7aa3",
|
||||
label: "#117 reasoning counts as liveness",
|
||||
detail: "TTFT previously waited for the first *visible content* chunk, so \
|
||||
on a thinking model it measured the whole reasoning span (88.8 s \
|
||||
on quadbrat). It now stops at the first delta of any kind. The \
|
||||
same change fixed decode rates computed by dividing \
|
||||
reasoning-inclusive token counts by a content-only window, which \
|
||||
had produced ~250 tok/s on a 3060.",
|
||||
affects: &["decode", "ttft"],
|
||||
},
|
||||
];
|
||||
|
||||
/// Boundaries affecting `metric`, in declaration order.
|
||||
pub fn regimes_for(metric: &str) -> Vec<&'static MeasurementRegime> {
|
||||
REGIMES
|
||||
.iter()
|
||||
.filter(|r| r.affects.contains(&metric))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A boundary that names no metric would draw nowhere and mislead by
|
||||
/// omission; one naming an unknown key would draw nowhere and look
|
||||
/// fine. Both are typos this catches.
|
||||
#[test]
|
||||
fn every_regime_affects_a_known_series_key() {
|
||||
// The keys `Trends.tsx` maps `SeriesPoint` onto.
|
||||
const KNOWN: &[&str] = &[
|
||||
"decode",
|
||||
"ttft",
|
||||
"ttftP95",
|
||||
"queueWait",
|
||||
"rejected",
|
||||
"prefillTps",
|
||||
"reasoning",
|
||||
"cached",
|
||||
"completion",
|
||||
"tpot",
|
||||
];
|
||||
for r in REGIMES {
|
||||
assert!(
|
||||
!r.affects.is_empty(),
|
||||
"{} affects nothing, so it would never draw",
|
||||
r.first_sha
|
||||
);
|
||||
for key in r.affects {
|
||||
assert!(
|
||||
KNOWN.contains(key),
|
||||
"{} names unknown series key {key:?}",
|
||||
r.first_sha
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regimes_for_selects_by_metric() {
|
||||
assert!(regimes_for("ttft").iter().any(|r| r.first_sha == "0fe7aa3"));
|
||||
assert!(
|
||||
regimes_for("completion").is_empty(),
|
||||
"#280 changed behaviour, not meaning — it must not be marked"
|
||||
);
|
||||
}
|
||||
|
||||
/// Short SHAs are matched against `SeriesPoint::git_sha`, which the
|
||||
/// store records at the width `/version` reports.
|
||||
#[test]
|
||||
fn declared_shas_are_short_form() {
|
||||
for r in REGIMES {
|
||||
assert_eq!(
|
||||
r.first_sha.len(),
|
||||
7,
|
||||
"{} is not a 7-char short sha; it would never match a series point",
|
||||
r.first_sha
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
use crate::config::ScenarioConfig;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use cortex_core::entitlements::HEADER_KEY_ID;
|
||||
use cortex_core::harness::ModelInfo;
|
||||
use cortex_core::openai::ChatCompletionChunk;
|
||||
use eventsource_stream::Eventsource;
|
||||
@@ -46,6 +47,17 @@ pub struct RunCtx<'a> {
|
||||
pub model_id: String,
|
||||
pub max_tokens: u64,
|
||||
pub timeout: Duration,
|
||||
/// Base `x-helexa-key-id` bench presents (#288), when configured.
|
||||
///
|
||||
/// The client already sends this as a default header, so every
|
||||
/// scenario is identified without having to remember. A concurrency
|
||||
/// burst overrides it per stream — see
|
||||
/// [`ConcurrencyScenario`] — because fair-share (#54) bounds one
|
||||
/// principal to `max_per_principal` in-flight-or-queued requests,
|
||||
/// which defaults to 2. Eight streams under one identity would be
|
||||
/// capped at two and the rest rejected, measuring the fair-share cap
|
||||
/// instead of the server.
|
||||
pub principal_key_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Operator-felt metrics for a single measured request.
|
||||
@@ -72,6 +84,34 @@ pub struct ScenarioMetrics {
|
||||
pub decode_ms: Option<u64>,
|
||||
/// Tokens submitted to prefill — the denominator for prefill tok/s.
|
||||
pub prefill_tokens: Option<u64>,
|
||||
/// Tokens spent inside the reasoning span, from
|
||||
/// `usage.completion_tokens_details.reasoning_tokens` (#223). A
|
||||
/// sub-count of `completion_tokens`.
|
||||
///
|
||||
/// Worth a trend of its own: on a reasoning model this is the
|
||||
/// dominant cost driver and it moves independently of speed. A
|
||||
/// template change, a sampling change (#283) or a reasoning-budget
|
||||
/// change can double what the model thinks before answering while
|
||||
/// every tok/s number stays flat — the bill doubles and no chart
|
||||
/// moves.
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
/// p95 inter-token arrival gap in milliseconds — the tail of the
|
||||
/// stream's smoothness, client-observed.
|
||||
///
|
||||
/// `decode_tps` is a mean over the whole decode window, so a stream
|
||||
/// that stalls for a second and then catches up is indistinguishable
|
||||
/// from one that never stalled. This is the number a user actually
|
||||
/// feels, and the one a batching stall or a mid-stream rebatch shows
|
||||
/// up in. `None` for non-streaming, which has no inter-token gaps by
|
||||
/// construction, and for streams too short to have a tail.
|
||||
pub tpot_p95_ms: Option<f64>,
|
||||
/// Prompt tokens served from neuron's prefix KV cache (#269), from
|
||||
/// `usage.prompt_tokens_details.cached_tokens`.
|
||||
///
|
||||
/// The reason prefill timing varies between otherwise identical
|
||||
/// samples. #269 exists because the saving was invisible and every
|
||||
/// client reported a 0% hit rate; bench then threw the number away.
|
||||
pub cached_tokens: Option<u64>,
|
||||
// ── Concurrency / agentic-load fields (#89) ──────────────────────────
|
||||
// Set only by the concurrency scenario, which fans out N simultaneous
|
||||
// streams to characterize the real a0/hermes/opencode workload that
|
||||
@@ -203,6 +243,9 @@ impl Scenario for ImageLatencyScenario {
|
||||
.as_u64()
|
||||
.map(|d| d + timing["decode_ms"].as_u64().unwrap_or(0)),
|
||||
prefill_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
cached_tokens: None,
|
||||
tpot_p95_ms: None,
|
||||
concurrency: None,
|
||||
ttft_p95_s: None,
|
||||
queue_wait_ms_median: None,
|
||||
@@ -233,6 +276,18 @@ pub fn build_scenarios(cfg: &ScenarioConfig) -> Vec<Box<dyn Scenario>> {
|
||||
id: format!("concurrency:{n}"),
|
||||
concurrency: n,
|
||||
approx_prompt_tokens: cfg.concurrency_prompt_tokens,
|
||||
streaming: true,
|
||||
}) as Box<dyn Scenario>);
|
||||
}
|
||||
// Non-streaming bursts (#285). A separate cell id so the two shapes
|
||||
// never average together — they measured 1.00x against 3.98x before
|
||||
// #285, and a combined number would have hidden both.
|
||||
for &n in &cfg.concurrency_nonstreaming_levels {
|
||||
scenarios.push(Box::new(ConcurrencyScenario {
|
||||
id: format!("concurrency:{n}:nostream"),
|
||||
concurrency: n,
|
||||
approx_prompt_tokens: cfg.concurrency_prompt_tokens,
|
||||
streaming: false,
|
||||
}) as Box<dyn Scenario>);
|
||||
}
|
||||
for &side in &cfg.image_sizes {
|
||||
@@ -257,7 +312,7 @@ pub fn build_scenarios(cfg: &ScenarioConfig) -> Vec<Box<dyn Scenario>> {
|
||||
pub async fn cold_probe(ctx: &RunCtx<'_>) -> Result<ScenarioMetrics> {
|
||||
let prompt = build_prompt(128);
|
||||
let payload = chat_payload(ctx, &prompt);
|
||||
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload))
|
||||
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload, None))
|
||||
.await
|
||||
.map_err(|_| anyhow!("cold probe timed out after {:?}", ctx.timeout))?
|
||||
}
|
||||
@@ -295,7 +350,7 @@ impl Scenario for ChatLatencyScenario {
|
||||
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
|
||||
let prompt = build_prompt(self.approx_prompt_tokens);
|
||||
let payload = chat_payload(ctx, &prompt);
|
||||
let fut = stream_and_measure(ctx, &payload);
|
||||
let fut = stream_and_measure(ctx, &payload, None);
|
||||
tokio::time::timeout(ctx.timeout, fut)
|
||||
.await
|
||||
.map_err(|_| anyhow!("request timed out after {:?}", ctx.timeout))?
|
||||
@@ -313,6 +368,16 @@ pub struct ConcurrencyScenario {
|
||||
id: String,
|
||||
concurrency: u32,
|
||||
approx_prompt_tokens: u32,
|
||||
/// Whether the burst streams (#285).
|
||||
///
|
||||
/// Both shapes matter and they are not interchangeable. Streaming
|
||||
/// multiplexes through the batch engine; non-streaming did not until
|
||||
/// #285, and serialized completely — 1.00x aggregate throughput at
|
||||
/// every concurrency level against 3.98x streamed. A bench that only
|
||||
/// streams cannot see that, which is why it went unmeasured: the
|
||||
/// neuron reports `in_flight: 8`, admission accepts, `/health` looks
|
||||
/// healthy, and throughput is single-stream.
|
||||
streaming: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -327,13 +392,26 @@ impl Scenario for ConcurrencyScenario {
|
||||
|
||||
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
|
||||
let prompt = build_prompt(self.approx_prompt_tokens);
|
||||
let payload = chat_payload(ctx, &prompt);
|
||||
let payload = if self.streaming {
|
||||
chat_payload(ctx, &prompt)
|
||||
} else {
|
||||
nonstreaming_payload(ctx, &prompt)
|
||||
};
|
||||
|
||||
// Fire all streams at once; each is independently timed and capped by
|
||||
// the per-request timeout so one hung stream can't stall the burst.
|
||||
// Fire all requests at once; each is independently timed and capped
|
||||
// by the per-request timeout so one hung request can't stall the
|
||||
// burst.
|
||||
let burst_start = Instant::now();
|
||||
let futs = (0..self.concurrency).map(|_| async {
|
||||
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload)).await
|
||||
let streaming = self.streaming;
|
||||
let payload = &payload;
|
||||
let futs = (0..self.concurrency).map(move |i| async move {
|
||||
// Each stream presents its own identity — see
|
||||
// `with_stream_identity`.
|
||||
if streaming {
|
||||
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, payload, Some(i))).await
|
||||
} else {
|
||||
tokio::time::timeout(ctx.timeout, request_and_measure(ctx, payload, Some(i))).await
|
||||
}
|
||||
});
|
||||
let results = futures::future::join_all(futs).await;
|
||||
let burst_window = burst_start.elapsed().as_secs_f64();
|
||||
@@ -351,7 +429,7 @@ impl Scenario for ConcurrencyScenario {
|
||||
}
|
||||
if streams.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"all {} concurrent streams failed ({rejected} shed by admission)",
|
||||
"all {} concurrent requests failed ({rejected} shed by admission)",
|
||||
self.concurrency
|
||||
));
|
||||
}
|
||||
@@ -380,9 +458,50 @@ impl Scenario for ConcurrencyScenario {
|
||||
total_s: burst_window,
|
||||
prompt_tokens: streams.iter().find_map(|m| m.prompt_tokens),
|
||||
completion_tokens: total_tokens,
|
||||
prefill_ms: None,
|
||||
decode_ms: None,
|
||||
prefill_tokens: None,
|
||||
// Median server-side phase timing across the burst. These
|
||||
// were `None`, which left the prefill-tok/s panel blank for
|
||||
// exactly the cells where queue behaviour matters most, and
|
||||
// made `queue_wait_ms` (TTFT minus server prefill, #85)
|
||||
// uncomputable for a burst — the one number that separates
|
||||
// "the server is slow" from "you were queued".
|
||||
//
|
||||
// Median rather than sum: every stream prefills its own copy
|
||||
// of the same prompt, so the representative per-stream cost
|
||||
// is the meaningful figure and it composes with `ttft_s`,
|
||||
// which is also a median.
|
||||
prefill_ms: median(
|
||||
&streams
|
||||
.iter()
|
||||
.filter_map(|m| m.prefill_ms.map(|v| v as f64))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map(|v| v as u64),
|
||||
decode_ms: median(
|
||||
&streams
|
||||
.iter()
|
||||
.filter_map(|m| m.decode_ms.map(|v| v as f64))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map(|v| v as u64),
|
||||
prefill_tokens: median(
|
||||
&streams
|
||||
.iter()
|
||||
.filter_map(|m| m.prefill_tokens.map(|v| v as f64))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map(|v| v as u64),
|
||||
// Summed across the burst, like `completion_tokens`: the cell
|
||||
// describes the whole burst, so a per-stream figure here
|
||||
// would not compose with it.
|
||||
reasoning_tokens: sum_opt(&streams, |m| m.reasoning_tokens),
|
||||
cached_tokens: sum_opt(&streams, |m| m.cached_tokens),
|
||||
// The worst stream in the burst, not a median of medians:
|
||||
// under load the question is how bad it got for somebody,
|
||||
// and averaging tails is how a stall stays invisible.
|
||||
tpot_p95_ms: streams
|
||||
.iter()
|
||||
.filter_map(|m| m.tpot_p95_ms)
|
||||
.max_by(|a, b| a.total_cmp(b)),
|
||||
concurrency: Some(self.concurrency),
|
||||
ttft_p95_s: percentile(&ttfts, 95.0),
|
||||
queue_wait_ms_median: median(&queue_waits),
|
||||
@@ -393,6 +512,146 @@ impl Scenario for ConcurrencyScenario {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum an optional per-stream count across a burst, `None` when no
|
||||
/// stream reported it — so "nobody measured it" stays distinguishable
|
||||
/// from "measured as zero", the same distinction #269 exists to
|
||||
/// preserve.
|
||||
fn sum_opt(
|
||||
streams: &[ScenarioMetrics],
|
||||
f: impl Fn(&ScenarioMetrics) -> Option<u64>,
|
||||
) -> Option<u64> {
|
||||
let vals: Vec<u64> = streams.iter().filter_map(f).collect();
|
||||
(!vals.is_empty()).then(|| vals.iter().sum())
|
||||
}
|
||||
|
||||
/// Per-stream identity for a concurrency burst (#288/#54).
|
||||
///
|
||||
/// Fair-share bounds *one principal* to `max_per_principal`
|
||||
/// in-flight-or-queued requests, defaulting to 2. A burst of 8 under a
|
||||
/// single identity would therefore have 6 rejected with `PrincipalCap`,
|
||||
/// and the scenario would measure the fair-share cap rather than the
|
||||
/// server.
|
||||
///
|
||||
/// Giving each stream its own key is also the more faithful model: a
|
||||
/// concurrency-8 burst is meant to stand in for eight simultaneous
|
||||
/// *callers*, not one caller issuing eight requests. Fair-share is still
|
||||
/// exercised — each identity is subject to it — just not self-inflicted.
|
||||
fn with_stream_identity(
|
||||
rb: reqwest::RequestBuilder,
|
||||
ctx: &RunCtx<'_>,
|
||||
stream: Option<u32>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
match (ctx.principal_key_id.as_deref(), stream) {
|
||||
(Some(base), Some(i)) => rb.header(HEADER_KEY_ID, format!("{base}-{i}")),
|
||||
// No principal configured, or a single-request scenario: the
|
||||
// client's default headers already carry the base identity.
|
||||
_ => rb,
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-streaming counterpart to [`chat_payload`] (#285).
|
||||
///
|
||||
/// `stream_options` is deliberately absent: usage is part of the body on
|
||||
/// this shape, and sending the streaming-only option to a strict server
|
||||
/// is a needless compatibility risk.
|
||||
fn nonstreaming_payload(ctx: &RunCtx, prompt: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"model": ctx.model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": ctx.max_tokens,
|
||||
"temperature": 0,
|
||||
"stream": false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Time one non-streaming chat completion (#285).
|
||||
///
|
||||
/// There is no first-chunk moment on this shape — the whole body arrives
|
||||
/// at once — so `ttft_s` is the full request wall-clock rather than a
|
||||
/// separate measurement. That is the honest reading: for a non-streaming
|
||||
/// caller, time-to-first-token *is* time-to-everything, and it is exactly
|
||||
/// what makes the serialization this scenario exists to detect so
|
||||
/// expensive.
|
||||
///
|
||||
/// `decode_tps` is per-request; the burst aggregate is computed by the
|
||||
/// caller over the whole window, the same way the streaming path does it.
|
||||
async fn request_and_measure(
|
||||
ctx: &RunCtx<'_>,
|
||||
payload: &serde_json::Value,
|
||||
stream: Option<u32>,
|
||||
) -> Result<ScenarioMetrics> {
|
||||
let start = Instant::now();
|
||||
let resp = with_stream_identity(ctx.client.post(&ctx.chat_url), ctx, stream)
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
.context("sending non-streaming chat request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("upstream returned {status}: {}", body.trim()));
|
||||
}
|
||||
let v: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("non-streaming chat response was not JSON")?;
|
||||
let total_s = start.elapsed().as_secs_f64();
|
||||
|
||||
let usage = v.get("usage");
|
||||
let completion_tokens = usage
|
||||
.and_then(|u| u.get("completion_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|u| u.get("prompt_tokens"))
|
||||
.and_then(|t| t.as_u64());
|
||||
let reasoning_tokens = usage
|
||||
.and_then(|u| u.get("completion_tokens_details"))
|
||||
.and_then(|d| d.get("reasoning_tokens"))
|
||||
.and_then(|t| t.as_u64());
|
||||
let cached_tokens = usage
|
||||
.and_then(|u| u.get("prompt_tokens_details"))
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|t| t.as_u64());
|
||||
let timing = usage.and_then(|u| u.get("helexa_timing"));
|
||||
let field = |name: &str| timing.and_then(|t| t.get(name)).and_then(|x| x.as_u64());
|
||||
let (prefill_ms, decode_ms, prefill_tokens) = (
|
||||
field("prefill_ms"),
|
||||
field("decode_ms"),
|
||||
field("prefill_tokens"),
|
||||
);
|
||||
|
||||
// Prefer the server's own decode window when it reports one (#85);
|
||||
// it excludes prefill, so it is comparable with the streaming path's
|
||||
// decode-window rate rather than being diluted by it.
|
||||
let decode_tps = match decode_ms {
|
||||
Some(ms) if ms > 200 => Some(completion_tokens as f64 / (ms as f64 / 1000.0)),
|
||||
_ if total_s > 0.2 => Some(completion_tokens as f64 / total_s),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(ScenarioMetrics {
|
||||
ttft_s: total_s,
|
||||
decode_tps,
|
||||
total_s,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
prefill_ms,
|
||||
decode_ms,
|
||||
prefill_tokens,
|
||||
reasoning_tokens,
|
||||
cached_tokens,
|
||||
// Non-streaming has no inter-token gaps: the body arrives whole.
|
||||
tpot_p95_ms: None,
|
||||
concurrency: None,
|
||||
ttft_p95_s: None,
|
||||
queue_wait_ms_median: None,
|
||||
rejected: None,
|
||||
artifact: None,
|
||||
image_units: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Quality probe (#91): runs a fixed prompt and stores the full generated
|
||||
/// text as an artifact for later scoring (manual now, LLM-judge later). The
|
||||
/// point is to compare reasoning/planning quality across models — the axis
|
||||
@@ -425,7 +684,7 @@ impl Scenario for CapabilityScenario {
|
||||
"stream": true,
|
||||
"stream_options": {"include_usage": true},
|
||||
});
|
||||
let fut = stream_and_measure_inner(ctx, &payload, true);
|
||||
let fut = stream_and_measure_inner(ctx, &payload, true, None);
|
||||
tokio::time::timeout(ctx.timeout, fut)
|
||||
.await
|
||||
.map_err(|_| anyhow!("capability probe timed out after {:?}", ctx.timeout))?
|
||||
@@ -468,8 +727,9 @@ fn percentile(values: &[f64], p: f64) -> Option<f64> {
|
||||
async fn stream_and_measure(
|
||||
ctx: &RunCtx<'_>,
|
||||
payload: &serde_json::Value,
|
||||
stream: Option<u32>,
|
||||
) -> Result<ScenarioMetrics> {
|
||||
stream_and_measure_inner(ctx, payload, false).await
|
||||
stream_and_measure_inner(ctx, payload, false, stream).await
|
||||
}
|
||||
|
||||
/// As [`stream_and_measure`] but accumulates the full visible text when
|
||||
@@ -479,11 +739,10 @@ async fn stream_and_measure_inner(
|
||||
ctx: &RunCtx<'_>,
|
||||
payload: &serde_json::Value,
|
||||
capture_text: bool,
|
||||
stream_ix: Option<u32>,
|
||||
) -> Result<ScenarioMetrics> {
|
||||
let start = Instant::now();
|
||||
let resp = ctx
|
||||
.client
|
||||
.post(&ctx.chat_url)
|
||||
let resp = with_stream_identity(ctx.client.post(&ctx.chat_url), ctx, stream_ix)
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
@@ -503,6 +762,10 @@ async fn stream_and_measure_inner(
|
||||
let mut prefill_ms: Option<u64> = None;
|
||||
let mut decode_ms: Option<u64> = None;
|
||||
let mut prefill_tokens: Option<u64> = None;
|
||||
let mut reasoning_tokens: Option<u64> = None;
|
||||
let mut cached_tokens: Option<u64> = None;
|
||||
// Inter-token arrival gaps, for the p95 tail.
|
||||
let mut gaps_ms: Vec<f64> = Vec::new();
|
||||
let mut captured = String::new();
|
||||
|
||||
while let Some(event) = stream.next().await {
|
||||
@@ -540,6 +803,11 @@ async fn stream_and_measure_inner(
|
||||
if content.is_some() || reasoning.is_some() {
|
||||
if first.is_none() {
|
||||
first = Some(now);
|
||||
} else if let Some(prev) = last {
|
||||
// Gaps only *between* generated deltas: the wait for
|
||||
// the first one is TTFT and belongs to prefill, not
|
||||
// to stream smoothness.
|
||||
gaps_ms.push(now.duration_since(prev).as_secs_f64() * 1000.0);
|
||||
}
|
||||
last = Some(now);
|
||||
chunk_count += 1;
|
||||
@@ -551,6 +819,14 @@ async fn stream_and_measure_inner(
|
||||
if let Some(usage) = chunk.usage {
|
||||
prompt_tokens = Some(usage.prompt_tokens);
|
||||
completion_tokens = Some(usage.completion_tokens);
|
||||
reasoning_tokens = usage
|
||||
.completion_tokens_details
|
||||
.as_ref()
|
||||
.map(|d| d.reasoning_tokens);
|
||||
cached_tokens = usage
|
||||
.prompt_tokens_details
|
||||
.as_ref()
|
||||
.map(|d| d.cached_tokens);
|
||||
if let Some(t) = usage.helexa_timing {
|
||||
prefill_ms = Some(t.prefill_ms);
|
||||
decode_ms = Some(t.decode_ms);
|
||||
@@ -589,6 +865,9 @@ async fn stream_and_measure_inner(
|
||||
prefill_ms,
|
||||
decode_ms,
|
||||
prefill_tokens,
|
||||
reasoning_tokens,
|
||||
cached_tokens,
|
||||
tpot_p95_ms: percentile(&gaps_ms, 95.0),
|
||||
// Concurrency fields unset on the single-request path; the
|
||||
// concurrency scenario builds its own aggregate (#89).
|
||||
concurrency: None,
|
||||
@@ -604,6 +883,113 @@ async fn stream_and_measure_inner(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A burst must report server-side phase timing, or `queue_wait_ms`
|
||||
/// (TTFT minus server prefill, #85) cannot be computed for the very
|
||||
/// cells it was designed for, and the prefill-tok/s panel stays
|
||||
/// blank under concurrency.
|
||||
#[test]
|
||||
fn a_burst_reports_median_phase_timing_not_none() {
|
||||
let s = |prefill_ms, decode_ms, prefill_tokens| ScenarioMetrics {
|
||||
ttft_s: 0.1,
|
||||
decode_tps: Some(10.0),
|
||||
total_s: 1.0,
|
||||
prompt_tokens: Some(100),
|
||||
completion_tokens: 10,
|
||||
prefill_ms,
|
||||
decode_ms,
|
||||
prefill_tokens,
|
||||
reasoning_tokens: None,
|
||||
cached_tokens: None,
|
||||
tpot_p95_ms: Some(5.0),
|
||||
concurrency: None,
|
||||
ttft_p95_s: None,
|
||||
queue_wait_ms_median: None,
|
||||
rejected: None,
|
||||
artifact: None,
|
||||
image_units: None,
|
||||
};
|
||||
let streams = [
|
||||
s(Some(10), Some(100), Some(512)),
|
||||
s(Some(20), Some(200), Some(512)),
|
||||
s(Some(30), Some(300), Some(512)),
|
||||
];
|
||||
let med = |f: fn(&ScenarioMetrics) -> Option<u64>| {
|
||||
median(
|
||||
&streams
|
||||
.iter()
|
||||
.filter_map(|m| f(m).map(|v| v as f64))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map(|v| v as u64)
|
||||
};
|
||||
assert_eq!(med(|m| m.prefill_ms), Some(20));
|
||||
assert_eq!(med(|m| m.decode_ms), Some(200));
|
||||
// Every stream prefills the same prompt, so the median is the
|
||||
// prompt size rather than a sum across the burst.
|
||||
assert_eq!(med(|m| m.prefill_tokens), Some(512));
|
||||
}
|
||||
|
||||
/// #288/#54: a burst must present one identity *per stream*.
|
||||
///
|
||||
/// Fair-share bounds a single principal to `max_per_principal`
|
||||
/// in-flight-or-queued requests, which defaults to 2. Eight streams
|
||||
/// under one key would have six rejected with `PrincipalCap`, and
|
||||
/// `concurrency:8` would silently measure the fair-share cap instead
|
||||
/// of the server — a worse failure than the anonymity it replaced,
|
||||
/// because it looks like backpressure.
|
||||
#[test]
|
||||
fn a_burst_presents_one_identity_per_stream() {
|
||||
let client = reqwest::Client::new();
|
||||
let ctx = RunCtx {
|
||||
client: &client,
|
||||
chat_url: "http://x/v1/chat/completions".into(),
|
||||
model_id: "m".into(),
|
||||
max_tokens: 16,
|
||||
timeout: Duration::from_secs(1),
|
||||
principal_key_id: Some("fleet-benchmark".into()),
|
||||
};
|
||||
let key_of = |stream: Option<u32>| -> Option<String> {
|
||||
with_stream_identity(client.post(&ctx.chat_url), &ctx, stream)
|
||||
.build()
|
||||
.expect("build")
|
||||
.headers()
|
||||
.get(HEADER_KEY_ID)
|
||||
.map(|v| v.to_str().expect("utf8").to_string())
|
||||
};
|
||||
let keys: Vec<Option<String>> = (0..3).map(|i| key_of(Some(i))).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
Some("fleet-benchmark-0".to_string()),
|
||||
Some("fleet-benchmark-1".to_string()),
|
||||
Some("fleet-benchmark-2".to_string()),
|
||||
]
|
||||
);
|
||||
// Distinct, or the cap bites anyway.
|
||||
let uniq: std::collections::HashSet<_> = keys.iter().collect();
|
||||
assert_eq!(uniq.len(), 3);
|
||||
}
|
||||
|
||||
/// A single-request scenario adds no override: the client's default
|
||||
/// headers already carry the base identity, and a per-request copy
|
||||
/// would be a second place for it to drift.
|
||||
#[test]
|
||||
fn a_single_request_scenario_keeps_the_client_identity() {
|
||||
let client = reqwest::Client::new();
|
||||
let ctx = RunCtx {
|
||||
client: &client,
|
||||
chat_url: "http://x/v1/chat/completions".into(),
|
||||
model_id: "m".into(),
|
||||
max_tokens: 16,
|
||||
timeout: Duration::from_secs(1),
|
||||
principal_key_id: Some("fleet-benchmark".into()),
|
||||
};
|
||||
let req = with_stream_identity(client.post(&ctx.chat_url), &ctx, None)
|
||||
.build()
|
||||
.expect("build");
|
||||
assert!(req.headers().get(HEADER_KEY_ID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_grows_with_token_target() {
|
||||
let small = build_prompt(128);
|
||||
@@ -646,6 +1032,7 @@ mod tests {
|
||||
prompt_sizes: vec![128],
|
||||
max_tokens: 64,
|
||||
concurrency_levels: vec![2, 8],
|
||||
concurrency_nonstreaming_levels: vec![8],
|
||||
concurrency_prompt_tokens: 512,
|
||||
capability_probes: vec![CapabilityProbe {
|
||||
name: "plan".into(),
|
||||
|
||||
@@ -14,6 +14,22 @@ use std::path::Path;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RunRecord {
|
||||
pub ts: String, // RFC3339
|
||||
/// Identity this sample was taken under (#288), `account/key`, or
|
||||
/// `None` for an anonymous run. Anonymous samples measure the
|
||||
/// admission yield policy rather than serving capacity once #262 is
|
||||
/// in the build, so this is the column that says whether two rows
|
||||
/// are comparable at all.
|
||||
pub principal: Option<String>,
|
||||
/// Tokens spent reasoning (#223) — the dominant cost driver on a
|
||||
/// reasoning model, and one that moves independently of every tok/s
|
||||
/// number on the dashboard.
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
/// Prompt tokens served from the prefix cache (#269) — the reason
|
||||
/// prefill timing varies between otherwise identical samples.
|
||||
pub cached_tokens: Option<u64>,
|
||||
/// p95 inter-token arrival gap (ms) — stream smoothness, which a
|
||||
/// mean tok/s cannot express.
|
||||
pub tpot_p95_ms: Option<f64>,
|
||||
// target
|
||||
pub target_name: String,
|
||||
pub target_kind: String,
|
||||
@@ -204,6 +220,18 @@ impl Store {
|
||||
("artifact", "TEXT"),
|
||||
("quality_score", "REAL"),
|
||||
("scorer", "TEXT"),
|
||||
// #288: which identity the sample was taken under. Rows
|
||||
// predating this are NULL, which is honest — they were
|
||||
// anonymous, and since #262 that measures a different
|
||||
// thing. Without the column the 2026-08-18 discontinuity
|
||||
// is folklore rather than data.
|
||||
("principal", "TEXT"),
|
||||
// #223 / #269: both are on the wire and were being discarded.
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
("cached_tokens", "INTEGER"),
|
||||
// #285: inter-token tail. `decode_tps` is a mean and hides
|
||||
// a stall that later catches up.
|
||||
("tpot_p95_ms", "REAL"),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -264,6 +292,7 @@ impl Store {
|
||||
swap_unload_ms, swap_load_ms,
|
||||
artifact, quality_score, scorer,
|
||||
image_units,
|
||||
principal, reasoning_tokens, cached_tokens, tpot_p95_ms,
|
||||
ok, error
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4,
|
||||
@@ -280,7 +309,8 @@ impl Store {
|
||||
?42, ?43,
|
||||
?44, ?45, ?46,
|
||||
?47,
|
||||
?48, ?49
|
||||
?48, ?49, ?50, ?51,
|
||||
?52, ?53
|
||||
)",
|
||||
params![
|
||||
r.ts,
|
||||
@@ -330,6 +360,10 @@ impl Store {
|
||||
r.quality_score,
|
||||
r.scorer,
|
||||
r.image_units,
|
||||
r.principal,
|
||||
r.reasoning_tokens,
|
||||
r.cached_tokens,
|
||||
r.tpot_p95_ms,
|
||||
r.ok as i64,
|
||||
r.error,
|
||||
],
|
||||
@@ -703,7 +737,15 @@ impl Store {
|
||||
}
|
||||
};
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT git_sha, build_timestamp, package_version, ttft_s, decode_tps, total_s, ts
|
||||
"SELECT git_sha, build_timestamp, package_version, ttft_s, decode_tps, total_s,
|
||||
ttft_p95_s, queue_wait_ms, rejected,
|
||||
-- Prefill rate derived here rather than stored: the
|
||||
-- inputs are already recorded and a stored rate
|
||||
-- would be a third thing to keep consistent.
|
||||
CASE WHEN prefill_ms > 0 AND prefill_tokens IS NOT NULL
|
||||
THEN (prefill_tokens * 1000.0) / prefill_ms END AS prefill_tps,
|
||||
reasoning_tokens, cached_tokens, completion_tokens,
|
||||
tpot_p95_ms, principal, ts
|
||||
FROM runs
|
||||
WHERE ok=1 AND target_name=?1 AND model_id=?2 AND scenario_id=?3
|
||||
ORDER BY id",
|
||||
@@ -717,7 +759,16 @@ impl Store {
|
||||
ttft_s: r.get(3)?,
|
||||
decode_tps: r.get(4)?,
|
||||
total_s: r.get(5)?,
|
||||
ts: r.get(6)?,
|
||||
ttft_p95_s: r.get(6)?,
|
||||
queue_wait_ms: r.get(7)?,
|
||||
rejected: r.get(8)?,
|
||||
prefill_tps: r.get(9)?,
|
||||
reasoning_tokens: r.get(10)?,
|
||||
cached_tokens: r.get(11)?,
|
||||
completion_tokens: r.get(12)?,
|
||||
tpot_p95_ms: r.get(13)?,
|
||||
principal: r.get(14)?,
|
||||
ts: r.get(15)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -838,6 +889,33 @@ pub struct SeriesPoint {
|
||||
pub ttft_s_median: Option<f64>,
|
||||
pub decode_tps_median: Option<f64>,
|
||||
pub total_s_median: Option<f64>,
|
||||
// ── Added because the two above cannot show what changed ──────
|
||||
/// Tail TTFT. The median above stayed flat at ~0.3 s across the
|
||||
/// build where p95 went 0.53 s → 11.77 s (#288); charting only the
|
||||
/// median is why that went unnoticed for a week.
|
||||
pub ttft_p95_s_median: Option<f64>,
|
||||
/// Admission queue wait — separates "the server is slow" from
|
||||
/// "you were queued behind someone".
|
||||
pub queue_wait_ms_median: Option<f64>,
|
||||
/// Requests shed by admission during the burst.
|
||||
pub rejected_median: Option<f64>,
|
||||
/// Prefill throughput, derived from `prefill_tokens / prefill_ms`.
|
||||
/// The other half of serving speed; only decode was ever charted.
|
||||
pub prefill_tps_median: Option<f64>,
|
||||
/// Tokens spent reasoning (#223) — the dominant cost driver on a
|
||||
/// reasoning model, and independent of every speed metric here.
|
||||
pub reasoning_tokens_median: Option<f64>,
|
||||
/// Prompt tokens served from the prefix cache (#269).
|
||||
pub cached_tokens_median: Option<f64>,
|
||||
/// Total generated tokens — catches a model that starts truncating
|
||||
/// or rambling, which no rate metric shows.
|
||||
pub completion_tokens_median: Option<f64>,
|
||||
/// p95 inter-token gap (ms) — stream smoothness.
|
||||
pub tpot_p95_ms_median: Option<f64>,
|
||||
/// Identity the samples were taken under (#288), or `None` if
|
||||
/// anonymous. Anonymous and identified points are **not
|
||||
/// comparable** once #262 is in the build.
|
||||
pub principal: Option<String>,
|
||||
pub samples: usize,
|
||||
}
|
||||
|
||||
@@ -848,6 +926,15 @@ struct SeriesRaw {
|
||||
ttft_s: Option<f64>,
|
||||
decode_tps: Option<f64>,
|
||||
total_s: Option<f64>,
|
||||
ttft_p95_s: Option<f64>,
|
||||
queue_wait_ms: Option<f64>,
|
||||
rejected: Option<f64>,
|
||||
prefill_tps: Option<f64>,
|
||||
reasoning_tokens: Option<f64>,
|
||||
cached_tokens: Option<f64>,
|
||||
completion_tokens: Option<f64>,
|
||||
tpot_p95_ms: Option<f64>,
|
||||
principal: Option<String>,
|
||||
ts: String,
|
||||
}
|
||||
|
||||
@@ -880,6 +967,19 @@ fn aggregate_series(raws: Vec<SeriesRaw>) -> Vec<SeriesPoint> {
|
||||
ttft_s_median: median(rows.iter().filter_map(|r| r.ttft_s)),
|
||||
decode_tps_median: median(rows.iter().filter_map(|r| r.decode_tps)),
|
||||
total_s_median: median(rows.iter().filter_map(|r| r.total_s)),
|
||||
ttft_p95_s_median: median(rows.iter().filter_map(|r| r.ttft_p95_s)),
|
||||
queue_wait_ms_median: median(rows.iter().filter_map(|r| r.queue_wait_ms)),
|
||||
rejected_median: median(rows.iter().filter_map(|r| r.rejected)),
|
||||
prefill_tps_median: median(rows.iter().filter_map(|r| r.prefill_tps)),
|
||||
reasoning_tokens_median: median(rows.iter().filter_map(|r| r.reasoning_tokens)),
|
||||
cached_tokens_median: median(rows.iter().filter_map(|r| r.cached_tokens)),
|
||||
completion_tokens_median: median(rows.iter().filter_map(|r| r.completion_tokens)),
|
||||
tpot_p95_ms_median: median(rows.iter().filter_map(|r| r.tpot_p95_ms)),
|
||||
// Any row's principal: a build's samples are all taken
|
||||
// under one identity, and a mixture would mean the
|
||||
// config changed mid-build, which the chart should show
|
||||
// as a discontinuity rather than average away.
|
||||
principal: rows.iter().find_map(|r| r.principal.clone()),
|
||||
samples: rows.len(),
|
||||
};
|
||||
(sort_key, point)
|
||||
@@ -1281,6 +1381,10 @@ mod tests {
|
||||
fn rec(target: &str, sha: &str, model: &str, scenario: &str, ok: bool) -> RunRecord {
|
||||
RunRecord {
|
||||
ts: "2026-06-13T00:00:00Z".into(),
|
||||
principal: None,
|
||||
reasoning_tokens: None,
|
||||
cached_tokens: None,
|
||||
tpot_p95_ms: None,
|
||||
target_name: target.into(),
|
||||
target_kind: "neuron".into(),
|
||||
endpoint: "http://x:13131".into(),
|
||||
|
||||
@@ -78,7 +78,10 @@ pub struct Sweeper {
|
||||
|
||||
impl Sweeper {
|
||||
pub fn new(cfg: BenchConfig) -> Result<Self> {
|
||||
let client = TargetClient::new(cfg.bench.request_timeout())?;
|
||||
let client = TargetClient::with_principal(
|
||||
cfg.bench.request_timeout(),
|
||||
cfg.bench.principal.as_ref(),
|
||||
)?;
|
||||
let store = Store::open(&cfg.bench.db_path)?;
|
||||
Ok(Sweeper { cfg, client, store })
|
||||
}
|
||||
@@ -169,6 +172,7 @@ impl Sweeper {
|
||||
model_id: model.id.clone(),
|
||||
max_tokens: self.cfg.scenarios.max_tokens,
|
||||
timeout: self.cfg.bench.request_timeout(),
|
||||
principal_key_id: self.cfg.bench.principal.as_ref().map(|p| p.key_id.clone()),
|
||||
};
|
||||
let cold = crate::scenario::cold_probe(&ctx).await;
|
||||
let swap = SwapTiming { unload_ms, load_ms };
|
||||
@@ -252,6 +256,7 @@ impl Sweeper {
|
||||
model_id: model.id.clone(),
|
||||
max_tokens: self.cfg.scenarios.max_tokens,
|
||||
timeout: self.cfg.bench.request_timeout(),
|
||||
principal_key_id: self.cfg.bench.principal.as_ref().map(|p| p.key_id.clone()),
|
||||
};
|
||||
|
||||
// One unmeasured warmup when the cell is empty (matches
|
||||
@@ -346,6 +351,15 @@ impl Sweeper {
|
||||
|
||||
RunRecord {
|
||||
ts: chrono::Utc::now().to_rfc3339(),
|
||||
// #288: stamp the identity this sample was taken under, so a
|
||||
// later reader can tell an anonymous row from an identified
|
||||
// one instead of inferring it from the date.
|
||||
principal: self
|
||||
.cfg
|
||||
.bench
|
||||
.principal
|
||||
.as_ref()
|
||||
.map(|p| format!("{}/{}", p.account_id, p.key_id)),
|
||||
target_name: target.name.clone(),
|
||||
target_kind: kind_str(target.kind).to_string(),
|
||||
endpoint: target.endpoint.clone(),
|
||||
@@ -383,6 +397,9 @@ impl Sweeper {
|
||||
prefill_ms: m.and_then(|m| m.prefill_ms),
|
||||
decode_ms: m.and_then(|m| m.decode_ms),
|
||||
prefill_tokens: m.and_then(|m| m.prefill_tokens),
|
||||
reasoning_tokens: m.and_then(|x| x.reasoning_tokens),
|
||||
cached_tokens: m.and_then(|x| x.cached_tokens),
|
||||
tpot_p95_ms: m.and_then(|x| x.tpot_p95_ms),
|
||||
vram_used_mb: health.map(|h| h.vram_used_mb),
|
||||
gpu_util_pct: health.map(|h| h.gpu_util_pct),
|
||||
gpu_temp_c: health.map(|h| h.gpu_temp_c),
|
||||
|
||||
@@ -15,6 +15,10 @@ fn rec(
|
||||
ok: bool,
|
||||
) -> RunRecord {
|
||||
RunRecord {
|
||||
principal: None,
|
||||
reasoning_tokens: None,
|
||||
cached_tokens: None,
|
||||
tpot_p95_ms: None,
|
||||
ts: "2026-06-13T00:00:00Z".into(),
|
||||
target_name: host.into(),
|
||||
target_kind: "neuron".into(),
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn spawn_mock(sha: &str) -> (String, Arc<Mutex<String>>) {
|
||||
fn config_for(endpoint: String, db_path: String) -> BenchConfig {
|
||||
BenchConfig {
|
||||
bench: BenchSettings {
|
||||
principal: None,
|
||||
sweep_interval_secs: 1,
|
||||
samples_per_version: 2,
|
||||
iteration_pause_secs: 0,
|
||||
@@ -91,6 +92,7 @@ fn config_for(endpoint: String, db_path: String) -> BenchConfig {
|
||||
prompt_sizes: vec![128], // single scenario keeps assertions simple
|
||||
max_tokens: 16,
|
||||
concurrency_levels: Vec::new(),
|
||||
concurrency_nonstreaming_levels: vec![],
|
||||
concurrency_prompt_tokens: 512,
|
||||
capability_probes: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -90,6 +90,7 @@ fn router_entry(cortex: &str, e: &CortexModelEntry) -> CortexModelEntry {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
reasoning_budget: Vec::new(),
|
||||
..e.clone()
|
||||
}
|
||||
}
|
||||
@@ -174,6 +175,7 @@ mod tests {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
reasoning_budget: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ fn model_entry(loaded: bool, feasible: bool) -> CortexModelEntry {
|
||||
max_output_tokens: None,
|
||||
context_window: None,
|
||||
context_length: None,
|
||||
reasoning_budget: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ mod tests {
|
||||
assert!(cfg.max_redirects > 0);
|
||||
}
|
||||
|
||||
// The closure's error type is `figment::Error`, which is 208 bytes and
|
||||
// not ours to shrink; `Jail::expect_with` dictates the signature. The
|
||||
// usual remedy (box the Err) is not available through a third-party
|
||||
// callback contract, and this is a test closure run once.
|
||||
#[allow(clippy::result_large_err)]
|
||||
#[test]
|
||||
fn loads_toml_with_env_override() {
|
||||
figment::Jail::expect_with(|jail| {
|
||||
|
||||
@@ -86,6 +86,16 @@ pub struct CandleHarnessConfig {
|
||||
#[serde(default)]
|
||||
pub context_limit: ContextLimitConfig,
|
||||
|
||||
/// What each OpenAI effort level costs, in reasoning tokens (#223).
|
||||
///
|
||||
/// OpenAI-shaped clients cannot send a token count — their whole
|
||||
/// vocabulary is the `minimal|low|medium|high` ladder — so the
|
||||
/// numbers live here and are advertised on `/models`. Operator
|
||||
/// configurable because the right values depend on the model and the
|
||||
/// hardware.
|
||||
#[serde(default)]
|
||||
pub reasoning_budget: crate::harness::reasoning_budget::ReasoningBudgetLadder,
|
||||
|
||||
/// Admission control (#53): bounds the per-model wait queue so a busy
|
||||
/// model returns a fast, retryable `429`/`503` instead of stalling new
|
||||
/// requests until their client times out.
|
||||
|
||||
@@ -1051,7 +1051,7 @@ mod tests {
|
||||
|
||||
// Fill the single queue slot with a waiter that parks on the semaphore.
|
||||
let ctrl2 = Arc::clone(&ctrl);
|
||||
let waiter = tokio::spawn(async move { ctrl2.enter(None).await.map(|p| drop(p)) });
|
||||
let waiter = tokio::spawn(async move { ctrl2.enter(None).await.map(drop) });
|
||||
// Give the waiter a moment to occupy the queue slot.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(ctrl.queue_depth(), 1);
|
||||
|
||||
@@ -380,7 +380,23 @@ impl GatedDeltaNet {
|
||||
self.head_v_dim,
|
||||
)?;
|
||||
// Stash the updated recurrent state for the next call.
|
||||
self.state.recurrent_state = Some(new_state.to_dtype(dtype)?);
|
||||
//
|
||||
// The delta rule ran in f32 and `state_init` upcasts back to f32
|
||||
// on entry, so storing at the model dtype is a round trip that
|
||||
// buys only memory (#284). Decode takes it once per token.
|
||||
//
|
||||
// Storing f32 keeps `new_state` as-is, which on the CUDA decode
|
||||
// path is the same buffer the kernel updated in place — so the
|
||||
// stored state aliases it, where the `to_dtype` branch always
|
||||
// allocated afresh. That is sound: the pre-update value is dead
|
||||
// by this point, and prefix snapshots deep-copy
|
||||
// (`snapshot_state`) precisely so they never share storage with
|
||||
// a buffer a later forward will overwrite.
|
||||
self.state.recurrent_state = Some(if gdn_state_f32() {
|
||||
new_state
|
||||
} else {
|
||||
new_state.to_dtype(dtype)?
|
||||
});
|
||||
|
||||
// core_attn_out: (B, H, L, D_v) → (B, L, H, D_v) → (B*L*H, D_v).
|
||||
let core_attn_out = core_attn_out.transpose(1, 2)?.contiguous()?; // (B, L, H, D_v)
|
||||
@@ -464,6 +480,33 @@ fn chunked_prefill_enabled() -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// `NEURON_GDN_STATE_F32=1` keeps the recurrent state in f32 between
|
||||
/// forwards instead of storing it at the model's activation dtype (#284).
|
||||
///
|
||||
/// The delta rule computes in f32 throughout, so storing the result at
|
||||
/// BF16 and upcasting it again next call is a pure round-trip loss.
|
||||
/// Measured on the chunked path: bit-exact carrying f32 at every chunk
|
||||
/// size, against 1.3e-3 relative error after 16 prefill chunks and
|
||||
/// **1.0e-2 after 4096 decode steps** — decode round-trips once per
|
||||
/// token, across 48 of this arch's 64 layers.
|
||||
///
|
||||
/// Off by default on purpose. Every #283 measurement was taken against
|
||||
/// the bf16 baseline, and flipping this silently would move the ground
|
||||
/// underneath the sampling A/B and the decode-rate work (#282, #285)
|
||||
/// at the same time. Turn it on deliberately, measure, then decide the
|
||||
/// default.
|
||||
///
|
||||
/// Costs ~2× the recurrent slab: +37.7 MB per GPU per sequence on the
|
||||
/// deployed 27B under TP=2, so +302 MB/GPU at `max_in_flight = 8`.
|
||||
fn gdn_state_f32() -> bool {
|
||||
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ENABLED.get_or_init(|| {
|
||||
std::env::var("NEURON_GDN_STATE_F32")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Chunk-parallel gated delta rule — a faithful port of the HF
|
||||
/// reference `torch_chunk_gated_delta_rule` (chunk_size = 64) in
|
||||
/// `transformers/models/qwen3_5/modeling_qwen3_5.py`, minus the steps
|
||||
@@ -1097,6 +1140,94 @@ mod tests {
|
||||
assert!(d_state < 2e-4, "final state diverged: {d_state}");
|
||||
}
|
||||
|
||||
/// #284: the state carried *between* forwards must not lose
|
||||
/// precision. Every other parity test here is single-call and
|
||||
/// all-f32, so none of them crosses the boundary where
|
||||
/// `GatedDeltaNet::forward` stores the state at the model's
|
||||
/// activation dtype — which is why the defect lived.
|
||||
///
|
||||
/// Two properties, one test:
|
||||
///
|
||||
/// 1. carrying f32, an N-chunk run equals a single call **exactly**,
|
||||
/// so the chunk-boundary math itself is sound (this kills the
|
||||
/// "chunked prefill corrupts state" hypothesis, #283 H1);
|
||||
/// 2. carrying the model dtype loses a measurable, chunk-count-
|
||||
/// dependent amount — the defect the `NEURON_GDN_STATE_F32` flag
|
||||
/// exists to switch off.
|
||||
///
|
||||
/// Asserting (2) is deliberate: if a future change makes the bf16
|
||||
/// carry lossless this test fails loudly and the flag can go, rather
|
||||
/// than the flag quietly outliving its reason.
|
||||
#[test]
|
||||
fn state_carried_across_chunks_is_exact_in_f32_and_lossy_in_bf16() {
|
||||
let (b, h, dk, dv) = (1, 2, 32, 32);
|
||||
let dev = Device::Cpu;
|
||||
let l = 1024;
|
||||
// Long-memory decay: g near zero keeps the state alive across
|
||||
// every boundary, which is both the realistic regime for
|
||||
// long-context retrieval and the one that accumulates most.
|
||||
let scale = 1.0 / (dk as f64).sqrt();
|
||||
let q = Tensor::randn(0f32, 1.0, (b, h, l, dk), &dev).unwrap();
|
||||
let q = (l2norm(&q, 1e-6).unwrap() * scale).unwrap();
|
||||
let k = Tensor::randn(0f32, 1.0, (b, h, l, dk), &dev).unwrap();
|
||||
let k = l2norm(&k, 1e-6).unwrap();
|
||||
let v = (Tensor::randn(0f32, 1.0, (b, h, l, dv), &dev).unwrap() * 0.5).unwrap();
|
||||
let g = (Tensor::rand(0f32, 1f32, (b, h, l), &dev).unwrap() * -0.02).unwrap();
|
||||
let beta = Tensor::rand(0.05f32, 0.95f32, (b, h, l), &dev).unwrap();
|
||||
let zeros = || Tensor::zeros((b, h, dk, dv), DType::F32, &dev).unwrap();
|
||||
|
||||
// Ground truth: the whole sequence in one call, f32 throughout.
|
||||
let (out_ref, _) = run_chunk_gated_delta_rule(&q, &k, &v, &g, &beta, zeros()).unwrap();
|
||||
|
||||
// Replay it in `chunk`-sized pieces, carrying the state the way
|
||||
// `forward` does.
|
||||
let replay = |chunk: usize, carry: Option<DType>| {
|
||||
let mut state = zeros();
|
||||
let mut outs = Vec::new();
|
||||
let mut pos = 0;
|
||||
while pos < l {
|
||||
let n = chunk.min(l - pos);
|
||||
let take = |t: &Tensor| t.narrow(2, pos, n).unwrap().contiguous().unwrap();
|
||||
let (o, s) = run_chunk_gated_delta_rule(
|
||||
&take(&q),
|
||||
&take(&k),
|
||||
&take(&v),
|
||||
&take(&g),
|
||||
&take(&beta),
|
||||
state,
|
||||
)
|
||||
.unwrap();
|
||||
state = match carry {
|
||||
Some(dt) => s.to_dtype(dt).unwrap().to_dtype(DType::F32).unwrap(),
|
||||
None => s,
|
||||
};
|
||||
outs.push(o);
|
||||
pos += n;
|
||||
}
|
||||
Tensor::cat(&outs, 2).unwrap()
|
||||
};
|
||||
|
||||
for chunk in [128usize, 256, 512] {
|
||||
let exact = max_abs_diff(&out_ref, &replay(chunk, None));
|
||||
assert_eq!(
|
||||
exact, 0.0,
|
||||
"chunk={chunk}: an f32 state carry must reproduce the \
|
||||
single-call result bit for bit, got {exact:e}"
|
||||
);
|
||||
}
|
||||
|
||||
// And the dtype round trip is what costs us. Compared on the
|
||||
// final chunk, where the accumulated state has done the most work.
|
||||
let tail = |t: &Tensor| t.narrow(2, l - 128, 128).unwrap().contiguous().unwrap();
|
||||
let lossy = max_abs_diff(&tail(&out_ref), &tail(&replay(128, Some(DType::BF16))));
|
||||
assert!(
|
||||
lossy > 1e-5,
|
||||
"storing the state at BF16 between chunks is expected to lose \
|
||||
precision (#284); if this now passes losslessly the flag can \
|
||||
be retired, got {lossy:e}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Adversarially correlated inputs: near-identical keys with
|
||||
/// beta ≈ 1 and negligible decay make the UT-transform matrix T
|
||||
/// maximally coherent — raw powers of T grow combinatorially
|
||||
|
||||
@@ -562,9 +562,9 @@ mod tests {
|
||||
.forward_batch_decode(&input, &positions, mask.as_ref())
|
||||
.expect("batched step");
|
||||
assert_eq!(h.dims()[0], 3);
|
||||
for row in 0..3 {
|
||||
for (row, exp) in expected.iter().enumerate() {
|
||||
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
|
||||
let diff = max_abs_diff(&expected[row][t], &got);
|
||||
let diff = max_abs_diff(&exp[t], &got);
|
||||
assert!(diff < 1e-4, "row {row} step {t} diverged: {diff}");
|
||||
}
|
||||
}
|
||||
@@ -610,9 +610,9 @@ mod tests {
|
||||
let h = model
|
||||
.forward_batch_decode(&input, &[3, 3], None)
|
||||
.expect("step");
|
||||
for row in 0..2 {
|
||||
for (row, exp) in expected.iter().enumerate() {
|
||||
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
|
||||
let diff = max_abs_diff(&expected[row], &got);
|
||||
let diff = max_abs_diff(exp, &got);
|
||||
assert!(diff < 1e-4, "row {row} diverged: {diff}");
|
||||
}
|
||||
}
|
||||
@@ -674,9 +674,9 @@ mod tests {
|
||||
let h = model
|
||||
.forward_batch_decode(&input, &positions, mask.as_ref())
|
||||
.expect("batched step");
|
||||
for row in 0..2 {
|
||||
for (row, exp) in expected.iter().enumerate() {
|
||||
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
|
||||
let diff = max_abs_diff(&expected[row][t], &got);
|
||||
let diff = max_abs_diff(&exp[t], &got);
|
||||
assert!(diff < 1e-4, "batched row {row} step {t}: {diff}");
|
||||
}
|
||||
}
|
||||
|
||||
+738
-1329
File diff suppressed because it is too large
Load Diff
@@ -233,7 +233,24 @@ pub fn render_chat_template(
|
||||
MessageContent::Null => Value::Null,
|
||||
};
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("role".into(), Value::String(m.role.clone()));
|
||||
// `developer` is OpenAI's successor to `system` and is what
|
||||
// a client sends once a model is declared reasoning-capable
|
||||
// (pi-ai: `model.reasoning && supportsDeveloperRole`). Most
|
||||
// chat templates — every one on this fleet — only know the
|
||||
// older spelling and `raise_exception` on anything else, so
|
||||
// an unmodified client got a 422 for a system prompt it sent
|
||||
// correctly.
|
||||
//
|
||||
// Rewritten only when the template does not handle the role
|
||||
// itself: a template that knows `developer` may treat it as
|
||||
// distinct from `system`, and substituting there would be us
|
||||
// deciding something the model's authors already decided.
|
||||
let role = if m.role == "developer" && !template.contains("developer") {
|
||||
"system"
|
||||
} else {
|
||||
m.role.as_str()
|
||||
};
|
||||
obj.insert("role".into(), Value::String(role.to_string()));
|
||||
obj.insert("content".into(), content_value);
|
||||
// Forward extras (e.g. tool_calls on assistant turns,
|
||||
// tool_call_id on tool result turns). HF templates that
|
||||
@@ -498,6 +515,52 @@ mod tests {
|
||||
/// Minimal Qwen3-style template — enough surface to confirm
|
||||
/// our renderer threads role + content correctly without
|
||||
/// loading a real model's tokenizer_config.json.
|
||||
/// A reasoning-capable model makes OpenAI clients send the system
|
||||
/// prompt as `developer` — and every chat template on this fleet
|
||||
/// raises on an unknown role, so the caller got a 422 for a prompt
|
||||
/// it had sent correctly. Observed live: pi with `reasoning: true`,
|
||||
/// `template_render_failed: Unexpected message role`.
|
||||
#[test]
|
||||
fn the_developer_role_reaches_a_template_that_only_knows_system() {
|
||||
const SYSTEM_ONLY: &str = "{%- for message in messages -%}\
|
||||
{%- if message.role == 'system' -%}SYS:{{ message.content }}\
|
||||
{%- elif message.role == 'user' -%}USR:{{ message.content }}\
|
||||
{%- else -%}{{ raise_exception('Unexpected message role.') }}\
|
||||
{%- endif -%}\
|
||||
{%- endfor -%}";
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "developer".into(),
|
||||
content: MessageContent::Text("be terse".into()),
|
||||
extra: Value::Object(Default::default()),
|
||||
},
|
||||
user_msg("hi"),
|
||||
];
|
||||
let out = render_chat_template(SYSTEM_ONLY, &messages, &Value::Null, &Value::Null)
|
||||
.expect("a developer role must not blow up a system-only template");
|
||||
assert!(out.contains("SYS:be terse"), "got {out:?}");
|
||||
}
|
||||
|
||||
/// A template that models `developer` itself keeps it: the role may
|
||||
/// mean something distinct there, and substituting would override a
|
||||
/// decision the model's authors already made.
|
||||
#[test]
|
||||
fn a_template_that_knows_developer_keeps_it() {
|
||||
const KNOWS_DEVELOPER: &str = "{%- for message in messages -%}\
|
||||
{%- if message.role == 'developer' -%}DEV:{{ message.content }}\
|
||||
{%- else -%}{{ message.role }}:{{ message.content }}\
|
||||
{%- endif -%}\
|
||||
{%- endfor -%}";
|
||||
let messages = vec![ChatMessage {
|
||||
role: "developer".into(),
|
||||
content: MessageContent::Text("be terse".into()),
|
||||
extra: Value::Object(Default::default()),
|
||||
}];
|
||||
let out = render_chat_template(KNOWS_DEVELOPER, &messages, &Value::Null, &Value::Null)
|
||||
.expect("render");
|
||||
assert!(out.contains("DEV:be terse"), "got {out:?}");
|
||||
}
|
||||
|
||||
/// The fetcher and the reader must agree on the filenames, or the
|
||||
/// reader looks for something nobody downloaded and falls back to a
|
||||
/// built-in prompt format without anything failing (#280 — observed
|
||||
|
||||
@@ -410,6 +410,10 @@ pub struct EngineRequest {
|
||||
/// → fallback (#272), rather than loose scalars each caller could
|
||||
/// default differently.
|
||||
pub sampling: crate::harness::sampling::SamplingParams,
|
||||
/// Reasoning-token budget for this request (#223). `None` is
|
||||
/// unbounded — the default, and the behaviour before it existed.
|
||||
/// Per-request because slots in one batch can differ.
|
||||
pub reasoning_budget: Option<usize>,
|
||||
pub eos_id: Option<u32>,
|
||||
pub tool_schemas: ToolSchemas,
|
||||
pub tx: mpsc::Sender<InferenceEvent>,
|
||||
@@ -471,6 +475,8 @@ struct Slot {
|
||||
/// repetition penalty and its window are per-request knobs, and
|
||||
/// slots in one batch can carry different ones.
|
||||
sampling: crate::harness::sampling::SamplingParams,
|
||||
/// Bounds this slot's think block (#223); `None` = unbounded.
|
||||
governor: Option<crate::harness::reasoning_budget::ReasoningGovernor>,
|
||||
router: mpsc::Sender<RouterMsg>,
|
||||
/// Set by the router when the consumer hangs up; the engine stops
|
||||
/// feeding the slot and compacts it out.
|
||||
@@ -578,23 +584,28 @@ async fn run_engine(cfg: EngineConfig, mut rx: mpsc::Receiver<EngineRequest>) {
|
||||
break;
|
||||
}
|
||||
};
|
||||
let nt =
|
||||
match sample_with_penalty(&logits, &slot.generated, &mut slot.lp, &slot.sampling) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let health = logits_health_slice(&logits_vec);
|
||||
tracing::warn!(
|
||||
?health,
|
||||
error = %e,
|
||||
"batch engine: sample failed; logits unhealthy"
|
||||
);
|
||||
// Unhealthy logits are a device-level problem —
|
||||
// fail the whole engine, mirroring the B=1 path's
|
||||
// poison classification.
|
||||
fatal = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let nt = match sample_with_penalty(
|
||||
&logits,
|
||||
&slot.generated,
|
||||
&mut slot.lp,
|
||||
&slot.sampling,
|
||||
slot.governor.as_mut(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let health = logits_health_slice(&logits_vec);
|
||||
tracing::warn!(
|
||||
?health,
|
||||
error = %e,
|
||||
"batch engine: sample failed; logits unhealthy"
|
||||
);
|
||||
// Unhealthy logits are a device-level problem —
|
||||
// fail the whole engine, mirroring the B=1 path's
|
||||
// poison classification.
|
||||
fatal = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if Some(nt) == slot.eos_id {
|
||||
finish_slot(slot, FinishReason::Stop, active_rate(&cfg, sess)).await;
|
||||
continue;
|
||||
@@ -784,6 +795,7 @@ async fn prefill_join(
|
||||
prompt_tokens,
|
||||
max_new,
|
||||
sampling,
|
||||
reasoning_budget,
|
||||
eos_id,
|
||||
tool_schemas,
|
||||
tx,
|
||||
@@ -792,6 +804,15 @@ async fn prefill_join(
|
||||
} = req;
|
||||
|
||||
let mut lp = LogitsProcessor::from_sampling(sampling.seed, sampling.to_sampling());
|
||||
// Built before prefill's first sample so a prompt that opens the
|
||||
// think block is governed from its very first token (#223).
|
||||
let mut governor = crate::harness::reasoning_budget::ReasoningGovernor::new(
|
||||
reasoning_budget,
|
||||
cfg.reasoning_tokens
|
||||
.as_ref()
|
||||
.map(|p| (p.open_id, p.close_id)),
|
||||
prompt_opens_reasoning(&prompt_tokens, cfg.reasoning_tokens.as_ref()),
|
||||
);
|
||||
|
||||
let prompt_len = prompt_tokens.len();
|
||||
let prefill_start = std::time::Instant::now();
|
||||
@@ -801,17 +822,18 @@ async fn prefill_join(
|
||||
// First token from the prefill logits.
|
||||
let generated: Vec<u32> = Vec::new();
|
||||
let logits = Tensor::new(logits_vec.as_slice(), &Device::Cpu)?;
|
||||
let first = match sample_with_penalty(&logits, &generated, &mut lp, &sampling) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let health = logits_health_slice(&logits_vec);
|
||||
tracing::warn!(
|
||||
?health,
|
||||
"batch engine: prefill sample failed; logits unhealthy"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let first =
|
||||
match sample_with_penalty(&logits, &generated, &mut lp, &sampling, governor.as_mut()) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let health = logits_health_slice(&logits_vec);
|
||||
tracing::warn!(
|
||||
?health,
|
||||
"batch engine: prefill sample failed; logits unhealthy"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Router task for this slot.
|
||||
let hangup = Arc::new(AtomicBool::new(false));
|
||||
@@ -841,6 +863,7 @@ async fn prefill_join(
|
||||
eos_id,
|
||||
lp,
|
||||
sampling,
|
||||
governor,
|
||||
router: router_tx,
|
||||
hangup,
|
||||
finished: None,
|
||||
@@ -1192,6 +1215,9 @@ mod tests {
|
||||
&crate::harness::sampling::ModelGenerationDefaults::default(),
|
||||
0,
|
||||
),
|
||||
// Unbounded: this fixture asserts exact token sequences,
|
||||
// so nothing may substitute a close marker (#223).
|
||||
reasoning_budget: None,
|
||||
eos_id: None,
|
||||
tool_schemas: ToolSchemas::new(),
|
||||
tx,
|
||||
|
||||
@@ -12,6 +12,8 @@ pub mod image;
|
||||
pub mod prefix_cache;
|
||||
pub mod preflight;
|
||||
pub mod preprocess;
|
||||
pub mod reasoning_budget;
|
||||
pub mod reasoning_effort;
|
||||
pub mod sampling;
|
||||
pub mod speculative;
|
||||
pub mod tp;
|
||||
|
||||
@@ -401,6 +401,7 @@ mod tests {
|
||||
quant: quant.map(String::from),
|
||||
tensor_parallel: tp,
|
||||
devices: None,
|
||||
sampling: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Bounding how long a model may think before it must answer (#223).
|
||||
//!
|
||||
//! A reasoning model with no stopping criterion will fill whatever
|
||||
//! output budget it is given. Measured on Qwen3.8-27B: given 16,384
|
||||
//! tokens it used 16,384; given 32,768 it used 32,768; both times it was
|
||||
//! still opening new topics when the cap bit, and both times the caller
|
||||
//! received no answer at all.
|
||||
//!
|
||||
//! The fix is not to stop the model thinking — the run that did produce
|
||||
//! files designed for 27,157 tokens first, and that design was good. It
|
||||
//! is to bound the thinking so the rest of the output budget survives
|
||||
//! for the answer.
|
||||
//!
|
||||
//! Two halves live here:
|
||||
//!
|
||||
//! - [`ReasoningBudget`] — how many reasoning tokens this request may
|
||||
//! spend, resolved from whichever control the caller's wire format
|
||||
//! gave them.
|
||||
//! - [`ReasoningGovernor`] — the per-request state that enforces it, by
|
||||
//! substituting the model's own `</think>` token when the budget runs
|
||||
//! out so generation transitions to answering instead of being cut off.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-model mapping from an OpenAI effort level to a token budget.
|
||||
///
|
||||
/// OpenAI-shaped clients cannot send a token count — pi-ai's vocabulary
|
||||
/// is the ladder `off | minimal | low | medium | high | xhigh | max` and
|
||||
/// nothing else — so the numbers have to live on the server. Operator
|
||||
/// configurable, because the right values depend on the model and the
|
||||
/// hardware; a derived default would be a guess baked into the binary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReasoningBudgetLadder {
|
||||
pub minimal: usize,
|
||||
pub low: usize,
|
||||
pub medium: usize,
|
||||
pub high: usize,
|
||||
/// `xhigh` and `max` are only offered by a client when the model
|
||||
/// declares them; absent here means "not offered", and a request
|
||||
/// naming one falls back to `high` rather than being rejected.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub xhigh: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<usize>,
|
||||
/// Tokens of the caller's **own** output budget held back for the
|
||||
/// answer, when the caller names no explicit `reasoning.max_tokens`.
|
||||
///
|
||||
/// This is what an OpenAI-shaped client gets instead of the rungs
|
||||
/// above, and it is deliberately not derived from effort. Effort is
|
||||
/// a prompt instruction the model is trained to follow (#290);
|
||||
/// making it *also* set a guillotine is what told Qwen3.8-27B to
|
||||
/// reason at `xhigh` and then cut it off at `medium`'s 12,288.
|
||||
///
|
||||
/// A reserve has no such double meaning. The model thinks as long
|
||||
/// as it likes within the budget the caller declared, minus enough
|
||||
/// to answer — so the caller always gets an answer, and the size of
|
||||
/// the think block stays the model's decision rather than ours.
|
||||
///
|
||||
/// Set to `0` to disable, restoring fully unbounded reasoning.
|
||||
#[serde(default = "default_answer_reserve")]
|
||||
pub answer_reserve_tokens: usize,
|
||||
}
|
||||
|
||||
/// Enough for a substantive answer with tool calls, not so much that it
|
||||
/// meaningfully shortens a think block: at the 32,768 budget pi sends,
|
||||
/// this leaves ~28.7 k for reasoning — above the ~27.2 k a full agentic
|
||||
/// design turn was measured to use.
|
||||
fn default_answer_reserve() -> usize {
|
||||
4_096
|
||||
}
|
||||
|
||||
impl Default for ReasoningBudgetLadder {
|
||||
/// Rungs sized against observed behaviour rather than round numbers:
|
||||
/// a long agentic design turn on this class of model runs to ~27k
|
||||
/// tokens, so `high` sits above that (bounded but rarely binding),
|
||||
/// `medium` at roughly half (enough to plan a whole implementation),
|
||||
/// and `low` at the scale of a focused single-file change.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
minimal: 1_024,
|
||||
low: 4_096,
|
||||
medium: 12_288,
|
||||
high: 32_768,
|
||||
xhigh: None,
|
||||
max: None,
|
||||
answer_reserve_tokens: default_answer_reserve(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReasoningBudgetLadder {
|
||||
/// Resolve an effort level to a token budget.
|
||||
///
|
||||
/// `none`/`off` are handled before this — they disable thinking
|
||||
/// outright via the chat template — so they have no budget here.
|
||||
/// An unrecognised level resolves to `None` (unbounded) rather than
|
||||
/// to a guess: silently substituting a budget nobody asked for is
|
||||
/// the failure mode this whole area has been full of.
|
||||
pub fn for_effort(&self, effort: &str) -> Option<usize> {
|
||||
match effort.trim().to_ascii_lowercase().as_str() {
|
||||
"minimal" => Some(self.minimal),
|
||||
"low" => Some(self.low),
|
||||
"medium" => Some(self.medium),
|
||||
"high" => Some(self.high),
|
||||
"xhigh" => Some(self.xhigh.unwrap_or(self.high)),
|
||||
"max" => Some(self.max.or(self.xhigh).unwrap_or(self.high)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mapping as a client should see it on `/v1/models` — the rungs
|
||||
/// this deployment actually offers, so a caller picking `low` knows
|
||||
/// what it bought instead of guessing (#274).
|
||||
pub fn advertised(&self) -> Vec<(String, usize)> {
|
||||
let mut out = vec![
|
||||
("minimal".to_string(), self.minimal),
|
||||
("low".to_string(), self.low),
|
||||
("medium".to_string(), self.medium),
|
||||
("high".to_string(), self.high),
|
||||
];
|
||||
if let Some(v) = self.xhigh {
|
||||
out.push(("xhigh".to_string(), v));
|
||||
}
|
||||
if let Some(v) = self.max {
|
||||
out.push(("max".to_string(), v));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforces a resolved budget over one request's token stream.
|
||||
///
|
||||
/// Fed every sampled token; returns the token that should actually be
|
||||
/// emitted. While the model is inside a think block and over budget, it
|
||||
/// returns the close marker instead, exactly once — after which the
|
||||
/// model is answering and the governor is inert.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReasoningGovernor {
|
||||
limit: usize,
|
||||
open_id: u32,
|
||||
close_id: u32,
|
||||
inside: bool,
|
||||
used: usize,
|
||||
forced: bool,
|
||||
}
|
||||
|
||||
impl ReasoningGovernor {
|
||||
/// `None` when the request named no budget, or the model has no
|
||||
/// reasoning markers to enforce one with — in both cases generation
|
||||
/// proceeds exactly as it did before this existed.
|
||||
pub fn new(
|
||||
limit: Option<usize>,
|
||||
markers: Option<(u32, u32)>,
|
||||
prompt_opened_reasoning: bool,
|
||||
) -> Option<Self> {
|
||||
let limit = limit?;
|
||||
let (open_id, close_id) = markers?;
|
||||
Some(Self {
|
||||
limit,
|
||||
open_id,
|
||||
close_id,
|
||||
inside: prompt_opened_reasoning,
|
||||
used: 0,
|
||||
forced: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the forced close has already been spent.
|
||||
pub fn forced(&self) -> bool {
|
||||
self.forced
|
||||
}
|
||||
|
||||
/// Reasoning tokens counted so far.
|
||||
pub fn used(&self) -> usize {
|
||||
self.used
|
||||
}
|
||||
|
||||
/// Govern one sampled token, returning the token to emit.
|
||||
pub fn govern(&mut self, sampled: u32) -> u32 {
|
||||
if sampled == self.open_id {
|
||||
self.inside = true;
|
||||
return sampled;
|
||||
}
|
||||
if sampled == self.close_id {
|
||||
self.inside = false;
|
||||
return sampled;
|
||||
}
|
||||
if !self.inside {
|
||||
return sampled;
|
||||
}
|
||||
if self.used >= self.limit {
|
||||
// Budget spent: hand the model its own close marker so it
|
||||
// transitions to answering. Cutting the stream here instead
|
||||
// would reproduce the very failure this exists to prevent.
|
||||
self.inside = false;
|
||||
self.forced = true;
|
||||
return self.close_id;
|
||||
}
|
||||
self.used += 1;
|
||||
sampled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const OPEN: u32 = 100;
|
||||
const CLOSE: u32 = 101;
|
||||
|
||||
fn governor(limit: usize, opened: bool) -> ReasoningGovernor {
|
||||
ReasoningGovernor::new(Some(limit), Some((OPEN, CLOSE)), opened).expect("governor")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_with_no_budget_is_ungoverned() {
|
||||
assert!(ReasoningGovernor::new(None, Some((OPEN, CLOSE)), true).is_none());
|
||||
}
|
||||
|
||||
/// A model with no detected markers cannot be governed — better
|
||||
/// unbounded than truncated at an arbitrary token.
|
||||
#[test]
|
||||
fn a_model_without_markers_is_ungoverned() {
|
||||
assert!(ReasoningGovernor::new(Some(10), None, true).is_none());
|
||||
}
|
||||
|
||||
/// The case that motivated this: the chat template left the think
|
||||
/// block open, so the model is reasoning from the first token.
|
||||
#[test]
|
||||
fn a_prompt_opened_block_is_bounded_and_force_closed() {
|
||||
let mut g = governor(3, true);
|
||||
assert_eq!(g.govern(7), 7);
|
||||
assert_eq!(g.govern(8), 8);
|
||||
assert_eq!(g.govern(9), 9);
|
||||
assert_eq!(g.govern(10), CLOSE, "the fourth token must close the block");
|
||||
assert!(g.forced());
|
||||
assert_eq!(g.used(), 3);
|
||||
// Answering now — the governor is inert.
|
||||
assert_eq!(g.govern(11), 11);
|
||||
assert_eq!(g.govern(12), 12);
|
||||
}
|
||||
|
||||
/// Content before the block opens is never counted against the
|
||||
/// reasoning budget.
|
||||
#[test]
|
||||
fn only_tokens_inside_the_block_are_counted() {
|
||||
let mut g = governor(2, false);
|
||||
assert_eq!(g.govern(5), 5);
|
||||
assert_eq!(g.govern(6), 6);
|
||||
assert_eq!(g.used(), 0);
|
||||
assert_eq!(g.govern(OPEN), OPEN);
|
||||
assert_eq!(g.govern(7), 7);
|
||||
assert_eq!(g.govern(8), 8);
|
||||
assert_eq!(g.govern(9), CLOSE);
|
||||
assert_eq!(g.used(), 2);
|
||||
}
|
||||
|
||||
/// A model that finishes thinking on its own is untouched — the
|
||||
/// budget is a ceiling, not a target.
|
||||
#[test]
|
||||
fn a_model_that_stops_on_its_own_is_not_forced() {
|
||||
let mut g = governor(100, true);
|
||||
for t in 20..30 {
|
||||
assert_eq!(g.govern(t), t);
|
||||
}
|
||||
assert_eq!(g.govern(CLOSE), CLOSE);
|
||||
assert!(!g.forced());
|
||||
for t in 40..50 {
|
||||
assert_eq!(g.govern(t), t);
|
||||
}
|
||||
assert!(!g.forced(), "content after the block must not be governed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ladder_resolves_the_levels_clients_can_send() {
|
||||
let l = ReasoningBudgetLadder::default();
|
||||
assert_eq!(l.for_effort("minimal"), Some(1_024));
|
||||
assert_eq!(l.for_effort("LOW"), Some(4_096));
|
||||
assert_eq!(l.for_effort(" medium "), Some(12_288));
|
||||
assert_eq!(l.for_effort("high"), Some(32_768));
|
||||
// Not offered by default → falls back to high rather than
|
||||
// rejecting a level the client was told it could send.
|
||||
assert_eq!(l.for_effort("xhigh"), Some(32_768));
|
||||
assert_eq!(l.for_effort("max"), Some(32_768));
|
||||
// Unknown → unbounded, never a substituted guess.
|
||||
assert_eq!(l.for_effort("enthusiastic"), None);
|
||||
assert_eq!(l.for_effort("none"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_advertised_ladder_lists_only_what_is_offered() {
|
||||
let l = ReasoningBudgetLadder::default();
|
||||
let names: Vec<_> = l.advertised().into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(names, ["minimal", "low", "medium", "high"]);
|
||||
let l2 = ReasoningBudgetLadder {
|
||||
xhigh: Some(49_152),
|
||||
..ReasoningBudgetLadder::default()
|
||||
};
|
||||
assert!(
|
||||
l2.advertised()
|
||||
.iter()
|
||||
.any(|(k, v)| k == "xhigh" && *v == 49_152)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Which reasoning-effort rungs a model actually supports, discovered
|
||||
//! from the model itself (#290).
|
||||
//!
|
||||
//! ## Why this is discovered rather than configured
|
||||
//!
|
||||
//! `Qwen/Qwen3.8-27B` accepts exactly three efforts and hard-fails on
|
||||
//! anything else:
|
||||
//!
|
||||
//! ```jinja
|
||||
//! {%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
|
||||
//! {%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
|
||||
//! {{- raise_exception('Unexpected reasoning effort ...') }}
|
||||
//! ```
|
||||
//!
|
||||
//! We previously advertised an invented ladder — `minimal`, `low`,
|
||||
//! `medium`, `high` — of which two values the template rejects outright
|
||||
//! and which omits `xhigh`, the model's own default and strongest rung.
|
||||
//!
|
||||
//! A caution about *why* that mattered, because the obvious story is
|
||||
//! wrong. It is tempting to say clients read our advertisement and were
|
||||
//! misled by it. pi-ai does not: its `getSupportedThinkingLevels` reads
|
||||
//! `model.thinkingLevelMap`, a static field in the operator's
|
||||
//! `models.json`, and treats `xhigh`/`max` as unavailable unless that
|
||||
//! map names them explicitly. So a pi user saw exactly
|
||||
//! `minimal, low, medium, high` — the invented ladder, to the value —
|
||||
//! for a reason that had nothing to do with what we published.
|
||||
//!
|
||||
//! The coincidence is the trap: our wrong advertisement and pi's
|
||||
//! default produced the same symptom, which made the advertisement look
|
||||
//! causal. Publishing rungs the model actually accepts is still correct
|
||||
//! — it is the truth, other clients may read it, and it is what makes
|
||||
//! `apply_effort_kwarg` right — but do not assume a given client
|
||||
//! consumes it. For pi the operator must also set `thinkingLevelMap`,
|
||||
//! by hand, and nothing validates that they match.
|
||||
//!
|
||||
//! A hardcoded per-model table would drift the moment a model ships a
|
||||
//! new template, which is exactly the class of defect #280 fixed by
|
||||
//! fetching the template instead of assuming it. So the rungs come from
|
||||
//! the artifact.
|
||||
//!
|
||||
//! ## How the probe works
|
||||
//!
|
||||
//! `raise_exception` is already wired to a render error, so the template
|
||||
//! answers the question directly: render it once per candidate effort
|
||||
//! and see which ones survive.
|
||||
//!
|
||||
//! Two ambiguities the probe has to resolve, or it would report
|
||||
//! confident nonsense:
|
||||
//!
|
||||
//! 1. **A template that ignores `reasoning_effort` entirely** renders
|
||||
//! successfully for every candidate. Supporting "all six" would be a
|
||||
//! lie. So a candidate only counts when the rendered prompt actually
|
||||
//! *differs* across efforts — if every render is identical the model
|
||||
//! has no effort control and we advertise none.
|
||||
//! 2. **The default** is whichever supported candidate renders
|
||||
//! identically to a render with no effort supplied. Qwen3.8 defaults
|
||||
//! to `xhigh`; without detecting that, a caller who says nothing gets
|
||||
//! the strongest rung while we believe it got nothing.
|
||||
|
||||
use anyhow::Result;
|
||||
use cortex_core::openai::{ChatMessage, MessageContent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Effort names in ascending order of thinking, as clients spell them.
|
||||
///
|
||||
/// Deliberately a superset of what any one model supports: this is the
|
||||
/// *candidate* set the probe tries, and the conventional vocabulary
|
||||
/// OpenAI-compatible clients use (pi-ai's `EXTENDED_THINKING_LEVELS`
|
||||
/// minus `off`, which is not an effort but a separate switch handled by
|
||||
/// `enable_thinking`).
|
||||
pub const CANDIDATE_EFFORTS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// What one model's template accepts.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SupportedEfforts {
|
||||
/// Accepted efforts, ascending, as the template spells them.
|
||||
pub levels: Vec<String>,
|
||||
/// The effort applied when the caller names none — the template's
|
||||
/// own `|default(...)`. `None` when it cannot be determined.
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
impl SupportedEfforts {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.levels.is_empty()
|
||||
}
|
||||
|
||||
pub fn supports(&self, effort: &str) -> bool {
|
||||
self.levels.iter().any(|l| l == effort)
|
||||
}
|
||||
|
||||
/// Map a caller's effort onto the nearest rung this model has.
|
||||
///
|
||||
/// Exact match wins. Otherwise walk **down** from the requested
|
||||
/// level first, then up — a caller asking for more thinking than the
|
||||
/// model offers should get the most it has, and a caller asking for
|
||||
/// less should not be silently upgraded into a slower, costlier
|
||||
/// response than they asked for.
|
||||
///
|
||||
/// Descending-first is the opposite of pi-ai's `clampThinkingLevel`,
|
||||
/// which searches upward first. That is deliberate: pi is choosing
|
||||
/// among levels it believes exist, whereas we are the ones who know
|
||||
/// what exists, and over-delivering thinking is the failure that
|
||||
/// costs the caller money and latency.
|
||||
///
|
||||
/// `None` when the model has no rungs at all.
|
||||
pub fn nearest(&self, requested: &str) -> Option<&str> {
|
||||
if self.levels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(exact) = self.levels.iter().find(|l| *l == requested) {
|
||||
return Some(exact.as_str());
|
||||
}
|
||||
let idx = CANDIDATE_EFFORTS.iter().position(|c| *c == requested)?;
|
||||
for c in CANDIDATE_EFFORTS[..idx].iter().rev() {
|
||||
if let Some(hit) = self.levels.iter().find(|l| l.as_str() == *c) {
|
||||
return Some(hit.as_str());
|
||||
}
|
||||
}
|
||||
for c in &CANDIDATE_EFFORTS[idx + 1..] {
|
||||
if let Some(hit) = self.levels.iter().find(|l| l.as_str() == *c) {
|
||||
return Some(hit.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a probe prompt at `effort`, or `None` if the template rejects
|
||||
/// it.
|
||||
fn probe_render(template: &str, effort: Option<&str>) -> Option<String> {
|
||||
let messages = [ChatMessage {
|
||||
role: "user".into(),
|
||||
content: MessageContent::Text("probe".into()),
|
||||
extra: Value::Null,
|
||||
}];
|
||||
let kwargs = match effort {
|
||||
Some(e) => json!({ "reasoning_effort": e }),
|
||||
None => Value::Null,
|
||||
};
|
||||
super::chat_template::render_chat_template(template, &messages, &Value::Null, &kwargs).ok()
|
||||
}
|
||||
|
||||
/// Discover which efforts `template` accepts (#290).
|
||||
///
|
||||
/// Errors are not propagated: a template that cannot render even the
|
||||
/// baseline probe yields no rungs, and the caller behaves as it did
|
||||
/// before this existed.
|
||||
pub fn probe(template: &str, model_id: &str) -> Result<SupportedEfforts> {
|
||||
let Some(baseline) = probe_render(template, None) else {
|
||||
tracing::debug!(
|
||||
model = %model_id,
|
||||
"reasoning effort: template does not render a baseline probe; advertising no rungs"
|
||||
);
|
||||
return Ok(SupportedEfforts::default());
|
||||
};
|
||||
|
||||
let mut rendered: Vec<(&str, String)> = Vec::new();
|
||||
for candidate in CANDIDATE_EFFORTS {
|
||||
if let Some(out) = probe_render(template, Some(candidate)) {
|
||||
rendered.push((candidate, out));
|
||||
}
|
||||
}
|
||||
|
||||
// A template that never reads `reasoning_effort` renders the same
|
||||
// bytes whatever we pass. Advertising every candidate in that case
|
||||
// would be the invented-ladder mistake again, in a new place.
|
||||
let effort_aware = rendered.windows(2).any(|w| w[0].1 != w[1].1)
|
||||
|| rendered.iter().any(|(_, out)| *out != baseline);
|
||||
if !effort_aware {
|
||||
tracing::debug!(
|
||||
model = %model_id,
|
||||
"reasoning effort: template ignores reasoning_effort; advertising no rungs"
|
||||
);
|
||||
return Ok(SupportedEfforts::default());
|
||||
}
|
||||
|
||||
let default = rendered
|
||||
.iter()
|
||||
.find(|(_, out)| *out == baseline)
|
||||
.map(|(name, _)| (*name).to_string());
|
||||
let levels: Vec<String> = rendered
|
||||
.iter()
|
||||
.map(|(name, _)| (*name).to_string())
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
model = %model_id,
|
||||
levels = %levels.join(","),
|
||||
default = ?default,
|
||||
"reasoning effort: rungs discovered from the model's chat template (#290)"
|
||||
);
|
||||
Ok(SupportedEfforts { levels, default })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The probe against the **real** deployed Qwen3.8-27B template,
|
||||
/// not a hand-written approximation of it. A synthetic template
|
||||
/// proves the probe logic; only the shipped artifact proves the
|
||||
/// answer, and the artifact is what #290 is about.
|
||||
#[test]
|
||||
fn the_real_qwen38_template_yields_low_medium_xhigh_defaulting_to_xhigh() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/data/qwen3.8-27b.chat_template.jinja"
|
||||
);
|
||||
let Ok(template) = std::fs::read_to_string(path) else {
|
||||
// The fixture is checked in; if it is ever removed this test
|
||||
// should fail loudly rather than silently pass.
|
||||
panic!("missing fixture {path}");
|
||||
};
|
||||
let s = probe(&template, "Qwen/Qwen3.8-27B").expect("probe");
|
||||
assert_eq!(
|
||||
s.levels,
|
||||
vec!["low", "medium", "xhigh"],
|
||||
"the shipped template accepts exactly these"
|
||||
);
|
||||
assert_eq!(s.default.as_deref(), Some("xhigh"));
|
||||
// The two rungs we used to advertise are rejected outright.
|
||||
assert!(!s.supports("minimal"));
|
||||
assert!(!s.supports("high"));
|
||||
}
|
||||
|
||||
/// The shape Qwen3.8-27B actually ships: three rungs, `xhigh`
|
||||
/// default, `raise_exception` on anything else.
|
||||
const QWEN38: &str = r#"
|
||||
{%- set resolved = reasoning_effort|default('xhigh') %}
|
||||
{%- if resolved not in ('xhigh', 'medium', 'low') %}
|
||||
{{- raise_exception('Unexpected reasoning effort ' ~ resolved) }}
|
||||
{%- endif %}
|
||||
{%- if resolved == 'xhigh' %}EFFORT-XHIGH{% elif resolved == 'low' %}EFFORT-LOW{% else %}EFFORT-MEDIUM{% endif %}
|
||||
{%- for m in messages %}<|im_start|>{{ m.role }}
|
||||
{{ m.content }}<|im_end|>
|
||||
{%- endfor %}
|
||||
"#;
|
||||
|
||||
/// A template with no effort control at all.
|
||||
const PLAIN: &str = r#"
|
||||
{%- for m in messages %}<|im_start|>{{ m.role }}
|
||||
{{ m.content }}<|im_end|>
|
||||
{%- endfor %}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn discovers_exactly_the_rungs_the_template_accepts() {
|
||||
let s = probe(QWEN38, "test").expect("probe");
|
||||
assert_eq!(s.levels, vec!["low", "medium", "xhigh"]);
|
||||
assert!(!s.supports("minimal"), "template raises on minimal");
|
||||
assert!(!s.supports("high"), "template raises on high");
|
||||
}
|
||||
|
||||
/// Without this, a caller who names no effort silently receives the
|
||||
/// strongest rung while we believe it received nothing — which is
|
||||
/// how the xhigh instruction ended up on every request in #290.
|
||||
#[test]
|
||||
fn detects_the_templates_own_default() {
|
||||
let s = probe(QWEN38, "test").expect("probe");
|
||||
assert_eq!(s.default.as_deref(), Some("xhigh"));
|
||||
}
|
||||
|
||||
/// A template that ignores the kwarg must advertise nothing rather
|
||||
/// than appearing to support all six.
|
||||
#[test]
|
||||
fn a_template_without_effort_control_advertises_no_rungs() {
|
||||
let s = probe(PLAIN, "test").expect("probe");
|
||||
assert!(s.is_empty(), "got {:?}", s.levels);
|
||||
assert_eq!(s.nearest("medium"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exact_match_is_returned_unchanged() {
|
||||
let s = probe(QWEN38, "test").expect("probe");
|
||||
assert_eq!(s.nearest("medium"), Some("medium"));
|
||||
assert_eq!(s.nearest("xhigh"), Some("xhigh"));
|
||||
}
|
||||
|
||||
/// `high` does not exist on this model. Descending first means it
|
||||
/// resolves to `medium`, not `xhigh`: a caller is never given more
|
||||
/// thinking (slower, dearer) than it asked for.
|
||||
#[test]
|
||||
fn an_unsupported_level_resolves_downward_not_upward() {
|
||||
let s = probe(QWEN38, "test").expect("probe");
|
||||
assert_eq!(s.nearest("high"), Some("medium"));
|
||||
}
|
||||
|
||||
/// `minimal` is below every rung the model has, so there is nothing
|
||||
/// to descend to and the nearest is the weakest available.
|
||||
#[test]
|
||||
fn a_level_below_every_rung_resolves_up_to_the_weakest() {
|
||||
let s = probe(QWEN38, "test").expect("probe");
|
||||
assert_eq!(s.nearest("minimal"), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_level_name_resolves_to_nothing_rather_than_a_guess() {
|
||||
let s = probe(QWEN38, "test").expect("probe");
|
||||
assert_eq!(s.nearest("turbo"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,29 @@
|
||||
//! Sampling parameter resolution (#272).
|
||||
//! Sampling parameter resolution (#272, #283).
|
||||
//!
|
||||
//! Three sources, in priority order:
|
||||
//! Four sources, in priority order:
|
||||
//!
|
||||
//! 1. what the **request** asked for,
|
||||
//! 2. what the **model** published in `generation_config.json`,
|
||||
//! 3. a built-in fallback.
|
||||
//! 2. what the **operator** set for this model (#283),
|
||||
//! 3. what the **model** published in `generation_config.json`,
|
||||
//! 4. a built-in fallback.
|
||||
//!
|
||||
//! Tier (2) exists because honouring the model's own file is right by
|
||||
//! default and still wrong sometimes: `Qwen/Qwen3.8-27B` publishes
|
||||
//! `temperature = 1.0`, which measured a 20% structural-defect rate on
|
||||
//! ~2k-token code generation against 0/60 at `<= 0.6` (#283,
|
||||
//! p = 0.0031). The operator's correction is explicit and lives in a
|
||||
//! config file — never a heuristic that inspects the model and guesses,
|
||||
//! which would be a footgun.
|
||||
//!
|
||||
//! The operator sits *below* the request on purpose. An explicit API
|
||||
//! parameter that is silently ignored is a contract break; the observed
|
||||
//! failure came from callers that named nothing, so a default-tier
|
||||
//! override is sufficient to fix it.
|
||||
//!
|
||||
//! Mechanically, tier (2) is folded into tier (3) once at load time by
|
||||
//! [`ModelGenerationDefaults::with_operator_override`], so the
|
||||
//! per-request resolve path stays a two-tier lookup and every call site
|
||||
//! gets the override for free — there is no site that can forget it.
|
||||
//!
|
||||
//! Before this, (2) did not exist and (3) was a hardcoded
|
||||
//! `temperature = 0.7` with `top_p = None` — and `None` selects
|
||||
@@ -98,6 +117,56 @@ impl ModelGenerationDefaults {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay the operator's per-model override (#283) onto what the
|
||||
/// model published, field by field.
|
||||
///
|
||||
/// Applied **once at load**, not per request, so the override cannot
|
||||
/// be forgotten at one of the resolve sites — the same reasoning
|
||||
/// that made #272 resolve sampling once rather than at eight
|
||||
/// `LogitsProcessor` construction sites.
|
||||
///
|
||||
/// Each field overlays independently: setting only `temperature`
|
||||
/// leaves the model's `top_p`/`top_k` in force, because an operator
|
||||
/// correcting one number should not silently blank the other two
|
||||
/// back to "no truncation".
|
||||
///
|
||||
/// Logged at `info` whenever it changes something — an operator
|
||||
/// override that is in force but invisible is how a fleet ends up
|
||||
/// with two configs that disagree and nobody noticing (#252).
|
||||
pub fn with_operator_override(
|
||||
mut self,
|
||||
model_id: &str,
|
||||
operator: Option<&cortex_core::harness::SamplingOverride>,
|
||||
) -> Self {
|
||||
let Some(op) = operator else {
|
||||
return self;
|
||||
};
|
||||
if op.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let before = self.clone();
|
||||
if let Some(t) = op.temperature {
|
||||
self.temperature = Some(t);
|
||||
}
|
||||
if let Some(p) = op.top_p {
|
||||
self.top_p = Some(p);
|
||||
}
|
||||
if let Some(k) = op.top_k {
|
||||
self.top_k = Some(k);
|
||||
}
|
||||
tracing::info!(
|
||||
model = %model_id,
|
||||
model_temperature = ?before.temperature,
|
||||
model_top_p = ?before.top_p,
|
||||
model_top_k = ?before.top_k,
|
||||
temperature = ?self.temperature,
|
||||
top_p = ?self.top_p,
|
||||
top_k = ?self.top_k,
|
||||
"sampling: operator override applied over the model's published defaults (#283)"
|
||||
);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What the caller asked for. All optional — absent means "no opinion",
|
||||
@@ -176,6 +245,7 @@ impl SamplingParams {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use candle_transformers::generation::Sampling;
|
||||
use cortex_core::harness::SamplingOverride;
|
||||
|
||||
fn qwen3_defaults() -> ModelGenerationDefaults {
|
||||
ModelGenerationDefaults {
|
||||
@@ -221,6 +291,106 @@ mod tests {
|
||||
assert_eq!(p.top_p, Some(0.95));
|
||||
}
|
||||
|
||||
// ── Operator override (#283) ──────────────────────────────
|
||||
|
||||
fn op(temperature: Option<f64>, top_p: Option<f64>, top_k: Option<usize>) -> SamplingOverride {
|
||||
SamplingOverride {
|
||||
temperature,
|
||||
top_p,
|
||||
top_k,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_op(o: SamplingOverride) -> ModelGenerationDefaults {
|
||||
qwen3_defaults().with_operator_override("Qwen/Qwen3.8-27B", Some(&o))
|
||||
}
|
||||
|
||||
/// The #283 case: the model publishes `temperature = 1.0`, which
|
||||
/// measured a 20% structural-defect rate on code generation. The
|
||||
/// operator sets 0.6 and a silent caller must get 0.6.
|
||||
#[test]
|
||||
fn the_operator_override_replaces_what_the_model_published() {
|
||||
let p = SamplingParams::resolve(
|
||||
&RequestedSampling::default(),
|
||||
&with_op(op(Some(0.6), None, None)),
|
||||
42,
|
||||
);
|
||||
assert_eq!(p.temperature, 0.6);
|
||||
}
|
||||
|
||||
/// Precedence is request > operator > model. An explicit API
|
||||
/// parameter that is silently ignored is a contract break, so a
|
||||
/// caller naming a temperature still wins over the operator.
|
||||
#[test]
|
||||
fn an_explicit_request_still_beats_the_operator() {
|
||||
let requested = RequestedSampling {
|
||||
temperature: Some(0.9),
|
||||
..Default::default()
|
||||
};
|
||||
let p = SamplingParams::resolve(&requested, &with_op(op(Some(0.6), None, None)), 42);
|
||||
assert_eq!(p.temperature, 0.9);
|
||||
}
|
||||
|
||||
/// Overriding one field must not blank the others back to "no
|
||||
/// truncation" — that was the #271 failure mode, reintroduced by a
|
||||
/// wholesale replace instead of a field-by-field overlay.
|
||||
#[test]
|
||||
fn overriding_temperature_alone_leaves_top_p_and_top_k_intact() {
|
||||
let d = with_op(op(Some(0.6), None, None));
|
||||
assert_eq!(d.temperature, Some(0.6));
|
||||
assert_eq!(d.top_p, Some(0.95), "the model's top_p must survive");
|
||||
assert_eq!(d.top_k, Some(20), "the model's top_k must survive");
|
||||
}
|
||||
|
||||
/// Each field overlays independently.
|
||||
#[test]
|
||||
fn every_field_can_be_overridden_independently() {
|
||||
let d = with_op(op(None, Some(0.8), Some(40)));
|
||||
assert_eq!(d.temperature, Some(1.0), "untouched, so the model's");
|
||||
assert_eq!(d.top_p, Some(0.8));
|
||||
assert_eq!(d.top_k, Some(40));
|
||||
}
|
||||
|
||||
/// No override, and an override that sets nothing, must both be
|
||||
/// indistinguishable from the pre-#283 behaviour.
|
||||
#[test]
|
||||
fn absent_or_empty_override_changes_nothing() {
|
||||
let base = qwen3_defaults();
|
||||
let none = qwen3_defaults().with_operator_override("m", None);
|
||||
let empty =
|
||||
qwen3_defaults().with_operator_override("m", Some(&SamplingOverride::default()));
|
||||
for d in [none, empty] {
|
||||
assert_eq!(d.temperature, base.temperature);
|
||||
assert_eq!(d.top_p, base.top_p);
|
||||
assert_eq!(d.top_k, base.top_k);
|
||||
}
|
||||
}
|
||||
|
||||
/// An operator override on a model that ships no
|
||||
/// `generation_config.json` must still take effect, rather than
|
||||
/// falling through to the built-in 0.7.
|
||||
#[test]
|
||||
fn the_override_applies_even_without_a_generation_config() {
|
||||
let d = ModelGenerationDefaults::default()
|
||||
.with_operator_override("m", Some(&op(Some(0.6), None, None)));
|
||||
let p = SamplingParams::resolve(&RequestedSampling::default(), &d, 42);
|
||||
assert_eq!(p.temperature, 0.6);
|
||||
assert_ne!(p.temperature, FALLBACK_TEMPERATURE);
|
||||
}
|
||||
|
||||
/// `temperature = 0` from an operator must select greedy decoding,
|
||||
/// not be treated as "unset" by an `unwrap_or`-shaped bug.
|
||||
#[test]
|
||||
fn an_operator_temperature_of_zero_is_greedy_not_unset() {
|
||||
let p = SamplingParams::resolve(
|
||||
&RequestedSampling::default(),
|
||||
&with_op(op(Some(0.0), None, None)),
|
||||
42,
|
||||
);
|
||||
assert_eq!(p.temperature, 0.0);
|
||||
assert_eq!(p.to_sampling(), Sampling::ArgMax);
|
||||
}
|
||||
|
||||
/// A model with no `generation_config.json` must behave exactly as
|
||||
/// it did before this landed, so nothing regresses on the models
|
||||
/// that do not ship one.
|
||||
|
||||
@@ -2445,7 +2445,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cuda"))]
|
||||
fn vb_over(path: &std::path::Path) -> ShardedVarBuilder {
|
||||
fn vb_over(path: &std::path::Path) -> ShardedVarBuilder<'_> {
|
||||
// SAFETY: mmap of a file the test just wrote; nothing mutates it.
|
||||
unsafe {
|
||||
candle_nn::var_builder::ShardedSafeTensors::var_builder(
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
//! propagate without us writing any logic.
|
||||
|
||||
use cortex_core::openai::{
|
||||
ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, HelexaTiming, PromptTokensDetails,
|
||||
Usage,
|
||||
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionResponse, ChatMessage, ChunkChoice,
|
||||
CompletionTokensDetails, HelexaTiming, MessageContent, PromptTokensDetails, Usage,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -450,10 +450,283 @@ fn usage_chunk(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain an [`InferenceEvent`] stream into a single non-streaming
|
||||
/// [`ChatCompletionResponse`] (#285).
|
||||
///
|
||||
/// Non-streaming used to be a *separate inference path* that took the
|
||||
/// per-model `inference_lock` for the whole request. That made it
|
||||
/// invisible to the batch engine (#98), so concurrent non-streaming
|
||||
/// callers serialized completely — measured on beast at 1.00x aggregate
|
||||
/// throughput across 1/2/4/8 concurrency, against 3.98x for the same
|
||||
/// work streamed. `max_in_flight` bought those requests admission and
|
||||
/// nothing else.
|
||||
///
|
||||
/// Expressing non-streaming as "run the stream, buffer the result"
|
||||
/// collapses the two paths onto one producer, so batching, prefix-cache
|
||||
/// reuse, tool-call extraction, the reasoning governor and server-side
|
||||
/// timing all apply identically to both. It also removes a second place
|
||||
/// where the same wire object could be assembled differently — the
|
||||
/// shape of defect #252 and #272 both were.
|
||||
///
|
||||
/// Semantics deliberately preserved from the path this replaces:
|
||||
/// reasoning text is **dropped** from `content` (it is counted in
|
||||
/// `usage.completion_tokens_details.reasoning_tokens` but never
|
||||
/// rendered), and `finish_reason` becomes `"tool_calls"` whenever any
|
||||
/// tool call was parsed.
|
||||
///
|
||||
/// Two fields that the old path hardcoded to `None` now populate,
|
||||
/// because the events carry them and the struct already had the slots:
|
||||
/// `prompt_tokens_details.cached_tokens` and `helexa_timing`.
|
||||
///
|
||||
/// A stream that ends without a `Finish` is an error, not an empty
|
||||
/// reply: that is how a dropped engine slot or a fatal worker fault
|
||||
/// presents, and returning a 200 with partial text would hide it.
|
||||
pub async fn collect_chat_completion(
|
||||
mut rx: mpsc::Receiver<InferenceEvent>,
|
||||
id: String,
|
||||
created: u64,
|
||||
model_id: String,
|
||||
) -> Result<ChatCompletionResponse, String> {
|
||||
let mut content = String::new();
|
||||
let mut tool_calls: Vec<serde_json::Value> = Vec::new();
|
||||
let mut finish: Option<(FinishReason, u32, u32, u32, u32, Option<FinishTiming>)> = None;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
InferenceEvent::Start => {}
|
||||
InferenceEvent::TextDelta(t) => content.push_str(&t),
|
||||
// Counted, never rendered — see the doc comment.
|
||||
InferenceEvent::ReasoningDelta(_) => {}
|
||||
InferenceEvent::ToolCall {
|
||||
index,
|
||||
id: call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => tool_calls.push(json!({
|
||||
"index": index,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": { "name": name, "arguments": arguments },
|
||||
})),
|
||||
InferenceEvent::Finish {
|
||||
reason,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
cached_tokens,
|
||||
timing,
|
||||
} => {
|
||||
finish = Some((
|
||||
reason,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
cached_tokens,
|
||||
timing,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((reason, prompt_tokens, completion_tokens, reasoning_tokens, cached_tokens, timing)) =
|
||||
finish
|
||||
else {
|
||||
return Err(
|
||||
"inference stream ended without a finish event (engine slot dropped?)".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
// A parsed tool call outranks whatever the sampler stopped on, so a
|
||||
// model that emits a call and then hits EOS still reports
|
||||
// `tool_calls` and the client dispatches instead of rendering.
|
||||
let finish_reason = if tool_calls.is_empty() {
|
||||
reason.as_openai_str().to_string()
|
||||
} else {
|
||||
"tool_calls".to_string()
|
||||
};
|
||||
let message_extra = if tool_calls.is_empty() {
|
||||
serde_json::Value::Object(Default::default())
|
||||
} else {
|
||||
json!({ "tool_calls": tool_calls })
|
||||
};
|
||||
|
||||
Ok(ChatCompletionResponse {
|
||||
id,
|
||||
object: "chat.completion".into(),
|
||||
created,
|
||||
model: model_id,
|
||||
choices: vec![ChatCompletionChoice {
|
||||
index: 0,
|
||||
message: ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content: MessageContent::Text(content),
|
||||
extra: message_extra,
|
||||
},
|
||||
finish_reason: Some(finish_reason),
|
||||
extra: serde_json::Value::Object(Default::default()),
|
||||
}],
|
||||
usage: Some(Usage {
|
||||
prompt_tokens: prompt_tokens as u64,
|
||||
completion_tokens: completion_tokens as u64,
|
||||
total_tokens: (prompt_tokens + completion_tokens) as u64,
|
||||
completion_tokens_details: (reasoning_tokens > 0).then_some(CompletionTokensDetails {
|
||||
reasoning_tokens: reasoning_tokens as u64,
|
||||
}),
|
||||
prompt_tokens_details: (cached_tokens > 0).then_some(PromptTokensDetails {
|
||||
cached_tokens: cached_tokens as u64,
|
||||
}),
|
||||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Non-streaming collector (#285) ────────────────────────────
|
||||
|
||||
async fn collect_from(events: Vec<InferenceEvent>) -> Result<ChatCompletionResponse, String> {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
for e in events {
|
||||
tx.send(e).await.expect("send");
|
||||
}
|
||||
drop(tx);
|
||||
collect_chat_completion(rx, "chatcmpl-test".into(), 1_700_000_000, "m".into()).await
|
||||
}
|
||||
|
||||
fn finish(reason: FinishReason) -> InferenceEvent {
|
||||
InferenceEvent::Finish {
|
||||
reason,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 4,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
timing: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collector_concatenates_text_deltas() {
|
||||
let r = collect_from(vec![
|
||||
InferenceEvent::Start,
|
||||
InferenceEvent::TextDelta("hel".into()),
|
||||
InferenceEvent::TextDelta("lo".into()),
|
||||
finish(FinishReason::Stop),
|
||||
])
|
||||
.await
|
||||
.expect("collected");
|
||||
let c = &r.choices[0];
|
||||
assert!(matches!(&c.message.content, MessageContent::Text(t) if t == "hello"));
|
||||
assert_eq!(c.finish_reason.as_deref(), Some("stop"));
|
||||
assert_eq!(r.object, "chat.completion");
|
||||
}
|
||||
|
||||
/// Reasoning is counted but never rendered — the behaviour of the
|
||||
/// path this replaced. A regression here would leak scratchpad into
|
||||
/// `content` for every non-streaming caller.
|
||||
#[tokio::test]
|
||||
async fn collector_drops_reasoning_text_but_keeps_the_count() {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
tx.send(InferenceEvent::ReasoningDelta("secret".into()))
|
||||
.await
|
||||
.expect("send");
|
||||
tx.send(InferenceEvent::TextDelta("answer".into()))
|
||||
.await
|
||||
.expect("send");
|
||||
tx.send(InferenceEvent::Finish {
|
||||
reason: FinishReason::Stop,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 9,
|
||||
reasoning_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.expect("send");
|
||||
drop(tx);
|
||||
let r = collect_chat_completion(rx, "id".into(), 1, "m".into())
|
||||
.await
|
||||
.expect("collected");
|
||||
let c = &r.choices[0];
|
||||
assert!(matches!(&c.message.content, MessageContent::Text(t) if t == "answer"));
|
||||
let u = r.usage.expect("usage");
|
||||
assert_eq!(
|
||||
u.completion_tokens_details
|
||||
.expect("details")
|
||||
.reasoning_tokens,
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
/// A parsed tool call outranks the sampler's stop reason, matching
|
||||
/// the streaming projector.
|
||||
#[tokio::test]
|
||||
async fn collector_promotes_finish_reason_to_tool_calls() {
|
||||
let r = collect_from(vec![
|
||||
InferenceEvent::ToolCall {
|
||||
index: 0,
|
||||
id: "call_1".into(),
|
||||
name: "read_file".into(),
|
||||
arguments: r#"{"path":"a.txt"}"#.into(),
|
||||
},
|
||||
finish(FinishReason::Stop),
|
||||
])
|
||||
.await
|
||||
.expect("collected");
|
||||
let c = &r.choices[0];
|
||||
assert_eq!(c.finish_reason.as_deref(), Some("tool_calls"));
|
||||
let calls = c.message.extra.get("tool_calls").expect("tool_calls");
|
||||
assert_eq!(calls[0]["function"]["name"], "read_file");
|
||||
assert_eq!(calls[0]["type"], "function");
|
||||
assert_eq!(calls[0]["id"], "call_1");
|
||||
}
|
||||
|
||||
/// cached_tokens and helexa_timing were hardcoded to `None` on the
|
||||
/// old non-streaming path; the events carried them all along.
|
||||
#[tokio::test]
|
||||
async fn collector_surfaces_cached_tokens_and_timing() {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
tx.send(InferenceEvent::Finish {
|
||||
reason: FinishReason::Stop,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 5,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 64,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: 12,
|
||||
decode_ms: 34,
|
||||
prefill_tokens: 100,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("send");
|
||||
drop(tx);
|
||||
let r = collect_chat_completion(rx, "id".into(), 1, "m".into())
|
||||
.await
|
||||
.expect("collected");
|
||||
let u = r.usage.expect("usage");
|
||||
assert_eq!(u.prompt_tokens_details.expect("details").cached_tokens, 64);
|
||||
let t = u.helexa_timing.expect("timing");
|
||||
assert_eq!((t.prefill_ms, t.decode_ms, t.prefill_tokens), (12, 34, 100));
|
||||
}
|
||||
|
||||
/// A dropped engine slot ends the channel with no Finish. Returning
|
||||
/// a 200 with partial text would hide a fatal worker fault.
|
||||
#[tokio::test]
|
||||
async fn collector_errors_when_the_stream_ends_without_finish() {
|
||||
let err = collect_from(vec![InferenceEvent::TextDelta("partial".into())])
|
||||
.await
|
||||
.expect_err("must not succeed");
|
||||
assert!(err.contains("without a finish"), "unexpected: {err}");
|
||||
}
|
||||
|
||||
/// Drain the projection's output into a Vec for assertion.
|
||||
async fn collect(mut rx: mpsc::Receiver<ChatCompletionChunk>) -> Vec<ChatCompletionChunk> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -227,6 +227,9 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
|
||||
}
|
||||
}
|
||||
|
||||
// A caller that asked for no reasoning must get none (#223).
|
||||
apply_reasoning_effort(&mut extra);
|
||||
|
||||
Ok(ChatCompletionRequest {
|
||||
model: req.model,
|
||||
messages,
|
||||
@@ -326,6 +329,53 @@ fn input_item_to_chat(item: ResponsesInputItem) -> Option<ChatMessage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the Responses reasoning control onto the template kwarg neuron
|
||||
/// already honours (#223).
|
||||
///
|
||||
/// `reasoning: {"effort": "none"}` is how a Responses client says "do
|
||||
/// not think" — it is what pi-ai emits for thinking-level *off*. The
|
||||
/// chat-completions path couples generation to surfacing via
|
||||
/// `default_enable_thinking`; this surface never did, so the switch had
|
||||
/// no route at all and a client asking for no reasoning got a full think
|
||||
/// block anyway. Observed live: a harness set thinking off and received
|
||||
/// 32,768 tokens of reasoning and no answer.
|
||||
///
|
||||
/// Only `none` and `off` map. `minimal` / `low` deliberately do not:
|
||||
/// they ask for *less* thinking, not none, and the template's switch is
|
||||
/// binary — silently turning "minimal" into "off" would be the same
|
||||
/// class of lie this whole area has been full of. They pass through and
|
||||
/// the model thinks as usual.
|
||||
///
|
||||
/// An explicit `chat_template_kwargs.enable_thinking` from the caller
|
||||
/// wins, matching `default_enable_thinking`'s precedence: the more
|
||||
/// specific control is the one the caller reached for.
|
||||
fn apply_reasoning_effort(extra: &mut serde_json::Map<String, Value>) {
|
||||
if extra
|
||||
.get("chat_template_kwargs")
|
||||
.and_then(|k| k.get("enable_thinking"))
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let effort = extra
|
||||
.get("reasoning")
|
||||
.and_then(|r| r.get("effort"))
|
||||
.and_then(|e| e.as_str())
|
||||
.map(|e| e.trim().to_ascii_lowercase());
|
||||
if !matches!(effort.as_deref(), Some("none") | Some("off")) {
|
||||
return;
|
||||
}
|
||||
let kwargs = extra
|
||||
.entry("chat_template_kwargs")
|
||||
.or_insert_with(|| json!({}));
|
||||
if !kwargs.is_object() {
|
||||
*kwargs = json!({});
|
||||
}
|
||||
if let Some(kw) = kwargs.as_object_mut() {
|
||||
kw.insert("enable_thinking".into(), Value::Bool(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a replayed reasoning item, under either spelling.
|
||||
///
|
||||
/// `summary` is what neuron emits (`summary_text` parts) and `content`
|
||||
@@ -697,6 +747,31 @@ async fn run_projection(
|
||||
return;
|
||||
}
|
||||
|
||||
// No `Finish` means the producer died rather than finished — the
|
||||
// poisoned-model path named above, an OOM, a dropped worker. This
|
||||
// used to default to `FinishReason::Stop`, which told the caller a
|
||||
// crash was a complete answer: pi-ai maps `status: "completed"` to
|
||||
// `stopReason: "stop"` and treats whatever partial text arrived as
|
||||
// the model's considered reply.
|
||||
//
|
||||
// Decided here, before the message item is opened, so a failed
|
||||
// stream does not leave an item open that nothing will ever close.
|
||||
let Some(reason) = finish else {
|
||||
let _ = tx
|
||||
.send(ResponseStreamFrame {
|
||||
event_name: events::FAILED,
|
||||
data: json!({
|
||||
"response": failed_response_shell(
|
||||
&meta,
|
||||
"server_error",
|
||||
"inference ended before the model finished",
|
||||
)
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
// A stream can end while still thinking — a reasoning model that
|
||||
// exhausts max_output_tokens mid-block never emits visible text.
|
||||
// Close the item so the client sees a finished response rather than
|
||||
@@ -711,7 +786,6 @@ async fn run_projection(
|
||||
return;
|
||||
}
|
||||
|
||||
let reason = finish.unwrap_or(FinishReason::Stop);
|
||||
let _ = emit_finish_frames(
|
||||
&tx,
|
||||
&meta,
|
||||
@@ -727,6 +801,23 @@ async fn run_projection(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The `response` payload for a [`events::FAILED`] frame.
|
||||
///
|
||||
/// pi-ai renders this as `` `${error.code}: ${error.message}` `` and
|
||||
/// throws, which is the correct outcome — the request genuinely failed
|
||||
/// — and is strictly better than a silent success.
|
||||
fn failed_response_shell(meta: &ResponseMeta, code: &str, message: &str) -> Value {
|
||||
json!({
|
||||
"id": meta.response_id,
|
||||
"object": "response",
|
||||
"created_at": meta.created_at,
|
||||
"status": "failed",
|
||||
"model": meta.model_id,
|
||||
"output": [],
|
||||
"error": { "code": code, "message": message },
|
||||
})
|
||||
}
|
||||
|
||||
/// Streaming state for a reasoning model's think block (#267).
|
||||
///
|
||||
/// The block is its own output item, ahead of the message, so the
|
||||
@@ -993,7 +1084,14 @@ async fn emit_finish_frames(
|
||||
}),
|
||||
},
|
||||
ResponseStreamFrame {
|
||||
event_name: events::COMPLETED,
|
||||
// `response.incomplete` when we stopped short, so a client
|
||||
// keyed on the event name rather than on `response.status`
|
||||
// still sees a terminal frame instead of timing out.
|
||||
event_name: if status == "incomplete" {
|
||||
events::INCOMPLETE
|
||||
} else {
|
||||
events::COMPLETED
|
||||
},
|
||||
data: json!({
|
||||
"response": response_shell(meta, status, &output_items, usage)
|
||||
}),
|
||||
@@ -1018,6 +1116,15 @@ fn response_shell(
|
||||
obj.insert("object".into(), Value::String("response".into()));
|
||||
obj.insert("created_at".into(), json!(meta.created_at));
|
||||
obj.insert("status".into(), Value::String(status.into()));
|
||||
// Why we stopped short. A client cannot distinguish an honest
|
||||
// truncation from a protocol fault without this, and pi-ai treats
|
||||
// the difference as continue-vs-halt.
|
||||
if status == "incomplete" {
|
||||
obj.insert(
|
||||
"incomplete_details".into(),
|
||||
json!({ "reason": "max_output_tokens" }),
|
||||
);
|
||||
}
|
||||
obj.insert("model".into(), Value::String(meta.model_id.clone()));
|
||||
obj.insert("output".into(), Value::Array(output.to_vec()));
|
||||
if let Some(u) = usage {
|
||||
@@ -1088,6 +1195,10 @@ pub fn build_response(
|
||||
}],
|
||||
status,
|
||||
}],
|
||||
// Same contract as the streaming shell: a truncated response
|
||||
// says so, or the caller cannot tell it from a fault.
|
||||
incomplete_details: matches!(reason, FinishReason::Length)
|
||||
.then(cortex_core::responses::IncompleteDetails::max_output_tokens),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
@@ -1492,6 +1603,104 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The control a Responses client actually reaches for (#223): pi-ai
|
||||
/// emits `reasoning: {"effort": "none"}` for thinking-level *off*.
|
||||
/// Before this it went nowhere, and a harness that asked for no
|
||||
/// thinking got 32,768 tokens of it and no answer.
|
||||
#[test]
|
||||
fn reasoning_effort_none_turns_thinking_off() {
|
||||
for effort in ["none", "off", "NONE", " off "] {
|
||||
let raw =
|
||||
format!(r#"{{"model":"m","input":"hi","reasoning":{{"effort":"{effort}"}}}}"#);
|
||||
let req: ResponsesRequest = serde_json::from_str(&raw).expect("parse");
|
||||
let chat = request_to_chat(req).expect("translate");
|
||||
assert_eq!(
|
||||
chat.extra
|
||||
.get("chat_template_kwargs")
|
||||
.and_then(|k| k.get("enable_thinking")),
|
||||
Some(&Value::Bool(false)),
|
||||
"effort {effort:?} must reach the template as enable_thinking=false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `minimal` and `low` ask for *less* thinking, not none. The
|
||||
/// template switch is binary, so mapping them to off would be a
|
||||
/// silent substitution — they pass through untouched instead.
|
||||
#[test]
|
||||
fn a_reduced_effort_is_not_silently_treated_as_none() {
|
||||
for effort in ["minimal", "low", "medium", "high"] {
|
||||
let raw =
|
||||
format!(r#"{{"model":"m","input":"hi","reasoning":{{"effort":"{effort}"}}}}"#);
|
||||
let req: ResponsesRequest = serde_json::from_str(&raw).expect("parse");
|
||||
let chat = request_to_chat(req).expect("translate");
|
||||
assert!(
|
||||
chat.extra
|
||||
.get("chat_template_kwargs")
|
||||
.and_then(|k| k.get("enable_thinking"))
|
||||
.is_none(),
|
||||
"effort {effort:?} must not be rewritten to off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The more specific control wins, matching `default_enable_thinking`.
|
||||
#[test]
|
||||
fn an_explicit_template_kwarg_beats_the_effort_mapping() {
|
||||
let raw = r#"{"model":"m","input":"hi",
|
||||
"reasoning":{"effort":"none"},
|
||||
"chat_template_kwargs":{"enable_thinking":true}}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).expect("parse");
|
||||
let chat = request_to_chat(req).expect("translate");
|
||||
assert_eq!(
|
||||
chat.extra
|
||||
.get("chat_template_kwargs")
|
||||
.and_then(|k| k.get("enable_thinking")),
|
||||
Some(&Value::Bool(true))
|
||||
);
|
||||
}
|
||||
|
||||
/// A request with no reasoning control is untouched — the model
|
||||
/// thinks by default, which is the Responses-native expectation.
|
||||
#[test]
|
||||
fn no_reasoning_control_leaves_thinking_alone() {
|
||||
let raw = r#"{"model":"m","input":"hi"}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).expect("parse");
|
||||
let chat = request_to_chat(req).expect("translate");
|
||||
assert!(chat.extra.get("chat_template_kwargs").is_none());
|
||||
}
|
||||
|
||||
/// End to end, on the rendered prompt: Qwen3's template emits an
|
||||
/// empty think block when `enable_thinking` is false, and opens one
|
||||
/// otherwise. This asserts the caller's control reaches that branch.
|
||||
#[test]
|
||||
fn reasoning_effort_none_reaches_the_template_branch() {
|
||||
const QWEN_THINK_SWITCH: &str = "{%- if enable_thinking is defined and enable_thinking is false -%}\
|
||||
<think>\n\n</think>\n\n\
|
||||
{%- else -%}\
|
||||
<think>\n\
|
||||
{%- endif -%}";
|
||||
let raw = r#"{"model":"m","input":"hi","reasoning":{"effort":"none"}}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).expect("parse");
|
||||
let chat = request_to_chat(req).expect("translate");
|
||||
let kwargs = chat
|
||||
.extra
|
||||
.get("chat_template_kwargs")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let out = crate::harness::chat_template::render_chat_template(
|
||||
QWEN_THINK_SWITCH,
|
||||
&chat.messages,
|
||||
&Value::Null,
|
||||
&kwargs,
|
||||
)
|
||||
.expect("render");
|
||||
assert!(
|
||||
out.contains("<think>\n\n</think>"),
|
||||
"the closed think block is what tells the model not to reason; got {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #223's root cause: `chat_template_kwargs` is how `enable_thinking`
|
||||
/// reaches the template, and the translator used to rebuild `extra`
|
||||
/// from an allowlist of one key, so it never arrived.
|
||||
@@ -1855,9 +2064,19 @@ mod tests {
|
||||
names.contains(&events::CONTENT_PART_ADDED),
|
||||
"message item must still be opened so the finish frames refer to something"
|
||||
);
|
||||
assert_eq!(names.last(), Some(&events::COMPLETED));
|
||||
// This is the live failure in miniature: the model spent its
|
||||
// whole budget thinking and produced almost no visible text.
|
||||
// The stream must still close, and must say *why* it stopped —
|
||||
// this frame used to be named `response.completed` while
|
||||
// carrying `status: "incomplete"`, an inconsistency that cost a
|
||||
// real agentic session its run.
|
||||
assert_eq!(names.last(), Some(&events::INCOMPLETE));
|
||||
let last = frames.last().unwrap();
|
||||
assert_eq!(last.data["response"]["status"], "incomplete");
|
||||
assert_eq!(
|
||||
last.data["response"]["incomplete_details"]["reason"],
|
||||
"max_output_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tool calls sit after the message, which sits after any reasoning
|
||||
@@ -2151,29 +2370,93 @@ mod tests {
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
let frames = collect(out).await;
|
||||
// The terminal frame is `response.incomplete`, not
|
||||
// `response.completed` — a client keyed on the event name must
|
||||
// still see an end to the stream.
|
||||
assert!(
|
||||
!frames.iter().any(|f| f.event_name == events::COMPLETED),
|
||||
"a truncated response must not announce itself as completed"
|
||||
);
|
||||
let terminal = frames
|
||||
.iter()
|
||||
.find(|f| f.event_name == events::INCOMPLETE)
|
||||
.expect("a terminal frame is required");
|
||||
assert_eq!(terminal.data["response"]["status"], "incomplete");
|
||||
// The field that decides whether a client recovers or halts.
|
||||
assert_eq!(
|
||||
terminal.data["response"]["incomplete_details"]["reason"], "max_output_tokens",
|
||||
"pi-ai maps `incomplete` with no reason to stopReason:error \
|
||||
and ends the agent loop; with max_output_tokens it maps to \
|
||||
stopReason:length, fails truncated tool calls safely, and \
|
||||
takes another turn"
|
||||
);
|
||||
}
|
||||
|
||||
/// The converse: a normal finish must stay `response.completed`
|
||||
/// and carry no reason, or every client sees phantom truncation.
|
||||
#[tokio::test]
|
||||
async fn a_normal_finish_carries_no_incomplete_reason() {
|
||||
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
|
||||
let out = project_responses_stream(rx, meta());
|
||||
tx.send(InferenceEvent::Start).await.unwrap();
|
||||
tx.send(InferenceEvent::Finish {
|
||||
reason: FinishReason::Stop,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
let frames = collect(out).await;
|
||||
let completed = frames
|
||||
.iter()
|
||||
.find(|f| f.event_name == events::COMPLETED)
|
||||
.unwrap();
|
||||
assert_eq!(completed.data["response"]["status"], "incomplete");
|
||||
.expect("a normal finish stays `response.completed`");
|
||||
assert_eq!(completed.data["response"]["status"], "completed");
|
||||
assert!(
|
||||
completed.data["response"]
|
||||
.get("incomplete_details")
|
||||
.is_none(),
|
||||
"a completed response must not carry an incomplete reason"
|
||||
);
|
||||
}
|
||||
|
||||
/// A producer that drops without ever sending `Finish` did not
|
||||
/// finish — it died (poisoned model, OOM, dropped worker). The
|
||||
/// stream must still terminate, and must terminate as a *failure*.
|
||||
///
|
||||
/// This previously asserted `COMPLETED`, encoding the defect: a
|
||||
/// crashed inference was announced as a complete response, so a
|
||||
/// client took whatever partial text had arrived to be the model's
|
||||
/// considered answer instead of retrying.
|
||||
#[tokio::test]
|
||||
async fn synthesises_start_frames_when_producer_skips_start() {
|
||||
// A producer that drops without sending Start (poisoned
|
||||
// model, immediate disconnect, …) should still produce a
|
||||
// coherent stream — the projector synthesises the
|
||||
// mandatory header frames before COMPLETED so the
|
||||
// consumer never sees an output_text.done without a
|
||||
// matching content_part.added.
|
||||
async fn a_producer_that_dies_terminates_the_stream_as_failed() {
|
||||
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
|
||||
let out = project_responses_stream(rx, meta());
|
||||
drop(tx);
|
||||
let frames = collect(out).await;
|
||||
let names: Vec<&str> = frames.iter().map(|f| f.event_name).collect();
|
||||
// The shell still opens, so the failure refers to a response.
|
||||
assert!(names.contains(&events::CREATED));
|
||||
assert!(names.contains(&events::COMPLETED));
|
||||
assert!(names.contains(&events::OUTPUT_TEXT_DONE));
|
||||
assert!(
|
||||
!names.contains(&events::COMPLETED),
|
||||
"a crash must never be announced as a completed response"
|
||||
);
|
||||
let failed = frames
|
||||
.iter()
|
||||
.find(|f| f.event_name == events::FAILED)
|
||||
.expect("the stream must terminate, or the client waits out its idle timeout");
|
||||
assert_eq!(failed.data["response"]["status"], "failed");
|
||||
assert_eq!(failed.data["response"]["error"]["code"], "server_error");
|
||||
assert!(
|
||||
failed.data["response"]["error"]["message"]
|
||||
.as_str()
|
||||
.is_some_and(|m| !m.is_empty()),
|
||||
"pi-ai renders `code: message`, so an empty message helps nobody"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -29,6 +29,7 @@ async fn test_load_default_models_skips_unknown_harness() {
|
||||
quant: None,
|
||||
tensor_parallel: None,
|
||||
devices: None,
|
||||
sampling: None,
|
||||
},
|
||||
ModelSpec {
|
||||
model_id: "model-b".into(),
|
||||
@@ -36,6 +37,7 @@ async fn test_load_default_models_skips_unknown_harness() {
|
||||
quant: None,
|
||||
tensor_parallel: None,
|
||||
devices: None,
|
||||
sampling: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -95,6 +97,7 @@ async fn test_load_default_models_skipped_on_driver_mismatch() {
|
||||
quant: Some("q6k".into()),
|
||||
tensor_parallel: Some(2),
|
||||
devices: None,
|
||||
sampling: None,
|
||||
}];
|
||||
let activation = ActivationTracker::new(&specs);
|
||||
let reason = "host NVIDIA driver/library mismatch (userspace NVML 580.159 vs loaded \
|
||||
@@ -196,6 +199,7 @@ fn qwen_spec() -> ModelSpec {
|
||||
quant: None,
|
||||
tensor_parallel: None,
|
||||
devices: None,
|
||||
sampling: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn test_candle_qwen3_load_unload_lifecycle() {
|
||||
quant: Some(quant),
|
||||
tensor_parallel: None,
|
||||
devices: Some(vec![0]),
|
||||
sampling: None,
|
||||
};
|
||||
|
||||
registry
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{%- set image_count = namespace(value=0) %}
|
||||
{%- set video_count = namespace(value=0) %}
|
||||
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
|
||||
{%- if content is string %}
|
||||
{{- content }}
|
||||
{%- elif content is iterable and content is not mapping %}
|
||||
{%- for item in content %}
|
||||
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
|
||||
{%- if is_system_content %}
|
||||
{{- raise_exception('System message cannot contain images.') }}
|
||||
{%- endif %}
|
||||
{%- if do_vision_count %}
|
||||
{%- set image_count.value = image_count.value + 1 %}
|
||||
{%- endif %}
|
||||
{%- if add_vision_id %}
|
||||
{{- 'Picture ' ~ image_count.value ~ ': ' }}
|
||||
{%- endif %}
|
||||
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
|
||||
{%- elif 'video' in item or item.type == 'video' %}
|
||||
{%- if is_system_content %}
|
||||
{{- raise_exception('System message cannot contain videos.') }}
|
||||
{%- endif %}
|
||||
{%- if do_vision_count %}
|
||||
{%- set video_count.value = video_count.value + 1 %}
|
||||
{%- endif %}
|
||||
{%- if add_vision_id %}
|
||||
{{- 'Video ' ~ video_count.value ~ ': ' }}
|
||||
{%- endif %}
|
||||
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
|
||||
{%- elif 'text' in item %}
|
||||
{{- item.text }}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected item type in content.') }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- elif content is none or content is undefined %}
|
||||
{{- '' }}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected content type.') }}
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
{%- if not messages %}
|
||||
{{- raise_exception('No messages provided.') }}
|
||||
{%- endif %}
|
||||
{%- set reasoning_instructions = '' %}
|
||||
{%- if enable_thinking is undefined or enable_thinking is true %}
|
||||
{%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
|
||||
{%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
|
||||
{{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ '. Supported types are xhigh (default), medium, and low.') }}
|
||||
{%- endif %}
|
||||
{%- if resolved_reasoning_effort == 'xhigh' %}
|
||||
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
|
||||
{%- elif resolved_reasoning_effort == 'low' %}
|
||||
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if tools and tools is iterable and tools is not mapping %}
|
||||
{{- '<|im_start|>system\n' }}
|
||||
{%- if reasoning_instructions %}
|
||||
{{- reasoning_instructions + '\n\n' }}
|
||||
{%- endif %}
|
||||
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
|
||||
{%- for tool in tools %}
|
||||
{{- "\n" }}
|
||||
{{- tool | tojson }}
|
||||
{%- endfor %}
|
||||
{{- "\n</tools>" }}
|
||||
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
|
||||
{%- if messages[0].role == 'system' %}
|
||||
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
||||
{%- if content %}
|
||||
{{- '\n\n' + content }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- else %}
|
||||
{%- if messages[0].role == 'system' %}
|
||||
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
||||
{%- if content %}
|
||||
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + content + '<|im_end|>\n' }}
|
||||
{%- elif reasoning_instructions %}
|
||||
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- elif reasoning_instructions %}
|
||||
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
||||
{%- for message in messages[::-1] %}
|
||||
{%- set index = (messages|length - 1) - loop.index0 %}
|
||||
{%- if ns.multi_step_tool and message.role == "user" %}
|
||||
{%- set content = render_content(message.content, false)|trim %}
|
||||
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
|
||||
{%- set ns.multi_step_tool = false %}
|
||||
{%- set ns.last_query_index = index %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if ns.multi_step_tool %}
|
||||
{{- raise_exception('No user query found in messages.') }}
|
||||
{%- endif %}
|
||||
{%- for message in messages %}
|
||||
{%- set content = render_content(message.content, true)|trim %}
|
||||
{%- if message.role == "system" %}
|
||||
{%- if not loop.first %}
|
||||
{{- raise_exception('System message must be at the beginning.') }}
|
||||
{%- endif %}
|
||||
{%- elif message.role == "user" %}
|
||||
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
|
||||
{%- elif message.role == "assistant" %}
|
||||
{%- set reasoning_content = '' %}
|
||||
{%- if message.reasoning_content is string %}
|
||||
{%- set reasoning_content = message.reasoning_content %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = reasoning_content|trim %}
|
||||
{%- if preserve_thinking is undefined or preserve_thinking is true or loop.index0 > ns.last_query_index %}
|
||||
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>' + message.role + '\n' + content }}
|
||||
{%- endif %}
|
||||
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
|
||||
{%- for tool_call in message.tool_calls %}
|
||||
{%- if tool_call.function is defined %}
|
||||
{%- set tool_call = tool_call.function %}
|
||||
{%- endif %}
|
||||
{%- if loop.first %}
|
||||
{%- if content|trim %}
|
||||
{{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
||||
{%- else %}
|
||||
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- if tool_call.arguments is defined and tool_call.arguments != '' %}
|
||||
{%- for args_name, args_value in tool_call.arguments|items %}
|
||||
{{- '<parameter=' + args_name + '>\n' }}
|
||||
{%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %}
|
||||
{{- args_value }}
|
||||
{{- '\n</parameter>\n' }}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{- '</function>\n</tool_call>' }}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- elif message.role == "tool" %}
|
||||
{%- if loop.previtem and loop.previtem.role != "tool" %}
|
||||
{{- '<|im_start|>user' }}
|
||||
{%- endif %}
|
||||
{{- '\n<tool_response>\n' }}
|
||||
{{- content }}
|
||||
{{- '\n</tool_response>' }}
|
||||
{%- if not loop.last and loop.nextitem.role != "tool" %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- elif loop.last %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected message role.') }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if add_generation_prompt %}
|
||||
{{- '<|im_start|>assistant\n' }}
|
||||
{%- if enable_thinking is defined and enable_thinking is false %}
|
||||
{{- '<think>\n\n</think>\n\n' }}
|
||||
{%- else %}
|
||||
{{- '<think>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
@@ -60,7 +60,9 @@ fn read_f32(path: &Path) -> Vec<f32> {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
|
||||
assert!(bytes.len().is_multiple_of(4), "truncated f32 file {path:?}");
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ fn spec(model_id: &str, tp: Option<u32>, quant: Option<&str>) -> ModelSpec {
|
||||
quant: quant.map(String::from),
|
||||
tensor_parallel: tp,
|
||||
devices: None,
|
||||
sampling: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ fn fixture_dir() -> PathBuf {
|
||||
fn read_f32(path: &Path) -> Vec<f32> {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,27 @@
|
||||
#
|
||||
# Omit only when full precision is genuinely what
|
||||
# you want served.
|
||||
# sampling - operator sampling override (#283). Optional
|
||||
# sub-table `[models.sampling]` with any of
|
||||
# `temperature`, `top_p`, `top_k`.
|
||||
#
|
||||
# Precedence is request > operator > model's
|
||||
# generation_config.json > built-in fallback: this
|
||||
# replaces what the model published, but a caller
|
||||
# that names a value still wins.
|
||||
#
|
||||
# Use when a model's own published sampling is
|
||||
# wrong for the workload you serve. Qwen3.8-27B
|
||||
# publishes temperature = 1.0, which measured a
|
||||
# 20% structural-defect rate on ~2k-token code
|
||||
# generation against 0/60 at <= 0.6 (Fisher exact
|
||||
# one-sided p = 0.0031, #283).
|
||||
#
|
||||
# Like `quant`, this must MATCH the same model's
|
||||
# `[default_models.sampling]` in the host's
|
||||
# neuron.toml — the same two-paths-to-one-model
|
||||
# hazard that made #252 possible.
|
||||
# `script/check-config-consistency.py` gates it.
|
||||
# vram_mb - rough estimate; advisory only, not enforced.
|
||||
# min_devices - GPU count this profile needs. TP profiles use
|
||||
# the same value as the tensor-parallel size.
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
# models.toml — helexa fleet catalogue
|
||||
#
|
||||
# Operator-owned and **tracked in git** (#283). It carries no secrets —
|
||||
# cortex.toml holds the API keys — and CI needs to see it: this file and
|
||||
# `asset/neuron/<host>.toml` both describe how a model is served, and
|
||||
# nothing forced them to agree until `script/check-config-consistency.py`
|
||||
# began gating on it. That gap is what #252 was.
|
||||
#
|
||||
# Deployed to the gateway by `.gitea/workflows/deploy.yml`, which is the
|
||||
# ONLY writer (operator decision 2026-08-25). Editing
|
||||
# /etc/cortex/models.toml on the host by hand is pointless — the next
|
||||
# deploy overwrites it from this file — and dangerous, because until it
|
||||
# does, the fleet is running something no commit describes.
|
||||
#
|
||||
# Fleet shape (sourced from asset/manifest.yml):
|
||||
# beast = 2x RTX 5090 (32 GB each) → tp-2 capable
|
||||
# benjy = 1x RTX 4090 (24 GB) → large single-GPU
|
||||
# quadbrat = 1x RTX 3060 (12 GB) → small / quantised
|
||||
|
||||
# Qwen3-Next single-GPU validation target (Stage 8c). Same architecture
|
||||
# as Qwen3.6-27B (`model_type = "qwen3_5"`, same layer_types pattern,
|
||||
# same Gated DeltaNet hyperparams) but small enough to fit on any
|
||||
# fleet GPU in bf16 (~1.6 GB). Stepping stone — if this answers the
|
||||
# Tbilisi probe correctly, the qwen3_5 code path is right and TP-2 on
|
||||
# Qwen3.6-27B is purely a sharding/collective problem.
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3.5-0.8B"
|
||||
harness = "candle"
|
||||
vram_mb = 2000
|
||||
min_devices = 1
|
||||
min_device_vram_mb = 4000
|
||||
# Illustrative pricing (USD per 1M tokens). helexa has no paying customers
|
||||
# yet; these differ per model purely to validate spend surfaces in clients.
|
||||
cost.input = 0.02
|
||||
cost.output = 0.08
|
||||
|
||||
# Frontier MoE target (epic #84, F2 #93): 80B total / 3B active,
|
||||
# 512 experts / 10 active + shared expert, same Gated DeltaNet hybrid
|
||||
# as the 27B. Weights ~45 GB @ Q4_K_M ISQ under TP-2 (~22.5 GB/GPU) —
|
||||
# beast only, and NOT co-resident with the 27B (cold-swap per F4e #99).
|
||||
# The serving quant belongs HERE as well as on any load request (#252):
|
||||
# a cortex cold-load builds its spec from this file, and without it the
|
||||
# model loads unquantised — ~149 GB, which cannot fit beast at all.
|
||||
# tensor_parallel is derived from min_devices, so only quant is needed.
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3-Coder-Next"
|
||||
harness = "candle"
|
||||
quant = "q4k"
|
||||
vram_mb = 46000
|
||||
min_devices = 2
|
||||
min_device_vram_mb = 24000
|
||||
# Shares the big-node class with the flagship: either may cold-swap the
|
||||
# other off beast (#99), in both directions, so a comparison between
|
||||
# them can move traffic each way on demand.
|
||||
residency_priority = 300
|
||||
# Illustrative pricing (USD per 1M tokens) — frontier tier.
|
||||
cost.input = 0.40
|
||||
cost.output = 1.60
|
||||
|
||||
# Thinking sibling of Coder-Next (same 80B-A3B arch) — on beast for the
|
||||
# F3 A/B evaluation (#94); one of the two frontier variants competes for
|
||||
# residency, the other stays cold-loadable.
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3-Next-80B-A3B-Thinking"
|
||||
harness = "candle"
|
||||
quant = "q4k"
|
||||
vram_mb = 46000
|
||||
min_devices = 2
|
||||
min_device_vram_mb = 24000
|
||||
# Shares the big-node class with the flagship: either may cold-swap the
|
||||
# other off beast (#99), in both directions, so a comparison between
|
||||
# them can move traffic each way on demand.
|
||||
residency_priority = 300
|
||||
# Illustrative pricing (USD per 1M tokens) — frontier tier.
|
||||
cost.input = 0.40
|
||||
cost.output = 1.60
|
||||
|
||||
# Tensor-parallel target. Beast is the only neuron with two big GPUs.
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3.6-27B"
|
||||
harness = "candle"
|
||||
# REQUIRED, not cosmetic (#252). Without it a cortex cold-load builds an
|
||||
# unquantised request: the model loads bf16, leaves ~2 GB free per card
|
||||
# against a ~3.9 GB prefill requirement, reports `loaded`, and then fails
|
||||
# every request. Activation from neuron's default_models has always said
|
||||
# q6k; this is the same fact on the path cortex uses.
|
||||
quant = "q6k"
|
||||
# Measured q6k residency across the pair (17.9 + 22.9 GB), not the 51.7 GB
|
||||
# bf16 checkpoint size. The old 54000 described a model we do not serve.
|
||||
vram_mb = 42000
|
||||
min_devices = 2
|
||||
min_device_vram_mb = 24000
|
||||
pinned_on = ["beast"]
|
||||
# The big-node class. Above the everyday class, so image generation can
|
||||
# never take beast however idle the flagship is; equal to the frontier
|
||||
# tier, so those two swap freely. `pinned_on` here is affinity only --
|
||||
# it no longer implies immunity, so this number is what protects it.
|
||||
residency_priority = 300
|
||||
# Illustrative pricing (USD per 1M tokens) — top tier, the 27B flagship.
|
||||
cost.input = 0.30
|
||||
cost.output = 1.20
|
||||
# DELIBERATE, TEMPORARY operator override (issue #67): unblocks the opencode
|
||||
# helexa-provider implementation by giving /v1/models a limit to consume on
|
||||
# beast. Mirrors the enforced NEURON_MAX_PROMPT_TOKENS=131072 there.
|
||||
# To be REMOVED once neuron self-derives these — its removal is the
|
||||
# acceptance test that the computed values replace this hand-set one.
|
||||
#limit.context = 131072
|
||||
#limit.input = 122880
|
||||
#limit.output = 8192
|
||||
|
||||
# Qwen3.8-27B (#251). Architecturally identical to the 3.6-27B — same
|
||||
# model_type, same layer counts, same vision tower; the two configs
|
||||
# differ only in `transformers_version` — so it serves through the same
|
||||
# qwen3_5 TP path with the same KV arithmetic.
|
||||
#
|
||||
# Same residency class as the 3.6-27B *deliberately*: equal rank is what
|
||||
# lets the two displace each other in BOTH directions, so whichever is
|
||||
# asked for takes beast and the other can take it back. A higher number
|
||||
# here would let this one claim the node permanently and make the
|
||||
# comparison between them impossible.
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3.8-27B"
|
||||
harness = "candle"
|
||||
quant = "q6k"
|
||||
vram_mb = 42000
|
||||
min_devices = 2
|
||||
min_device_vram_mb = 24000
|
||||
pinned_on = ["beast"]
|
||||
residency_priority = 300
|
||||
cost.input = 0.30
|
||||
cost.output = 1.20
|
||||
|
||||
# Sampling override (#283). The model publishes `temperature = 1.0` in
|
||||
# its generation_config.json, and honouring that (#272) is correct by
|
||||
# default — but measured on this stack it produced a 20% structural-
|
||||
# defect rate on ~2k-token code generation (4/20 samples failed
|
||||
# `node --check` or dropped required exports) against 0/60 at
|
||||
# temperature <= 0.6. Fisher exact one-sided p = 0.0031.
|
||||
#
|
||||
# Extrapolated at constant hazard that is a ~79% chance of at least one
|
||||
# defect in a 15k-token file, which is why this model had never once
|
||||
# completed the tower-defence task two cloud models did in ten minutes.
|
||||
#
|
||||
# 0.6 is Qwen's own published *inference* guidance for thinking mode —
|
||||
# a number that lives in a model card no machine can read, which is
|
||||
# exactly why it has to be set here by hand.
|
||||
#
|
||||
# Precedence is request > operator > model, so a caller that names a
|
||||
# temperature still gets it. MUST match asset/neuron/beast.toml;
|
||||
# script/check-config-consistency.py fails CI if it drifts.
|
||||
[models.sampling]
|
||||
temperature = 0.6
|
||||
|
||||
# Mid-size dense bf16 — fits on benjy (24 GB) or beast (32 GB ea.).
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3-8B"
|
||||
harness = "candle"
|
||||
vram_mb = 18000
|
||||
min_devices = 1
|
||||
min_device_vram_mb = 16000
|
||||
# Everyday class, shared with Z-Image — they take turns on benjy.
|
||||
residency_priority = 200
|
||||
# Illustrative pricing (USD per 1M tokens) — mid tier (helexa/balanced).
|
||||
cost.input = 0.10
|
||||
cost.output = 0.40
|
||||
|
||||
# Single-GPU dense bf16 — fits anywhere with ≥4 GB.
|
||||
[[models]]
|
||||
id = "Qwen/Qwen3-1.7B"
|
||||
harness = "candle"
|
||||
vram_mb = 4000
|
||||
min_devices = 1
|
||||
min_device_vram_mb = 4000
|
||||
# Illustrative pricing (USD per 1M tokens) — small tier (helexa/small).
|
||||
cost.input = 0.04
|
||||
cost.output = 0.16
|
||||
|
||||
# Small GGUF quantised — runs on quadbrat (12 GB) without breaking a sweat.
|
||||
[[models]]
|
||||
id = "unsloth/Qwen3-0.6B-GGUF"
|
||||
harness = "candle"
|
||||
quant = "Q4_K_M"
|
||||
vram_mb = 500
|
||||
min_devices = 1
|
||||
min_device_vram_mb = 4000
|
||||
# Illustrative pricing (USD per 1M tokens) — cheapest, the tiny GGUF.
|
||||
cost.input = 0.015
|
||||
cost.output = 0.06
|
||||
|
||||
# -- Tier aliases ------------------------------------------------------------
|
||||
# Clients can request inference against an alias and cortex transparently
|
||||
# routes to the concrete model id below. Useful so client code can pick a
|
||||
# latency/quality tier without hardcoding specific model ids — operators
|
||||
# can swap the targets here without touching client config.
|
||||
[aliases]
|
||||
"helexa/small" = "Qwen/Qwen3-1.7B"
|
||||
"helexa/balanced" = "Qwen/Qwen3-8B"
|
||||
"helexa/large" = "Qwen/Qwen3.8-27B"
|
||||
"helexa/image" = "Tongyi-MAI/Z-Image-Turbo"
|
||||
|
||||
# Text-to-image (#197/#203): Z-Image-Turbo, 6B S3-DiT + Qwen3-4B text
|
||||
# encoder (runs on host CPU by default — see [harness.candle.image] in
|
||||
# neuron.toml) + VAE. BF16 DiT+VAE resident ≈ 14.6 GB; fits benjy's
|
||||
# 4090 via cold-swap with Qwen3-8B (operator decision 2026-07-29,
|
||||
# #197). Not default-activated anywhere — cortex cold-loads it on
|
||||
# first images request and the evictor swaps it back out under text
|
||||
# pressure. Metered in megapixel-steps (#202), not tokens; the token
|
||||
# cost fields deliberately stay unset.
|
||||
[[models]]
|
||||
id = "Tongyi-MAI/Z-Image-Turbo"
|
||||
harness = "candle"
|
||||
vram_mb = 15000
|
||||
min_devices = 1
|
||||
min_device_vram_mb = 20000
|
||||
# Everyday class, shared with Qwen3-8B: an image request cold-swaps the
|
||||
# 8B off benjy and the next text request swaps it straight back. Below
|
||||
# the big-node class, so it can never take beast -- its device
|
||||
# constraints alone would otherwise let it land there.
|
||||
residency_priority = 200
|
||||
@@ -110,6 +110,33 @@ name = "candle"
|
||||
# harness = "candle"
|
||||
# quant = "Q4_K_M"
|
||||
# devices = [0]
|
||||
#
|
||||
# Operator sampling override (#283) — optional, per model.
|
||||
#
|
||||
# By default neuron honours what the model published in its
|
||||
# `generation_config.json`; that is the model authors' stated intent and
|
||||
# is right most of the time. It is not always right for the workload you
|
||||
# serve: Qwen3.8-27B publishes `temperature = 1.0`, which measured a 20%
|
||||
# structural-defect rate on ~2k-token code generation against 0/60 at
|
||||
# <= 0.6 (Fisher exact one-sided p = 0.0031).
|
||||
#
|
||||
# Precedence is request > operator > model > built-in fallback, so a
|
||||
# caller that names a temperature still gets what it asked for; this only
|
||||
# changes what a caller who names nothing receives. Each field overlays
|
||||
# independently — setting `temperature` alone leaves the model's `top_p`
|
||||
# and `top_k` in force.
|
||||
#
|
||||
# Deliberately explicit. neuron will never inspect a model and guess a
|
||||
# "better" temperature: a guesser is a footgun, a number you wrote is not.
|
||||
#
|
||||
# If cortex can also cold-load this model, the same values must appear in
|
||||
# its `models.toml` entry — `script/check-config-consistency.py` gates on
|
||||
# that, because two files describing one model is how #252 happened.
|
||||
#
|
||||
# [default_models.sampling]
|
||||
# temperature = 0.6
|
||||
# top_p = 0.95
|
||||
# top_k = 20
|
||||
|
||||
# Text-to-image serving (#198). Applies to diffusers-format image
|
||||
# models (Z-Image family). All fields optional.
|
||||
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when the two files describing one model disagree.
|
||||
|
||||
`models.toml` (cortex's catalogue, used for cold loads) and
|
||||
`asset/neuron/<host>.toml` (`[[default_models]]`, used for models a host
|
||||
holds resident) both describe *how a model is served*. Nothing forced
|
||||
them to agree, and #252 is what that costs: models.toml omitted `quant`,
|
||||
so a cortex cold-load served bf16 and could not prefill, while the same
|
||||
model was fine when the host loaded it from its own config.
|
||||
|
||||
#283 adds a second such field — the operator sampling override — so this
|
||||
check exists before the second instance of the bug rather than after it.
|
||||
|
||||
Checked, per model id present in both files:
|
||||
|
||||
* ``quant`` — the #252 field
|
||||
* ``sampling`` — the #283 field, compared field-by-field
|
||||
|
||||
A model in only one file is not an error: plenty of catalogue entries are
|
||||
never resident anywhere, and a host may hold a model the catalogue does
|
||||
not list. Only *disagreement* fails.
|
||||
|
||||
Usage::
|
||||
|
||||
check-config-consistency.py # repo defaults
|
||||
check-config-consistency.py --models /etc/cortex/models.toml \\
|
||||
--neuron asset/neuron/beast.toml
|
||||
|
||||
Exit status is 0 when consistent, 1 on any disagreement, 2 on bad input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
||||
print("error: needs Python 3.11+ for tomllib", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Fields that must agree wherever both files mention the same model.
|
||||
# Extend this list whenever a serving-critical field lands in both.
|
||||
SCALAR_FIELDS = ["quant"]
|
||||
TABLE_FIELDS = ["sampling"]
|
||||
|
||||
|
||||
def load(path: str) -> dict:
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
except FileNotFoundError:
|
||||
print(f"error: {path} not found", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
print(f"error: {path} is not valid TOML: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def catalogue_entries(path: str) -> dict[str, dict]:
|
||||
"""model id -> profile, from models.toml's `[[models]]`."""
|
||||
return {m["id"]: m for m in load(path).get("models", []) if "id" in m}
|
||||
|
||||
|
||||
def resident_entries(path: str) -> dict[str, dict]:
|
||||
"""model id -> spec, from a neuron config's `[[default_models]]`."""
|
||||
return {
|
||||
m["model_id"]: m
|
||||
for m in load(path).get("default_models", [])
|
||||
if "model_id" in m
|
||||
}
|
||||
|
||||
|
||||
def label(path: str) -> str:
|
||||
"""Repo-relative when the file is in the repo, basename otherwise.
|
||||
|
||||
Deploy runs pass paths outside the tree (a models.toml fetched off
|
||||
the gateway), and `../../../..`-prefixed relpaths make the diff
|
||||
unreadable in a CI log.
|
||||
"""
|
||||
rel = os.path.relpath(path, REPO)
|
||||
return rel if not rel.startswith("..") else os.path.basename(path)
|
||||
|
||||
|
||||
def compare(model_id: str, cat: dict, res: dict, catalogue_path: str, neuron_path: str):
|
||||
"""Yield one human-readable line per disagreement."""
|
||||
cat_name = label(catalogue_path)
|
||||
neu_name = label(neuron_path)
|
||||
|
||||
for field in SCALAR_FIELDS:
|
||||
a, b = cat.get(field), res.get(field)
|
||||
# Absent on one side is "unspecified", not "different" -- but for
|
||||
# quant specifically that IS the #252 bug, so say so loudly.
|
||||
if a != b:
|
||||
if a is None or b is None:
|
||||
detail = (
|
||||
" (one side leaves it unset -- this is exactly #252: "
|
||||
"an unset quant makes a cold load serve bf16)"
|
||||
if field == "quant"
|
||||
else " (one side leaves it unset)"
|
||||
)
|
||||
else:
|
||||
detail = ""
|
||||
yield (
|
||||
f"{model_id}: {field} differs\n"
|
||||
f" {cat_name}: {a!r}\n"
|
||||
f" {neu_name}: {b!r}{detail}"
|
||||
)
|
||||
|
||||
for field in TABLE_FIELDS:
|
||||
a = cat.get(field) or {}
|
||||
b = res.get(field) or {}
|
||||
if a == b:
|
||||
continue
|
||||
keys = sorted(set(a) | set(b))
|
||||
rows = "\n".join(
|
||||
f" {k}: {cat_name}={a.get(k)!r} {neu_name}={b.get(k)!r}"
|
||||
for k in keys
|
||||
if a.get(k) != b.get(k)
|
||||
)
|
||||
yield (
|
||||
f"{model_id}: {field} differs\n"
|
||||
f" a model must sample the same way whether cortex cold-loads it\n"
|
||||
f" or the host holds it resident:\n{rows}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--models", default=os.path.join(REPO, "models.toml"),
|
||||
help="path to models.toml (default: repo root)")
|
||||
ap.add_argument("--neuron", nargs="*", default=None,
|
||||
help="neuron config(s) (default: asset/neuron/*.toml)")
|
||||
args = ap.parse_args()
|
||||
|
||||
neuron_paths = args.neuron
|
||||
if not neuron_paths:
|
||||
neuron_paths = sorted(glob.glob(os.path.join(REPO, "asset", "neuron", "*.toml")))
|
||||
if not neuron_paths:
|
||||
print("error: no neuron configs found", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
catalogue = catalogue_entries(args.models)
|
||||
problems: list[str] = []
|
||||
compared = 0
|
||||
|
||||
for npath in neuron_paths:
|
||||
for model_id, spec in resident_entries(npath).items():
|
||||
profile = catalogue.get(model_id)
|
||||
if profile is None:
|
||||
# A host may hold a model the catalogue does not list.
|
||||
# Not an error, but worth seeing -- cortex cannot route
|
||||
# to what it has no profile for.
|
||||
print(f"note: {model_id} is resident in {label(npath)} "
|
||||
f"but absent from {label(args.models)}")
|
||||
continue
|
||||
compared += 1
|
||||
problems.extend(compare(model_id, profile, spec, args.models, npath))
|
||||
|
||||
if problems:
|
||||
print(f"\nconfig inconsistency: {len(problems)} disagreement(s)\n",
|
||||
file=sys.stderr)
|
||||
for p in problems:
|
||||
print(f" {p}\n", file=sys.stderr)
|
||||
print("Two config files describe one model. Make them agree, or the\n"
|
||||
"model is served one way on a cold load and another when\n"
|
||||
"resident -- see #252 and #283.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"config consistency OK: {compared} model/host pairing(s) agree "
|
||||
f"on {', '.join(SCALAR_FIELDS + TABLE_FIELDS)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+47
-23
@@ -138,14 +138,28 @@ if ! ssh "${bench_host}" '
|
||||
echo " failed to prepare ${bench_host} for helexa-bench"
|
||||
fi
|
||||
|
||||
# Push application config to the fleet. The deploy workflow is
|
||||
# scoped to package install + service restart; config changes ride
|
||||
# along with this script instead, since:
|
||||
# - cortex.toml and models.toml are gitignored (operator-owned, may
|
||||
# include secrets), so CI never sees them
|
||||
# - asset/neuron/<short>.toml is tracked but iterating locally is
|
||||
# faster than pushing a commit and waiting for build-prerelease
|
||||
# to roll over
|
||||
# Push the one config CI cannot own.
|
||||
#
|
||||
# Operator decision 2026-08-25 (#283): the automated deployment path is
|
||||
# the ONLY path. `.gitea/workflows/deploy.yml` now ships models.toml to
|
||||
# the gateway, asset/neuron/<short>.toml to each neuron, and
|
||||
# asset/helexa-bench/<host>.toml to the bench host — digest-compared,
|
||||
# restarting the service when the file changes.
|
||||
#
|
||||
# Those three syncs USED to live here, and that is exactly how the fleet
|
||||
# drifted: benjy and quadbrat were still running pre-#197 configs months
|
||||
# after the tracked assets had moved on, because deploying them meant
|
||||
# remembering to re-run this script. A hand-sync that exists "for local
|
||||
# iteration" is a footgun that silently overwrites, or silently fails to
|
||||
# overwrite, whatever CI just placed. Do not add one back.
|
||||
#
|
||||
# What remains is cortex.toml, which carries the inference API keys
|
||||
# ([[entitlements.keys]]) and therefore cannot be tracked in git for CI
|
||||
# to read. It is the last hand-synced config on the fleet and the last
|
||||
# place this drift can still happen; closing it needs the secrets to
|
||||
# come from Gitea secrets at deploy time rather than from a working
|
||||
# copy.
|
||||
#
|
||||
# Missing source files are skipped silently — re-run after editing.
|
||||
# Optional 4th/5th args set the destination group and mode. Configs that
|
||||
# carry secrets (API keys, JWT signing keys, SMTP/DB passwords) must land
|
||||
@@ -174,23 +188,15 @@ sync_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==> ${cortex_host}: syncing gateway configs"
|
||||
# cortex.toml holds the inference API keys ([[entitlements.keys]]).
|
||||
echo "==> ${cortex_host}: syncing gateway secrets config"
|
||||
# cortex.toml holds the inference API keys ([[entitlements.keys]]), so it
|
||||
# is gitignored and CI cannot see it. The only config still synced by
|
||||
# hand — everything else is deploy.yml's.
|
||||
sync_config "${cortex_host}" "${repo_path}/cortex.toml" /etc/cortex/cortex.toml cortex 0640
|
||||
sync_config "${cortex_host}" "${repo_path}/models.toml" /etc/cortex/models.toml
|
||||
|
||||
for neuron_host in "${neuron_hosts[@]}"; do
|
||||
short="${neuron_host%%.*}"
|
||||
echo "==> ${neuron_host}: syncing per-host neuron config"
|
||||
sync_config "${neuron_host}" \
|
||||
"${repo_path}/asset/neuron/${short}.toml" \
|
||||
/etc/neuron/neuron.toml
|
||||
done
|
||||
|
||||
echo "==> ${bench_host}: syncing bench config"
|
||||
sync_config "${bench_host}" \
|
||||
"${repo_path}/asset/helexa-bench/${bench_host%%.*}.toml" \
|
||||
/etc/helexa-bench/helexa-bench.toml
|
||||
# models.toml, asset/neuron/<short>.toml and asset/helexa-bench/<host>.toml
|
||||
# are deliberately NOT synced here. deploy.yml owns them; see the note
|
||||
# above sync_config().
|
||||
|
||||
# ── bench UI: public nginx vhost on the gateway (bench.helexa.ai) ──────
|
||||
# The built SPA is rsynced to the webroot by deploy.yml; nginx serves it
|
||||
@@ -600,6 +606,24 @@ if ! ssh "${svc_host}" '
|
||||
echo " failed to prepare ${svc_host}"
|
||||
fi
|
||||
|
||||
# ── STILL HAND-SYNCED, and tracked as such ────────────────────────────
|
||||
# Everything below this line is a config CI does not yet own, which by
|
||||
# the 2026-08-25 decision (#283) makes it a footgun still loaded. Two
|
||||
# distinct reasons, needing two distinct fixes:
|
||||
#
|
||||
# * helexa-router.toml / helexa-upstream.toml / helexa-angels.toml are
|
||||
# gitignored because they carry JWT secrets, SMTP passwords and
|
||||
# database URLs. Same class as cortex.toml: CI cannot read them until
|
||||
# the secrets come from Gitea secrets and the file is rendered at
|
||||
# deploy time.
|
||||
# * asset/searxng/{settings.yml,searxng.container,helexa-searxng.xml}
|
||||
# ARE tracked and could move to deploy.yml today but for one thing:
|
||||
# asset/sudoers.d/gallumbits-host.conf grants gitea_ci no rsync at
|
||||
# all, so the deploy would fail until the grants land on the host.
|
||||
# Grants first, deploy step second — wiring it the other way round
|
||||
# breaks every deploy in between.
|
||||
#
|
||||
# See the tracking issue; do not close the gap by adding syncs here.
|
||||
echo "==> ${svc_host}: syncing service configs"
|
||||
sync_config "${svc_host}" "${repo_path}/helexa-router.toml" /etc/helexa-router/helexa-router.toml
|
||||
# helexa-upstream.toml holds the session JWT secret, the SMTP submission
|
||||
|
||||
Reference in New Issue
Block a user