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
coderouter/logging.py
CHANGED
|
@@ -1101,3 +1101,265 @@ def log_context_budget_trimmed(
|
|
|
1101
1101
|
"max_context_tokens": max_context_tokens,
|
|
1102
1102
|
}
|
|
1103
1103
|
logger.info("context-budget-trimmed", extra=payload)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
# ---------------------------------------------------------------------------
|
|
1107
|
+
# v2.0-G (L4): Drift detection logging
|
|
1108
|
+
# ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def log_drift_detected(
|
|
1112
|
+
logger: logging.Logger,
|
|
1113
|
+
*,
|
|
1114
|
+
provider: str,
|
|
1115
|
+
profile: str,
|
|
1116
|
+
severity: str,
|
|
1117
|
+
reason: str,
|
|
1118
|
+
action: str,
|
|
1119
|
+
signals: dict[str, float],
|
|
1120
|
+
) -> None:
|
|
1121
|
+
"""Emit a ``drift-detected`` warning line.
|
|
1122
|
+
|
|
1123
|
+
Fired when the drift detector finds quality degradation in the
|
|
1124
|
+
provider's rolling response window.
|
|
1125
|
+
"""
|
|
1126
|
+
logger.warning(
|
|
1127
|
+
"drift-detected",
|
|
1128
|
+
extra={
|
|
1129
|
+
"provider": provider,
|
|
1130
|
+
"profile": profile,
|
|
1131
|
+
"severity": severity,
|
|
1132
|
+
"reason": reason,
|
|
1133
|
+
"action": action,
|
|
1134
|
+
"signals": signals,
|
|
1135
|
+
},
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def log_drift_promoted(
|
|
1140
|
+
logger: logging.Logger,
|
|
1141
|
+
*,
|
|
1142
|
+
provider: str,
|
|
1143
|
+
profile: str,
|
|
1144
|
+
demoted_to_rank: int,
|
|
1145
|
+
cooldown_s: int,
|
|
1146
|
+
) -> None:
|
|
1147
|
+
"""Emit a ``drift-promoted`` info line.
|
|
1148
|
+
|
|
1149
|
+
Fired when a drifted provider is demoted in the chain and a
|
|
1150
|
+
different provider takes over as primary.
|
|
1151
|
+
"""
|
|
1152
|
+
logger.info(
|
|
1153
|
+
"drift-promoted",
|
|
1154
|
+
extra={
|
|
1155
|
+
"provider": provider,
|
|
1156
|
+
"profile": profile,
|
|
1157
|
+
"demoted_to_rank": demoted_to_rank,
|
|
1158
|
+
"cooldown_s": cooldown_s,
|
|
1159
|
+
},
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def log_drift_reload_attempted(
|
|
1164
|
+
logger: logging.Logger,
|
|
1165
|
+
*,
|
|
1166
|
+
provider: str,
|
|
1167
|
+
success: bool,
|
|
1168
|
+
) -> None:
|
|
1169
|
+
"""Emit a ``drift-reload-attempted`` info line.
|
|
1170
|
+
|
|
1171
|
+
Fired after attempting an Ollama KV cache flush (keep_alive=0).
|
|
1172
|
+
"""
|
|
1173
|
+
logger.info(
|
|
1174
|
+
"drift-reload-attempted",
|
|
1175
|
+
extra={
|
|
1176
|
+
"provider": provider,
|
|
1177
|
+
"success": success,
|
|
1178
|
+
},
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def log_drift_recovered(
|
|
1183
|
+
logger: logging.Logger,
|
|
1184
|
+
*,
|
|
1185
|
+
provider: str,
|
|
1186
|
+
profile: str,
|
|
1187
|
+
after_s: float,
|
|
1188
|
+
) -> None:
|
|
1189
|
+
"""Emit a ``drift-recovered`` info line.
|
|
1190
|
+
|
|
1191
|
+
Fired when a previously-drifted provider's cooldown expires and its
|
|
1192
|
+
rank is restored.
|
|
1193
|
+
"""
|
|
1194
|
+
logger.info(
|
|
1195
|
+
"drift-recovered",
|
|
1196
|
+
extra={
|
|
1197
|
+
"provider": provider,
|
|
1198
|
+
"profile": profile,
|
|
1199
|
+
"after_s": round(after_s, 1),
|
|
1200
|
+
},
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
# ---------------------------------------------------------------------------
|
|
1205
|
+
# v2.0-H (L6): Partial stitch surfaced logging
|
|
1206
|
+
# ---------------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def log_partial_stitch_surfaced(
|
|
1210
|
+
logger: logging.Logger,
|
|
1211
|
+
*,
|
|
1212
|
+
provider: str,
|
|
1213
|
+
profile: str,
|
|
1214
|
+
text_blocks: int,
|
|
1215
|
+
text_length: int,
|
|
1216
|
+
) -> None:
|
|
1217
|
+
"""Emit a ``partial-stitch-surfaced`` info line.
|
|
1218
|
+
|
|
1219
|
+
Fired when a mid-stream failure is gracefully terminated with partial
|
|
1220
|
+
content delivered to the client (partial_stitch_action=surface).
|
|
1221
|
+
"""
|
|
1222
|
+
logger.info(
|
|
1223
|
+
"partial-stitch-surfaced",
|
|
1224
|
+
extra={
|
|
1225
|
+
"provider": provider,
|
|
1226
|
+
"profile": profile,
|
|
1227
|
+
"text_blocks": text_blocks,
|
|
1228
|
+
"text_length": text_length,
|
|
1229
|
+
},
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
# ---------------------------------------------------------------------------
|
|
1234
|
+
# v2.0-I: Continuous probe log shapes
|
|
1235
|
+
#
|
|
1236
|
+
# Two event lanes mirror the backend-health triplet:
|
|
1237
|
+
# * ``probe-completed`` — info: a single provider probe finished
|
|
1238
|
+
# (success or failure). Quiet in normal
|
|
1239
|
+
# operation; operators grep for these to
|
|
1240
|
+
# diagnose individual backend issues.
|
|
1241
|
+
# * ``probe-round-completed`` — info: one full sweep across all probed
|
|
1242
|
+
# providers finished. Summary counter for
|
|
1243
|
+
# dashboards to render "probes/min" and
|
|
1244
|
+
# "failure ratio per round".
|
|
1245
|
+
# ---------------------------------------------------------------------------
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
class ProbeCompletedPayload(TypedDict):
|
|
1249
|
+
"""Structured shape of the ``probe-completed`` log record.
|
|
1250
|
+
|
|
1251
|
+
Fields
|
|
1252
|
+
provider: the provider that was probed.
|
|
1253
|
+
success: whether the 1-token probe request succeeded.
|
|
1254
|
+
latency_ms: round-trip time of the probe in milliseconds.
|
|
1255
|
+
error: short error message (truncated) when success=False;
|
|
1256
|
+
None on success.
|
|
1257
|
+
model_name: model name extracted from the probe response,
|
|
1258
|
+
if available.
|
|
1259
|
+
"""
|
|
1260
|
+
|
|
1261
|
+
provider: str
|
|
1262
|
+
success: bool
|
|
1263
|
+
latency_ms: float
|
|
1264
|
+
error: str | None
|
|
1265
|
+
model_name: str | None
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
class ProbeRoundCompletedPayload(TypedDict):
|
|
1269
|
+
"""Structured shape of the ``probe-round-completed`` log record.
|
|
1270
|
+
|
|
1271
|
+
Fields
|
|
1272
|
+
providers_probed: number of providers probed in this round.
|
|
1273
|
+
failures: number of probe failures in this round.
|
|
1274
|
+
"""
|
|
1275
|
+
|
|
1276
|
+
providers_probed: int
|
|
1277
|
+
failures: int
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def log_probe_completed(
|
|
1281
|
+
logger: logging.Logger,
|
|
1282
|
+
*,
|
|
1283
|
+
provider: str,
|
|
1284
|
+
success: bool,
|
|
1285
|
+
latency_ms: float,
|
|
1286
|
+
error: str | None = None,
|
|
1287
|
+
model_name: str | None = None,
|
|
1288
|
+
) -> None:
|
|
1289
|
+
"""Emit a ``probe-completed`` info line for one provider probe.
|
|
1290
|
+
|
|
1291
|
+
Single chokepoint mirroring :func:`log_capability_degraded`. Info
|
|
1292
|
+
level — individual probes are diagnostic noise at normal operation;
|
|
1293
|
+
operators filter on ``success=False`` for alerting.
|
|
1294
|
+
"""
|
|
1295
|
+
payload: ProbeCompletedPayload = {
|
|
1296
|
+
"provider": provider,
|
|
1297
|
+
"success": success,
|
|
1298
|
+
"latency_ms": round(latency_ms, 1),
|
|
1299
|
+
"error": error,
|
|
1300
|
+
"model_name": model_name,
|
|
1301
|
+
}
|
|
1302
|
+
logger.info("probe-completed", extra=payload)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def log_probe_round_completed(
|
|
1306
|
+
logger: logging.Logger,
|
|
1307
|
+
*,
|
|
1308
|
+
providers_probed: int,
|
|
1309
|
+
failures: int,
|
|
1310
|
+
) -> None:
|
|
1311
|
+
"""Emit a ``probe-round-completed`` info line summarizing one sweep.
|
|
1312
|
+
|
|
1313
|
+
Info level — fires once per probe interval (default 60s). Dashboards
|
|
1314
|
+
render this as "probes/min" and "failure ratio" without needing to
|
|
1315
|
+
aggregate the individual ``probe-completed`` lines.
|
|
1316
|
+
"""
|
|
1317
|
+
payload: ProbeRoundCompletedPayload = {
|
|
1318
|
+
"providers_probed": providers_probed,
|
|
1319
|
+
"failures": failures,
|
|
1320
|
+
}
|
|
1321
|
+
logger.info("probe-round-completed", extra=payload)
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
class ProbeCapabilitiesDriftPayload(TypedDict):
|
|
1325
|
+
"""Structured shape of the ``probe-capabilities-drift`` log record.
|
|
1326
|
+
|
|
1327
|
+
Fields
|
|
1328
|
+
provider: the provider whose probe response model mismatched.
|
|
1329
|
+
configured_model: the model string in providers.yaml.
|
|
1330
|
+
observed_model: the model name returned by the probe response.
|
|
1331
|
+
in_registry: whether the observed model has an entry in the
|
|
1332
|
+
capability registry.
|
|
1333
|
+
"""
|
|
1334
|
+
|
|
1335
|
+
provider: str
|
|
1336
|
+
configured_model: str
|
|
1337
|
+
observed_model: str
|
|
1338
|
+
in_registry: bool
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def log_probe_capabilities_drift(
|
|
1342
|
+
logger: logging.Logger,
|
|
1343
|
+
*,
|
|
1344
|
+
provider: str,
|
|
1345
|
+
configured_model: str,
|
|
1346
|
+
observed_model: str,
|
|
1347
|
+
in_registry: bool,
|
|
1348
|
+
) -> None:
|
|
1349
|
+
"""Emit a ``probe-capabilities-drift`` warning line.
|
|
1350
|
+
|
|
1351
|
+
Fired when the continuous probe detects that the model name returned
|
|
1352
|
+
by the provider differs from the configured model. This can indicate
|
|
1353
|
+
a model swap (Ollama auto-updated), a misconfiguration, or a new
|
|
1354
|
+
model that the capability registry doesn't know about yet.
|
|
1355
|
+
|
|
1356
|
+
Warn level — model drift can cause subtle behavior changes (e.g. a
|
|
1357
|
+
model that supports thinking being replaced by one that doesn't).
|
|
1358
|
+
"""
|
|
1359
|
+
payload: ProbeCapabilitiesDriftPayload = {
|
|
1360
|
+
"provider": provider,
|
|
1361
|
+
"configured_model": configured_model,
|
|
1362
|
+
"observed_model": observed_model,
|
|
1363
|
+
"in_registry": in_registry,
|
|
1364
|
+
}
|
|
1365
|
+
logger.warning("probe-capabilities-drift", extra=payload)
|
coderouter/metrics/collector.py
CHANGED
|
@@ -48,6 +48,13 @@ Event inventory (dispatch table in :meth:`MetricsCollector._dispatch`)
|
|
|
48
48
|
+ per-profile counter + latest_ratio gauge
|
|
49
49
|
``context-budget-trimmed`` (v2.0-F)→ ``context_budget_trims_total``
|
|
50
50
|
+ per-profile counter
|
|
51
|
+
``drift-detected`` (v2.0-G) → ``drift_detected_total`` + per-provider
|
|
52
|
+
``drift-promoted`` (v2.0-G) → ``drift_promoted_total``
|
|
53
|
+
``drift-reload-attempted`` → ``drift_reload_total`` / success
|
|
54
|
+
``partial-stitch-surfaced`` → ``partial_stitch_surfaced_total`` (v2.0-H)
|
|
55
|
+
``probe-completed`` (v2.0-I) → ``probe_total`` / ``probe_success`` / ``probe_failure``
|
|
56
|
+
+ per-provider latency gauge
|
|
57
|
+
``probe-round-completed`` → ``probe_rounds_total`` (v2.0-I)
|
|
51
58
|
``coderouter-startup`` → ``startup_info`` (stored for the UI header)
|
|
52
59
|
|
|
53
60
|
Unrecognized events are ignored (forward-compat: adding a new log
|
|
@@ -194,6 +201,29 @@ class MetricsCollector(logging.Handler):
|
|
|
194
201
|
self._context_budget_trims_by_profile: Counter[str] = Counter()
|
|
195
202
|
self._context_budget_latest_ratio: dict[str, float] = {}
|
|
196
203
|
|
|
204
|
+
# v2.0-G (L4): drift detection counters. Per-provider counts of
|
|
205
|
+
# drift events, promotions (rank demotions), and reload attempts.
|
|
206
|
+
self._drift_detected_total: int = 0
|
|
207
|
+
self._drift_detected_by_provider: Counter[str] = Counter()
|
|
208
|
+
self._drift_promoted_total: int = 0
|
|
209
|
+
self._drift_reload_total: int = 0
|
|
210
|
+
self._drift_reload_success_total: int = 0
|
|
211
|
+
|
|
212
|
+
# v2.0-H (L6): partial stitch surfaced counter. Tracks how often
|
|
213
|
+
# the mid-stream failure recovery delivered partial content to the
|
|
214
|
+
# client instead of a generic error event.
|
|
215
|
+
self._partial_stitch_surfaced_total: int = 0
|
|
216
|
+
|
|
217
|
+
# v2.0-I: continuous probe counters. Per-provider probe attempts
|
|
218
|
+
# and outcomes, plus a round counter for the dashboard's
|
|
219
|
+
# "probes/min" panel.
|
|
220
|
+
self._probe_total: Counter[str] = Counter() # per-provider total probes
|
|
221
|
+
self._probe_success: Counter[str] = Counter() # per-provider successes
|
|
222
|
+
self._probe_failure: Counter[str] = Counter() # per-provider failures
|
|
223
|
+
self._probe_rounds_total: int = 0
|
|
224
|
+
self._probe_latency_ms: dict[str, float] = {} # per-provider latest
|
|
225
|
+
self._probe_drift_detected: Counter[str] = Counter() # per-provider drift
|
|
226
|
+
|
|
197
227
|
# Last-error snapshot per provider (overwrites previous). Enables the
|
|
198
228
|
# dashboard's "last error" column without scanning the ring.
|
|
199
229
|
self._last_error: dict[str, dict[str, Any]] = {}
|
|
@@ -372,6 +402,43 @@ class MetricsCollector(logging.Handler):
|
|
|
372
402
|
profile = _str(extras.get("profile"))
|
|
373
403
|
self._context_budget_trims_total += 1
|
|
374
404
|
self._context_budget_trims_by_profile[profile] += 1
|
|
405
|
+
elif event == "drift-detected":
|
|
406
|
+
# v2.0-G (L4): drift detection fired.
|
|
407
|
+
provider = _str(extras.get("provider"))
|
|
408
|
+
self._drift_detected_total += 1
|
|
409
|
+
self._drift_detected_by_provider[provider] += 1
|
|
410
|
+
self._push_recent(event, extras, record)
|
|
411
|
+
elif event == "drift-promoted":
|
|
412
|
+
# v2.0-G (L4): drifted provider was demoted.
|
|
413
|
+
self._drift_promoted_total += 1
|
|
414
|
+
self._push_recent(event, extras, record)
|
|
415
|
+
elif event == "drift-reload-attempted":
|
|
416
|
+
# v2.0-G (L4): Ollama KV cache flush attempted.
|
|
417
|
+
self._drift_reload_total += 1
|
|
418
|
+
if extras.get("success"):
|
|
419
|
+
self._drift_reload_success_total += 1
|
|
420
|
+
elif event == "partial-stitch-surfaced":
|
|
421
|
+
# v2.0-H (L6): mid-stream failure gracefully surfaced.
|
|
422
|
+
self._partial_stitch_surfaced_total += 1
|
|
423
|
+
self._push_recent(event, extras, record)
|
|
424
|
+
elif event == "probe-completed":
|
|
425
|
+
# v2.0-I: per-provider probe outcome.
|
|
426
|
+
provider = _str(extras.get("provider"))
|
|
427
|
+
self._probe_total[provider] += 1
|
|
428
|
+
if extras.get("success"):
|
|
429
|
+
self._probe_success[provider] += 1
|
|
430
|
+
else:
|
|
431
|
+
self._probe_failure[provider] += 1
|
|
432
|
+
latency_raw = extras.get("latency_ms")
|
|
433
|
+
if isinstance(latency_raw, int | float):
|
|
434
|
+
self._probe_latency_ms[provider] = float(latency_raw)
|
|
435
|
+
elif event == "probe-round-completed":
|
|
436
|
+
# v2.0-I: round counter for the dashboard.
|
|
437
|
+
self._probe_rounds_total += 1
|
|
438
|
+
elif event == "probe-capabilities-drift":
|
|
439
|
+
# v2.0-I: model mismatch detected by probe.
|
|
440
|
+
provider = _str(extras.get("provider"))
|
|
441
|
+
self._probe_drift_detected[provider] += 1
|
|
375
442
|
elif event == "coderouter-startup":
|
|
376
443
|
# Snapshot a subset — startup payload contains lists that are
|
|
377
444
|
# safe to surface to /metrics.json. Version / providers /
|
|
@@ -534,6 +601,23 @@ class MetricsCollector(logging.Handler):
|
|
|
534
601
|
"context_budget_latest_ratio": dict(
|
|
535
602
|
self._context_budget_latest_ratio
|
|
536
603
|
),
|
|
604
|
+
# v2.0-G (L4): drift detection aggregate counters.
|
|
605
|
+
"drift_detected_total": self._drift_detected_total,
|
|
606
|
+
"drift_detected_by_provider": dict(
|
|
607
|
+
self._drift_detected_by_provider
|
|
608
|
+
),
|
|
609
|
+
"drift_promoted_total": self._drift_promoted_total,
|
|
610
|
+
"drift_reload_total": self._drift_reload_total,
|
|
611
|
+
"drift_reload_success_total": self._drift_reload_success_total,
|
|
612
|
+
# v2.0-H (L6): partial stitch surfaced.
|
|
613
|
+
"partial_stitch_surfaced_total": self._partial_stitch_surfaced_total,
|
|
614
|
+
# v2.0-I: continuous probe counters.
|
|
615
|
+
"probe_total": dict(self._probe_total),
|
|
616
|
+
"probe_success": dict(self._probe_success),
|
|
617
|
+
"probe_failure": dict(self._probe_failure),
|
|
618
|
+
"probe_rounds_total": self._probe_rounds_total,
|
|
619
|
+
"probe_latency_ms": dict(self._probe_latency_ms),
|
|
620
|
+
"probe_drift_detected": dict(self._probe_drift_detected),
|
|
537
621
|
},
|
|
538
622
|
"providers": provider_rows,
|
|
539
623
|
"recent": list(self._recent),
|
|
@@ -578,6 +662,15 @@ class MetricsCollector(logging.Handler):
|
|
|
578
662
|
self._cost_savings_usd.clear()
|
|
579
663
|
self._cost_total_usd_aggregate = 0.0
|
|
580
664
|
self._cost_savings_usd_aggregate = 0.0
|
|
665
|
+
# v2.0-H (L6)
|
|
666
|
+
self._partial_stitch_surfaced_total = 0
|
|
667
|
+
# v2.0-I
|
|
668
|
+
self._probe_total.clear()
|
|
669
|
+
self._probe_success.clear()
|
|
670
|
+
self._probe_failure.clear()
|
|
671
|
+
self._probe_rounds_total = 0
|
|
672
|
+
self._probe_latency_ms.clear()
|
|
673
|
+
self._probe_drift_detected.clear()
|
|
581
674
|
# v2.0-F (L1)
|
|
582
675
|
self._context_budget_warnings_total = 0
|
|
583
676
|
self._context_budget_trims_total = 0
|
coderouter/metrics/prometheus.py
CHANGED
|
@@ -381,6 +381,147 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
|
|
|
381
381
|
samples=ratio_samples,
|
|
382
382
|
)
|
|
383
383
|
)
|
|
384
|
+
|
|
385
|
+
# ---- v2.0-G (L4): drift detection metrics ------------------------------
|
|
386
|
+
lines.extend(
|
|
387
|
+
_counter(
|
|
388
|
+
name="drift_detected_total",
|
|
389
|
+
help_text=(
|
|
390
|
+
"Drift detection events (quality degradation detected), "
|
|
391
|
+
"by provider."
|
|
392
|
+
),
|
|
393
|
+
samples=[
|
|
394
|
+
((("provider", p),), v)
|
|
395
|
+
for p, v in sorted(
|
|
396
|
+
counters.get("drift_detected_by_provider", {}).items()
|
|
397
|
+
)
|
|
398
|
+
],
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
drift_promoted = counters.get("drift_promoted_total", 0)
|
|
402
|
+
if drift_promoted:
|
|
403
|
+
lines.extend(
|
|
404
|
+
_counter(
|
|
405
|
+
name="drift_promoted_total",
|
|
406
|
+
help_text=(
|
|
407
|
+
"Number of times a drifted provider was demoted "
|
|
408
|
+
"(promote/reload action fired)."
|
|
409
|
+
),
|
|
410
|
+
samples=[(((),), drift_promoted)],
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
drift_reload = counters.get("drift_reload_total", 0)
|
|
414
|
+
if drift_reload:
|
|
415
|
+
lines.extend(
|
|
416
|
+
_counter(
|
|
417
|
+
name="drift_reload_total",
|
|
418
|
+
help_text="Ollama KV cache flush attempts (reload action).",
|
|
419
|
+
samples=[(((),), drift_reload)],
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
drift_reload_ok = counters.get("drift_reload_success_total", 0)
|
|
423
|
+
if drift_reload_ok:
|
|
424
|
+
lines.extend(
|
|
425
|
+
_counter(
|
|
426
|
+
name="drift_reload_success_total",
|
|
427
|
+
help_text="Successful Ollama KV cache flush attempts.",
|
|
428
|
+
samples=[(((),), drift_reload_ok)],
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# ---- v2.0-H (L6): partial stitch surfaced metric -----------------------
|
|
433
|
+
partial_stitch = counters.get("partial_stitch_surfaced_total", 0)
|
|
434
|
+
if partial_stitch:
|
|
435
|
+
lines.extend(
|
|
436
|
+
_counter(
|
|
437
|
+
name="partial_stitch_surfaced_total",
|
|
438
|
+
help_text=(
|
|
439
|
+
"Mid-stream failures where partial content was delivered "
|
|
440
|
+
"to the client (partial_stitch_action=surface)."
|
|
441
|
+
),
|
|
442
|
+
samples=[((), partial_stitch)],
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# ---- v2.0-I: continuous probe metrics ------------------------------------
|
|
447
|
+
probe_total_samples: list[tuple[tuple[tuple[str, str], ...], int]] = []
|
|
448
|
+
for provider, count in sorted(counters.get("probe_total", {}).items()):
|
|
449
|
+
probe_total_samples.append(
|
|
450
|
+
((("provider", provider),), count)
|
|
451
|
+
)
|
|
452
|
+
if probe_total_samples:
|
|
453
|
+
lines.extend(
|
|
454
|
+
_counter(
|
|
455
|
+
name="probe_total",
|
|
456
|
+
help_text=(
|
|
457
|
+
"Continuous health probe attempts, by provider. "
|
|
458
|
+
"Each probe sends a 1-token completion to verify "
|
|
459
|
+
"the full model pipeline."
|
|
460
|
+
),
|
|
461
|
+
samples=probe_total_samples,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
probe_outcome_samples: list[tuple[tuple[tuple[str, str], ...], int]] = []
|
|
465
|
+
for provider, count in sorted(counters.get("probe_success", {}).items()):
|
|
466
|
+
probe_outcome_samples.append(
|
|
467
|
+
((("provider", provider), ("outcome", "success")), count)
|
|
468
|
+
)
|
|
469
|
+
for provider, count in sorted(counters.get("probe_failure", {}).items()):
|
|
470
|
+
probe_outcome_samples.append(
|
|
471
|
+
((("provider", provider), ("outcome", "failure")), count)
|
|
472
|
+
)
|
|
473
|
+
if probe_outcome_samples:
|
|
474
|
+
lines.extend(
|
|
475
|
+
_counter(
|
|
476
|
+
name="probe_outcomes_total",
|
|
477
|
+
help_text=(
|
|
478
|
+
"Continuous probe outcomes by provider and result "
|
|
479
|
+
"(success | failure)."
|
|
480
|
+
),
|
|
481
|
+
samples=probe_outcome_samples,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
probe_rounds = counters.get("probe_rounds_total", 0)
|
|
485
|
+
if probe_rounds:
|
|
486
|
+
lines.extend(
|
|
487
|
+
_counter(
|
|
488
|
+
name="probe_rounds_total",
|
|
489
|
+
help_text="Completed probe sweep rounds (one round probes all eligible providers).",
|
|
490
|
+
samples=[((), probe_rounds)],
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
probe_drift_samples: list[tuple[tuple[tuple[str, str], ...], int]] = [
|
|
494
|
+
((("provider", p),), v)
|
|
495
|
+
for p, v in sorted(counters.get("probe_drift_detected", {}).items())
|
|
496
|
+
]
|
|
497
|
+
if probe_drift_samples:
|
|
498
|
+
lines.extend(
|
|
499
|
+
_counter(
|
|
500
|
+
name="probe_drift_detected_total",
|
|
501
|
+
help_text=(
|
|
502
|
+
"Model-name mismatches detected by continuous probing "
|
|
503
|
+
"(configured model != response model), by provider."
|
|
504
|
+
),
|
|
505
|
+
samples=probe_drift_samples,
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
# Gauge: latest probe latency per provider (ms).
|
|
509
|
+
latency_samples: list[tuple[tuple[tuple[str, str], ...], float]] = [
|
|
510
|
+
((("provider", p),), round(v, 1))
|
|
511
|
+
for p, v in sorted(counters.get("probe_latency_ms", {}).items())
|
|
512
|
+
]
|
|
513
|
+
if latency_samples:
|
|
514
|
+
lines.extend(
|
|
515
|
+
_gauge_float(
|
|
516
|
+
name="probe_latency_ms",
|
|
517
|
+
help_text=(
|
|
518
|
+
"Latest probe round-trip latency in milliseconds, by "
|
|
519
|
+
"provider. Gauge (most recent value, not cumulative)."
|
|
520
|
+
),
|
|
521
|
+
samples=latency_samples,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
|
|
384
525
|
return "\n".join(lines) + "\n"
|
|
385
526
|
|
|
386
527
|
|
coderouter/routing/adaptive.py
CHANGED
|
@@ -234,6 +234,29 @@ class AdaptiveAdjuster:
|
|
|
234
234
|
while entry.observations and entry.observations[0].ts_monotonic < cutoff:
|
|
235
235
|
entry.observations.popleft()
|
|
236
236
|
|
|
237
|
+
def demote(self, provider: str, *, steps: int = 2) -> None:
|
|
238
|
+
"""Force-demote a provider by injecting synthetic failure observations.
|
|
239
|
+
|
|
240
|
+
Used by v2.0-G drift detection to push a provider's error rate above
|
|
241
|
+
the demotion threshold (``ERROR_RATE_DEMOTE_THRESHOLD``). Each step
|
|
242
|
+
injects one synthetic failure observation, so ``steps=2`` guarantees
|
|
243
|
+
the provider will be ranked lower on the next ``compute_effective_order``.
|
|
244
|
+
|
|
245
|
+
The injected observations carry no latency signal (``latency_ms=None``)
|
|
246
|
+
and expire naturally after ``ROLLING_WINDOW_S`` seconds.
|
|
247
|
+
"""
|
|
248
|
+
ts = time.monotonic()
|
|
249
|
+
with self._lock:
|
|
250
|
+
entry = self._state.setdefault(provider, _AdjusterState())
|
|
251
|
+
for _ in range(steps):
|
|
252
|
+
entry.observations.append(
|
|
253
|
+
_ProviderObservation(
|
|
254
|
+
ts_monotonic=ts,
|
|
255
|
+
latency_ms=None,
|
|
256
|
+
success=False,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
237
260
|
# ------------------------------------------------------------------
|
|
238
261
|
# Stats
|
|
239
262
|
# ------------------------------------------------------------------
|