coderouter-cli 2.7.2__py3-none-any.whl → 2.7.4__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/config/schemas.py +51 -0
- coderouter/ingress/launcher_routes.py +51 -1
- coderouter/ingress/openai_routes.py +85 -8
- coderouter/logging.py +40 -0
- coderouter/metrics/collector.py +29 -0
- coderouter/routing/fallback.py +290 -5
- {coderouter_cli-2.7.2.dist-info → coderouter_cli-2.7.4.dist-info}/METADATA +3 -1
- {coderouter_cli-2.7.2.dist-info → coderouter_cli-2.7.4.dist-info}/RECORD +11 -11
- {coderouter_cli-2.7.2.dist-info → coderouter_cli-2.7.4.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.7.2.dist-info → coderouter_cli-2.7.4.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.7.2.dist-info → coderouter_cli-2.7.4.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -718,6 +718,57 @@ class FallbackChain(BaseModel):
|
|
|
718
718
|
),
|
|
719
719
|
)
|
|
720
720
|
|
|
721
|
+
# --- ⑧ (empty-response): per-request empty-response fallback ------------
|
|
722
|
+
#
|
|
723
|
+
# Some local backends (observed: gemma4:26b on ``no_tool_temptation``
|
|
724
|
+
# prompts) return a 200 with a structurally-valid but *content-empty*
|
|
725
|
+
# Anthropic response — no tool_use and no non-whitespace text — for a
|
|
726
|
+
# fraction of requests. The drift guard's ``empty_response_rate`` is a
|
|
727
|
+
# windowed aggregate (default threshold 0.3, ``min_window_fill`` 6): it
|
|
728
|
+
# promotes/reloads a backend once the *rate* is bad, but cannot rescue a
|
|
729
|
+
# single blank turn in-flight. This knob adds a per-request in-flight
|
|
730
|
+
# fallback that re-dispatches the *same* request to the next provider in
|
|
731
|
+
# the chain the moment an empty response is detected.
|
|
732
|
+
#
|
|
733
|
+
# Design: "empty" is judged on *content*, not usage.output_tokens (which
|
|
734
|
+
# some backends report unreliably). A response is empty when its content
|
|
735
|
+
# list is empty, or every block is either a whitespace-only ``text`` block
|
|
736
|
+
# or a ``thinking`` block — i.e. nothing the client can act on. A single
|
|
737
|
+
# ``tool_use`` block or one non-whitespace ``text`` block makes it non-empty.
|
|
738
|
+
#
|
|
739
|
+
# * ``off`` — no detection, no fallback, no log. Backward-compatible
|
|
740
|
+
# default (byte-for-byte identical to pre-⑧ behavior).
|
|
741
|
+
# * ``warn`` — detect + emit an ``empty-response-detected`` log line
|
|
742
|
+
# only; the empty response is returned unchanged.
|
|
743
|
+
# * ``fallback`` — on an empty response, log ``empty-response-detected``
|
|
744
|
+
# and continue to the next provider (the empty response
|
|
745
|
+
# is *not* recorded as an error). If every provider in
|
|
746
|
+
# the chain returns empty, the last empty response is
|
|
747
|
+
# returned as-is (a 200 blank is a legitimate answer)
|
|
748
|
+
# with ``chain_exhausted=True`` on the log line. On the
|
|
749
|
+
# streaming path, ``fallback`` buffers events until real
|
|
750
|
+
# content is observed, so an empty stream can be swapped
|
|
751
|
+
# to the next provider without the client seeing bytes.
|
|
752
|
+
#
|
|
753
|
+
# Default ``off`` preserves complete backward compatibility; the original
|
|
754
|
+
# request object is never mutated, so a later provider always receives the
|
|
755
|
+
# untouched request.
|
|
756
|
+
empty_response_action: Literal["off", "warn", "fallback"] = Field(
|
|
757
|
+
default="off",
|
|
758
|
+
description=(
|
|
759
|
+
"⑧ (empty-response): action when a provider returns a 200 with "
|
|
760
|
+
"empty content (no tool_use and no non-whitespace text; a "
|
|
761
|
+
"thinking-only response counts as empty). ``off`` (default) does "
|
|
762
|
+
"nothing. ``warn`` emits an ``empty-response-detected`` log only "
|
|
763
|
+
"and returns the empty response. ``fallback`` re-dispatches the "
|
|
764
|
+
"same request to the next provider (empty is not counted as an "
|
|
765
|
+
"error); if the whole chain returns empty, the last empty response "
|
|
766
|
+
"is returned unchanged. Streaming buffers events until real "
|
|
767
|
+
"content appears so empty streams can be swapped provider-side "
|
|
768
|
+
"without the client seeing any bytes."
|
|
769
|
+
),
|
|
770
|
+
)
|
|
771
|
+
|
|
721
772
|
# --- S2 (shim): tool_choice capability gate + emulation -----------------
|
|
722
773
|
#
|
|
723
774
|
# Anthropic clients (Claude Code, SDKs) can pin the model to a specific
|
|
@@ -655,6 +655,28 @@ async def api_backends(request: Request) -> dict[str, Any]:
|
|
|
655
655
|
return {"backends": result}
|
|
656
656
|
|
|
657
657
|
|
|
658
|
+
def _launcher_provider_config(backend: str, port: int) -> Any:
|
|
659
|
+
"""Build the provider entry for a launcher-started backend.
|
|
660
|
+
|
|
661
|
+
``model`` is left EMPTY on purpose: llama-server / vllm / mlx decide
|
|
662
|
+
which model is actually loaded, and the empty-model /v1/models
|
|
663
|
+
passthrough then surfaces the upstream's real model id to benchmark
|
|
664
|
+
clients — swap the GGUF, no config edit needed.
|
|
665
|
+
|
|
666
|
+
Name embeds backend + port (``launcher-llamacpp-8085``) so restarting
|
|
667
|
+
on the same port REPLACES the entry instead of piling up duplicates.
|
|
668
|
+
"""
|
|
669
|
+
from coderouter.config.schemas import ProviderConfig
|
|
670
|
+
|
|
671
|
+
safe_backend = backend.replace(".", "")
|
|
672
|
+
return ProviderConfig(
|
|
673
|
+
name=f"launcher-{safe_backend}-{port}",
|
|
674
|
+
base_url=f"http://localhost:{port}/v1",
|
|
675
|
+
model="",
|
|
676
|
+
timeout_s=120.0,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
658
680
|
@router.post("/api/launcher/start")
|
|
659
681
|
async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
660
682
|
"""Start a new backend process."""
|
|
@@ -717,7 +739,35 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
717
739
|
_background_tasks.add(_task)
|
|
718
740
|
_task.add_done_callback(_background_tasks.discard)
|
|
719
741
|
|
|
720
|
-
|
|
742
|
+
# Provider auto-sync: register the just-started backend as a routable
|
|
743
|
+
# provider so requests can reach it without hand-editing providers.yaml
|
|
744
|
+
# (in-memory only — see FallbackEngine.register_provider docstring).
|
|
745
|
+
# Sync failure must never fail the start itself.
|
|
746
|
+
provider_sync: dict[str, Any] | None = None
|
|
747
|
+
engine = getattr(request.app.state, "engine", None)
|
|
748
|
+
if engine is not None and hasattr(engine, "register_provider"):
|
|
749
|
+
try:
|
|
750
|
+
provider_sync = engine.register_provider(
|
|
751
|
+
_launcher_provider_config(req.backend, req.port)
|
|
752
|
+
)
|
|
753
|
+
proc.log_tail.append(
|
|
754
|
+
"[launcher] provider sync: "
|
|
755
|
+
f"{provider_sync['provider']} -> profile "
|
|
756
|
+
f"'{provider_sync['profile']}' (in-memory)"
|
|
757
|
+
)
|
|
758
|
+
except Exception as exc:
|
|
759
|
+
logger.warning(
|
|
760
|
+
"launcher provider sync failed",
|
|
761
|
+
extra={"backend": req.backend, "port": req.port, "error": str(exc)},
|
|
762
|
+
)
|
|
763
|
+
proc.log_tail.append(f"[launcher] provider sync failed: {exc}")
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
"id": proc_id,
|
|
767
|
+
"pid": p.pid,
|
|
768
|
+
"command": cmd,
|
|
769
|
+
"provider_sync": provider_sync,
|
|
770
|
+
}
|
|
721
771
|
|
|
722
772
|
|
|
723
773
|
@router.post("/api/launcher/stop/{proc_id}")
|
|
@@ -78,22 +78,99 @@ def _resolve_stream_timeout_s(engine: FallbackEngine, profile: str | None) -> fl
|
|
|
78
78
|
return max(_STREAM_TIMEOUT_MIN_S, float(per_call) * _STREAM_TIMEOUT_MULTIPLIER)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
# --- /v1/models upstream passthrough -------------------------------------
|
|
82
|
+
#
|
|
83
|
+
# Providers with an *empty* ``model`` field are passthrough providers: the
|
|
84
|
+
# upstream (llama-server, LM Studio, ...) decides which model is loaded and
|
|
85
|
+
# CodeRouter never sends a model name. For those providers the historic
|
|
86
|
+
# behaviour — returning the provider *name* — makes external benchmarks
|
|
87
|
+
# indistinguishable across loaded GGUFs (the launcher_gui setup always
|
|
88
|
+
# reported ``llama-cpp-local`` regardless of the selected model). We now ask
|
|
89
|
+
# the upstream's own ``/models`` endpoint for its real model id(s) and
|
|
90
|
+
# surface those instead.
|
|
91
|
+
#
|
|
92
|
+
# Guards: only for ``kind == "openai_compat"`` with ``model == ""``; a short
|
|
93
|
+
# per-upstream TTL cache keeps repeated SDK probes cheap; ANY failure (refused
|
|
94
|
+
# connection, timeout, unexpected shape) falls back to the historic
|
|
95
|
+
# provider-name entry, so this is strictly additive. Providers with a
|
|
96
|
+
# configured model keep the exact previous behaviour.
|
|
97
|
+
|
|
98
|
+
_UPSTREAM_MODELS_TTL_S = 30.0
|
|
99
|
+
_UPSTREAM_MODELS_TIMEOUT_S = 2.0
|
|
100
|
+
# base_url -> (expires_monotonic, ids)
|
|
101
|
+
_upstream_models_cache: dict[str, tuple[float, list[str]]] = {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _fetch_upstream_model_ids(base_url: str) -> list[str]:
|
|
105
|
+
"""GET {base_url}/models and return the upstream model ids.
|
|
106
|
+
|
|
107
|
+
Raises on any transport / shape problem — the caller treats every
|
|
108
|
+
exception as "fall back to the provider-name entry".
|
|
109
|
+
"""
|
|
110
|
+
import httpx # local import keeps module import time flat
|
|
111
|
+
|
|
112
|
+
url = base_url.rstrip("/") + "/models"
|
|
113
|
+
async with httpx.AsyncClient(timeout=_UPSTREAM_MODELS_TIMEOUT_S) as client:
|
|
114
|
+
resp = await client.get(url)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
payload = resp.json()
|
|
117
|
+
ids = [
|
|
118
|
+
str(item["id"])
|
|
119
|
+
for item in payload.get("data", [])
|
|
120
|
+
if isinstance(item, dict) and item.get("id")
|
|
121
|
+
]
|
|
122
|
+
if not ids:
|
|
123
|
+
raise ValueError(f"upstream {url} returned no model ids")
|
|
124
|
+
return ids
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _upstream_model_ids_cached(base_url: str) -> list[str]:
|
|
128
|
+
"""TTL-cached wrapper around :func:`_fetch_upstream_model_ids`."""
|
|
129
|
+
now = time.monotonic()
|
|
130
|
+
hit = _upstream_models_cache.get(base_url)
|
|
131
|
+
if hit is not None and hit[0] > now:
|
|
132
|
+
return hit[1]
|
|
133
|
+
ids = await _fetch_upstream_model_ids(base_url)
|
|
134
|
+
_upstream_models_cache[base_url] = (now + _UPSTREAM_MODELS_TTL_S, ids)
|
|
135
|
+
return ids
|
|
136
|
+
|
|
137
|
+
|
|
81
138
|
@router.get("/models")
|
|
82
139
|
async def list_models(request: Request) -> dict[str, object]:
|
|
83
|
-
"""
|
|
140
|
+
"""/v1/models: provider names, with upstream passthrough for empty-model
|
|
141
|
+
providers (see the passthrough note above)."""
|
|
84
142
|
config = request.app.state.config
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
143
|
+
created = int(time.time())
|
|
144
|
+
data: list[dict[str, object]] = []
|
|
145
|
+
for p in config.providers:
|
|
146
|
+
if p.model == "" and p.kind == "openai_compat":
|
|
147
|
+
try:
|
|
148
|
+
ids = await _upstream_model_ids_cached(str(p.base_url))
|
|
149
|
+
except Exception: # any failure means "no passthrough"
|
|
150
|
+
logger.debug(
|
|
151
|
+
"models passthrough failed; falling back to provider name",
|
|
152
|
+
extra={"provider": p.name},
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
data.extend(
|
|
156
|
+
{
|
|
157
|
+
"id": model_id,
|
|
158
|
+
"object": "model",
|
|
159
|
+
"created": created,
|
|
160
|
+
"owned_by": f"coderouter/{p.name}",
|
|
161
|
+
}
|
|
162
|
+
for model_id in ids
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
data.append(
|
|
88
166
|
{
|
|
89
167
|
"id": p.name,
|
|
90
168
|
"object": "model",
|
|
91
|
-
"created":
|
|
169
|
+
"created": created,
|
|
92
170
|
"owned_by": "coderouter",
|
|
93
171
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
172
|
+
)
|
|
173
|
+
return {"object": "list", "data": data}
|
|
97
174
|
|
|
98
175
|
|
|
99
176
|
@router.post("/chat/completions", response_model=None)
|
coderouter/logging.py
CHANGED
|
@@ -1372,6 +1372,46 @@ def log_partial_stitch_surfaced(
|
|
|
1372
1372
|
)
|
|
1373
1373
|
|
|
1374
1374
|
|
|
1375
|
+
# ---------------------------------------------------------------------------
|
|
1376
|
+
# ⑧ (empty-response): per-request empty-response fallback log shape
|
|
1377
|
+
#
|
|
1378
|
+
# Fired when a provider returns a 200 with content-empty output (no
|
|
1379
|
+
# tool_use and no non-whitespace text). The single event lane carries the
|
|
1380
|
+
# ``action`` (warn|fallback) that triggered it, whether the empty response
|
|
1381
|
+
# came from the streaming or non-streaming path, and ``chain_exhausted``
|
|
1382
|
+
# — True only when every provider in the chain returned empty and the last
|
|
1383
|
+
# empty response is being returned to the client as-is.
|
|
1384
|
+
# ---------------------------------------------------------------------------
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def log_empty_response_detected(
|
|
1388
|
+
logger: logging.Logger,
|
|
1389
|
+
provider: str,
|
|
1390
|
+
*,
|
|
1391
|
+
action: str,
|
|
1392
|
+
stream: bool,
|
|
1393
|
+
chain_exhausted: bool = False,
|
|
1394
|
+
) -> None:
|
|
1395
|
+
"""Emit an ``empty-response-detected`` warn line.
|
|
1396
|
+
|
|
1397
|
+
Fired when ``empty_response_action`` is ``warn`` or ``fallback`` and a
|
|
1398
|
+
provider returns a structurally-valid but content-empty response. The
|
|
1399
|
+
``action`` field records which knob value produced the line; ``stream``
|
|
1400
|
+
distinguishes the SSE path from the non-streaming path; ``chain_exhausted``
|
|
1401
|
+
is True only when the whole chain returned empty and the last empty
|
|
1402
|
+
response is being returned unchanged.
|
|
1403
|
+
"""
|
|
1404
|
+
logger.warning(
|
|
1405
|
+
"empty-response-detected",
|
|
1406
|
+
extra={
|
|
1407
|
+
"provider": provider,
|
|
1408
|
+
"action": action,
|
|
1409
|
+
"stream": stream,
|
|
1410
|
+
"chain_exhausted": chain_exhausted,
|
|
1411
|
+
},
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
|
|
1375
1415
|
# ---------------------------------------------------------------------------
|
|
1376
1416
|
# v2.0-I: Continuous probe log shapes
|
|
1377
1417
|
#
|
coderouter/metrics/collector.py
CHANGED
|
@@ -52,6 +52,7 @@ Event inventory (dispatch table in :meth:`MetricsCollector._dispatch`)
|
|
|
52
52
|
``drift-promoted`` (v2.0-G) → ``drift_promoted_total``
|
|
53
53
|
``drift-reload-attempted`` → ``drift_reload_total`` / success
|
|
54
54
|
``partial-stitch-surfaced`` → ``partial_stitch_surfaced_total`` (v2.0-H)
|
|
55
|
+
``empty-response-detected`` → ``empty_responses_total`` + per-provider (⑧)
|
|
55
56
|
``probe-completed`` (v2.0-I) → ``probe_total`` / ``probe_success`` / ``probe_failure``
|
|
56
57
|
+ per-provider latency gauge
|
|
57
58
|
``probe-round-completed`` → ``probe_rounds_total`` (v2.0-I)
|
|
@@ -109,6 +110,7 @@ _KNOWN_EVENTS: Final[frozenset[str]] = frozenset(
|
|
|
109
110
|
"drift-promoted",
|
|
110
111
|
"drift-reload-attempted",
|
|
111
112
|
"partial-stitch-surfaced",
|
|
113
|
+
"empty-response-detected",
|
|
112
114
|
"probe-completed",
|
|
113
115
|
"probe-round-completed",
|
|
114
116
|
"probe-capabilities-drift",
|
|
@@ -272,6 +274,13 @@ class MetricsCollector(logging.Handler):
|
|
|
272
274
|
# client instead of a generic error event.
|
|
273
275
|
self._partial_stitch_surfaced_total: int = 0
|
|
274
276
|
|
|
277
|
+
# ⑧ (empty-response): count of per-request empty-response detections
|
|
278
|
+
# (both ``warn`` and ``fallback`` actions). Aggregate + per-provider
|
|
279
|
+
# so a dashboard can spot which backend blanks out. Counts every
|
|
280
|
+
# detection event, including the terminal chain-exhausted one.
|
|
281
|
+
self._empty_responses_total: int = 0
|
|
282
|
+
self._empty_responses_by_provider: Counter[str] = Counter()
|
|
283
|
+
|
|
275
284
|
# v2.0-I: continuous probe counters. Per-provider probe attempts
|
|
276
285
|
# and outcomes, plus a round counter for the dashboard's
|
|
277
286
|
# "probes/min" panel.
|
|
@@ -329,6 +338,7 @@ class MetricsCollector(logging.Handler):
|
|
|
329
338
|
"drift-promoted": self._on_drift_promoted,
|
|
330
339
|
"drift-reload-attempted": self._on_drift_reload_attempted,
|
|
331
340
|
"partial-stitch-surfaced": self._on_partial_stitch_surfaced,
|
|
341
|
+
"empty-response-detected": self._on_empty_response_detected,
|
|
332
342
|
"probe-completed": self._on_probe_completed,
|
|
333
343
|
"probe-round-completed": self._on_probe_round_completed,
|
|
334
344
|
"probe-capabilities-drift": self._on_probe_capabilities_drift,
|
|
@@ -670,6 +680,17 @@ class MetricsCollector(logging.Handler):
|
|
|
670
680
|
self._partial_stitch_surfaced_total += 1
|
|
671
681
|
self._push_recent("partial-stitch-surfaced", extras, record)
|
|
672
682
|
|
|
683
|
+
def _on_empty_response_detected(
|
|
684
|
+
self, extras: dict[str, Any], record: logging.LogRecord
|
|
685
|
+
) -> None:
|
|
686
|
+
# ⑧ (empty-response): a content-empty 200 was detected under a
|
|
687
|
+
# ``warn`` / ``fallback`` action.
|
|
688
|
+
self._empty_responses_total += 1
|
|
689
|
+
provider = _str(extras.get("provider"))
|
|
690
|
+
if provider:
|
|
691
|
+
self._empty_responses_by_provider[provider] += 1
|
|
692
|
+
self._push_recent("empty-response-detected", extras, record)
|
|
693
|
+
|
|
673
694
|
def _on_probe_completed(
|
|
674
695
|
self, extras: dict[str, Any], record: logging.LogRecord
|
|
675
696
|
) -> None:
|
|
@@ -888,6 +909,11 @@ class MetricsCollector(logging.Handler):
|
|
|
888
909
|
"drift_reload_success_total": self._drift_reload_success_total,
|
|
889
910
|
# v2.0-H (L6): partial stitch surfaced.
|
|
890
911
|
"partial_stitch_surfaced_total": self._partial_stitch_surfaced_total,
|
|
912
|
+
# ⑧ (empty-response): per-request empty-response fallback.
|
|
913
|
+
"empty_responses_total": self._empty_responses_total,
|
|
914
|
+
"empty_responses_by_provider": dict(
|
|
915
|
+
self._empty_responses_by_provider
|
|
916
|
+
),
|
|
891
917
|
# v2.0-I: continuous probe counters.
|
|
892
918
|
"probe_total": dict(self._probe_total),
|
|
893
919
|
"probe_success": dict(self._probe_success),
|
|
@@ -1042,6 +1068,9 @@ class MetricsCollector(logging.Handler):
|
|
|
1042
1068
|
self._tokens_saved_by_mechanism.clear()
|
|
1043
1069
|
# v2.0-H (L6)
|
|
1044
1070
|
self._partial_stitch_surfaced_total = 0
|
|
1071
|
+
# ⑧ (empty-response)
|
|
1072
|
+
self._empty_responses_total = 0
|
|
1073
|
+
self._empty_responses_by_provider.clear()
|
|
1045
1074
|
# v2.0-I
|
|
1046
1075
|
self._probe_total.clear()
|
|
1047
1076
|
self._probe_success.clear()
|
coderouter/routing/fallback.py
CHANGED
|
@@ -76,6 +76,7 @@ from coderouter.logging import (
|
|
|
76
76
|
log_chain_memory_pressure_blocked,
|
|
77
77
|
log_chain_paid_gate_blocked,
|
|
78
78
|
log_demote_unhealthy_provider,
|
|
79
|
+
log_empty_response_detected,
|
|
79
80
|
log_memory_pressure_detected,
|
|
80
81
|
log_skip_budget_exceeded,
|
|
81
82
|
log_skip_memory_pressure,
|
|
@@ -973,6 +974,82 @@ def _warn_if_uniform_auth_failure(errors: list[AdapterError], *, profile: str) -
|
|
|
973
974
|
)
|
|
974
975
|
|
|
975
976
|
|
|
977
|
+
# ---------------------------------------------------------------------------
|
|
978
|
+
# ⑧ (empty-response): content-based emptiness judgement + stream buffering
|
|
979
|
+
# ---------------------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _block_field(block: Any, key: str) -> Any:
|
|
983
|
+
"""Read ``key`` from a content block whether it is a dict or a model.
|
|
984
|
+
|
|
985
|
+
Anthropic content blocks reach the engine as plain dicts (openai_compat
|
|
986
|
+
translation, most test fakes) or as pydantic-ish objects (native
|
|
987
|
+
passthrough). This mirrors the accessor pattern already used by the
|
|
988
|
+
drift fingerprint at the success-path tail.
|
|
989
|
+
"""
|
|
990
|
+
if isinstance(block, dict):
|
|
991
|
+
return block.get(key)
|
|
992
|
+
return getattr(block, key, None)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _anthropic_response_is_empty(resp: AnthropicResponse) -> bool:
|
|
996
|
+
"""Return True when ``resp`` carries no client-actionable content.
|
|
997
|
+
|
|
998
|
+
"Empty" is judged on content, never on ``usage.output_tokens`` (some
|
|
999
|
+
backends report that unreliably). A response is empty when:
|
|
1000
|
+
- its ``content`` list is empty / falsy, or
|
|
1001
|
+
- every block is either a whitespace-only ``text`` block or a
|
|
1002
|
+
``thinking`` block.
|
|
1003
|
+
|
|
1004
|
+
A single ``tool_use`` block, or one ``text`` block with any
|
|
1005
|
+
non-whitespace character, makes the response non-empty. Unknown block
|
|
1006
|
+
types are treated conservatively as *content* (non-empty) so a novel
|
|
1007
|
+
actionable block is never silently swallowed.
|
|
1008
|
+
"""
|
|
1009
|
+
content = getattr(resp, "content", None)
|
|
1010
|
+
if not content:
|
|
1011
|
+
return True
|
|
1012
|
+
for block in content:
|
|
1013
|
+
btype = _block_field(block, "type")
|
|
1014
|
+
if btype == "text":
|
|
1015
|
+
text = _block_field(block, "text") or ""
|
|
1016
|
+
if text.strip():
|
|
1017
|
+
return False
|
|
1018
|
+
# whitespace-only text → keep scanning
|
|
1019
|
+
continue
|
|
1020
|
+
if btype == "thinking":
|
|
1021
|
+
# thinking-only carries nothing the client can act on
|
|
1022
|
+
continue
|
|
1023
|
+
# tool_use or any other (unknown → conservatively non-empty) block
|
|
1024
|
+
return False
|
|
1025
|
+
return True
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _stream_event_is_real_content(event: AnthropicStreamEvent) -> bool:
|
|
1029
|
+
"""Return True when ``event`` is the first byte of actionable content.
|
|
1030
|
+
|
|
1031
|
+
Real content is either a ``content_block_start`` opening a ``tool_use``
|
|
1032
|
+
block, or a ``content_block_delta`` carrying a non-whitespace text
|
|
1033
|
+
delta. ``message_start`` / ``ping`` / empty-text openers do not count —
|
|
1034
|
+
they are the buffered preamble that an empty stream also emits.
|
|
1035
|
+
"""
|
|
1036
|
+
data = getattr(event, "data", None) or {}
|
|
1037
|
+
etype = data.get("type") if isinstance(data, dict) else None
|
|
1038
|
+
if etype == "content_block_start":
|
|
1039
|
+
block = data.get("content_block") or {}
|
|
1040
|
+
return isinstance(block, dict) and block.get("type") == "tool_use"
|
|
1041
|
+
if etype == "content_block_delta":
|
|
1042
|
+
delta = data.get("delta") or {}
|
|
1043
|
+
if isinstance(delta, dict):
|
|
1044
|
+
# text_delta with non-whitespace text is real content;
|
|
1045
|
+
# input_json_delta (tool args) is real content too.
|
|
1046
|
+
if delta.get("type") == "text_delta":
|
|
1047
|
+
return bool((delta.get("text") or "").strip())
|
|
1048
|
+
if delta.get("type") == "input_json_delta":
|
|
1049
|
+
return True
|
|
1050
|
+
return False
|
|
1051
|
+
|
|
1052
|
+
|
|
976
1053
|
class FallbackEngine:
|
|
977
1054
|
"""Sequential fallback router — the core of CodeRouter.
|
|
978
1055
|
|
|
@@ -1096,6 +1173,80 @@ class FallbackEngine:
|
|
|
1096
1173
|
# v2.0-K: persistent state store (None = in-memory only).
|
|
1097
1174
|
self._state_store: StateStore | None = None
|
|
1098
1175
|
|
|
1176
|
+
def register_provider(
|
|
1177
|
+
self, provider: ProviderConfig, profile_name: str = "launcher"
|
|
1178
|
+
) -> dict[str, Any]:
|
|
1179
|
+
"""Register (or update) a provider at runtime — launcher auto-sync.
|
|
1180
|
+
|
|
1181
|
+
Motivation: the embedded launcher can start a llama.cpp / vllm /
|
|
1182
|
+
mlx backend on an arbitrary port, but routing is driven entirely
|
|
1183
|
+
by providers.yaml — historically the operator had to hand-edit the
|
|
1184
|
+
config or the new backend was simply unreachable (observed in the
|
|
1185
|
+
wild as "launcher started on 8085, providers.yaml pointed at
|
|
1186
|
+
8080"). This method closes that gap in-process:
|
|
1187
|
+
|
|
1188
|
+
- the provider entry is appended to ``config.providers`` (or, when
|
|
1189
|
+
a provider with the same *name* already exists, its config entry
|
|
1190
|
+
and cached adapter are REPLACED — the launcher restarting a
|
|
1191
|
+
backend on the same port lands here),
|
|
1192
|
+
- an adapter is built and cached alongside the static ones,
|
|
1193
|
+
- the ``profile_name`` chain is created on demand, and the
|
|
1194
|
+
provider is moved to the FRONT of that chain (most recently
|
|
1195
|
+
launched backend wins the launcher profile).
|
|
1196
|
+
|
|
1197
|
+
Deliberately **in-memory only** — no providers.yaml rewrite. A
|
|
1198
|
+
YAML round-trip would destroy operator comments (and a
|
|
1199
|
+
comment-preserving writer means a new dependency), and the
|
|
1200
|
+
launcher's process registry is itself in-memory by design: both
|
|
1201
|
+
the process and its provider entry share the lifetime of this
|
|
1202
|
+
server process. ``default_profile`` is never touched — routing to
|
|
1203
|
+
the launcher profile stays an explicit opt-in
|
|
1204
|
+
(``X-CodeRouter-Profile: launcher`` or body ``profile``).
|
|
1205
|
+
|
|
1206
|
+
Returns a small summary dict for the launcher API response.
|
|
1207
|
+
"""
|
|
1208
|
+
from coderouter.config.schemas import FallbackChain
|
|
1209
|
+
|
|
1210
|
+
replaced = False
|
|
1211
|
+
for i, existing in enumerate(self.config.providers):
|
|
1212
|
+
if existing.name == provider.name:
|
|
1213
|
+
self.config.providers[i] = provider
|
|
1214
|
+
replaced = True
|
|
1215
|
+
break
|
|
1216
|
+
if not replaced:
|
|
1217
|
+
self.config.providers.append(provider)
|
|
1218
|
+
self._adapters[provider.name] = build_adapter(provider)
|
|
1219
|
+
|
|
1220
|
+
try:
|
|
1221
|
+
chain = self.config.profile_by_name(profile_name)
|
|
1222
|
+
except KeyError:
|
|
1223
|
+
chain = FallbackChain(name=profile_name, providers=[provider.name])
|
|
1224
|
+
self.config.profiles.append(chain)
|
|
1225
|
+
else:
|
|
1226
|
+
if provider.name in chain.providers:
|
|
1227
|
+
chain.providers.remove(provider.name)
|
|
1228
|
+
chain.providers.insert(0, provider.name)
|
|
1229
|
+
|
|
1230
|
+
logger.info(
|
|
1231
|
+
"launcher provider sync: registered %s -> %s (profile %r)",
|
|
1232
|
+
provider.name,
|
|
1233
|
+
provider.base_url,
|
|
1234
|
+
profile_name,
|
|
1235
|
+
extra={
|
|
1236
|
+
"provider": provider.name,
|
|
1237
|
+
"base_url": str(provider.base_url),
|
|
1238
|
+
"profile": profile_name,
|
|
1239
|
+
"replaced": replaced,
|
|
1240
|
+
},
|
|
1241
|
+
)
|
|
1242
|
+
return {
|
|
1243
|
+
"provider": provider.name,
|
|
1244
|
+
"base_url": str(provider.base_url),
|
|
1245
|
+
"profile": profile_name,
|
|
1246
|
+
"replaced": replaced,
|
|
1247
|
+
"persisted": False, # in-memory only, by design (see docstring)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1099
1250
|
@property
|
|
1100
1251
|
def plugins(self) -> PluginRegistry:
|
|
1101
1252
|
"""Return the plugin registry, lazily building an empty one if absent.
|
|
@@ -1717,6 +1868,20 @@ class FallbackEngine:
|
|
|
1717
1868
|
getattr(profile, "cache_control_action", "off"),
|
|
1718
1869
|
)
|
|
1719
1870
|
|
|
1871
|
+
def _resolve_empty_response_action(self, profile_name: str | None) -> str:
|
|
1872
|
+
"""⑧ (empty-response): resolve ``empty_response_action`` for a profile.
|
|
1873
|
+
|
|
1874
|
+
Defaults to ``off`` so a missing / stub profile (e.g. a
|
|
1875
|
+
``__new__``-constructed test engine) yields the backward-compatible
|
|
1876
|
+
no-op. Resolved once at the top of each Anthropic entry point.
|
|
1877
|
+
"""
|
|
1878
|
+
chosen = profile_name or self.config.default_profile
|
|
1879
|
+
try:
|
|
1880
|
+
profile = self.config.profile_by_name(chosen)
|
|
1881
|
+
except (KeyError, ValueError):
|
|
1882
|
+
return "off"
|
|
1883
|
+
return getattr(profile, "empty_response_action", "off")
|
|
1884
|
+
|
|
1720
1885
|
def _resolve_chain(self, profile_name: str | None) -> list[BaseAdapter]:
|
|
1721
1886
|
"""Return the list of adapters to try, in order, for this profile.
|
|
1722
1887
|
|
|
@@ -2431,6 +2596,11 @@ class FallbackEngine:
|
|
|
2431
2596
|
tool_choice_action, cache_control_action = self._resolve_shim_actions(
|
|
2432
2597
|
request.profile
|
|
2433
2598
|
)
|
|
2599
|
+
# ⑧ (empty-response): resolve the per-request empty-response action
|
|
2600
|
+
# once. ``last_empty_resp`` holds the most recent content-empty 200
|
|
2601
|
+
# under ``fallback`` so a fully-empty chain returns it verbatim.
|
|
2602
|
+
empty_response_action = self._resolve_empty_response_action(request.profile)
|
|
2603
|
+
last_empty_resp: AnthropicResponse | None = None
|
|
2434
2604
|
|
|
2435
2605
|
for adapter, will_degrade in chain:
|
|
2436
2606
|
is_native = isinstance(adapter, AnthropicAdapter)
|
|
@@ -2585,6 +2755,26 @@ class FallbackEngine:
|
|
|
2585
2755
|
stream=False,
|
|
2586
2756
|
response_fingerprint=_fp(_fp_text) if _fp_text else None,
|
|
2587
2757
|
)
|
|
2758
|
+
# ⑧ (empty-response): per-request empty-response handling. Runs
|
|
2759
|
+
# after the drift observation above (which still gets the real
|
|
2760
|
+
# output_tokens=0 signal for the windowed empty_response_rate)
|
|
2761
|
+
# but before the cache-observed / observer fanout, so a
|
|
2762
|
+
# ``fallback`` swap does not emit a "completed" line for a
|
|
2763
|
+
# response the client never sees.
|
|
2764
|
+
if empty_response_action != "off" and _anthropic_response_is_empty(resp):
|
|
2765
|
+
if empty_response_action == "fallback":
|
|
2766
|
+
# Remember this empty response so a fully-empty chain can
|
|
2767
|
+
# return it verbatim, then try the next provider. Not
|
|
2768
|
+
# recorded in ``errors`` — a 200 blank is not a failure.
|
|
2769
|
+
last_empty_resp = resp
|
|
2770
|
+
log_empty_response_detected(
|
|
2771
|
+
logger, adapter.name, action="fallback", stream=False
|
|
2772
|
+
)
|
|
2773
|
+
continue
|
|
2774
|
+
# ``warn``: log only; fall through and return the empty resp.
|
|
2775
|
+
log_empty_response_detected(
|
|
2776
|
+
logger, adapter.name, action="warn", stream=False
|
|
2777
|
+
)
|
|
2588
2778
|
# v1.9-A: pair every successful Anthropic response with a
|
|
2589
2779
|
# cache-observed log line. Native Anthropic / LM Studio
|
|
2590
2780
|
# /v1/messages report cache_read_input_tokens /
|
|
@@ -2625,6 +2815,20 @@ class FallbackEngine:
|
|
|
2625
2815
|
)
|
|
2626
2816
|
return resp
|
|
2627
2817
|
|
|
2818
|
+
# ⑧ (empty-response): the chain is exhausted. Under ``fallback``,
|
|
2819
|
+
# if every provider that answered returned an empty 200, return the
|
|
2820
|
+
# last empty response verbatim (a 200 blank is a legitimate answer)
|
|
2821
|
+
# rather than raising — errors is empty in that case anyway.
|
|
2822
|
+
if last_empty_resp is not None:
|
|
2823
|
+
log_empty_response_detected(
|
|
2824
|
+
logger,
|
|
2825
|
+
last_empty_resp.coderouter_provider or "unknown",
|
|
2826
|
+
action="fallback",
|
|
2827
|
+
stream=False,
|
|
2828
|
+
chain_exhausted=True,
|
|
2829
|
+
)
|
|
2830
|
+
return last_empty_resp
|
|
2831
|
+
|
|
2628
2832
|
profile = request.profile or self.config.default_profile
|
|
2629
2833
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
2630
2834
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
@@ -2676,6 +2880,15 @@ class FallbackEngine:
|
|
|
2676
2880
|
tool_choice_action, cache_control_action = self._resolve_shim_actions(
|
|
2677
2881
|
request.profile
|
|
2678
2882
|
)
|
|
2883
|
+
# ⑧ (empty-response): resolve the per-request empty-response action.
|
|
2884
|
+
# Only ``fallback`` changes the streaming path (it buffers events
|
|
2885
|
+
# until real content appears); ``off`` / ``warn`` stream unchanged.
|
|
2886
|
+
# ``last_empty_stream_buffer`` holds the buffered preamble of the
|
|
2887
|
+
# most recent empty stream so a fully-empty chain can flush it and
|
|
2888
|
+
# terminate normally rather than raising.
|
|
2889
|
+
empty_response_action = self._resolve_empty_response_action(request.profile)
|
|
2890
|
+
empty_fallback = empty_response_action == "fallback"
|
|
2891
|
+
last_empty_stream_buffer: list[AnthropicStreamEvent] | None = None
|
|
2679
2892
|
|
|
2680
2893
|
for adapter, will_degrade in chain:
|
|
2681
2894
|
is_native = isinstance(adapter, AnthropicAdapter)
|
|
@@ -2828,15 +3041,59 @@ class FallbackEngine:
|
|
|
2828
3041
|
self._observe_provider_success(
|
|
2829
3042
|
adapter.name, profile=request.profile
|
|
2830
3043
|
)
|
|
2831
|
-
|
|
2832
|
-
|
|
3044
|
+
# ⑧ (empty-response): under ``fallback`` we withhold the opening
|
|
3045
|
+
# events (message_start / empty content_block_start / ping) from
|
|
3046
|
+
# the client until the *first real content* event is observed.
|
|
3047
|
+
# Because no bytes have reached the client, an empty stream can
|
|
3048
|
+
# still be swapped for the next provider. ``off`` / ``warn`` keep
|
|
3049
|
+
# the legacy immediate-yield behavior (byte-for-byte unchanged).
|
|
3050
|
+
#
|
|
3051
|
+
# ``content_started`` flips True the moment real content is seen
|
|
3052
|
+
# (or immediately, when the action is not ``fallback``); from
|
|
3053
|
+
# then on events pass straight through and the mid-stream guard
|
|
3054
|
+
# is live exactly as before.
|
|
3055
|
+
content_started = not empty_fallback
|
|
3056
|
+
buffer: list[AnthropicStreamEvent] = []
|
|
2833
3057
|
# Mid-stream guard identical to stream() — any error after the
|
|
2834
|
-
# first event is terminal.
|
|
3058
|
+
# first *forwarded* event is terminal.
|
|
2835
3059
|
try:
|
|
2836
|
-
|
|
3060
|
+
# Seed the loop with the already-fetched ``first`` event,
|
|
3061
|
+
# then drain the rest of the iterator.
|
|
3062
|
+
pending = first
|
|
3063
|
+
iterator = event_iter.__aiter__()
|
|
3064
|
+
while True:
|
|
3065
|
+
ev = pending
|
|
2837
3066
|
acc.observe(ev)
|
|
2838
|
-
|
|
3067
|
+
if content_started:
|
|
3068
|
+
yield ev
|
|
3069
|
+
else:
|
|
3070
|
+
buffer.append(ev)
|
|
3071
|
+
if _stream_event_is_real_content(ev):
|
|
3072
|
+
# Real content arrived — flush the withheld
|
|
3073
|
+
# preamble in order, then switch to passthrough.
|
|
3074
|
+
content_started = True
|
|
3075
|
+
for buffered_ev in buffer:
|
|
3076
|
+
yield buffered_ev
|
|
3077
|
+
buffer = []
|
|
3078
|
+
try:
|
|
3079
|
+
pending = await iterator.__anext__()
|
|
3080
|
+
except StopAsyncIteration:
|
|
3081
|
+
break
|
|
2839
3082
|
except AdapterError as exc:
|
|
3083
|
+
if not content_started:
|
|
3084
|
+
# Empty-stream failure before any real content under
|
|
3085
|
+
# ``fallback``: nothing reached the client, so treat it
|
|
3086
|
+
# like an empty response and try the next provider. Do
|
|
3087
|
+
# NOT raise MidStreamError (that would surface to the
|
|
3088
|
+
# client). Record the error-only observation for adaptive.
|
|
3089
|
+
self._adaptive.record_attempt(
|
|
3090
|
+
adapter.name, latency_ms=None, success=False
|
|
3091
|
+
)
|
|
3092
|
+
log_empty_response_detected(
|
|
3093
|
+
logger, adapter.name, action="fallback", stream=True
|
|
3094
|
+
)
|
|
3095
|
+
last_empty_stream_buffer = buffer
|
|
3096
|
+
continue
|
|
2840
3097
|
# M2: mid-stream failure — record an error-only
|
|
2841
3098
|
# observation (no latency; first-event success already
|
|
2842
3099
|
# recorded) so adaptive error rate reflects the breakage.
|
|
@@ -2868,6 +3125,17 @@ class FallbackEngine:
|
|
|
2868
3125
|
raise MidStreamError(
|
|
2869
3126
|
adapter.name, exc, partial_content=acc.partial_content
|
|
2870
3127
|
) from exc
|
|
3128
|
+
# ⑧ (empty-response): the stream ended cleanly but no real
|
|
3129
|
+
# content was ever observed under ``fallback`` — the preamble is
|
|
3130
|
+
# still buffered (never sent). Treat as an empty response: keep
|
|
3131
|
+
# the buffer for a possible chain-exhausted flush and try the
|
|
3132
|
+
# next provider.
|
|
3133
|
+
if empty_fallback and not content_started:
|
|
3134
|
+
log_empty_response_detected(
|
|
3135
|
+
logger, adapter.name, action="fallback", stream=True
|
|
3136
|
+
)
|
|
3137
|
+
last_empty_stream_buffer = buffer
|
|
3138
|
+
continue
|
|
2871
3139
|
# v2.0-G (L4): drift detection observation (stream success).
|
|
2872
3140
|
# P1-4: compute response fingerprint for goal_progress_stall.
|
|
2873
3141
|
_stream_fp_text = " ".join(
|
|
@@ -2923,6 +3191,23 @@ class FallbackEngine:
|
|
|
2923
3191
|
)
|
|
2924
3192
|
return
|
|
2925
3193
|
|
|
3194
|
+
# ⑧ (empty-response): the chain is exhausted under ``fallback`` and
|
|
3195
|
+
# every provider produced an empty stream. Flush the last buffered
|
|
3196
|
+
# (empty) preamble so the client gets a well-formed, terminating SSE
|
|
3197
|
+
# sequence (message_start … message_stop) instead of an error — a
|
|
3198
|
+
# 200 blank is a legitimate answer. errors is empty in that case.
|
|
3199
|
+
if last_empty_stream_buffer is not None:
|
|
3200
|
+
log_empty_response_detected(
|
|
3201
|
+
logger,
|
|
3202
|
+
"unknown",
|
|
3203
|
+
action="fallback",
|
|
3204
|
+
stream=True,
|
|
3205
|
+
chain_exhausted=True,
|
|
3206
|
+
)
|
|
3207
|
+
for buffered_ev in last_empty_stream_buffer:
|
|
3208
|
+
yield buffered_ev
|
|
3209
|
+
return
|
|
3210
|
+
|
|
2926
3211
|
profile = request.profile or self.config.default_profile
|
|
2927
3212
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
2928
3213
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.4
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -220,6 +220,8 @@ providers:
|
|
|
220
220
|
| **オプションプロファイル** | `providers.yaml` に名前付きプリセットを定義 → ドロップダウンで選択するだけ |
|
|
221
221
|
| **複数プロセス管理** | llama.cpp と vllm を同時に起動し、ポートごとに独立管理 |
|
|
222
222
|
| **ログビューア** | 各プロセスの stdout/stderr をブラウザ内でリアルタイム確認 |
|
|
223
|
+
| **provider 自動同期** (v2.7.4) | 起動したバックエンドを provider として自動登録(`launcher-llamacpp-8085` 等)。providers.yaml 無編集で `X-CodeRouter-Profile: launcher` からルーティング可能。メモリ内のみ・serve と同寿命 |
|
|
224
|
+
| **モデル名パススルー** (v2.7.4) | `model: ""` の provider は `/v1/models` が上流のロード中モデル ID(gguf 名)をそのまま返す。gguf を差し替えても config 編集不要 — 外部ベンチからモデルを識別できる |
|
|
223
225
|
|
|
224
226
|
```yaml
|
|
225
227
|
# providers.yaml に追記するだけで有効になる
|
|
@@ -10,7 +10,7 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
|
|
|
10
10
|
coderouter/gguf_introspect.py,sha256=FZO14STLSp94Rfo5AInGwYUOpfjiXOW6CH5RiczTWDE,9514
|
|
11
11
|
coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
|
|
12
12
|
coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
|
|
13
|
-
coderouter/logging.py,sha256=
|
|
13
|
+
coderouter/logging.py,sha256=91cveN8SCJEMNz9DGi6TrFSlc8cx0wdUkOdSLWLA-z8,56144
|
|
14
14
|
coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
|
|
15
15
|
coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
|
|
16
16
|
coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
|
|
@@ -23,7 +23,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
|
|
|
23
23
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
24
24
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
25
25
|
coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
|
|
26
|
-
coderouter/config/schemas.py,sha256=
|
|
26
|
+
coderouter/config/schemas.py,sha256=f46bpmSzZ_zJRhHbo9-inp-mAW86WZAg6hfSxgPaEcc,77741
|
|
27
27
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
28
28
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
29
29
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
@@ -41,11 +41,11 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
|
|
|
41
41
|
coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
|
|
42
42
|
coderouter/ingress/app.py,sha256=h6s-UWsbGP22rku4pPVlvRdrtbMj4KoEEUZoQQOrTTw,19370
|
|
43
43
|
coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
|
|
44
|
-
coderouter/ingress/launcher_routes.py,sha256=
|
|
44
|
+
coderouter/ingress/launcher_routes.py,sha256=QzG0l0c2ZmAcQai3E-xs3cV5fUYf3ajjZauvI9pKDh4,57959
|
|
45
45
|
coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
|
|
46
|
-
coderouter/ingress/openai_routes.py,sha256=
|
|
46
|
+
coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
|
|
47
47
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
48
|
-
coderouter/metrics/collector.py,sha256=
|
|
48
|
+
coderouter/metrics/collector.py,sha256=wdGuX5yOBWEHCz1KVIfaUBZP5NXW3U34yyYlLGwgoCE,61212
|
|
49
49
|
coderouter/metrics/prometheus.py,sha256=THYkvrcpQK3ZNW-BV0h3O-PoT2ppNZ0dapQuu1nyHwE,24410
|
|
50
50
|
coderouter/plugins/__init__.py,sha256=76hMLe5dV_ilripHXzWn3HSYoIALjzlw4EJVyI-GyIM,1974
|
|
51
51
|
coderouter/plugins/base.py,sha256=n9hsck2NCSqi6oeHIumKC5zhQ8JGwCXUz7J5AZQCQss,5772
|
|
@@ -56,7 +56,7 @@ coderouter/routing/adaptive.py,sha256=G2o377twGSjbUh65wiIFx6klnpFGjsD_nI3oDvcBwh
|
|
|
56
56
|
coderouter/routing/auto_router.py,sha256=y4v0c8u5F9f98Vmhx1vRcKPiOgAvpzbFqr6TIh058h0,13341
|
|
57
57
|
coderouter/routing/budget.py,sha256=PblmVKJGs_BwNa9uDHAA8hmZ4XIVKv38mHAeU0V3OMs,8451
|
|
58
58
|
coderouter/routing/capability.py,sha256=rRQhzTgQYFTFDNYcSLKvq3xjdw6B1w7T-3jS7PTMI14,27864
|
|
59
|
-
coderouter/routing/fallback.py,sha256=
|
|
59
|
+
coderouter/routing/fallback.py,sha256=L5hwe76DRlmHTR39dXafi27T5zRrgLlgX9uINddcSGk,142983
|
|
60
60
|
coderouter/state/__init__.py,sha256=XoGcPmmBQSiZWML2S0juSveQ78xfhtdeCliNnVyzu7E,1088
|
|
61
61
|
coderouter/state/audit_log.py,sha256=n7vuDsTfd5iuNUJhlRQPakJ2tMSsT9EfgPCzs1GEaac,10941
|
|
62
62
|
coderouter/state/replay.py,sha256=Z_YHKroTKZdrL8qObFxcoLOAQWWXZvXFdLfxzvBhEJg,11230
|
|
@@ -67,8 +67,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
|
|
|
67
67
|
coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
|
|
68
68
|
coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
|
|
69
69
|
coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
|
|
70
|
-
coderouter_cli-2.7.
|
|
71
|
-
coderouter_cli-2.7.
|
|
72
|
-
coderouter_cli-2.7.
|
|
73
|
-
coderouter_cli-2.7.
|
|
74
|
-
coderouter_cli-2.7.
|
|
70
|
+
coderouter_cli-2.7.4.dist-info/METADATA,sha256=55ucKwRohGC6WEQr_NyGWjRjzhVgE7rOYgl4rfDNNkw,15388
|
|
71
|
+
coderouter_cli-2.7.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
72
|
+
coderouter_cli-2.7.4.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
73
|
+
coderouter_cli-2.7.4.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
74
|
+
coderouter_cli-2.7.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|