coderouter-cli 2.6.1__py3-none-any.whl → 2.7.1__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.
@@ -26,6 +26,7 @@ import asyncio
26
26
  import contextlib
27
27
  import os
28
28
  import platform
29
+ import secrets
29
30
  import shlex
30
31
  import shutil
31
32
  import subprocess
@@ -39,11 +40,57 @@ from fastapi import APIRouter, HTTPException, Request
39
40
  from fastapi.responses import HTMLResponse
40
41
  from pydantic import BaseModel
41
42
 
43
+ from coderouter.logging import get_logger
44
+
45
+ logger = get_logger(__name__)
46
+
42
47
  router = APIRouter()
43
48
 
44
49
  # 背景タスクへの強参照を保持する (create_task の戻り値が GC されるのを防ぐ)
45
50
  _background_tasks: set[asyncio.Task[Any]] = set()
46
51
 
52
+ # ---------------------------------------------------------------------------
53
+ # H8: token auth for state-changing endpoints (start / stop / delete)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ # Env var holding the shared secret. When unset, auth is disabled and the
57
+ # launcher behaves exactly as before (local-only assumption). When set, every
58
+ # state-changing request must carry a matching X-CodeRouter-Token header.
59
+ _LAUNCHER_TOKEN_ENV = "CODEROUTER_LAUNCHER_TOKEN"
60
+ _LAUNCHER_TOKEN_HEADER = "X-CodeRouter-Token"
61
+
62
+ # Guard so the "no token configured" warning is only logged once per process.
63
+ _token_warning_emitted = False
64
+
65
+
66
+ def _require_launcher_token(request: Request) -> None:
67
+ """Enforce the launcher shared-secret on state-changing endpoints.
68
+
69
+ If ``CODEROUTER_LAUNCHER_TOKEN`` is unset the launcher stays open (its
70
+ historical local-only behaviour) and a one-time warning is logged. When
71
+ the env var is set, the ``X-CodeRouter-Token`` header must match it using
72
+ a constant-time comparison, otherwise a 401 is raised.
73
+ """
74
+ global _token_warning_emitted
75
+ expected = os.environ.get(_LAUNCHER_TOKEN_ENV, "")
76
+ if not expected:
77
+ if not _token_warning_emitted:
78
+ logger.warning(
79
+ "launcher-auth-disabled",
80
+ extra={
81
+ "hint": (
82
+ f"{_LAUNCHER_TOKEN_ENV} is not set; launcher "
83
+ "start/stop/delete endpoints are unauthenticated. "
84
+ "Set it when binding to anything other than loopback."
85
+ ),
86
+ },
87
+ )
88
+ _token_warning_emitted = True
89
+ return
90
+ provided = request.headers.get(_LAUNCHER_TOKEN_HEADER, "")
91
+ if not secrets.compare_digest(provided, expected):
92
+ raise HTTPException(status_code=401, detail="Invalid or missing launcher token.")
93
+
47
94
  # ---------------------------------------------------------------------------
48
95
  # Model file extensions to scan
49
96
  # ---------------------------------------------------------------------------
@@ -153,6 +200,38 @@ _BACKEND_DEFAULTS: dict[str, str] = {
153
200
  "mlx": "python", # mlx_lm.server (Apple Silicon 向け)
154
201
  }
155
202
 
203
+ # H8: flags that re-specify the model path. Allowing these through
204
+ # ``options`` / ``extra_args`` would let a caller override the vetted
205
+ # ``model_path`` and load an arbitrary file (or, for vllm/mlx, swap the
206
+ # ``-m`` module target). We reject them per backend so the model is only
207
+ # ever set by the ``model_path`` field.
208
+ # - llama.cpp: ``-m`` / ``--model`` select the GGUF file.
209
+ # - vllm / mlx: ``--model`` selects the model; ``-m`` selects the python
210
+ # module that ``_build_cmd`` pins, so it must not be re-specified either.
211
+ _MODEL_FLAGS: dict[str, frozenset[str]] = {
212
+ "llama.cpp": frozenset({"-m", "--model"}),
213
+ "vllm": frozenset({"-m", "--model"}),
214
+ "mlx": frozenset({"-m", "--model"}),
215
+ }
216
+
217
+
218
+ def _assert_no_model_override(backend: str, tokens: list[str]) -> None:
219
+ """Raise ValueError if ``tokens`` contain a model-selecting flag.
220
+
221
+ ``tokens`` is the flat list of argument tokens sourced from ``options``
222
+ keys or ``shlex.split(extra_args)``. Matching is exact on the flag name;
223
+ ``--model=foo`` style is caught by comparing the part before ``=``.
224
+ """
225
+ banned = _MODEL_FLAGS.get(backend, frozenset())
226
+ for token in tokens:
227
+ name = token.split("=", 1)[0]
228
+ if name in banned:
229
+ raise ValueError(
230
+ f"Flag {name!r} is not allowed: the model is set by "
231
+ "'model_path' and cannot be re-specified via options or "
232
+ "extra_args."
233
+ )
234
+
156
235
 
