coderouter-cli 2.6.1__py3-none-any.whl → 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/adapters/anthropic_native.py +66 -30
- coderouter/adapters/base.py +46 -3
- coderouter/adapters/openai_compat.py +53 -11
- coderouter/config/schemas.py +154 -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 +115 -1
- coderouter/ingress/app.py +162 -1
- coderouter/ingress/launcher_routes.py +187 -11
- coderouter/ingress/openai_routes.py +77 -7
- coderouter/logging.py +24 -3
- coderouter/metrics/collector.py +454 -245
- coderouter/metrics/prometheus.py +3 -3
- coderouter/routing/fallback.py +387 -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 +39 -23
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.0.dist-info}/RECORD +25 -25
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.0.dist-info}/licenses/LICENSE +0 -0
coderouter/metrics/prometheus.py
CHANGED
|
@@ -407,7 +407,7 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
|
|
|
407
407
|
"Number of times a drifted provider was demoted "
|
|
408
408
|
"(promote/reload action fired)."
|
|
409
409
|
),
|
|
410
|
-
samples=[((
|
|
410
|
+
samples=[((), drift_promoted)],
|
|
411
411
|
)
|
|
412
412
|
)
|
|
413
413
|
drift_reload = counters.get("drift_reload_total", 0)
|
|
@@ -416,7 +416,7 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
|
|
|
416
416
|
_counter(
|
|
417
417
|
name="drift_reload_total",
|
|
418
418
|
help_text="Ollama KV cache flush attempts (reload action).",
|
|
419
|
-
samples=[((
|
|
419
|
+
samples=[((), drift_reload)],
|
|
420
420
|
)
|
|
421
421
|
)
|
|
422
422
|
drift_reload_ok = counters.get("drift_reload_success_total", 0)
|
|
@@ -425,7 +425,7 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
|
|
|
425
425
|
_counter(
|
|
426
426
|
name="drift_reload_success_total",
|
|
427
427
|
help_text="Successful Ollama KV cache flush attempts.",
|
|
428
|
-
samples=[((
|
|
428
|
+
samples=[((), drift_reload_ok)],
|
|
429
429
|
)
|
|
430
430
|
)
|
|
431
431
|
|
coderouter/routing/fallback.py
CHANGED
|
@@ -27,6 +27,8 @@ from __future__ import annotations
|
|
|
27
27
|
import asyncio
|
|
28
28
|
import time
|
|
29
29
|
from collections.abc import AsyncIterator
|
|
30
|
+
from contextvars import ContextVar
|
|
31
|
+
from dataclasses import dataclass
|
|
30
32
|
from typing import TYPE_CHECKING, Any, Final
|
|
31
33
|
|
|
32
34
|
if TYPE_CHECKING:
|
|
@@ -103,6 +105,71 @@ from coderouter.translation import (
|
|
|
103
105
|
logger = get_logger(__name__)
|
|
104
106
|
|
|
105
107
|
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# M1: request-scoped drift verdict
|
|
110
|
+
#
|
|
111
|
+
# The drift verdict produced while serving one request is per-request
|
|
112
|
+
# state, not engine-global state. A single ``ContextVar`` isolates the
|
|
113
|
+
# verdict per-request: FastAPI/Starlette runs each request handler in its
|
|
114
|
+
# own asyncio task, so the value set inside ``generate_anthropic`` /
|
|
115
|
+
# ``stream_anthropic`` is only visible to the same request's ingress
|
|
116
|
+
# handler, and concurrent requests never observe each other's verdict.
|
|
117
|
+
#
|
|
118
|
+
# Prior to M1 the verdict was stored on a shared engine attribute
|
|
119
|
+
# (``_last_drift_verdict``), which two problems: (1) concurrent requests
|
|
120
|
+
# clobbered each other's verdict, and (2) the cooldown early-return never
|
|
121
|
+
# cleared it, so the ingress header went stale. Both are fixed by scoping
|
|
122
|
+
# the value to the request.
|
|
123
|
+
#
|
|
124
|
+
# Default ``None`` means "no drift observed for this request".
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
_drift_verdict_ctx: ContextVar[DriftVerdict | None] = ContextVar(
|
|
128
|
+
"coderouter_drift_verdict", default=None
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# M11: request-scoped prepared-dispatch cache
|
|
134
|
+
#
|
|
135
|
+
# The ingress calls ``apply_context_budget`` before dispatching, which
|
|
136
|
+
# resolves the Anthropic chain (including adaptive / drift reordering)
|
|
137
|
+
# and runs the token-count estimate for the budget guard. Then it calls
|
|
138
|
+
# ``generate_anthropic`` / ``stream_anthropic``, which — pre-M11 — re-ran
|
|
139
|
+
# BOTH the chain resolution and the estimate a second time.
|
|
140
|
+
#
|
|
141
|
+
# ``apply_context_budget`` now stashes the resolved chain + the
|
|
142
|
+
# (possibly-trimmed) request + budget status in this request-scoped
|
|
143
|
+
# ContextVar. The engine entry points pick it up and skip the redundant
|
|
144
|
+
# work. The ContextVar is per-request (same isolation rationale as the
|
|
145
|
+
# drift verdict), so concurrent requests never share a prepared chain.
|
|
146
|
+
#
|
|
147
|
+
# Backward compatible: when the ingress did NOT pre-call
|
|
148
|
+
# ``apply_context_budget`` (direct engine callers, unit tests, mock
|
|
149
|
+
# engines), the ContextVar is empty and the entry points resolve + trim
|
|
150
|
+
# as before.
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class _PreparedDispatch:
|
|
156
|
+
"""Cached result of the pre-dispatch chain resolution + budget guard.
|
|
157
|
+
|
|
158
|
+
``request`` is the possibly-trimmed request that
|
|
159
|
+
``apply_context_budget`` returned; reusing it means the budget guard
|
|
160
|
+
(and its token estimate) does not run again on the second pass.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
request: AnthropicRequest
|
|
164
|
+
chain: list[tuple[BaseAdapter, bool]]
|
|
165
|
+
budget_status: str | None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_prepared_dispatch_ctx: ContextVar[_PreparedDispatch | None] = ContextVar(
|
|
169
|
+
"coderouter_prepared_dispatch", default=None
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
106
173
|
# ---------------------------------------------------------------------------
|
|
107
174
|
# v1.9-A: cache observation helper
|
|
108
175
|
#
|
|
@@ -904,12 +971,26 @@ class FallbackEngine:
|
|
|
904
971
|
# Track which providers are currently in drift-demoted state
|
|
905
972
|
# and when their cooldown expires (monotonic timestamp).
|
|
906
973
|
self._drift_demoted: dict[str, float] = {}
|
|
907
|
-
#
|
|
974
|
+
# M1: DEPRECATED shared drift verdict. Retained only so legacy
|
|
975
|
+
# callers / tests that read ``engine._last_drift_verdict`` do not
|
|
976
|
+
# break. The authoritative, request-scoped verdict now lives in
|
|
977
|
+
# the ``_drift_verdict_ctx`` ContextVar (see module docstring).
|
|
978
|
+
# This attribute mirrors the last verdict written by
|
|
979
|
+
# ``_observe_drift_signal`` but MUST NOT be relied on for the
|
|
980
|
+
# ingress header under concurrency — use ``last_drift_severity``.
|
|
908
981
|
self._last_drift_verdict: DriftVerdict | None = None
|
|
909
982
|
# v2.0-J: active recovery probe tasks (one per excluded provider).
|
|
910
983
|
self._recovery_tasks: dict[str, asyncio.Task[None]] = {}
|
|
911
984
|
# v2.0-J: shutdown event shared with recovery probe tasks.
|
|
912
985
|
self._recovery_shutdown: asyncio.Event | None = None
|
|
986
|
+
# H7: providers restored as *excluded* from persisted state that
|
|
987
|
+
# still need a recovery probe armed. Populated by
|
|
988
|
+
# ``_load_all_state`` when it runs before an event loop exists
|
|
989
|
+
# (e.g. sync ``attach_state_store``); drained by
|
|
990
|
+
# ``rearm_pending_recovery_probes``. Without this, an excluded
|
|
991
|
+
# provider is filtered out of the chain, never observed, and so
|
|
992
|
+
# never gets a recovery probe -> permanent exclusion.
|
|
993
|
+
self._pending_recovery_rearm: dict[str, str] = {}
|
|
913
994
|
# v2.0-K: persistent state store (None = in-memory only).
|
|
914
995
|
self._state_store: StateStore | None = None
|
|
915
996
|
|
|
@@ -932,13 +1013,18 @@ class FallbackEngine:
|
|
|
932
1013
|
|
|
933
1014
|
@property
|
|
934
1015
|
def last_drift_severity(self) -> str | None:
|
|
935
|
-
"""Return the severity
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1016
|
+
"""Return the severity of *this request's* drift verdict, or None.
|
|
1017
|
+
|
|
1018
|
+
M1: reads the request-scoped ``_drift_verdict_ctx`` ContextVar so
|
|
1019
|
+
the value reflects only the verdict produced while serving the
|
|
1020
|
+
current request. The ingress reads this after generate_anthropic /
|
|
1021
|
+
stream_anthropic to set the ``X-CodeRouter-Drift`` response header.
|
|
1022
|
+
Returns ``"mild"`` or ``"severe"`` when drift was detected,
|
|
1023
|
+
``None`` otherwise (including when this request is in a provider's
|
|
1024
|
+
drift cooldown — the ContextVar defaults to None per-request, so
|
|
1025
|
+
the header never goes stale across requests).
|
|
940
1026
|
"""
|
|
941
|
-
v =
|
|
1027
|
+
v = _drift_verdict_ctx.get()
|
|
942
1028
|
if v is None or not v.drifted:
|
|
943
1029
|
return None
|
|
944
1030
|
return v.severity
|
|
@@ -1283,6 +1369,71 @@ class FallbackEngine:
|
|
|
1283
1369
|
sh_state = store.get("self_healing", "state")
|
|
1284
1370
|
if sh_state is not None:
|
|
1285
1371
|
self.self_healing.load_state(sh_state) # type: ignore[arg-type]
|
|
1372
|
+
# H7: providers restored as excluded are dropped from the
|
|
1373
|
+
# chain, so they are never re-attempted and thus never
|
|
1374
|
+
# observed failing again -- the only path that arms a
|
|
1375
|
+
# recovery probe. Re-arm one probe per restored provider
|
|
1376
|
+
# here so exclusions can eventually be lifted.
|
|
1377
|
+
self._rearm_recovery_probes()
|
|
1378
|
+
|
|
1379
|
+
def _rearm_recovery_probes(self) -> None:
|
|
1380
|
+
"""Arm a recovery probe for every currently-excluded provider.
|
|
1381
|
+
|
|
1382
|
+
Called after ``_load_all_state`` restores the self-healing set on
|
|
1383
|
+
startup. Mirrors the per-provider ``_spawn_recovery_probe`` path
|
|
1384
|
+
that ``_observe_provider_failure`` takes on a fresh UNHEALTHY
|
|
1385
|
+
transition, but drives it from the persisted exclusion set.
|
|
1386
|
+
|
|
1387
|
+
When no event loop is running yet (sync ``attach_state_store`` in
|
|
1388
|
+
tests, or startup before the loop is live) the pending providers
|
|
1389
|
+
are stashed in ``_pending_recovery_rearm`` for
|
|
1390
|
+
``rearm_pending_recovery_probes`` to flush once the loop exists.
|
|
1391
|
+
"""
|
|
1392
|
+
import asyncio
|
|
1393
|
+
|
|
1394
|
+
excluded = self.self_healing.excluded_profiles()
|
|
1395
|
+
if not excluded:
|
|
1396
|
+
return
|
|
1397
|
+
|
|
1398
|
+
try:
|
|
1399
|
+
asyncio.get_running_loop()
|
|
1400
|
+
loop_running = True
|
|
1401
|
+
except RuntimeError:
|
|
1402
|
+
loop_running = False
|
|
1403
|
+
|
|
1404
|
+
for provider, profile in excluded.items():
|
|
1405
|
+
if loop_running:
|
|
1406
|
+
self._arm_probe_for(provider, profile)
|
|
1407
|
+
else:
|
|
1408
|
+
# Defer until a loop is available.
|
|
1409
|
+
self._pending_recovery_rearm[provider] = profile
|
|
1410
|
+
|
|
1411
|
+
def _arm_probe_for(self, provider: str, profile: str) -> None:
|
|
1412
|
+
"""Resolve the owning chain and spawn a recovery probe.
|
|
1413
|
+
|
|
1414
|
+
No-op when the profile can no longer be resolved (e.g. the
|
|
1415
|
+
provider/profile was removed from config across the restart).
|
|
1416
|
+
"""
|
|
1417
|
+
chosen = profile or self.config.default_profile
|
|
1418
|
+
try:
|
|
1419
|
+
chain = self.config.profile_by_name(chosen)
|
|
1420
|
+
except (KeyError, ValueError):
|
|
1421
|
+
return
|
|
1422
|
+
self._spawn_recovery_probe(provider, chain=chain)
|
|
1423
|
+
|
|
1424
|
+
async def rearm_pending_recovery_probes(self) -> None:
|
|
1425
|
+
"""Flush recovery probes deferred while no event loop was running.
|
|
1426
|
+
|
|
1427
|
+
Intended to be awaited from the app lifespan startup path (which
|
|
1428
|
+
runs inside the event loop) after ``attach_state_store``. Idempotent
|
|
1429
|
+
and safe to call when nothing is pending.
|
|
1430
|
+
"""
|
|
1431
|
+
if not self._pending_recovery_rearm:
|
|
1432
|
+
return
|
|
1433
|
+
pending = dict(self._pending_recovery_rearm)
|
|
1434
|
+
self._pending_recovery_rearm.clear()
|
|
1435
|
+
for provider, profile in pending.items():
|
|
1436
|
+
self._arm_probe_for(provider, profile)
|
|
1286
1437
|
|
|
1287
1438
|
def _observe_drift_signal(
|
|
1288
1439
|
self,
|
|
@@ -1379,10 +1530,15 @@ class FallbackEngine:
|
|
|
1379
1530
|
verdict = detect_drift(window, thresholds)
|
|
1380
1531
|
|
|
1381
1532
|
if not verdict.drifted:
|
|
1533
|
+
# M1: leave the request-scoped ContextVar at its default
|
|
1534
|
+
# (None) — do not clear it to a shared value. Mirror onto the
|
|
1535
|
+
# deprecated attribute for legacy readers only.
|
|
1382
1536
|
self._last_drift_verdict = None
|
|
1383
1537
|
return None
|
|
1384
1538
|
|
|
1385
|
-
#
|
|
1539
|
+
# M1: store request-scoped for the ingress header (authoritative),
|
|
1540
|
+
# and mirror onto the deprecated shared attribute for compat.
|
|
1541
|
+
_drift_verdict_ctx.set(verdict)
|
|
1386
1542
|
self._last_drift_verdict = verdict
|
|
1387
1543
|
|
|
1388
1544
|
# Emit log
|
|
@@ -1652,6 +1808,17 @@ class FallbackEngine:
|
|
|
1652
1808
|
if self._profile_is_adaptive(request.profile) and base:
|
|
1653
1809
|
base = self._adaptive.compute_effective_order(base)
|
|
1654
1810
|
|
|
1811
|
+
# M3: drift demotion is applied here, independent of the adaptive
|
|
1812
|
+
# flag. Prior to M3, drift only demoted via
|
|
1813
|
+
# ``AdaptiveAdjuster.demote`` (synthetic failures), which had no
|
|
1814
|
+
# effect unless the profile opted into ``adaptive: true`` AND
|
|
1815
|
+
# enough samples accumulated. Now a drift-demoted provider whose
|
|
1816
|
+
# cooldown is still active is pushed to the back of the chain
|
|
1817
|
+
# deterministically, so the logged "demoted" action matches the
|
|
1818
|
+
# real try-order regardless of profile settings.
|
|
1819
|
+
if base:
|
|
1820
|
+
base = self._apply_drift_demotion(base)
|
|
1821
|
+
|
|
1655
1822
|
if not anthropic_request_requires_thinking(request):
|
|
1656
1823
|
return [(a, False) for a in base]
|
|
1657
1824
|
|
|
@@ -1679,6 +1846,49 @@ class FallbackEngine:
|
|
|
1679
1846
|
return False
|
|
1680
1847
|
return profile.adaptive
|
|
1681
1848
|
|
|
1849
|
+
def _apply_drift_demotion(
|
|
1850
|
+
self, adapters: list[BaseAdapter]
|
|
1851
|
+
) -> list[BaseAdapter]:
|
|
1852
|
+
"""M3: push drift-demoted providers to the back of the chain.
|
|
1853
|
+
|
|
1854
|
+
Drift demotion is a mechanism independent of adaptive routing:
|
|
1855
|
+
``_observe_drift_signal`` records a per-provider cooldown-expiry
|
|
1856
|
+
timestamp in ``self._drift_demoted`` when it detects drift and the
|
|
1857
|
+
profile action is ``promote`` / ``reload``. Here we honor that
|
|
1858
|
+
state by moving still-in-cooldown providers behind the
|
|
1859
|
+
non-demoted ones, preserving relative order within each bucket
|
|
1860
|
+
(stable partition). Expired entries are pruned lazily so a
|
|
1861
|
+
recovered provider returns to its declared position.
|
|
1862
|
+
|
|
1863
|
+
This runs regardless of ``profile.adaptive`` so the "drift
|
|
1864
|
+
demoted" log emitted at detection time matches the actual
|
|
1865
|
+
try-order — the previous behavior only affected order when
|
|
1866
|
+
``adaptive: true`` and enough synthetic-failure samples had
|
|
1867
|
+
accumulated in the adaptive window.
|
|
1868
|
+
"""
|
|
1869
|
+
demoted_map = getattr(self, "_drift_demoted", None)
|
|
1870
|
+
if not demoted_map:
|
|
1871
|
+
return adapters
|
|
1872
|
+
|
|
1873
|
+
now = time.monotonic()
|
|
1874
|
+
# Lazily prune expired cooldowns so recovered providers restore.
|
|
1875
|
+
expired = [p for p, until in demoted_map.items() if now >= until]
|
|
1876
|
+
for provider in expired:
|
|
1877
|
+
demoted_map.pop(provider, None)
|
|
1878
|
+
if not demoted_map:
|
|
1879
|
+
return adapters
|
|
1880
|
+
|
|
1881
|
+
kept: list[BaseAdapter] = []
|
|
1882
|
+
demoted: list[BaseAdapter] = []
|
|
1883
|
+
for adapter in adapters:
|
|
1884
|
+
if adapter.name in demoted_map:
|
|
1885
|
+
demoted.append(adapter)
|
|
1886
|
+
else:
|
|
1887
|
+
kept.append(adapter)
|
|
1888
|
+
if not demoted:
|
|
1889
|
+
return adapters
|
|
1890
|
+
return kept + demoted
|
|
1891
|
+
|
|
1682
1892
|
async def generate(self, request: ChatRequest) -> ChatResponse:
|
|
1683
1893
|
"""Non-streaming OpenAI-shaped generation with sequential fallback.
|
|
1684
1894
|
|
|
@@ -1698,8 +1908,19 @@ class FallbackEngine:
|
|
|
1698
1908
|
"try-provider",
|
|
1699
1909
|
extra={"provider": adapter.name, "stream": False},
|
|
1700
1910
|
)
|
|
1911
|
+
# M2: time the attempt so the OpenAI-shaped non-streaming path
|
|
1912
|
+
# feeds the adaptive rolling window too (previously only
|
|
1913
|
+
# generate_anthropic recorded, so OpenAI clients got no
|
|
1914
|
+
# adaptive routing signal).
|
|
1915
|
+
attempt_started = time.monotonic()
|
|
1701
1916
|
try:
|
|
1702
1917
|
response = await adapter.generate(request, overrides=overrides)
|
|
1918
|
+
# M2: record the successful attempt latency.
|
|
1919
|
+
self._adaptive.record_attempt(
|
|
1920
|
+
adapter.name,
|
|
1921
|
+
latency_ms=(time.monotonic() - attempt_started) * 1000.0,
|
|
1922
|
+
success=True,
|
|
1923
|
+
)
|
|
1703
1924
|
logger.info(
|
|
1704
1925
|
"provider-ok",
|
|
1705
1926
|
extra={"provider": adapter.name, "stream": False},
|
|
@@ -1712,6 +1933,17 @@ class FallbackEngine:
|
|
|
1712
1933
|
)
|
|
1713
1934
|
return response
|
|
1714
1935
|
except AdapterError as exc:
|
|
1936
|
+
# M2: record the failed attempt. Auth failures carry no
|
|
1937
|
+
# useful latency signal (mirrors generate_anthropic).
|
|
1938
|
+
self._adaptive.record_attempt(
|
|
1939
|
+
adapter.name,
|
|
1940
|
+
latency_ms=(
|
|
1941
|
+
None
|
|
1942
|
+
if exc.status_code in {401, 403}
|
|
1943
|
+
else (time.monotonic() - attempt_started) * 1000.0
|
|
1944
|
+
),
|
|
1945
|
+
success=False,
|
|
1946
|
+
)
|
|
1715
1947
|
logger.warning(
|
|
1716
1948
|
"provider-failed",
|
|
1717
1949
|
extra={
|
|
@@ -1748,14 +1980,35 @@ class FallbackEngine:
|
|
|
1748
1980
|
"try-provider",
|
|
1749
1981
|
extra={"provider": adapter.name, "stream": True},
|
|
1750
1982
|
)
|
|
1983
|
+
# M2: measure latency to the first streamed event so the
|
|
1984
|
+
# OpenAI streaming path feeds the adaptive window. Claude Code
|
|
1985
|
+
# and most agents stream, so without this adaptive routing was
|
|
1986
|
+
# effectively inert on the busiest path.
|
|
1987
|
+
attempt_started = time.monotonic()
|
|
1751
1988
|
stream_iter = adapter.stream(request, overrides=overrides)
|
|
1752
1989
|
try:
|
|
1753
1990
|
first = await anext(stream_iter)
|
|
1754
1991
|
except StopAsyncIteration:
|
|
1755
1992
|
# Adapter produced zero chunks — treat as failure, try next
|
|
1993
|
+
# M2: record the empty-stream failure.
|
|
1994
|
+
self._adaptive.record_attempt(
|
|
1995
|
+
adapter.name,
|
|
1996
|
+
latency_ms=(time.monotonic() - attempt_started) * 1000.0,
|
|
1997
|
+
success=False,
|
|
1998
|
+
)
|
|
1756
1999
|
errors.append(AdapterError("empty stream", provider=adapter.name, retryable=True))
|
|
1757
2000
|
continue
|
|
1758
2001
|
except AdapterError as exc:
|
|
2002
|
+
# M2: record the pre-first-event failure.
|
|
2003
|
+
self._adaptive.record_attempt(
|
|
2004
|
+
adapter.name,
|
|
2005
|
+
latency_ms=(
|
|
2006
|
+
None
|
|
2007
|
+
if exc.status_code in {401, 403}
|
|
2008
|
+
else (time.monotonic() - attempt_started) * 1000.0
|
|
2009
|
+
),
|
|
2010
|
+
success=False,
|
|
2011
|
+
)
|
|
1759
2012
|
logger.warning(
|
|
1760
2013
|
"provider-failed",
|
|
1761
2014
|
extra={
|
|
@@ -1774,6 +2027,13 @@ class FallbackEngine:
|
|
|
1774
2027
|
break
|
|
1775
2028
|
continue
|
|
1776
2029
|
|
|
2030
|
+
# M2: first event landed → record success with the
|
|
2031
|
+
# time-to-first-event latency.
|
|
2032
|
+
self._adaptive.record_attempt(
|
|
2033
|
+
adapter.name,
|
|
2034
|
+
latency_ms=(time.monotonic() - attempt_started) * 1000.0,
|
|
2035
|
+
success=True,
|
|
2036
|
+
)
|
|
1777
2037
|
logger.info(
|
|
1778
2038
|
"provider-ok",
|
|
1779
2039
|
extra={"provider": adapter.name, "stream": True},
|
|
@@ -1794,6 +2054,13 @@ class FallbackEngine:
|
|
|
1794
2054
|
async for chunk in stream_iter:
|
|
1795
2055
|
yield chunk
|
|
1796
2056
|
except AdapterError as exc:
|
|
2057
|
+
# M2: a mid-stream failure is a partial failure for
|
|
2058
|
+
# adaptive purposes. We already recorded the success at
|
|
2059
|
+
# first-event; add an error-only observation (no latency)
|
|
2060
|
+
# so the error rate reflects the mid-stream breakage.
|
|
2061
|
+
self._adaptive.record_attempt(
|
|
2062
|
+
adapter.name, latency_ms=None, success=False
|
|
2063
|
+
)
|
|
1797
2064
|
logger.warning(
|
|
1798
2065
|
"provider-failed-midstream",
|
|
1799
2066
|
extra={
|
|
@@ -1849,12 +2116,52 @@ class FallbackEngine:
|
|
|
1849
2116
|
The returned request (possibly trimmed) should be passed to the
|
|
1850
2117
|
engine method; the engine will skip the guard on the second pass
|
|
1851
2118
|
since the request is already under threshold.
|
|
2119
|
+
|
|
2120
|
+
M11: the resolved chain + trimmed request + status are cached in a
|
|
2121
|
+
request-scoped ContextVar so the immediately-following
|
|
2122
|
+
``generate_anthropic`` / ``stream_anthropic`` call can skip a
|
|
2123
|
+
second chain resolution AND a second token estimate. See
|
|
2124
|
+
``_prepared_dispatch_ctx``.
|
|
1852
2125
|
"""
|
|
1853
2126
|
chain = self._resolve_anthropic_chain(request)
|
|
1854
2127
|
first_provider_config = chain[0][0].config if chain else None
|
|
1855
|
-
|
|
2128
|
+
trimmed_request, status = _apply_context_budget_guard(
|
|
1856
2129
|
request, config=self.config, first_provider_config=first_provider_config,
|
|
1857
2130
|
)
|
|
2131
|
+
# M11: stash for the engine entry point to reuse. Keyed to the
|
|
2132
|
+
# request identity so a stale cache from an earlier request on the
|
|
2133
|
+
# same context can't be mistaken for this one.
|
|
2134
|
+
_prepared_dispatch_ctx.set(
|
|
2135
|
+
_PreparedDispatch(
|
|
2136
|
+
request=trimmed_request,
|
|
2137
|
+
chain=chain,
|
|
2138
|
+
budget_status=status,
|
|
2139
|
+
)
|
|
2140
|
+
)
|
|
2141
|
+
return trimmed_request, status
|
|
2142
|
+
|
|
2143
|
+
def _take_prepared_dispatch(
|
|
2144
|
+
self, request: AnthropicRequest
|
|
2145
|
+
) -> _PreparedDispatch | None:
|
|
2146
|
+
"""M11: return + consume the cached prepared dispatch, if valid.
|
|
2147
|
+
|
|
2148
|
+
Returns the ``_PreparedDispatch`` stashed by
|
|
2149
|
+
``apply_context_budget`` iff it corresponds to ``request`` (identity
|
|
2150
|
+
match — the ingress passes the exact object the guard returned).
|
|
2151
|
+
The cache is cleared on read so a subsequent direct engine call in
|
|
2152
|
+
the same context does not accidentally reuse it.
|
|
2153
|
+
"""
|
|
2154
|
+
prepared = _prepared_dispatch_ctx.get()
|
|
2155
|
+
if prepared is None:
|
|
2156
|
+
return None
|
|
2157
|
+
# Clear immediately — single-use per request.
|
|
2158
|
+
_prepared_dispatch_ctx.set(None)
|
|
2159
|
+
if prepared.request is not request:
|
|
2160
|
+
# The engine was handed a different request object than the one
|
|
2161
|
+
# the guard prepared (e.g. a plugin replaced it, or a direct
|
|
2162
|
+
# caller). Fall back to a fresh resolution to stay correct.
|
|
2163
|
+
return None
|
|
2164
|
+
return prepared
|
|
1858
2165
|
|
|
1859
2166
|
# ====================================================================
|
|
1860
2167
|
# v2.3.0: Plugin SDK hook helpers
|
|
@@ -1969,18 +2276,28 @@ class FallbackEngine:
|
|
|
1969
2276
|
# grow ``request.system`` without bypassing the budget cap —
|
|
1970
2277
|
# the budget guard sees the post-filter payload.
|
|
1971
2278
|
request = await self._apply_input_filters(request)
|
|
1972
|
-
chain = self._resolve_anthropic_chain(request)
|
|
1973
2279
|
|
|
1974
|
-
#
|
|
1975
|
-
#
|
|
1976
|
-
#
|
|
1977
|
-
#
|
|
1978
|
-
#
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
request
|
|
1983
|
-
|
|
2280
|
+
# M11: reuse the chain + trimmed request the ingress already
|
|
2281
|
+
# resolved in ``apply_context_budget`` when neither the tool-loop
|
|
2282
|
+
# guard nor the input filters replaced the request object (the
|
|
2283
|
+
# common no-plugin path). This avoids a second chain resolution
|
|
2284
|
+
# (incl. adaptive/drift reorder) and a second full token estimate.
|
|
2285
|
+
prepared = self._take_prepared_dispatch(request)
|
|
2286
|
+
if prepared is not None:
|
|
2287
|
+
chain = prepared.chain
|
|
2288
|
+
request = prepared.request
|
|
2289
|
+
else:
|
|
2290
|
+
chain = self._resolve_anthropic_chain(request)
|
|
2291
|
+
# v2.0-F (L1): context budget guard runs after chain resolution
|
|
2292
|
+
# (needs the first provider's max_context_tokens). May trim the
|
|
2293
|
+
# request's messages if over the trim threshold.
|
|
2294
|
+
# NOTE: When called from the ingress via apply_context_budget()
|
|
2295
|
+
# first, the request is already under threshold — this is a
|
|
2296
|
+
# cheap no-op re-check (estimate < threshold → returns None).
|
|
2297
|
+
first_provider_config = chain[0][0].config if chain else None
|
|
2298
|
+
request, _ctx_status = _apply_context_budget_guard(
|
|
2299
|
+
request, config=self.config, first_provider_config=first_provider_config,
|
|
2300
|
+
)
|
|
1984
2301
|
|
|
1985
2302
|
overrides = self._resolve_profile_overrides(request.profile)
|
|
1986
2303
|
errors: list[AdapterError] = []
|
|
@@ -2194,14 +2511,22 @@ class FallbackEngine:
|
|
|
2194
2511
|
# before chain resolution, and the budget guard below sees
|
|
2195
2512
|
# the post-filter payload.
|
|
2196
2513
|
request = await self._apply_input_filters(request)
|
|
2197
|
-
chain = self._resolve_anthropic_chain(request)
|
|
2198
2514
|
|
|
2199
|
-
#
|
|
2200
|
-
#
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2515
|
+
# M11: reuse the ingress-prepared chain + trimmed request when the
|
|
2516
|
+
# guard / filters were no-ops (identity match). Mirrors the
|
|
2517
|
+
# non-streaming path — avoids a redundant resolution + estimate.
|
|
2518
|
+
prepared = self._take_prepared_dispatch(request)
|
|
2519
|
+
if prepared is not None:
|
|
2520
|
+
chain = prepared.chain
|
|
2521
|
+
request = prepared.request
|
|
2522
|
+
else:
|
|
2523
|
+
chain = self._resolve_anthropic_chain(request)
|
|
2524
|
+
# v2.0-F (L1): context budget guard mirrors the non-streaming
|
|
2525
|
+
# path. See apply_context_budget() — usually a no-op re-check.
|
|
2526
|
+
first_provider_config = chain[0][0].config if chain else None
|
|
2527
|
+
request, _ctx_status = _apply_context_budget_guard(
|
|
2528
|
+
request, config=self.config, first_provider_config=first_provider_config,
|
|
2529
|
+
)
|
|
2205
2530
|
|
|
2206
2531
|
overrides = self._resolve_profile_overrides(request.profile)
|
|
2207
2532
|
errors: list[AdapterError] = []
|
|
@@ -2253,6 +2578,11 @@ class FallbackEngine:
|
|
|
2253
2578
|
# Stage 1: acquire an AnthropicStreamEvent iterator. Failures
|
|
2254
2579
|
# here are candidates for fallback (no bytes have been sent to
|
|
2255
2580
|
# the client yet).
|
|
2581
|
+
# M2: time to first event feeds the adaptive window on the
|
|
2582
|
+
# Anthropic streaming path (Claude Code's default). Previously
|
|
2583
|
+
# only the non-streaming generate_anthropic recorded, so the
|
|
2584
|
+
# busiest path produced no adaptive signal.
|
|
2585
|
+
attempt_started = time.monotonic()
|
|
2256
2586
|
event_iter: AsyncIterator[AnthropicStreamEvent]
|
|
2257
2587
|
first: AnthropicStreamEvent
|
|
2258
2588
|
try:
|
|
@@ -2278,9 +2608,26 @@ class FallbackEngine:
|
|
|
2278
2608
|
)
|
|
2279
2609
|
first = await anext(event_iter)
|
|
2280
2610
|
except StopAsyncIteration:
|
|
2611
|
+
# M2: record the empty-stream failure.
|
|
2612
|
+
self._adaptive.record_attempt(
|
|
2613
|
+
adapter.name,
|
|
2614
|
+
latency_ms=(time.monotonic() - attempt_started) * 1000.0,
|
|
2615
|
+
success=False,
|
|
2616
|
+
)
|
|
2281
2617
|
errors.append(AdapterError("empty stream", provider=adapter.name, retryable=True))
|
|
2282
2618
|
continue
|
|
2283
2619
|
except AdapterError as exc:
|
|
2620
|
+
# M2: record the pre-first-event failure. Auth failures
|
|
2621
|
+
# carry no useful latency signal (mirrors generate_anthropic).
|
|
2622
|
+
self._adaptive.record_attempt(
|
|
2623
|
+
adapter.name,
|
|
2624
|
+
latency_ms=(
|
|
2625
|
+
None
|
|
2626
|
+
if exc.status_code in {401, 403}
|
|
2627
|
+
else (time.monotonic() - attempt_started) * 1000.0
|
|
2628
|
+
),
|
|
2629
|
+
success=False,
|
|
2630
|
+
)
|
|
2284
2631
|
logger.warning(
|
|
2285
2632
|
"provider-failed",
|
|
2286
2633
|
extra={
|
|
@@ -2317,6 +2664,13 @@ class FallbackEngine:
|
|
|
2317
2664
|
"downgrade": downgrading,
|
|
2318
2665
|
},
|
|
2319
2666
|
)
|
|
2667
|
+
# M2: first event landed → record success with the
|
|
2668
|
+
# time-to-first-event latency on the Anthropic streaming path.
|
|
2669
|
+
self._adaptive.record_attempt(
|
|
2670
|
+
adapter.name,
|
|
2671
|
+
latency_ms=(time.monotonic() - attempt_started) * 1000.0,
|
|
2672
|
+
success=True,
|
|
2673
|
+
)
|
|
2320
2674
|
# v1.9-E phase 2 (L5): first chunk landed → success for
|
|
2321
2675
|
# the L5 health monitor on the Anthropic streaming path.
|
|
2322
2676
|
self._observe_provider_success(
|
|
@@ -2331,6 +2685,12 @@ class FallbackEngine:
|
|
|
2331
2685
|
acc.observe(ev)
|
|
2332
2686
|
yield ev
|
|
2333
2687
|
except AdapterError as exc:
|
|
2688
|
+
# M2: mid-stream failure — record an error-only
|
|
2689
|
+
# observation (no latency; first-event success already
|
|
2690
|
+
# recorded) so adaptive error rate reflects the breakage.
|
|
2691
|
+
self._adaptive.record_attempt(
|
|
2692
|
+
adapter.name, latency_ms=None, success=False
|
|
2693
|
+
)
|
|
2334
2694
|
logger.warning(
|
|
2335
2695
|
"provider-failed-midstream",
|
|
2336
2696
|
extra={
|