context-engineering-core 0.12.0__tar.gz → 0.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/CHANGELOG.md +15 -1
  2. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/PKG-INFO +1 -1
  3. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/pyproject.toml +1 -1
  4. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/__init__.py +1 -1
  5. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/data/freeze.json +2 -2
  6. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/common.py +66 -16
  7. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/pipeline.py +3 -5
  8. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/tokens.py +14 -0
  9. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/.gitignore +0 -0
  10. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/LICENSE +0 -0
  11. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/README.md +0 -0
  12. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/evidence_answer_policy.py +0 -0
  13. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/incident-demo.json +0 -0
  14. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/inspection-input.json +0 -0
  15. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/rolling_summary.py +0 -0
  16. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/sdk_quickstart.py +0 -0
  17. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/service-config.example.json +0 -0
  18. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/structured_answers.py +0 -0
  19. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/examples/validated_answer.py +0 -0
  20. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/__main__.py +0 -0
  21. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/_sqlite.py +0 -0
  22. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/_windows_security.py +0 -0
  23. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/_windows_storage.py +0 -0
  24. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/adapters.py +0 -0
  25. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/answers.py +0 -0
  26. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/budget.py +0 -0
  27. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/cli.py +0 -0
  28. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/config.py +0 -0
  29. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/context.py +0 -0
  30. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/demo.py +0 -0
  31. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/errors.py +0 -0
  32. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/__init__.py +0 -0
  33. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/commands.py +0 -0
  34. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/corpus.py +0 -0
  35. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/data/ground_truth.json +0 -0
  36. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/data/pin_retention.json +0 -0
  37. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/data/protocol.json +0 -0
  38. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/data/scenario.json +0 -0
  39. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/data/summary_fixture.json +0 -0
  40. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/execution.py +0 -0
  41. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/fixtures.py +0 -0
  42. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/grading.py +0 -0
  43. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/harness.py +0 -0
  44. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/__init__.py +0 -0
  45. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/__main__.py +0 -0
  46. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/data/ground_truth.json +0 -0
  47. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/data/manifest.json +0 -0
  48. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/data/protocol.json +0 -0
  49. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/data/scenarios.json +0 -0
  50. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/data/scoring_manifest.json +0 -0
  51. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/data/scoring_protocol.json +0 -0
  52. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/heldout/scoring.py +0 -0
  53. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/protocol.py +0 -0
  54. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/reporting.py +0 -0
  55. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/results.py +0 -0
  56. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/run_commands.py +0 -0
  57. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/evaluation/strategies.py +0 -0
  58. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/inspection.py +0 -0
  59. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/__init__.py +0 -0
  60. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/cap.py +0 -0
  61. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/pin.py +0 -0
  62. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/retrieve.py +0 -0
  63. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/summarize.py +0 -0
  64. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/layers/window.py +0 -0
  65. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/mcp_server.py +0 -0
  66. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/memory/__init__.py +0 -0
  67. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/memory/codec.py +0 -0
  68. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/memory/demo.py +0 -0
  69. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/memory/store.py +0 -0
  70. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/models.py +0 -0
  71. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/__init__.py +0 -0
  72. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/client.py +0 -0
  73. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/commands.py +0 -0
  74. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/contracts.py +0 -0
  75. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/credentials.py +0 -0
  76. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/memory.py +0 -0
  77. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/store.py +0 -0
  78. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/transport.py +0 -0
  79. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/providers/windows_credentials.py +0 -0
  80. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/py.typed +0 -0
  81. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/__init__.py +0 -0
  82. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/__main__.py +0 -0
  83. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/app.py +0 -0
  84. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/auth.py +0 -0
  85. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/client.py +0 -0
  86. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/control.py +0 -0
  87. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/demo.py +0 -0
  88. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/integrations.py +0 -0
  89. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/schemas.py +0 -0
  90. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/worker.py +0 -0
  91. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/service/workers.py +0 -0
  92. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/summaries.py +0 -0
  93. {context_engineering_core-0.12.0 → context_engineering_core-0.12.1}/src/context_engine/work.py +0 -0
@@ -3,7 +3,21 @@
3
3
  Earlier versions (0.1.0–0.9.6) are documented in `docs/C01_REPORT.md` through
