coderouter-cli 2.14.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.
coderouter/logging.py CHANGED
@@ -1479,6 +1479,63 @@ def log_empty_response_detected(
1479
1479
  )
1480
1480
 
1481
1481
 
1482
+ # ---------------------------------------------------------------------------
1483
+ # v2.15.0 (stream-truncation): upstream SSE ended without a terminator
1484
+ #
1485
+ # Fired from the adapter's SSE parse loop when ``stream_truncation_action``
1486
+ # is ``warn`` or ``error`` and the upstream line iterator was exhausted
1487
+ # without a ``message_stop`` (Anthropic wire) / ``[DONE]`` or a
1488
+ # ``finish_reason`` (OpenAI wire).
1489
+ #
1490
+ # ``action`` records which knob value produced the line, so a dashboard can
1491
+ # separate "we only measured it" (``warn``) from "we acted on it" (``error``).
1492
+ # ``events_forwarded`` is how many events/chunks the adapter had already
1493
+ # yielded — 0 means nothing reached the engine and a clean fallback is still
1494
+ # possible. ``tool_call_in_flight`` is the sharp edge: True means a
1495
+ # ``tool_use`` / ``tool_calls`` block was still open, so whatever the
1496
+ # translation layer synthesizes closes a block whose argument JSON is
1497
+ # provably incomplete.
1498
+ # ---------------------------------------------------------------------------
1499
+
1500
+
1501
+ def log_stream_truncation_detected(
1502
+ logger: logging.Logger,
1503
+ provider: str,
1504
+ *,
1505
+ action: str,
1506
+ wire: str,
1507
+ events_forwarded: int,
1508
+ saw_stream_start: bool,
1509
+ tool_call_in_flight: bool = False,
1510
+ ) -> None:
1511
+ """Emit a ``stream-truncation-detected`` warn line.
1512
+
1513
+ Args:
1514
+ logger: Target logger.
1515
+ provider: ``ProviderConfig.name`` whose stream was cut.
1516
+ action: The ``stream_truncation_action`` value in effect (``warn``
1517
+ or ``error`` — ``off`` never reaches here).
1518
+ wire: ``anthropic`` or ``openai`` — which SSE dialect was being
1519
+ parsed, i.e. which terminator went missing.
1520
+ events_forwarded: Count of events/chunks already yielded downstream.
1521
+ saw_stream_start: Whether the opening event (``message_start`` /
1522
+ first data chunk) had arrived at all. False means the upstream
1523
+ produced nothing before closing.
1524
+ tool_call_in_flight: Whether a tool-call block was still open.
1525
+ """
1526
+ logger.warning(
1527
+ "stream-truncation-detected",
1528
+ extra={
1529
+ "provider": provider,
1530
+ "action": action,
1531
+ "wire": wire,
1532
+ "events_forwarded": events_forwarded,
1533
+ "saw_stream_start": saw_stream_start,
1534
+ "tool_call_in_flight": tool_call_in_flight,
1535
+ },
1536
+ )
1537
+
1538
+
1482
1539
  # ---------------------------------------------------------------------------
1483
1540
  # v2.0-I: Continuous probe log shapes
1484
1541
  #
@@ -1612,3 +1669,89 @@ def log_probe_capabilities_drift(
1612
1669
  "in_registry": in_registry,
1613
1670
  }
1614
1671
  logger.warning("probe-capabilities-drift", extra=payload)
