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.
@@ -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/rollback.py ADDED
@@ -0,0 +1,217 @@
1
+ """Restore the files CodeRouter rewrote (v2.14.0).
2
+
3
+ Two commands in this codebase mutate files the operator owns:
4
+ ``doctor --apply`` (providers.yaml, model-capabilities.yaml) and
5
+ ``vscode-init`` (.vscode/settings.json, .envrc). Both already wrote a
6
+ ``.bak`` sibling before touching anything — and then left the operator to
7
+ find it and copy it back by hand. The stated reasoning was that anyone
8
+ using git already has versioned history. That is true right up until the
9
+ file being rewritten is ``~/.coderouter/providers.yaml``, which is not in
10
+ anybody's repo, or until ``--force`` removes a line the operator wanted
11
+ (v2.11's ``.envrc`` incident, H-11).
12
+
13
+ So this module supplies the missing half: a way to get the old bytes back.
14
+
15
+ Swap, not overwrite
16
+ -------------------
17
+ Restoring does not discard the current file — it writes the current
18
+ contents back out as the new ``.bak`` before putting the old bytes in
19
+ place. Rollback therefore toggles: run it twice and you are where you
20
+ started. A one-way restore turns "I wanted to compare" into "I destroyed
21
+ the newer version", which is the same class of mistake this module exists
22
+ to undo.
23
+
24
+ Metadata
25
+ --------
26
+ Restores go through :func:`shutil.copy2`, so mode and mtime survive the
27
+ round trip. That matters for ``.envrc``, which is deliberately created
28
+ 0600 because it carries ``ANTHROPIC_AUTH_TOKEN``; a restore that reset it
29
+ to the default umask would quietly widen a credential file.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import shutil
36
+ from collections.abc import Iterable, Sequence
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+
40
+ __all__ = [
41
+ "BACKUP_SUFFIX",
42
+ "RestoreOutcome",
43
+ "backup_for",
44
+ "discover_managed_files",
45
+ "exit_code_for_rollback",
46
+ "format_rollback_report",
47
+ "restore_file",
48
+ "restore_many",
49
+ ]
50
+
51
+ # Both writers use the same convention: the backup is the original name
52
+ # with ``.bak`` appended (``providers.yaml.bak``, ``settings.json.bak``),
53
+ # single generation, overwritten on each apply.
54
+ BACKUP_SUFFIX = ".bak"
55
+
56
+
57
+ def backup_for(path: str | os.PathLike[str]) -> Path:
58
+ """Return the ``.bak`` sibling of ``path``."""
59
+ p = Path(path)
60
+ return p.with_name(p.name + BACKUP_SUFFIX)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class RestoreOutcome:
65
+ """What happened (or would happen) for one managed file.
66
+
67
+ ``status`` is one of ``restored`` / ``would-restore`` / ``no-backup`` /
68
+ ``identical`` / ``failed``. ``identical`` is reported rather than
69
+ silently skipped: an operator who runs rollback and sees nothing wants
70
+ to know the difference between "there was no backup" and "the backup
71
+ already matches what is on disk".
72
+ """
73
+
74
+ path: Path
75
+ backup: Path
76
+ status: str
77
+ detail: str = ""
78
+
79
+
80
+ def discover_managed_files(
81
+ *,
82
+ config_path: str | os.PathLike[str] | None = None,
83
+ workspace: str | os.PathLike[str] | None = None,
84
+ ) -> list[Path]:
85
+ """List the files CodeRouter is known to rewrite, in restore order.
86
+
87
+ ``config_path`` should be the path the loader actually read — pass it
88
+ in rather than re-deriving it here, for the same reason
89
+ ``doctor --apply`` delegates to ``resolve_config_path``: a second copy
90
+ of the search order is exactly how the v2.13.0 CWD opt-in produced a
91
+ file mismatch.
92
+
93
+ The user-layer ``model-capabilities.yaml`` is always included because
94
+ ``doctor --apply`` writes there regardless of which config was loaded.
95
+ """
96
+ paths: list[Path] = []
97
+ if config_path:
98
+ paths.append(Path(config_path).expanduser())
99
+ paths.append(Path.home() / ".coderouter" / "model-capabilities.yaml")
100
+ if workspace:
101
+ root = Path(workspace).expanduser()
102
+ paths.append(root / ".vscode" / "settings.json")
103
+ paths.append(root / ".envrc")
104
+ # Preserve order while dropping duplicates (config_path can legitimately
105
+ # be the user-layer path in some setups).
106
+ seen: set[Path] = set()
107
+ unique: list[Path] = []
108
+ for p in paths:
109
+ if p not in seen:
110
+ seen.add(p)
111
+ unique.append(p)
112
+ return unique
113
+
114
+
115
+ def restore_file(
116
+ path: str | os.PathLike[str], *, dry_run: bool = False
117
+ ) -> RestoreOutcome:
118
+ """Swap ``path`` with its ``.bak`` sibling.
119
+
120
+ The current contents become the new backup, so the operation is its
121
+ own inverse. A missing backup is not an error — it just means this
122
+ file was never rewritten by us.
123
+ """
124
+ target = Path(path).expanduser()
125
+ backup = backup_for(target)
126
+
127
+ if not backup.is_file():
128
+ return RestoreOutcome(target, backup, "no-backup", "no .bak sibling found")
129
+
130
+ backup_bytes = backup.read_bytes()
131
+ current_bytes = target.read_bytes() if target.is_file() else None
132
+ if current_bytes == backup_bytes:
133
+ return RestoreOutcome(
134
+ target, backup, "identical", "the backup already matches the live file"
135
+ )
136
+
137
+ if dry_run:
138
+ return RestoreOutcome(
139
+ target,
140
+ backup,
141
+ "would-restore",
142
+ f"{len(backup_bytes)} bytes would replace "
143
+ f"{'nothing (file absent)' if current_bytes is None else f'{len(current_bytes)} bytes'}",
144
+ )
145
+
146
+ try:
147
+ # Write the current contents out as the new backup FIRST. If the
148
+ # copy of the old bytes then fails, the operator has lost nothing:
149
+ # both versions are still on disk.
150
+ if current_bytes is not None:
151
+ tmp = backup.with_name(backup.name + ".swap")
152
+ shutil.copy2(target, tmp)
153
+ shutil.copy2(backup, target)
154
+ os.replace(tmp, backup)
155
+ else:
156
+ target.parent.mkdir(parents=True, exist_ok=True)
157
+ shutil.copy2(backup, target)
158
+ backup.unlink()
159
+ except OSError as exc:
160
+ return RestoreOutcome(target, backup, "failed", str(exc))
161
+
162
+ return RestoreOutcome(
163
+ target,
164
+ backup,
165
+ "restored",
166
+ "current contents kept as the new .bak — re-run to toggle back",
167
+ )
168
+
169
+
170
+ def restore_many(
171
+ paths: Iterable[str | os.PathLike[str]], *, dry_run: bool = False
172
+ ) -> list[RestoreOutcome]:
173
+ """Restore each path, continuing past files that have no backup."""
174
+ return [restore_file(p, dry_run=dry_run) for p in paths]
175
+
176
+
177
+ _STATUS_GLYPH = {
178
+ "restored": "RESTORED",
179
+ "would-restore": "WOULD ",
180
+ "identical": "SAME ",
181
+ "no-backup": "SKIP ",
182
+ "failed": "FAILED ",
183
+ }
184
+
185
+
186
+ def format_rollback_report(
187
+ outcomes: Sequence[RestoreOutcome], *, dry_run: bool
188
+ ) -> str:
189
+ """Render outcomes for the terminal."""
190
+ header = "coderouter rollback" + (" --dry-run" if dry_run else "")
191
+ lines = ["", header, "=" * 52]
192
+ for outcome in outcomes:
193
+ glyph = _STATUS_GLYPH.get(outcome.status, "???? ")
194
+ lines.append(f"[{glyph}] {outcome.path}")
195
+ if outcome.detail:
196
+ lines.append(f" {outcome.detail}")
197
+ restored = sum(o.status in {"restored", "would-restore"} for o in outcomes)
198
+ failed = sum(o.status == "failed" for o in outcomes)
199
+ lines.append("-" * 52)
200
+ verb = "would restore" if dry_run else "restored"
201
+ lines.append(f"{verb}: {restored} file(s); failed: {failed}")
202
+ lines.append("")
203
+ return "\n".join(lines)
204
+
205
+
206
+ def exit_code_for_rollback(outcomes: Sequence[RestoreOutcome]) -> int:
207
+ """0 when something was (or would be) restored and nothing failed.
208
+
209
+ A run where every file reports ``no-backup`` exits 2 — "nothing to do"
210
+ is a different answer from "done", and a wrapper script should be able
211
+ to tell them apart without parsing text.
212
+ """
213
+ if any(o.status == "failed" for o in outcomes):
214
+ return 1
215
+ if any(o.status in {"restored", "would-restore"} for o in outcomes):
216
+ return 0
217
+ return 2
@@ -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
  ]