Skip to content

feat: cache template render counting - #1441

Merged
hachall merged 9 commits into
mainfrom
feat/cache-render-counting
Aug 6, 2026
Merged

feat: cache template render counting#1441
hachall merged 9 commits into
mainfrom
feat/cache-render-counting

Conversation

@hachall

@hachall hachall commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

No description provided.

hachall and others added 5 commits August 3, 2026 12:45
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.
Copilot AI review requested due to automatic review settings August 5, 2026 18:06
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

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

View logs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/render client 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.

Comment thread dwctl/src/prompt_cache/parse.rs Outdated
Comment thread dwctl/src/prompt_cache/classifier.rs
Comment thread dwctl/src/prompt_cache/tokenizer.rs Outdated
Comment thread dwctl/src/prompt_cache/inject.rs
@hachall
hachall marked this pull request as draft August 5, 2026 18:18
hachall added 2 commits August 5, 2026 19:46
- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::render computes the size bucket by serializing messages/tools to String (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_counts clones the entire messages JSON array (v.get("messages").cloned()), which can be large. The value can be borrowed from v (or a local empty default) across the async /v1/render call 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 String for the model label and then clones it for the histogram. This adds per-request allocations on the response hot path; the metrics macros accept &str label values, so this can be allocation-free. Also drift.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
hachall marked this pull request as ready for review August 6, 2026 14:10
@hachall
hachall merged commit 61adf92 into main Aug 6, 2026
24 checks passed
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>
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.

2 participants