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
|
@@ -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)
|
coderouter/metrics/collector.py
CHANGED
|
@@ -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()
|
coderouter/metrics/prometheus.py
CHANGED
|
@@ -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()):
|
coderouter/routing/__init__.py
CHANGED
|
@@ -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
|
]
|