coderouter-cli 2.6.1__py3-none-any.whl → 2.7.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.
@@ -98,6 +98,11 @@ async def probe_one(
98
98
  """
99
99
  import os
100
100
 
101
+ # Local import: keeps the module-level import graph free of the
102
+ # convert ↔ anthropic_native circular dependency (adapters pull in
103
+ # translation.convert, which pulls back in this adapter).
104
+ from coderouter.adapters.anthropic_native import anthropic_messages_url
105
+
101
106
  start = time.monotonic()
102
107
  provider_name = provider.name
103
108
  base_url = str(provider.base_url).rstrip("/")
@@ -116,7 +121,10 @@ async def probe_one(
116
121
  try:
117
122
  async with httpx.AsyncClient(timeout=timeout_s) as client:
118
123
  if provider.kind == "anthropic":
119
- url = f"{base_url}/v1/messages"
124
+ # Use the shared normalizer so the probe hits the exact
125
+ # same endpoint the adapter does — base_url ending in
126
+ # `/v1` must not produce `/v1/v1/messages` (bug H4).
127
+ url = anthropic_messages_url(base_url)
120
128
  body: dict[str, Any] = {
121
129
  "model": provider.model,
122
130
  "max_tokens": 1,
@@ -53,6 +53,7 @@ the body of its read/write.
53
53
 
54
54
  from __future__ import annotations
55
55
 
56
+ import re
56
57
  import threading
57
58
  import time
58
59
 
@@ -76,9 +77,18 @@ _MEMORY_PRESSURE_PHRASES: tuple[str, ...] = (
76
77
  "failed to allocate",
77
78
  "failed to load model",
78
79
  "ggml_cuda_host_malloc", # llama.cpp specific
79
- "oom",
80
80
  )
81
81
 
82
+ # M10: "oom" needs word-boundary matching, not a bare substring test.
83
+ # A plain ``"oom" in text`` false-fires on "room" / "zoom" / "bloom" and
84
+ # would wrongly cool down a healthy provider whose error body merely
85
+ # contains one of those words. ``\boom\b`` matches the standalone token
86
+ # "oom" (and "OOM" once lowercased) while rejecting the embedded cases.
87
+ # Note the digit/underscore edges are word chars, so "oom_killer" is NOT
88
+ # matched by this pattern alone — real oom-killer bodies still trip the
89
+ # match via the separate "out of memory" phrase or a standalone "oom".
90
+ _OOM_TOKEN_RE = re.compile(r"\boom\b")
91
+
82
92
 
83
93
  def is_memory_pressure_error(exc: AdapterError) -> bool:
84
94
  """Return True iff ``exc`` looks like a backend OOM signal.
@@ -96,7 +106,11 @@ def is_memory_pressure_error(exc: AdapterError) -> bool:
96
106
  focused on the actual signal.
97
107
  """
98
108
  text = str(exc).lower()
99
- return any(phrase in text for phrase in _MEMORY_PRESSURE_PHRASES)
109
+ if any(phrase in text for phrase in _MEMORY_PRESSURE_PHRASES):
110
+ return True
111
+ # M10: standalone "oom" token only (word-boundary), so "room" /
112
+ # "zoom" / "bloom" in an unrelated error body don't false-fire.
113
+ return _OOM_TOKEN_RE.search(text) is not None
100
114
 
101
115
 
102
116
  # ---------------------------------------------------------------------------
@@ -181,6 +181,20 @@ class SelfHealingOrchestrator:
181
181
  with self._lock:
182
182
  return set(self._excluded.keys())
183
183
 
184
+ def excluded_profiles(self) -> dict[str, str]:
185
+ """Return a snapshot mapping excluded provider -> owning profile.
186
+
187
+ Used by the engine to re-arm recovery probes after a restart:
188
+ each excluded provider must be probed under the profile whose
189
+ ``backend_health_threshold`` / ``recovery_probe_*`` settings
190
+ drove its exclusion. See :meth:`excluded_providers` for the
191
+ name-only variant.
192
+ """
193
+ with self._lock:
194
+ return {
195
+ name: entry.profile for name, entry in self._excluded.items()
196
+ }
197
+
184
198
  def reset(self) -> None:
185
199
  """Drop all state. Mainly for tests."""
186
200
  with self._lock:
@@ -21,6 +21,8 @@ SDKs send values like "2023-06-01"; we log it for diagnostics only.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import asyncio
25
+ import contextlib
24
26
  import json
25
27
  from collections.abc import AsyncIterator
26
28
  from typing import Any
@@ -51,6 +53,109 @@ _ANTHROPIC_BETA_HEADER = "anthropic-beta"
51
53
  _CTX_BUDGET_HEADER = "X-CodeRouter-Context-Budget"
52
54
  _DRIFT_HEADER = "X-CodeRouter-Drift"
53
55
 
56
+ # M14: overall SSE stream ceiling. A single streamed request can legitimately
57
+ # run much longer than one provider call (long generations, tool turns), so we
58
+ # derive the ceiling from the profile's per-call ``timeout_s`` scaled up, and
59
+ # fall back to a large default when no profile timeout is configured. This is a
60
+ # safety net against a wedged upstream holding a client (and the upstream
61
+ # socket) open forever — NOT a tight per-token deadline.
62
+ _STREAM_TIMEOUT_MULTIPLIER = 20.0
63
+ _STREAM_TIMEOUT_DEFAULT_S = 900.0
64
+ _STREAM_TIMEOUT_MIN_S = 60.0
65
+
66
+
67
+ def _resolve_stream_timeout_s(engine: FallbackEngine, profile: str | None) -> float:
68
+ """M14: derive the overall stream ceiling (seconds) for a profile.
69
+
70
+ Uses the profile's per-call ``timeout_s`` (or the first provider's, or
71
+ the default) scaled by ``_STREAM_TIMEOUT_MULTIPLIER`` so a whole stream
72
+ gets a generous but bounded budget. Never returns below
73
+ ``_STREAM_TIMEOUT_MIN_S``. Any resolution failure (stub config in tests,
74
+ missing profile) falls back to ``_STREAM_TIMEOUT_DEFAULT_S`` — the guard
75
+ stays active rather than disabling itself. Does NOT change the config
76
+ schema.
77
+ """
78
+ per_call: float | None = None
79
+ try:
80
+ config = engine.config
81
+ chosen = profile or config.default_profile
82
+ chain_cfg = config.profile_by_name(chosen)
83
+ per_call = getattr(chain_cfg, "timeout_s", None)
84
+ if per_call is None:
85
+ # Fall back to the first provider's timeout.
86
+ for pname in getattr(chain_cfg, "providers", []) or []:
87
+ pconf = next(
88
+ (p for p in config.providers if p.name == pname), None
89
+ )
90
+ if pconf is not None:
91
+ per_call = getattr(pconf, "timeout_s", None)
92
+ break
93
+ except (AttributeError, KeyError, ValueError):
94
+ per_call = None
95
+
96
+ if per_call is None:
97
+ return _STREAM_TIMEOUT_DEFAULT_S
98
+ return max(_STREAM_TIMEOUT_MIN_S, float(per_call) * _STREAM_TIMEOUT_MULTIPLIER)
99
+
100
+
101
+ async def _guard_stream(
102
+ source: AsyncIterator[str],
103
+ *,
104
+ timeout_s: float,
105
+ label: str,
106
+ ) -> AsyncIterator[str]:
107
+ """M14: enforce an overall timeout + clean cancellation on an SSE stream.
108
+
109
+ Wraps ``source`` (an iterator of already-formatted SSE frames) so that:
110
+
111
+ * The total wall-clock time is bounded by ``timeout_s`` via
112
+ ``asyncio.timeout`` — a wedged upstream can no longer hold the client
113
+ (and the upstream socket) open indefinitely.
114
+ * On client disconnect (the ASGI server throws ``CancelledError`` into
115
+ the generator) or on timeout, the underlying ``source`` generator is
116
+ explicitly closed via ``aclose()``. Closing the engine generator runs
117
+ its ``finally`` blocks, which lets the adapter's ``httpx`` streaming
118
+ context manager exit and release the upstream connection instead of
119
+ leaking it until GC.
120
+
121
+ Errors raised by ``source`` itself (in-stream ``event: error`` framing)
122
+ are produced by the caller's iterator before it reaches here, so this
123
+ wrapper only has to deal with timeout / cancellation / normal completion.
124
+ """
125
+ try:
126
+ async with asyncio.timeout(timeout_s):
127
+ async for frame in source:
128
+ yield frame
129
+ except TimeoutError:
130
+ # Overall ceiling hit. Emit a terminal error frame so a
131
+ # spec-compliant client sees a clean stream end rather than a
132
+ # silently truncated body, then stop.
133
+ logger.warning("sse-stream-timeout", extra={"label": label, "timeout_s": timeout_s})
134
+ err_event = AnthropicStreamEvent(
135
+ type="error",
136
+ data={
137
+ "type": "error",
138
+ "error": {
139
+ "type": "timeout_error",
140
+ "message": f"stream exceeded {timeout_s:.0f}s ceiling",
141
+ },
142
+ },
143
+ )
144
+ yield _format_anthropic_sse(err_event)
145
+ except asyncio.CancelledError:
146
+ # Client disconnected. Re-raise after ensuring the source is closed
147
+ # in the finally block so the upstream connection is released.
148
+ logger.info("sse-client-disconnect", extra={"label": label})
149
+ raise
150
+ finally:
151
+ # Guarantee the upstream generator is finalized on every exit path
152
+ # (normal completion, timeout, or cancellation).
153
+ aclose = getattr(source, "aclose", None)
154
+ if aclose is not None:
155
+ # Best-effort cleanup; never mask the original exit reason.
156
+ with contextlib.suppress(asyncio.CancelledError, Exception):
157
+ await aclose()
158
+
54
159
 
55
160
  @router.post("/messages", response_model=None)
56
161
  async def messages(
@@ -151,8 +256,17 @@ async def messages(
151
256
  drift_severity = engine.last_drift_severity
152
257
  if drift_severity:
153
258
  stream_headers[_DRIFT_HEADER] = drift_severity
154
- return StreamingResponse(
259
+ # M14: wrap the SSE iterator with an overall timeout + client
260
+ # disconnect cleanup so a wedged upstream cannot pin the client
261
+ # (or the upstream socket) open forever.
262
+ timeout_s = _resolve_stream_timeout_s(engine, anth_req.profile)
263
+ guarded = _guard_stream(
155
264
  _anthropic_sse_iterator(engine, anth_req),
265
+ timeout_s=timeout_s,
266
+ label="anthropic",
267
+ )
268
+ return StreamingResponse(
269
+ guarded,
156
270
  media_type="text/event-stream",
157
271
  headers=stream_headers,
158
272
  )
coderouter/ingress/app.py CHANGED
@@ -7,7 +7,10 @@ import os
7
7
  from collections.abc import AsyncIterator
8
8
  from contextlib import asynccontextmanager
9
9
 
10
- from fastapi import FastAPI
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.responses import JSONResponse
12
+ from starlette.middleware.base import BaseHTTPMiddleware
13
+ from starlette.types import ASGIApp
11
14
 
12
15
  from coderouter import __version__
13
16
  from coderouter.config import load_config
@@ -25,6 +28,137 @@ from coderouter.routing.capability import check_claude_code_chain_suitability
25
28
  logger = get_logger(__name__)
26
29
 
27
30
 
31
+ # ---------------------------------------------------------------------------
32
+ # H8: DNS-rebinding protection via Host-header validation
33
+ # ---------------------------------------------------------------------------
34
+
35
+ # Loopback hosts that are always allowed. ``testserver`` is the default Host
36
+ # that Starlette's TestClient sends, so it is whitelisted to keep the existing
37
+ # suite (and any local integration tests) working without extra configuration.
38
+ _DEFAULT_ALLOWED_HOSTS: frozenset[str] = frozenset(
39
+ {"localhost", "127.0.0.1", "[::1]", "::1", "testserver"}
40
+ )
41
+
42
+
43
+ def _parse_allowed_hosts(raw: str | None) -> frozenset[str]:
44
+ """Merge the built-in loopback set with ``CODEROUTER_ALLOWED_HOSTS``.
45
+
46
+ The env var is a comma-separated list of extra hostnames (no port) that
47
+ should be accepted — the escape hatch for deliberate external exposure.
48
+ """
49
+ extra = {
50
+ part.strip().lower()
51
+ for part in (raw or "").split(",")
52
+ if part.strip()
53
+ }
54
+ return _DEFAULT_ALLOWED_HOSTS | frozenset(extra)
55
+
56
+
57
+ def _host_without_port(host_header: str) -> str:
58
+ """Strip the optional ``:port`` suffix from a Host header value.
59
+
60
+ Handles bracketed IPv6 literals (``[::1]:8080`` → ``[::1]``) as well as
61
+ the common ``host:port`` form. A bare ``[::1]`` or ``host`` is returned
62
+ unchanged.
63
+ """
64
+ value = host_header.strip().lower()
65
+ if value.startswith("["):
66
+ # IPv6 literal: keep everything up to and including the closing bracket.
67
+ end = value.find("]")
68
+ if end != -1:
69
+ return value[: end + 1]
70
+ return value
71
+ # IPv4 / hostname: split off a trailing :port if present.
72
+ if ":" in value:
73
+ return value.rsplit(":", 1)[0]
74
+ return value
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # M14: request body size limit (DoS protection)
79
+ # ---------------------------------------------------------------------------
80
+
81
+ # Default cap for incoming request bodies. Anthropic/OpenAI chat payloads are
82
+ # comfortably below this even with large system prompts; the ceiling exists to
83
+ # stop a client from streaming an unbounded body and exhausting memory.
84
+ _DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024 # 64 MB
85
+ _MAX_BODY_BYTES_ENV = "CODEROUTER_MAX_BODY_BYTES"
86
+
87
+
88
+ def _parse_max_body_bytes(raw: str | None) -> int:
89
+ """Resolve the body-size cap from ``CODEROUTER_MAX_BODY_BYTES``.
90
+
91
+ Falls back to :data:`_DEFAULT_MAX_BODY_BYTES` when the env var is unset,
92
+ empty, non-numeric, or non-positive — a misconfigured value must never
93
+ silently disable the guard.
94
+ """
95
+ if raw is None or not raw.strip():
96
+ return _DEFAULT_MAX_BODY_BYTES
97
+ try:
98
+ value = int(raw.strip())
99
+ except ValueError:
100
+ return _DEFAULT_MAX_BODY_BYTES
101
+ return value if value > 0 else _DEFAULT_MAX_BODY_BYTES
102
+
103
+
104
+ class BodySizeLimitMiddleware(BaseHTTPMiddleware):
105
+ """Reject requests whose declared body exceeds the configured cap.
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.
111
+ """
112
+
113
+ def __init__(self, app: ASGIApp, max_bytes: int) -> None:
114
+ super().__init__(app)
115
+ self._max_bytes = max_bytes
116
+
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
+ },
133
+ )
134
+ return await call_next(request)
135
+
136
+
137
+ class HostValidationMiddleware(BaseHTTPMiddleware):
138
+ """Reject requests whose Host header is not an allow-listed hostname.
139
+
140
+ This blocks DNS-rebinding attacks: even when CodeRouter is bound to
141
+ ``127.0.0.1``, a malicious page can point a hostname it controls at the
142
+ loopback address and drive the local API from the victim's browser. By
143
+ pinning the accepted Host values we make such cross-origin requests fail
144
+ closed with 403.
145
+ """
146
+
147
+ def __init__(self, app: ASGIApp, allowed_hosts: frozenset[str]) -> None:
148
+ super().__init__(app)
149
+ self._allowed = allowed_hosts
150
+
151
+ async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
152
+ host_header = request.headers.get("host", "")
153
+ host = _host_without_port(host_header)
154
+ if host not in self._allowed:
155
+ return JSONResponse(
156
+ status_code=403,
157
+ content={"detail": f"Host {host_header!r} is not allowed."},
158
+ )
159
+ return await call_next(request)
160
+
161
+
28
162
  def create_app(config_path: str | None = None) -> FastAPI:
29
163
  """Build a FastAPI app with routes, engine, and lifespan installed.
