coderouter-cli 2.0.0__py3-none-any.whl → 2.1.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/config/schemas.py +103 -0
- coderouter/guards/continuous_probe.py +349 -0
- coderouter/guards/drift_actions.py +111 -0
- coderouter/guards/drift_detection.py +308 -0
- coderouter/ingress/anthropic_routes.py +75 -11
- coderouter/ingress/app.py +39 -0
- coderouter/logging.py +262 -0
- coderouter/metrics/collector.py +93 -0
- coderouter/metrics/prometheus.py +141 -0
- coderouter/routing/adaptive.py +23 -0
- coderouter/routing/fallback.py +285 -4
- {coderouter_cli-2.0.0.dist-info → coderouter_cli-2.1.0.dist-info}/METADATA +7 -6
- {coderouter_cli-2.0.0.dist-info → coderouter_cli-2.1.0.dist-info}/RECORD +16 -13
- {coderouter_cli-2.0.0.dist-info → coderouter_cli-2.1.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.0.0.dist-info → coderouter_cli-2.1.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.0.0.dist-info → coderouter_cli-2.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Drift detection guard (v2.0-G, L4).
|
|
2
|
+
|
|
3
|
+
Detects gradual quality degradation in model responses during long-running
|
|
4
|
+
agent sessions. Unlike L5 (binary crash) and adaptive routing (latency),
|
|
5
|
+
drift detection targets the "succeeds but quality is decaying" pattern:
|
|
6
|
+
empty responses, shrinking output length, tool-call silence, anomalous
|
|
7
|
+
stop reasons.
|
|
8
|
+
|
|
9
|
+
Architecture
|
|
10
|
+
============
|
|
11
|
+
|
|
12
|
+
Three layers:
|
|
13
|
+
|
|
14
|
+
1. **Observation model** — :class:`ResponseObservation` captures the
|
|
15
|
+
quality-relevant fields from each successful provider response.
|
|
16
|
+
2. **Detector** — :func:`detect_drift` is a pure function that takes
|
|
17
|
+
a window of observations and thresholds, returns a
|
|
18
|
+
:class:`DriftVerdict`.
|
|
19
|
+
3. **Window manager** — :class:`DriftWindow` maintains per-provider
|
|
20
|
+
rolling deques of observations, thread-safe for the async engine.
|
|
21
|
+
|
|
22
|
+
The engine calls :meth:`DriftWindow.record` after each provider-ok/failed
|
|
23
|
+
event, then calls :func:`detect_drift` to check whether corrective action
|
|
24
|
+
is needed.
|
|
25
|
+
|
|
26
|
+
Signals
|
|
27
|
+
=======
|
|
28
|
+
|
|
29
|
+
* ``empty_response_rate`` — fraction of responses with output_tokens == 0
|
|
30
|
+
* ``length_collapse`` — median output_tokens in the recent half vs. the
|
|
31
|
+
earlier half of the window; ratio below threshold = collapse
|
|
32
|
+
* ``tool_silence_rate`` — fraction of responses missing tool_use blocks
|
|
33
|
+
(only meaningful when the request contained tools)
|
|
34
|
+
* ``stop_anomaly_rate`` — fraction of responses with unexpected stop_reason
|
|
35
|
+
(not "end_turn" / "tool_use" / "max_tokens")
|
|
36
|
+
* ``error_rate`` — fraction of attempts that ended in failure
|
|
37
|
+
|
|
38
|
+
Thresholds are bundled as :class:`DriftThresholds` with three presets
|
|
39
|
+
(``low`` / ``normal`` / ``high`` sensitivity).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import statistics
|
|
45
|
+
from collections import deque
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from typing import Literal
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Observation model
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ResponseObservation:
|
|
56
|
+
"""Quality-relevant snapshot of a single provider response.
|
|
57
|
+
|
|
58
|
+
Captured post-response (after adapter translation), before returning
|
|
59
|
+
to the client. Fields are intentionally minimal — only what the
|
|
60
|
+
detector needs.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
provider: str
|
|
64
|
+
output_tokens: int
|
|
65
|
+
has_tool_use: bool
|
|
66
|
+
"""Whether the response contained at least one tool_use block."""
|
|
67
|
+
request_had_tools: bool
|
|
68
|
+
"""Whether the request included a tools[] array (context for tool_silence)."""
|
|
69
|
+
stop_reason: str | None
|
|
70
|
+
"""Anthropic stop_reason: 'end_turn' / 'tool_use' / 'max_tokens' / None."""
|
|
71
|
+
is_error: bool = False
|
|
72
|
+
"""True if the attempt ended in provider-failed / provider-failed-midstream."""
|
|
73
|
+
stream: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Thresholds
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class DriftThresholds:
|
|
83
|
+
"""Threshold set for drift detection.
|
|
84
|
+
|
|
85
|
+
Each field is the value above which (or below which for length_collapse)
|
|
86
|
+
the corresponding signal is considered anomalous.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
# Rate thresholds (signal > threshold → anomaly)
|
|
90
|
+
empty_response_rate: float = 0.3
|
|
91
|
+
"""Fraction of responses with output_tokens == 0 to trigger."""
|
|
92
|
+
stop_anomaly_rate: float = 0.4
|
|
93
|
+
"""Fraction of responses with unexpected stop_reason."""
|
|
94
|
+
error_rate: float = 0.25
|
|
95
|
+
"""Fraction of failed attempts."""
|
|
96
|
+
tool_silence_rate: float = 0.7
|
|
97
|
+
"""Fraction of tool-eligible responses missing tool_use."""
|
|
98
|
+
|
|
99
|
+
# Ratio threshold (recent_median / earlier_median < threshold → collapse)
|
|
100
|
+
length_collapse_ratio: float = 0.5
|
|
101
|
+
"""If recent half median is < 50% of earlier half median → collapse."""
|
|
102
|
+
|
|
103
|
+
# Minimum observations before detection fires
|
|
104
|
+
min_window_fill: int = 6
|
|
105
|
+
"""Don't trigger until at least this many observations in the window."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Presets
|
|
109
|
+
THRESHOLDS_LOW = DriftThresholds(
|
|
110
|
+
empty_response_rate=0.5,
|
|
111
|
+
length_collapse_ratio=0.3,
|
|
112
|
+
tool_silence_rate=0.8,
|
|
113
|
+
stop_anomaly_rate=0.6,
|
|
114
|
+
error_rate=0.4,
|
|
115
|
+
min_window_fill=10,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
THRESHOLDS_NORMAL = DriftThresholds() # defaults
|
|
119
|
+
|
|
120
|
+
THRESHOLDS_HIGH = DriftThresholds(
|
|
121
|
+
empty_response_rate=0.2,
|
|
122
|
+
length_collapse_ratio=0.7,
|
|
123
|
+
tool_silence_rate=0.5,
|
|
124
|
+
stop_anomaly_rate=0.3,
|
|
125
|
+
error_rate=0.15,
|
|
126
|
+
min_window_fill=4,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
SENSITIVITY_PRESETS: dict[str, DriftThresholds] = {
|
|
130
|
+
"low": THRESHOLDS_LOW,
|
|
131
|
+
"normal": THRESHOLDS_NORMAL,
|
|
132
|
+
"high": THRESHOLDS_HIGH,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Verdict
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class DriftVerdict:
|
|
143
|
+
"""Result of drift detection for a single provider window."""
|
|
144
|
+
|
|
145
|
+
drifted: bool
|
|
146
|
+
severity: Literal["none", "mild", "severe"]
|
|
147
|
+
signals: dict[str, float]
|
|
148
|
+
"""Signal name → computed value for observability."""
|
|
149
|
+
reason: str
|
|
150
|
+
"""Human-readable explanation of why drift was detected (empty if none)."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_NO_DRIFT = DriftVerdict(drifted=False, severity="none", signals={}, reason="")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Detector (pure function)
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def detect_drift(
|
|
162
|
+
window: list[ResponseObservation],
|
|
163
|
+
thresholds: DriftThresholds | None = None,
|
|
164
|
+
) -> DriftVerdict:
|
|
165
|
+
"""Analyze a window of observations and return a drift verdict.
|
|
166
|
+
|
|
167
|
+
Pure function — no I/O, no side effects. Safe to call from any context.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
window:
|
|
172
|
+
List of recent :class:`ResponseObservation` for a single provider,
|
|
173
|
+
ordered oldest-first.
|
|
174
|
+
thresholds:
|
|
175
|
+
Detection thresholds. Defaults to ``THRESHOLDS_NORMAL``.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
DriftVerdict with severity ``none`` / ``mild`` / ``severe``.
|
|
180
|
+
"""
|
|
181
|
+
if thresholds is None:
|
|
182
|
+
thresholds = THRESHOLDS_NORMAL
|
|
183
|
+
|
|
184
|
+
if len(window) < thresholds.min_window_fill:
|
|
185
|
+
return _NO_DRIFT
|
|
186
|
+
|
|
187
|
+
signals: dict[str, float] = {}
|
|
188
|
+
mild_flags: list[str] = []
|
|
189
|
+
severe_flags: list[str] = []
|
|
190
|
+
|
|
191
|
+
total = len(window)
|
|
192
|
+
|
|
193
|
+
# --- Signal 1: Empty response rate ---
|
|
194
|
+
empty_count = sum(1 for obs in window if obs.output_tokens == 0 and not obs.is_error)
|
|
195
|
+
non_error_count = sum(1 for obs in window if not obs.is_error)
|
|
196
|
+
if non_error_count > 0:
|
|
197
|
+
empty_rate = empty_count / non_error_count
|
|
198
|
+
signals["empty_response_rate"] = round(empty_rate, 3)
|
|
199
|
+
if empty_rate > thresholds.empty_response_rate:
|
|
200
|
+
severe_flags.append(f"empty_response_rate={empty_rate:.2f}")
|
|
201
|
+
|
|
202
|
+
# --- Signal 2: Length collapse (median comparison) ---
|
|
203
|
+
non_error_lengths = [obs.output_tokens for obs in window if not obs.is_error]
|
|
204
|
+
if len(non_error_lengths) >= 4:
|
|
205
|
+
mid = len(non_error_lengths) // 2
|
|
206
|
+
earlier_half = non_error_lengths[:mid]
|
|
207
|
+
recent_half = non_error_lengths[mid:]
|
|
208
|
+
earlier_median = statistics.median(earlier_half)
|
|
209
|
+
recent_median = statistics.median(recent_half)
|
|
210
|
+
if earlier_median > 0:
|
|
211
|
+
collapse_ratio = recent_median / earlier_median
|
|
212
|
+
signals["length_collapse_ratio"] = round(collapse_ratio, 3)
|
|
213
|
+
if collapse_ratio < thresholds.length_collapse_ratio:
|
|
214
|
+
severe_flags.append(
|
|
215
|
+
f"length_collapse={collapse_ratio:.2f}"
|
|
216
|
+
f" (recent_median={recent_median:.0f}, earlier={earlier_median:.0f})"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# --- Signal 3: Tool silence rate ---
|
|
220
|
+
tool_eligible = [obs for obs in window if obs.request_had_tools and not obs.is_error]
|
|
221
|
+
if len(tool_eligible) >= 3:
|
|
222
|
+
tool_silent_count = sum(1 for obs in tool_eligible if not obs.has_tool_use)
|
|
223
|
+
tool_silence_rate = tool_silent_count / len(tool_eligible)
|
|
224
|
+
signals["tool_silence_rate"] = round(tool_silence_rate, 3)
|
|
225
|
+
if tool_silence_rate > thresholds.tool_silence_rate:
|
|
226
|
+
mild_flags.append(f"tool_silence_rate={tool_silence_rate:.2f}")
|
|
227
|
+
|
|
228
|
+
# --- Signal 4: Stop reason anomaly rate ---
|
|
229
|
+
_EXPECTED_STOP = {"end_turn", "tool_use", "max_tokens"}
|
|
230
|
+
non_error_obs = [obs for obs in window if not obs.is_error]
|
|
231
|
+
if non_error_obs:
|
|
232
|
+
anomaly_count = sum(
|
|
233
|
+
1 for obs in non_error_obs if obs.stop_reason not in _EXPECTED_STOP
|
|
234
|
+
)
|
|
235
|
+
stop_anomaly_rate = anomaly_count / len(non_error_obs)
|
|
236
|
+
signals["stop_anomaly_rate"] = round(stop_anomaly_rate, 3)
|
|
237
|
+
if stop_anomaly_rate > thresholds.stop_anomaly_rate:
|
|
238
|
+
mild_flags.append(f"stop_anomaly_rate={stop_anomaly_rate:.2f}")
|
|
239
|
+
|
|
240
|
+
# --- Signal 5: Error rate ---
|
|
241
|
+
error_count = sum(1 for obs in window if obs.is_error)
|
|
242
|
+
error_rate = error_count / total
|
|
243
|
+
signals["error_rate"] = round(error_rate, 3)
|
|
244
|
+
if error_rate > thresholds.error_rate:
|
|
245
|
+
mild_flags.append(f"error_rate={error_rate:.2f}")
|
|
246
|
+
|
|
247
|
+
# --- Severity synthesis ---
|
|
248
|
+
if severe_flags:
|
|
249
|
+
severity: Literal["none", "mild", "severe"] = "severe"
|
|
250
|
+
elif len(mild_flags) >= 2:
|
|
251
|
+
severity = "severe"
|
|
252
|
+
elif mild_flags:
|
|
253
|
+
severity = "mild"
|
|
254
|
+
else:
|
|
255
|
+
return DriftVerdict(drifted=False, severity="none", signals=signals, reason="")
|
|
256
|
+
|
|
257
|
+
reason_parts = severe_flags + mild_flags
|
|
258
|
+
return DriftVerdict(
|
|
259
|
+
drifted=True,
|
|
260
|
+
severity=severity,
|
|
261
|
+
signals=signals,
|
|
262
|
+
reason=", ".join(reason_parts),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Window manager
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass
|
|
272
|
+
class DriftWindow:
|
|
273
|
+
"""Per-provider rolling window of response observations.
|
|
274
|
+
|
|
275
|
+
Thread-safe for the single-threaded async event loop (no locking needed
|
|
276
|
+
since all access happens on the same asyncio loop). If CodeRouter ever
|
|
277
|
+
goes multi-threaded, add a Lock.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
max_size: int = 20
|
|
281
|
+
_windows: dict[str, deque[ResponseObservation]] = field(default_factory=dict)
|
|
282
|
+
|
|
283
|
+
def record(self, obs: ResponseObservation) -> None:
|
|
284
|
+
"""Append an observation to the provider's window."""
|
|
285
|
+
dq = self._windows.get(obs.provider)
|
|
286
|
+
if dq is None:
|
|
287
|
+
dq = deque(maxlen=self.max_size)
|
|
288
|
+
self._windows[obs.provider] = dq
|
|
289
|
+
dq.append(obs)
|
|
290
|
+
|
|
291
|
+
def get_window(self, provider: str) -> list[ResponseObservation]:
|
|
292
|
+
"""Return a snapshot of the provider's window (oldest-first)."""
|
|
293
|
+
dq = self._windows.get(provider)
|
|
294
|
+
if dq is None:
|
|
295
|
+
return []
|
|
296
|
+
return list(dq)
|
|
297
|
+
|
|
298
|
+
def clear(self, provider: str) -> None:
|
|
299
|
+
"""Clear a provider's window (e.g. after recovery)."""
|
|
300
|
+
self._windows.pop(provider, None)
|
|
301
|
+
|
|
302
|
+
def clear_all(self) -> None:
|
|
303
|
+
"""Reset all windows."""
|
|
304
|
+
self._windows.clear()
|
|
305
|
+
|
|
306
|
+
def __len__(self) -> int:
|
|
307
|
+
"""Total observations across all providers."""
|
|
308
|
+
return sum(len(dq) for dq in self._windows.values())
|
|
@@ -49,6 +49,7 @@ _MODE_HEADER = "x-coderouter-mode"
|
|
|
49
49
|
_ANTHROPIC_VERSION_HEADER = "anthropic-version"
|
|
50
50
|
_ANTHROPIC_BETA_HEADER = "anthropic-beta"
|
|
51
51
|
_CTX_BUDGET_HEADER = "X-CodeRouter-Context-Budget"
|
|
52
|
+
_DRIFT_HEADER = "X-CodeRouter-Drift"
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
@router.post("/messages", response_model=None)
|
|
@@ -144,6 +145,12 @@ async def messages(
|
|
|
144
145
|
}
|
|
145
146
|
if ctx_budget_status:
|
|
146
147
|
stream_headers[_CTX_BUDGET_HEADER] = ctx_budget_status
|
|
148
|
+
# v2.0-G: drift header is set post-stream via a trailer-like
|
|
149
|
+
# mechanism — for streaming we cannot know the verdict before
|
|
150
|
+
# the first chunk ships. Instead, check pre-existing drift state.
|
|
151
|
+
drift_severity = engine.last_drift_severity
|
|
152
|
+
if drift_severity:
|
|
153
|
+
stream_headers[_DRIFT_HEADER] = drift_severity
|
|
147
154
|
return StreamingResponse(
|
|
148
155
|
_anthropic_sse_iterator(engine, anth_req),
|
|
149
156
|
media_type="text/event-stream",
|
|
@@ -167,10 +174,18 @@ async def messages(
|
|
|
167
174
|
detail=_tool_loop_break_detail(exc),
|
|
168
175
|
) from exc
|
|
169
176
|
|
|
177
|
+
# v2.0-G: collect drift header after engine dispatch.
|
|
178
|
+
drift_severity = engine.last_drift_severity
|
|
179
|
+
resp_headers: dict[str, str] = {}
|
|
170
180
|
if ctx_budget_status:
|
|
181
|
+
resp_headers[_CTX_BUDGET_HEADER] = ctx_budget_status
|
|
182
|
+
if drift_severity:
|
|
183
|
+
resp_headers[_DRIFT_HEADER] = drift_severity
|
|
184
|
+
|
|
185
|
+
if resp_headers:
|
|
171
186
|
return JSONResponse(
|
|
172
187
|
content=anth_resp.model_dump(exclude_none=True),
|
|
173
|
-
headers=
|
|
188
|
+
headers=resp_headers,
|
|
174
189
|
)
|
|
175
190
|
return anth_resp.model_dump(exclude_none=True)
|
|
176
191
|
|
|
@@ -233,17 +248,66 @@ async def _anthropic_sse_iterator(
|
|
|
233
248
|
"sse-midstream-error",
|
|
234
249
|
extra={"provider": exc.provider, "original": str(exc.original)},
|
|
235
250
|
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
|
|
252
|
+
# v2.0-H (L6): partial stitch surface mode — synthesize a graceful
|
|
253
|
+
# stream termination that delivers accumulated text to the client.
|
|
254
|
+
profile_name = anth_req.profile or engine.config.default_profile
|
|
255
|
+
partial_action = "off"
|
|
256
|
+
try:
|
|
257
|
+
chain_cfg = engine.config.profile_by_name(profile_name)
|
|
258
|
+
partial_action = chain_cfg.partial_stitch_action
|
|
259
|
+
except (KeyError, ValueError):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
if partial_action == "surface" and exc.partial_content:
|
|
263
|
+
# Emit message_delta with accumulated usage (signals stream end).
|
|
264
|
+
yield _format_anthropic_sse(AnthropicStreamEvent(
|
|
265
|
+
type="message_delta",
|
|
266
|
+
data={
|
|
267
|
+
"type": "message_delta",
|
|
268
|
+
"delta": {"stop_reason": None, "stop_sequence": None},
|
|
269
|
+
"usage": {"output_tokens": 0},
|
|
243
270
|
},
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
271
|
+
))
|
|
272
|
+
# Emit message_stop so the client sees a complete stream.
|
|
273
|
+
yield _format_anthropic_sse(AnthropicStreamEvent(
|
|
274
|
+
type="message_stop",
|
|
275
|
+
data={"type": "message_stop"},
|
|
276
|
+
))
|
|
277
|
+
# Emit coderouter_partial metadata event (client-optional).
|
|
278
|
+
yield _format_anthropic_sse(AnthropicStreamEvent(
|
|
279
|
+
type="coderouter_partial",
|
|
280
|
+
data={
|
|
281
|
+
"type": "coderouter_partial",
|
|
282
|
+
"partial_content": exc.partial_content,
|
|
283
|
+
"provider": exc.provider,
|
|
284
|
+
"reason": "mid_stream_failure",
|
|
285
|
+
"original_error": str(exc.original)[:200],
|
|
286
|
+
},
|
|
287
|
+
))
|
|
288
|
+
logger.info(
|
|
289
|
+
"partial-stitch-surfaced",
|
|
290
|
+
extra={
|
|
291
|
+
"provider": exc.provider,
|
|
292
|
+
"profile": profile_name,
|
|
293
|
+
"text_blocks": len(exc.partial_content),
|
|
294
|
+
"text_length": sum(
|
|
295
|
+
len(b.get("text", "")) for b in exc.partial_content
|
|
296
|
+
),
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
err_event = AnthropicStreamEvent(
|
|
301
|
+
type="error",
|
|
302
|
+
data={
|
|
303
|
+
"type": "error",
|
|
304
|
+
"error": {
|
|
305
|
+
"type": "api_error",
|
|
306
|
+
"message": str(exc),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
yield _format_anthropic_sse(err_event)
|
|
247
311
|
|
|
248
312
|
|
|
249
313
|
def _format_anthropic_sse(ev: AnthropicStreamEvent) -> str:
|
coderouter/ingress/app.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import os
|
|
6
7
|
from collections.abc import AsyncIterator
|
|
7
8
|
from contextlib import asynccontextmanager
|
|
@@ -71,7 +72,45 @@ def create_app(config_path: str | None = None) -> FastAPI:
|
|
|
71
72
|
# chronological order. Non-fatal — the chain still works, just
|
|
72
73
|
# potentially sub-optimally for the agentic harness.
|
|
73
74
|
check_claude_code_chain_suitability(config, logger=logger)
|
|
75
|
+
|
|
76
|
+
# v2.0-I: launch continuous probe background task if configured.
|
|
77
|
+
probe_task = None
|
|
78
|
+
shutdown_event = None
|
|
79
|
+
if config.continuous_probe == "active":
|
|
80
|
+
import asyncio
|
|
81
|
+
|
|
82
|
+
from coderouter.guards.continuous_probe import probe_loop
|
|
83
|
+
from coderouter.routing.capability import get_default_registry
|
|
84
|
+
|
|
85
|
+
shutdown_event = asyncio.Event()
|
|
86
|
+
probe_task = asyncio.create_task(
|
|
87
|
+
probe_loop(
|
|
88
|
+
config.providers,
|
|
89
|
+
record_fn=engine.backend_health.record_attempt,
|
|
90
|
+
interval_s=config.probe_interval_s,
|
|
91
|
+
timeout_s=config.probe_timeout_s,
|
|
92
|
+
probe_paid=config.probe_paid,
|
|
93
|
+
shutdown_event=shutdown_event,
|
|
94
|
+
registry=get_default_registry(),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
logger.info(
|
|
98
|
+
"continuous-probe-started",
|
|
99
|
+
extra={
|
|
100
|
+
"interval_s": config.probe_interval_s,
|
|
101
|
+
"probe_paid": config.probe_paid,
|
|
102
|
+
"providers": len(config.providers),
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
74
106
|
yield
|
|
107
|
+
|
|
108
|
+
# Graceful shutdown of probe task
|
|
109
|
+
if probe_task is not None and shutdown_event is not None:
|
|
110
|
+
shutdown_event.set()
|
|
111
|
+
with contextlib.suppress(Exception):
|
|
112
|
+
await probe_task
|
|
113
|
+
|
|
75
114
|
logger.info("coderouter-shutdown")
|
|
76
115
|
|
|
77
116
|
app = FastAPI(
|