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.
- coderouter/config/schemas.py +50 -0
- coderouter/guards/context_budget.py +165 -20
- coderouter/ingress/launcher_routes.py +11 -1
- coderouter/py.typed +0 -0
- coderouter/token_estimation.py +264 -31
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.12.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.12.0.dist-info}/RECORD +10 -9
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.12.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.12.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.12.0.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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://
|
|
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
|
coderouter/token_estimation.py
CHANGED
|
@@ -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
|
|
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) →
|
|
55
|
-
|
|
56
|
-
blocks contribute 0 chars
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
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(
|
|
134
|
-
|
|
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(
|
|
168
|
-
|
|
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
|
|
180
|
-
of a char/4 count. Used by :mod:`coderouter.language_tax`
|
|
181
|
-
accurate tokenizer for language-tax measurement
|
|
182
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
75
|
-
coderouter_cli-2.
|
|
76
|
-
coderouter_cli-2.
|
|
77
|
-
coderouter_cli-2.
|
|
78
|
-
coderouter_cli-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|