coderouter-cli 2.14.0__py3-none-any.whl → 2.15.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 +106 -1
- coderouter/adapters/base.py +65 -0
- coderouter/adapters/openai_compat.py +60 -0
- coderouter/config/schemas.py +65 -0
- coderouter/doctor.py +48 -1
- coderouter/ingress/anthropic_routes.py +82 -12
- coderouter/ingress/openai_routes.py +31 -2
- coderouter/logging.py +143 -0
- coderouter/metrics/collector.py +40 -0
- coderouter/metrics/prometheus.py +46 -0
- coderouter/routing/__init__.py +8 -0
- coderouter/routing/fallback.py +304 -4
- coderouter/routing/fallback_trace.py +501 -0
- coderouter/state/audit_log.py +4 -0
- coderouter/vscode_init.py +10 -3
- {coderouter_cli-2.14.0.dist-info → coderouter_cli-2.15.1.dist-info}/METADATA +17 -5
- {coderouter_cli-2.14.0.dist-info → coderouter_cli-2.15.1.dist-info}/RECORD +20 -19
- {coderouter_cli-2.14.0.dist-info → coderouter_cli-2.15.1.dist-info}/WHEEL +1 -1
- {coderouter_cli-2.14.0.dist-info → coderouter_cli-2.15.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.14.0.dist-info → coderouter_cli-2.15.1.dist-info}/licenses/LICENSE +0 -0
coderouter/routing/fallback.py
CHANGED
|
@@ -46,6 +46,7 @@ from coderouter.adapters.base import (
|
|
|
46
46
|
ChatResponse,
|
|
47
47
|
ProviderCallOverrides,
|
|
48
48
|
StreamChunk,
|
|
49
|
+
StreamTruncatedError,
|
|
49
50
|
)
|
|
50
51
|
from coderouter.adapters.registry import build_adapter
|
|
51
52
|
from coderouter.config.schemas import CodeRouterConfig, ProviderConfig
|
|
@@ -79,6 +80,7 @@ from coderouter.logging import (
|
|
|
79
80
|
log_chain_paid_gate_blocked,
|
|
80
81
|
log_demote_unhealthy_provider,
|
|
81
82
|
log_empty_response_detected,
|
|
83
|
+
log_fallback_occurred,
|
|
82
84
|
log_memory_pressure_detected,
|
|
83
85
|
log_skip_budget_exceeded,
|
|
84
86
|
log_skip_memory_pressure,
|
|
@@ -100,6 +102,23 @@ from coderouter.routing.capability import (
|
|
|
100
102
|
strip_cache_control,
|
|
101
103
|
strip_thinking,
|
|
102
104
|
)
|
|
105
|
+
from coderouter.routing.fallback_trace import (
|
|
106
|
+
REASON_BACKEND_UNHEALTHY,
|
|
107
|
+
REASON_BUDGET_EXCEEDED,
|
|
108
|
+
REASON_EMPTY_RESPONSE,
|
|
109
|
+
REASON_EMPTY_STREAM,
|
|
110
|
+
REASON_MEMORY_PRESSURE,
|
|
111
|
+
REASON_PAID_GATE,
|
|
112
|
+
REASON_SELF_HEALING_EXCLUDED,
|
|
113
|
+
REASON_STREAM_TRUNCATED,
|
|
114
|
+
REASON_UNKNOWN_PROVIDER,
|
|
115
|
+
FallbackTrace,
|
|
116
|
+
begin_fallback_trace,
|
|
117
|
+
classify_adapter_error,
|
|
118
|
+
current_fallback_trace,
|
|
119
|
+
describe_adapter_error,
|
|
120
|
+
ensure_fallback_trace,
|
|
121
|
+
)
|
|
103
122
|
from coderouter.translation import (
|
|
104
123
|
AnthropicRequest,
|
|
105
124
|
AnthropicResponse,
|
|
@@ -1053,6 +1072,36 @@ def _stream_event_is_real_content(event: AnthropicStreamEvent) -> bool:
|
|
|
1053
1072
|
return False
|
|
1054
1073
|
|
|
1055
1074
|
|
|
1075
|
+
def _log_fallback_trace(trace: FallbackTrace | None, *, profile: str | None) -> None:
|
|
1076
|
+
"""v2.15.0: emit one ``fallback-occurred`` line per recorded hop.
|
|
1077
|
+
|
|
1078
|
+
Called at every terminal point of a dispatch (successful return,
|
|
1079
|
+
chain-exhausted raise, empty-chain flush). Deferring the emission to
|
|
1080
|
+
the end is what lets each line carry a resolved ``to_provider`` — at
|
|
1081
|
+
the moment a provider fails, the engine does not yet know who (if
|
|
1082
|
+
anyone) will take over.
|
|
1083
|
+
|
|
1084
|
+
The ``logged`` latch makes the call idempotent, so a path with several
|
|
1085
|
+
``return`` statements cannot double-log. A request whose first
|
|
1086
|
+
provider served produces no lines at all.
|
|
1087
|
+
"""
|
|
1088
|
+
if trace is None or trace.logged or not trace.hops:
|
|
1089
|
+
return
|
|
1090
|
+
trace.logged = True
|
|
1091
|
+
for index, hop in enumerate(trace.hops):
|
|
1092
|
+
log_fallback_occurred(
|
|
1093
|
+
logger,
|
|
1094
|
+
from_provider=hop.from_provider,
|
|
1095
|
+
to_provider=hop.to_provider,
|
|
1096
|
+
reason=hop.reason,
|
|
1097
|
+
detail=hop.detail,
|
|
1098
|
+
profile=trace.profile or profile,
|
|
1099
|
+
stream=hop.stream,
|
|
1100
|
+
pre_attempt=hop.pre_attempt,
|
|
1101
|
+
hop_index=index,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
|
|
1056
1105
|
class FallbackEngine:
|
|
1057
1106
|
"""Sequential fallback router — the core of CodeRouter.
|
|
1058
1107
|
|
|
@@ -1989,12 +2038,21 @@ class FallbackEngine:
|
|
|
1989
2038
|
Invariant across every adapter call on one chain (profiles are
|
|
1990
2039
|
immutable per request), so callers resolve this once at the top of
|
|
1991
2040
|
each engine method and pass to every adapter invocation.
|
|
2041
|
+
|
|
2042
|
+
v2.15.0: ``stream_truncation_action`` rides along. It is not a
|
|
2043
|
+
provider-level knob, but the adapter's SSE parse loop is the only
|
|
2044
|
+
place that can see a missing terminator, and this object is the
|
|
2045
|
+
channel that already reaches it. ``getattr`` with an ``off`` default
|
|
2046
|
+
keeps ``model_construct``-built / stub profiles working.
|
|
1992
2047
|
"""
|
|
1993
2048
|
chosen = profile_name or self.config.default_profile
|
|
1994
2049
|
profile = self.config.profile_by_name(chosen)
|
|
1995
2050
|
return ProviderCallOverrides(
|
|
1996
2051
|
timeout_s=profile.timeout_s,
|
|
1997
2052
|
append_system_prompt=profile.append_system_prompt,
|
|
2053
|
+
stream_truncation_action=getattr(
|
|
2054
|
+
profile, "stream_truncation_action", "off"
|
|
2055
|
+
),
|
|
1998
2056
|
)
|
|
1999
2057
|
|
|
2000
2058
|
def _resolve_shim_actions(self, profile_name: str | None) -> tuple[str, str]:
|
|
@@ -2062,6 +2120,13 @@ class FallbackEngine:
|
|
|
2062
2120
|
"""
|
|
2063
2121
|
chosen = profile_name or self.config.default_profile
|
|
2064
2122
|
chain = self.config.profile_by_name(chosen)
|
|
2123
|
+
# v2.15.0: every gate below that removes a provider also records a
|
|
2124
|
+
# pre-attempt hop on the request-scoped fallback trace, so the
|
|
2125
|
+
# ingress can answer "why did my request not go to `local`?" with
|
|
2126
|
+
# `budget-exceeded` instead of silence. ``ensure_`` (not ``begin_``)
|
|
2127
|
+
# because this method also runs from the ingress's
|
|
2128
|
+
# ``apply_context_budget`` pre-pass, before dispatch starts a trace.
|
|
2129
|
+
trace = ensure_fallback_trace(profile=chosen)
|
|
2065
2130
|
|
|
2066
2131
|
# Pass 1: paid gate. Same shape as v0.6-C; produces the
|
|
2067
2132
|
# post-paid candidate list and tracks the names that were
|
|
@@ -2076,6 +2141,7 @@ class FallbackEngine:
|
|
|
2076
2141
|
"skip-unknown-provider",
|
|
2077
2142
|
extra={"profile": chosen, "provider": prov_name},
|
|
2078
2143
|
)
|
|
2144
|
+
trace.record_skip(prov_name, REASON_UNKNOWN_PROVIDER)
|
|
2079
2145
|
continue
|
|
2080
2146
|
if provider_cfg.paid and not self.config.allow_paid:
|
|
2081
2147
|
logger.info(
|
|
@@ -2083,6 +2149,7 @@ class FallbackEngine:
|
|
|
2083
2149
|
extra={"profile": chosen, "provider": prov_name},
|
|
2084
2150
|
)
|
|
2085
2151
|
blocked_by_paid.append(prov_name)
|
|
2152
|
+
trace.record_skip(prov_name, REASON_PAID_GATE)
|
|
2086
2153
|
continue
|
|
2087
2154
|
post_paid.append((prov_name, self._adapters[prov_name], provider_cfg))
|
|
2088
2155
|
|
|
@@ -2107,6 +2174,11 @@ class FallbackEngine:
|
|
|
2107
2174
|
month=self._budget.current_month(),
|
|
2108
2175
|
)
|
|
2109
2176
|
blocked_by_budget.append(prov_name)
|
|
2177
|
+
trace.record_skip(
|
|
2178
|
+
prov_name,
|
|
2179
|
+
REASON_BUDGET_EXCEEDED,
|
|
2180
|
+
detail=f"monthly_budget_usd={budget_usd}",
|
|
2181
|
+
)
|
|
2110
2182
|
continue
|
|
2111
2183
|
post_budget.append((prov_name, adapter))
|
|
2112
2184
|
|
|
@@ -2134,6 +2206,11 @@ class FallbackEngine:
|
|
|
2134
2206
|
seconds_until_eligible=seconds,
|
|
2135
2207
|
)
|
|
2136
2208
|
blocked_by_pressure.append(prov_name)
|
|
2209
|
+
trace.record_skip(
|
|
2210
|
+
prov_name,
|
|
2211
|
+
REASON_MEMORY_PRESSURE,
|
|
2212
|
+
detail=f"eligible_in_s={seconds}",
|
|
2213
|
+
)
|
|
2137
2214
|
continue
|
|
2138
2215
|
adapters.append(adapter)
|
|
2139
2216
|
|
|
@@ -2209,6 +2286,11 @@ class FallbackEngine:
|
|
|
2209
2286
|
if chain.backend_health_action == "exclude":
|
|
2210
2287
|
excluded = self.self_healing.excluded_providers()
|
|
2211
2288
|
if excluded:
|
|
2289
|
+
for adapter in adapters:
|
|
2290
|
+
if adapter.name in excluded:
|
|
2291
|
+
trace.record_skip(
|
|
2292
|
+
adapter.name, REASON_SELF_HEALING_EXCLUDED
|
|
2293
|
+
)
|
|
2212
2294
|
adapters = [a for a in adapters if a.name not in excluded]
|
|
2213
2295
|
|
|
2214
2296
|
# Pass 4c: v2.x ``skip`` action — self-contained half-open circuit
|
|
@@ -2223,6 +2305,7 @@ class FallbackEngine:
|
|
|
2223
2305
|
# failing with NoProvidersAvailableError before trying anything.
|
|
2224
2306
|
if chain.backend_health_action == "skip":
|
|
2225
2307
|
kept: list[BaseAdapter] = []
|
|
2308
|
+
skipped_unhealthy: list[str] = []
|
|
2226
2309
|
for adapter in adapters:
|
|
2227
2310
|
if self._backend_health.should_skip(
|
|
2228
2311
|
adapter.name,
|
|
@@ -2233,10 +2316,17 @@ class FallbackEngine:
|
|
|
2233
2316
|
provider=adapter.name,
|
|
2234
2317
|
profile=chosen,
|
|
2235
2318
|
)
|
|
2319
|
+
skipped_unhealthy.append(adapter.name)
|
|
2236
2320
|
else:
|
|
2237
2321
|
kept.append(adapter)
|
|
2238
2322
|
if kept:
|
|
2239
2323
|
adapters = kept
|
|
2324
|
+
# v2.15.0: only record the skips that actually took effect.
|
|
2325
|
+
# In the last-resort branch below nobody is removed, so
|
|
2326
|
+
# recording there would invent a fallback that never
|
|
2327
|
+
# happened.
|
|
2328
|
+
for name in skipped_unhealthy:
|
|
2329
|
+
trace.record_skip(name, REASON_BACKEND_UNHEALTHY)
|
|
2240
2330
|
elif adapters:
|
|
2241
2331
|
# Everyone was skipped — last-resort: try the unfiltered
|
|
2242
2332
|
# chain rather than 502 without attempting a single provider.
|
|
@@ -2385,10 +2475,17 @@ class FallbackEngine:
|
|
|
2385
2475
|
the full per-provider error list so the ingress layer can
|
|
2386
2476
|
surface a single 502.
|
|
2387
2477
|
"""
|
|
2478
|
+
# v2.15.0: open the trace *before* chain resolution so the gate
|
|
2479
|
+
# skips inside ``_resolve_chain`` land on this request's trace and
|
|
2480
|
+
# not on a leftover from an earlier call in the same context.
|
|
2481
|
+
trace = begin_fallback_trace(
|
|
2482
|
+
profile=request.profile or self.config.default_profile
|
|
2483
|
+
)
|
|
2388
2484
|
adapters = self._resolve_chain(request.profile)
|
|
2389
2485
|
overrides = self._resolve_profile_overrides(request.profile)
|
|
2390
2486
|
errors: list[AdapterError] = []
|
|
2391
2487
|
for adapter in adapters:
|
|
2488
|
+
trace.record_attempt(adapter.name)
|
|
2392
2489
|
logger.info(
|
|
2393
2490
|
"try-provider",
|
|
2394
2491
|
extra={"provider": adapter.name, "stream": False},
|
|
@@ -2416,6 +2513,7 @@ class FallbackEngine:
|
|
|
2416
2513
|
self._observe_provider_success(
|
|
2417
2514
|
adapter.name, profile=request.profile
|
|
2418
2515
|
)
|
|
2516
|
+
_log_fallback_trace(trace, profile=request.profile)
|
|
2419
2517
|
return response
|
|
2420
2518
|
except AdapterError as exc:
|
|
2421
2519
|
# M2: record the failed attempt. Auth failures carry no
|
|
@@ -2444,10 +2542,17 @@ class FallbackEngine:
|
|
|
2444
2542
|
adapter.name, exc, profile=request.profile
|
|
2445
2543
|
)
|
|
2446
2544
|
errors.append(exc)
|
|
2545
|
+
trace.record_failure(
|
|
2546
|
+
adapter.name,
|
|
2547
|
+
classify_adapter_error(exc),
|
|
2548
|
+
detail=describe_adapter_error(exc),
|
|
2549
|
+
stream=False,
|
|
2550
|
+
)
|
|
2447
2551
|
if not exc.retryable:
|
|
2448
2552
|
break
|
|
2449
2553
|
profile = request.profile or self.config.default_profile
|
|
2450
2554
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
2555
|
+
_log_fallback_trace(trace, profile=profile)
|
|
2451
2556
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
2452
2557
|
|
|
2453
2558
|
async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
|
|
@@ -2473,10 +2578,15 @@ class FallbackEngine:
|
|
|
2473
2578
|
fall back mid-stream (the client has already received partial content).
|
|
2474
2579
|
We only fall through if the *initial* response is an error.
|
|
2475
2580
|
"""
|
|
2581
|
+
# v2.15.0: mirror of ``_generate_impl`` — see the comment there.
|
|
2582
|
+
trace = begin_fallback_trace(
|
|
2583
|
+
profile=request.profile or self.config.default_profile
|
|
2584
|
+
)
|
|
2476
2585
|
adapters: list[BaseAdapter] = self._resolve_chain(request.profile)
|
|
2477
2586
|
overrides = self._resolve_profile_overrides(request.profile)
|
|
2478
2587
|
errors: list[AdapterError] = []
|
|
2479
2588
|
for adapter in adapters:
|
|
2589
|
+
trace.record_attempt(adapter.name)
|
|
2480
2590
|
logger.info(
|
|
2481
2591
|
"try-provider",
|
|
2482
2592
|
extra={"provider": adapter.name, "stream": True},
|
|
@@ -2498,6 +2608,9 @@ class FallbackEngine:
|
|
|
2498
2608
|
success=False,
|
|
2499
2609
|
)
|
|
2500
2610
|
errors.append(AdapterError("empty stream", provider=adapter.name, retryable=True))
|
|
2611
|
+
trace.record_failure(
|
|
2612
|
+
adapter.name, REASON_EMPTY_STREAM, stream=True
|
|
2613
|
+
)
|
|
2501
2614
|
continue
|
|
2502
2615
|
except AdapterError as exc:
|
|
2503
2616
|
# M2: record the pre-first-event failure.
|
|
@@ -2524,6 +2637,12 @@ class FallbackEngine:
|
|
|
2524
2637
|
adapter.name, exc, profile=request.profile
|
|
2525
2638
|
)
|
|
2526
2639
|
errors.append(exc)
|
|
2640
|
+
trace.record_failure(
|
|
2641
|
+
adapter.name,
|
|
2642
|
+
classify_adapter_error(exc),
|
|
2643
|
+
detail=describe_adapter_error(exc),
|
|
2644
|
+
stream=True,
|
|
2645
|
+
)
|
|
2527
2646
|
if not exc.retryable:
|
|
2528
2647
|
break
|
|
2529
2648
|
continue
|
|
@@ -2546,6 +2665,7 @@ class FallbackEngine:
|
|
|
2546
2665
|
self._observe_provider_success(
|
|
2547
2666
|
adapter.name, profile=request.profile
|
|
2548
2667
|
)
|
|
2668
|
+
_log_fallback_trace(trace, profile=request.profile)
|
|
2549
2669
|
yield first
|
|
2550
2670
|
# Mid-stream fallback guard: once the first byte is out the door,
|
|
2551
2671
|
# any subsequent adapter exception is terminal — we cannot fall
|
|
@@ -2583,6 +2703,7 @@ class FallbackEngine:
|
|
|
2583
2703
|
|
|
2584
2704
|
profile = request.profile or self.config.default_profile
|
|
2585
2705
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
2706
|
+
_log_fallback_trace(trace, profile=profile)
|
|
2586
2707
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
2587
2708
|
|
|
2588
2709
|
# ==================================================================
|
|
@@ -2626,6 +2747,23 @@ class FallbackEngine:
|
|
|
2626
2747
|
"""
|
|
2627
2748
|
chain = self._resolve_anthropic_chain(request)
|
|
2628
2749
|
first_provider_config = chain[0][0].config if chain else None
|
|
2750
|
+
# v2.15.0: ``_resolve_chain`` (called above, underneath
|
|
2751
|
+
# ``_resolve_anthropic_chain``) recorded a pre-attempt hop for every
|
|
2752
|
+
# provider a gate removed — paid / budget / memory-pressure /
|
|
2753
|
+
# backend-health — but each of those hops is still missing its
|
|
2754
|
+
# ``to_provider``. Here, and only here, the successor is already
|
|
2755
|
+
# known: ``chain`` is post-reorder (adaptive, drift demotion,
|
|
2756
|
+
# thinking-capability bucketing), so its head is the provider the
|
|
2757
|
+
# engine will actually try first. Resolving now is what lets the
|
|
2758
|
+
# streaming ingress ship a complete ``X-CodeRouter-Fallback-To``
|
|
2759
|
+
# header, since SSE headers commit before the generator starts.
|
|
2760
|
+
# Runtime attempt failures are still resolved later by
|
|
2761
|
+
# ``record_attempt``; ``resolve_pending`` only fills hops holding
|
|
2762
|
+
# ``None``, so the two cannot contradict each other.
|
|
2763
|
+
if chain:
|
|
2764
|
+
budget_trace = current_fallback_trace()
|
|
2765
|
+
if budget_trace is not None:
|
|
2766
|
+
budget_trace.resolve_pending(chain[0][0].name)
|
|
2629
2767
|
trimmed_request, status = _apply_context_budget_guard(
|
|
2630
2768
|
request, config=self.config, first_provider_config=first_provider_config,
|
|
2631
2769
|
)
|
|
@@ -2810,6 +2948,15 @@ class FallbackEngine:
|
|
|
2810
2948
|
# ``skip_prepared_dispatch`` (swap) forces a fresh resolution —
|
|
2811
2949
|
# see ``generate_anthropic``'s docstring.
|
|
2812
2950
|
prepared = None if skip_prepared_dispatch else self._take_prepared_dispatch(request)
|
|
2951
|
+
# v2.15.0: open the request-scoped fallback trace. ``keep_existing``
|
|
2952
|
+
# is True exactly when the M11 prepared dispatch was consumed —
|
|
2953
|
+
# proof that the ingress already resolved the chain for *this*
|
|
2954
|
+
# request, so the pre-attempt hops it recorded (budget / health /
|
|
2955
|
+
# paid gate) must be carried forward rather than dropped.
|
|
2956
|
+
trace = begin_fallback_trace(
|
|
2957
|
+
keep_existing=prepared is not None,
|
|
2958
|
+
profile=request.profile or self.config.default_profile,
|
|
2959
|
+
)
|
|
2813
2960
|
if prepared is not None:
|
|
2814
2961
|
chain = prepared.chain
|
|
2815
2962
|
request = prepared.request
|
|
@@ -2886,6 +3033,10 @@ class FallbackEngine:
|
|
|
2886
3033
|
action=cache_control_action,
|
|
2887
3034
|
request_had_cache_control=request_had_cache_control,
|
|
2888
3035
|
)
|
|
3036
|
+
# v2.15.0: recording the attempt resolves every pending hop's
|
|
3037
|
+
# ``to_provider`` — including consecutive chain-resolve skips,
|
|
3038
|
+
# which all point at whoever actually ran.
|
|
3039
|
+
trace.record_attempt(adapter.name)
|
|
2889
3040
|
logger.info(
|
|
2890
3041
|
"try-provider",
|
|
2891
3042
|
extra={
|
|
@@ -2958,6 +3109,16 @@ class FallbackEngine:
|
|
|
2958
3109
|
stream=False,
|
|
2959
3110
|
)
|
|
2960
3111
|
errors.append(exc)
|
|
3112
|
+
# v2.15.0: capture the departure reason (timeout / 5xx /
|
|
3113
|
+
# auth / ...). Non-retryable errors are recorded too: the
|
|
3114
|
+
# hop simply never gets a ``to_provider``, which is exactly
|
|
3115
|
+
# the "we left and had nowhere to go" narrative.
|
|
3116
|
+
trace.record_failure(
|
|
3117
|
+
adapter.name,
|
|
3118
|
+
classify_adapter_error(exc),
|
|
3119
|
+
detail=describe_adapter_error(exc),
|
|
3120
|
+
stream=False,
|
|
3121
|
+
)
|
|
2961
3122
|
if not exc.retryable:
|
|
2962
3123
|
break
|
|
2963
3124
|
continue
|
|
@@ -3019,6 +3180,9 @@ class FallbackEngine:
|
|
|
3019
3180
|
log_empty_response_detected(
|
|
3020
3181
|
logger, adapter.name, action="fallback", stream=False
|
|
3021
3182
|
)
|
|
3183
|
+
trace.record_failure(
|
|
3184
|
+
adapter.name, REASON_EMPTY_RESPONSE, stream=False
|
|
3185
|
+
)
|
|
3022
3186
|
continue
|
|
3023
3187
|
# ``warn``: log only; fall through and return the empty resp.
|
|
3024
3188
|
log_empty_response_detected(
|
|
@@ -3062,6 +3226,7 @@ class FallbackEngine:
|
|
|
3062
3226
|
latency_ms=(time.monotonic() - attempt_started) * 1000.0,
|
|
3063
3227
|
stream=False,
|
|
3064
3228
|
)
|
|
3229
|
+
_log_fallback_trace(trace, profile=request.profile)
|
|
3065
3230
|
return resp
|
|
3066
3231
|
|
|
3067
3232
|
# ⑧ (empty-response): the chain is exhausted. Under ``fallback``,
|
|
@@ -3076,10 +3241,12 @@ class FallbackEngine:
|
|
|
3076
3241
|
stream=False,
|
|
3077
3242
|
chain_exhausted=True,
|
|
3078
3243
|
)
|
|
3244
|
+
_log_fallback_trace(trace, profile=request.profile)
|
|
3079
3245
|
return last_empty_resp
|
|
3080
3246
|
|
|
3081
3247
|
profile = request.profile or self.config.default_profile
|
|
3082
3248
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
3249
|
+
_log_fallback_trace(trace, profile=profile)
|
|
3083
3250
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
3084
3251
|
|
|
3085
3252
|
async def stream_anthropic(
|
|
@@ -3127,6 +3294,12 @@ class FallbackEngine:
|
|
|
3127
3294
|
# non-streaming path — avoids a redundant resolution + estimate.
|
|
3128
3295
|
# ``skip_prepared_dispatch`` (swap) forces a fresh resolution.
|
|
3129
3296
|
prepared = None if skip_prepared_dispatch else self._take_prepared_dispatch(request)
|
|
3297
|
+
# v2.15.0: mirror of the non-streaming path — see the comment there
|
|
3298
|
+
# for why ``keep_existing`` tracks the prepared dispatch.
|
|
3299
|
+
trace = begin_fallback_trace(
|
|
3300
|
+
keep_existing=prepared is not None,
|
|
3301
|
+
profile=request.profile or self.config.default_profile,
|
|
3302
|
+
)
|
|
3130
3303
|
if prepared is not None:
|
|
3131
3304
|
chain = prepared.chain
|
|
3132
3305
|
request = prepared.request
|
|
@@ -3158,6 +3331,18 @@ class FallbackEngine:
|
|
|
3158
3331
|
empty_response_action = self._resolve_empty_response_action(request.profile)
|
|
3159
3332
|
empty_fallback = empty_response_action == "fallback"
|
|
3160
3333
|
last_empty_stream_buffer: list[AnthropicStreamEvent] | None = None
|
|
3334
|
+
# v2.15.0 (stream-truncation): does the buffer we are holding
|
|
3335
|
+
# *terminate* the message? An empty stream's buffer does — it is a
|
|
3336
|
+
# complete ``message_start`` … ``message_stop`` sequence, which is
|
|
3337
|
+
# why flushing it on chain exhaustion gives the client a well-formed
|
|
3338
|
+
# 200 blank. A truncated stream's buffer does NOT, by definition:
|
|
3339
|
+
# the upstream went quiet with the message still open. Flushing that
|
|
3340
|
+
# would hand the client an unterminated SSE stream and hang it, so
|
|
3341
|
+
# the flush at the bottom is gated on this flag. Only
|
|
3342
|
+
# ``stream_truncation_action: error`` can ever set it True (under
|
|
3343
|
+
# ``off`` / ``warn`` no ``StreamTruncatedError`` is raised), which
|
|
3344
|
+
# keeps the default byte-for-byte identical to v2.14.0.
|
|
3345
|
+
last_empty_stream_truncated = False
|
|
3161
3346
|
|
|
3162
3347
|
for adapter, will_degrade in chain:
|
|
3163
3348
|
is_native = isinstance(adapter, AnthropicAdapter)
|
|
@@ -3191,6 +3376,8 @@ class FallbackEngine:
|
|
|
3191
3376
|
action=cache_control_action,
|
|
3192
3377
|
request_had_cache_control=request_had_cache_control,
|
|
3193
3378
|
)
|
|
3379
|
+
# v2.15.0: see the non-streaming sibling.
|
|
3380
|
+
trace.record_attempt(adapter.name)
|
|
3194
3381
|
logger.info(
|
|
3195
3382
|
"try-provider",
|
|
3196
3383
|
extra={
|
|
@@ -3253,6 +3440,9 @@ class FallbackEngine:
|
|
|
3253
3440
|
success=False,
|
|
3254
3441
|
)
|
|
3255
3442
|
errors.append(AdapterError("empty stream", provider=adapter.name, retryable=True))
|
|
3443
|
+
trace.record_failure(
|
|
3444
|
+
adapter.name, REASON_EMPTY_STREAM, stream=True
|
|
3445
|
+
)
|
|
3256
3446
|
continue
|
|
3257
3447
|
except AdapterError as exc:
|
|
3258
3448
|
# M2: record the pre-first-event failure. Auth failures
|
|
@@ -3289,6 +3479,14 @@ class FallbackEngine:
|
|
|
3289
3479
|
stream=True,
|
|
3290
3480
|
)
|
|
3291
3481
|
errors.append(exc)
|
|
3482
|
+
# v2.15.0: pre-first-event failure — no bytes reached the
|
|
3483
|
+
# client, so this really is a fallback.
|
|
3484
|
+
trace.record_failure(
|
|
3485
|
+
adapter.name,
|
|
3486
|
+
classify_adapter_error(exc),
|
|
3487
|
+
detail=describe_adapter_error(exc),
|
|
3488
|
+
stream=True,
|
|
3489
|
+
)
|
|
3292
3490
|
if not exc.retryable:
|
|
3293
3491
|
break
|
|
3294
3492
|
continue
|
|
@@ -3362,10 +3560,62 @@ class FallbackEngine:
|
|
|
3362
3560
|
self._adaptive.record_attempt(
|
|
3363
3561
|
adapter.name, latency_ms=None, success=False
|
|
3364
3562
|
)
|
|
3365
|
-
|
|
3366
|
-
|
|
3563
|
+
# v2.15.0 (self-healing): the guards learn about this
|
|
3564
|
+
# branch too. ``_observe_provider_success`` already fired
|
|
3565
|
+
# the moment the first event landed, so without the two
|
|
3566
|
+
# observations below a backend that opens a stream and
|
|
3567
|
+
# then goes quiet keeps a *clean* bill of health on L2 /
|
|
3568
|
+
# L4 / L5 — and adaptive routing would keep preferring it
|
|
3569
|
+
# for its fast first byte. That is the opposite of what
|
|
3570
|
+
# this feature is for. The calls mirror the mid-stream
|
|
3571
|
+
# sibling below verbatim; the only difference in this
|
|
3572
|
+
# branch is that no bytes reached the client, so we fall
|
|
3573
|
+
# back instead of raising ``MidStreamError``.
|
|
3574
|
+
#
|
|
3575
|
+
# This is deliberately NOT gated on
|
|
3576
|
+
# ``StreamTruncatedError``: an ``AdapterError`` raised
|
|
3577
|
+
# here means the provider genuinely failed, whatever the
|
|
3578
|
+
# label. The pre-v2.15.0 behavior made provider health
|
|
3579
|
+
# depend on ``empty_response_action`` — an unrelated
|
|
3580
|
+
# knob — because the identical failure under ``off`` /
|
|
3581
|
+
# ``warn`` goes down the mid-stream path and *is*
|
|
3582
|
+
# observed. A truly empty-but-clean stream is a
|
|
3583
|
+
# different case entirely: it exits the loop normally
|
|
3584
|
+
# (see below) and is still not treated as a failure.
|
|
3585
|
+
self._observe_provider_failure(
|
|
3586
|
+
adapter.name, exc, profile=request.profile
|
|
3367
3587
|
)
|
|
3588
|
+
self._observe_drift_signal(
|
|
3589
|
+
adapter.name,
|
|
3590
|
+
profile=request.profile,
|
|
3591
|
+
is_error=True,
|
|
3592
|
+
request_had_tools=bool(request.tools),
|
|
3593
|
+
stream=True,
|
|
3594
|
+
)
|
|
3595
|
+
errors.append(exc)
|
|
3596
|
+
# v2.15.0 (stream-truncation): same branch, different
|
|
3597
|
+
# label. A truncated stream is not an *empty* one — the
|
|
3598
|
+
# upstream produced events, it just never finished the
|
|
3599
|
+
# message — so the hop is recorded as ``stream-truncated``
|
|
3600
|
+
# and the empty-response log is skipped. The adapter has
|
|
3601
|
+
# already emitted ``stream-truncation-detected``.
|
|
3602
|
+
truncated = isinstance(exc, StreamTruncatedError)
|
|
3603
|
+
if truncated:
|
|
3604
|
+
trace.record_failure(
|
|
3605
|
+
adapter.name,
|
|
3606
|
+
REASON_STREAM_TRUNCATED,
|
|
3607
|
+
detail=describe_adapter_error(exc),
|
|
3608
|
+
stream=True,
|
|
3609
|
+
)
|
|
3610
|
+
else:
|
|
3611
|
+
log_empty_response_detected(
|
|
3612
|
+
logger, adapter.name, action="fallback", stream=True
|
|
3613
|
+
)
|
|
3614
|
+
trace.record_failure(
|
|
3615
|
+
adapter.name, REASON_EMPTY_STREAM, stream=True
|
|
3616
|
+
)
|
|
3368
3617
|
last_empty_stream_buffer = buffer
|
|
3618
|
+
last_empty_stream_truncated = truncated
|
|
3369
3619
|
continue
|
|
3370
3620
|
# M2: mid-stream failure — record an error-only
|
|
3371
3621
|
# observation (no latency; first-event success already
|
|
@@ -3407,7 +3657,13 @@ class FallbackEngine:
|
|
|
3407
3657
|
log_empty_response_detected(
|
|
3408
3658
|
logger, adapter.name, action="fallback", stream=True
|
|
3409
3659
|
)
|
|
3660
|
+
trace.record_failure(
|
|
3661
|
+
adapter.name, REASON_EMPTY_STREAM, stream=True
|
|
3662
|
+
)
|
|
3410
3663
|
last_empty_stream_buffer = buffer
|
|
3664
|
+
# The stream ended *cleanly* — this buffer is a complete
|
|
3665
|
+
# message_start … message_stop sequence and is safe to flush.
|
|
3666
|
+
last_empty_stream_truncated = False
|
|
3411
3667
|
continue
|
|
3412
3668
|
# v2.0-G (L4): drift detection observation (stream success).
|
|
3413
3669
|
# P1-4: compute response fingerprint for goal_progress_stall.
|
|
@@ -3462,14 +3718,56 @@ class FallbackEngine:
|
|
|
3462
3718
|
latency_ms=None, # streaming latency is end-of-stream-relative; left to plugin
|
|
3463
3719
|
stream=True,
|
|
3464
3720
|
)
|
|
3721
|
+
_log_fallback_trace(trace, profile=request.profile)
|
|
3465
3722
|
return
|
|
3466
3723
|
|
|
3467
3724
|
# ⑧ (empty-response): the chain is exhausted under ``fallback`` and
|
|
3468
3725
|
# every provider produced an empty stream. Flush the last buffered
|
|
3469
3726
|
# (empty) preamble so the client gets a well-formed, terminating SSE
|
|
3470
3727
|
# sequence (message_start … message_stop) instead of an error — a
|
|
3471
|
-
# 200 blank is a legitimate answer.
|
|
3472
|
-
|
|
3728
|
+
# 200 blank is a legitimate answer.
|
|
3729
|
+
#
|
|
3730
|
+
# v2.15.0 (stream-truncation): ``last_empty_stream_truncated`` gates
|
|
3731
|
+
# this. The buffer of a *truncated* stream stops mid-message — it has
|
|
3732
|
+
# no ``message_stop`` — so flushing it would emit an unterminated SSE
|
|
3733
|
+
# stream, no exception, no error frame, and a client that waits
|
|
3734
|
+
# forever. It would also fire ``empty-response-detected`` for a
|
|
3735
|
+
# stream that was not empty at all, inflating
|
|
3736
|
+
# ``empty_responses_total``. When the buffer we are holding came from
|
|
3737
|
+
# a truncation we fall through to ``NoProvidersAvailableError``
|
|
3738
|
+
# instead: the ingress turns that into an ``event: error``
|
|
3739
|
+
# (``overloaded_error``) frame, which terminates the stream and tells
|
|
3740
|
+
# the operator the truth. An operator who set
|
|
3741
|
+
# ``stream_truncation_action: error`` asked not to have truncation
|
|
3742
|
+
# swallowed; silently synthesizing an ``end_turn`` over a half-written
|
|
3743
|
+
# answer would be exactly that. Under the default ``off`` no
|
|
3744
|
+
# ``StreamTruncatedError`` exists, the flag is never set, and this
|
|
3745
|
+
# path is byte-identical to v2.14.0.
|
|
3746
|
+
#
|
|
3747
|
+
# v2.15.0 post-review fix: ``last_empty_stream_truncated`` only ever
|
|
3748
|
+
# becomes True for a ``StreamTruncatedError`` — it says nothing about
|
|
3749
|
+
# a transport break (``httpx.RemoteProtocolError``) or a read timeout
|
|
3750
|
+
# further down the same chain, which leave an equally unterminated
|
|
3751
|
+
# buffer (no ``message_stop``) but arrive as a plain ``AdapterError``.
|
|
3752
|
+
# Under ``stream_truncation_action: error`` those must not be
|
|
3753
|
+
# flushed either, or the client hangs on an unterminated SSE stream
|
|
3754
|
+
# exactly like the case above — the operator asked not to have
|
|
3755
|
+
# truncation swallowed, and a TCP-level break mid-message is a
|
|
3756
|
+
# truncation. ``_buffer_unterminated`` is gated on ``error`` so
|
|
3757
|
+
# ``off`` / ``warn`` never reach the buffer inspection below and stay
|
|
3758
|
+
# byte-identical to v2.14.0 — under those actions a buffer with no
|
|
3759
|
+
# ``message_stop`` can legitimately be a clean-but-empty stream
|
|
3760
|
+
# (preamble only, no content, no terminator emitted by the
|
|
3761
|
+
# translator) that v2.14.0 flushes as a 200 blank.
|
|
3762
|
+
_buffer_unterminated = (
|
|
3763
|
+
overrides.stream_truncation_action == "error"
|
|
3764
|
+
and not any(
|
|
3765
|
+
ev.type == "message_stop" for ev in (last_empty_stream_buffer or [])
|
|
3766
|
+
)
|
|
3767
|
+
)
|
|
3768
|
+
if last_empty_stream_buffer is not None and not (
|
|
3769
|
+
last_empty_stream_truncated or _buffer_unterminated
|
|
3770
|
+
):
|
|
3473
3771
|
log_empty_response_detected(
|
|
3474
3772
|
logger,
|
|
3475
3773
|
"unknown",
|
|
@@ -3477,10 +3775,12 @@ class FallbackEngine:
|
|
|
3477
3775
|
stream=True,
|
|
3478
3776
|
chain_exhausted=True,
|
|
3479
3777
|
)
|
|
3778
|
+
_log_fallback_trace(trace, profile=request.profile)
|
|
3480
3779
|
for buffered_ev in last_empty_stream_buffer:
|
|
3481
3780
|
yield buffered_ev
|
|
3482
3781
|
return
|
|
3483
3782
|
|
|
3484
3783
|
profile = request.profile or self.config.default_profile
|
|
3485
3784
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
3785
|
+
_log_fallback_trace(trace, profile=profile)
|
|
3486
3786
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|