coderouter-cli 2.13.0__py3-none-any.whl → 2.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- log_empty_response_detected(
3366
- logger, adapter.name, action="fallback", stream=True
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. errors is empty in that case.
3472
- if last_empty_stream_buffer is not None:
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)