coderouter-cli 1.7.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/__init__.py +17 -0
- coderouter/__main__.py +6 -0
- coderouter/adapters/__init__.py +23 -0
- coderouter/adapters/anthropic_native.py +502 -0
- coderouter/adapters/base.py +220 -0
- coderouter/adapters/openai_compat.py +395 -0
- coderouter/adapters/registry.py +17 -0
- coderouter/cli.py +345 -0
- coderouter/cli_stats.py +751 -0
- coderouter/config/__init__.py +10 -0
- coderouter/config/capability_registry.py +339 -0
- coderouter/config/env_file.py +295 -0
- coderouter/config/loader.py +73 -0
- coderouter/config/schemas.py +515 -0
- coderouter/data/__init__.py +7 -0
- coderouter/data/model-capabilities.yaml +86 -0
- coderouter/doctor.py +1596 -0
- coderouter/env_security.py +434 -0
- coderouter/errors.py +29 -0
- coderouter/ingress/__init__.py +5 -0
- coderouter/ingress/anthropic_routes.py +205 -0
- coderouter/ingress/app.py +144 -0
- coderouter/ingress/dashboard_routes.py +493 -0
- coderouter/ingress/metrics_routes.py +92 -0
- coderouter/ingress/openai_routes.py +153 -0
- coderouter/logging.py +315 -0
- coderouter/metrics/__init__.py +39 -0
- coderouter/metrics/collector.py +471 -0
- coderouter/metrics/prometheus.py +221 -0
- coderouter/output_filters.py +407 -0
- coderouter/routing/__init__.py +13 -0
- coderouter/routing/auto_router.py +244 -0
- coderouter/routing/capability.py +285 -0
- coderouter/routing/fallback.py +611 -0
- coderouter/translation/__init__.py +57 -0
- coderouter/translation/anthropic.py +204 -0
- coderouter/translation/convert.py +1291 -0
- coderouter/translation/tool_repair.py +236 -0
- coderouter_cli-1.7.0.dist-info/METADATA +509 -0
- coderouter_cli-1.7.0.dist-info/RECORD +43 -0
- coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
- coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
- coderouter_cli-1.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""`.env` file security checks (v1.6.3).
|
|
2
|
+
|
|
3
|
+
Run from ``coderouter doctor --check-env [PATH]`` to surface the three
|
|
4
|
+
common ``.env`` mishaps before they bite:
|
|
5
|
+
|
|
6
|
+
1. **Filesystem permissions** — on POSIX, the file should be readable
|
|
7
|
+
only by the owner (``mode & 0o077 == 0``). World/group readable
|
|
8
|
+
``.env`` exposes API keys to other accounts on shared machines and
|
|
9
|
+
to backup tools that recurse with broad scope.
|
|
10
|
+
2. **`.gitignore` coverage** — ``.env`` MUST be matched by the repo's
|
|
11
|
+
ignore rules so an absent-minded ``git add .`` doesn't stage it.
|
|
12
|
+
3. **`git` tracking state** — if ``.env`` is already tracked
|
|
13
|
+
(committed in the past), no ``.gitignore`` rule will save it. The
|
|
14
|
+
fix is to ``git rm --cached``, rotate the leaked keys, and update
|
|
15
|
+
``.gitignore``.
|
|
16
|
+
|
|
17
|
+
Design choices
|
|
18
|
+
--------------
|
|
19
|
+
* Pure stdlib (``os`` / ``stat`` / ``subprocess`` / ``shutil``) so we
|
|
20
|
+
preserve the runtime-deps freeze (5 packages — see plan.md §5.4).
|
|
21
|
+
* No HTTP, no asyncio — these are local filesystem and git checks, so
|
|
22
|
+
the module is intentionally separate from ``coderouter.doctor``
|
|
23
|
+
(which is httpx-heavy). The CLI surfaces both via the same
|
|
24
|
+
``coderouter doctor`` namespace.
|
|
25
|
+
* Verdict severity is mapped to the same exit-code contract used by
|
|
26
|
+
the model probes (0 OK / 2 patchable / 1 blocker), so wrappers like
|
|
27
|
+
``coderouter doctor --check-env --check-model nim-x`` (a v1.7
|
|
28
|
+
candidate) can collapse the verdicts uniformly.
|
|
29
|
+
* Windows: file-mode check is skipped with verdict SKIP (Windows POSIX
|
|
30
|
+
bits are unreliable). Git checks still run if ``git`` is on PATH.
|
|
31
|
+
|
|
32
|
+
Non-destructive contract
|
|
33
|
+
------------------------
|
|
34
|
+
This module never writes, never deletes, never invokes ``git add`` or
|
|
35
|
+
``git rm``. It only reads filesystem metadata and runs read-only git
|
|
36
|
+
plumbing (``git check-ignore`` / ``git ls-files --error-unmatch``).
|
|
37
|
+
The repo state is not mutated.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import shutil
|
|
44
|
+
import stat
|
|
45
|
+
import subprocess
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from enum import StrEnum
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"EnvSecurityCheck",
|
|
52
|
+
"EnvSecurityReport",
|
|
53
|
+
"EnvSecurityVerdict",
|
|
54
|
+
"check_env_security",
|
|
55
|
+
"exit_code_for_env_security",
|
|
56
|
+
"format_env_security_report",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EnvSecurityVerdict(StrEnum):
|
|
61
|
+
"""Per-check verdict.
|
|
62
|
+
|
|
63
|
+
Mapping to exit code (see :func:`exit_code_for_env_security`):
|
|
64
|
+
OK → contributes 0
|
|
65
|
+
SKIP → contributes 0 (not applicable, e.g. Windows file-mode)
|
|
66
|
+
WARN → contributes 2 (fix recommended; chmod / .gitignore edit)
|
|
67
|
+
ERROR → contributes 1 (real leak risk; .env is git-tracked, etc.)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
OK = "ok"
|
|
71
|
+
SKIP = "skip"
|
|
72
|
+
WARN = "warn"
|
|
73
|
+
ERROR = "error"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class EnvSecurityCheck:
|
|
78
|
+
"""Outcome of a single check in the env-security suite.
|
|
79
|
+
|
|
80
|
+
``fix`` is a 1-line shell command (or short snippet) the operator
|
|
81
|
+
can copy-paste to remediate. ``None`` when no remediation is
|
|
82
|
+
applicable (e.g. on a SKIP verdict).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
verdict: EnvSecurityVerdict
|
|
87
|
+
detail: str
|
|
88
|
+
fix: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class EnvSecurityReport:
|
|
93
|
+
"""Aggregate report for a single ``--check-env`` invocation."""
|
|
94
|
+
|
|
95
|
+
path: Path
|
|
96
|
+
checks: list[EnvSecurityCheck] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def exit_code_for_env_security(report: EnvSecurityReport) -> int:
|
|
100
|
+
"""Derive the CLI exit code from a report (see :class:`EnvSecurityVerdict`).
|
|
101
|
+
|
|
102
|
+
Same shape as :func:`coderouter.doctor.exit_code_for` so callers
|
|
103
|
+
can union the two reports without special-casing.
|
|
104
|
+
"""
|
|
105
|
+
has_blocker = False
|
|
106
|
+
has_warn = False
|
|
107
|
+
for c in report.checks:
|
|
108
|
+
if c.verdict == EnvSecurityVerdict.ERROR:
|
|
109
|
+
has_blocker = True
|
|
110
|
+
elif c.verdict == EnvSecurityVerdict.WARN:
|
|
111
|
+
has_warn = True
|
|
112
|
+
if has_blocker:
|
|
113
|
+
return 1
|
|
114
|
+
if has_warn:
|
|
115
|
+
return 2
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check_env_security(
|
|
120
|
+
path: str | os.PathLike[str],
|
|
121
|
+
*,
|
|
122
|
+
git_executable: str | None = None,
|
|
123
|
+
) -> EnvSecurityReport:
|
|
124
|
+
"""Run the 3-check env-security suite against ``path``.
|
|
125
|
+
|
|
126
|
+
The checks are independent — each runs even if a previous one
|
|
127
|
+
failed, so the report shows everything wrong at once (rather than
|
|
128
|
+
making the user fix one thing, re-run, see the next, etc.).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path: ``.env`` file to inspect. Does not need to exist; if
|
|
132
|
+
absent, all checks return SKIP with a clear message so
|
|
133
|
+
the operator can ack ("yeah, I haven't created one yet").
|
|
134
|
+
git_executable: Override the ``git`` binary path; primarily
|
|
135
|
+
for tests. Defaults to ``shutil.which("git")``.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
:class:`EnvSecurityReport` with one entry per check (in
|
|
139
|
+
deterministic order: existence, perms, gitignore, tracking).
|
|
140
|
+
"""
|
|
141
|
+
p = Path(path).resolve()
|
|
142
|
+
report = EnvSecurityReport(path=p)
|
|
143
|
+
|
|
144
|
+
# -------------------------------------------------------------- #
|
|
145
|
+
# Check 0: existence
|
|
146
|
+
# -------------------------------------------------------------- #
|
|
147
|
+
if not p.exists():
|
|
148
|
+
report.checks.append(
|
|
149
|
+
EnvSecurityCheck(
|
|
150
|
+
name="existence",
|
|
151
|
+
verdict=EnvSecurityVerdict.SKIP,
|
|
152
|
+
detail=f"no file at {p} — nothing to inspect",
|
|
153
|
+
fix=None,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
# Bail early: nothing to inspect, but report SKIP for the
|
|
157
|
+
# remaining checks so the output stays consistent.
|
|
158
|
+
report.checks.append(_skip("permissions", "no file to inspect"))
|
|
159
|
+
report.checks.append(_skip("gitignore", "no file to inspect"))
|
|
160
|
+
report.checks.append(_skip("git-tracking", "no file to inspect"))
|
|
161
|
+
return report
|
|
162
|
+
|
|
163
|
+
if not p.is_file():
|
|
164
|
+
report.checks.append(
|
|
165
|
+
EnvSecurityCheck(
|
|
166
|
+
name="existence",
|
|
167
|
+
verdict=EnvSecurityVerdict.ERROR,
|
|
168
|
+
detail=f"path exists but is not a regular file: {p}",
|
|
169
|
+
fix=None,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return report
|
|
173
|
+
|
|
174
|
+
report.checks.append(
|
|
175
|
+
EnvSecurityCheck(
|
|
176
|
+
name="existence",
|
|
177
|
+
verdict=EnvSecurityVerdict.OK,
|
|
178
|
+
detail=f"found at {p}",
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# -------------------------------------------------------------- #
|
|
183
|
+
# Check 1: permissions (POSIX only — Windows bits are unreliable)
|
|
184
|
+
# -------------------------------------------------------------- #
|
|
185
|
+
report.checks.append(_check_permissions(p))
|
|
186
|
+
|
|
187
|
+
# -------------------------------------------------------------- #
|
|
188
|
+
# Check 2: .gitignore coverage
|
|
189
|
+
# Check 3: git-tracking state
|
|
190
|
+
# Both depend on `git`. If git is unavailable, both SKIP with the
|
|
191
|
+
# same explanatory message so users on a non-git checkout aren't
|
|
192
|
+
# spammed with "git not found" twice.
|
|
193
|
+
# -------------------------------------------------------------- #
|
|
194
|
+
git_bin = git_executable or shutil.which("git")
|
|
195
|
+
if not git_bin:
|
|
196
|
+
report.checks.append(
|
|
197
|
+
_skip("gitignore", "git not on PATH — cannot evaluate .gitignore")
|
|
198
|
+
)
|
|
199
|
+
report.checks.append(
|
|
200
|
+
_skip("git-tracking", "git not on PATH — cannot evaluate tracking")
|
|
201
|
+
)
|
|
202
|
+
return report
|
|
203
|
+
|
|
204
|
+
repo_root = _find_repo_root(p, git_bin)
|
|
205
|
+
if repo_root is None:
|
|
206
|
+
report.checks.append(
|
|
207
|
+
_skip("gitignore", "not inside a git repository")
|
|
208
|
+
)
|
|
209
|
+
report.checks.append(
|
|
210
|
+
_skip("git-tracking", "not inside a git repository")
|
|
211
|
+
)
|
|
212
|
+
return report
|
|
213
|
+
|
|
214
|
+
report.checks.append(_check_gitignore(p, repo_root, git_bin))
|
|
215
|
+
report.checks.append(_check_git_tracking(p, repo_root, git_bin))
|
|
216
|
+
|
|
217
|
+
return report
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def format_env_security_report(report: EnvSecurityReport) -> str:
|
|
221
|
+
"""Render an :class:`EnvSecurityReport` as a human-readable block.
|
|
222
|
+
|
|
223
|
+
Output is intentionally similar in shape to
|
|
224
|
+
:func:`coderouter.doctor.format_report` (header line, indented
|
|
225
|
+
detail, fix command on a separate indented line) so the two
|
|
226
|
+
reports can sit next to each other without visual whiplash.
|
|
227
|
+
"""
|
|
228
|
+
lines: list[str] = []
|
|
229
|
+
lines.append("─" * 60)
|
|
230
|
+
lines.append(f"env-security: {report.path}")
|
|
231
|
+
lines.append("Checks:")
|
|
232
|
+
for c in report.checks:
|
|
233
|
+
lines.append(f" [{c.verdict.value.upper():6s}] {c.name}")
|
|
234
|
+
lines.append(f" {c.detail}")
|
|
235
|
+
if c.fix:
|
|
236
|
+
lines.append(f" fix: {c.fix}")
|
|
237
|
+
|
|
238
|
+
has_warn = any(c.verdict == EnvSecurityVerdict.WARN for c in report.checks)
|
|
239
|
+
has_err = any(c.verdict == EnvSecurityVerdict.ERROR for c in report.checks)
|
|
240
|
+
if has_err:
|
|
241
|
+
summary = "Summary: at least one check escalated to ERROR (real leak risk)."
|
|
242
|
+
elif has_warn:
|
|
243
|
+
summary = "Summary: WARN(s) present — apply the suggested fix(es)."
|
|
244
|
+
else:
|
|
245
|
+
summary = "Summary: all checks pass."
|
|
246
|
+
lines.append(summary)
|
|
247
|
+
lines.append(f"Exit: {exit_code_for_env_security(report)}")
|
|
248
|
+
return "\n".join(lines)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Internals
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _skip(name: str, reason: str) -> EnvSecurityCheck:
|
|
257
|
+
return EnvSecurityCheck(
|
|
258
|
+
name=name,
|
|
259
|
+
verdict=EnvSecurityVerdict.SKIP,
|
|
260
|
+
detail=reason,
|
|
261
|
+
fix=None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_permissions(p: Path) -> EnvSecurityCheck:
|
|
266
|
+
"""Verify ``.env`` is owner-only readable on POSIX systems."""
|
|
267
|
+
if os.name == "nt":
|
|
268
|
+
return _skip("permissions", "Windows — POSIX mode bits unreliable")
|
|
269
|
+
|
|
270
|
+
mode = p.stat().st_mode
|
|
271
|
+
perm = stat.S_IMODE(mode)
|
|
272
|
+
other_or_group = perm & 0o077
|
|
273
|
+
if other_or_group == 0:
|
|
274
|
+
return EnvSecurityCheck(
|
|
275
|
+
name="permissions",
|
|
276
|
+
verdict=EnvSecurityVerdict.OK,
|
|
277
|
+
detail=f"mode = {oct(perm)} (owner-only)",
|
|
278
|
+
)
|
|
279
|
+
return EnvSecurityCheck(
|
|
280
|
+
name="permissions",
|
|
281
|
+
verdict=EnvSecurityVerdict.WARN,
|
|
282
|
+
detail=(
|
|
283
|
+
f"mode = {oct(perm)} grants group/other access. API keys in "
|
|
284
|
+
f"this file are visible to other accounts on shared machines "
|
|
285
|
+
f"and to backup tools."
|
|
286
|
+
),
|
|
287
|
+
fix=f"chmod 0600 {p}",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _find_repo_root(p: Path, git_bin: str) -> Path | None:
|
|
292
|
+
"""Return the git repo root containing ``p``, or None if not in a repo.
|
|
293
|
+
|
|
294
|
+
Uses ``git rev-parse --show-toplevel`` from ``p``'s directory — the
|
|
295
|
+
cheapest way to ask git the question without parsing ``.git``
|
|
296
|
+
layouts ourselves (worktrees, submodules, ``GIT_DIR=...`` envs all
|
|
297
|
+
DTRT).
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
result = subprocess.run(
|
|
301
|
+
[git_bin, "rev-parse", "--show-toplevel"],
|
|
302
|
+
cwd=p.parent,
|
|
303
|
+
capture_output=True,
|
|
304
|
+
text=True,
|
|
305
|
+
timeout=5,
|
|
306
|
+
check=False,
|
|
307
|
+
)
|
|
308
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
309
|
+
return None
|
|
310
|
+
if result.returncode != 0:
|
|
311
|
+
return None
|
|
312
|
+
root = result.stdout.strip()
|
|
313
|
+
if not root:
|
|
314
|
+
return None
|
|
315
|
+
return Path(root)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _check_gitignore(p: Path, repo_root: Path, git_bin: str) -> EnvSecurityCheck:
|
|
319
|
+
"""Verify ``.env`` is matched by ``.gitignore`` (any rule).
|
|
320
|
+
|
|
321
|
+
``git check-ignore`` exit codes:
|
|
322
|
+
0 = path IS ignored
|
|
323
|
+
1 = path is NOT ignored
|
|
324
|
+
128 = error (treated as ERROR verdict so the operator notices)
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
result = subprocess.run(
|
|
328
|
+
[git_bin, "check-ignore", "-q", str(p)],
|
|
329
|
+
cwd=repo_root,
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
timeout=5,
|
|
333
|
+
check=False,
|
|
334
|
+
)
|
|
335
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
336
|
+
return EnvSecurityCheck(
|
|
337
|
+
name="gitignore",
|
|
338
|
+
verdict=EnvSecurityVerdict.SKIP,
|
|
339
|
+
detail=f"git check-ignore failed: {exc}",
|
|
340
|
+
fix=None,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if result.returncode == 0:
|
|
344
|
+
return EnvSecurityCheck(
|
|
345
|
+
name="gitignore",
|
|
346
|
+
verdict=EnvSecurityVerdict.OK,
|
|
347
|
+
detail=f"matched by .gitignore in {repo_root}",
|
|
348
|
+
)
|
|
349
|
+
if result.returncode == 1:
|
|
350
|
+
# Compute a relative path for the suggested fix when possible.
|
|
351
|
+
try:
|
|
352
|
+
rel = p.relative_to(repo_root)
|
|
353
|
+
except ValueError:
|
|
354
|
+
rel = p
|
|
355
|
+
return EnvSecurityCheck(
|
|
356
|
+
name="gitignore",
|
|
357
|
+
verdict=EnvSecurityVerdict.WARN,
|
|
358
|
+
detail=(
|
|
359
|
+
f"NOT matched by any .gitignore rule in {repo_root}. "
|
|
360
|
+
f"`git add .` from this repo will stage the file."
|
|
361
|
+
),
|
|
362
|
+
fix=f'echo "{rel}" >> {repo_root}/.gitignore',
|
|
363
|
+
)
|
|
364
|
+
return EnvSecurityCheck(
|
|
365
|
+
name="gitignore",
|
|
366
|
+
verdict=EnvSecurityVerdict.SKIP,
|
|
367
|
+
detail=(
|
|
368
|
+
f"git check-ignore returned unexpected code {result.returncode}: "
|
|
369
|
+
f"{(result.stderr or result.stdout).strip()!r}"
|
|
370
|
+
),
|
|
371
|
+
fix=None,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _check_git_tracking(p: Path, repo_root: Path, git_bin: str) -> EnvSecurityCheck:
|
|
376
|
+
"""Verify ``.env`` is NOT currently tracked by git.
|
|
377
|
+
|
|
378
|
+
``git ls-files --error-unmatch`` exit codes:
|
|
379
|
+
0 = path IS tracked
|
|
380
|
+
1 = path is NOT tracked (this is what we want)
|
|
381
|
+
other = error
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
result = subprocess.run(
|
|
385
|
+
[git_bin, "ls-files", "--error-unmatch", str(p)],
|
|
386
|
+
cwd=repo_root,
|
|
387
|
+
capture_output=True,
|
|
388
|
+
text=True,
|
|
389
|
+
timeout=5,
|
|
390
|
+
check=False,
|
|
391
|
+
)
|
|
392
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
393
|
+
return EnvSecurityCheck(
|
|
394
|
+
name="git-tracking",
|
|
395
|
+
verdict=EnvSecurityVerdict.SKIP,
|
|
396
|
+
detail=f"git ls-files failed: {exc}",
|
|
397
|
+
fix=None,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if result.returncode == 0:
|
|
401
|
+
try:
|
|
402
|
+
rel = p.relative_to(repo_root)
|
|
403
|
+
except ValueError:
|
|
404
|
+
rel = p
|
|
405
|
+
return EnvSecurityCheck(
|
|
406
|
+
name="git-tracking",
|
|
407
|
+
verdict=EnvSecurityVerdict.ERROR,
|
|
408
|
+
detail=(
|
|
409
|
+
f"file is currently tracked by git in {repo_root}. Any "
|
|
410
|
+
f"secrets in it have been (or could be) committed and "
|
|
411
|
+
f"pushed. .gitignore rules do NOT untrack already-"
|
|
412
|
+
f"tracked files."
|
|
413
|
+
),
|
|
414
|
+
fix=(
|
|
415
|
+
f"git -C {repo_root} rm --cached {rel} && "
|
|
416
|
+
f"echo '{rel}' >> {repo_root}/.gitignore && "
|
|
417
|
+
f"# rotate any leaked keys, then commit"
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
if result.returncode == 1:
|
|
421
|
+
return EnvSecurityCheck(
|
|
422
|
+
name="git-tracking",
|
|
423
|
+
verdict=EnvSecurityVerdict.OK,
|
|
424
|
+
detail=f"not tracked by git in {repo_root}",
|
|
425
|
+
)
|
|
426
|
+
return EnvSecurityCheck(
|
|
427
|
+
name="git-tracking",
|
|
428
|
+
verdict=EnvSecurityVerdict.SKIP,
|
|
429
|
+
detail=(
|
|
430
|
+
f"git ls-files returned unexpected code {result.returncode}: "
|
|
431
|
+
f"{(result.stderr or result.stdout).strip()!r}"
|
|
432
|
+
),
|
|
433
|
+
fix=None,
|
|
434
|
+
)
|
coderouter/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Root exception hierarchy.
|
|
2
|
+
|
|
3
|
+
All CodeRouter-raised exceptions inherit from :class:`CodeRouterError` so
|
|
4
|
+
callers (tests, embedders, downstream integrations) can catch "anything
|
|
5
|
+
CodeRouter produced" with a single ``except CodeRouterError`` clause
|
|
6
|
+
without having to import each leaf type individually.
|
|
7
|
+
|
|
8
|
+
The concrete subclasses still live next to the code that raises them
|
|
9
|
+
(:mod:`coderouter.adapters.base` for :class:`AdapterError`,
|
|
10
|
+
:mod:`coderouter.routing.fallback` for :class:`NoProvidersAvailableError`
|
|
11
|
+
and :class:`MidStreamError`) — this module only defines the root and
|
|
12
|
+
re-exports the leaves for discoverability. Existing import paths are
|
|
13
|
+
preserved; nothing has to change at call sites.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodeRouterError(Exception):
|
|
20
|
+
"""Base class for every exception CodeRouter raises internally.
|
|
21
|
+
|
|
22
|
+
Exists so external code can write ``except CodeRouterError`` to catch
|
|
23
|
+
any failure the router itself produces, without having to enumerate
|
|
24
|
+
the leaves (which are free to grow over time). Does not add any
|
|
25
|
+
behavior beyond :class:`Exception`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["CodeRouterError"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Anthropic-compatible route: POST /v1/messages.
|
|
2
|
+
|
|
3
|
+
Accepts Anthropic Messages API requests and routes them through the
|
|
4
|
+
engine's Anthropic-shaped entry points (`generate_anthropic` /
|
|
5
|
+
`stream_anthropic`). For `kind: "anthropic"` providers the engine does
|
|
6
|
+
direct passthrough; for `kind: "openai_compat"` providers it handles
|
|
7
|
+
translation, tool-call repair, and the v0.3-D tool-turn downgrade.
|
|
8
|
+
|
|
9
|
+
SSE streaming events follow the Anthropic wire protocol
|
|
10
|
+
(`message_start` / `content_block_*` / `message_delta` / `message_stop`).
|
|
11
|
+
|
|
12
|
+
Profile selection mirrors the OpenAI route (see openai_routes.py):
|
|
13
|
+
Body field `profile` > `X-CodeRouter-Profile` header >
|
|
14
|
+
`X-CodeRouter-Mode` header (v0.6-D, via mode_aliases) >
|
|
15
|
+
auto_router (v1.6-A, when ``default_profile: auto``) >
|
|
16
|
+
config default.
|
|
17
|
+
|
|
18
|
+
`anthropic-version` header is accepted but not enforced — Claude Code and
|
|
19
|
+
SDKs send values like "2023-06-01"; we log it for diagnostics only.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from collections.abc import AsyncIterator
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from fastapi import APIRouter, Header, HTTPException, Request
|
|
29
|
+
from fastapi.responses import StreamingResponse
|
|
30
|
+
|
|
31
|
+
from coderouter.logging import get_logger
|
|
32
|
+
from coderouter.routing import (
|
|
33
|
+
FallbackEngine,
|
|
34
|
+
MidStreamError,
|
|
35
|
+
NoProvidersAvailableError,
|
|
36
|
+
)
|
|
37
|
+
from coderouter.routing.auto_router import RESERVED_PROFILE_NAME, classify
|
|
38
|
+
from coderouter.translation import (
|
|
39
|
+
AnthropicRequest,
|
|
40
|
+
AnthropicStreamEvent,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
router = APIRouter()
|
|
44
|
+
logger = get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
_PROFILE_HEADER = "x-coderouter-profile"
|
|
47
|
+
_MODE_HEADER = "x-coderouter-mode"
|
|
48
|
+
_ANTHROPIC_VERSION_HEADER = "anthropic-version"
|
|
49
|
+
_ANTHROPIC_BETA_HEADER = "anthropic-beta"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/messages", response_model=None)
|
|
53
|
+
async def messages(
|
|
54
|
+
payload: dict[str, Any],
|
|
55
|
+
request: Request,
|
|
56
|
+
x_coderouter_profile: str | None = Header(default=None, alias=_PROFILE_HEADER),
|
|
57
|
+
x_coderouter_mode: str | None = Header(default=None, alias=_MODE_HEADER),
|
|
58
|
+
anthropic_version: str | None = Header(default=None, alias=_ANTHROPIC_VERSION_HEADER),
|
|
59
|
+
anthropic_beta: str | None = Header(default=None, alias=_ANTHROPIC_BETA_HEADER),
|
|
60
|
+
) -> StreamingResponse | dict[str, Any]:
|
|
61
|
+
"""Anthropic Messages API endpoint.
|
|
62
|
+
|
|
63
|
+
Validates the body into :class:`AnthropicRequest`, resolves the
|
|
64
|
+
profile (body > profile header > mode header > config default),
|
|
65
|
+
then dispatches to the engine's Anthropic-shaped entry points. For
|
|
66
|
+
streaming requests, returns a :class:`StreamingResponse` that
|
|
67
|
+
serializes engine events onto the Anthropic SSE wire; otherwise
|
|
68
|
+
returns the JSON response body.
|
|
69
|
+
"""
|
|
70
|
+
engine: FallbackEngine = request.app.state.engine
|
|
71
|
+
config = request.app.state.config
|
|
72
|
+
|
|
73
|
+
if anthropic_version:
|
|
74
|
+
# Don't enforce — just trace. Future: match against a known list.
|
|
75
|
+
logger.debug(
|
|
76
|
+
"anthropic-version-header",
|
|
77
|
+
extra={"value": anthropic_version},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
anth_req = AnthropicRequest.model_validate(payload)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
84
|
+
|
|
85
|
+
# v0.4-D: forward the `anthropic-beta` header through to the native
|
|
86
|
+
# adapter. Without this, any body field gated behind a beta header
|
|
87
|
+
# (`context_management`, newer cache_control/thinking variants, etc.)
|
|
88
|
+
# is rejected by api.anthropic.com with 400 "Extra inputs are not
|
|
89
|
+
# permitted". We stash it on the request model with exclude=True so
|
|
90
|
+
# the adapter can reach it without leaking into the wire body.
|
|
91
|
+
if anthropic_beta:
|
|
92
|
+
anth_req.anthropic_beta = anthropic_beta
|
|
93
|
+
|
|
94
|
+
# Profile selection — body field wins over header (same policy as OpenAI route).
|
|
95
|
+
if anth_req.profile is None and x_coderouter_profile:
|
|
96
|
+
anth_req.profile = x_coderouter_profile
|
|
97
|
+
|
|
98
|
+
# v0.6-D: X-CodeRouter-Mode → mode_aliases → profile. Mode sits below
|
|
99
|
+
# Profile because Mode is intent / Profile is the implementation.
|
|
100
|
+
if anth_req.profile is None and x_coderouter_mode:
|
|
101
|
+
try:
|
|
102
|
+
anth_req.profile = config.resolve_mode(x_coderouter_mode)
|
|
103
|
+
except KeyError as exc:
|
|
104
|
+
available = sorted(config.mode_aliases.keys())
|
|
105
|
+
raise HTTPException(
|
|
106
|
+
status_code=400,
|
|
107
|
+
detail=(f"unknown mode {x_coderouter_mode!r}. available modes: {available}"),
|
|
108
|
+
) from exc
|
|
109
|
+
logger.info(
|
|
110
|
+
"mode-alias-resolved",
|
|
111
|
+
extra={"mode": x_coderouter_mode, "profile": anth_req.profile},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# v1.6-A: auto router slot. Symmetric with the OpenAI route — fires only
|
|
115
|
+
# when ``default_profile: auto`` is set and no explicit profile signal won
|
|
116
|
+
# above. When inactive the engine falls through to ``default_profile`` on
|
|
117
|
+
# its own. ``classify`` inspects the raw ``payload`` dict (not the
|
|
118
|
+
# AnthropicRequest), so both OpenAI and Anthropic ingress use the same
|
|
119
|
+
# classifier without a shared request shim.
|
|
120
|
+
if anth_req.profile is None and config.default_profile == RESERVED_PROFILE_NAME:
|
|
121
|
+
anth_req.profile = classify(payload, config)
|
|
122
|
+
|
|
123
|
+
if anth_req.profile is not None:
|
|
124
|
+
try:
|
|
125
|
+
config.profile_by_name(anth_req.profile)
|
|
126
|
+
except KeyError as exc:
|
|
127
|
+
available = [p.name for p in config.profiles]
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
status_code=400,
|
|
130
|
+
detail=(f"unknown profile {anth_req.profile!r}. available: {available}"),
|
|
131
|
+
) from exc
|
|
132
|
+
|
|
133
|
+
if anth_req.stream:
|
|
134
|
+
return StreamingResponse(
|
|
135
|
+
_anthropic_sse_iterator(engine, anth_req),
|
|
136
|
+
media_type="text/event-stream",
|
|
137
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
anth_resp = await engine.generate_anthropic(anth_req)
|
|
142
|
+
except NoProvidersAvailableError as exc:
|
|
143
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
144
|
+
|
|
145
|
+
return anth_resp.model_dump(exclude_none=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _anthropic_sse_iterator(
|
|
149
|
+
engine: FallbackEngine, anth_req: AnthropicRequest
|
|
150
|
+
) -> AsyncIterator[str]:
|
|
151
|
+
"""Serialize engine.stream_anthropic() onto the Anthropic SSE wire.
|
|
152
|
+
|
|
153
|
+
Each emitted block is `event: <type>\\ndata: <json>\\n\\n` per the
|
|
154
|
+
Anthropic spec (distinct from OpenAI's `data:`-only format).
|
|
155
|
+
Errors map to in-stream `event: error` events — we never switch an
|
|
156
|
+
in-flight HTTP response to a 5xx once headers have shipped.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
async for ev in engine.stream_anthropic(anth_req):
|
|
160
|
+
yield _format_anthropic_sse(ev)
|
|
161
|
+
except NoProvidersAvailableError as exc:
|
|
162
|
+
# No provider produced even the first event — surface as overloaded.
|
|
163
|
+
err_event = AnthropicStreamEvent(
|
|
164
|
+
type="error",
|
|
165
|
+
data={
|
|
166
|
+
"type": "error",
|
|
167
|
+
"error": {
|
|
168
|
+
"type": "overloaded_error",
|
|
169
|
+
"message": str(exc),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
yield _format_anthropic_sse(err_event)
|
|
174
|
+
except MidStreamError as exc:
|
|
175
|
+
# v0.3-B: a provider failed AFTER emitting at least one event. We
|
|
176
|
+
# cannot fall back (client already received partial content), so
|
|
177
|
+
# close the stream with an explicit error event. `api_error`
|
|
178
|
+
# distinguishes this from "no provider could start" (overloaded).
|
|
179
|
+
logger.warning(
|
|
180
|
+
"sse-midstream-error",
|
|
181
|
+
extra={"provider": exc.provider, "original": str(exc.original)},
|
|
182
|
+
)
|
|
183
|
+
err_event = AnthropicStreamEvent(
|
|
184
|
+
type="error",
|
|
185
|
+
data={
|
|
186
|
+
"type": "error",
|
|
187
|
+
"error": {
|
|
188
|
+
"type": "api_error",
|
|
189
|
+
"message": str(exc),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
yield _format_anthropic_sse(err_event)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _format_anthropic_sse(ev: AnthropicStreamEvent) -> str:
|
|
197
|
+
"""Serialize an :class:`AnthropicStreamEvent` onto the SSE wire.
|
|
198
|
+
|
|
199
|
+
Anthropic's SSE format requires both an ``event:`` and a ``data:``
|
|
200
|
+
line per frame (unlike OpenAI's ``data:``-only chunks). The event
|
|
201
|
+
name carries the type (``message_start`` / ``content_block_delta``
|
|
202
|
+
/ ...) and the data line carries the JSON payload.
|
|
203
|
+
"""
|
|
204
|
+
payload = json.dumps(ev.data, ensure_ascii=False)
|
|
205
|
+
return f"event: {ev.type}\ndata: {payload}\n\n"
|