1672
+
1673
+
1674
+ # ---------------------------------------------------------------------------
1675
+ # v2.15.0: fallback reason logging
1676
+ #
1677
+ # One ``fallback-occurred`` line per hop. Fires only when the chain
1678
+ # actually moves — a request served by its first provider produces no
1679
+ # line at all, so this adds zero volume to the healthy path.
1680
+ #
1681
+ # The vocabulary of ``reason`` is defined in
1682
+ # :mod:`coderouter.routing.fallback_trace` (a single canonical set that
1683
+ # also names the pre-existing ``provider-does-not-support`` /
1684
+ # ``translation-lossy`` degrade reasons, which never appear here — a
1685
+ # degrade keeps the request on the same provider).
1686
+ #
1687
+ # ``to_provider`` is ``None`` when the hop is the chain's last one and no
1688
+ # successor was ever attempted (chain exhausted). The line is still
1689
+ # emitted: "we left A because of R and had nowhere to go" is the single
1690
+ # most diagnostic event in the whole engine.
1691
+ # ---------------------------------------------------------------------------
1692
+
1693
+
1694
+ class FallbackOccurredPayload(TypedDict):
1695
+ """Structured shape of the ``fallback-occurred`` log record.
1696
+
1697
+ Fields
1698
+ provider: the provider being left. Duplicated from
1699
+ ``from_provider`` under the field name every other engine
1700
+ event uses, so existing per-provider log queries keep working
1701
+ without learning a new key.
1702
+ from_provider: the provider being left.
1703
+ to_provider: the provider taking over, or None if the chain was
1704
+ exhausted with no successor.
1705
+ reason: canonical fallback reason (see ``fallback_trace``).
1706
+ detail: short human-readable context (HTTP status, truncated
1707
+ error text), or None.
1708
+ profile: the resolved profile name, when known.
1709
+ stream: True when the hop happened on a streaming request.
1710
+ pre_attempt: True when the provider was filtered out during chain
1711
+ resolution (budget / health / paid gate) and therefore never
1712
+ actually called; False when it was called and failed.
1713
+ hop_index: 0-based position of this hop within the request.
1714
+ """
1715
+
1716
+ provider: str
1717
+ from_provider: str
1718
+ to_provider: str | None
1719
+ reason: str
1720
+ detail: str | None
1721
+ profile: str | None
1722
+ stream: bool
1723
+ pre_attempt: bool
1724
+ hop_index: int
1725
+
1726
+
1727
+ def log_fallback_occurred(
1728
+ logger: logging.Logger,
1729
+ *,
1730
+ from_provider: str,
1731
+ to_provider: str | None,
1732
+ reason: str,
1733
+ detail: str | None = None,
1734
+ profile: str | None = None,
1735
+ stream: bool = False,
1736
+ pre_attempt: bool = False,
1737
+ hop_index: int = 0,
1738
+ ) -> None:
1739
+ """Emit a ``fallback-occurred`` warning line for one chain transition.
1740
+
1741
+ Warn level, matching ``provider-failed`` — a fallback is a
1742
+ degradation of the intended routing even when the request ultimately
1743
+ succeeds, and operators grep for it when a profile's primary provider
1744
+ stops earning its place at the front of the chain.
1745
+ """
1746
+ payload: FallbackOccurredPayload = {
1747
+ "provider": from_provider,
1748
+ "from_provider": from_provider,
1749
+ "to_provider": to_provider,
1750
+ "reason": reason,
1751
+ "detail": detail,
1752
+ "profile": profile,
1753
+ "stream": stream,
1754
+ "pre_attempt": pre_attempt,
1755
+ "hop_index": hop_index,
1756
+ }
1757
+ logger.warning("fallback-occurred", extra=payload)
@@ -53,6 +53,8 @@ Event inventory (dispatch table in :meth:`MetricsCollector._dispatch`)
53
53
  ``drift-reload-attempted`` → ``drift_reload_total`` / success
54
54
  ``partial-stitch-surfaced`` → ``partial_stitch_surfaced_total`` (v2.0-H)
55
55
  ``empty-response-detected`` → ``empty_responses_total`` + per-provider (⑧)
56
+ ``stream-truncation-detected`` → ``stream_truncated_total`` + per-provider
57
+ + per-action (v2.15.0)
56
58
  ``probe-completed`` (v2.0-I) → ``probe_total`` / ``probe_success`` / ``probe_failure``
57
59
  + per-provider latency gauge
58
60
  ``probe-round-completed`` → ``probe_rounds_total`` (v2.0-I)
@@ -111,6 +113,7 @@ _KNOWN_EVENTS: Final[frozenset[str]] = frozenset(
111
113
  "drift-reload-attempted",
112
114
  "partial-stitch-surfaced",
113
115
  "empty-response-detected",
116
+ "stream-truncation-detected",
114
117
  "probe-completed",
115
118
  "probe-round-completed",
116
119
  "probe-capabilities-drift",
@@ -281,6 +284,16 @@ class MetricsCollector(logging.Handler):
281
284
  self._empty_responses_total: int = 0
282
285
  self._empty_responses_by_provider: Counter[str] = Counter()
283
286
 
287
+ # v2.15.0 (stream-truncation): count of upstream SSE streams that
288
+ # ended without a protocol terminator, under either ``warn`` or
289
+ # ``error``. Per-provider answers "which backend goes quiet"; the
290
+ # per-action split separates measurement from enforcement so a
291
+ # dashboard can show the ``warn``-phase rate before an operator
292
+ # commits to ``error``.
293
+ self._stream_truncated_total: int = 0
294
+ self._stream_truncated_by_provider: Counter[str] = Counter()
295
+ self._stream_truncated_by_action: Counter[str] = Counter()
296
+
284
297
  # v2.0-I: continuous probe counters. Per-provider probe attempts
285
298
  # and outcomes, plus a round counter for the dashboard's
286
299
  # "probes/min" panel.
@@ -339,6 +352,7 @@ class MetricsCollector(logging.Handler):
339
352
  "drift-reload-attempted": self._on_drift_reload_attempted,
340
353
  "partial-stitch-surfaced": self._on_partial_stitch_surfaced,
341
354
  "empty-response-detected": self._on_empty_response_detected,
355
+ "stream-truncation-detected": self._on_stream_truncation_detected,
342
356
  "probe-completed": self._on_probe_completed,
343
357
  "probe-round-completed": self._on_probe_round_completed,
344
358
  "probe-capabilities-drift": self._on_probe_capabilities_drift,
@@ -691,6 +705,20 @@ class MetricsCollector(logging.Handler):
691
705
  self._empty_responses_by_provider[provider] += 1
692
706
  self._push_recent("empty-response-detected", extras, record)
693
707
 
708
+ def _on_stream_truncation_detected(
709
+ self, extras: dict[str, Any], record: logging.LogRecord
710
+ ) -> None:
711
+ # v2.15.0 (stream-truncation): an upstream SSE stream ended without
712
+ # its terminator under a ``warn`` / ``error`` action.
713
+ self._stream_truncated_total += 1
714
+ provider = _str(extras.get("provider"))
715
+ if provider:
716
+ self._stream_truncated_by_provider[provider] += 1
717
+ action = _str(extras.get("action"))
718
+ if action:
719
+ self._stream_truncated_by_action[action] += 1
720
+ self._push_recent("stream-truncation-detected", extras, record)
721
+
694
722
  def _on_probe_completed(
695
723
  self, extras: dict[str, Any], record: logging.LogRecord
696
724
  ) -> None:
@@ -914,6 +942,14 @@ class MetricsCollector(logging.Handler):
914
942
  "empty_responses_by_provider": dict(
915
943
  self._empty_responses_by_provider
916
944
  ),
945
+ # v2.15.0 (stream-truncation).
946
+ "stream_truncated_total": self._stream_truncated_total,
947
+ "stream_truncated_by_provider": dict(
948
+ self._stream_truncated_by_provider
949
+ ),
950
+ "stream_truncated_by_action": dict(
951
+ self._stream_truncated_by_action
952
+ ),
917
953
  # v2.0-I: continuous probe counters.
918
954
  "probe_total": dict(self._probe_total),
919
955
  "probe_success": dict(self._probe_success),
@@ -1071,6 +1107,10 @@ class MetricsCollector(logging.Handler):
1071
1107
  # ⑧ (empty-response)
1072
1108
  self._empty_responses_total = 0
1073
1109
  self._empty_responses_by_provider.clear()
1110
+ # v2.15.0 (stream-truncation)
1111
+ self._stream_truncated_total = 0
1112
+ self._stream_truncated_by_provider.clear()
1113
+ self._stream_truncated_by_action.clear()
1074
1114
  # v2.0-I
1075
1115
  self._probe_total.clear()
1076
1116
  self._probe_success.clear()
@@ -443,6 +443,52 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
443
443
  )
