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.
@@ -44,9 +44,14 @@ from coderouter.adapters.base import (
44
44
  ChatResponse,
45
45
  ProviderCallOverrides,
46
46
  StreamChunk,
47
+ StreamTruncatedError,
48
+ )
49
+ from coderouter.credentials import resolve_provider_credential
50
+ from coderouter.logging import (
51
+ get_logger,
52
+ log_output_filter_applied,
53
+ log_stream_truncation_detected,
47
54
  )
48
- from coderouter.config.loader import resolve_api_key
49
- from coderouter.logging import get_logger, log_output_filter_applied
50
55
  from coderouter.output_filters import OutputFilterChain
51
56
  from coderouter.translation.anthropic import (
52
57
  AnthropicRequest,
@@ -74,6 +79,74 @@ _RETRYABLE_STATUSES = {404, 408, 425, 429, 500, 502, 503, 504}
74
79
  _DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
75
80
 
76
81
 
82
+ class _AnthropicTerminatorTracker:
83
+ """v2.15.0: watch an Anthropic SSE event stream for its terminator.
84
+
85
+ The parse loop in :meth:`AnthropicAdapter.stream_anthropic` used to be
86
+ stateless across events — ``current_event`` was reset on every blank
87
+ line and nothing remembered whether the stream had ended *properly*.
88
+ That made "upstream sent 30 deltas and hung up" indistinguishable from
89
+ "upstream sent the whole message". This tracker is the missing memory.
90
+
91
+ What counts as a terminator (deliberately generous, to keep the false
92
+ positive rate down — see ``stream_truncation_action`` in
93
+ ``config/schemas.py``):
94
+
95
+ * ``message_stop`` — the canonical Anthropic terminator.
96
+ * ``message_delta`` carrying a non-null ``stop_reason`` — the message is
97
+ semantically complete even if the provider omits ``message_stop``.
98
+ Some Anthropic-compatible local servers do exactly that, and their
99
+ content is *not* truncated, so flagging them would be a false positive.
100
+ * a top-level ``error`` event — the upstream ended the stream on purpose
101
+ and the frame is forwarded to the client verbatim (current behavior).
102
+ Reporting truncation on top would double-report one failure.
103
+
104
+ ``open_block_type`` tracks the currently-open content block so a stream
105
+ cut in the middle of a ``tool_use`` block's ``input_json_delta`` can be
106
+ reported as such: that is the case where the translation layer's
107
+ terminator synthesis closes a block whose argument JSON is provably
108
+ incomplete.
109
+ """
110
+
111
+ __slots__ = ("events", "open_block_type", "saw_start", "saw_terminator")
112
+
113
+ def __init__(self) -> None:
114
+ """Start with nothing observed."""
115
+ self.saw_terminator: bool = False
116
+ self.saw_start: bool = False
117
+ self.events: int = 0
118
+ self.open_block_type: str | None = None
119
+
120
+ def observe(self, event_type: str, data: Any) -> None:
121
+ """Fold one parsed SSE event into the tracked state.
122
+
123
+ ``data`` is whatever ``json.loads`` produced for the block, so it is
124
+ not guaranteed to be a mapping; non-dict payloads are treated as
125
+ empty rather than raising inside a streaming hot path.
126
+ """
127
+ self.events += 1
128
+ if not isinstance(data, dict):
129
+ data = {}
130
+ if event_type == "message_start":
131
+ self.saw_start = True
132
+ elif event_type == "content_block_start":
133
+ block = data.get("content_block")
134
+ self.open_block_type = block.get("type") if isinstance(block, dict) else None
135
+ elif event_type == "content_block_stop":
136
+ self.open_block_type = None
137
+ elif event_type == "message_delta":
138
+ delta = data.get("delta")
139
+ if isinstance(delta, dict) and delta.get("stop_reason"):
140
+ self.saw_terminator = True
141
+ elif event_type in {"message_stop", "error"}:
142
+ self.saw_terminator = True
143
+
144
+ @property
145
+ def tool_call_in_flight(self) -> bool:
146
+ """True when a ``tool_use`` block was still open at end-of-stream."""
147
+ return self.open_block_type == "tool_use"
148
+
149
+
77
150
  def anthropic_messages_url(base_url: str) -> str:
78
151
  """Resolve the ``/v1/messages`` endpoint URL from a ``base_url``.
79
152
 
@@ -134,7 +207,9 @@ class AnthropicAdapter(BaseAdapter):
134
207
  self.config.extra_body.get("anthropic_version", _DEFAULT_ANTHROPIC_VERSION)
135
208
  ),
136
209
  }
137
- api_key = resolve_api_key(self.config.api_key_env)
210
+ # v2.14.0: see openai_compat._headers — same resolver, so an
211
+ # Anthropic-protocol provider can also ride a CLI session token.
212
+ api_key = resolve_provider_credential(self.config)
138
213
  if api_key:
139
214
  headers["x-api-key"] = api_key
140
215
  # v0.4-D: forward the client's anthropic-beta header verbatim.
@@ -473,6 +548,12 @@ class AnthropicAdapter(BaseAdapter):
473
548
  filter_chains: dict[int, OutputFilterChain] = {}
474
549
  logged_flag: list[bool] = [False]
475
550
 
551
+ # v2.15.0 (stream-truncation): remember whether the upstream ever
552
+ # terminated its message. Resolved once per call — the knob is a
553
+ # profile-level setting threaded in through ProviderCallOverrides.
554
+ truncation_action = self.effective_stream_truncation_action(overrides)
555
+ terminator = _AnthropicTerminatorTracker()
556
+
476
557
  try:
477
558
  # H3: stream over the shared client (pool / keep-alive / TLS
478
559
  # reuse). Only the ``stream(...)`` context is scoped here; the
@@ -512,6 +593,7 @@ class AnthropicAdapter(BaseAdapter):
512
593
  current_event = None
513
594
  data_lines = []
514
595
  continue
596
+ terminator.observe(current_event, data_obj)
515
597
  for out_event in self._process_stream_event_for_filters(
516
598
  AnthropicStreamEvent(type=current_event, data=data_obj),
517
599
  chains=filter_chains,
@@ -540,12 +622,37 @@ class AnthropicAdapter(BaseAdapter):
540
622
  except json.JSONDecodeError:
541
623
  data_obj = None
542
624
  if data_obj is not None:
625
+ terminator.observe(current_event, data_obj)
543
626
  for out_event in self._process_stream_event_for_filters(
544
627
  AnthropicStreamEvent(type=current_event, data=data_obj),
545
628
  chains=filter_chains,
546
629
  logged_flag=logged_flag,
547
630
  ):
548
631
  yield out_event
632
+
633
+ # v2.15.0 (stream-truncation): the upstream line iterator is
634
+ # exhausted. Reaching here with no terminator observed means the
635
+ # HTTP body ended cleanly while the Anthropic message was still
636
+ # open — the exact case transport-level error handling below
637
+ # cannot see (``httpx.RemoteProtocolError`` and timeouts already
638
+ # raise). Under ``off`` this block is inert, which is what keeps
639
+ # the default byte-for-byte identical to v2.14.0.
640
+ if truncation_action != "off" and not terminator.saw_terminator:
641
+ log_stream_truncation_detected(
642
+ logger,
643
+ self.name,
644
+ action=truncation_action,
645
+ wire="anthropic",
646
+ events_forwarded=terminator.events,
647
+ saw_stream_start=terminator.saw_start,
648
+ tool_call_in_flight=terminator.tool_call_in_flight,
649
+ )
650
+ if truncation_action == "error":
651
+ raise StreamTruncatedError(
652
+ "upstream stream truncated (no message_stop terminator)",
653
+ provider=self.name,
654
+ tool_call_in_flight=terminator.tool_call_in_flight,
655
+ )
549
656
  except httpx.TimeoutException as exc:
550
657
  raise AdapterError(
551
658
  f"timeout streaming from {url}", provider=self.name, retryable=True
@@ -137,6 +137,49 @@ class AdapterError(CodeRouterError):
137
137
  return f"[{self.provider}{sc}] {super().__str__()}"
138
138
 
139
139
 
140
+ # v2.15.0 (stream-truncation): raised by an adapter when the upstream SSE
141
+ # stream ended *without* its protocol terminator (``message_stop`` on the
142
+ # Anthropic wire, ``data: [DONE]`` / a ``finish_reason`` on the OpenAI wire).
143
+ #
144
+ # HTTP-level breakage (timeout, ``httpx.RemoteProtocolError``) already became a
145
+ # plain ``AdapterError`` in both adapters. This subclass covers the layer
146
+ # mismatch the transport cannot see: the HTTP body ended cleanly, but the *LLM
147
+ # protocol* carried inside it was still mid-message.
148
+ #
149
+ # It subclasses ``AdapterError`` on purpose — every existing engine branch
150
+ # (``except AdapterError``) keeps working untouched, and the L2/L4/L5/L6 guards
151
+ # learn from the failure automatically. The subclass only adds identity, so
152
+ # ``classify_adapter_error`` can label the hop ``stream-truncated`` instead of
153
+ # the generic ``upstream-error``.
154
+ #
155
+ # Raised only when the active profile sets ``stream_truncation_action: error``.
156
+ # Under ``off`` (the default) and ``warn`` the adapter returns normally and the
157
+ # legacy terminator-synthesis path in ``coderouter.translation.convert`` (H6 /
158
+ # M9) runs byte-for-byte as before.
159
+ class StreamTruncatedError(AdapterError):
160
+ """Upstream SSE stream ended without its protocol terminator event."""
161
+
162
+ def __init__(
163
+ self,
164
+ message: str,
165
+ *,
166
+ provider: str,
167
+ tool_call_in_flight: bool = False,
168
+ ) -> None:
169
+ """Construct a truncation error.
170
+
171
+ Args:
172
+ message: Human-readable reason. Never contains upstream body text.
173
+ provider: The ``ProviderConfig.name`` whose stream was cut.
174
+ tool_call_in_flight: True when the stream was cut while a
175
+ ``tool_use`` / ``tool_calls`` block was still open, i.e. the
176
+ accumulated argument JSON is certainly incomplete. Carried for
177
+ observability only — it never changes control flow.
178
+ """
179
+ super().__init__(message, provider=provider, status_code=None, retryable=True)
180
+ self.tool_call_in_flight = tool_call_in_flight
181
+
182
+
140
183
  # v0.6-B: per-call overrides resolved from the active profile. The engine
141
184
  # builds one instance per request (since a profile is invariant across its
142
185
  # chain) and threads it through every adapter call on that chain. Adapters
@@ -150,6 +193,13 @@ class AdapterError(CodeRouterError):
150
193
  # - ``append_system_prompt=""`` is a meaningful explicit value: "for
151
194
  # this profile, clear the provider's directive". The adapter must
152
195
  # distinguish ``None`` (no override) from ``""`` (override-to-empty).
196
+ #
197
+ # v2.15.0 (stream-truncation): ``stream_truncation_action`` rides the same
198
+ # channel. It is a *profile*-level knob but has to be read inside the adapter's
199
+ # SSE parse loop, and ``ProviderCallOverrides`` is the only object the engine
200
+ # already threads into every adapter call. Its default of ``"off"`` keeps
201
+ # ``ProviderCallOverrides()`` (and every legacy call site that passes nothing)
202
+ # on the pre-v2.15.0 behavior.
153
203
  class ProviderCallOverrides(BaseModel):
154
204
  """Per-call provider overrides, resolved from the active profile."""
155
205
 
@@ -157,6 +207,7 @@ class ProviderCallOverrides(BaseModel):
157
207
 
158
208
  timeout_s: float | None = None
159
209
  append_system_prompt: str | None = None
210
+ stream_truncation_action: Literal["off", "warn", "error"] = "off"
160
211
 
161
212
 
162
213
  class BaseAdapter(ABC):
@@ -236,6 +287,20 @@ class BaseAdapter(ABC):
236
287
  return overrides.append_system_prompt or None
237
288
  return self.config.append_system_prompt
238
289
 
290
+ def effective_stream_truncation_action(
291
+ self, overrides: ProviderCallOverrides | None
292
+ ) -> str:
293
+ """v2.15.0: resolve ``stream_truncation_action`` for this call.
294
+
295
+ There is no per-provider counterpart — the knob lives on the profile
296
+ only — so this is just a null-safe read. ``None`` overrides (legacy
297
+ call sites, unit tests constructing adapters directly) resolve to
298
+ ``"off"``, which is the pre-v2.15.0 behavior.
299
+ """
300
+ if overrides is None:
301
+ return "off"
302
+ return overrides.stream_truncation_action
303
+
239
304
  @abstractmethod
240
305
  async def healthcheck(self) -> bool:
241
306
  """Lightweight check that the upstream is reachable. Return True if healthy."""
@@ -28,12 +28,14 @@ from coderouter.adapters.base import (
28
28
  ChatResponse,
29
29
  ProviderCallOverrides,
30
30
  StreamChunk,
31
+ StreamTruncatedError,
31
32
  )
32
- from coderouter.config.loader import resolve_api_key
33
+ from coderouter.credentials import resolve_provider_credential
33
34
  from coderouter.logging import (
34
35
  get_logger,
35
36
  log_capability_degraded,
36
37
  log_output_filter_applied,
38
+ log_stream_truncation_detected,
37
39
  )
38
40
  from coderouter.output_filters import OutputFilterChain
39
41
 
@@ -105,7 +107,10 @@ class OpenAICompatAdapter(BaseAdapter):
105
107
  "Content-Type": "application/json",
106
108
  "User-Agent": "CodeRouter/0.1",
107
109
  }
108
- api_key = resolve_api_key(self.config.api_key_env)
110
+ # v2.14.0: resolve_provider_credential covers api_key_env (the
111
+ # historical path, unchanged) and credential.source=cli_session,
112
+ # which borrows the token a vendor CLI already wrote to disk.
113
+ api_key = resolve_provider_credential(self.config)
109
114
  if api_key:
110
115
  headers["Authorization"] = f"Bearer {api_key}"
111
116
  return headers
@@ -344,6 +349,20 @@ class OpenAICompatAdapter(BaseAdapter):
344
349
  # Captured for the closing flush chunk (if any): reuse the last
345
350
  # seen chunk's id/model so the flush emission looks native.
346
351
  last_chunk_template: dict[str, Any] | None = None
352
+
353
+ # v2.15.0 (stream-truncation): the loop below used to treat "saw
354
+ # [DONE]" and "the line iterator ran out" as the same event. These
355
+ # three flags are the memory that tells them apart.
356
+ #
357
+ # A terminator is EITHER ``data: [DONE]`` OR a ``finish_reason`` on
358
+ # any choice. Accepting ``finish_reason`` matters: a provider that
359
+ # emits a complete message and then closes without the ``[DONE]``
360
+ # sentinel is not truncated, and flagging it would be a false
361
+ # positive. See ``stream_truncation_action`` in ``config/schemas.py``.
362
+ truncation_action = self.effective_stream_truncation_action(overrides)
363
+ saw_terminator = False
364
+ chunks_seen = 0
365
+ tool_call_in_flight = False
347
366
  try:
348
367
  # H3: stream over the shared client so the connection pool /
349
368
  # keep-alive / TLS session are reused. Only the ``stream(...)``
@@ -373,11 +392,31 @@ class OpenAICompatAdapter(BaseAdapter):
373
392
  continue
374
393
  data_str = line[len("data:") :].strip()
375
394
  if data_str == "[DONE]":
395
+ # v2.15.0: the sentinel — this stream ended on purpose.
396
+ saw_terminator = True
376
397
  break
377
398
  try:
378
399
  payload_obj = json.loads(data_str)
379
400
  except json.JSONDecodeError:
380
401
  continue # skip malformed chunks rather than abort
402
+ # v2.15.0 (stream-truncation): fold this chunk into the
403
+ # terminator state before any filtering / validation, so a
404
+ # chunk that is later dropped as malformed still counts as
405
+ # "the upstream got this far".
406
+ chunks_seen += 1
407
+ for _choice in payload_obj.get("choices") or []:
408
+ if not isinstance(_choice, dict):
409
+ continue
410
+ if _choice.get("finish_reason"):
411
+ # A finish_reason means the message is complete
412
+ # even if the [DONE] sentinel never arrives, and
413
+ # it also closes any tool call that was open.
414
+ saw_terminator = True
415
+ tool_call_in_flight = False
416
+ elif isinstance(_choice.get("delta"), dict) and _choice[
417
+ "delta"
418
+ ].get("tool_calls"):
419
+ tool_call_in_flight = True
381
420
  if strip_reasoning:
382
421
  stripped = _strip_reasoning_field(
383
422
  payload_obj.get("choices"), delta_key=True
@@ -443,6 +482,30 @@ class OpenAICompatAdapter(BaseAdapter):
443
482
  streaming=True,
444
483
  )
445
484
  output_filter_logged = True
485
+
486
+ # v2.15.0 (stream-truncation): decided AFTER the filter flush on
487
+ # purpose — the flush is the last chance to hand the caller every
488
+ # safe byte the filter chain was still holding, and under
489
+ # ``partial_stitch_action: surface`` those bytes end up in the
490
+ # partial content the client receives. Under ``off`` this block is
491
+ # inert, keeping the default byte-for-byte identical to v2.14.0.
492
+ if truncation_action != "off" and not saw_terminator:
493
+ log_stream_truncation_detected(
494
+ logger,
495
+ self.name,
496
+ action=truncation_action,
497
+ wire="openai",
498
+ events_forwarded=chunks_seen,
499
+ saw_stream_start=chunks_seen > 0,
500
+ tool_call_in_flight=tool_call_in_flight,
501
+ )
502
+ if truncation_action == "error":
503
+ raise StreamTruncatedError(
504
+ "upstream stream truncated "
505
+ "(no [DONE] sentinel and no finish_reason)",
506
+ provider=self.name,
507
+ tool_call_in_flight=tool_call_in_flight,
508
+ )
446
509
  except httpx.TimeoutException as exc:
447
510
  raise AdapterError(
448
511
  f"timeout streaming from {url}", provider=self.name, retryable=True
coderouter/cli.py CHANGED
@@ -149,6 +149,22 @@ def _build_parser() -> argparse.ArgumentParser:
149
149
  "See docs/guides/troubleshooting.md §5 for the threat model."
150
150
  ),
151
151
  )
152
+ # v2.14.0: --check-secrets is the runtime counterpart to --check-env.
153
+ # --check-env asks "is the file holding my keys protected?"; this asks
154
+ # "does the running process know its own secrets, and did any of them
155
+ # already reach a log file?". The second question is the one that used
156
+ # to have no answer at all.
157
+ doctor.add_argument(
158
+ "--check-secrets",
159
+ action="store_true",
160
+ help=(
161
+ "Audit credential hygiene: prove the log-redaction filter "
162
+ "actually scrubs, report which declared api_key_env vars are "
163
+ "set, flag credentials pasted into a base_url, and scan the "
164
+ "already-written requests.jsonl / audit.jsonl under state_dir "
165
+ "for live key values. Exit 0 clean / 2 needs attention / 1 leak."
166
+ ),
167
+ )
152
168
  doctor.add_argument(
153
169
  "--config",
154
170
  default=None,
@@ -192,6 +208,55 @@ def _build_parser() -> argparse.ArgumentParser:
192
208
  ),
193
209
  )
194
210
 
211
+ # v2.14.0: `coderouter rollback` — the missing half of --apply.
212
+ # Both writers (doctor --apply, vscode-init) already dropped a `.bak`
213
+ # next to every file they rewrote; nothing could put it back. Restore
214
+ # is a swap, not an overwrite, so running it twice returns you to
215
+ # where you started rather than destroying the newer version.
216
+ rollback = sub.add_parser(
217
+ "rollback",
218
+ help="Restore files a previous --apply / vscode-init rewrote (v2.14.0).",
219
+ description=(
220
+ "Swap each managed file with its .bak sibling: providers.yaml, "
221
+ "~/.coderouter/model-capabilities.yaml, and (with --workspace) "
222
+ ".vscode/settings.json and .envrc. The current contents become "
223
+ "the new .bak, so a second run toggles back. "
224
+ "Exit codes: 0 restored, 2 nothing to restore, 1 a restore failed."
225
+ ),
226
+ )
227
+ rollback.add_argument(
228
+ "--config",
229
+ default=None,
230
+ help=(
231
+ "Path to providers.yaml. Defaults to the same file the loader "
232
+ "would read ($CODEROUTER_CONFIG, then ~/.coderouter/providers.yaml)."
233
+ ),
234
+ )
235
+ rollback.add_argument(
236
+ "--workspace",
237
+ metavar="DIR",
238
+ default=None,
239
+ help=(
240
+ "Also restore the vscode-init outputs under DIR: "
241
+ "DIR/.vscode/settings.json and DIR/.envrc."
242
+ ),
243
+ )
244
+ rollback.add_argument(
245
+ "--path",
246
+ action="append",
247
+ metavar="FILE",
248
+ default=None,
249
+ help=(
250
+ "Restore exactly this file from its .bak sibling. Repeatable. "
251
+ "When given, the default discovery set is not used."
252
+ ),
253
+ )
254
+ rollback.add_argument(
255
+ "--dry-run",
256
+ action="store_true",
257
+ help="Report what would be restored without touching any file.",
258
+ )
259
+
195
260
  # v1.5-C: `coderouter stats` — live TUI over GET /metrics.json.
196
261
  # Lazy-imports ``curses`` inside the runner so the CLI boot stays
197
262
  # snappy and environments without curses (rare, but e.g. minimal
@@ -490,6 +555,9 @@ def main(argv: list[str] | None = None) -> int:
490
555
  if args.command == "doctor":
491
556
  return _run_doctor(args)
492
557
 
558
+ if args.command == "rollback":
559
+ return _run_rollback(args)
560
+
493
561
  if args.command == "stats":
494
562
  # v1.5-C: stats is intentionally a thin wrapper — all logic
495
563
  # (fetch, render, curses loop) lives in coderouter.cli_stats so
@@ -526,9 +594,11 @@ def _run_doctor(args: argparse.Namespace) -> int:
526
594
  worst-case of the two reports so CI guarding against both leak
527
595
  risks AND broken providers can use a single command.
528
596
  """
529
- if args.check_model is None and args.check_env is None:
597
+ check_secrets = bool(getattr(args, "check_secrets", False))
598
+ if args.check_model is None and args.check_env is None and not check_secrets:
530
599
  print(
531
- "doctor: provide --check-model PROVIDER and/or --check-env [PATH]",
600
+ "doctor: provide --check-model PROVIDER, --check-env [PATH], "
601
+ "and/or --check-secrets",
532
602
  file=sys.stderr,
533
603
  )
534
604
  return 1
@@ -541,6 +611,9 @@ def _run_doctor(args: argparse.Namespace) -> int:
541
611
  if args.check_env is not None:
542
612
  worst_exit = max(worst_exit, _run_check_env(args.check_env))
543
613
 
614
+ if check_secrets:
615
+ worst_exit = max(worst_exit, _run_check_secrets(args.config))
616
+
544
617
  if args.check_model is not None:
545
618
  worst_exit = max(worst_exit, _run_check_model(args))
546
619
 
@@ -969,5 +1042,70 @@ def _run_check_env(arg_value: str) -> int:
969
1042
  return exit_code_for_env_security(report)
970
1043
 
971
1044
 
1045
+ def _run_rollback(args: argparse.Namespace) -> int:
1046
+ """v2.14.0: put back what ``--apply`` / ``vscode-init`` overwrote.
1047
+
1048
+ Explicit ``--path`` wins over discovery so an operator can restore one
1049
+ file without also reverting an unrelated ``model-capabilities.yaml``
1050
+ edit from last week. Discovery resolves providers.yaml through the
1051
+ loader's own search order rather than a second copy of it.
1052
+ """
1053
+ from coderouter.rollback import (
1054
+ discover_managed_files,
1055
+ exit_code_for_rollback,
1056
+ format_rollback_report,
1057
+ restore_many,
1058
+ )
1059
+
1060
+ explicit = getattr(args, "path", None)
1061
+ if explicit:
1062
+ targets: list[Path] = [Path(p).expanduser() for p in explicit]
1063
+ else:
1064
+ try:
1065
+ config_path: Path | None = _resolve_config_path(args.config)
1066
+ except Exception:
1067
+ # No config anywhere is not fatal here — the workspace files
1068
+ # and the user-layer capabilities file may still have backups.
1069
+ config_path = None
1070
+ targets = discover_managed_files(
1071
+ config_path=config_path,
1072
+ workspace=getattr(args, "workspace", None),
1073
+ )
1074
+
1075
+ outcomes = restore_many(targets, dry_run=bool(args.dry_run))
1076
+ print(format_rollback_report(outcomes, dry_run=bool(args.dry_run)))
1077
+ return exit_code_for_rollback(outcomes)
1078
+
1079
+
1080
+ def _run_check_secrets(config_path: str | None) -> int:
1081
+ """v2.14.0: credential-hygiene audit for the running configuration.
1082
+
1083
+ Loads the config through the normal loader — which is also what arms
1084
+ the redaction registry — then runs the suite from
1085
+ :mod:`coderouter.secret_redaction`. A config that fails to load is a
1086
+ blocker (exit 1) rather than a skip: we cannot claim anything about
1087
+ secret hygiene for a file we could not read.
1088
+ """
1089
+ from coderouter.config.loader import load_config
1090
+ from coderouter.secret_redaction import (
1091
+ check_secret_hygiene,
1092
+ exit_code_for_secret_report,
1093
+ format_secret_report,
1094
+ )
1095
+
1096
+ try:
1097
+ config = load_config(config_path)
1098
+ except FileNotFoundError as exc:
1099
+ print(f"doctor: {exc}", file=sys.stderr)
1100
+ return 1
1101
+ except Exception as exc:
1102
+ print(f"doctor: failed to load config: {exc}", file=sys.stderr)
1103
+ return 1
1104
+
1105
+ report = check_secret_hygiene(config)
1106
+ print(format_secret_report(report))
1107
+ return exit_code_for_secret_report(report)
1108
+
1109
+
972
1110
  if __name__ == "__main__":
973
1111
  sys.exit(main())
@@ -34,6 +34,7 @@ import yaml
34
34
 
35
35
  from coderouter.config.schemas import CodeRouterConfig
36
36
  from coderouter.logging import get_logger
37
+ from coderouter.secret_redaction import register_config_secrets, register_secret
37
38
 
38
39
  logger = get_logger(__name__)
39
40
 
@@ -253,12 +254,34 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
253
254
  elif env_paid in {"0", "false", "no", "off"}:
254
255
  config.allow_paid = False
255
256
 
257
+ # v2.14.0: arm log redaction before anything else runs. Registering here
258
+ # (rather than lazily on the first resolve_api_key call) means the
259
+ # startup log lines — which name providers and profiles — are already
260
+ # scrubbed, and so is any config-validation error raised downstream.
261
+ # Only labels are logged; the values never leave the registry.
262
+ registered = register_config_secrets(config)
263
+ if registered:
264
+ logger.info(
265
+ "secret-redaction-armed",
266
+ extra={"count": len(registered), "labels": sorted(set(registered))},
267
+ )
268
+
256
269
  return config
257
270
 
258
271
 
259
272
  def resolve_api_key(api_key_env: str | None) -> str | None:
260
- """Look up an API key from the named env var. Returns None if unset."""
273
+ """Look up an API key from the named env var. Returns None if unset.
274
+
275
+ v2.14.0: the resolved value is registered with
276
+ :mod:`coderouter.secret_redaction` before it is handed out. This is the
277
+ moment a credential enters the process, so it is the one place that can
278
+ guarantee the log scrubber knows about every key we actually use —
279
+ including keys for providers added by an adapter plugin, which never
280
+ pass through ``register_config_secrets``.
281
+ """
261
282
  if not api_key_env:
262
283
  return None
263
284
  value = os.environ.get(api_key_env, "").strip()
285
+ if value:
286
+ register_secret(value, api_key_env)
264
287
  return value or None