157
236
  def _resolve_binary(backend: str, configured: str | None) -> str:
158
237
  """Return the executable to use, expanding ~ and env vars."""
@@ -307,6 +386,27 @@ def _model_size_gb(path: str) -> float:
307
386
  return 0.0
308
387
 
309
388
 
389
+ def _resolve_within_model_dirs(model_path: str, model_dirs: list[str]) -> Path:
390
+ """Resolve ``model_path`` and assert it lives under a configured model dir.
391
+
392
+ M14: ``/api/launcher/suggest`` accepts an arbitrary ``model_path`` query
393
+ param that was previously fed straight into ``expanduser().stat()``. That
394
+ let a caller probe the existence and size of any file on disk (a path
395
+ traversal / information-disclosure primitive). We now resolve the path
396
+ (following ``~`` and ``..``) and require it to be contained in one of the
397
+ resolved ``model_dirs``. Anything outside — or a request when no
398
+ ``model_dirs`` are configured — raises ``ValueError`` (mapped to 400).
399
+ """
400
+ if not model_dirs:
401
+ raise ValueError("No model_dirs configured; model_path cannot be validated.")
402
+ candidate = Path(model_path).expanduser().resolve()
403
+ for raw in model_dirs:
404
+ base = Path(raw).expanduser().resolve()
405
+ if candidate == base or base in candidate.parents:
406
+ return candidate
407
+ raise ValueError("model_path is not under any configured model_dirs.")
408
+
409
+
310
410
  def _build_cmd(
311
411
  backend: str,
312
412
  model_path: str,
@@ -343,6 +443,9 @@ def _build_cmd(
343
443
  "Expected 'llama.cpp', 'vllm' or 'mlx'."
344
444
  )
345
445
 
446
+ # H8: reject model-path re-specification via options keys.
447
+ _assert_no_model_override(backend, list(options.keys()))
448
+
346
449
  for flag, val in options.items():
347
450
  if isinstance(val, bool):
348
451
  if val:
@@ -351,7 +454,10 @@ def _build_cmd(
351
454
  cmd.extend([flag, str(val)])
352
455
 
353
456
  if extra_args.strip():
354
- cmd.extend(shlex.split(extra_args))
457
+ extra_tokens = shlex.split(extra_args)
458
+ # H8: reject model-path re-specification via free-form extra_args.
459
+ _assert_no_model_override(backend, extra_tokens)
460
+ cmd.extend(extra_tokens)
355
461
 
356
462
  return cmd
357
463
 
@@ -361,6 +467,14 @@ def _build_cmd(
361
467
  # ---------------------------------------------------------------------------
362
468
 
363
469
 
470
+ # M14: cap the StreamReader buffer for launched processes. A backend that
471
+ # emits a huge amount of output without a newline (e.g. a progress bar that
472
+ # only writes ``\r``) would otherwise make ``readline()`` buffer without
473
+ # bound. asyncio raises ``LimitOverrunError`` once the limit is exceeded; we
474
+ # recover by reading the buffered chunk and continuing so log tailing survives.
475
+ _LOG_STREAM_LIMIT = 256 * 1024 # 256 KB
476
+
477
+
364
478
  async def _tail_logs(proc: ManagedProcess) -> None:
365
479
  """Read stdout+stderr into proc.log_tail until the process exits."""
366
480
  p = proc._proc
@@ -371,7 +485,22 @@ async def _tail_logs(proc: ManagedProcess) -> None:
371
485
  if stream is None:
372
486
  return
373
487
  while True:
374
- line = await stream.readline()
488
+ try:
489
+ line = await stream.readline()
490
+ except (asyncio.LimitOverrunError, ValueError):
491
+ # M14: a single line exceeded the buffer limit (no newline in
492
+ # sight). Drain the currently buffered bytes so the reader can
493
+ # make progress instead of raising forever, then continue.
494
+ try:
495
+ chunk = await stream.read(_LOG_STREAM_LIMIT)
496
+ except (asyncio.LimitOverrunError, ValueError):
497
+ chunk = b""
498
+ if not chunk:
499
+ if stream.at_eof():
500
+ break
501
+ continue
502
+ proc.log_tail.append(chunk.decode(errors="replace").rstrip())
503
+ continue
375
504
  if not line:
376
505
  break
377
506
  proc.log_tail.append(line.decode(errors="replace").rstrip())
@@ -529,6 +658,7 @@ async def api_backends(request: Request) -> dict[str, Any]:
529
658
  @router.post("/api/launcher/start")
530
659
  async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
531
660
  """Start a new backend process."""
661
+ _require_launcher_token(request)
532
662
  # Resolve binary path from providers.yaml launcher.backends
533
663
  cfg = request.app.state.config
534
664
  launcher_cfg = getattr(cfg, "launcher", None)
@@ -565,6 +695,9 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
565
695
  *cmd,
566
696
  stdout=asyncio.subprocess.PIPE,
567
697
  stderr=asyncio.subprocess.PIPE,
698
+ # M14: bound the per-stream StreamReader buffer so a newline-less
699
+ # flood from the child cannot grow it without limit.
700
+ limit=_LOG_STREAM_LIMIT,
568
701
  )
569
702
  except FileNotFoundError:
570
703
  raise HTTPException(
@@ -590,6 +723,7 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
590
723
  @router.post("/api/launcher/stop/{proc_id}")
591
724
  async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
592
725
  """Terminate a running process (SIGTERM, then SIGKILL after 5s)."""
726
+ _require_launcher_token(request)
593
727
  try:
594
728
  proc = _registry(request).get(proc_id)
595
729
  except KeyError:
@@ -597,12 +731,18 @@ async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
597
731
  status_code=404, detail=f"Process {proc_id!r} not found.") from None
598
732
 
599
733
  if proc._proc and proc.status == "running":
600
- proc._proc.terminate()
734
+ # M14: the child may already be gone (crashed / reaped) between the
735
+ # status check and the signal. terminate()/kill() then raise
736
+ # ProcessLookupError, which previously escaped as a 500. Suppress it
737
+ # (mirroring shutdown_launcher) so stop is idempotent.
738
+ with contextlib.suppress(ProcessLookupError):
739
+ proc._proc.terminate()
601
740
  proc.log_tail.append("[launcher] SIGTERM sent")
602
741
  try:
603
742
  await asyncio.wait_for(proc._proc.wait(), timeout=5.0)
604
743
  except TimeoutError:
605
- proc._proc.kill()
744
+ with contextlib.suppress(ProcessLookupError):
745
+ proc._proc.kill()
606
746
  proc.log_tail.append("[launcher] SIGKILL sent (timeout)")
607
747
  proc.status = "stopped"
608
748
  proc.pid = None
@@ -613,6 +753,7 @@ async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
613
753
  @router.delete("/api/launcher/processes/{proc_id}")
614
754
  async def api_delete(proc_id: str, request: Request) -> dict[str, Any]:
615
755
  """Remove a stopped process from the registry."""
756
+ _require_launcher_token(request)
616
757
  reg = _registry(request)
617
758
  try:
618
759
  proc = reg.get(proc_id)
@@ -638,17 +779,28 @@ async def api_logs(proc_id: str, request: Request, n: int = 100) -> dict[str, An
638
779
 
639
780
 
640
781
  @router.get("/api/launcher/suggest")
641
- async def api_suggest(model_path: str = "",
782
+ async def api_suggest(request: Request, model_path: str = "",
642
783
  backend: str = "llama.cpp") -> dict[str, Any]:
643
784
  """Suggest launch flags for the given model based on detected hardware.
644
785
 
645
786
  クライアントの「推奨値」ボタンから呼ばれる。値はあくまで目安。
646
787
  バックエンドごとにフラグ体系が違うため backend も受け取る。
788
+
789
+ M14: ``model_path`` is validated against the configured ``model_dirs``
790
+ before it is ``stat``-ed, so this endpoint can no longer be used to probe
791
+ the existence/size of arbitrary filesystem paths.
647
792
  """
648
793
  hw = await asyncio.to_thread(_detect_hardware)
649
794
  size_gb = 0.0
650
795
  if model_path:
651
- size_gb = await asyncio.to_thread(_model_size_gb, model_path)
796
+ cfg = request.app.state.config
797
+ launcher_cfg = getattr(cfg, "launcher", None)
798
+ model_dirs: list[str] = launcher_cfg.model_dirs if launcher_cfg else []
799
+ try:
800
+ resolved = _resolve_within_model_dirs(model_path, model_dirs)
801
+ except ValueError as exc:
802
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
803
+ size_gb = await asyncio.to_thread(_model_size_gb, str(resolved))
652
804
  return {
653
805
  "extra_args": _suggest_launch_flags(backend, size_gb, hw),
654
806
  "backend": backend,
@@ -828,6 +980,15 @@ _LAUNCHER_HTML = r"""<!doctype html>
828
980
  "use strict";
829
981
 
830
982
  const POLL_MS = 3000;
983
+ // H8: launcher shared-secret. The server substitutes __LAUNCHER_TOKEN__
984
+ // with the configured token (or an empty string when auth is disabled).
985
+ const LAUNCHER_TOKEN = "__LAUNCHER_TOKEN__";
986
+ // Build headers for state-changing requests, adding the token only when set.
987
+ const authHeaders = (base) => {
988
+ const h = Object.assign({}, base || {});
989
+ if (LAUNCHER_TOKEN) h["X-CodeRouter-Token"] = LAUNCHER_TOKEN;
990
+ return h;
991
+ };
831
992
  let allProfiles = {}; // backend → [{name, args}]
832
993
  const _modelCache = {}; // index → {path, name, dir, size_gb}
833
994
  let selectedLogId = null;
@@ -1093,7 +1254,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
1093
1254
  try {
1094
1255
  const res = await fetch("/api/launcher/start", {
1095
1256
  method: "POST",
1096
- headers: {"Content-Type": "application/json"},
1257
+ headers: authHeaders({"Content-Type": "application/json"}),
1097
1258
  body: JSON.stringify({name, backend, model_path: model, port,
1098
1259
  options: selectedProfileArgs(), extra_args: extra}),
1099
1260
  });
@@ -1150,7 +1311,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
1150
1311
 
1151
1312
  window.stopProc = async (id) => {
1152
1313
  if (!confirm("プロセスを停止しますか?")) return;
1153
- const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST"});
1314
+ const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST", headers: authHeaders()});
1154
1315
  const d = await r.json();
1155
1316
  statusMsg(`停止: ${d.status}`);
1156
1317
  await fetchProcesses();
@@ -1159,7 +1320,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
1159
1320
 
1160
1321
  window.deleteProc = async (id) => {
1161
1322
  if (!confirm("レジストリから削除しますか?")) return;
1162
- await fetch(`/api/launcher/processes/${id}`, {method:"DELETE"});
1323
+ await fetch(`/api/launcher/processes/${id}`, {method:"DELETE", headers: authHeaders()});
1163
1324
  if (selectedLogId === id) closeLog();
1164
1325
  await fetchProcesses();
1165
1326
  };
@@ -1214,5 +1375,20 @@ _LAUNCHER_HTML = r"""<!doctype html>
1214
1375
 
1215
1376
  @router.get("/launcher", response_class=HTMLResponse)
1216
1377
  async def launcher_page() -> HTMLResponse:
1217
- """Serve the launcher single-page UI."""
1218
- return HTMLResponse(content=_LAUNCHER_HTML)
1378
+ """Serve the launcher single-page UI.
1379
+
1380
+ H8: inject the configured launcher token so the inline JS can attach it
1381
+ to state-changing requests. When the env var is unset the placeholder
1382
+ becomes an empty string and the UI keeps working unauthenticated (the
1383
+ historical local-only default). The token is escaped for a JS string
1384
+ context to avoid breaking out of the double-quoted literal.
1385
+ """
1386
+ token = os.environ.get(_LAUNCHER_TOKEN_ENV, "")
1387
+ safe_token = (
1388
+ token.replace("\\", "\\\\")
1389
+ .replace('"', '\\"')
1390
+ .replace("<", "\\u003c")
1391
+ .replace(">", "\\u003e")
1392
+ )
1393
+ html = _LAUNCHER_HTML.replace("__LAUNCHER_TOKEN__", safe_token)
1394
+ return HTMLResponse(content=html)
@@ -21,6 +21,8 @@ through to the engine, which applies ``config.default_profile``.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import asyncio
25
+ import contextlib
24
26
  import json
25
27
  import time
26
28
  from collections.abc import AsyncIterator
@@ -40,6 +42,41 @@ logger = get_logger(__name__)
40
42
  _PROFILE_HEADER = "x-coderouter-profile"
41
43
  _MODE_HEADER = "x-coderouter-mode"
42
44
 
45
+ # M14: overall SSE stream ceiling — see anthropic_routes for rationale.
46
+ _STREAM_TIMEOUT_MULTIPLIER = 20.0
47
+ _STREAM_TIMEOUT_DEFAULT_S = 900.0
48
+ _STREAM_TIMEOUT_MIN_S = 60.0
49
+
50
+
51
+ def _resolve_stream_timeout_s(engine: FallbackEngine, profile: str | None) -> float:
52
+ """M14: derive the overall stream ceiling (seconds) for a profile.
53
+
54
+ Mirrors the Anthropic route. Uses the profile's per-call ``timeout_s``
55
+ (or the first provider's, or the default) scaled up. Never below
56
+ ``_STREAM_TIMEOUT_MIN_S``; any resolution failure falls back to
57
+ ``_STREAM_TIMEOUT_DEFAULT_S``. Does not change the config schema.
58
+ """
59
+ per_call: float | None = None
60
+ try:
61
+ config = engine.config
62
+ chosen = profile or config.default_profile
63
+ chain_cfg = config.profile_by_name(chosen)
64
+ per_call = getattr(chain_cfg, "timeout_s", None)
65
+ if per_call is None:
66
+ for pname in getattr(chain_cfg, "providers", []) or []:
67
+ pconf = next(
68
+ (p for p in config.providers if p.name == pname), None
69
+ )
70
+ if pconf is not None:
71
+ per_call = getattr(pconf, "timeout_s", None)
72
+ break
73
+ except (AttributeError, KeyError, ValueError):
74
+ per_call = None
75
+
76
+ if per_call is None:
77
+ return _STREAM_TIMEOUT_DEFAULT_S
78
+ return max(_STREAM_TIMEOUT_MIN_S, float(per_call) * _STREAM_TIMEOUT_MULTIPLIER)
79
+
43
80
 
44
81
  @router.get("/models")
45
82
  async def list_models(request: Request) -> dict[str, object]:
@@ -125,8 +162,10 @@ async def chat_completions(
125
162
  ) from exc
126
163
 
127
164
  if chat_req.stream:
165
+ # M14: overall stream timeout + client-disconnect cleanup.
166
+ timeout_s = _resolve_stream_timeout_s(engine, chat_req.profile)
128
167
  return StreamingResponse(
129
- _sse_iterator(engine, chat_req),
168
+ _sse_iterator(engine, chat_req, timeout_s=timeout_s),
130
169
  media_type="text/event-stream",
131
170
  headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
132
171
  )
@@ -139,15 +178,46 @@ async def chat_completions(
139
178
  return response.model_dump(exclude_none=True)
140
179
 
141
180
 
142
- async def _sse_iterator(engine: FallbackEngine, chat_req: ChatRequest) -> AsyncIterator[str]:
143
- """Wrap the engine's stream into SSE wire format."""
181
+ async def _sse_iterator(
182
+ engine: FallbackEngine,
183
+ chat_req: ChatRequest,
184
+ *,
185
+ timeout_s: float = _STREAM_TIMEOUT_DEFAULT_S,
186
+ ) -> AsyncIterator[str]:
187
+ """Wrap the engine's stream into SSE wire format.
188
+
189
+ M14: bounded by an overall ``timeout_s`` ceiling and guarantees the
190
+ upstream engine generator is finalized on client disconnect
191
+ (``CancelledError``) or timeout, so the upstream connection is
192
+ released instead of leaking.
193
+ """
194
+ source = engine.stream(chat_req)
144
195
  try:
145
- async for chunk in engine.stream(chat_req):
146
- data = chunk.model_dump(exclude_none=True)
147
- yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
148
- yield "data: [DONE]\n\n"
196
+ async with asyncio.timeout(timeout_s):
197
+ async for chunk in source:
198
+ data = chunk.model_dump(exclude_none=True)
199
+ yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
200
+ yield "data: [DONE]\n\n"
149
201
  except NoProvidersAvailableError as exc:
150
202
  # Encode the error inside the SSE channel — OpenAI clients handle this
151
203
  err = {"error": {"message": str(exc), "type": "no_providers_available"}}
152
204
  yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
153
205
  yield "data: [DONE]\n\n"
206
+ except TimeoutError:
207
+ # M14: overall ceiling hit — surface a terminal error frame.
208
+ logger.warning("sse-stream-timeout", extra={"timeout_s": timeout_s})
209
+ err = {"error": {"message": f"stream exceeded {timeout_s:.0f}s ceiling", "type": "timeout"}}
210
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
211
+ yield "data: [DONE]\n\n"
212
+ except asyncio.CancelledError:
213
+ # M14: client disconnected — re-raise after finalizing the source.
214
+ logger.info("sse-client-disconnect")
215
+ raise
216
+ finally:
217
+ # M14: ensure the engine generator's finally blocks run so the
218
+ # adapter's httpx streaming context releases the upstream socket.
219
+ aclose = getattr(source, "aclose", None)
220
+ if aclose is not None:
221
+ # Best-effort cleanup; never mask the original exit reason.
222
+ with contextlib.suppress(asyncio.CancelledError, Exception):
223
+ await aclose()
coderouter/logging.py CHANGED
@@ -76,15 +76,36 @@ class JsonLineFormatter(logging.Formatter):
76
76
  return json.dumps(payload, ensure_ascii=False)
77
77
 
78
78
 
79
+ # Marker attribute stamped on the handler this module installs. Used by
80
+ # configure_logging to remove ONLY its own prior handler on reconfigure,
81
+ # rather than nuking every handler on the root logger. Removing all
82
+ # handlers was a M4 bug: a second create_app() in the same process would
83
+ # also detach the MetricsCollector / audit / request-log handlers that
84
+ # other subsystems had attached, silently killing metrics and the JSONL
85
+ # journals from the second app onward.
86
+ _CODEROUTER_LOG_HANDLER_MARKER: str = "_coderouter_json_line_handler"
87
+
88
+
79
89
  def configure_logging(level: str = "INFO") -> None:
80
- """Install JSON-line logging on the root logger. Idempotent."""
90
+ """Install JSON-line logging on the root logger. Idempotent.
91
+
92
+ Only the handler this function previously installed (identified by the
93
+ :data:`_CODEROUTER_LOG_HANDLER_MARKER` attribute) is removed on
94
+ reconfigure. Handlers attached by other subsystems — the
95
+ :class:`~coderouter.metrics.collector.MetricsCollector`, the audit /
96
+ request-log JSONL handlers — are left in place so a second
97
+ ``create_app()`` in the same process does not silently detach them.
98
+ """
81
99
  root = logging.getLogger()
82
100
  root.setLevel(level.upper())
83
- # Avoid duplicate handlers on reload
101
+ # Avoid duplicate handlers on reload — but only remove OUR own prior
102
+ # handler, never handlers other subsystems attached (M4).
84
103
  for h in list(root.handlers):
85
- root.removeHandler(h)
104
+ if getattr(h, _CODEROUTER_LOG_HANDLER_MARKER, False):
105
+ root.removeHandler(h)
86
106
  handler = logging.StreamHandler(sys.stderr)
87
107
  handler.setFormatter(JsonLineFormatter())
108
+ setattr(handler, _CODEROUTER_LOG_HANDLER_MARKER, True)
88
109
  root.addHandler(handler)
89
110
 
90
111
 
@@ -108,6 +129,7 @@ CapabilityDegradedReason = Literal[
108
129
  "provider-does-not-support",
109
130
  "translation-lossy",
110
131
  "non-standard-field",
132
+ "unsupported-backend",
111
133
  ]
112
134
  """Why a capability was degraded.
113
135
 
@@ -121,6 +143,10 @@ CapabilityDegradedReason = Literal[
121
143
  - ``non-standard-field``: upstream emits a field that is not in the spec
122
144
  the ingress speaks, so we strip it on the response-side boundary.
123
145
  v0.5-C reasoning field.
146
+ - ``unsupported-backend``: the request asks for a semantic the target
147
+ backend does not honor (S2 forced ``tool_choice``); with action
148
+ ``warn`` the request is sent unchanged, with ``emulate`` a separate
149
+ ``tool-choice-emulated`` line records the substituted directive.
124
150
  """
125
151
 
126
152