Repository navigation
postprocess: strip_actual_usage; bodylog keeps actual_usage; rename rate_limit to load_rate_limit - #30
Merged
Conversation
Some sglang builds return a top-level "actual_usage" object next to "usage" (a
second token count, e.g. different cached_tokens). Clients and billing should
only see "usage", so add a strip_actual_usage handler that removes the
top-level field from SSE frames and non-stream JSON bodies and touches nothing
else. A plain substring test runs first, so only the frame that carries the
field -- the final usage chunk of a stream -- is decoded. The bodylog still
records the original, since it runs before post-processing. vllm_format drops
the field too.
The SSE/JSON plumbing of vllm_format is moved into json_rewrite_handler() so
both handlers share it; behavior is unchanged.
Fix: postprocess decoded with the shared cjson instance, which encodes every
empty table as an object, so rewriting a usage chunk turned "choices":[] into
"choices":{}. Use a private cjson instance with decode_array_with_array_mt so
arrays round-trip as arrays.
A route opts in with postprocess = "strip_actual_usage" (one handler per route).
…n address
--actual-usage (or {"emit_actual_usage": true} per request) adds a top-level
actual_usage next to usage on chat responses, with its own cached_tokens, as
some sglang builds do. --host lets the mock run inside a pod (0.0.0.0); the
default stays 127.0.0.1, and the wire is unchanged unless the flag is given.
The listener stores a success response as its content plus resp_meta, and resp_meta took usage but no other top-level field. So a response's actual_usage (some sglang builds) never reached the bodylog -- which is the only place it can survive once openresty strips it from the client response. Carry it into resp_meta.actual_usage, as usage is; absent upstream means absent here.
On a ModelRoute, nginx.values.rate_limit is already taken: it is a video route's download speed limit, and the CRD rejects it on llm routes. So the documented way to keep load-based limiting off for a route -- rate_limit: false in the ModelRoute -- could not be applied. Rename the route option to load_rate_limit and the global default to _G.LOAD_RATE_LIMIT_ENABLED. The endpoints (/_rate_limit_status, /_rate_limit_toggle) and the fields they return are unchanged. The option was released in v0.1.29 and no route sets it, so no alias is kept for the old name.
zoboth
approved these changes
Nov 10, 2026
zoboth
left a comment
Collaborator
There was a problem hiding this comment.
I read the whole diff. I found no blockers.
What I checked
strip_actual_usage_json: it only checks for the substring"actual_usage"before decoding. An escaped\"actual_usage\"inside a string value doesn't match, so content frames stay byte-identical. Only the top-level key is removed, and it fails open on decode and encode errors. The handler is limited to 2xx responses and totext/event-stream/application/json, as described.json_rewrite_handler(): the move is mechanical. The JSON buffering, the overflow fail-open and the SSE leftover/line splitting all match the oldvllm_formatbody line for line, withrewrite_jsonpassed in. EachM.registercall gets its own handler table.- Private cjson instance:
require("cjson.safe").new()together withdecode_array_with_array_mt(true)keeps the setting on this instance only, so other modules'cjsonstay as they are.json_null(cjson.null) is the same lightuserdata in every instance, so the null checks invllm_formatstill work. The"choices":[]→{}fix is real and is covered by test 5. - bodylog-listener:
actualUsagefollows the same path asusage(taken from whichever frame carries it, and only added toresp_metawhen present). The test also covers the case where upstream sends none. - Mock: the wire is unchanged without the new flags.
--hostdefaults to127.0.0.1as before.
Non-blocking notes
- The
rate_limit→load_rate_limitrename isn't in the PR description. This PR also renames the route optionrate_limit(merged in #29) toload_rate_limit, and_G.RATE_LIMIT_ENABLEDto_G.LOAD_RATE_LIMIT_ENABLED, acrossroute.lua,directives.lua,debug_endpoints.lua,session_base.conf, the README andtest_rate_limit_switch.sh. The reason makes sense: the ModelRoute CRD rejectsrate_limiton llm routes. But there is no fallback for the old names. A Lua route conf withrate_limit = false, or an overriddensession_base.confwith_G.RATE_LIMIT_ENABLED = false, will be ignored without any message after this change, and load-based 429s come back on. That's the safe direction, so I'm not blocking on it. Still, I'd suggest either reading the old names as a deprecated alias with anngx.log(ngx.WARN, ...), or at least logging whenopts.rate_limit ~= nilon an llm route. I'd also add a line to the PR description, or split the rename into its own PR, so it shows up in the changelog. session_base.confstill says "Per route:rate_limit = falsein the route conf" in the comment right above_G.LOAD_RATE_LIMIT_ENABLED. It should sayload_rate_limit.- The directive key
rate_limit→load_rate_limitalso changes what/_route_optsreturns. If the console, or anything else that reads directives, looks for the keyrate_limitby name, it needs to change at the same time. I couldn't check this from here. - The
debug_endpoints.luacomment alignment onroute_valuemoved by a few columns (cosmetic). - Optional: test 3 covers
strip_actual_usage_jsonon a nestedactual_usagedirectly, but not through the handler. It's fine as it is. One SSE case throughrun()would cover the end-to-end path too.
The Lua unit tests (test/utest_*.lua) were run by hand only, so a regression in lua/ could merge unnoticed -- the post-processor's empty-array bug did. test/run_lua_unit_tests.sh runs all twelve with the resty CLI (with the two special invocations they need) and exits non-zero on any failure or when it finds no test. A new lua job in test.yml runs it on jammy with the OpenResty version the Dockerfile pins; the workflow now also triggers on lua/ and the unit tests. README: a section on response post-processing (how a route opts in, the two handlers, which responses are touched, buffering and Content-Length, fail-open, what the bodylog keeps), and the Testing section points at the runner.
This was referenced Nov 10, 2026
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.
What
New post-processing handler
strip_actual_usage: removes the top-levelactual_usageobject that some sglang builds return next tousage, from SSE frames and non-stream JSON bodies (2xx only). Nothing else in the response is touched.vllm_formatdropsactual_usageas well.The SSE/JSON plumbing of
vllm_formatmoves intojson_rewrite_handler(), shared by both handlers. No behavior change forvllm_formatapart from the two items here.bodylog-listener keeps
actual_usageinresp_meta.actual_usage. The listener stores a success response as its content plusresp_meta, andresp_metatookusagebut no other top-level field, soactual_usagenever reached the bodylog. Once openresty strips it from the client response, the bodylog is the only place it survives.Test mock (
test/mock_vllm_sse.py):--actual-usage(or{"emit_actual_usage": true}per request) returns anactual_usage;--hostlets it run in a pod. Unchanged wire without the flags.Fix: rewriting a frame with the shared cjson instance turned
"choices":[]into"choices":{}(cjson encodes every empty table as an object). That is exactly the final usage chunk of a stream. Post-processing now uses a private cjson instance withdecode_array_with_array_mt(true), so arrays round-trip as arrays. The setting does not leak into other modules.Rename the rate-limit master switch option
rate_limit→load_rate_limit(and_G.RATE_LIMIT_ENABLED→_G.LOAD_RATE_LIMIT_ENABLED). On a ModelRoute,nginx.values.rate_limitis already a video route's download speed limit, and the CRD rejects it on llm routes, so the documentedrate_limit: falsecould not be applied. The endpoints (/_rate_limit_status,/_rate_limit_toggle) and their fields are unchanged; no alias for the old name (released in v0.1.29, set nowhere).CI runs the Lua unit tests. New
test/run_lua_unit_tests.shruns alltest/utest_*.luawith the resty CLI and exits non-zero on a failure or when it finds none; a newluajob intest.ymlruns it on jammy with the OpenResty version the Dockerfile pins.README: a "Response post-processing" section (opting in, the two handlers, which responses are touched, buffering / Content-Length, fail-open, what the bodylog keeps); Testing points at the runner.
Why
actual_usageis a second token count, e.g. with a differentcached_tokensthanusage:Clients and billing should see one count,
usage. openresty hands the bodylog the raw upstream chunks (bodylog runs before post-processing), and with the listener change above the bodylog record keepsactual_usage.Cost
A plain substring test (
"actual_usage") runs before any decoding. Frames without it pass through byte-identical and undecoded. In practice only the final usage chunk of a stream gets decoded and re-encoded once.Enabling
Off unless a route opts in (one handler per route):
Tests
End to end on a test cluster (
openresty 0.1.30-rc1+bodylog 0.1.16-rc1):actual_usage; it is there when the mock is called directly.usageis unchanged;"choices":[]is still an array.resp_meta.actual_usage.0.1.29, no handler) the same checks fail.load_rate_limit: "false"passes the ModelRoute CRD (server dry-run);rate_limitis rejected.New
test/utest_postprocess_strip_actual_usage.luacovers:[DONE]stay byte-identical;usagestays unchanged and"choices":[]stays an array;actual_usageis left alone;vllm_formatdrops the field and keepschoicesan array.Results:
resty test/utest_postprocess_strip_actual_usage.lua: PASS.resty test/utest_postprocess_vllm.lua: PASS (unchanged).lua/postprocess.lua, the new test fails, and the oldvllm_formatemits"choices":{}for the usage frame.go test ./...inbodylog-listener-go: PASS. NewTestRespMetaKeepsActualUsage(stream and non-stream; absent upstream means absent) fails on the previousmain.go.