coderouter-cli 1.7.0__py3-none-any.whl → 1.8.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/cli.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import sys
7
+ from pathlib import Path
7
8
 
8
9
  import uvicorn
9
10
 
@@ -126,6 +127,40 @@ def _build_parser() -> argparse.ArgumentParser:
126
127
  "./providers.yaml, or ~/.coderouter/providers.yaml."
127
128
  ),
128
129
  )
130
+ # v1.7-B (#3): --apply writes the doctor-emitted YAML patches back
131
+ # into providers.yaml / model-capabilities.yaml while preserving
132
+ # comments and key order. --dry-run is the same path minus the file
133
+ # write — prints a unified diff (``git apply``-compatible) for review.
134
+ # Bare ``--dry-run`` (without ``--apply``) is the canonical "preview"
135
+ # form; ``--apply --dry-run`` is also accepted as an explicit synonym
136
+ # so muscle-memory from ``git apply --dry-run`` works either way.
137
+ # Both flags are no-ops when --check-model is absent (--check-env
138
+ # has its own remediation surface and is not in scope for --apply).
139
+ # Implementation lives in coderouter/doctor_apply.py — round-trip
140
+ # via the optional ``ruamel.yaml`` dependency, see that module's
141
+ # docstring for the contract and shape invariants.
142
+ doctor.add_argument(
143
+ "--apply",
144
+ action="store_true",
145
+ help=(
146
+ "After --check-model, write the suggested patches back into "
147
+ "providers.yaml / model-capabilities.yaml. A `.bak` backup is "
148
+ "created next to each modified file. Idempotent: a re-run "
149
+ "after a successful apply is a no-op (no write, exit 0). "
150
+ "Requires the optional `ruamel.yaml` dependency — install "
151
+ "via `pip install coderouter-cli[doctor]`."
152
+ ),
153
+ )
154
+ doctor.add_argument(
155
+ "--dry-run",
156
+ action="store_true",
157
+ help=(
158
+ "Preview --apply changes as a unified diff without writing "
159
+ "to disk. Implies --apply mode for diff generation. The "
160
+ "output is `git apply`-compatible so it can be saved and "
161
+ "applied later (or piped to `patch -p0`)."
162
+ ),
163
+ )
129
164
 
130
165
  # v1.5-C: `coderouter stats` — live TUI over GET /metrics.json.
131
166
  # Lazy-imports ``curses`` inside the runner so the CLI boot stays
@@ -283,7 +318,14 @@ def _run_doctor(args: argparse.Namespace) -> int:
283
318
 
284
319
 
285
320
  def _run_check_model(args: argparse.Namespace) -> int:
286
- """v0.7-B: per-provider HTTP capability probe."""
321
+ """v0.7-B: per-provider HTTP capability probe.
322
+
323
+ v1.7-B (#3): when ``--apply`` or ``--dry-run`` is also set, we run
324
+ the same probes and then route the emitted patches through
325
+ :func:`coderouter.doctor_apply.apply_doctor_patches`. Bare probe
326
+ (no apply / dry-run flags) keeps the original behavior verbatim
327
+ so existing CI integrations don't change shape.
328
+ """
287
329
  from coderouter.config.loader import load_config