30
164
 
@@ -189,6 +323,15 @@ def create_app(config_path: str | None = None) -> FastAPI:
189
323
  with contextlib.suppress(Exception):
190
324
  await engine.shutdown_recovery_probes()
191
325
 
326
+ # H3: close each adapter's shared httpx.AsyncClient so pooled
327
+ # connections / keep-alive sockets are released cleanly instead of
328
+ # being left to garbage collection. Adapters are cached on the
329
+ # engine (one per provider); ``aclose`` is idempotent and a no-op
330
+ # when the adapter never issued a request.
331
+ for _adapter in engine._adapters.values():
332
+ with contextlib.suppress(Exception):
333
+ await _adapter.aclose()
334
+
192
335
  # v2.0-K: persist state and close audit log on shutdown.
193
336
  if state_store is not None:
194
337
  with contextlib.suppress(Exception):
@@ -228,6 +371,24 @@ def create_app(config_path: str | None = None) -> FastAPI:
228
371
  app.state.engine = engine
229
372
  app.state.config = config
230
373
 
374
+ # H8: DNS-rebinding protection. Applied to every route so a hostname an
375
+ # attacker controls cannot be pointed at loopback and used to drive the
376
+ # local API from a victim's browser. Extra hostnames for deliberate
377
+ # external exposure come from CODEROUTER_ALLOWED_HOSTS (comma-separated).
378
+ allowed_hosts = _parse_allowed_hosts(
379
+ os.environ.get("CODEROUTER_ALLOWED_HOSTS")
380
+ )
381
+ app.add_middleware(HostValidationMiddleware, allowed_hosts=allowed_hosts)
382
+
383
+ # M14: request body size limit (DoS protection). Added after the Host
384
+ # middleware so that — because add_middleware wraps outermost-last — Host
385
+ # validation runs first and the size guard second. SSE streaming responses
386
+ # are unaffected: the check only reads the request Content-Length.
387
+ max_body_bytes = _parse_max_body_bytes(
388
+ os.environ.get(_MAX_BODY_BYTES_ENV)
389
+ )
390
+ app.add_middleware(BodySizeLimitMiddleware, max_bytes=max_body_bytes)
391
+
231
392
  @app.get("/healthz")
232
393
  async def healthz() -> dict[str, object]:
233
394
  """Lightweight liveness / config snapshot endpoint.