Skip to content

fix: canonicalize JSON values for stable vLLM prefix caching#30

Open
myshytf wants to merge 6 commits into
local-inference-lab:masterfrom
myshytf:fix/json-canonicalization-for-prefix-cache
Open

fix: canonicalize JSON values for stable vLLM prefix caching#30
myshytf wants to merge 6 commits into
local-inference-lab:masterfrom
myshytf:fix/json-canonicalization-for-prefix-cache

Conversation

@myshytf

@myshytf myshytf commented Jul 6, 2026

Copy link
Copy Markdown

Supersedes #28 (closed, not merged).

Problem

#28 enabled serde_json preserve_order to fix struct field serialization order (model before chat_template_kwargs in #[serde(flatten)] output). The fix was correct for the struct-level problem but incomplete: preserve_order switches Value::Object from BTreeMap (alphabetical) to IndexMap (insertion order), removing the free key canonicalization BTreeMap provided.

Without preserve_order, BTreeMap automatically sorted all JSON object keys alphabetically — regardless of how clients constructed the JSON. This gave consistent serialized bytes for free, which is critical for vLLM prefix cache (byte-sensitive token matching).

With preserve_order ON, dynamic JSON values (tool parameters, tool_call arguments, content objects, extra_body) now preserve insertion order. Different clients or code paths constructing the same logical JSON with different key orders produce different bytes → different token sequences → cache miss.

Fix

Add canonicalize_chat_request_json_values() in src/upstream.rs which recursively sorts all Value::Object keys before sending to upstream vLLM. This restores the alphabetical key ordering guarantee that BTreeMap provided, while keeping preserve_order for struct-level field order (model first).

Canonicalization targets

  • message content
  • assistant tool_call arguments
  • tool/function parameters
  • tool_choice
  • response_format
  • extra_body / chat_template_kwargs

Also canonicalize in stringify_json_value() before string conversion.

Regression test

sanitize_chat_request_canonicalizes_preserve_order_sensitive_json_values — constructs the same logical request with reversed key order and asserts identical upstream JSON bytes after sanitize_chat_request().

Files changed

