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
coderouter/cli.py ADDED
@@ -0,0 +1,345 @@
1
+ """CLI entry: `coderouter serve` (and friends)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ import uvicorn
9
+
10
+ from coderouter import __version__
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="coderouter",
16
+ description="Local-first, free-first, fallback-built-in LLM router.",
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"coderouter {__version__}")
19
+ sub = parser.add_subparsers(dest="command", required=True)
20
+
21
+ serve = sub.add_parser("serve", help="Run the HTTP server.")
22
+ serve.add_argument("--host", default="127.0.0.1", help="Bind host (default 127.0.0.1)")
23
+ serve.add_argument("--port", type=int, default=4000, help="Bind port (default 4000)")
24
+ serve.add_argument(
25
+ "--config",
26
+ default=None,
27
+ help="Path to providers.yaml. Defaults to $CODEROUTER_CONFIG, "
28
+ "./providers.yaml, or ~/.coderouter/providers.yaml.",
29
+ )
30
+ serve.add_argument(
31
+ "--mode",
32
+ default=None,
33
+ help=(
34
+ "Override the YAML default_profile for this server instance. "
35
+ "Equivalent to setting CODEROUTER_MODE=<profile>. "
36
+ "Per-request overrides via header/body still win. "
37
+ "Unknown profile names fail fast at startup."
38
+ ),
39
+ )
40
+ serve.add_argument(
41
+ "--reload", action="store_true", help="Auto-reload on code change (dev only)."
42
+ )
43
+ serve.add_argument("--log-level", default="info", help="uvicorn log level (default: info)")
44
+
45
+ # v1.6.3: `--env-file PATH` is a thin gateway between CodeRouter and any
46
+ # tool that emits `.env` (1Password CLI `op run --env-file=...`, sops,
47
+ # direnv, plain hand-edited files). Files are parsed by
48
+ # ``coderouter.config.env_file`` (stdlib only, see env_file.py docstring
49
+ # for the supported subset of `.env` syntax). Multiple --env-file flags
50
+ # layer left-to-right; later files fill in gaps but DO NOT overwrite
51
+ # already-set environment variables (so an explicit shell-level
52
+ # `export FOO=...` always wins). Use `--env-file-override` to flip that.
53
+ serve.add_argument(
54
+ "--env-file",
55
+ metavar="PATH",
56
+ action="append",
57
+ default=None,
58
+ help=(
59
+ "Load environment variables from a `.env`-style file BEFORE "
60
+ "binding the server. Repeat to layer multiple files. By "
61
+ "default, file values do NOT override variables already in "
62
+ "the environment (the shell `export` wins). See "
63
+ "docs/troubleshooting.md §5 for 1Password / direnv / sops "
64
+ "integration recipes."
65
+ ),
66
+ )
67
+ serve.add_argument(
68
+ "--env-file-override",
69
+ action="store_true",
70
+ help=(
71
+ "When loading --env-file, overwrite variables that are already "
72
+ "set in the environment. Off by default (shell wins)."
73
+ ),
74
+ )
75
+
76
+ # v0.7-B: `coderouter doctor --check-model <provider>` runs a small
77
+ # live-probe suite against one provider and reports per-capability
78
+ # verdicts + suggested YAML patches. See coderouter/doctor.py for
79
+ # probe details and exit-code semantics (0/1/2).
80
+ doctor = sub.add_parser(
81
+ "doctor",
82
+ help="Diagnose a provider's capabilities (v0.7-B).",
83
+ description=(
84
+ "Run live probes against a provider from providers.yaml and "
85
+ "compare observed behavior with the registry / providers.yaml "
86
+ "declarations. Emits copy-paste YAML patches on mismatch. "
87
+ "Exit codes: 0 match, 2 needs tuning, 1 probe failed to run."
88
+ ),
89
+ )
90
+ # v0.7-B: --check-model targets one provider's HTTP capabilities.
91
+ # v1.6.3: --check-env targets a `.env` file's local-fs security
92
+ # (perms / .gitignore / git tracking). Either is acceptable
93
+ # alone; both can be passed in one invocation, in which case
94
+ # env-security runs first and the exit code is the worst of
95
+ # the two reports (so CI guarding against leaks AND broken
96
+ # providers can use a single command).
97
+ doctor.add_argument(
98
+ "--check-model",
99
+ metavar="PROVIDER",
100
+ default=None,
101
+ help=(
102
+ "Name of a provider declared in providers.yaml. The doctor "
103
+ "targets exactly one provider per invocation; re-run with a "
104
+ "different name to check another."
105
+ ),
106
+ )
107
+ doctor.add_argument(
108
+ "--check-env",
109
+ metavar="PATH",
110
+ nargs="?",
111
+ const="", # bare `--check-env` (no PATH) → use default discovery
112
+ default=None,
113
+ help=(
114
+ "Run env-security checks against a `.env`-style file: "
115
+ "POSIX file mode (0600 expected), .gitignore coverage, "
116
+ "and git-tracking state. Bare `--check-env` (no PATH) "
117
+ "looks for `./.env` then `~/.coderouter/.env`. "
118
+ "See docs/troubleshooting.md §5 for the threat model."
119
+ ),
120
+ )
121
+ doctor.add_argument(
122
+ "--config",
123
+ default=None,
124
+ help=(
125
+ "Path to providers.yaml. Defaults to $CODEROUTER_CONFIG, "
126
+ "./providers.yaml, or ~/.coderouter/providers.yaml."
127
+ ),
128
+ )
129
+
130
+ # v1.5-C: `coderouter stats` — live TUI over GET /metrics.json.
131
+ # Lazy-imports ``curses`` inside the runner so the CLI boot stays
132
+ # snappy and environments without curses (rare, but e.g. minimal
133
+ # containers) can still use ``--once`` for script-mode dumps.
134
+ stats = sub.add_parser(
135
+ "stats",
136
+ help="Live TUI over the metrics endpoint (v1.5-C).",
137
+ description=(
138
+ "Connect to a running `coderouter serve` and render providers, "
139
+ "fallback/gate counters, and a recent-events ring. Refreshes "
140
+ "once per --interval seconds. Use --once for a single plain-"
141
+ "text dump (also the default when stdout is not a TTY, so "
142
+ "`coderouter stats | grep foo` works in scripts)."
143
+ ),
144
+ )
145
+ from coderouter.cli_stats import DEFAULT_INTERVAL_S, DEFAULT_URL
146
+
147
+ stats.add_argument(
148
+ "--url",
149
+ default=DEFAULT_URL,
150
+ help=f"Metrics endpoint URL (default {DEFAULT_URL}).",
151
+ )
152
+ stats.add_argument(
153
+ "--interval",
154
+ type=float,
155
+ default=DEFAULT_INTERVAL_S,
156
+ help=f"Refresh interval in seconds (default {DEFAULT_INTERVAL_S}).",
157
+ )
158
+ stats.add_argument(
159
+ "--once",
160
+ action="store_true",
161
+ help="Print one snapshot as plain text and exit (scripts / non-tty).",
162
+ )
163
+
164
+ return parser
165
+
166
+
167
+ def main(argv: list[str] | None = None) -> int:
168
+ args = _build_parser().parse_args(argv)
169
+
170
+ if args.command == "serve":
171
+ # We pass the config path via env so the app factory (loaded by uvicorn
172
+ # in a fresh process when --reload is on) can pick it up.
173
+ import os
174
+
175
+ # v1.6.3: --env-file is processed FIRST so subsequent --config /
176
+ # --mode handling (and the worker's eventual os.environ.get(...)
177
+ # lookups) can see file-loaded values. We don't auto-source ./.env
178
+ # — the user must opt in explicitly with --env-file ./.env, which
179
+ # keeps the "what env reaches the worker?" answer 1:1 with the
180
+ # command line and prevents surprise hijacks of API keys.
181
+ if args.env_file:
182
+ from coderouter.config.env_file import EnvFileError, load_env_file
183
+
184
+ for path in args.env_file:
185
+ try:
186
+ applied = load_env_file(path, override=args.env_file_override)
187
+ except FileNotFoundError as exc:
188
+ print(f"serve: --env-file: {exc}", file=sys.stderr)
189
+ return 1
190
+ except EnvFileError as exc:
191
+ print(f"serve: --env-file: {exc}", file=sys.stderr)
192
+ return 1
193
+ # Single-line summary so the operator can verify keys
194
+ # actually landed (vs being skipped because they were
195
+ # already in the environment). We deliberately log key
196
+ # NAMES only, never values — secrets must not leak via
197
+ # stdout / stderr.
198
+ if applied:
199
+ print(
200
+ f"serve: --env-file {path}: loaded {len(applied)} "
201
+ f"variable(s): {', '.join(sorted(applied))}",
202
+ file=sys.stderr,
203
+ )
204
+ else:
205
+ print(
206
+ f"serve: --env-file {path}: 0 variables applied "
207
+ f"(all keys already in environment, "
208
+ f"--env-file-override disabled)",
209
+ file=sys.stderr,
210
+ )
211
+
212
+ if args.config:
213
+ os.environ["CODEROUTER_CONFIG"] = args.config
214
+
215
+ # v0.6-A: --mode translates to CODEROUTER_MODE for the worker. Strip
216
+ # surrounding whitespace defensively — quoting accidents like
217
+ # ``--mode " coding "`` would otherwise surface as confusing
218
+ # "profile not found: ' coding '" errors in the loader.
219
+ if args.mode is not None:
220
+ stripped = args.mode.strip()
221
+ if stripped:
222
+ os.environ["CODEROUTER_MODE"] = stripped
223
+
224
+ uvicorn.run(
225
+ "coderouter.ingress.app:create_app",
226
+ factory=True,
227
+ host=args.host,
228
+ port=args.port,
229
+ reload=args.reload,
230
+ log_level=args.log_level,
231
+ )
232
+ return 0
233
+
234
+ if args.command == "doctor":
235
+ return _run_doctor(args)
236
+
237
+ if args.command == "stats":
238
+ # v1.5-C: stats is intentionally a thin wrapper — all logic
239
+ # (fetch, render, curses loop) lives in coderouter.cli_stats so
240
+ # the CLI file stays focused on argparse wiring.
241
+ from coderouter.cli_stats import main as stats_main
242
+
243
+ return stats_main(args.url, interval=args.interval, once=args.once)
244
+
245
+ print(f"unknown command: {args.command}", file=sys.stderr)
246
+ return 2
247
+
248
+
249
+ def _run_doctor(args: argparse.Namespace) -> int:
250
+ """Drive ``coderouter doctor`` (v0.7-B `--check-model`, v1.6.3 `--check-env`).
251
+
252
+ Kept as a small function rather than a nested import site so tests
253
+ that monkeypatch the doctor module have a stable attribute
254
+ (``coderouter.cli._run_doctor``) to target. The actual probe logic
255
+ lives in ``coderouter.doctor`` (HTTP probes) and
256
+ ``coderouter.env_security`` (filesystem / git probes) — this just
257
+ wires the entry points together and pipes output to stdout.
258
+
259
+ When both flags are passed, env-security runs first (cheap, local)
260
+ and the model probe runs second; the final exit code is the
261
+ worst-case of the two reports so CI guarding against both leak
262
+ risks AND broken providers can use a single command.
263
+ """
264
+ if args.check_model is None and args.check_env is None:
265
+ print(
266
+ "doctor: provide --check-model PROVIDER and/or --check-env [PATH]",
267
+ file=sys.stderr,
268
+ )
269
+ return 1
270
+
271
+ worst_exit = 0
272
+
273
+ # v1.6.3: --check-env runs first because it's cheap (no HTTP) and
274
+ # because if .env is leaking secrets that's a more urgent thing for
275
+ # the operator to see than a downstream model issue.
276
+ if args.check_env is not None:
277
+ worst_exit = max(worst_exit, _run_check_env(args.check_env))
278
+
279
+ if args.check_model is not None:
280
+ worst_exit = max(worst_exit, _run_check_model(args))
281
+
282
+ return worst_exit
283
+
284
+
285
+ def _run_check_model(args: argparse.Namespace) -> int:
286
+ """v0.7-B: per-provider HTTP capability probe."""
287
+ from coderouter.config.loader import load_config
288
+ from coderouter.doctor import (
289
+ exit_code_for,
290
+ format_report,
291
+ run_check_model_sync,
292
+ )
293
+
294
+ try:
295
+ config = load_config(args.config)
296
+ except FileNotFoundError as exc:
297
+ print(f"doctor: {exc}", file=sys.stderr)
298
+ return 1
299
+ except Exception as exc: # pydantic ValidationError, YAML parse error, etc.
300
+ print(f"doctor: failed to load config: {exc}", file=sys.stderr)
301
+ return 1
302
+
303
+ try:
304
+ report = run_check_model_sync(config, args.check_model)
305
+ except KeyError as exc:
306
+ print(f"doctor: {exc}", file=sys.stderr)
307
+ return 1
308
+
309
+ print(format_report(report))
310
+ return exit_code_for(report)
311
+
312
+
313
+ def _run_check_env(arg_value: str) -> int:
314
+ """v1.6.3: filesystem / git security checks for `.env`.
315
+
316
+ ``arg_value`` is the value argparse hands us:
317
+ * ``""`` → bare ``--check-env`` with no PATH; auto-discover
318
+ (./.env then ~/.coderouter/.env).
319
+ * else → operator-supplied path; use verbatim.
320
+ """
321
+ from pathlib import Path
322
+
323
+ from coderouter.env_security import (
324
+ check_env_security,
325
+ exit_code_for_env_security,
326
+ format_env_security_report,
327
+ )
328
+
329
+ if arg_value:
330
+ target = Path(arg_value).expanduser()
331
+ else:
332
+ # Auto-discovery: cwd first (project-local), then user-global.
333
+ candidates = [Path.cwd() / ".env", Path.home() / ".coderouter" / ".env"]
334
+ target = next((c for c in candidates if c.exists()), candidates[0])
335
+ # Even if neither exists, run check_env_security against the
336
+ # first candidate — its existence check will SKIP loudly so the
337
+ # operator knows nothing was found.
338
+
339
+ report = check_env_security(target)
340
+ print(format_env_security_report(report))
341
+ return exit_code_for_env_security(report)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ sys.exit(main())