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.
@@ -29,12 +29,13 @@ from collections.abc import AsyncIterator
29
29
  from typing import Any
30
30
 
31
31
  from fastapi import APIRouter, Header, HTTPException, Request
32
- from fastapi.responses import StreamingResponse
32
+ from fastapi.responses import JSONResponse, StreamingResponse
33
33
 
34
34
  from coderouter.adapters.base import ChatRequest
35
35
  from coderouter.logging import get_logger
36
36
  from coderouter.routing import FallbackEngine, NoProvidersAvailableError
37
37
  from coderouter.routing.auto_router import RESERVED_PROFILE_NAME, classify
38
+ from coderouter.routing.fallback_trace import current_fallback_trace
38
39
 
39
40
  router = APIRouter()
40
41
  logger = get_logger(__name__)
@@ -179,7 +180,7 @@ async def chat_completions(
179
180
  request: Request,
180
181
  x_coderouter_profile: str | None = Header(default=None, alias=_PROFILE_HEADER),
181
182
  x_coderouter_mode: str | None = Header(default=None, alias=_MODE_HEADER),
182
- ) -> StreamingResponse | dict[str, Any]:
183
+ ) -> StreamingResponse | JSONResponse | dict[str, Any]:
183
184
  """OpenAI Chat Completions endpoint.
184
185
 
185
186
  Validates the body into :class:`ChatRequest`, resolves the profile
@@ -252,6 +253,34 @@ async def chat_completions(
252
253
  except NoProvidersAvailableError as exc:
253
254
  raise HTTPException(status_code=502, detail=str(exc)) from exc
254
255
 
256
+ # v2.15.0: fallback reason headers, same shape and same construction as
257
+ # ``anthropic_routes.messages`` — the engine filled the request-scoped
258
+ # trace while ``generate`` was awaited, and ``header_values()`` is empty
259
+ # unless the chain actually moved. A request served by its first
260
+ # provider therefore still returns the bare dict, byte-identical to
261
+ # v2.14.0.
262
+ #
263
+ # Only the non-streaming half exists here, and that is deliberate. The
264
+ # Anthropic streaming path can carry the trail two ways: chain-resolve
265
+ # hops ride the HTTP headers (``apply_context_budget`` resolves the
266
+ # chain before the response commits) and runtime hops ride the trailing
267
+ # ``coderouter_fallback`` SSE event (Anthropic's wire is typed, so an
268
+ # unknown event type is ignored by spec-compliant SDKs). Neither is
269
+ # available on the OpenAI wire: this route has no pre-dispatch chain
270
+ # resolution, and OpenAI SSE frames are untyped — every ``data:`` line
271
+ # is parsed as a ChatCompletionChunk, so injecting a metadata frame
272
+ # would hand strict clients an object they cannot deserialize. That is
273
+ # a breaking change, so the streaming OpenAI path stays untouched and
274
+ # its reasons are available via the ``fallback-occurred`` log lines.
275
+ fallback_trace = current_fallback_trace()
276
+ if fallback_trace is not None:
277
+ resp_headers = fallback_trace.header_values()
278
+ if resp_headers:
279
+ return JSONResponse(
280
+ content=response.model_dump(exclude_none=True),
281
+ headers=resp_headers,
282
+ )
283
+
255
284
  return response.model_dump(exclude_none=True)
256
285
 
257
286
 
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
  ]