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/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)
@@ -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
@@ -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
 
@@ -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
  # ------------------------------------------------------------------