288
330
  from coderouter.doctor import (
289
331
  exit_code_for,
@@ -307,7 +349,131 @@ def _run_check_model(args: argparse.Namespace) -> int:
307
349
  return 1
308
350
 
309
351
  print(format_report(report))
310
- return exit_code_for(report)
352
+ base_exit = exit_code_for(report)
353
+
354
+ apply_mode = bool(getattr(args, "apply", False))
355
+ dry_run_mode = bool(getattr(args, "dry_run", False))
356
+ if apply_mode or dry_run_mode:
357
+ # Resolve the same providers.yaml the loader picked up so the
358
+ # apply step writes back to the exact file that was probed
359
+ # (avoids a mismatch when CODEROUTER_CONFIG points elsewhere
360
+ # than the default path).
361
+ config_path = _resolve_config_path(args.config)
362
+ return _run_apply_or_dry_run(
363
+ report=report,
364
+ config_path=config_path,
365
+ write=apply_mode and not dry_run_mode,
366
+ base_exit=base_exit,
367
+ )
368
+
369
+ return base_exit
370
+
371
+
372
+ def _resolve_config_path(explicit: str | None) -> Path:
373
+ """Mirror loader._candidate_paths and return the file actually used.
374
+
375
+ Used by ``--apply`` to write back to the same path the loader
376
+ picked up when it parsed providers.yaml. Falls through the same
377
+ search order so a ``CODEROUTER_CONFIG`` env or default-path lookup
378
+ matches the live config.
379
+ """
380
+ import os
381
+
382
+ candidates: list[Path] = []
383
+ if explicit:
384
+ candidates.append(Path(explicit))
385
+ if env_path := os.environ.get("CODEROUTER_CONFIG"):
386
+ candidates.append(Path(env_path))
387
+ candidates.append(Path.cwd() / "providers.yaml")
388
+ candidates.append(Path.home() / ".coderouter" / "providers.yaml")
389
+ for p in candidates:
390
+ if p.is_file():
391
+ return p
392
+ # Fall back to the last candidate even if absent — the apply step
393
+ # will surface a clearer error than this resolver would.
394
+ return candidates[-1]
395
+
396
+
397
+ def _run_apply_or_dry_run(
398
+ *,
399
+ report: object,
400
+ config_path: Path,
401
+ write: bool,
402
+ base_exit: int,
403
+ ) -> int:
404
+ """v1.7-B (#3): drive ``apply_doctor_patches`` and render the result.
405
+
406
+ Returns 0 when the apply step itself is clean (regardless of
407
+ whether the underlying probes flagged ``NEEDS_TUNING``). The
408
+ rationale: once the operator has applied the patches, the next
409
+ ``doctor`` run is the right place to re-evaluate the chain — a
410
+ successful apply should not propagate the "exit 2 / needs tuning"
411
+ signal because the issue is now (presumably) addressed.
412
+ """
413
+ from coderouter.doctor_apply import (
414
+ DoctorApplyError,
415
+ MissingDependencyError,
416
+ apply_doctor_patches,
417
+ )
418
+
419
+ print() # blank line between probe report and apply section
420
+ try:
421
+ result = apply_doctor_patches(
422
+ report=report,
423
+ config_path=config_path,
424
+ write=write,
425
+ )
426
+ except MissingDependencyError as exc:
427
+ print(f"doctor --apply: {exc}", file=sys.stderr)
428
+ return 1
429
+ except DoctorApplyError as exc:
430
+ print(f"doctor --apply: {exc}", file=sys.stderr)
431
+ return 1
432
+
433
+ label = "Apply" if write else "Dry-run"
434
+ print(f"{label}: {len(result.target_paths)} target file(s).")
435
+ if result.skipped_unknown_target:
436
+ print(
437
+ f" warning: {len(result.skipped_unknown_target)} probe(s) "
438
+ f"emitted an unknown target_file value: "
439
+ f"{sorted(set(result.skipped_unknown_target))}",
440
+ file=sys.stderr,
441
+ )
442
+
443
+ if result.is_no_op:
444
+ # Distinguish "nothing to do because base_exit was 0" from
445
+ # "nothing to do because everything already applied":
446
+ if base_exit == 0:
447
+ print(" No NEEDS_TUNING patches to apply — chain is healthy.")
448
+ else:
449
+ print(
450
+ f" All {result.no_op_patches} patch(es) already applied "
451
+ f"— providers.yaml is up to date."
452
+ )
453
+ return 0
454
+
455
+ print(
456
+ f" {result.changes_applied} patch(es) applied"
457
+ + (f", {result.no_op_patches} already up to date" if result.no_op_patches else "")
458
+ + "."
459
+ )
460
+ for path in result.target_paths:
461
+ diff = result.diffs.get(str(path), "")
462
+ if not diff:
463
+ continue
464
+ print()
465
+ print(diff, end="" if diff.endswith("\n") else "\n")
466
+
467
+ if write:
468
+ for orig, bak in result.backups.items():
469
+ print(f" Backup: {orig} → {bak}")
470
+ else:
471
+ print()
472
+ print(" (dry-run — no files were modified. Re-run with --apply to write.)")
473
+
474
+ return 0
475
+
476
+
311
477
 
312
478
 
313
479
  def _run_check_env(arg_value: str) -> int:
@@ -102,6 +102,19 @@ class RegistryCapabilities(BaseModel):
102
102
  "doctor --check-model num_ctx probe (not consumed in v0.7-A)."
103
103
  ),
