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.
Files changed (43) hide show
  1. coderouter/__init__.py +17 -0
  2. coderouter/__main__.py +6 -0
  3. coderouter/adapters/__init__.py +23 -0
  4. coderouter/adapters/anthropic_native.py +502 -0
  5. coderouter/adapters/base.py +220 -0
  6. coderouter/adapters/openai_compat.py +395 -0
  7. coderouter/adapters/registry.py +17 -0
  8. coderouter/cli.py +345 -0
  9. coderouter/cli_stats.py +751 -0
  10. coderouter/config/__init__.py +10 -0
  11. coderouter/config/capability_registry.py +339 -0
  12. coderouter/config/env_file.py +295 -0
  13. coderouter/config/loader.py +73 -0
  14. coderouter/config/schemas.py +515 -0
  15. coderouter/data/__init__.py +7 -0
  16. coderouter/data/model-capabilities.yaml +86 -0
  17. coderouter/doctor.py +1596 -0
  18. coderouter/env_security.py +434 -0
  19. coderouter/errors.py +29 -0
  20. coderouter/ingress/__init__.py +5 -0
  21. coderouter/ingress/anthropic_routes.py +205 -0
  22. coderouter/ingress/app.py +144 -0
  23. coderouter/ingress/dashboard_routes.py +493 -0
  24. coderouter/ingress/metrics_routes.py +92 -0
  25. coderouter/ingress/openai_routes.py +153 -0
  26. coderouter/logging.py +315 -0
  27. coderouter/metrics/__init__.py +39 -0
  28. coderouter/metrics/collector.py +471 -0
  29. coderouter/metrics/prometheus.py +221 -0
  30. coderouter/output_filters.py +407 -0
  31. coderouter/routing/__init__.py +13 -0
  32. coderouter/routing/auto_router.py +244 -0
  33. coderouter/routing/capability.py +285 -0
  34. coderouter/routing/fallback.py +611 -0
  35. coderouter/translation/__init__.py +57 -0
  36. coderouter/translation/anthropic.py +204 -0
  37. coderouter/translation/convert.py +1291 -0
  38. coderouter/translation/tool_repair.py +236 -0
  39. coderouter_cli-1.7.0.dist-info/METADATA +509 -0
  40. coderouter_cli-1.7.0.dist-info/RECORD +43 -0
  41. coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
  42. coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
  43. 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,5 @@
1
+ """HTTP ingress (OpenAI-compatible in v0.1; Anthropic-compatible coming v0.2)."""
2
+
3
+ from coderouter.ingress.app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -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"