fix(bifrost): give the gateway a real cost, and a file to configure it - #281
fix(bifrost): give the gateway a real cost, and a file to configure it#281elronbandel wants to merge 3 commits into
Conversation
bifrost prices every call from its bundled pricing.json, keyed on the model
name. Ours never matched: the handles we route are the upstream proxy's own
(`azure/gpt-5.5`, `aws/claude-opus-5`, `rits/google/gemma-4-31B`), not
litellm's public catalog names. The lookup missed and bifrost wrote
gen_ai.usage.cost=0 — on every one of the ~2,100 provider-call spans in the
results store, which is why the dashboard's cost column was blank.
The upstream does know the cost; it returns `x-litellm-response-cost` on every
response. But that is an HTTP header, and bifrost's span carries the cost it
computed itself, not one read off the wire — and its otel plugin has no
header-capture knob. So rather than teach it to forward a header, give it the
rate: a global wildcard `governance.pricing_overrides` entry makes its own
arithmetic come out right.
One flat rate covers every model because the upstream bills one. Probing it
with gpt-5.4 / gpt-5.5 / gpt-4.1 / gpt-5.6-sol / claude-opus-5 /
claude-sonnet-5 / claude-haiku-4-5 / gemini-3-flash / gemma-4-31B returns the
same cost for the same token counts. Solving two samples gives $2.50/1M in and
$15.00/1M out, and that predicts `x-litellm-response-cost` to within 1e-9 on
every model tried. Overridable via EVAL_COST_INPUT_PER_TOKEN /
EVAL_COST_OUTPUT_PER_TOKEN for when the contract changes.
Verified end to end against the real upstream: span cost now equals the header
exactly (0.000265, 0.0003325, 0.00093 over three calls). The dashboard needs no
change — it already reads `gen_ai.usage.cost`. This fixes new runs only;
existing traces keep their zeros (their token counts are intact, so history
could be back-computed at the same rates if we want it).
Tests, per gateways/RULES.md rule 6 (OTel emission is covered per flavor):
* otel_bifrost_span_cost_is_nonzero asserts the attribute is populated. Not
a cost SLO (rule 12 forbids those) — no threshold, just "not zero".
* EVAL_MODEL in the fixtures was `openai/azure/gpt-5.4`, which violates the
bifrost/litellm env contract (a bare handle; the wire comes from the
inbound path). The prefixed string went upstream verbatim and 403'd, so
all eight upstream_{bifrost,litellm}_* tests and both bifrost OTel tests
were failing before this change. portkey's contract is the opposite — it
requires <provider>/<model> and strips the first segment — so the default
is now per-flavor via eval_model(). The full `--ignored` suite passes
(15/15); it did not before.
* start_pod_with_otel named its collector `otelcol-<nanos>`, so two OTel
tests entering on the same clock tick collided on the container name.
Added a sequence counter.
Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
The flat upstream rate is right for our litellm proxy, which bills every
model the same, but the gateway image ships standalone — anyone pointing
it at a provider with real per-model pricing had no way to say so short
of rebuilding.
`EVAL_PRICING_FILE` (default /opt/gateway/data/pricing.tsv, absent from
the image) is read at startup and rendered into one `exact`
pricing_override per model, layered alongside the wildcard rather than
replacing it — so an incomplete list falls back to EVAL_COST_* instead
of pricing at 0, which was the original bug. A malformed line exits 2
with the line number (rule 22).
Two traps are load-bearing and documented in the file format, the
comments, and the assertions:
* bifrost's `exact` match sees the BARE requested model
(`azure/gpt-5.5`), not the provider-prefixed key it uses to index
its own pricing.json (`openai/azure/gpt-5.5`). A prefixed pattern
matches nothing and silently leaves the model on the fallback.
* prices are per token, not per million.
Whitespace-separated rather than JSON because the alpine image has
neither jq nor python, and it must run under plain `docker run`
(rule 20) — hence awk.
pricing.tsv.example is copied in as documentation only; `start` never
reads it, so no price is baked into the image (rule 1).
Tests: a static check that the example parses the way `start` reads it
(3 fields, per-token magnitude, no wire prefix), plus two runtime tests
that a mounted file's rate wins for a listed model and that an unlisted
one stays on the fallback.
Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
Prices depend on provider, contract, account, and they change — so the image should not contain one. The previous commit left $2.50/$15 as a shell default in `start`, which made our IBM proxy's rate a property of the gateway image for everybody who runs it. Now nothing is baked. Both knobs are optional and independent: EVAL_COST_*_PER_TOKEN flat rate for every model, as a `*` wildcard EVAL_PRICING_FILE per-model rates, one `exact` override each Set both and the file wins for the models it names while the flat rate catches the rest. Set neither and we render no `pricing_overrides` at all, leaving bifrost's own bundled-catalog lookup exactly as it behaves out of the box — the unconfigured path is a working deployment, not a boot failure, so it warns rather than exits (models absent from that catalog report cost 0, which is otherwise invisible until someone reads a blank cost column). Two misconfigurations now fail loud instead of pricing half the bill at 0 or silently applying nothing (rule 22): one of the two flat knobs without the other, and an EVAL_PRICING_FILE path that doesn't exist — a typo in `-v` previously booted fine. Our own rate moves to deploy/values-openshift.yaml via the chart's existing gatewayExtraEnv hook, where changing it is an edit to a values file rather than an image rebuild. That is also the first thing that ever set it: the chart's gateway container had no cost env at all, so production was running on the image default. src/RULES.md 12 already says platform-specific settings belong in a composable values overlay. Tests: the two rate-dependent tests now pass the rate in the way a deployment does, plus a new one pinning that an unconfigured gateway still serves — an empty `pricing_overrides` array is easy to get wrong (a stray leading comma is invalid JSON and bifrost won't start). Verified by rendering all four combinations; file-only correctly strips the leading comma, and both misconfigurations exit 2. Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
|
Pushed The first pass left Both knobs are optional and independent:
Set both and the file wins for the models it names while the flat rate catches the rest. Set neither and we render no Two things now fail loud instead of quietly pricing wrong (rule 22):
Our own rate moved to Verified by rendering all four combinations: flat-only, file-only (correctly strips the leading comma — this is why the new no-config test exists, an empty array is easy to get subtly wrong and invalid JSON stops bifrost booting), both, and neither. Both misconfigurations exit 2. Suite is 18/18 ignored + 17/17 static. One thing I did not touch: |
The bug
Every provider-call span in the results store carried
gen_ai.usage.cost=0— all ~2,100 of them — which is why the dashboard's cost column was blank across the entire result history.bifrost prices each call from its bundled
pricing.json, keyed on the model name. The handles we route are the upstream proxy's own (azure/gpt-5.5,aws/claude-opus-5,rits/google/gemma-4-31B), not litellm's public catalog names, so the lookup missed and it wrote 0. There is no fallback layer.The upstream does know the cost — it returns
x-litellm-response-coston every response — but that's an HTTP header, and bifrost's span carries the cost it computed itself. Its otel plugin has no header-capture knob.The fix (commit 1)
A global wildcard
governance.pricing_overridesentry giving bifrost the rate, so its own arithmetic comes out right. One flat rate is correct here because the upstream bills one: probing it with gpt-5.4 / gpt-5.5 / gpt-4.1-mini / claude-opus-5 / claude-sonnet-5 / gemini-3-flash / gemma-4-31B returns the same cost for the same token counts. Solving two samples gives $2.50/1M in, $15.00/1M out, which then predictsx-litellm-response-costto 1e-9 on every model tried. Overridable viaEVAL_COST_INPUT_PER_TOKEN/EVAL_COST_OUTPUT_PER_TOKEN.Per-model prices (commit 2)
One flat rate is right for our proxy, but the image ships standalone.
EVAL_PRICING_FILE(default/opt/gateway/data/pricing.tsv, absent from the image) is read at startup and rendered into oneexactoverride per model:```
docker run -e EVAL_MODEL=azure/gpt-5.5 \
-v ./pricing.tsv:/opt/gateway/data/pricing.tsv:ro \
... ghcr.io/exgentic/models/bifrost:latest
```
```
model in/token out/token
azure/gpt-5.5 0.0000050 0.0000300
aws/claude-opus-5 0.0000150 0.0000750
```
Those entries are layered alongside the wildcard, not in place of it, so an incomplete list falls back to
EVAL_COST_*rather than pricing at 0 — which was the original bug. Specificity decides; array order is irrelevant. A malformed line exits 2 with the line number (rule 22).Two traps, documented in three places
exactmatch sees the BARE requested model (azure/gpt-5.5), not the provider-prefixed key it uses to index its ownpricing.json(openai/azure/gpt-5.5). A prefixed pattern matches nothing and silently leaves the model on the fallback. Verified empirically both ways.Both are called out in the env contract, the
.examplefile, and a static test assertion.Why not just replace
pricing.json?It works, but it's a trap: any model absent from the replacement gets cost 0 with no fallback, reintroducing exactly this bug. Confirmed on the cluster.
Why whitespace-separated, not JSON?
The alpine image has neither
jqnorpython, and it must run under plaindocker run(rule 20). Hence awk, hence the simple format.pricing.tsv.exampleis copied in as documentation only —startreadspricing.tsv, never the example — so no price is baked into the image (rule 1).Test fixture repairs
The
--ignoredgateway suite was 7/15 green before this branch, and the failures are pre-existing (reproduced on cleanorigin/mainwith my changes stashed):EVAL_MODEL=openai/azure/gpt-5.4for every flavor, but bifrost and litellm take a bare handle — the prefixed string went upstream verbatim and 403'd withteam_model_access_denied. portkey is the opposite: it eats the first path segment (MODEL_NAME=${EVAL_MODEL#*/}) and needs the prefix. Reconciled with a per-flavoreval_model().start_pod_with_otelon the same clock tick collided onotelcol-<nanos>("name is already in use"). Fixed with an atomic counter in the suffix.Tests
static_bifrost_pricing_example_parses_as_the_start_script_reads_it— 3 fields per row, per-token magnitude (catches an undivided per-1M figure), and no wire-prefixed model names.otel_bifrost_span_cost_is_nonzero— the regression guard for the actual bug.otel_bifrost_prices_a_listed_model_from_the_mounted_price_file— the file's rate wins over the flat fallback (the two differ by six orders of magnitude, so the resulting cost is unambiguous about which applied).otel_bifrost_falls_back_when_the_price_file_omits_the_model— a partial file leaves other models onEVAL_COST_*, and one model's entry doesn't leak onto another.Asserting which rate applied, never that cost is under a threshold — a cost SLO is forbidden (tests/run/gateways/RULES.md rule 12).
Results:
--ignoredsuite 17/17 (was 7/15); static tier 17/17;cargo fmt --checkclean; clippy adds no new warnings.upstream_portkey_openaiflaked once under--test-threads=2with an upstreamfetch failedinside portkey and passed alone — unrelated to pricing, portkey has no pricing path.Also verified by hand against the real upstream, all four paths: baked file applies its rate, mounted file via
EVAL_PRICING_FILEapplies its rate, malformed file exits 2 loudly, no file at all falls back and still serves 200.Dashboard
No change needed —
app/results.pyalready readsgen_ai.usage.cost. It was reading a real attribute that was really 0.🤖 Generated with Claude Code