coderouter-cli 2.6.0__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.
@@ -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=[(((),), drift_promoted)],
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=[(((),), drift_reload)],
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=[(((),), drift_reload_ok)],
428
+ samples=[((), drift_reload_ok)],
429
429
  )
430
430
  )
431
431
 
@@ -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
- # Last drift verdict (set by _observe_drift_signal for ingress header).
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 string of the most recent drift verdict, or None.
936
-
937
- The ingress reads this after generate_anthropic / stream_anthropic to
938
- set the ``X-CodeRouter-Drift`` response header. Returns ``"mild"`` or
939
- ``"severe"`` when drift was detected, ``None`` otherwise.
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 = self._last_drift_verdict
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
- # Store for ingress response header.
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
- return _apply_context_budget_guard(
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
- # v2.0-F (L1): context budget guard runs after chain resolution
1975
- # (needs the first provider's max_context_tokens). May trim the
1976
- # request's messages if over the trim threshold.
1977
- # NOTE: When called from the ingress via apply_context_budget()
1978
- # first, the request is already under threshold this is a
1979
- # cheap no-op re-check (estimate < threshold → returns None).
1980
- first_provider_config = chain[0][0].config if chain else None
1981
- request, _ctx_status = _apply_context_budget_guard(
1982
- request, config=self.config, first_provider_config=first_provider_config,
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
- # v2.0-F (L1): context budget guard mirrors the non-streaming path.
2200
- # See apply_context_budget() note usually a no-op re-check here.
2201
- first_provider_config = chain[0][0].config if chain else None
2202
- request, _ctx_status = _apply_context_budget_guard(
2203
- request, config=self.config, first_provider_config=first_provider_config,
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={