coderouter-cli 2.6.1__py3-none-any.whl → 2.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/adapters/anthropic_native.py +66 -30
- coderouter/adapters/base.py +46 -3
- coderouter/adapters/openai_compat.py +53 -11
- coderouter/config/capability_registry.py +22 -0
- coderouter/config/schemas.py +232 -0
- coderouter/guards/context_budget.py +114 -19
- coderouter/guards/continuous_probe.py +9 -1
- coderouter/guards/memory_pressure.py +16 -2
- coderouter/guards/self_healing.py +14 -0
- coderouter/ingress/anthropic_routes.py +223 -1
- coderouter/ingress/app.py +162 -1
- coderouter/ingress/launcher_routes.py +187 -11
- coderouter/ingress/openai_routes.py +77 -7
- coderouter/logging.py +29 -3
- coderouter/metrics/collector.py +454 -245
- coderouter/metrics/prometheus.py +3 -3
- coderouter/routing/capability.py +217 -0
- coderouter/routing/fallback.py +539 -27
- coderouter/state/audit_log.py +69 -7
- coderouter/state/request_log.py +70 -7
- coderouter/translation/convert.py +112 -9
- coderouter/translation/tool_repair.py +671 -50
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/METADATA +28 -9
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/RECORD +27 -27
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/licenses/LICENSE +0 -0
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:
|
|
@@ -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
|
-
#
|
|
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
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
1975
|
-
#
|
|
1976
|
-
#
|
|
1977
|
-
#
|
|
1978
|
-
#
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
request
|
|
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
|
-
#
|
|
2200
|
-
#
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
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={
|