4
4
  `docs/C10_STORAGE_ACL_REPORT.md` and preserved as wheels under `archives/`.
5
5
 
6
- ## 0.12.0 — 2026-10-31 (unreleased)
6
+ ## 0.12.1 — 2026-10-31 (unreleased)
7
+
8
+ ### Changed (performance only; output identical)
9
+ - Budget fit checks compute only the request's estimated token count
10
+ (`TiktokenCounter.estimate_tokens`), skipping per-message counts and hashing that
11
+ are only reported. Custom counters keep the full `count_request` path.
12
+ - WINDOW candidates skip their own token count; per-turn JSON is reused.
13
+ - Window planning (run by RETRIEVE, WINDOW and SUMMARIZE) and history digests are
14
+ memoized by value (bounded; built-in counter/serializer only).
15
+ - All-layers median on the 100-turn benchmark: 95 → 67 ms at 900 tokens and
16
+ 208 → 80 ms at 3000 tokens. All 310 strategy-benchmark assemblies (31 facts x 5
17
+ strategies x 2 budgets) are byte-identical to 0.12.0. 0.12.0 wheel/freeze archived
18
+ in `archives/c12-latency-0121/`.
19
+
20
+ ## 0.12.0 — 2026-10-31 (PyPI)
7
21
 
8
22
  ### Added
9
23
  - `context_engine.adapters`: `openai_chat`, `anthropic_messages`,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: context-engineering-core
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
4
  Summary: Provider-independent context layers, source contracts, and token budget accounting
5
5
  Project-URL: Homepage, https://github.com/Nandu1729/Context-Engine
6
6
  Project-URL: Repository, https://github.com/Nandu1729/Context-Engine
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "context-engineering-core"
7
- version = "0.12.0"
7
+ version = "0.12.1"
8
8
  description = "Provider-independent context layers, source contracts, and token budget accounting"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  """Core contracts. Importing this package performs no configuration or network I/O."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.12.1"
4
4
 
5
5
 
6
6
  def __getattr__(name):
@@ -7,7 +7,7 @@
7
7
  "summary_fixture": "b3c1b76bb330b52493e5150647747bad77d5de3a55d05d032bb5703be1c70196"
8
8
  },
9
9
  "runtime": {
10
- "code_hash": "59eeb4d28e6b3106a18317d5b6acee255f0b22d5e5143e3e7bb74b1146a39ff4",
10
+ "code_hash": "c3d4bfbdad58327eb09a763ae398fd97277e33339ce00cfb5395b2615f2d269f",
11
11
  "dependencies": {
12
12
  "certifi": "2026.10.23",
13
13
  "charset-normalizer": "3.5.1",
@@ -19,7 +19,7 @@
19
19
  "tiktoken": "0.14.0",
20
20
  "urllib3": "2.8.0"
21
21
  },
22
- "package_version": "0.12.0",
22
+ "package_version": "0.12.1",
23
23
  "python": "3.12.13"
24
24
  },
25
25
  "schema_version": 1
@@ -1,7 +1,9 @@
1
1
  """Shared rendering/accounting primitives used by layers and the public pipeline."""
2
2
 
3
3
  import hashlib
4
+ from collections import OrderedDict
4
5
  from dataclasses import asdict, replace
6
+ from functools import lru_cache
5
7
 
6
8
  from ..context import ContextCarrier, LayerDiagnostic
7
9
  from ..errors import LayerStateError
@@ -15,7 +17,7 @@ from ..models import (
15
17
  Turn,
16
18
  canonical_json,
17
19
  )
18
- from ..tokens import TokenCounter
20
+ from ..tokens import CanonicalChatSerializer, TiktokenCounter, TokenCounter, estimated_tokens
19
21
  from ..work import raise_if_cancelled
20
22
 
21
23
  ORDER = tuple(BlockKind)
@@ -23,6 +25,12 @@ OPTIONAL = (BlockKind.SUMMARY, BlockKind.WINDOW, BlockKind.RETRIEVED)
23
25
 
24
26
 
25
27
  def history_digest(turns: tuple[Turn, ...]) -> str:
28
+ # Turns are immutable values, so equal histories share one cached digest.
29
+ return _history_digest(tuple(turns))
30
+
31
+
32
+ @lru_cache(maxsize=256)
33
+ def _history_digest(turns: tuple[Turn, ...]) -> str:
26
34
  values = [
27
35
  (asdict(t.scope), t.turn_id, t.revision, t.timestamp.isoformat(), t.content_hash)
28
36
  for t in turns
@@ -101,18 +109,21 @@ def set_block(
101
109
  )
102
110
 
103
111
 
112
+ @lru_cache(maxsize=4096)
113
+ def _turn_json(turn) -> str:
114
+ return canonical_json({"turn": turn.turn_id, "messages": [m.to_wire() for m in turn.messages]})
115
+
116
+
104
117
  def window_block(
105
- context: ContextCarrier, ids: tuple[str, ...], counter: TokenCounter
118
+ context: ContextCarrier, ids: tuple[str, ...], counter: TokenCounter, *, count: bool = True
106
119
  ) -> ContextBlock:
120
+ """`count=False` skips the block's own token count (planning-only candidates)."""
107
121
  selected = [t for t in context.working_turns if t.turn_id in ids]
