coderouter-cli 2.11.2__py3-none-any.whl → 2.12.0__py3-none-any.whl

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.
@@ -27,6 +27,7 @@ from pydantic import (
27
27
  )
28
28
 
29
29
  from coderouter.logging import get_logger
30
+ from coderouter.token_estimation import set_include_tool_content
30
31
 
31
32
  logger = get_logger(__name__)
32
33
 
@@ -2306,6 +2307,36 @@ class CodeRouterConfig(BaseModel):
2306
2307
  ),
2307
2308
  )
2308
2309
 
2310
+ # H-5 escape hatch: token-estimation scope.
2311
+ #
2312
+ # Deliberately top-level rather than per-``FallbackChain``, even
2313
+ # though ``context_budget_action`` lives on the chain. The shared
2314
+ # char/4 estimator has four consumers and three of them run with no
2315
+ # profile in hand: the auto-router runs *before* a profile exists
2316
+ # (it is what picks one), ``POST /v1/messages/count_tokens`` answers
2317
+ # a client question that must not depend on which chain would have
2318
+ # served it, and language-tax measurement is profile-agnostic. A
2319
+ # per-profile switch would let the router and the guard disagree
2320
+ # about the size of the very same request — exactly the class of bug
2321
+ # this key exists to let operators back out of.
2322
+ token_estimation_include_tool_content: bool = Field(
2323
+ default=True,
2324
+ description=(
2325
+ "v2.12 (H-5): count ``tool_result`` / ``tool_use`` / "
2326
+ "``thinking`` content blocks in the shared char/4 token "
2327
+ "estimator. True (default) is the correct behavior — up to "
2328
+ "v2.11.x these blocks estimated to 0 chars, under-counting a "
2329
+ "Claude Code style session by 5x at 20 turns and ~29x at 200. "
2330
+ "Image blocks stay at 0 either way. Set to false as an escape "
2331
+ "hatch to restore v2.11.x-identical estimates (and therefore "
2332
+ "v2.11.x context-budget firing, auto-router "
2333
+ "``content_token_count_min`` matching and "
2334
+ "``/v1/messages/count_tokens`` numbers) if the corrected "
2335
+ "estimate disrupts a tuned deployment. Compatibility shim "
2336
+ "only — scheduled for removal in a future release."
2337
+ ),
2338
+ )
2339
+
2309
2340
  # v2.0-I: Continuous probing — background health checks for idle periods.
2310
2341
  continuous_probe: Literal["off", "active"] = Field(
2311
2342
  default="off",
@@ -2816,6 +2847,25 @@ class CodeRouterConfig(BaseModel):
2816
2847
  )
2817
2848
  return self
2818
2849
 
2850
+ @model_validator(mode="after")
2851
+ def _apply_token_estimation_scope(self) -> CodeRouterConfig:
2852
+ """Publish ``token_estimation_include_tool_content`` process-wide.
2853
+
2854
+ The char/4 estimator is a dependency-free leaf module that every
2855
+ consumer imports directly as a plain function; there is no
2856
+ object graph to thread a flag through, and three of the four
2857
+ consumers have no profile/config handle at their call site. So
2858
+ the loaded config pushes the setting into the module once, here.
2859
+
2860
+ Idempotent and deterministic: the value always reflects the most
2861
+ recently constructed config, which in a server process is the
2862
+ single config that was loaded at startup. Declared last so a
2863
+ config that fails any other validator never takes effect. Tests
2864
+ that flip it should restore the default (True) afterwards.
2865
+ """
2866
+ set_include_tool_content(self.token_estimation_include_tool_content)
2867
+ return self
2868
+
2819
2869
  def provider_by_name(self, name: str) -> ProviderConfig:
2820
2870
  """Look up a provider config by name. Raises KeyError if not found."""
2821
2871
  for p in self.providers:
