coderouter-cli 2.11.2__py3-none-any.whl → 2.13.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.
@@ -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__ = [
@@ -50,6 +50,14 @@ Design choices
50
50
  are guarded independently.
51
51
  - **No new dependency** — subprocess (stdlib) for restart commands,
52
52
  asyncio for recovery probe scheduling.
53
+ - **v2.13.0: argv dispatch (shell=False)** — ``restart_command`` is run
54
+ as ``subprocess.run(shlex.split(command), shell=False)``, never through
55
+ a shell. A value containing shell metacharacters is refused outright
56
+ (not run), because under shell=False those tokens would half-execute
57
+ the command (e.g. ``touch a; b`` would create a file named ``a;`` and
58
+ exit 0, falsely reporting a successful restart). This closes the H-1
59
+ code-execution vector where a hostile CWD providers.yaml could drive
60
+ arbitrary shell through self-healing.
53
61
  - **Restart is opt-in** — only providers with ``restart_command``
54
62
  set get automatic restart. Others rely solely on recovery probes
55
63
  (waiting for manual restart by the operator).
@@ -62,12 +70,14 @@ from __future__ import annotations
62
70
 
63
71
  import asyncio
64
72
  import contextlib
73
+ import os
74
+ import shlex
65
75
  import subprocess
66
76
  import threading
67
77
  import time
68
78
  from dataclasses import dataclass
69
79
 
70
- from coderouter.config.schemas import ProviderConfig
80
+ from coderouter.config.schemas import RESTART_COMMAND_SHELL_META_RE, ProviderConfig
71
81
  from coderouter.logging import (
72
82
  get_logger,
73
83
  log_self_healing_exclude,
@@ -268,6 +278,51 @@ class SelfHealingOrchestrator:
268
278
 
269
279
  provider = provider_config.name
270
280
 
281
+ # v2.13.0: argv dispatch (shell=False). We validate/parse the command
282
+ # BEFORE taking the per-provider restart lock so a malformed value
283
+ # short-circuits without ever touching the lock (and a later valid
284
+ # attempt is unaffected).
285
+ #
286
+ # A-5: refuse anything with shell metacharacters. Under shell=False,
287
+ # ``touch a; b`` would create a file literally named ``a;`` and exit
288
+ # 0 — reporting a "successful" restart while only half-executing the
289
+ # intended pipeline. Refusing loudly beats half-running silently.
290
+ if RESTART_COMMAND_SHELL_META_RE.search(command):
291
+ log_self_healing_restart(
292
+ logger,
293
+ provider=provider,
294
+ command=command,
295
+ success=False,
296
+ error=(
297
+ "restart_command contains shell syntax but is no longer "
298
+ "run through a shell (v2.13.0). Wrap it in a script and "
299
+ "point restart_command at that script, or write it as "
300
+ "/bin/sh -c '...'."
301
+ ),
302
+ )
303
+ return False
304
+
305
+ try:
306
+ argv = shlex.split(command, posix=(os.name != "nt"))
307
+ except ValueError as exc:
308
+ log_self_healing_restart(
309
+ logger,
310
+ provider=provider,
311
+ command=command,
312
+ success=False,
313
+ error=f"unparsable restart_command: {exc}",
314
+ )
315
+ return False
316
+ if not argv:
317
+ log_self_healing_restart(
318
+ logger,
319
+ provider=provider,
320
+ command=command,
321
+ success=False,
322
+ error="restart_command is empty after parsing",
323
+ )
324
+ return False
325
+
271
326
  # Get or create a per-provider lock.
272
327
  with self._lock:
273
328
  if provider not in self._restart_locks:
@@ -281,8 +336,8 @@ class SelfHealingOrchestrator:
281
336
 
282
337
  try:
283
338
  result = subprocess.run(
284
- command,
285
- shell=True,
339
+ argv,
340
+ shell=False,
286
341
  capture_output=True,
287
342
  timeout=timeout_s,
288
343
  text=True,
coderouter/ingress/app.py CHANGED
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
10
10
  from fastapi import FastAPI, Request
11
11
  from fastapi.responses import JSONResponse
12
12
  from starlette.middleware.base import BaseHTTPMiddleware
13
- from starlette.types import ASGIApp
13
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
14
14
 
15
15
  from coderouter import __version__
16
16
  from coderouter.config import load_config
@@ -101,37 +101,78 @@ def _parse_max_body_bytes(raw: str | None) -> int:
101
101
  return value if value > 0 else _DEFAULT_MAX_BODY_BYTES
102
102
 
103
103
 
104
- class BodySizeLimitMiddleware(BaseHTTPMiddleware):
105
- """Reject requests whose declared body exceeds the configured cap.
104
+ class _BodyTooLarge(Exception):
105
+ pass
106
106
 
107
- The check is on the incoming ``Content-Length`` header, so it is cheap
108
- (no body is read) and fires before the route handler runs. Streaming
109
- *responses* (SSE) are unaffected — this only inspects the request side.
110
- A body over the limit fails closed with 413.
107
+
108
+ class BodySizeLimitMiddleware:
109
+ """Reject requests whose body exceeds the configured cap.
110
+
111
+ M-3: this is a **pure ASGI** middleware, not a ``BaseHTTPMiddleware``.
112
+ The old dispatch only checked the ``Content-Length`` header, so a chunked
113
+ request (no Content-Length) bypassed the cap entirely. We now also count
114
+ the bytes actually received and fail closed with 413 the moment the total
115
+ crosses the limit. It must be pure ASGI because a ``BaseHTTPMiddleware``
116
+ that consumes ``request.stream()`` to measure the body would hand a
117
+ drained (empty) body to the downstream route.
118
+
119
+ Streaming *responses* (SSE) are unaffected — this only inspects the
120
+ request side.
111
121
  """
112
122
 
113
123
  def __init__(self, app: ASGIApp, max_bytes: int) -> None:
114
- super().__init__(app)
124
+ self.app = app
115
125
  self._max_bytes = max_bytes
116
126
 
117
- async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
118
- content_length = request.headers.get("content-length")
119
- if content_length is not None:
120
- try:
121
- declared = int(content_length)
122
- except ValueError:
123
- declared = -1
124
- if declared > self._max_bytes:
125
- return JSONResponse(
126
- status_code=413,
127
- content={
128
- "detail": (
129
- f"Request body too large: {declared} bytes exceeds "
130
- f"the {self._max_bytes}-byte limit."
131
- )
132
- },
127
+ def _too_large(self, observed: int) -> JSONResponse:
128
+ return JSONResponse(
129
+ status_code=413,
130
+ content={
131
+ "detail": (
132
+ f"Request body too large: {observed} bytes exceeds "
133
+ f"the {self._max_bytes}-byte limit."
133
134
  )
134
- return await call_next(request)
135
+ },
136
+ )
137
+
138
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
139
+ if scope["type"] != "http":
140
+ await self.app(scope, receive, send)
141
+ return
142
+ for key, value in scope.get("headers", []):
143
+ if key == b"content-length":
144
+ try:
145
+ declared = int(value)
146
+ except ValueError:
147
+ break
148
+ if declared > self._max_bytes:
149
+ await self._too_large(declared)(scope, receive, send)
150
+ return
151
+ break
152
+ received = 0
153
+ response_started = False
154
+
155
+ async def limited_receive() -> Message:
156
+ nonlocal received
157
+ message = await receive()
158
+ if message["type"] == "http.request":
159
+ received += len(message.get("body", b""))
160
+ if received > self._max_bytes:
161
+ raise _BodyTooLarge(received)
162
+ return message
163
+
164
+ async def guarded_send(message: Message) -> None:
165
+ nonlocal response_started
166
+ if message["type"] == "http.response.start":
167
+ response_started = True
168
+ await send(message)
169
+
170
+ try:
171
+ await self.app(scope, limited_receive, guarded_send)
172
+ except _BodyTooLarge as exc:
173
+ if response_started:
174
+ raise
175
+ await self._too_large(int(exc.args[0]))(scope, receive, send)
135
176
 
136
177
 
137
178
  class HostValidationMiddleware(BaseHTTPMiddleware):
@@ -409,10 +450,12 @@ def create_app(config_path: str | None = None) -> FastAPI:
409
450
  )
410
451
  app.add_middleware(HostValidationMiddleware, allowed_hosts=allowed_hosts)
411
452
 
412
- # M14: request body size limit (DoS protection). Added after the Host
413
- # middleware so that — because add_middleware wraps outermost-last — Host
414
- # validation runs first and the size guard second. SSE streaming responses
415
- # are unaffected: the check only reads the request Content-Length.
453
+ # M14: request body size limit (DoS protection). ``add_middleware`` wraps
454
+ # outermost-last, so the middleware added *after* HostValidation ends up
455
+ # the outermost layer — i.e. the size guard runs FIRST, before Host
456
+ # validation. That ordering is harmless: an oversized/chunked body is
457
+ # rejected as early as possible. SSE streaming responses are unaffected;
458
+ # this only inspects the request side.
416
459
  max_body_bytes = _parse_max_body_bytes(
417
460
  os.environ.get(_MAX_BODY_BYTES_ENV)
418
461
  )