feat(demos): ai-gateway demo with tiered token budgets and span-derived dashboards - #23
Draft
Ladas wants to merge 1 commit into
Draft
feat(demos): ai-gateway demo with tiered token budgets and span-derived dashboards#23Ladas wants to merge 1 commit into
Ladas wants to merge 1 commit into
Conversation
Ladas
force-pushed
the
feat/ai-gateway-demo
branch
12 times, most recently
from
September 8, 2026 04:40
abc4d21 to
7ef506c
Compare
Adds demos/ai-gateway: the Praxis experimental gateway on its own KIND
cluster, in front of a real local model (Ollama), enforcing per-tier token
budgets, with dashboards built from both Prometheus counters and Tempo span
metrics. Ports are offset from otel-benchmark so both demos run at once.
Enables the token-rate-limit-filter cargo feature on the experimental
server. This image exists to expose feature-gated upstream work and
previously enabled only one of the four experimental AI filters.
The dashboards are built around a distinction that is easy to get wrong.
Prometheus counters are incremented on every request and are unaffected by
trace sampling; span metrics are derived only from sampled traces. So
Prometheus owns counts, rates and budget state, while span metrics own
latency distributions and the per-filter breakdown. The weighted-cost panel
takes its quantile from span metrics and its request rate from
praxis_http_requests_total -- multiplying two sampled series would
under-report by exactly the sampling factor while looking plausible. A
cross-check panel divides one plane by the other, which should approximate
the configured sampling rate; a drop means spans are being lost rather than
sampled.
Documents four things that are easy to get wrong and produce
plausible-but-wrong results:
- token_rate_limit must be declared before token_count. Response hooks run
in reverse declared order, and it reconciles against the token.total
metadata token_count writes. Declared after, it charges every request at
its flat estimate and the metrics look reasonable.
- Ollama stays on its default loopback binding. Containers reach it via
host.docker.internal, which the container runtime proxies from the host
side, so OLLAMA_HOST=0.0.0.0 needlessly exposes the model server.
- The rate-limit demo uses a small model deliberately. The limiter decides
at admission, before the upstream call, but the burst must land inside
the sliding window. A 27B model takes 74-78s per request under
concurrency, so the budget would age out faster than it was consumed and
the limit would never be reached.
- reserved_tokens is a flat estimate well above real usage, so the tier
throttles at roughly a third of its nominal budget. That is the current
upstream milestone's design, pending configurable estimation (ai#121).
All output in the README was captured from real runs.
Signed-off-by: Ladislav Smola <lsmola@redhat.com>
Ladas
force-pushed
the
feat/ai-gateway-demo
branch
from
September 8, 2026 06:48
7ef506c to
d3dfc37
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Base
#13 has merged, so this branch is rebased directly onto
mainand its diff isexactly its own 16 files — everything under
demos/ai-gateway/plus enablingthe
token-rate-limit-filterfeature on the experimental server.Summary
demos/ai-gateway/— the experimental gateway in front of a real localmodel (Ollama), enforcing per-tier token budgets, with dashboards built
from both Prometheus counters and Tempo span metrics. Runs on its own KIND
cluster with ports offset from
otel-benchmark, so both demos can be up atonce.
Also enables the
token-rate-limit-filtercargo feature onpraxis-experimental-server. This image exists to expose feature-gatedupstream work, and previously enabled only one of the four experimental AI
filters.
otel-benchmark(#13)ai-gateway(this)The rate limit is real, not rigged
Two tiers matched on a header, with
reserved_tokens: 200— a realisticestimate for a short chat completion, not a number tuned to trip on request
six:
premiumX-Tier: premiumfreescripts/rate-limit-demo.shsends the same 40-request burst as each tier.Captured from a real run:
25 admitted is exactly 5,000 / 200. Identical load, different outcome.
Two telemetry planes, and why it matters
This is the design constraint the dashboards are built around.
praxis_*)traces_spanmetrics_*)Random sampling is unbiased for percentiles, so span-derived latency is
trustworthy even at
sampling_rate: 0.1. Counts are not — they are low byexactly the sampling factor.
This demo sets
sampling_rate: 1.0explicitly. It pushes a handful of requestsper minute through a local model, and at 0.1 the span panels would be built
from a couple of traces. Leaving the field unset would have reached the same
sampler by accident (the default is
ParentBased(AlwaysOn)) while the READMEtalked about a sampled fraction, so the choice is now written down with its
reason.
../otel-benchmarkruns at 0.1 under ~500 RPS, which is where the twoplanes visibly diverge.
So the "Weighted Filter Cost" panel takes its quantile from span metrics and
its request rate from
praxis_http_requests_total. Multiplying two sampledseries would under-report 10x while looking entirely plausible — the first
version of this dashboard did exactly that.
A Sampling Ratio Cross-Check panel divides one plane by the other. It
should approximate the configured sampling rate — ~1.0 here, measured at 1.06
over a controlled 10-request window; a drop means spans are being
lost rather than sampled, and it plots
otelcol_exporter_send_failed_spansalongside so the two are distinguishable.
Dashboards
Per-filter latency is the panel a trace waterfall cannot give you: praxis
emits one span per filter per phase, so
span_namebecomes the breakdown keyacross all requests rather than one at a time.
Four traps that produce plausible-but-wrong results, and what the demo does instead
Trap: declaring
token_rate_limitaftertoken_count. Responsehooks run in reverse declared order, and it reconciles against the
token.totalmetadatatoken_countwrites. Declared after, it chargesevery request at its flat estimate — the metrics look reasonable and are
wrong. The demo declares it before. Verified: correct ordering gives
estimated 300 / actual 279 / refunded 22 / overage 1; wrong ordering givesactual == estimated.Trap:
OLLAMA_HOST=0.0.0.0. It is the obvious first guess for lettinga container reach the host, and it publishes the model server to the
whole local network. It is also unnecessary: a KIND pod reaches
host.docker.internal, which the container runtime proxies from the hostside, with Ollama on plain loopback. The demo leaves Ollama on its
default
127.0.0.1binding — verified from inside a pod.Trap: a slow model makes the limit unreachable. The limiter decides at
admission, before the upstream call, so the model is irrelevant to what is
demonstrated — but the burst must land inside the sliding window.
qwen3.8:27btakes 74-78s per request under concurrency (1.7sidle), so 40 requests span ~12 minutes and the budget ages out faster than
it is consumed.
qwen3.5:0.8banswers in ~0.3s warm. The rate-limitstep uses the small model; the quality step (7) keeps
qwen3.8:27b.Trap: reading
budget / reserved_tokensas the effective limit. It isnot, because reservations reconcile: measured over 110 requests,
estimated 22,000 / actual 7,511 / refunded 14,489, or ~68 real tokenseach. The refund lands quickly, so the sustained limit follows real usage
(~70 requests/min on a 5,000-token free tier), not the ~25 that
5,000 / 200suggests. What the estimate bounds is concurrency: everyin-flight request holds 200 tokens, so 25 simultaneous requests exhaust the
budget on reservations alone while the same 25 spread out cost ~1,700
tokens. The demo states the measured limit, drives enough traffic to
actually reach it, and charts estimated-vs-actual-vs-refunded so the gap is
visible.
Caveats
This is a demo configuration, and two
insecure_optionssay so:allow_public_admin: truebinds the admin listener to the pod IP soPrometheus can scrape
/metrics. That listener also serves/api/log-level(PUT/DELETE) and/api/kv, so every pod in the clustercan change the gateway's log level. Fine on a throwaway KIND cluster; a
shared cluster needs a NetworkPolicy on
:9901. Metrics on a port separatefrom the mutating admin API is the proper fix and is worth an upstream
issue — praxis does not offer it today.
allow_private_endpoints: truedisables SSRF / DNS-rebinding hardening sothe gateway can reach
host.docker.internal. Drop it for a public upstream.token_rate_limitis experimental and its parent proposal is notaccepted (ai#121, open questions in ai#796). The config surface may
change.
It does not authenticate. A header-matched rule trusts whatever set the
header, so a real deployment needs an auth filter populating
X-Tierandstripping client-supplied copies (grid#101).
The
memorybackend is per-replica;backend.kind: valkeyshares onebudget across replicas.
Review fixes in the latest push
Six of these produced dashboards or scripts that looked fine and were not:
processor emits the service dimension as
service, notservice_name, sonine panels across two dashboards matched zero series and showed their
"no span metrics yet" placeholder forever. Verified against the live series:
service,span_kind,span_name,status_code. Fixed; 22 span names nowresolve.
forge.yamlscraped
otel-collector.otel.svc:8888with a comment explaining thatwithout it span loss is silent — but this demo's collector manifest was
copied from before that fix, so the target was down from the start. Now up.
wait -nsilently defeated the concurrency cap. It is bash 4.3+, macOSships 3.2, and
|| trueswallowed the error, so all 40 requests fired atonce. Replaced with
xargs -P.qwen3.8:27b, while the rate-limit scriptdefaults to
qwen3.5:0.8b; Ollama's HTTP API does not auto-pull, so everyadmitted request would have 404'd.
memory_limiterand theexporter="otlp/tempo"filter from feat: add OTel observability benchmark demo #13, plusfeat: add OTel observability benchmark demo #13's Span Export Failures panel, which this demo's copy predated.
execsteps name the kubectl context; thepraxis.yamlheader comment nolonger points at benchmark scripts this demo does not have; the README gained
the forge install command;
.gitignoreuses onedemos/*/.forge/glob.Test plan
praxis-forge upcreatesai-gw-localalongsideotel-bench-localhost.docker.internaldenied{rule="free"} 15, 22 span-metric seriescargo check --locked,make lint,make test, taplo, markdownlintmainafter feat: add OTel observability benchmark demo #13 merged; 16 files, 0 overlappraxis-proxy/forge#16 (
extraPortMappings) and #13 have both merged, so thisbranch has no open dependencies — forge installs from
main.