@@ -38,6 +38,11 @@ Uses the shared :func:`~coderouter.token_estimation.estimate_tokens_from_anthrop
38
38
  (char/4 heuristic, 5-deps invariant). See that module's docstring
39
39
  for the CJK caveat and recommended threshold compensation.
40
40
 
41
+ Since H-5 the estimator also counts ``tool_result`` / ``tool_use`` /
42
+ ``thinking`` blocks, so a Claude-Code-shaped session now reports a
43
+ number 5-29x larger than it did in v2.11.x — i.e. the guard finally
44
+ fires on the sessions it was written for.
45
+
41
46
  Trim algorithm
42
47
  ==============
43
48
 
@@ -49,6 +54,16 @@ Trim algorithm
49
54
  ``tool_use`` assistant message (and vice versa).
50
55
  5. After removal, re-estimate; if still over ``trim_target``,
51
56
  reduce ``preserve_last_n`` by 1 and retry (minimum floor: 2).
57
+ 6. The result is **never** an empty message list — see
58
+ :func:`_normalize_head`.
59
+
60
+ Cost
61
+ ====
62
+
63
+ The incremental loop pre-computes each message's character count once
64
+ and subtracts as units are dropped, so a trim is O(total chars) rather
65
+ than O(messages x total chars). Before that change an 800-message trim
66
+ blocked the event loop for ~0.2s.
52
67
  """
53
68
 
54
69
  from __future__ import annotations
@@ -57,6 +72,9 @@ from dataclasses import dataclass
57
72
  from typing import TYPE_CHECKING, Any
58
73
 
59
74
  from coderouter.token_estimation import (
75
+ chars_to_tokens,
76
+ count_message_chars,
77
+ count_system_chars,
60
78
  estimate_tokens_from_anthropic_request,
61
79
  )
62
80
 
@@ -398,7 +416,37 @@ def _group_removable_units(
398
416
  return units
399
417
 
400
418
 
401
- def _normalize_head(messages: list[Any]) -> list[Any]:
419
+ def _role_of(msg: Any) -> Any:
420
+ """Role of a message (Pydantic model or dict)."""
421
+ if hasattr(msg, "role"):
422
+ return msg.role
423
+ if isinstance(msg, dict):
424
+ return msg.get("role")
425
+ return None
426
+
427
+
428
+ def _is_clean_user(msg: Any) -> bool:
429
+ """True for a ``user`` message that does not open with a tool_result."""
430
+ return _role_of(msg) == "user" and not _has_tool_result(msg)
431
+
432
+
433
+ #: Content of the synthetic head inserted when no real clean ``user``
434
+ #: message can be re-attached within budget. Short on purpose (~17
435
+ #: tokens): its only job is to give Anthropic the valid opening turn the
436
+ #: wire format demands while telling the model why history is missing.
437
+ TRIM_PLACEHOLDER_HEAD_TEXT = "[earlier conversation trimmed to fit the context budget]"
438
+
439
+
440
+ def _synthetic_head() -> dict[str, Any]:
441
+ """A minimal, always-affordable clean ``user`` message."""
442
+ return {"role": "user", "content": TRIM_PLACEHOLDER_HEAD_TEXT}
443
+
444
+
445
+ def _normalize_head(
446
+ messages: list[Any],
447
+ *,
448
+ fallback_head: Any | None = None,
449
+ ) -> list[Any]:
402
450
  """Drop leading messages until the first is a clean ``user`` message.
403
451
 
404
452
  Anthropic requires the first message to be a ``user`` message, and a
@@ -407,18 +455,77 @@ def _normalize_head(messages: list[Any]) -> list[Any]:
407
455
  any assistant message and any user message that contains a
408
456
  tool_result, stopping at the first ``user`` message with no
409
457
  tool_result block.
458
+
459
+ **Never returns an empty list for a non-empty input.** In a
460
+ tool-heavy agent session the preserved tail can legitimately look
461
+ like ``[assistant(tool_use), user(tool_result), assistant(tool_use),
462
+ user(tool_result)]`` — every single message matches the drop
463
+ condition, and the naive loop returned ``[]``. ``AnthropicRequest``
464
+ declares no ``min_length`` on ``messages``, so pydantic happily
465
+ forwarded that and the upstream API answered 400, destroying the
466
+ session the guard exists to protect. (Latent before H-5 only
467
+ because ``tool_result`` blocks estimated to 0 chars, so trim never
468
+ fired on exactly those conversations.)
469
+
470
+ When no surviving message can serve as the head, ``fallback_head``
471
+ is prepended. The caller picks it under the token budget — see
472
+ :func:`_pick_fallback_head`; this function deliberately does no
473
+ budget arithmetic of its own.
410
474
  """
411
- start = 0
412
- n = len(messages)
413
- while start < n:
414
- msg = messages[start]
415
- role = msg.role if hasattr(msg, "role") else (
416
- msg.get("role") if isinstance(msg, dict) else None
417
- )
418
- if role == "user" and not _has_tool_result(msg):
419
- break
420
- start += 1
421
- return messages[start:]
475
+ if not messages:
476
+ return list(messages)
477
+
478
+ for start, msg in enumerate(messages):
479
+ if _is_clean_user(msg):
480
+ return messages[start:]
481
+
482
+ # Every surviving message is an assistant turn or a tool_result-
483
+ # carrying user turn: normalizing by dropping would empty the list.
484
+ if fallback_head is not None:
485
+ return [fallback_head, *messages]
486
+ return list(messages)
487
+
488
+
489
+ def _pick_fallback_head(
490
+ messages: list[Any],
491
+ msg_chars: list[int],
492
+ dropped: set[int],
493
+ *,
494
+ before: int,
495
+ kept_chars: int,
496
+ target_tokens: int,
497
+ ) -> Any:
498
+ """Choose a head message that keeps the result inside the budget.
499
+
500
+ Called only when nothing in the surviving window is a clean ``user``
501
+ message, so *something* has to be put in front or the request is a
502
+ guaranteed 400.
503
+
504
+ Selection order:
505
+
506
+ 1. The **most recent** dropped clean ``user`` message that still
507
+ fits under ``target_tokens`` — recency matters because it is
508
+ usually the operator's latest standing instruction.
509
+ 2. Failing that, a synthetic placeholder (:func:`_synthetic_head`).
510
+
511
+ The budget check is the whole point. Re-attaching the *newest*
512
+ candidate unconditionally is a trap: the most recent clean user
513
+ message in an agent session is very often a huge pasted file, and
514
+ restoring it undoes the entire trim. Measured on the canonical
515
+ "200 KB paste then 25 tool calls" shape, that path turned a
516
+ 67,066-token request into a 67,061-token request against a 24,576
517
+ target — reported as ``status="trimmed"`` and then rejected
518
+ upstream. A budget-blind restore does not avoid the 400, it
519
+ guarantees it.
520
+ """
521
+ for idx in sorted(dropped, reverse=True):
522
+ if idx >= before:
523
+ continue
524
+ if not _is_clean_user(messages[idx]):
525
+ continue
526
+ if chars_to_tokens(kept_chars + msg_chars[idx]) <= target_tokens:
527
+ return messages[idx]
528
+ return _synthetic_head()
422
529
 
423
530
 
424
531
  def _do_trim(
@@ -437,12 +544,23 @@ def _do_trim(
437
544
  hard floor that is never removed. After trimming, the head is
438
545
  normalized so the first surviving message is a ``user`` message
439
546
  without a leading ``tool_result`` (avoids upstream 400s). Bug H5.
547
+
548
+ The loop is incremental in *characters*: every message's character
549
+ contribution is computed exactly once up front and subtracted as
550
+ units are dropped, instead of rebuilding the kept list and
551
+ re-walking the whole conversation on every iteration (the old
552
+ O(messages x chars) shape cost ~0.2s of event-loop time for an
553
+ 800-message session). Because the char/4 heuristic floor-divides
554
+ the grand total exactly once, the running sum stays bit-identical
555
+ to a full recompute.
440
556
  """
441
557
  if not messages:
442
558
  return messages
443
559
 
444
- already = estimate_tokens_from_anthropic_request(system=system, messages=messages)
445
- if already <= target_tokens:
560
+ system_chars = count_system_chars(system)
561
+ msg_chars = [count_message_chars(m) for m in messages]
562
+ total_chars = system_chars + sum(msg_chars)
563
+ if chars_to_tokens(total_chars) <= target_tokens:
446
564
  # Nothing to do — return the input unchanged.
447
565
  return messages
448
566
 
@@ -453,14 +571,41 @@ def _do_trim(
453
571
  # Indices we have decided to drop; peel atomic units from the front.
454
572
  dropped: set[int] = set()
455
573
  for unit in units:
456
- kept = [messages[i] for i in range(len(messages)) if i not in dropped]
457
- estimated = estimate_tokens_from_anthropic_request(system=system, messages=kept)
458
- if estimated <= target_tokens:
574
+ if chars_to_tokens(total_chars) <= target_tokens:
459
575
  break
576
+ unit_chars = sum(msg_chars[i] for i in unit)
577
+ if unit_chars <= 0:
578
+ # Dropping this unit would not move the estimate at all
579
+ # (e.g. an image-only turn, which counts as 0 chars by
580
+ # design). Destroying history for no budget gain is pure
581
+ # loss — skip it and try the next unit.
582
+ continue
460
583
  dropped.update(unit)
461
-
462
- trimmed = [messages[i] for i in range(len(messages)) if i not in dropped]
463
- return _normalize_head(trimmed)
584
+ total_chars -= unit_chars
585
+
586
+ kept_indices = [i for i in range(len(messages)) if i not in dropped]
587
+ trimmed = [messages[i] for i in kept_indices]
588
+ if not trimmed:
589
+ # Structural fail-safe: never hand back an empty history.
590
+ return list(messages[-max(preserve_last_n, 1) :])
591
+
592
+ if any(_is_clean_user(m) for m in trimmed):
593
+ # A real head survives — no fallback needed, and computing one
594
+ # would only cost time.
595
+ return _normalize_head(trimmed)
596
+
597
+ # Nothing in the surviving window can open the request. Pick a head
598
+ # that fits the remaining budget (``total_chars`` already reflects
599
+ # every drop above), falling back to a synthetic marker.
600
+ fallback_head = _pick_fallback_head(
601
+ messages,
602
+ msg_chars,
603
+ dropped,
604
+ before=kept_indices[0],
605
+ kept_chars=total_chars,
606
+ target_tokens=target_tokens,
607
+ )
608
+ return _normalize_head(trimmed, fallback_head=fallback_head)
464
609
 
465
610
 
466
611
  __all__ = [
@@ -714,11 +714,21 @@ async def _backend_ready(backend: str, port: int, *, probe_timeout_s: float) ->
714
714
  Falling through to the bare TCP connect here would silently re-introduce
715
715
  the bug readiness gating was added to fix (provider registered before the
716
716
  model finished loading).
717
+
718
+ The probe targets ``127.0.0.1`` literally, never ``localhost``. On a host
719
+ where ``localhost`` resolves to ``::1`` first — the default on GitHub's
720
+ macOS runners, and common on Macs generally — an httpx request to
721
+ ``http://localhost:<port>`` fails with ``Address family not supported``
722
+ against a backend listening on IPv4 only, which is what ``llama-server``
723
+ does by default (``--host 127.0.0.1``). Readiness then never succeeds and
724
+ the spawn times out with ``status='loading'`` even though the backend is
725
+ up and serving. The bare TCP fallback below already used ``127.0.0.1``;
726
+ this branch was the odd one out.
717
727
  """
718
728
  if base_backend(backend) in ("llama.cpp", "vllm"):
719
729
  try:
720
730
  async with httpx.AsyncClient(timeout=probe_timeout_s) as client:
721
- resp = await client.get(f"http://localhost:{port}/health")
731
+ resp = await client.get(f"http://127.0.0.1:{port}/health")
722
732
  return resp.status_code == 200
723
733
  except Exception:
724
734
  return False
coderouter/py.typed ADDED
File without changes
@@ -6,6 +6,8 @@ Provides a shared char/4 token estimator used by:
6
6
  matcher for longContext auto-switch (v1.10).
7
7
  - :mod:`coderouter.guards.context_budget` — L1 context overflow
8
8
  detection (v2.0-F).
9
+ - ``POST /v1/messages/count_tokens`` (:mod:`coderouter.ingress.anthropic_routes`).
10
+ - :mod:`coderouter.language_tax` — CJK tax measurement.
9
11
 
10
12
  No external dependencies. The heuristic is ``total_chars // 4``:
11
13
 
@@ -18,6 +20,58 @@ No external dependencies. The heuristic is ``total_chars // 4``:
18
20
  this is a known gap; operators can lower the warn/trim thresholds
19
21
  to compensate (e.g. 0.60 / 0.70 instead of 0.80 / 0.90).
20
22
 
23
+ Which content blocks are counted (H-5, v2.12)
24
+ =============================================
25
+
26
+ Up to v2.11.x only ``text`` blocks were counted. Agent clients
27
+ (Claude Code, Cline, OpenClaw, …) put almost all of their context in
28
+ ``tool_result`` and ``tool_use`` blocks, so the estimate was off by
29
+ 5x at 20 turns and ~29x at 200 turns for a tool-heavy session. The
30
+ extractor now dispatches on the block ``type``:
31
+
32
+ ===================== ===========================================
33
+ block type contribution
34
+ ===================== ===========================================
35
+ ``text`` ``text`` verbatim
36
+ ``tool_result`` ``content`` — ``str`` verbatim, or the same
37
+ per-type rules applied recursively to the
38
+ block list (so a nested ``image`` is still 0)
39
+ ``tool_use`` ``name`` + ``json.dumps(input)``
40
+ ``thinking`` the ``thinking`` string (it is replayed back
41
+ to the model on the next turn, so it really
42
+ does occupy the context window)
43
+ ``image`` **0** — deliberately
44
+ ``redacted_thinking`` 0 (opaque ciphertext, not model-visible text)
45
+ anything else 0 (forward-compatible default)
46
+ ===================== ===========================================
47
+
48
+ The ``image`` exclusion is load-bearing, not an oversight: a naive
49
+ ``json.dumps(block)`` of a request carrying a 400 KB base64 PNG
50
+ over-estimates by ~35x (11,789 → 410,781 tokens measured), which
51
+ would make the context-budget guard shred the history of any session
52
+ that ever pasted a screenshot. Images are billed on their own axis
53
+ and must stay at 0.
54
+
55
+ Known cross-path inconsistency (out of scope for H-5)
56
+ -----------------------------------------------------
57
+ ``POST /v1/messages/count_tokens`` additionally appends the JSON of
58
+ the request's ``tools`` array before counting (see
59
+ ``ingress/anthropic_routes.py``), while
60
+ :func:`estimate_tokens_from_anthropic_request` /
61
+ :func:`estimate_tokens_from_body` do **not** count tool *definitions*
62
+ at all. A request with large tool schemas therefore gets a bigger
63
+ number from ``count_tokens`` than the context-budget guard sees. This
64
+ is pre-existing behavior and is intentionally left unchanged here —
65
+ tracked as a follow-up, not part of the H-5 fix.
66
+
67
+ Escape hatch
68
+ ------------
69
+ :func:`set_include_tool_content` (wired to the top-level config key
70
+ ``token_estimation_include_tool_content``) restores the exact v2.11.x
71
+ numbers by skipping ``tool_result`` / ``tool_use`` / ``thinking``
72
+ again. It exists only to unblock operators surprised by the new
73
+ (correct) guard/routing behavior and is scheduled for removal.
74
+
21
75
  Future (v2.0+): detect ``tiktoken`` at import time and offer a
22
76
  precision mode. The public API is stable either way — callers get
23
77
  an ``int`` estimate regardless of the backend.
@@ -25,6 +79,7 @@ an ``int`` estimate regardless of the backend.
25
79
 
26
80
  from __future__ import annotations
27
81
 
82
+ import json
28
83
  from typing import Any
29
84
 
30
85
  # ---------------------------------------------------------------------------
@@ -40,40 +95,153 @@ CHARS_PER_TOKEN_HEURISTIC: int = 4
40
95
  #: operators who use it should declare explicitly or via registry).
41
96
  DEFAULT_MAX_CONTEXT_TOKENS: int = 128_000
42
97
 
98
+ #: Depth cap for the recursive block walk. ``tool_result.content`` is
99
+ #: the only legal nesting level in the Anthropic wire format; the cap
100
+ #: just makes a hand-crafted / buggy payload unable to blow the stack.
101
+ _MAX_BLOCK_DEPTH: int = 4
102
+
103
+ #: Block types that never contribute characters, whatever the flags.
104
+ #: ``image`` is base64 (or a URL) billed on a separate axis;
105
+ #: ``redacted_thinking`` is opaque ciphertext the model never reads as
106
+ #: text. Counting either one over-estimates catastrophically.
107
+ _ZERO_CHAR_BLOCK_TYPES: frozenset[str] = frozenset(
108
+ {"image", "image_url", "redacted_thinking"}
109
+ )
110
+
111
+ #: Block types gated behind the ``include_tool_content`` switch.
112
+ _TOOL_BLOCK_TYPES: frozenset[str] = frozenset({"tool_result", "tool_use", "thinking"})
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Process-wide opt-out (v2.11.x compatibility escape hatch)
117
+ # ---------------------------------------------------------------------------
118
+
119
+ #: Module-level default for :func:`set_include_tool_content`. Set from
120
+ #: ``CodeRouterConfig.token_estimation_include_tool_content`` at config
121
+ #: load time. A module global (rather than a parameter threaded through
122
+ #: every call site) is required because three of the four consumers —
123
+ #: the auto-router, ``count_tokens`` and language-tax — run *outside*
124
+ #: any profile context and must all agree on one number per process.
125
+ _INCLUDE_TOOL_CONTENT: bool = True
126
+
127
+
128
+ def set_include_tool_content(enabled: bool) -> None:
129
+ """Set the process-wide default for tool-content counting.
130
+
131
+ Called from ``CodeRouterConfig``'s validator so a single config key
132
+ controls every consumer of this module consistently. ``False``
133
+ reproduces v2.11.x estimates exactly.
134
+ """
135
+ global _INCLUDE_TOOL_CONTENT
136
+ _INCLUDE_TOOL_CONTENT = bool(enabled)
137
+
138
+
139
+ def get_include_tool_content() -> bool:
140
+ """Return the current process-wide tool-content counting default."""
141
+ return _INCLUDE_TOOL_CONTENT
142
+
143
+
144
+ def _resolve_include(include_tool_content: bool | None) -> bool:
145
+ return _INCLUDE_TOOL_CONTENT if include_tool_content is None else include_tool_content
146
+
43
147
 
44
148
  # ---------------------------------------------------------------------------
45
149
  # Text extraction helpers
46
150
  # ---------------------------------------------------------------------------
47
151
 
48
152
 
49
- def _extract_text_from_content(content: Any) -> str:
153
+ def _tool_use_text(block: dict[str, Any]) -> str:
154
+ """Serialize a ``tool_use`` block's name + input for char counting.
155
+
156
+ ``input`` is arbitrary JSON, so its wire size is the honest proxy
157
+ for how much context it occupies. ``ensure_ascii=False`` keeps CJK
158
+ arguments at their real character count instead of inflating them
159
+ into ``\\uXXXX`` escapes.
160
+ """
161
+ pieces: list[str] = []
162
+ name = block.get("name")
163
+ if isinstance(name, str):
164
+ pieces.append(name)
165
+ raw_input = block.get("input")
166
+ if raw_input is not None:
167
+ try:
168
+ pieces.append(json.dumps(raw_input, ensure_ascii=False))
169
+ except (TypeError, ValueError):
170
+ pieces.append(str(raw_input))
171
+ return "\n".join(pieces)
172
+
173
+
174
+ def _extract_text_from_content(
175
+ content: Any,
176
+ *,
177
+ include_tool_content: bool | None = None,
178
+ _depth: int = 0,
179
+ ) -> str:
50
180
  """Extract concatenated text from a message's ``content`` field.
51
181
 
52
182
  Handles:
53
183
  - ``str`` (short form) → returned verbatim.
54
- - ``list[dict]`` (multimodal blocks) → text-type blocks
55
- concatenated with newlines. Image / tool_use / tool_result
56
- blocks contribute 0 chars (billed differently or don't
57
- consume text-token budget).
184
+ - ``list[dict]`` (multimodal blocks) → dispatched per block
185
+ ``type``; see the module docstring's table for the exact rules.
186
+ ``image`` blocks always contribute 0 chars; ``tool_result`` /
187
+ ``tool_use`` / ``thinking`` contribute their text unless
188
+ ``include_tool_content`` is False.
58
189
  - ``None`` / other → empty string.
190
+
191
+ ``include_tool_content=None`` (the default) resolves to the
192
+ process-wide setting from :func:`set_include_tool_content`.
59
193
  """
194
+ include = _resolve_include(include_tool_content)
60
195
  if isinstance(content, str):
61
196
  return content
62
- if isinstance(content, list):
63
- pieces: list[str] = []
64
- for block in content:
65
- if not isinstance(block, dict):
197
+ if not isinstance(content, list):
198
+ return ""
199
+
200
+ pieces: list[str] = []
201
+ for block in content:
202
+ if not isinstance(block, dict):
203
+ continue
204
+ btype = block.get("type")
205
+
206
+ if btype == "text":
207
+ text = block.get("text")
208
+ if isinstance(text, str):
209
+ pieces.append(text)
210
+ continue
211
+ if btype is None:
212
+ # Fallback for blocks without explicit type but with text
213
+ text = block.get("text")
214
+ if isinstance(text, str):
215
+ pieces.append(text)
216
+ continue
217
+ if btype in _ZERO_CHAR_BLOCK_TYPES:
218
+ # Deliberate 0: see module docstring (35x over-estimate).
219
+ continue
220
+ if btype not in _TOOL_BLOCK_TYPES or not include:
221
+ # Unknown/forward-compat blocks contribute nothing, and the
222
+ # escape hatch drops the tool blocks back to v2.11.x's 0.
223
+ continue
224
+
225
+ if btype == "tool_result":
226
+ if _depth >= _MAX_BLOCK_DEPTH:
66
227
  continue
67
- btype = block.get("type")
68
- if btype == "text":
69
- text = block.get("text")
70
- if isinstance(text, str):
71
- pieces.append(text)
72
- elif btype is None and "text" in block and isinstance(block["text"], str):
73
- # Fallback for blocks without explicit type but with text
74
- pieces.append(block["text"])
75
- return "\n".join(pieces)
76
- return ""
228
+ inner = _extract_text_from_content(
229
+ block.get("content"),
230
+ include_tool_content=True,
231
+ _depth=_depth + 1,
232
+ )
233
+ if inner:
234
+ pieces.append(inner)
235
+ elif btype == "tool_use":
236
+ rendered = _tool_use_text(block)
237
+ if rendered:
238
+ pieces.append(rendered)
239
+ elif btype == "thinking":
240
+ thinking = block.get("thinking")
241
+ if isinstance(thinking, str):
242
+ pieces.append(thinking)
243
+
244
+ return "\n".join(pieces)
77
245
 
78
246
 
79
247
  def _count_system_chars(system: Any) -> int:
@@ -106,12 +274,56 @@ def _extract_system_text(system: Any) -> str:
106
274
  return ""
107
275
 
108
276
 
277
+ def _message_content(msg: Any) -> Any:
278
+ """Pull ``content`` off a Pydantic message model or a plain dict."""
279
+ if hasattr(msg, "content"):
280
+ return msg.content
281
+ if isinstance(msg, dict):
282
+ return msg.get("content")
283
+ return None
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Public API: character-level primitives
288
+ # ---------------------------------------------------------------------------
289
+ #
290
+ # The guard's trim loop needs per-message character counts so it can
291
+ # subtract as it drops instead of re-walking the whole conversation
292
+ # after every removal (O(n^2) → O(n)). Counting *chars* rather than
293
+ # per-message tokens keeps the incremental sum bit-identical to a full
294
+ # recompute: the heuristic floor-divides the grand total exactly once.
295
+
296
+
297
+ def count_system_chars(system: Any) -> int:
298
+ """Public alias of the system-prompt character count."""
299
+ return _count_system_chars(system)
300
+
301
+
302
+ def count_message_chars(msg: Any, *, include_tool_content: bool | None = None) -> int:
303
+ """Characters a single message contributes to the estimate."""
304
+ return len(
305
+ _extract_text_from_content(
306
+ _message_content(msg),
307
+ include_tool_content=include_tool_content,
308
+ )
309
+ )
310
+
311
+
312
+ def chars_to_tokens(total_chars: int) -> int:
313
+ """Apply the char/4 heuristic to an already-summed character count."""
314
+ return total_chars // CHARS_PER_TOKEN_HEURISTIC
315
+
316
+
109
317
  # ---------------------------------------------------------------------------
110
318
  # Public API
111
319
  # ---------------------------------------------------------------------------
112
320
 
113
321
 
114
- def estimate_tokens_from_body(body: dict[str, Any]) -> int:
322
+ def estimate_tokens_from_body(
323
+ body: dict[str, Any],
324
+ *,
325
+ include_tool_content: bool | None = None,
326
+ ) -> int:
115
327
  """Estimate the prompt's token count from a raw request body dict.
116
328
 
117
329
  Works with both Anthropic (``system`` + ``messages``) and OpenAI
@@ -120,7 +332,10 @@ def estimate_tokens_from_body(body: dict[str, Any]) -> int:
120
332
  sums character counts, divides by :data:`CHARS_PER_TOKEN_HEURISTIC`.
121
333
 
122
334
  Image blocks contribute 0 — they're billed differently and don't
123
- fill the text-token side of the context window.
335
+ fill the text-token side of the context window. ``tool_result`` /
336
+ ``tool_use`` / ``thinking`` blocks DO count (H-5); see the module
337
+ docstring. Tool *definitions* (``body["tools"]``) are still not
338
+ counted — known inconsistency with ``count_tokens``.
124
339
 
125
340
  This is the function formerly known as
126
341
  ``coderouter.routing.auto_router._estimate_total_tokens``.
@@ -130,14 +345,20 @@ def estimate_tokens_from_body(body: dict[str, Any]) -> int:
130
345
  if isinstance(messages, list):
131
346
  for msg in messages:
132
347
  if isinstance(msg, dict):
133
- total_chars += len(_extract_text_from_content(msg.get("content")))
134
- return total_chars // CHARS_PER_TOKEN_HEURISTIC
348
+ total_chars += len(
349
+ _extract_text_from_content(
350
+ msg.get("content"),
351
+ include_tool_content=include_tool_content,
352
+ )
353
+ )
354
+ return chars_to_tokens(total_chars)
135
355
 
136
356
 
137
357
  def estimate_tokens_from_anthropic_request(
138
358
  *,
139
359
  system: Any,
140
360
  messages: list[Any],
361
+ include_tool_content: bool | None = None,
141
362
  ) -> int:
142
363
  """Estimate token count for an Anthropic-shaped request.
143
364
 
@@ -153,6 +374,10 @@ def estimate_tokens_from_anthropic_request(
153
374
  messages : list[AnthropicMessage]
154
375
  The request's ``messages`` list. Each item should have a
155
376
  ``content`` attribute (str or list of blocks).
377
+ include_tool_content : bool | None
378
+ ``None`` → process-wide default (see
379
+ :func:`set_include_tool_content`). ``False`` reproduces the
380
+ v2.11.x estimate exactly.
156
381
  """
157
382
  total_chars = _count_system_chars(system)
158
383
  for msg in messages:
@@ -164,23 +389,26 @@ def estimate_tokens_from_anthropic_request(
164
389
  content = msg.get("content")
165
390
  else:
166
391
  continue
167
- total_chars += len(_extract_text_from_content(content))
168
- return total_chars // CHARS_PER_TOKEN_HEURISTIC
392
+ total_chars += len(
393
+ _extract_text_from_content(content, include_tool_content=include_tool_content)
394
+ )
395
+ return chars_to_tokens(total_chars)
169
396
 
170
397
 
171
398
  def extract_text_from_anthropic_request(
172
399
  *,
173
400
  system: Any,
174
401
  messages: list[Any],
402
+ include_tool_content: bool | None = None,
175
403
  ) -> str:
176
404
  """Concatenate all text in an Anthropic-shaped request.
177
405
 
178
406
  Mirrors :func:`estimate_tokens_from_anthropic_request` but returns
179
- the raw text (system prompt + every message's text blocks) instead
180
- of a char/4 count. Used by :mod:`coderouter.language_tax` to feed an
181
- accurate tokenizer for language-tax measurement. Non-text blocks
182
- (images / tool_use / tool_result) contribute nothing — same rule the
183
- char/4 estimator uses.
407
+ the raw text (system prompt + every message's countable blocks)
408
+ instead of a char/4 count. Used by :mod:`coderouter.language_tax`
409
+ to feed an accurate tokenizer for language-tax measurement, and by
410
+ ``POST /v1/messages/count_tokens``. Image blocks contribute nothing
411
+ — same rule the char/4 estimator uses.
184
412
  """
185
413
  pieces: list[str] = []
186
414
  sys_text = _extract_system_text(system)
@@ -193,7 +421,7 @@ def extract_text_from_anthropic_request(
193
421
  content = msg.get("content")
194
422
  else:
195
423
  continue
196
- text = _extract_text_from_content(content)
424
+ text = _extract_text_from_content(content, include_tool_content=include_tool_content)
197
425
  if text:
198
426
  pieces.append(text)
199
427
  return "\n".join(pieces)
@@ -202,7 +430,12 @@ def extract_text_from_anthropic_request(
202
430
  __all__ = [
203
431
  "CHARS_PER_TOKEN_HEURISTIC",
204
432
  "DEFAULT_MAX_CONTEXT_TOKENS",
433
+ "chars_to_tokens",
434
+ "count_message_chars",
435
+ "count_system_chars",
205
436
  "estimate_tokens_from_anthropic_request",
206
437
  "estimate_tokens_from_body",
207
438
  "extract_text_from_anthropic_request",
439
+ "get_include_tool_content",
440
+ "set_include_tool_content",
208
441
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.11.2
3
+ Version: 2.12.0
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -15,7 +15,7 @@ coderouter/launcher_speculative.py,sha256=h0RTGsD1Y56sJVywDJwybrT75_Ta3GF1Jz_B1d
15
15
  coderouter/launcher_swap.py,sha256=tMW5ZElDdf9T1VQGsUkvfLiSCdj_oXN_UtkV7DE8ySY,24175
16
16
  coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
17
17
  coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
18
- coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
18
+ coderouter/token_estimation.py,sha256=7klQYA4FIg1vtjQGWAhwiBhiLwyz8KTetrLVXFJLGIc,16957
19
19
  coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
20
20
  coderouter/vscode_init.py,sha256=fW0chCDTngvWQuJgQ7aqYmE6hnZO3hCk8mAiEkZ9FCo,26741
21
21
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
@@ -27,13 +27,13 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
27
27
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
28
28
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
29
29
  coderouter/config/loader.py,sha256=jUoHAbEyjK6Y0HDqhjXHO4tfncMPuzsjfWJokrPb2x8,6876
30
- coderouter/config/schemas.py,sha256=8MZMW3Iask-ZvkWV2b77UYszqMgJ4ETc6gEU80Ekx9g,131685
30
+ coderouter/config/schemas.py,sha256=AK2lsPMqBa6NYEXNHwxis8Nd9bcW2q_x4ciO3QuYIpk,134441
31
31
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
32
32
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
33
33
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
34
34
  coderouter/guards/_fingerprint.py,sha256=qsgNzIq9jv3FHrKL39nGJARp0cMenpN_QmWoJu87vU4,4835
35
35
  coderouter/guards/backend_health.py,sha256=GLUVQ5BhU6i8Cxz_FrgIMs5AZ-YSKAW8veFWgvn9GFI,12565
36
- coderouter/guards/context_budget.py,sha256=6u08JqBdRQkDz9NIQ-aISXo3w3L804oYFg027s04IwY,17202
36
+ coderouter/guards/context_budget.py,sha256=gw3MkiHDjpLmQTlTdJT6gRAxYEnoU0xmwSvPZk_miOQ,23059
37
37
  coderouter/guards/continuous_probe.py,sha256=WIfS-apVMWXGv7bPBxxkJssePa95I4fT0mi4PNqK5iE,12181
38
38
  coderouter/guards/drift_actions.py,sha256=A6pY5CR480Ct5rCVyjlBvjPFVc93eu_r5qcUpK9mWKc,3602
39
39
  coderouter/guards/drift_detection.py,sha256=lW45EZDGRleDUTstw_CHlfUiwLUrlDHX8g7ncuOYNEw,13962
@@ -45,7 +45,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
45
45
  coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
46
46
  coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
47
47
  coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
48
- coderouter/ingress/launcher_routes.py,sha256=g-0Mee1vpY10b82jI51-2fTeR8hxutMCaLYBcicwM_k,133287
48
+ coderouter/ingress/launcher_routes.py,sha256=ipgY1omiSGQn5RBvsuQMLepCJ03w4eafacg2JDVufe8,133937
49
49
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
50
50
  coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
51
51
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
@@ -71,8 +71,9 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
71
71
  coderouter/translation/anthropic.py,sha256=ZQQGS9itN_3o7IKD68lKr5gR_Kem32Jsd7OcGjJzv8Q,13468
72
72
  coderouter/translation/convert.py,sha256=VrstDAMsUpcQ1VgMTn_-ETp1OjGX8v0ZGhCkcd4icyU,53037
73
73
  coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
74
- coderouter_cli-2.11.2.dist-info/METADATA,sha256=zw-XK_yuV-v6dBPqAohL8iYdQJW_rmbI0UhmVvyfT0s,16897
75
- coderouter_cli-2.11.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
76
- coderouter_cli-2.11.2.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
77
- coderouter_cli-2.11.2.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
78
- coderouter_cli-2.11.2.dist-info/RECORD,,
74
+ coderouter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
+ coderouter_cli-2.12.0.dist-info/METADATA,sha256=B1BU4vM2YPNzqR8sMFJy7vfPPUqBliwXqqpiLESl7mE,16897
76
+ coderouter_cli-2.12.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
77
+ coderouter_cli-2.12.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
78
+ coderouter_cli-2.12.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
79
+ coderouter_cli-2.12.0.dist-info/RECORD,,