444
444
  )
445
445
 
446
+ # ---- v2.15.0: stream truncation metric -----------------------------------
447
+ # Exported per (provider, action) so the ``warn``-phase measurement and
448
+ # the ``error``-phase enforcement are separable on one dashboard: a
449
+ # rising ``action="warn"`` series is the evidence an operator needs
450
+ # before flipping a profile to ``error``.
451
+ stream_truncated_by_provider = counters.get("stream_truncated_by_provider", {})
452
+ stream_truncated_total = counters.get("stream_truncated_total", 0)
453
+ if stream_truncated_total:
454
+ trunc_samples: list[tuple[tuple[tuple[str, str], ...], int]] = [
455
+ ((("provider", provider),), count)
456
+ for provider, count in sorted(stream_truncated_by_provider.items())
457
+ ]
458
+ if not trunc_samples:
459
+ # Detections whose log line carried no provider still count.
460
+ trunc_samples = [((), stream_truncated_total)]
461
+ lines.extend(
462
+ _counter(
463
+ name="stream_truncated_total",
464
+ help_text=(
465
+ "Upstream SSE streams that ended without a protocol "
466
+ "terminator (no message_stop / no [DONE] and no "
467
+ "finish_reason), by provider. Requires "
468
+ "stream_truncation_action=warn or error."
469
+ ),
470
+ samples=trunc_samples,
471
+ )
472
+ )
473
+ action_samples: list[tuple[tuple[tuple[str, str], ...], int]] = [
474
+ ((("action", action),), count)
475
+ for action, count in sorted(
476
+ counters.get("stream_truncated_by_action", {}).items()
477
+ )
478
+ ]
479
+ if action_samples:
480
+ lines.extend(
481
+ _counter(
482
+ name="stream_truncated_by_action_total",
483
+ help_text=(
484
+ "Stream truncation detections by the "
485
+ "stream_truncation_action value in effect "
486
+ "(warn = measured only, error = fallback attempted)."
487
+ ),
488
+ samples=action_samples,
489
+ )
490
+ )
491
+
446
492
  # ---- v2.0-I: continuous probe metrics ------------------------------------
447
493
  probe_total_samples: list[tuple[tuple[tuple[str, str], ...], int]] = []
448
494
  for provider, count in sorted(counters.get("probe_total", {}).items()):
@@ -5,9 +5,17 @@ from coderouter.routing.fallback import (
5
5
  MidStreamError,
6
6
  NoProvidersAvailableError,
7
7
  )
8
+ from coderouter.routing.fallback_trace import (
9
+ FallbackHop,
10
+ FallbackTrace,
11
+ current_fallback_trace,
12
+ )
8
13
 
9
14
  __all__ = [
10
15
  "FallbackEngine",
16
+ "FallbackHop",
17
+ "FallbackTrace",
11
18
  "MidStreamError",
12
19
  "NoProvidersAvailableError",
20
+ "current_fallback_trace",
13
21
  ]