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.
- coderouter/adapters/anthropic_native.py +110 -3
- coderouter/adapters/base.py +65 -0
- coderouter/adapters/openai_compat.py +65 -2
- coderouter/cli.py +140 -2
- coderouter/config/loader.py +24 -1
- coderouter/config/schemas.py +209 -0
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/ingress/anthropic_routes.py +82 -12
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/openai_routes.py +31 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/logging.py +150 -0
- coderouter/metrics/collector.py +40 -0
- coderouter/metrics/prometheus.py +46 -0
- coderouter/rollback.py +217 -0
- coderouter/routing/__init__.py +8 -0
- coderouter/routing/fallback.py +304 -4
- coderouter/routing/fallback_trace.py +501 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +10 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +166 -23
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/METADATA +2 -2
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/RECORD +29 -24
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/WHEEL +1 -1
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
|
|
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
|
coderouter/adapters/base.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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())
|
coderouter/config/loader.py
CHANGED
|
@@ -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
|