104
104
  )
105
+ claude_code_suitability: Literal["ok", "degraded"] | None = Field(
106
+ default=None,
107
+ description=(
108
+ "v1.7-B: hint for use behind Claude Code's agentic-coding "
109
+ "harness. ``degraded`` = the model over-eagerly invokes "
110
+ "tools/skills when given Claude Code's system prompt — e.g. "
111
+ "Llama-3.3-70B treating small talk like ``こんにちは`` as "
112
+ "``Skill(hello)`` invocations (see docs/troubleshooting.md "
113
+ "§4-1 for the symptom log). ``ok`` = explicitly verified "
114
+ "clean. ``None`` = no opinion (treated as ``ok`` at the "
115
+ "startup check)."
116
+ ),
117
+ )
105
118
 
106
119
 
107
120
  class CapabilityRule(BaseModel):
@@ -168,6 +181,7 @@ class ResolvedCapabilities:
168
181
  reasoning_passthrough: bool | None = None
169
182
  tools: bool | None = None
170
183
  max_context_tokens: int | None = None
184
+ claude_code_suitability: Literal["ok", "degraded"] | None = None
171
185
 
172
186
 
173
187
  # ---------------------------------------------------------------------------
@@ -218,11 +232,13 @@ class CapabilityRegistry:
218
232
  resolved_reasoning: bool | None = None
219
233
  resolved_tools: bool | None = None
220
234
  resolved_max_ctx: int | None = None
235
+ resolved_suitability: Literal["ok", "degraded"] | None = None
221
236
 
222
237
  thinking_locked = False
223
238
  reasoning_locked = False
224
239
  tools_locked = False
225
240
  max_ctx_locked = False
241
+ suitability_locked = False
226
242
 
227
243
  for rule in self._rules:
228
244
  if not rule.kind_matches(kind):
@@ -242,7 +258,16 @@ class CapabilityRegistry:
242
258
  if not max_ctx_locked and caps.max_context_tokens is not None:
243
259
  resolved_max_ctx = caps.max_context_tokens
244
260
  max_ctx_locked = True
245
- if thinking_locked and reasoning_locked and tools_locked and max_ctx_locked:
261
+ if not suitability_locked and caps.claude_code_suitability is not None:
262
+ resolved_suitability = caps.claude_code_suitability
263
+ suitability_locked = True
264
+ if (
265
+ thinking_locked
266
+ and reasoning_locked
267
+ and tools_locked
268
+ and max_ctx_locked
269
+ and suitability_locked
270
+ ):
246
271
  break
247
272
 
248
273
  return ResolvedCapabilities(
@@ -250,6 +275,7 @@ class CapabilityRegistry:
250
275
  reasoning_passthrough=resolved_reasoning,
251
276
  tools=resolved_tools,
252
277
  max_context_tokens=resolved_max_ctx,
278
+ claude_code_suitability=resolved_suitability,
253
279
  )
254
280
 
255
281
  # ------------------------------------------------------------------
@@ -31,6 +31,11 @@
31
31
  # reasoning_passthrough: bool — opt OUT of the adapter's passive `reasoning` strip
32
32
  # tools: bool — upstream reliably emits tool_calls
33
33
  # max_context_tokens: int — declared model context window
34
+ # claude_code_suitability: str — "ok" | "degraded". Hint for use behind
35
+ # Claude Code's agentic-coding harness;
36
+ # "degraded" triggers a startup WARN when
37
+ # the provider is on a `claude-code-*`
38
+ # chain. See docs/troubleshooting.md §4-1.
34
39
  #
35
40
  # First-match semantics: rules within a file are evaluated top-to-bottom
36
41
  # per flag; the first rule whose glob matches AND declares that flag
@@ -84,3 +89,147 @@ rules:
84
89
  kind: anthropic
85
90
  capabilities:
86
91
  thinking: true
92
+
93
+ # ------------------------------------------------------------------
94
+ # Claude Code suitability — agentic harness compatibility hint (v1.7-B).
95
+ #
96
+ # "degraded" = the model over-eagerly invokes tools / skills when given
97
+ # Claude Code's system prompt, even for trivial small talk. Concretely,
98
+ # Llama-3.3-70B (verified 2026-04-24 against NVIDIA NIM) rewrites
99
+ # ``こんにちは`` into ``Skill(hello)`` invocations and fabricates
100
+ # ``AskUserQuestion("What is your name?")`` elicitations — see
101
+ # docs/articles/note-nvidia-nim.md §6-2 + docs/troubleshooting.md §4-1.
102
+ #
103
+ # Glob coverage: NIM uses ``meta/llama-3.3-70b-instruct``, OpenRouter
104
+ # uses ``meta-llama/llama-3.3-70b-instruct``, some local servers use
105
+ # ``Llama-3.3-70B-Instruct``. fnmatch is case-sensitive so we declare
106
+ # both common case-variants explicitly. The leading ``*`` wildcard
107
+ # absorbs any vendor-prefix slug (``meta/`` / ``meta-llama/`` / etc.).
108
+ #
109
+ # An operator who has tuned their Llama-3.3 deployment (custom system
110
+ # prompt, tool whitelist, etc.) can opt out via
111
+ # ``~/.coderouter/model-capabilities.yaml`` with the matching glob and
112
+ # ``claude_code_suitability: ok``.
113
+ # ------------------------------------------------------------------
114
+
115
+ - match: "*llama-3.3-70b*"
116
+ kind: openai_compat
117
+ capabilities:
118
+ claude_code_suitability: degraded
119
+
120
+ - match: "*Llama-3.3-70B*"
121
+ kind: openai_compat
122
+ capabilities:
123
+ claude_code_suitability: degraded
124
+
125
+ # ------------------------------------------------------------------
126
+ # Qwen3-Coder family — agentic coding 専用設計 (v1.7-B 追加)
127
+ #
128
+ # Alibaba の Qwen3-Coder series は agentic coding と tool use を
129
+ # 主目的に学習されており、Claude Sonnet の tool-call 行動に最も近い
130
+ # ローカル/オープン代替として知られています (note 記事 + r/LocalLLaMA
131
+ # 2026-04 Megathread コミュニティ評)。
132
+ #
133
+ # ここで `tools: true` を先回り宣言することで、providers.yaml 側で
134
+ # 個別に capabilities.tools: true を書かなくても tool-call 経路が
135
+ # 有効になります。`claude_code_suitability: ok` も併せて宣言、
136
+ # claude-code-* プロファイル startup check (v1.7-B) で degraded 警告が
137
+ # 出ないことを保証。
138
+ #
139
+ # glob 範囲 (case-sensitive — 大文字版も併記):
140
+ # Ollama tag : qwen3-coder:* (例: qwen3-coder:30b-a3b)
141
+ # NIM slug : qwen/qwen3-coder-* (例: qwen/qwen3-coder-480b-a35b-instruct)
142
+ # OpenRouter slug : qwen/qwen3-coder* (例: qwen/qwen3-coder:free)
143
+ # HF GGUF (Ollama) : hf.co/*/Qwen3-Coder-*-GGUF* (大文字)
144
+ # ------------------------------------------------------------------
145
+
146
+ - match: "qwen3-coder:*"
147
+ kind: openai_compat
148
+ capabilities:
149
+ tools: true
150
+ claude_code_suitability: ok
151
+
152
+ - match: "qwen/qwen3-coder-*"
153
+ kind: openai_compat
154
+ capabilities:
155
+ tools: true
156
+ claude_code_suitability: ok
157
+
158
+ - match: "qwen/qwen3-coder*"
159
+ kind: openai_compat
160
+ capabilities:
161
+ tools: true
162
+ claude_code_suitability: ok
163
+
164
+ - match: "*Qwen3-Coder-*"
165
+ kind: openai_compat
166
+ capabilities:
167
+ tools: true
168
+ claude_code_suitability: ok
169
+
170
+ # ------------------------------------------------------------------
171
+ # Qwen3.6 family (v1.7-B 追加)
172
+ #
173
+ # 2026-04 リリースの Qwen3.6 シリーズ。Ollama 公式 tag は
174
+ # qwen3.6:27b / qwen3.6:35b、全 variant が tools+vision+thinking 対応、
175
+ # 256K context。note 記事 (r/LocalLLaMA 2026-04 Megathread) で
176
+ # 「Claude Code 代替として最高」「local champ」と評価。
177
+ # Qwen3-Coder と並んで Claude Sonnet 互換性が高い。
178
+ # ------------------------------------------------------------------
179
+
180
+ - match: "qwen3.6:*"
181
+ kind: openai_compat
182
+ capabilities:
183
+ tools: true
184
+ claude_code_suitability: ok
185
+
186
+ - match: "qwen/qwen3.6-*"
187
+ kind: openai_compat
188
+ capabilities:
189
+ tools: true
190
+ claude_code_suitability: ok
191
+
192
+ # ------------------------------------------------------------------
193
+ # Gemma 4 family (v1.7-B 追加)
194
+ #
195
+ # Google 公式 Gemma 4。Ollama 公式 tag は gemma4:e2b / e4b / 26b / 31b、
196
+ # 全 variant が tools+vision+thinking 対応、E2B/E4B は audio もサポート。
197
+ # MoE (26b は active 3.8B / total 25.2B)。note 記事で「日常・バランスの
198
+ # 王者」と評価。Claude Haiku 互換性に近い簡潔な応答スタイル。
199
+ # ------------------------------------------------------------------
200
+
201
+ - match: "gemma4:*"
202
+ kind: openai_compat
203
+ capabilities:
204
+ tools: true
205
+
206
+ - match: "google/gemma-4*"
207
+ kind: openai_compat
208
+ capabilities:
209
+ tools: true
210
+
211
+ # ------------------------------------------------------------------
212
+ # GLM family (Z.AI / Zhipu AI、v1.7-B 追加)
213
+ #
214
+ # Z.AI の OpenAI-compat エンドポイントから利用する GLM-4.x / 5.x 系列。
215
+ # モデル名 slug は **大文字必須** (Cursor 等のドキュメント明記)。
216
+ # tools / vision 対応、Coding Plan の API 経由でも General API 経由でも
217
+ # 同じモデルが利用可能。
218
+ #
219
+ # GLM-5.1 / GLM-5-Turbo: Opus 級フラッグシップ
220
+ # GLM-4.7: Sonnet/Opus 級、Coding Plan のデフォルト
221
+ # GLM-4.5-Air: Haiku 級、軽量・高速
222
+ #
223
+ # note 記事は「intent 理解が Claude Opus 級」と評価。reasoning 用途に
224
+ # 特に向く。
225
+ # ------------------------------------------------------------------
226
+
227
+ - match: "GLM-5*"
228
+ kind: openai_compat
229
+ capabilities:
230
+ tools: true
231
+
232
+ - match: "GLM-4.[5-9]*"
233
+ kind: openai_compat
234
+ capabilities:
235
+ tools: true