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
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())
|