coderouter-cli 2.6.1__py3-none-any.whl → 2.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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:
@@ -84,10 +86,14 @@ from coderouter.routing.adaptive import AdaptiveAdjuster
84
86
  from coderouter.routing.budget import BudgetTracker
85
87
  from coderouter.routing.capability import (
86
88
  anthropic_request_has_cache_control,
89
+ anthropic_request_has_forced_tool_choice,
87
90
  anthropic_request_requires_thinking,
91
+ emulate_tool_choice,
88
92
  log_capability_degraded,
89
93
  provider_supports_cache_control,
90
94
  provider_supports_thinking,
95
+ provider_supports_tool_choice,
96
+ strip_cache_control,
91
97
  strip_thinking,
92
98
  )
93
99
  from coderouter.translation import (
@@ -103,6 +109,71 @@ from coderouter.translation import (
103
109
  logger = get_logger(__name__)
104
110
 
105
111
 
112
+ # ---------------------------------------------------------------------------
113
+ # M1: request-scoped drift verdict
114
+ #
115
+ # The drift verdict produced while serving one request is per-request
116
+ # state, not engine-global state. A single ``ContextVar`` isolates the
117
+ # verdict per-request: FastAPI/Starlette runs each request handler in its
118
+ # own asyncio task, so the value set inside ``generate_anthropic`` /
119
+ # ``stream_anthropic`` is only visible to the same request's ingress
120
+ # handler, and concurrent requests never observe each other's verdict.
121
+ #
122
+ # Prior to M1 the verdict was stored on a shared engine attribute
123
+ # (``_last_drift_verdict``), which two problems: (1) concurrent requests
124
+ # clobbered each other's verdict, and (2) the cooldown early-return never
125
+ # cleared it, so the ingress header went stale. Both are fixed by scoping
126
+ # the value to the request.
127
+ #
128
+ # Default ``None`` means "no drift observed for this request".
129
+ # ---------------------------------------------------------------------------
130
+
131
+ _drift_verdict_ctx: ContextVar[DriftVerdict | None] = ContextVar(
132
+ "coderouter_drift_verdict", default=None
133
+ )
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # M11: request-scoped prepared-dispatch cache
138
+ #
139
+ # The ingress calls ``apply_context_budget`` before dispatching, which
140
+ # resolves the Anthropic chain (including adaptive / drift reordering)
141
+ # and runs the token-count estimate for the budget guard. Then it calls
142
+ # ``generate_anthropic`` / ``stream_anthropic``, which — pre-M11 — re-ran
143
+ # BOTH the chain resolution and the estimate a second time.
144
+ #
145
+ # ``apply_context_budget`` now stashes the resolved chain + the
146
+ # (possibly-trimmed) request + budget status in this request-scoped
147
+ # ContextVar. The engine entry points pick it up and skip the redundant
148
+ # work. The ContextVar is per-request (same isolation rationale as the
149
+ # drift verdict), so concurrent requests never share a prepared chain.
150
+ #
151
+ # Backward compatible: when the ingress did NOT pre-call
152
+ # ``apply_context_budget`` (direct engine callers, unit tests, mock
153
+ # engines), the ContextVar is empty and the entry points resolve + trim
154
+ # as before.
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ @dataclass
159
+ class _PreparedDispatch:
160
+ """Cached result of the pre-dispatch chain resolution + budget guard.
161
+
162
+ ``request`` is the possibly-trimmed request that
163
+ ``apply_context_budget`` returned; reusing it means the budget guard
164
+ (and its token estimate) does not run again on the second pass.
165
+ """
166
+
167
+ request: AnthropicRequest
168
+ chain: list[tuple[BaseAdapter, bool]]
169
+ budget_status: str | None
170
+
171
+
172
+ _prepared_dispatch_ctx: ContextVar[_PreparedDispatch | None] = ContextVar(
173
+ "coderouter_prepared_dispatch", default=None
174
+ )
175
+
176
+
106
177
  # ---------------------------------------------------------------------------
107
178
  # v1.9-A: cache observation helper
108
179
  #
@@ -368,6 +439,104 @@ def _apply_context_budget_guard(
368
439
  return request, "warning"
369
440
 
370
441
 
442
+ # ---------------------------------------------------------------------------
443
+ # S2 / S3 (shim): per-provider tool_choice + cache_control shims
444
+ #
445
+ # Applied at the adapter-call site (same position as strip_thinking) so the
446
+ # mutation is per-provider: a request that falls through to a *capable*
447
+ # provider later in the chain still receives its original tool_choice /
448
+ # cache_control markers. All three actions are opt-in (default ``off``) and
449
+ # read from the resolved profile.
450
+ #
451
+ # The shim is a pure function returning the (possibly-copied) request to
452
+ # send to *this* provider. It never mutates the original request object.
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def _apply_tool_choice_shim(
457
+ request: AnthropicRequest,
458
+ *,
459
+ adapter: BaseAdapter,
460
+ action: str,
461
+ ) -> AnthropicRequest:
462
+ """S2: warn / emulate a forced tool_choice on a non-supporting provider.
463
+
464
+ Returns the request to hand to this provider. Only acts when the
465
+ request carries a forced ``tool_choice`` (``any`` / ``tool``) AND the
466
+ provider does not support it:
467
+
468
+ * ``warn`` → emit a ``capability-degraded`` log; request unchanged.
469
+ * ``emulate`` → return :func:`emulate_tool_choice` (tool_choice
470
+ stripped, system directive injected) + emit a
471
+ ``tool-choice-emulated`` log.
472
+
473
+ Any other case (action ``off``, non-forced choice, or a capable
474
+ provider) returns ``request`` unchanged.
475
+ """
476
+ if action == "off":
477
+ return request
478
+ if not anthropic_request_has_forced_tool_choice(request):
479
+ return request
480
+ if provider_supports_tool_choice(adapter.config):
481
+ return request
482
+
483
+ if action == "warn":
484
+ log_capability_degraded(
485
+ logger,
486
+ provider=adapter.name,
487
+ dropped=["tool_choice"],
488
+ reason="unsupported-backend",
489
+ )
490
+ return request
491
+
492
+ if action == "emulate":
493
+ choice = request.tool_choice if isinstance(request.tool_choice, dict) else {}
494
+ mode = choice.get("type")
495
+ tool_name = choice.get("name") if mode == "tool" else None
496
+ emulated = emulate_tool_choice(request)
497
+ logger.info(
498
+ "tool-choice-emulated",
499
+ extra={"provider": adapter.name, "tool_name": tool_name, "mode": mode},
500
+ )
501
+ return emulated
502
+
503
+ return request
504
+
505
+
506
+ def _apply_cache_control_shim(
507
+ request: AnthropicRequest,
508
+ *,
509
+ adapter: BaseAdapter,
510
+ action: str,
511
+ request_had_cache_control: bool,
512
+ ) -> AnthropicRequest:
513
+ """S3: strip cache_control markers before a non-supporting provider.
514
+
515
+ Returns the request to hand to this provider. Only acts when the
516
+ profile's ``cache_control_action`` is ``strip``, the request carries
517
+ cache_control markers, and the provider does not support them. Emits a
518
+ ``cache-control-stripped`` log with the marker count. Never emits a
519
+ tokens-saved event (this is a wire-compatibility strip, not a savings).
520
+
521
+ When the strip does not apply, returns ``request`` unchanged — the
522
+ existing v0.5-B ``capability-degraded`` observability log (emitted at
523
+ the call site) is preserved as the ``off`` default behavior.
524
+ """
525
+ if action != "strip":
526
+ return request
527
+ if not request_had_cache_control:
528
+ return request
529
+ if provider_supports_cache_control(adapter.config):
530
+ return request
531
+
532
+ stripped, markers_removed = strip_cache_control(request)
533
+ logger.info(
534
+ "cache-control-stripped",
535
+ extra={"provider": adapter.name, "markers_removed": markers_removed},
536
+ )
537
+ return stripped
538
+
539
+
371
540
  def _emit_cache_observed(
372
541
  response: AnthropicResponse,
373
542
  *,
@@ -904,12 +1073,26 @@ class FallbackEngine:
904
1073
  # Track which providers are currently in drift-demoted state
905
1074
  # and when their cooldown expires (monotonic timestamp).
906
1075
  self._drift_demoted: dict[str, float] = {}
907
- # Last drift verdict (set by _observe_drift_signal for ingress header).
1076
+ # M1: DEPRECATED shared drift verdict. Retained only so legacy
1077
+ # callers / tests that read ``engine._last_drift_verdict`` do not
1078
+ # break. The authoritative, request-scoped verdict now lives in
1079
+ # the ``_drift_verdict_ctx`` ContextVar (see module docstring).
1080
+ # This attribute mirrors the last verdict written by
1081
+ # ``_observe_drift_signal`` but MUST NOT be relied on for the
1082
+ # ingress header under concurrency — use ``last_drift_severity``.
908
1083
  self._last_drift_verdict: DriftVerdict | None = None
909
1084
  # v2.0-J: active recovery probe tasks (one per excluded provider).
910
1085
  self._recovery_tasks: dict[str, asyncio.Task[None]] = {}
911
1086
  # v2.0-J: shutdown event shared with recovery probe tasks.
912
1087
  self._recovery_shutdown: asyncio.Event | None = None
1088
+ # H7: providers restored as *excluded* from persisted state that
1089
+ # still need a recovery probe armed. Populated by
1090
+ # ``_load_all_state`` when it runs before an event loop exists
1091
+ # (e.g. sync ``attach_state_store``); drained by
1092
+ # ``rearm_pending_recovery_probes``. Without this, an excluded
1093
+ # provider is filtered out of the chain, never observed, and so
1094
+ # never gets a recovery probe -> permanent exclusion.
1095
+ self._pending_recovery_rearm: dict[str, str] = {}
913
1096
  # v2.0-K: persistent state store (None = in-memory only).
914
1097
  self._state_store: StateStore | None = None
915
1098
 
@@ -932,13 +1115,18 @@ class FallbackEngine:
932
1115
 
933
1116
  @property
934
1117
  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.
1118
+ """Return the severity of *this request's* drift verdict, or None.
1119
+
1120
+ M1: reads the request-scoped ``_drift_verdict_ctx`` ContextVar so
1121
+ the value reflects only the verdict produced while serving the
1122
+ current request. The ingress reads this after generate_anthropic /
1123
+ stream_anthropic to set the ``X-CodeRouter-Drift`` response header.
1124
+ Returns ``"mild"`` or ``"severe"`` when drift was detected,
1125
+ ``None`` otherwise (including when this request is in a provider's
1126
+ drift cooldown — the ContextVar defaults to None per-request, so
1127
+ the header never goes stale across requests).
940
1128
  """
941
- v = self._last_drift_verdict
1129
+ v = _drift_verdict_ctx.get()
942
1130
  if v is None or not v.drifted:
943
1131
  return None
944
1132
  return v.severity
@@ -1283,6 +1471,71 @@ class FallbackEngine:
1283
1471
  sh_state = store.get("self_healing", "state")
1284
1472
  if sh_state is not None:
1285
1473
  self.self_healing.load_state(sh_state) # type: ignore[arg-type]
1474
+ # H7: providers restored as excluded are dropped from the
1475
+ # chain, so they are never re-attempted and thus never
1476
+ # observed failing again -- the only path that arms a
1477
+ # recovery probe. Re-arm one probe per restored provider
1478
+ # here so exclusions can eventually be lifted.
1479
+ self._rearm_recovery_probes()
1480
+
1481
+ def _rearm_recovery_probes(self) -> None:
1482
+ """Arm a recovery probe for every currently-excluded provider.
1483
+
1484
+ Called after ``_load_all_state`` restores the self-healing set on
1485
+ startup. Mirrors the per-provider ``_spawn_recovery_probe`` path
1486
+ that ``_observe_provider_failure`` takes on a fresh UNHEALTHY
1487
+ transition, but drives it from the persisted exclusion set.
1488
+
1489
+ When no event loop is running yet (sync ``attach_state_store`` in
1490
+ tests, or startup before the loop is live) the pending providers
1491
+ are stashed in ``_pending_recovery_rearm`` for
1492
+ ``rearm_pending_recovery_probes`` to flush once the loop exists.
1493
+ """
1494
+ import asyncio
1495
+
1496
+ excluded = self.self_healing.excluded_profiles()
1497
+ if not excluded:
1498
+ return
1499
+
1500
+ try:
1501
+ asyncio.get_running_loop()
1502
+ loop_running = True
1503
+ except RuntimeError:
1504
+ loop_running = False
1505
+
1506
+ for provider, profile in excluded.items():
1507
+ if loop_running:
1508
+ self._arm_probe_for(provider, profile)
1509
+ else:
1510
+ # Defer until a loop is available.
1511
+ self._pending_recovery_rearm[provider] = profile
1512
+
1513
+ def _arm_probe_for(self, provider: str, profile: str) -> None:
1514
+ """Resolve the owning chain and spawn a recovery probe.
1515
+
1516
+ No-op when the profile can no longer be resolved (e.g. the
1517
+ provider/profile was removed from config across the restart).
1518
+ """
1519
+ chosen = profile or self.config.default_profile
1520
+ try:
1521
+ chain = self.config.profile_by_name(chosen)
1522
+ except (KeyError, ValueError):
1523
+ return
1524
+ self._spawn_recovery_probe(provider, chain=chain)
1525
+
1526
+ async def rearm_pending_recovery_probes(self) -> None:
1527
+ """Flush recovery probes deferred while no event loop was running.
1528
+
1529
+ Intended to be awaited from the app lifespan startup path (which
1530
+ runs inside the event loop) after ``attach_state_store``. Idempotent
1531
+ and safe to call when nothing is pending.
1532
+ """
1533
+ if not self._pending_recovery_rearm:
1534
+ return
1535
+ pending = dict(self._pending_recovery_rearm)
1536
+ self._pending_recovery_rearm.clear()
1537
+ for provider, profile in pending.items():
1538
+ self._arm_probe_for(provider, profile)
1286
1539
 
1287
1540
  def _observe_drift_signal(
1288
1541
  self,
@@ -1379,10 +1632,15 @@ class FallbackEngine:
1379
1632
  verdict = detect_drift(window, thresholds)
1380
1633
 
1381
1634
  if not verdict.drifted:
1635
+ # M1: leave the request-scoped ContextVar at its default
1636
+ # (None) — do not clear it to a shared value. Mirror onto the
1637
+ # deprecated attribute for legacy readers only.
1382
1638
  self._last_drift_verdict = None
1383
1639
  return None
1384
1640
 
1385
- # Store for ingress response header.
1641
+ # M1: store request-scoped for the ingress header (authoritative),
1642
+ # and mirror onto the deprecated shared attribute for compat.
1643
+ _drift_verdict_ctx.set(verdict)
1386
1644
  self._last_drift_verdict = verdict
1387
1645
 
1388
1646
  # Emit log
@@ -1440,6 +1698,25 @@ class FallbackEngine:
1440
1698
  append_system_prompt=profile.append_system_prompt,
1441
1699
  )
1442
1700
 
1701
+ def _resolve_shim_actions(self, profile_name: str | None) -> tuple[str, str]:
1702
+ """S2 / S3 (shim): resolve ``(tool_choice_action, cache_control_action)``.
1703
+
1704
+ Both default to ``off``, so a missing / stub profile (e.g. a
1705
+ ``__new__``-constructed test engine) yields the backward-compatible
1706
+ no-op pair. Resolved once at the top of each Anthropic entry point
1707
+ (profiles are immutable per request) and passed to every adapter
1708
+ call so the per-provider shim helpers stay pure.
1709
+ """
1710
+ chosen = profile_name or self.config.default_profile
1711
+ try:
1712
+ profile = self.config.profile_by_name(chosen)
1713
+ except (KeyError, ValueError):
1714
+ return "off", "off"
1715
+ return (
1716
+ getattr(profile, "tool_choice_action", "off"),
1717
+ getattr(profile, "cache_control_action", "off"),
1718
+ )
1719
+
1443
1720
  def _resolve_chain(self, profile_name: str | None) -> list[BaseAdapter]:
1444
1721
  """Return the list of adapters to try, in order, for this profile.
1445
1722
 
@@ -1652,6 +1929,17 @@ class FallbackEngine:
1652
1929
  if self._profile_is_adaptive(request.profile) and base:
1653
1930
  base = self._adaptive.compute_effective_order(base)
1654
1931
 
1932
+ # M3: drift demotion is applied here, independent of the adaptive
1933
+ # flag. Prior to M3, drift only demoted via
1934
+ # ``AdaptiveAdjuster.demote`` (synthetic failures), which had no
1935
+ # effect unless the profile opted into ``adaptive: true`` AND
1936
+ # enough samples accumulated. Now a drift-demoted provider whose
1937
+ # cooldown is still active is pushed to the back of the chain
1938
+ # deterministically, so the logged "demoted" action matches the
1939
+ # real try-order regardless of profile settings.
1940
+ if base:
1941
+ base = self._apply_drift_demotion(base)
1942
+
1655
1943
  if not anthropic_request_requires_thinking(request):
1656
1944
  return [(a, False) for a in base]
1657
1945
 
@@ -1679,6 +1967,49 @@ class FallbackEngine:
1679
1967
  return False
1680
1968
  return profile.adaptive
1681
1969
 
1970
+ def _apply_drift_demotion(
1971
+ self, adapters: list[BaseAdapter]
1972
+ ) -> list[BaseAdapter]:
1973
+ """M3: push drift-demoted providers to the back of the chain.
1974
+
1975
+ Drift demotion is a mechanism independent of adaptive routing:
1976
+ ``_observe_drift_signal`` records a per-provider cooldown-expiry
1977
+ timestamp in ``self._drift_demoted`` when it detects drift and the
1978
+ profile action is ``promote`` / ``reload``. Here we honor that
1979
+ state by moving still-in-cooldown providers behind the
1980
+ non-demoted ones, preserving relative order within each bucket
1981
+ (stable partition). Expired entries are pruned lazily so a
1982
+ recovered provider returns to its declared position.
1983
+
1984
+ This runs regardless of ``profile.adaptive`` so the "drift
1985
+ demoted" log emitted at detection time matches the actual
1986
+ try-order — the previous behavior only affected order when
1987
+ ``adaptive: true`` and enough synthetic-failure samples had
1988
+ accumulated in the adaptive window.
1989
+ """
1990
+ demoted_map = getattr(self, "_drift_demoted", None)
1991
+ if not demoted_map:
1992
+ return adapters
1993
+
1994
+ now = time.monotonic()
1995
+ # Lazily prune expired cooldowns so recovered providers restore.
1996
+ expired = [p for p, until in demoted_map.items() if now >= until]
1997
+ for provider in expired:
1998
+ demoted_map.pop(provider, None)
1999
+ if not demoted_map:
2000
+ return adapters
2001
+
2002
+ kept: list[BaseAdapter] = []
2003
+ demoted: list[BaseAdapter] = []
2004
+ for adapter in adapters:
2005
+ if adapter.name in demoted_map:
2006
+ demoted.append(adapter)
2007
+ else:
2008
+ kept.append(adapter)
2009
+ if not demoted:
2010
+ return adapters
2011
+ return kept + demoted
2012
+
1682
2013
  async def generate(self, request: ChatRequest) -> ChatResponse:
1683
2014
  """Non-streaming OpenAI-shaped generation with sequential fallback.
1684
2015
 
@@ -1698,8 +2029,19 @@ class FallbackEngine:
1698
2029
  "try-provider",
1699
2030
  extra={"provider": adapter.name, "stream": False},
1700
2031
  )
2032
+ # M2: time the attempt so the OpenAI-shaped non-streaming path
2033
+ # feeds the adaptive rolling window too (previously only
2034
+ # generate_anthropic recorded, so OpenAI clients got no
2035
+ # adaptive routing signal).
2036
+ attempt_started = time.monotonic()
1701
2037
  try:
1702
2038
  response = await adapter.generate(request, overrides=overrides)
2039
+ # M2: record the successful attempt latency.
2040
+ self._adaptive.record_attempt(
2041
+ adapter.name,
2042
+ latency_ms=(time.monotonic() - attempt_started) * 1000.0,
2043
+ success=True,
2044
+ )
1703
2045
  logger.info(
1704
2046
  "provider-ok",
1705
2047
  extra={"provider": adapter.name, "stream": False},
@@ -1712,6 +2054,17 @@ class FallbackEngine:
1712
2054
  )
1713
2055
  return response
1714
2056
  except AdapterError as exc:
2057
+ # M2: record the failed attempt. Auth failures carry no
2058
+ # useful latency signal (mirrors generate_anthropic).
2059
+ self._adaptive.record_attempt(
2060
+ adapter.name,
2061
+ latency_ms=(
2062
+ None
2063
+ if exc.status_code in {401, 403}
2064
+ else (time.monotonic() - attempt_started) * 1000.0
2065
+ ),
2066
+ success=False,
2067
+ )
1715
2068
  logger.warning(
1716
2069
  "provider-failed",
1717
2070
  extra={
@@ -1748,14 +2101,35 @@ class FallbackEngine:
1748
2101
  "try-provider",
1749
2102
  extra={"provider": adapter.name, "stream": True},
1750
2103
  )
2104
+ # M2: measure latency to the first streamed event so the
2105
+ # OpenAI streaming path feeds the adaptive window. Claude Code
2106
+ # and most agents stream, so without this adaptive routing was
2107
+ # effectively inert on the busiest path.
2108
+ attempt_started = time.monotonic()
1751
2109
  stream_iter = adapter.stream(request, overrides=overrides)
1752
2110
  try:
1753
2111
  first = await anext(stream_iter)
1754
2112
  except StopAsyncIteration:
1755
2113
  # Adapter produced zero chunks — treat as failure, try next
2114
+ # M2: record the empty-stream failure.
2115
+ self._adaptive.record_attempt(
2116
+ adapter.name,
2117
+ latency_ms=(time.monotonic() - attempt_started) * 1000.0,
2118
+ success=False,
2119
+ )
1756
2120
  errors.append(AdapterError("empty stream", provider=adapter.name, retryable=True))
1757
2121
  continue
1758
2122
  except AdapterError as exc:
2123
+ # M2: record the pre-first-event failure.
2124
+ self._adaptive.record_attempt(
2125
+ adapter.name,
2126
+ latency_ms=(
2127
+ None
2128
+ if exc.status_code in {401, 403}
2129
+ else (time.monotonic() - attempt_started) * 1000.0
2130
+ ),
2131
+ success=False,
2132
+ )
1759
2133
  logger.warning(
1760
2134
  "provider-failed",
1761
2135
  extra={
@@ -1774,6 +2148,13 @@ class FallbackEngine:
1774
2148
  break
1775
2149
  continue
1776
2150
 
2151
+ # M2: first event landed → record success with the
2152
+ # time-to-first-event latency.
2153
+ self._adaptive.record_attempt(
2154
+ adapter.name,
2155
+ latency_ms=(time.monotonic() - attempt_started) * 1000.0,
2156
+ success=True,
2157
+ )
1777
2158
  logger.info(
1778
2159
  "provider-ok",
1779
2160
  extra={"provider": adapter.name, "stream": True},
@@ -1794,6 +2175,13 @@ class FallbackEngine:
1794
2175
  async for chunk in stream_iter:
1795
2176
  yield chunk
1796
2177
  except AdapterError as exc:
2178
+ # M2: a mid-stream failure is a partial failure for
2179
+ # adaptive purposes. We already recorded the success at
2180
+ # first-event; add an error-only observation (no latency)
2181
+ # so the error rate reflects the mid-stream breakage.
2182
+ self._adaptive.record_attempt(
2183
+ adapter.name, latency_ms=None, success=False
2184
+ )
1797
2185
  logger.warning(
1798
2186
  "provider-failed-midstream",
1799
2187
  extra={
@@ -1849,12 +2237,52 @@ class FallbackEngine:
1849
2237
  The returned request (possibly trimmed) should be passed to the
1850
2238
  engine method; the engine will skip the guard on the second pass
1851
2239
  since the request is already under threshold.
2240
+
2241
+ M11: the resolved chain + trimmed request + status are cached in a
2242
+ request-scoped ContextVar so the immediately-following
2243
+ ``generate_anthropic`` / ``stream_anthropic`` call can skip a
2244
+ second chain resolution AND a second token estimate. See
2245
+ ``_prepared_dispatch_ctx``.
1852
2246
  """
1853
2247
  chain = self._resolve_anthropic_chain(request)
1854
2248
  first_provider_config = chain[0][0].config if chain else None
1855
- return _apply_context_budget_guard(
2249
+ trimmed_request, status = _apply_context_budget_guard(
1856
2250
  request, config=self.config, first_provider_config=first_provider_config,
1857
2251
  )
2252
+ # M11: stash for the engine entry point to reuse. Keyed to the
2253
+ # request identity so a stale cache from an earlier request on the
2254
+ # same context can't be mistaken for this one.
2255
+ _prepared_dispatch_ctx.set(
2256
+ _PreparedDispatch(
2257
+ request=trimmed_request,
2258
+ chain=chain,
2259
+ budget_status=status,
2260
+ )
2261
+ )
2262
+ return trimmed_request, status
2263
+
2264
+ def _take_prepared_dispatch(
2265
+ self, request: AnthropicRequest
2266
+ ) -> _PreparedDispatch | None:
2267
+ """M11: return + consume the cached prepared dispatch, if valid.
2268
+
2269
+ Returns the ``_PreparedDispatch`` stashed by
2270
+ ``apply_context_budget`` iff it corresponds to ``request`` (identity
2271
+ match — the ingress passes the exact object the guard returned).
2272
+ The cache is cleared on read so a subsequent direct engine call in
2273
+ the same context does not accidentally reuse it.
2274
+ """
2275
+ prepared = _prepared_dispatch_ctx.get()
2276
+ if prepared is None:
2277
+ return None
2278
+ # Clear immediately — single-use per request.
2279
+ _prepared_dispatch_ctx.set(None)
2280
+ if prepared.request is not request:
2281
+ # The engine was handed a different request object than the one
2282
+ # the guard prepared (e.g. a plugin replaced it, or a direct
2283
+ # caller). Fall back to a fresh resolution to stay correct.
2284
+ return None
2285
+ return prepared
1858
2286
 
1859
2287
  # ====================================================================
1860
2288
  # v2.3.0: Plugin SDK hook helpers
@@ -1969,18 +2397,28 @@ class FallbackEngine:
1969
2397
  # grow ``request.system`` without bypassing the budget cap —
1970
2398
  # the budget guard sees the post-filter payload.
1971
2399
  request = await self._apply_input_filters(request)
1972
- chain = self._resolve_anthropic_chain(request)
1973
2400
 
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
- )
2401
+ # M11: reuse the chain + trimmed request the ingress already
2402
+ # resolved in ``apply_context_budget`` when neither the tool-loop
2403
+ # guard nor the input filters replaced the request object (the
2404
+ # common no-plugin path). This avoids a second chain resolution
2405
+ # (incl. adaptive/drift reorder) and a second full token estimate.
2406
+ prepared = self._take_prepared_dispatch(request)
2407
+ if prepared is not None:
2408
+ chain = prepared.chain
2409
+ request = prepared.request
2410
+ else:
2411
+ chain = self._resolve_anthropic_chain(request)
2412
+ # v2.0-F (L1): context budget guard runs after chain resolution
2413
+ # (needs the first provider's max_context_tokens). May trim the
2414
+ # request's messages if over the trim threshold.
2415
+ # NOTE: When called from the ingress via apply_context_budget()
2416
+ # first, the request is already under threshold — this is a
2417
+ # cheap no-op re-check (estimate < threshold → returns None).
2418
+ first_provider_config = chain[0][0].config if chain else None
2419
+ request, _ctx_status = _apply_context_budget_guard(
2420
+ request, config=self.config, first_provider_config=first_provider_config,
2421
+ )
1984
2422
 
1985
2423
  overrides = self._resolve_profile_overrides(request.profile)
1986
2424
  errors: list[AdapterError] = []
@@ -1989,6 +2427,10 @@ class FallbackEngine:
1989
2427
  # ever asked for caching. Compute once; the v0.5-B gate uses the
1990
2428
  # same value below for the capability-degraded log.
1991
2429
  request_had_cache_control = anthropic_request_has_cache_control(request)
2430
+ # S2 / S3 (shim): resolve the opt-in per-provider actions once.
2431
+ tool_choice_action, cache_control_action = self._resolve_shim_actions(
2432
+ request.profile
2433
+ )
1992
2434
 
1993
2435
  for adapter, will_degrade in chain:
1994
2436
  is_native = isinstance(adapter, AnthropicAdapter)
@@ -2020,6 +2462,19 @@ class FallbackEngine:
2020
2462
  dropped=["cache_control"],
2021
2463
  reason="translation-lossy",
2022
2464
  )
2465
+ # S2 (shim): forced tool_choice warn / emulate on non-supporting
2466
+ # providers. S3 (shim): cache_control strip. Both mutate a
2467
+ # per-provider copy; a later capable provider still gets the
2468
+ # original request.
2469
+ effective_request = _apply_tool_choice_shim(
2470
+ effective_request, adapter=adapter, action=tool_choice_action
2471
+ )
2472
+ effective_request = _apply_cache_control_shim(
2473
+ effective_request,
2474
+ adapter=adapter,
2475
+ action=cache_control_action,
2476
+ request_had_cache_control=request_had_cache_control,
2477
+ )
2023
2478
  logger.info(
2024
2479
  "try-provider",
2025
2480
  extra={
@@ -2194,14 +2649,22 @@ class FallbackEngine:
2194
2649
  # before chain resolution, and the budget guard below sees
2195
2650
  # the post-filter payload.
2196
2651
  request = await self._apply_input_filters(request)
2197
- chain = self._resolve_anthropic_chain(request)
2198
2652
 
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
- )
2653
+ # M11: reuse the ingress-prepared chain + trimmed request when the
2654
+ # guard / filters were no-ops (identity match). Mirrors the
2655
+ # non-streaming path avoids a redundant resolution + estimate.
2656
+ prepared = self._take_prepared_dispatch(request)
2657
+ if prepared is not None:
2658
+ chain = prepared.chain
2659
+ request = prepared.request
2660
+ else:
2661
+ chain = self._resolve_anthropic_chain(request)
2662
+ # v2.0-F (L1): context budget guard mirrors the non-streaming
2663
+ # path. See apply_context_budget() — usually a no-op re-check.
2664
+ first_provider_config = chain[0][0].config if chain else None
2665
+ request, _ctx_status = _apply_context_budget_guard(
2666
+ request, config=self.config, first_provider_config=first_provider_config,
2667
+ )
2205
2668
 
2206
2669
  overrides = self._resolve_profile_overrides(request.profile)
2207
2670
  errors: list[AdapterError] = []
@@ -2209,6 +2672,10 @@ class FallbackEngine:
2209
2672
  # v1.9-A: compute once for the v0.5-B capability-degraded gate
2210
2673
  # AND for the cache-observed emission below.
2211
2674
  request_had_cache_control = anthropic_request_has_cache_control(request)
2675
+ # S2 / S3 (shim): resolve the opt-in per-provider actions once.
2676
+ tool_choice_action, cache_control_action = self._resolve_shim_actions(
2677
+ request.profile
2678
+ )
2212
2679
 
2213
2680
  for adapter, will_degrade in chain:
2214
2681
  is_native = isinstance(adapter, AnthropicAdapter)
@@ -2232,6 +2699,16 @@ class FallbackEngine:
2232
2699
  dropped=["cache_control"],
2233
2700
  reason="translation-lossy",
2234
2701
  )
2702
+ # S2 / S3 (shim): mirror of the non-streaming path.
2703
+ effective_request = _apply_tool_choice_shim(
2704
+ effective_request, adapter=adapter, action=tool_choice_action
2705
+ )
2706
+ effective_request = _apply_cache_control_shim(
2707
+ effective_request,
2708
+ adapter=adapter,
2709
+ action=cache_control_action,
2710
+ request_had_cache_control=request_had_cache_control,
2711
+ )
2235
2712
  logger.info(
2236
2713
  "try-provider",
2237
2714
  extra={
@@ -2253,6 +2730,11 @@ class FallbackEngine:
2253
2730
  # Stage 1: acquire an AnthropicStreamEvent iterator. Failures
2254
2731
  # here are candidates for fallback (no bytes have been sent to
2255
2732
  # the client yet).
2733
+ # M2: time to first event feeds the adaptive window on the
2734
+ # Anthropic streaming path (Claude Code's default). Previously
2735
+ # only the non-streaming generate_anthropic recorded, so the
2736
+ # busiest path produced no adaptive signal.
2737
+ attempt_started = time.monotonic()
2256
2738
  event_iter: AsyncIterator[AnthropicStreamEvent]
2257
2739
  first: AnthropicStreamEvent
2258
2740
  try:
@@ -2278,9 +2760,26 @@ class FallbackEngine:
2278
2760
  )
2279
2761
  first = await anext(event_iter)
2280
2762
  except StopAsyncIteration:
2763
+ # M2: record the empty-stream failure.
2764
+ self._adaptive.record_attempt(
2765
+ adapter.name,
2766
+ latency_ms=(time.monotonic() - attempt_started) * 1000.0,
2767
+ success=False,
2768
+ )
2281
2769
  errors.append(AdapterError("empty stream", provider=adapter.name, retryable=True))
2282
2770
  continue
2283
2771
  except AdapterError as exc:
2772
+ # M2: record the pre-first-event failure. Auth failures
2773
+ # carry no useful latency signal (mirrors generate_anthropic).
2774
+ self._adaptive.record_attempt(
2775
+ adapter.name,
2776
+ latency_ms=(
2777
+ None
2778
+ if exc.status_code in {401, 403}
2779
+ else (time.monotonic() - attempt_started) * 1000.0
2780
+ ),
2781
+ success=False,
2782
+ )
2284
2783
  logger.warning(
2285
2784
  "provider-failed",
2286
2785
  extra={
@@ -2317,6 +2816,13 @@ class FallbackEngine:
2317
2816
  "downgrade": downgrading,
2318
2817
  },
2319
2818
  )
2819
+ # M2: first event landed → record success with the
2820
+ # time-to-first-event latency on the Anthropic streaming path.
2821
+ self._adaptive.record_attempt(
2822
+ adapter.name,
2823
+ latency_ms=(time.monotonic() - attempt_started) * 1000.0,
2824
+ success=True,
2825
+ )
2320
2826
  # v1.9-E phase 2 (L5): first chunk landed → success for
2321
2827
  # the L5 health monitor on the Anthropic streaming path.
2322
2828
  self._observe_provider_success(
@@ -2331,6 +2837,12 @@ class FallbackEngine:
2331
2837
  acc.observe(ev)
2332
2838
  yield ev
2333
2839
  except AdapterError as exc:
2840
+ # M2: mid-stream failure — record an error-only
2841
+ # observation (no latency; first-event success already
2842
+ # recorded) so adaptive error rate reflects the breakage.
2843
+ self._adaptive.record_attempt(
2844
+ adapter.name, latency_ms=None, success=False
2845
+ )
2334
2846
  logger.warning(
2335
2847
  "provider-failed-midstream",
2336
2848
  extra={