File Description
Cargo.toml / Cargo.lock Enable serde_json preserve_order feature (from #28)
src/upstream.rs canonicalize_chat_request_json_values() + canonicalize_json_value() + stringify_json_value canonicalization + regression test

Test results

cargo test --release:
  lib:     281 passed
  binary:    3 passed
  gateway:  91 passed
  total:   375 passed, 0 failed
cargo build --release: OK

myshytf and others added 6 commits July 2, 2026 19:53
… instructions

System reminders, local command caveats, skill listings, and system-role
history turns were lifted into the Responses `instructions` field, which
renders at the very top of the upstream prompt. Any mid-session reminder
therefore rewrote the prompt head and invalidated the entire prefix cache
(observed via analyze-log on real Claude Code traffic: system message
rewritten at ~16-32KB offsets, 0% of a 100k+-token history reused).

Now private context is emitted at its original position as a guarded user
turn (same wrap_private_context framing), keeping request histories
append-only. /clear command echoes are still dropped entirely.

Measured through the full gateway against GLM-5.2 + LMCache: a turn that
introduces a <system-reminder> now reuses 2304/2435 prompt tokens (94.6%,
GPU prefix + LMCache) instead of 0%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t_tokens

vLLM reports prompt_tokens_details.cached_tokens (with
--enable-prompt-tokens-details); the Anthropic adapter now maps it into
usage.cache_read_input_tokens on message_start/message_delta and the
non-stream response, with input_tokens excluding cache reads per Anthropic
semantics. Claude Code's cache indicator previously always showed a miss
even when the backend hit 98% (verified: turn-2 now reports
cache_read_input_tokens=2048, input_tokens=200, 27.7s -> 1.6s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cherry-pick 0a5ba94 (G4: strip images to [Image #N] placeholders, cache
originals per-session, inject analyzeImage server tool, run it against an
OpenAI-compatible vision backend) adapted to this fork lineage:

- responses_to_chat: merge lower_request_with_default_reasoning_effort and
  lower_request_with_image_agent into lower_request_with_options (this
  lineage threads the config default reasoning effort through lowering).
- engine: request_model_genuinely_resolves rewritten against this lineage's
  UpstreamModelCatalog (new exact_id/canonical_unique/default_id helpers
  mirroring normalize()); no G7 model_routes here. Dropped the G2
  chat_reasoning_suppressed hunk that rode along in the cherry-pick (its
  callers do not exist in this lineage).
- upstream: took candidate_backend_models trait/impl additions + image-URI
  log/error redaction; kept this lineage's simple ensure_success (no
  G1 shrink-and-retry, no G6 bounded SSE frames).
- config: image_agent_enabled / vision_url / vision_model /
  image_cache_max_size / image_cache_ttl_secs + env overrides + per-profile
  native_vision override, grafted onto the capabilities-era config shapes.
- Dropped G4's tests/common + port_* test additions (harness not present
  in this lineage); gateway tests updated for the new Gateway::new arity.

cargo test: 280 lib + 3 main + 89 gateway green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ix caching

Stock G4 gating injects the analyzeImage tool + IMAGE HANDLING system prefix
only on turns whose LATEST user message carries an image. On a long session
that means the prompt head (tools block + system prefix, at the very top of
the GLM template) flips when the first image arrives and flips BACK on the
next text-only turn — each flip invalidates the entire prefix cache and
re-prefills the whole history. It also leaves images in OLDER history turns
un-stripped on those follow-up turns (clients replay full history), sending
raw image bytes to a text-only backend.

image_agent_always_active: true (config / LLMCONDUIT_IMAGE_AGENT_ALWAYS_ACTIVE)
activates the agent on EVERY turn of an eligible backend: constant tool +
system prefix (byte-identical head, placeholder numbering deterministic in
scan order so history placeholders re-materialize identically), history
images always stripped+cached. Costs a constant few hundred prompt tokens.
tool_choice:"none" turns keep the identical head too (the model simply
cannot call the tool there).

Default false (stock G4 behavior). Two gateway tests pin the always mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serde_json without preserve_order serializes #[serde(flatten)] fields
via BTreeMap (alphabetical), causing chat_template_kwargs to appear
before model in upstream requests. vLLM prefix cache is byte-sensitive,
so inconsistent key ordering between requests degrades cache hit rates.

Enable preserve_order feature so serde_json uses IndexMap (insertion
order = struct field order), ensuring model is always first in the
serialized JSON sent to vLLM.
Commit 27a9fdf enabled serde_json preserve_order to fix struct field
serialization order (model before chat_template_kwargs). However,
preserve_order switches Value::Object from BTreeMap (alphabetical) to
IndexMap (insertion order), removing the free canonicalization BTreeMap
provided. This caused severe prefix cache misses: different clients or
code paths constructing the same logical JSON with different key
insertion orders now produce different serialized bytes → different
vLLM token sequences → cache miss.

Fix: add canonicalize_chat_request_json_values() which recursively sorts
all Value::Object keys before sending to upstream vLLM. This restores the
alphabetical key ordering guarantee while keeping preserve_order for
struct-level field order (model first).

Canonicalization targets:
- message content
- assistant tool_call arguments
- tool/function parameters
- tool_choice
- response_format
- extra_body / chat_template_kwargs

Also canonicalize in stringify_json_value() before string conversion.

Regression test: sanitize_chat_request_canonicalizes_preserve_order_sensitive_json_values
— same logical request with reversed key order → asserts identical
upstream JSON bytes after sanitize.

Supersedes local-inference-lab#28.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@myshytf, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ad877c33-01c7-40a2-8fd7-41435718db36

📥 Commits

Reviewing files that changed from the base of the PR and between a96abaf and f0f40f7.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • Cargo.toml
  • src/adapters/anthropic_to_responses.rs
  • src/adapters/chat_completions.rs
  • src/adapters/chat_to_responses.rs
  • src/adapters/responses_to_anthropic.rs
  • src/adapters/responses_to_chat.rs
  • src/cli.rs
  • src/config.rs
  • src/debug.html
  • src/engine.rs
  • src/http.rs
  • src/lib.rs
  • src/models/anthropic.rs
  • src/monitor.rs
  • src/search.rs
  • src/upstream.rs
  • src/vision.rs
  • tests/gateway.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant