feat: cache template render counting - #1441
Merged
Merged
Conversation
Implements the render-exact-counts handover (working-docs/cache-pricing/
render-exact-counts-handover.md). The classifier counted raw content-block
text, which drifts against the engine's chat-templated prompt_tokens in
both directions — undercounting plain chat (role headers invisible) and
overcounting tool-heavy prompts (JSON envelopes compact under the
template), the drift behind the detail.dev billing-cap incident.
Behind cache.render_counting (default OFF, serde-defaulted so the flag
can roll config-first):
- Every breakpoint is priced by rendering its RECONSTRUCTED prefix
(generation prompt off): parse records a PrefixSpec per marker — tools
subset for tool-definition markers, whole messages, plus the partial
message for mid-message markers — so every marker position raw counting
prices, exact counting prices too. ≤4 prefix renders + 1 full render per
classified request (well inside the classify deadline per the svc's
measured perf).
- The full-body render (generation prompt on, telemetry-stripped — the
engine's exact bytes) feeds the drift alarm: CacheStats.render_total vs
engine prompt_tokens at the usage join → dwctl_cache_render_drift_tokens
histogram + _exceeded_total counter above max(1%, 16 tokens). Template
parity becomes a measured invariant.
- Per-breakpoint fallback: a prefix the template can't express falls back
to that breakpoint's raw-segment count — no marker position regresses
below today's accuracy. Whole-request ladder: no template_version → raw
counting with the plain scope; transport/5xx → no caching, like
tokenize outages.
- Scope folds template_version ("{tok}+{tmpl}") so raw-era entries age
out instead of mispricing exact-era reads (one-time miss churn, like the
tools[] rollout).
Raw-segment counting is unchanged and remains the fallback and the
flag-off path (all 102 pre-existing prompt_cache tests pass untouched;
8 new tests cover prefix specs, exact pricing incl. tool-definition
markers, fallback, floor, and transport degradation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nting # Conflicts: # dwctl/src/prompt_cache/inject.rs
Swap the per-breakpoint render loop for the released tokenizer-svc contract:
one /v1/render call carries the transmitted body plus every breakpoint as a
structured prefix ({tools}/{message}/{message,block}/{message,tool_call}), and
the response returns prefix_counts alongside the full-render total — the
separate drift render disappears.
- PrefixSpec ordinals now count the request AS TRANSMITTED (telemetry strip
mode removes blocks from the forwarded body, shifting wire indices)
- null prefix_counts entries (template-refused views, e.g. Qwen tools-only)
backfill from the nearest non-null neighbour plus the raw-tokenized delta
of intervening blocks; the matched read counts as a neighbour
- monotonicity check: non-null counts non-decreasing and <= total, violation
falls back to raw counting (a lying template must not drive billing)
- 400 BAD_REQUEST (malformed prefixes = dwctl bug) logs loudly + falls back;
whole-request render failures now fall back to raw segment_counts
- drift alarm threshold >1% AND >20 tokens, exceeded counter labelled per
model
…m label The drift histogram previously inherited the exporter's default seconds-oriented buckets (useless for token counts). Explicit signed token buckets whose edges (±5/±20/±50/±100/±500) double as alert thresholds, plus a model label, mean Grafana alert rules can be built per-model at any of these thresholds and retuned without a code release. The hardcoded >1%+>20-token exceeded counter stays as a convenience series.
Deploying control-layer with
|
| Latest commit: |
8e2d483
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2350c79f.control-layer.pages.dev |
| Branch Preview URL: | https://feat-cache-render-counting.control-layer.pages.dev |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds an “exact counting” path for prompt-cache pricing by using tokenizer-svc /v1/render to count chat-template-rendered tokens (including template versioning), and introduces a drift metric comparing those counts to engine-reported usage.prompt_tokens.
Changes:
- Add tokenizer-svc
/v1/renderclient types and error handling, including template-version support from/v1/models. - Extend prompt parsing to generate structured prefix specs for every cache marker, enabling exact per-breakpoint counting.
- Add render-drift metrics + config flag (
cache.render_counting) and plumb render totals into usage injection for observability.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| dwctl/src/prompt_cache/tokenizer.rs | Adds /v1/render request/response types and client method; exposes template_version via /v1/models. |
| dwctl/src/prompt_cache/stats.rs | Extends cache stats with optional render_total for drift measurement. |
| dwctl/src/prompt_cache/parse.rs | Builds PrefixSpec/WirePrefix mappings per marker, including telemetry-strip ordinal handling and new unit tests. |
| dwctl/src/prompt_cache/layer.rs | Updates cache-layer tests to account for new classifier constructor parameter. |
| dwctl/src/prompt_cache/inject.rs | Injects drift metrics into usage rewriting using render_total and model label. |
| dwctl/src/prompt_cache/classifier.rs | Adds exact-counting mode, folds template version into index scope, and implements render-count flow + fallbacks/backfills. |
| dwctl/src/lib.rs | Registers custom histogram buckets for render-drift metric. |
| dwctl/src/config.rs | Introduces cache.render_counting config option with defaults and rollout guidance. |
hachall
marked this pull request as draft
August 5, 2026 18:18
- clamp null-backfill estimates to the next exact prefix count and the render total: the raw delta counts serialized JSON and can run high on tool-heavy spans; a longer prefix can't cost less than a shorter one says - WirePrefix docs: tools is a COUNT, not an inclusive index - drop duplicate glob import in parse tests - hoist the drift-metric model label allocation; ignore zero prompt_tokens in the drift alarm (upstream nonsense, not template drift) - document keep-mode telemetry semantics in render_counts
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
dwctl/src/prompt_cache/tokenizer.rs:190
TokenizerClient::rendercomputes the size bucket by serializingmessages/toolstoString(and does it twice). This allocates and re-serializes potentially large JSON payloads on the hot path just for metrics.
let start = std::time::Instant::now();
let size = cache_metrics::tokenize_size_bucket(messages.to_string().len() + tools.map(|t| t.to_string().len()).unwrap_or(0));
dwctl/src/prompt_cache/classifier.rs:416
render_countsclones the entiremessagesJSON array (v.get("messages").cloned()), which can be large. The value can be borrowed fromv(or a local empty default) across the async/v1/rendercall to avoid a potentially expensive deep clone.
let messages = v.get("messages").cloned().unwrap_or(serde_json::Value::Array(Vec::new()));
let tools = v.get("tools");
dwctl/src/prompt_cache/inject.rs:236
- The drift metric path allocates a new
Stringfor the model label and then clones it for the histogram. This adds per-request allocations on the response hot path; themetricsmacros accept&strlabel values, so this can be allocation-free. Alsodrift.unsigned_abs()is computed twice.
let drift = render_total as i64 - prompt as i64;
let model_label = model.unwrap_or("unknown").to_string();
metrics::histogram!("dwctl_cache_render_drift_tokens", "model" => model_label.clone()).record(drift as f64);
// Small constant seams are expected; alarm only on >1% AND >20 tokens, per-model
// so a single drifting template is visible amid healthy traffic. A zero prompt
hachall
marked this pull request as ready for review
August 6, 2026 14:10
hachall
pushed a commit
that referenced
this pull request
Aug 6, 2026
🤖 I have created a release *beep* *boop* --- ## [10.8.0](v10.7.2...v10.8.0) (2026-08-06) ### Features * cache template render counting ([#1441](#1441)) ([61adf92](61adf92)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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.
No description provided.