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.
@@ -0,0 +1,501 @@
1
+ """v2.15.0: request-scoped fallback trace — *why* did the chain move?
2
+
3
+ Before v2.15.0 the engine could tell an operator (via ``try-provider`` /
4
+ ``provider-failed`` log lines) that a fallback happened, but nothing tied
5
+ the two together into a single "we left A for B because of R" record, and
6
+ nothing at all reached the *client*. This module is that missing record.
7
+
8
+ Vocabulary
9
+ ==========
10
+
11
+ ``reason`` already existed in the codebase as a keyword argument on
12
+ :func:`coderouter.routing.capability.log_capability_degraded`
13
+ (``provider-does-not-support`` / ``translation-lossy`` /
14
+ ``unsupported-backend``) and on the drift verdict. Those are **degrade**
15
+ reasons: the request stays on the same provider, something is stripped.
16
+ v2.15.0 keeps those strings verbatim (they are re-exported here as
17
+ ``REASON_PROVIDER_DOES_NOT_SUPPORT`` etc. so there is one place to read
18
+ the whole vocabulary) and adds the **fallback** reasons — the ones that
19
+ actually move the request to a different provider.
20
+
21
+ Only fallback reasons ever produce a :class:`FallbackHop`. Degrade
22
+ reasons never do, by construction: nothing moved.
23
+
24
+ Fallback reasons split in two families:
25
+
26
+ *Pre-attempt* (the provider was filtered out of the chain during
27
+ :meth:`FallbackEngine._resolve_chain`, so it was never called):
28
+ ``paid-gate`` / ``budget-exceeded`` / ``memory-pressure`` /
29
+ ``backend-unhealthy`` / ``self-healing-excluded`` / ``unknown-provider``.
30
+
31
+ *Attempt-failure* (the provider was called and did not deliver):
32
+ ``timeout`` / ``rate-limit`` / ``auth`` / ``upstream-5xx`` /
33
+ ``upstream-4xx`` / ``connection`` / ``upstream-error`` /
34
+ ``empty-response`` / ``empty-stream``.
35
+
36
+ Request scoping
37
+ ===============
38
+
39
+ The trace lives in a :class:`~contextvars.ContextVar`, the same isolation
40
+ mechanism the drift verdict (``_drift_verdict_ctx``) and the M11 prepared
41
+ dispatch (``_prepared_dispatch_ctx``) already use in
42
+ :mod:`coderouter.routing.fallback`. Starlette runs each request handler in
43
+ its own asyncio task, so one request never sees another's hops, and the
44
+ value cannot go stale across requests (the ContextVar defaults to
45
+ ``None``).
46
+
47
+ Two entry points, deliberately different:
48
+
49
+ * :func:`begin_fallback_trace` — installs a *fresh* trace. Called by the
50
+ engine's dispatch entry points. ``keep_existing=True`` preserves a trace
51
+ the ingress's ``apply_context_budget`` pre-pass already started for the
52
+ same request (chain-resolve skips are recorded there, before dispatch).
53
+ * :func:`current_fallback_trace` — read-only lookup. The ingress uses it
54
+ after dispatch to build the ``X-CodeRouter-Fallback-*`` headers and the
55
+ ``coderouter_fallback`` SSE metadata event.
56
+
57
+ Ordering / shape contract (what a client can rely on)
58
+ =====================================================
59
+
60
+ For a request that fell back twice::
61
+
62
+ X-CodeRouter-Fallback-From: local
63
+ X-CodeRouter-Fallback-To: openrouter
64
+ X-CodeRouter-Fallback-Reason: timeout,upstream-5xx
65
+ X-CodeRouter-Fallback-Chain: local>ollama>openrouter
66
+
67
+ ``Chain`` is the providers in try-order; ``Reason`` is the reason for each
68
+ *departure*, in the same order. ``len(Chain) == len(Reason) + 1`` when the
69
+ chain finally produced an answer, and ``len(Chain) == len(Reason)`` when
70
+ the whole chain was exhausted (there was no provider to hand off to, so
71
+ ``X-CodeRouter-Fallback-To`` is omitted). ``From`` is the first provider
72
+ that was abandoned, ``To`` the one that ultimately served.
73
+
74
+ None of these headers are emitted when no fallback happened — the
75
+ zero-fallback path is byte-for-byte unchanged from v2.14.0.
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ from contextvars import ContextVar
81
+ from dataclasses import dataclass, field
82
+ from typing import TYPE_CHECKING, Any, Final
83
+
84
+ if TYPE_CHECKING:
85
+ from coderouter.adapters.base import AdapterError
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Canonical reason vocabulary
89
+ # ---------------------------------------------------------------------------
90
+
91
+ # -- pre-attempt (chain-resolve filters; provider was never called) ---------
92
+ REASON_PAID_GATE: Final = "paid-gate"
93
+ REASON_BUDGET_EXCEEDED: Final = "budget-exceeded"
94
+ REASON_MEMORY_PRESSURE: Final = "memory-pressure"
95
+ REASON_BACKEND_UNHEALTHY: Final = "backend-unhealthy"
96
+ REASON_SELF_HEALING_EXCLUDED: Final = "self-healing-excluded"
97
+ REASON_UNKNOWN_PROVIDER: Final = "unknown-provider"
98
+
99
+ # -- attempt failures (provider was called) --------------------------------
100
+ REASON_TIMEOUT: Final = "timeout"
101
+ REASON_RATE_LIMIT: Final = "rate-limit"
102
+ REASON_AUTH: Final = "auth"
103
+ REASON_UPSTREAM_5XX: Final = "upstream-5xx"
104
+ REASON_UPSTREAM_4XX: Final = "upstream-4xx"
105
+ REASON_CONNECTION: Final = "connection"
106
+ REASON_UPSTREAM_ERROR: Final = "upstream-error"
107
+ REASON_EMPTY_RESPONSE: Final = "empty-response"
108
+ REASON_EMPTY_STREAM: Final = "empty-stream"
109
+ # v2.15.0: the upstream SSE stream ended without its protocol terminator —
110
+ # distinct from ``empty-stream`` (nothing at all arrived) and from
111
+ # ``connection`` (the transport itself broke). Produced only when the profile
112
+ # sets ``stream_truncation_action: error``.
113
+ REASON_STREAM_TRUNCATED: Final = "stream-truncated"
114
+
115
+ # -- degrade reasons (pre-existing strings; never produce a hop) -----------
116
+ # Kept here so the whole ``reason`` vocabulary is readable in one place.
117
+ REASON_PROVIDER_DOES_NOT_SUPPORT: Final = "provider-does-not-support"
118
+ REASON_TRANSLATION_LOSSY: Final = "translation-lossy"
119
+ REASON_UNSUPPORTED_BACKEND: Final = "unsupported-backend"
120
+
121
+ PRE_ATTEMPT_REASONS: Final[frozenset[str]] = frozenset(
122
+ {
123
+ REASON_PAID_GATE,
124
+ REASON_BUDGET_EXCEEDED,
125
+ REASON_MEMORY_PRESSURE,
126
+ REASON_BACKEND_UNHEALTHY,
127
+ REASON_SELF_HEALING_EXCLUDED,
128
+ REASON_UNKNOWN_PROVIDER,
129
+ }
130
+ )
131
+
132
+ ATTEMPT_FAILURE_REASONS: Final[frozenset[str]] = frozenset(
133
+ {
134
+ REASON_TIMEOUT,
135
+ REASON_RATE_LIMIT,
136
+ REASON_AUTH,
137
+ REASON_UPSTREAM_5XX,
138
+ REASON_UPSTREAM_4XX,
139
+ REASON_CONNECTION,
140
+ REASON_UPSTREAM_ERROR,
141
+ REASON_EMPTY_RESPONSE,
142
+ REASON_EMPTY_STREAM,
143
+ REASON_STREAM_TRUNCATED,
144
+ }
145
+ )
146
+
147
+ FALLBACK_REASONS: Final[frozenset[str]] = PRE_ATTEMPT_REASONS | ATTEMPT_FAILURE_REASONS
148
+
149
+ DEGRADE_REASONS: Final[frozenset[str]] = frozenset(
150
+ {
151
+ REASON_PROVIDER_DOES_NOT_SUPPORT,
152
+ REASON_TRANSLATION_LOSSY,
153
+ REASON_UNSUPPORTED_BACKEND,
154
+ }
155
+ )
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Header names — follow the v2.0-F / v2.0-G convention already in
160
+ # ``ingress/anthropic_routes.py`` (``X-CodeRouter-Context-Budget`` /
161
+ # ``X-CodeRouter-Drift``): ``X-CodeRouter-`` prefix, Train-Case suffix,
162
+ # short scalar value.
163
+ # ---------------------------------------------------------------------------
164
+
165
+ HEADER_FALLBACK_FROM: Final = "X-CodeRouter-Fallback-From"
166
+ HEADER_FALLBACK_TO: Final = "X-CodeRouter-Fallback-To"
167
+ HEADER_FALLBACK_REASON: Final = "X-CodeRouter-Fallback-Reason"
168
+ HEADER_FALLBACK_CHAIN: Final = "X-CodeRouter-Fallback-Chain"
169
+
170
+ # SSE metadata event name for the streaming path. Mirrors the existing
171
+ # ``coderouter_partial`` extension event (v2.0-H) — a client-optional
172
+ # trailing frame, emitted after ``message_stop``, that spec-compliant
173
+ # Anthropic SDKs ignore.
174
+ SSE_FALLBACK_EVENT: Final = "coderouter_fallback"
175
+
176
+ # Provider names and reasons are config identifiers, but a header value has
177
+ # to stay in the printable-ASCII token range no matter what a user typed in
178
+ # their YAML. Anything outside the allowlist collapses to ``_``.
179
+ _SAFE_CHARS: Final[frozenset[str]] = frozenset(
180
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
181
+ )
182
+
183
+
184
+ def _sanitize(value: str) -> str:
185
+ """Reduce ``value`` to a header-safe token.
186
+
187
+ Keeps ``[A-Za-z0-9._-]`` and replaces everything else with ``_`` so a
188
+ provider named ``my provider`` or one carrying non-ASCII can never
189
+ produce a malformed (or header-injecting) response header.
190
+ """
191
+ return "".join(ch if ch in _SAFE_CHARS else "_" for ch in value) or "_"
192
+
193
+
194
+ def describe_adapter_error(exc: AdapterError) -> str:
195
+ """Build the short, non-leaking ``detail`` string for a failed attempt.
196
+
197
+ Deliberately structural: the upstream's error *text* stays in the
198
+ existing ``provider-failed`` warn line (which passes through the
199
+ secret-redaction filter) and never reaches a response header or an SSE
200
+ event. Here we only report the HTTP status, or the transport shape
201
+ when there was no response at all.
202
+ """
203
+ if exc.status_code is not None:
204
+ return f"status={exc.status_code}"
205
+ reason = classify_adapter_error(exc)
206
+ if reason == REASON_STREAM_TRUNCATED:
207
+ # Not a transport fault — the HTTP body ended cleanly; it was the
208
+ # LLM protocol inside it that never closed.
209
+ return "no-terminator"
210
+ return f"transport={reason}"
211
+
212
+
213
+ def classify_adapter_error(exc: AdapterError) -> str:
214
+ """Map an :class:`AdapterError` onto a canonical fallback reason.
215
+
216
+ Status codes are the primary signal. Adapters raise with
217
+ ``status_code=None`` for pre-response failures (httpx timeout,
218
+ transport error, JSON decode), so those are disambiguated by the
219
+ message text the adapters already write — ``timeout ...`` and
220
+ ``transport error: ...`` are stable prefixes in both
221
+ ``adapters/openai_compat.py`` and ``adapters/anthropic_native.py``.
222
+
223
+ v2.15.0: ``StreamTruncatedError`` is checked first and by type, not by
224
+ message text. It carries ``status_code=None`` and would otherwise land in
225
+ the generic ``upstream-error`` bucket, hiding the one failure mode this
226
+ reason exists to name. The import is function-local because
227
+ ``adapters.base`` is a TYPE_CHECKING-only import at module scope here
228
+ (the runtime import order would otherwise cycle through the registry).
229
+ """
230
+ from coderouter.adapters.base import StreamTruncatedError
231
+
232
+ if isinstance(exc, StreamTruncatedError):
233
+ return REASON_STREAM_TRUNCATED
234
+ status = exc.status_code
235
+ if status is None:
236
+ message = str(exc).lower()
237
+ if "timeout" in message or "timed out" in message:
238
+ return REASON_TIMEOUT
239
+ if "transport error" in message or "connect" in message:
240
+ return REASON_CONNECTION
241
+ return REASON_UPSTREAM_ERROR
242
+ if status == 408:
243
+ return REASON_TIMEOUT
244
+ if status == 429:
245
+ return REASON_RATE_LIMIT
246
+ if status in {401, 403}:
247
+ return REASON_AUTH
248
+ if status >= 500:
249
+ return REASON_UPSTREAM_5XX
250
+ if status >= 400:
251
+ return REASON_UPSTREAM_4XX
252
+ return REASON_UPSTREAM_ERROR
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class FallbackHop:
257
+ """One provider→provider transition and the reason behind it.
258
+
259
+ ``to_provider`` is ``None`` until the *next* provider is actually
260
+ attempted (see :meth:`FallbackTrace.record_attempt`); it stays ``None``
261
+ forever when the chain was exhausted without a successor.
262
+ """
263
+
264
+ from_provider: str
265
+ reason: str
266
+ to_provider: str | None = None
267
+ detail: str | None = None
268
+ stream: bool = False
269
+ pre_attempt: bool = False
270
+
271
+ def as_dict(self) -> dict[str, Any]:
272
+ """Serializable view, used by the SSE metadata event."""
273
+ return {
274
+ "from": self.from_provider,
275
+ "to": self.to_provider,
276
+ "reason": self.reason,
277
+ "detail": self.detail,
278
+ "stream": self.stream,
279
+ "pre_attempt": self.pre_attempt,
280
+ }
281
+
282
+
283
+ @dataclass
284
+ class FallbackTrace:
285
+ """Ordered record of every fallback that happened while serving one request.
286
+
287
+ Mutated in place by the engine. The ingress only ever reads it, so the
288
+ object can be shared safely between the request-handler context and the
289
+ SSE-iterator context (which inherits a copy of the same ContextVar
290
+ mapping, and therefore the same object).
291
+ """
292
+
293
+ hops: list[FallbackHop] = field(default_factory=list)
294
+ attempts: list[str] = field(default_factory=list)
295
+ profile: str | None = None
296
+ #: Set once the engine has emitted the ``fallback-occurred`` log lines
297
+ #: for this request, so a dispatch path with several terminal returns
298
+ #: cannot log the same hop trail twice.
299
+ logged: bool = False
300
+
301
+ # -- recording ---------------------------------------------------------
302
+
303
+ def record_skip(
304
+ self, provider: str, reason: str, *, detail: str | None = None
305
+ ) -> None:
306
+ """Record a provider filtered out of the chain before it was called."""
307
+ self.hops.append(
308
+ FallbackHop(
309
+ from_provider=provider,
310
+ reason=reason,
311
+ detail=detail,
312
+ pre_attempt=True,
313
+ )
314
+ )
315
+
316
+ def record_failure(
317
+ self,
318
+ provider: str,
319
+ reason: str,
320
+ *,
321
+ detail: str | None = None,
322
+ stream: bool = False,
323
+ ) -> None:
324
+ """Record a provider that was called and did not deliver."""
325
+ self.hops.append(
326
+ FallbackHop(
327
+ from_provider=provider,
328
+ reason=reason,
329
+ detail=detail,
330
+ stream=stream,
331
+ pre_attempt=False,
332
+ )
333
+ )
334
+
335
+ def record_attempt(self, provider: str) -> None:
336
+ """Record that ``provider`` is being tried, resolving pending hops.
337
+
338
+ Every hop still waiting for a successor (``to_provider is None``)
339
+ gets ``provider`` filled in. Consecutive skips therefore all point
340
+ at the same provider that finally ran, which is exactly the
341
+ narrative an operator wants: "budget blocked local and health
342
+ blocked ollama, so openrouter served it".
343
+ """
344
+ self.attempts.append(provider)
345
+ self.resolve_pending(provider)
346
+
347
+ def resolve_pending(self, provider: str) -> None:
348
+ """Point every still-unresolved hop at ``provider``.
349
+
350
+ Split out of :meth:`record_attempt` because the *streaming* ingress
351
+ needs the answer earlier than the first attempt: an SSE response
352
+ commits its HTTP headers before the engine's generator runs a
353
+ single line, so ``X-CodeRouter-Fallback-To`` would always be
354
+ missing if only ``record_attempt`` could fill it. The engine calls
355
+ this from ``apply_context_budget`` — the one place where the chain
356
+ is fully ordered (adaptive reorder, drift demotion and capability
357
+ bucketing have all been applied) and its head is therefore
358
+ genuinely the provider that will be tried first.
359
+
360
+ Idempotent and monotonic: only hops holding ``None`` are touched,
361
+ so a later ``record_attempt`` can never rewrite an answer this
362
+ already gave, and the two mechanisms compose instead of fighting.
363
+ """
364
+ for index, hop in enumerate(self.hops):
365
+ if hop.to_provider is None:
366
+ self.hops[index] = FallbackHop(
367
+ from_provider=hop.from_provider,
368
+ reason=hop.reason,
369
+ to_provider=provider,
370
+ detail=hop.detail,
371
+ stream=hop.stream,
372
+ pre_attempt=hop.pre_attempt,
373
+ )
374
+
375
+ # -- reading -----------------------------------------------------------
376
+
377
+ @property
378
+ def occurred(self) -> bool:
379
+ """True iff at least one fallback hop was recorded."""
380
+ return bool(self.hops)
381
+
382
+ @property
383
+ def chain(self) -> list[str]:
384
+ """Providers in try-order, including the one that finally served."""
385
+ if not self.hops:
386
+ return list(self.attempts)
387
+ names = [hop.from_provider for hop in self.hops]
388
+ final = self.hops[-1].to_provider
389
+ if final is not None:
390
+ names.append(final)
391
+ return names
392
+
393
+ @property
394
+ def reasons(self) -> list[str]:
395
+ """Departure reason for each hop, in order."""
396
+ return [hop.reason for hop in self.hops]
397
+
398
+ @property
399
+ def from_provider(self) -> str | None:
400
+ """The first provider that was abandoned."""
401
+ return self.hops[0].from_provider if self.hops else None
402
+
403
+ @property
404
+ def to_provider(self) -> str | None:
405
+ """The provider that ultimately served, or ``None`` if none did."""
406
+ return self.hops[-1].to_provider if self.hops else None
407
+
408
+ def header_values(self) -> dict[str, str]:
409
+ """Build the ``X-CodeRouter-Fallback-*`` response headers.
410
+
411
+ Returns an empty dict when no fallback occurred, so the caller can
412
+ merge unconditionally and the zero-fallback response keeps exactly
413
+ the headers it had before v2.15.0.
414
+ """
415
+ if not self.hops:
416
+ return {}
417
+ headers: dict[str, str] = {
418
+ HEADER_FALLBACK_FROM: _sanitize(self.hops[0].from_provider),
419
+ HEADER_FALLBACK_REASON: ",".join(_sanitize(r) for r in self.reasons),
420
+ HEADER_FALLBACK_CHAIN: ">".join(_sanitize(p) for p in self.chain),
421
+ }
422
+ final = self.hops[-1].to_provider
423
+ if final is not None:
424
+ headers[HEADER_FALLBACK_TO] = _sanitize(final)
425
+ return headers
426
+
427
+ def as_event_payload(self) -> dict[str, Any]:
428
+ """Build the ``coderouter_fallback`` SSE metadata event body."""
429
+ return {
430
+ "type": SSE_FALLBACK_EVENT,
431
+ "from": self.from_provider,
432
+ "to": self.to_provider,
433
+ "reason": self.reasons,
434
+ "chain": self.chain,
435
+ "hops": [hop.as_dict() for hop in self.hops],
436
+ }
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Request-scoped storage
441
+ # ---------------------------------------------------------------------------
442
+
443
+ _fallback_trace_ctx: ContextVar[FallbackTrace | None] = ContextVar(
444
+ "coderouter_fallback_trace", default=None
445
+ )
446
+
447
+
448
+ def begin_fallback_trace(
449
+ *, keep_existing: bool = False, profile: str | None = None
450
+ ) -> FallbackTrace:
451
+ """Install (or reuse) the trace for the request being dispatched.
452
+
453
+ ``keep_existing=True`` is used by the engine entry points when the M11
454
+ prepared dispatch was consumed: that proves the ingress already called
455
+ ``apply_context_budget`` for *this* request, so the trace it started
456
+ (holding the chain-resolve skips) must be kept rather than replaced.
457
+
458
+ In every other case a fresh trace is installed, which is what makes
459
+ successive direct engine calls in one context (unit tests, scripts)
460
+ independent instead of accumulating hops forever.
461
+ """
462
+ existing = _fallback_trace_ctx.get()
463
+ if keep_existing and existing is not None:
464
+ if profile is not None:
465
+ existing.profile = profile
466
+ return existing
467
+ trace = FallbackTrace(profile=profile)
468
+ _fallback_trace_ctx.set(trace)
469
+ return trace
470
+
471
+
472
+ def ensure_fallback_trace(*, profile: str | None = None) -> FallbackTrace:
473
+ """Return the current trace, creating one only if none exists.
474
+
475
+ Used by chain resolution, which runs both inside dispatch (a trace
476
+ already exists) and from the ingress's ``apply_context_budget``
477
+ pre-pass (it does not yet).
478
+ """
479
+ existing = _fallback_trace_ctx.get()
480
+ if existing is not None:
481
+ if profile is not None and existing.profile is None:
482
+ existing.profile = profile
483
+ return existing
484
+ trace = FallbackTrace(profile=profile)
485
+ _fallback_trace_ctx.set(trace)
486
+ return trace
487
+
488
+
489
+ def current_fallback_trace() -> FallbackTrace | None:
490
+ """Return the trace for the current request, or ``None``.
491
+
492
+ Read-only accessor for the ingress. ``None`` means "nothing recorded"
493
+ — either no fallback machinery ran (a stubbed engine in a test) or the
494
+ engine never started a trace. Callers must treat it as "no fallback".
495
+ """
496
+ return _fallback_trace_ctx.get()
497
+
498
+
499
+ def reset_fallback_trace() -> None:
500
+ """Clear the current trace. Test helper; not used in the request path."""
501
+ _fallback_trace_ctx.set(None)