108
- content = (
109
- canonical_json(
110
- [{"turn": t.turn_id, "messages": [m.to_wire() for m in t.messages]} for t in selected]
111
- )
112
- if selected
113
- else ""
122
+ # Identical to canonical_json of the list: compact separators, per-item sorted keys.
123
+ content = "[" + ",".join(_turn_json(t) for t in selected) + "]" if selected else ""
124
+ return ContextBlock(
125
+ BlockKind.WINDOW, content, text_tokens=counter.count_text(content) if count else None
114
126
  )
115
- return ContextBlock(BlockKind.WINDOW, content, text_tokens=counter.count_text(content))
116
127
 
117
128
 
118
129
  def incremental_cost(context: ContextCarrier, block: ContextBlock, counter: TokenCounter) -> int:
@@ -121,8 +132,7 @@ def incremental_cost(context: ContextCarrier, block: ContextBlock, counter: Toke
121
132
  )
122
133
  return max(
123
134
  0,
124
- counter.count_request(request).estimated_tokens
125
- - context.plan.required_estimate.estimated_tokens,
135
+ estimated_tokens(counter, request) - context.plan.required_estimate.estimated_tokens,
126
136
  )
127
137
 
128
138
 
@@ -135,24 +145,64 @@ def fits_block(
135
145
  # RETRIEVE runs first but must leave the exact planned WINDOW viable.
136
146
  if context.window_planned and block.kind != BlockKind.WINDOW:
137
147
  blocks = tuple(b for b in blocks if b.kind != BlockKind.WINDOW) + (
138
- window_block(context, context.plan.window_turn_ids, counter),
148
+ window_block(context, context.plan.window_turn_ids, counter, count=False),
139
149
  )
140
150
  request = render_request(blocks, context.question, context.required_request.tools)
141
- return counter.count_request(request).estimated_tokens <= context.plan.input_allowance
151
+ return estimated_tokens(counter, request) <= context.plan.input_allowance
142
152
 
143
153
 
144
- def plan_window(context: ContextCarrier, counter: TokenCounter) -> ContextCarrier:
145
- require_prepared(context, counter)
154
+ _WINDOW_MEMO: OrderedDict = OrderedDict()
155
+ _WINDOW_MEMO_SIZE = 64
156
+
157
+
158
+ def _window_key(context: ContextCarrier, counter: TokenCounter):
159
+ """Every input the WINDOW scan reads; None (no memo) for custom counters/serializers."""
160
+ if (
161
+ type(counter) is not TiktokenCounter
162
+ or type(counter.serializer) is not CanonicalChatSerializer
163
+ ):
164
+ return None
165
+ return (
166
+ counter.config,
167
+ tuple(context.working_turns),
168
+ required_blocks(context),
169
+ context.question,
170
+ context.required_request.tools,
171
+ context.plan.window_tokens,
172
+ context.plan.required_estimate.estimated_tokens,
173
+ )
174
+
175
+
176
+ def _scan_window(context: ContextCarrier, counter: TokenCounter) -> tuple[str, ...]:
146
177
  selected: tuple[str, ...] = ()
147
178
  for turn in reversed(context.working_turns):
148
179
  raise_if_cancelled(context.cancellation)
149
180
  candidate = (turn.turn_id,) + selected
150
181
  if (
151
- incremental_cost(context, window_block(context, candidate, counter), counter)
182
+ incremental_cost(
183
+ context, window_block(context, candidate, counter, count=False), counter
184
+ )
152
185
  > context.plan.window_tokens
153
186
  ):
154
187
  break
155
188
  selected = candidate
189
+ return selected
190
+
191
+
192
+ def plan_window(context: ContextCarrier, counter: TokenCounter) -> ContextCarrier:
193
+ require_prepared(context, counter)
194
+ # RETRIEVE, WINDOW and SUMMARIZE each re-plan the same inputs; the scan is pure.
195
+ key = _window_key(context, counter)
196
+ if key is None or key not in _WINDOW_MEMO:
197
+ selected = _scan_window(context, counter)
198
+ if key is not None:
199
+ _WINDOW_MEMO[key] = selected
200
+ if len(_WINDOW_MEMO) > _WINDOW_MEMO_SIZE:
201
+ _WINDOW_MEMO.popitem(last=False)
202
+ else:
203
+ raise_if_cancelled(context.cancellation)
204
+ _WINDOW_MEMO.move_to_end(key)
205
+ selected = _WINDOW_MEMO[key]
156
206
  if context.window_planned:
157
207
  if selected != context.plan.window_turn_ids:
158
208
  raise LayerStateError("Stored WINDOW membership differs from shared planner")
@@ -27,7 +27,7 @@ from .models import (
27
27
  canonical_json,
28
28
  freeze_sequence,
29
29
  )
30
- from .tokens import TiktokenCounter, TokenCounter, TokenEstimate
30
+ from .tokens import TiktokenCounter, TokenCounter, TokenEstimate, estimated_tokens
31
31
  from .work import Cancellation, raise_if_cancelled, validate_cancellation
32
32
 
33
33
 
@@ -245,7 +245,7 @@ def _finalize(
245
245
  def _block_accounting(blocks, question, tools, counter, estimate):
246
246
  empty_question = replace(question, content="")
247
247
  running: tuple[ContextBlock, ...] = ()
248
- previous = counter.count_request(render_request((), empty_question, tools)).estimated_tokens
248
+ previous = estimated_tokens(counter, render_request((), empty_question, tools))
249
249
  base = previous
250
250
  result = []
251
251
  by_kind = {b.kind: b for b in blocks}
@@ -254,9 +254,7 @@ def _block_accounting(blocks, question, tools, counter, estimate):
254
254
  current_question = question if kind == BlockKind.QUESTION else empty_question
255
255
  if kind != BlockKind.QUESTION:
256
256
  running += (item,)
257
- current = counter.count_request(
258
- render_request(running, current_question, tools)
259
- ).estimated_tokens
257
+ current = estimated_tokens(counter, render_request(running, current_question, tools))
260
258
  content = question.content if kind == BlockKind.QUESTION else item.content
261
259
  result.append(
262
260
  BlockUsage(
@@ -152,6 +152,14 @@ class TiktokenCounter:
152
152
  return _cached_count(self.config.encoding_name, text)
153
153
  return len(self._encoding.encode_ordinary(text))
154
154
 
155
+ def estimate_tokens(self, request: ChatRequest) -> int:
156
+ """`count_request(request).estimated_tokens` without the reporting-only parts."""
157
+ serialized = self.count_text(self.serializer.serialize(request))
158
+ return math.ceil(
159
+ (serialized + self.config.adapter_overhead_tokens)
160
+ * Fraction(str(self.config.calibration_factor))
161
+ )
162
+
155
163
  def count_request(self, request: ChatRequest) -> TokenEstimate:
156
164
  rendered = self.serializer.serialize(request)
157
165
  return TokenEstimate(
@@ -169,3 +177,9 @@ class TiktokenCounter:
169
177
  serializer_name=self.serializer.name,
170
178
  request_fingerprint=hashlib.sha256(rendered.encode("utf-8")).hexdigest(),
171
179
  )
180
+
181
+
182
+ def estimated_tokens(counter: TokenCounter, request: ChatRequest) -> int:
183
+ """Budget-check estimate; custom counters without `estimate_tokens` use the full count."""
184
+ fast = getattr(counter, "estimate_tokens", None)
185
+ return fast(request) if fast is not None else counter.count_request(request).estimated_tokens