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.
- coderouter/adapters/anthropic_native.py +66 -30
- coderouter/adapters/base.py +46 -3
- coderouter/adapters/openai_compat.py +53 -11
- coderouter/config/capability_registry.py +22 -0
- coderouter/config/schemas.py +232 -0
- coderouter/guards/context_budget.py +114 -19
- coderouter/guards/continuous_probe.py +9 -1
- coderouter/guards/memory_pressure.py +16 -2
- coderouter/guards/self_healing.py +14 -0
- coderouter/ingress/anthropic_routes.py +223 -1
- coderouter/ingress/app.py +162 -1
- coderouter/ingress/launcher_routes.py +187 -11
- coderouter/ingress/openai_routes.py +77 -7
- coderouter/logging.py +29 -3
- coderouter/metrics/collector.py +454 -245
- coderouter/metrics/prometheus.py +3 -3
- coderouter/routing/capability.py +217 -0
- coderouter/routing/fallback.py +539 -27
- coderouter/state/audit_log.py +69 -7
- coderouter/state/request_log.py +70 -7
- coderouter/translation/convert.py +112 -9
- coderouter/translation/tool_repair.py +671 -50
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/METADATA +28 -9
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/RECORD +27 -27
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
143
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|