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.
- coderouter/cli.py +20 -21
- coderouter/config/loader.py +133 -34
- coderouter/config/schemas.py +143 -47
- coderouter/guards/context_budget.py +165 -20
- coderouter/guards/self_healing.py +58 -3
- coderouter/ingress/app.py +72 -29
- coderouter/ingress/launcher_routes.py +248 -62
- coderouter/launcher_devices.py +11 -4
- coderouter/py.typed +0 -0
- coderouter/token_estimation.py +264 -31
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/RECORD +15 -14
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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__ = [
|
|
@@ -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
|
-
|
|
285
|
-
shell=
|
|
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
|
|
105
|
-
|
|
104
|
+
class _BodyTooLarge(Exception):
|
|
105
|
+
pass
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
124
|
+
self.app = app
|
|
115
125
|
self._max_bytes = max_bytes
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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).
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
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
|
)
|