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,73 @@
1
+ """Load and validate CodeRouter configuration.
2
+
3
+ Search order (first hit wins):
4
+ 1. Path passed explicitly (CLI --config flag)
5
+ 2. $CODEROUTER_CONFIG env var
6
+ 3. ./providers.yaml (current working dir)
7
+ 4. ~/.coderouter/providers.yaml
8
+
9
+ Secrets are resolved by reading the env var named by `api_key_env`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ from coderouter.config.schemas import CodeRouterConfig
20
+
21
+
22
+ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
23
+ paths: list[Path] = []
24
+ if explicit:
25
+ paths.append(Path(explicit))
26
+ if env_path := os.environ.get("CODEROUTER_CONFIG"):
27
+ paths.append(Path(env_path))
28
+ paths.append(Path.cwd() / "providers.yaml")
29
+ paths.append(Path.home() / ".coderouter" / "providers.yaml")
30
+ return paths
31
+
32
+
33
+ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
34
+ """Load providers.yaml + apply ALLOW_PAID env override."""
35
+ candidates = _candidate_paths(path)
36
+ chosen: Path | None = next((p for p in candidates if p.is_file()), None)
37
+ if chosen is None:
38
+ searched = "\n ".join(str(p) for p in candidates)
39
+ raise FileNotFoundError(
40
+ f"providers.yaml not found. Searched:\n {searched}\n"
41
+ f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
42
+ )
43
+
44
+ with chosen.open("r", encoding="utf-8") as f:
45
+ raw = yaml.safe_load(f) or {}
46
+
47
+ # v0.6-A: CODEROUTER_MODE env overrides the YAML default_profile BEFORE
48
+ # initial validation, so that (a) a typo'd file default that would otherwise
49
+ # fail can be rescued by an explicit env-set mode, and (b) the model-
50
+ # validator's "default_profile must exist in profiles" check applies to the
51
+ # *effective* mode the engine will see, not the pre-override YAML value.
52
+ env_mode = os.environ.get("CODEROUTER_MODE", "").strip()
53
+ if env_mode:
54
+ raw["default_profile"] = env_mode
55
+
56
+ config = CodeRouterConfig.model_validate(raw)
57
+
58
+ # Env var ALLOW_PAID overrides file value (so users can flip it per-shell)
59
+ env_paid = os.environ.get("ALLOW_PAID", "").strip().lower()
60
+ if env_paid in {"1", "true", "yes", "on"}:
61
+ config.allow_paid = True
62
+ elif env_paid in {"0", "false", "no", "off"}:
63
+ config.allow_paid = False
64
+
65
+ return config
66
+
67
+
68
+ def resolve_api_key(api_key_env: str | None) -> str | None:
69
+ """Look up an API key from the named env var. Returns None if unset."""
70
+ if not api_key_env:
71
+ return None
72
+ value = os.environ.get(api_key_env, "").strip()
73
+ return value or None
@@ -0,0 +1,515 @@
1
+ """Pydantic schemas for providers.yaml and runtime config.
2
+
3
+ Design notes (see plan.md §2 / §5.4):
4
+ - Capability flags let providers declare what they support.
5
+ - `paid: true` providers are blocked unless ALLOW_PAID=true (memo.txt §2.3).
6
+ - Adapter `kind` in v0.3.x:
7
+ - "openai_compat": llama.cpp / Ollama / OpenRouter / LM Studio / Together / Groq ...
8
+ - "anthropic": native Anthropic Messages API passthrough (api.anthropic.com,
9
+ or any server speaking the Anthropic wire format). When the
10
+ Anthropic ingress routes to this provider, no translation is
11
+ performed — request and response flow through verbatim.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Literal, Self
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
20
+
21
+
22
+ class Capabilities(BaseModel):
23
+ """Capability flags per provider (plan.md §2.5)."""
24
+
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+ chat: bool = True
28
+ streaming: bool = True
29
+ tools: bool = False
30
+ vision: bool = False
31
+ prompt_cache: bool = False
32
+ # v0.5-A: Anthropic's extended-thinking body field (`thinking: {type:
33
+ # enabled, budget_tokens: N}` or `{type: enabled}` adaptive). Narrow,
34
+ # per-model flag — when unset, the capability gate falls back to a
35
+ # model-name heuristic (see coderouter/routing/capability.py). Distinct
36
+ # from `reasoning_control` below, which is the v1.0+ abstract interface.
37
+ thinking: bool = False
38
+ # v0.5-C: opt out of the openai_compat adapter's passive `reasoning`
39
+ # field strip. By default (False), the adapter removes non-standard
40
+ # `message.reasoning` / `delta.reasoning` fields emitted by some
41
+ # OpenRouter free-tier models (gpt-oss-120b:free confirmed 2026-04)
42
+ # because strict OpenAI clients reject the unknown key. Set True when
43
+ # you explicitly want the raw reasoning text to flow to the client
44
+ # (e.g. CodeRouter is fronting a reasoning-aware downstream).
45
+ reasoning_passthrough: bool = False
46
+ # v1.0+ fields, declared early so providers.yaml can future-proof
47
+ reasoning_control: Literal["none", "openai", "anthropic", "provider_specific"] = "none"
48
+ mcp: Literal["none", "anthropic", "provider_specific"] = "none"
49
+ openai_compatible: bool = True
50
+
51
+
52
+ class ProviderConfig(BaseModel):
53
+ """A single provider entry from providers.yaml.
54
+
55
+ Examples:
56
+ - Local llama.cpp server: kind=openai_compat, base_url=http://localhost:8080/v1
57
+ - OpenRouter free: kind=openai_compat, base_url=https://openrouter.ai/api/v1
58
+ - (future) Anthropic: kind=anthropic, base_url=https://api.anthropic.com
59
+ """
60
+
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ name: str = Field(..., description="Unique identifier used in profiles.yaml")
64
+ kind: Literal["openai_compat", "anthropic"] = Field(
65
+ default="openai_compat",
66
+ description=(
67
+ "Adapter type. 'openai_compat' covers llama.cpp / Ollama / "
68
+ "OpenRouter / LM Studio / Together / Groq. 'anthropic' is the "
69
+ "native Anthropic Messages API passthrough (v0.3.x)."
70
+ ),
71
+ )
72
+ base_url: HttpUrl
73
+ model: str = Field(..., description="Upstream model id sent in the request body")
74
+ api_key_env: str | None = Field(
75
+ default=None,
76
+ description="Env var name holding the API key. None = no auth (e.g. local).",
77
+ )
78
+
79
+ # Routing-relevant flags
80
+ paid: bool = Field(
81
+ default=False,
82
+ description="If true, only used when ALLOW_PAID=true (plan.md §2.3).",
83
+ )
84
+ timeout_s: float = Field(default=30.0, ge=1.0, le=600.0)
85
+
86
+ # Provider-specific extras merged into the outbound request body.
87
+ # Use for non-standard fields like Ollama's `think: false`, `keep_alive`,
88
+ # `options.num_ctx`, or any vendor-specific toggle. User-supplied request
89
+ # fields take precedence over these defaults.
90
+ extra_body: dict[str, object] = Field(default_factory=dict)
91
+
92
+ # Directive appended to the system message content before sending.
93
+ # Use for model-intrinsic switches that travel reliably through any API
94
+ # layer — e.g. Qwen3's "/no_think" to skip the reasoning track, since
95
+ # Ollama's OpenAI-compat endpoint silently drops the native `think` flag.
96
+ append_system_prompt: str | None = Field(
97
+ default=None,
98
+ description="Appended to existing system message (or added as a new one).",
99
+ )
100
+
101
+ # v1.0-A: declarative output cleaning chain. Names map to filter
102
+ # implementations in ``coderouter/output_filters.py`` — currently
103
+ # ``strip_thinking`` (``<think>...</think>`` blocks) and
104
+ # ``strip_stop_markers`` (``<|python_tag|>`` / ``<|eot_id|>`` /
105
+ # ``<|im_end|>`` / ``<|turn|>`` / ``<|end|>`` / ``<|channel>thought``).
106
+ # Empty = no scrubbing (backward compatible with v0.7.x). Applied at
107
+ # the adapter boundary on both streaming and non-streaming paths;
108
+ # stateful across SSE chunk boundaries. Unknown names fail at load.
109
+ output_filters: list[str] = Field(
110
+ default_factory=list,
111
+ description=(
112
+ "v1.0-A: ordered filter chain applied to assistant content. "
113
+ "Known: strip_thinking, strip_stop_markers. Empty = off."
114
+ ),
115
+ )
116
+
117
+ capabilities: Capabilities = Field(default_factory=Capabilities)
118
+
119
+ @model_validator(mode="after")
120
+ def _check_output_filters_known(self) -> ProviderConfig:
121
+ """v1.0-A: fail at config-load on a typo'd filter name.
122
+
123
+ Same fast-fail pattern as ``_check_default_profile_exists`` —
124
+ surfaces ``output_filters: [strp_thinking]`` at startup rather
125
+ than silently no-op'ing forever.
126
+ """
127
+ # Import locally to avoid a hard package-level cycle
128
+ # (output_filters imports nothing from config).
129
+ from coderouter.output_filters import validate_output_filters
130
+
131
+ validate_output_filters(self.output_filters)
132
+ return self
133
+
134
+
135
+ class FallbackChain(BaseModel):
136
+ """An ordered list of provider names to try in sequence.
137
+
138
+ v0.6-B: optional profile-level overrides for ``timeout_s`` and
139
+ ``append_system_prompt``. When set, these REPLACE the provider's own
140
+ values for calls routed through this profile — "replace" rather than
141
+ "append" semantics keeps debugging predictable and matches how
142
+ ``timeout_s`` (a scalar limit) naturally behaves. Unset fields leave
143
+ the provider's own defaults in effect. The ``retry_max`` field is
144
+ deferred to a later minor until a retry mechanism exists at the
145
+ adapter layer (§9.3 #4 partial).
146
+ """
147
+
148
+ model_config = ConfigDict(extra="forbid")
149
+
150
+ name: str = Field(..., description="Profile name, e.g. 'default', 'coding'")
151
+ providers: list[str] = Field(
152
+ ...,
153
+ min_length=1,
154
+ description="Provider names in fallback order. First success wins.",
155
+ )
156
+ timeout_s: float | None = Field(
157
+ default=None,
158
+ ge=1.0,
159
+ le=600.0,
160
+ description=(
161
+ "v0.6-B: profile-level HTTP timeout override (seconds). When "
162
+ "set, replaces ``ProviderConfig.timeout_s`` for every call "
163
+ "routed through this profile. Unset = provider default."
164
+ ),
165
+ )
166
+ append_system_prompt: str | None = Field(
167
+ default=None,
168
+ description=(
169
+ "v0.6-B: profile-level override for the provider's "
170
+ "``append_system_prompt`` directive. When set, REPLACES the "
171
+ "provider's directive for this profile (not appended). Pass "
172
+ "an empty string to explicitly clear the provider directive "
173
+ "for this profile."
174
+ ),
175
+ )
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # v1.6-A: auto_router — declarative request-body classifier
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ class RuleMatcher(BaseModel):
184
+ """One-of matcher for an :class:`AutoRouteRule`.
185
+
186
+ Exactly one of the matcher fields must be set; the ``_exactly_one``
187
+ validator enforces this at load. Adding a new matcher type means
188
+ adding a new optional field — the single-field invariant enforces
189
+ discriminated-union semantics without pydantic's tagged-union syntax.
190
+
191
+ Variants (v1.6-A):
192
+
193
+ - ``has_image: True`` — any ``image_url`` / ``image`` /
194
+ ``input_image`` content block in the latest user message.
195
+ - ``code_fence_ratio_min: 0.3`` — triple-backtick span chars ÷ total
196
+ chars of latest user message is ``>=`` this threshold.
197
+ - ``content_contains: "foo"`` — substring match (case-sensitive).
198
+ - ``content_regex: r"..."`` — Python ``re.search``; compiled at
199
+ model-construction time so typos fail startup.
200
+ """
201
+
202
+ model_config = ConfigDict(extra="forbid")
203
+
204
+ has_image: bool | None = None
205
+ code_fence_ratio_min: float | None = Field(default=None, ge=0.0, le=1.0)
206
+ content_contains: str | None = None
207
+ content_regex: str | None = None
208
+
209
+ _MATCHER_FIELDS: tuple[str, ...] = (
210
+ "has_image",
211
+ "code_fence_ratio_min",
212
+ "content_contains",
213
+ "content_regex",
214
+ )
215
+
216
+ @model_validator(mode="after")
217
+ def _exactly_one(self) -> Self:
218
+ set_fields = [
219
+ name for name in self._MATCHER_FIELDS if getattr(self, name) is not None
220
+ ]
221
+ if len(set_fields) != 1:
222
+ raise ValueError(
223
+ f"RuleMatcher must have exactly one matcher field set, "
224
+ f"got {len(set_fields)}: {set_fields}"
225
+ )
226
+ return self
227
+
228
+ @model_validator(mode="after")
229
+ def _compile_regex_eagerly(self) -> Self:
230
+ """Compile ``content_regex`` at load so bad patterns fail startup."""
231
+ if self.content_regex is not None:
232
+ try:
233
+ re.compile(self.content_regex)
234
+ except re.error as exc:
235
+ raise ValueError(
236
+ f"Invalid regex for content_regex {self.content_regex!r}: {exc}"
237
+ ) from exc
238
+ return self
239
+
240
+
241
+ class AutoRouteRule(BaseModel):
242
+ """One rule in ``auto_router.rules``: matcher → profile."""
243
+
244
+ model_config = ConfigDict(extra="forbid")
245
+
246
+ id: str = Field(
247
+ description=(
248
+ "Stable identifier surfaced in the auto-router-resolved log "
249
+ "payload. Recommended prefixes: ``builtin:`` for bundled "
250
+ "rules, ``user:`` for YAML-defined rules."
251
+ ),
252
+ )
253
+ profile: str = Field(
254
+ description="Profile name to resolve to. Must exist in profiles[].",
255
+ )
256
+ match: RuleMatcher
257
+
258
+
259
+ class AutoRouterConfig(BaseModel):
260
+ """The ``auto_router:`` block in providers.yaml.
261
+
262
+ When absent and ``default_profile == "auto"``, the bundled ruleset
263
+ (``BUNDLED_RULES`` in :mod:`coderouter.routing.auto_router`) applies.
264
+ When present, ``rules`` entirely **replaces** bundled rules (no
265
+ merge) — see ``docs/designs/v1.6-auto-router.md`` §7 for rationale.
266
+ """
267
+
268
+ model_config = ConfigDict(extra="forbid")
269
+
270
+ disabled: bool = Field(
271
+ default=False,
272
+ description=(
273
+ "Hard off-switch. When True, classification is skipped and "
274
+ "``default_rule_profile`` is used unconditionally."
275
+ ),
276
+ )
277
+ rules: list[AutoRouteRule] = Field(
278
+ default_factory=list,
279
+ description="Ordered rules; first match wins.",
280
+ )
281
+ default_rule_profile: str = Field(
282
+ default="writing",
283
+ description=(
284
+ "Profile used when no rule matches (or when ``disabled`` is "
285
+ "True). Must exist in profiles[]."
286
+ ),
287
+ )
288
+
289
+
290
+ class CodeRouterConfig(BaseModel):
291
+ """Top-level config loaded from providers.yaml."""
292
+
293
+ model_config = ConfigDict(extra="forbid")
294
+
295
+ allow_paid: bool = Field(
296
+ default=False,
297
+ description="Master switch. ALLOW_PAID=false blocks all paid providers (plan.md §2.3).",
298
+ )
299
+ default_profile: str = Field(default="default")
300
+ providers: list[ProviderConfig] = Field(..., min_length=1)
301
+ profiles: list[FallbackChain] = Field(..., min_length=1)
302
+ mode_aliases: dict[str, str] = Field(
303
+ default_factory=dict,
304
+ description=(
305
+ "v0.6-D: intent-to-profile mapping. Clients send "
306
+ "``X-CodeRouter-Mode: coding`` and the ingress resolves it to "
307
+ "the aliased profile name. Lets clients name their intent "
308
+ "(``coding`` / ``long`` / ``fast``) independently of the "
309
+ "underlying profile names — you can rewire the chain without "
310
+ "touching client code. Keys = mode names, values = profile "
311
+ "names (must exist in ``profiles``). Empty dict = feature off."
312
+ ),
313
+ )
314
+ # v1.5-E: display-time timezone for dashboard + ``coderouter stats``.
315
+ # The metrics ring keeps timestamps in UTC ISO form (stable wire format,
316
+ # matches JsonLineFormatter); this field only affects rendering. When
317
+ # unset, consumers default to UTC (no behavior change from v1.5-D). An
318
+ # IANA name is required — offset strings like ``+09:00`` are rejected to
319
+ # keep DST semantics unambiguous. Validated via ``zoneinfo.ZoneInfo`` at
320
+ # load time so a typo like ``Asia/Tokyoo`` fails fast rather than 500'ing
321
+ # the first dashboard poll.
322
+ display_timezone: str | None = Field(
323
+ default=None,
324
+ description=(
325
+ "v1.5-E: IANA timezone name used for rendering timestamps in "
326
+ "``/dashboard`` and ``coderouter stats``. Example: ``Asia/Tokyo`` "
327
+ "or ``America/New_York``. None → UTC. The underlying "
328
+ "``/metrics.json`` snapshot keeps UTC ISO timestamps; conversion "
329
+ "is display-only."
330
+ ),
331
+ )
332
+ # v1.6-A: optional auto-routing rules. When ``default_profile == "auto"``
333
+ # and this field is None, the bundled ruleset (image → multi /
334
+ # code-fence → coding / fallthrough → writing) applies. When set,
335
+ # ``rules`` is a complete replacement (no merge with bundled).
336
+ auto_router: AutoRouterConfig | None = Field(
337
+ default=None,
338
+ description=(
339
+ "v1.6-A: classifier rules consulted only when "
340
+ "``default_profile == 'auto'``. None + auto → bundled rules "
341
+ "apply (requires multi/coding/writing profiles to exist). "
342
+ "Set to override bundled behavior."
343
+ ),
344
+ )
345
+
346
+ @model_validator(mode="after")
347
+ def _check_default_profile_exists(self) -> CodeRouterConfig:
348
+ """v0.6-A: surface a typo'd ``default_profile`` at load time.
349
+
350
+ Previously a bad ``default_profile`` only blew up on the first
351
+ request (``profile_by_name`` → KeyError → 500). Checking here
352
+ converts a silent-until-used misconfig into a fast-fail at
353
+ startup, which matches how ``--mode`` / ``CODEROUTER_MODE`` are
354
+ validated in ``loader.py``.
355
+
356
+ v1.6-A: ``default_profile == "auto"`` is a reserved sentinel
357
+ that triggers the auto-router; it never maps to a declared
358
+ profile directly and is therefore exempt from this existence
359
+ check.
360
+ """
361
+ if self.default_profile == "auto":
362
+ return self
363
+ names = {p.name for p in self.profiles}
364
+ if self.default_profile not in names:
365
+ raise ValueError(
366
+ f"default_profile {self.default_profile!r} is not declared in "
367
+ f"profiles: known={sorted(names)}"
368
+ )
369
+ return self
370
+
371
+ @model_validator(mode="after")
372
+ def _check_auto_is_reserved(self) -> CodeRouterConfig:
373
+ """v1.6-A: ``auto`` is a reserved sentinel for the auto-router.
374
+
375
+ Users cannot define a profile named ``auto`` — it would collide
376
+ with the ``default_profile: auto`` trigger. Fast-fail at load
377
+ with a pointer to rename.
378
+ """
379
+ for prof in self.profiles:
380
+ if prof.name == "auto":
381
+ raise ValueError(
382
+ "'auto' is reserved as a profile name in v1.6+ "
383
+ "(it is the sentinel that activates auto_router). "
384
+ "Rename this profile to something else, e.g. "
385
+ "'auto-route' or 'smart'."
386
+ )
387
+ return self
388
+
389
+ @model_validator(mode="after")
390
+ def _check_auto_router_profiles_exist(self) -> CodeRouterConfig:
391
+ """v1.6-A: every ``auto_router.rules[*].profile`` must be declared.
392
+
393
+ Also validates ``default_rule_profile``. Same fast-fail
394
+ philosophy as :meth:`_check_default_profile_exists` and
395
+ :meth:`_check_mode_alias_targets_exist`.
396
+ """
397
+ if self.auto_router is None:
398
+ return self
399
+ names = {p.name for p in self.profiles}
400
+ bad = sorted(
401
+ {
402
+ r.profile
403
+ for r in self.auto_router.rules
404
+ if r.profile not in names
405
+ }
406
+ )
407
+ if bad:
408
+ raise ValueError(
409
+ f"auto_router.rules points to unknown profile(s): {bad}. "
410
+ f"known profiles={sorted(names)}"
411
+ )
412
+ if self.auto_router.default_rule_profile not in names:
413
+ raise ValueError(
414
+ f"auto_router.default_rule_profile "
415
+ f"{self.auto_router.default_rule_profile!r} is not declared "
416
+ f"in profiles: known={sorted(names)}"
417
+ )
418
+ return self
419
+
420
+ @model_validator(mode="after")
421
+ def _check_bundled_auto_router_requirements(self) -> CodeRouterConfig:
422
+ """v1.6-A: bundled ruleset needs multi/coding/writing to exist.
423
+
424
+ Only fires when the user opted into auto routing
425
+ (``default_profile == 'auto'``) without supplying a custom
426
+ ``auto_router`` block. In that path the classifier falls back to
427
+ the bundled rules (see
428
+ :mod:`coderouter.routing.auto_router`), which reference three
429
+ named profiles. Missing any of them would 500 on the first
430
+ request, so we surface it at load instead.
431
+ """
432
+ if self.default_profile != "auto" or self.auto_router is not None:
433
+ return self
434
+ names = {p.name for p in self.profiles}
435
+ required = ("multi", "coding", "writing")
436
+ missing = [r for r in required if r not in names]
437
+ if missing:
438
+ raise ValueError(
439
+ f"bundled auto_router requires profiles {list(required)} to "
440
+ f"exist, but missing: {missing}. "
441
+ f"Either (a) define all three profiles in providers.yaml, or "
442
+ f"(b) override with a custom ``auto_router:`` block, or "
443
+ f"(c) set ``default_profile`` to a non-auto profile name."
444
+ )
445
+ return self
446
+
447
+ @model_validator(mode="after")
448
+ def _check_display_timezone_resolves(self) -> CodeRouterConfig:
449
+ """v1.5-E: fail fast on a typo'd IANA zone name.
450
+
451
+ Same philosophy as the other ``_check_*`` validators — a broken
452
+ ``display_timezone`` would otherwise silently fall back to UTC
453
+ (or worse, blow up the first dashboard poll with a stack trace).
454
+ Checking at load time converts that into a startup error with the
455
+ offending value in the message.
456
+ """
457
+ if self.display_timezone is None:
458
+ return self
459
+ # Imported locally to sidestep the slow ``zoneinfo`` cold-import
460
+ # cost on machines that never set a display timezone.
461
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
462
+
463
+ try:
464
+ ZoneInfo(self.display_timezone)
465
+ except ZoneInfoNotFoundError as exc:
466
+ raise ValueError(
467
+ f"display_timezone={self.display_timezone!r} is not a known "
468
+ f"IANA zone (try 'Asia/Tokyo', 'America/New_York', 'UTC'): {exc}"
469
+ ) from exc
470
+ return self
471
+
472
+ @model_validator(mode="after")
473
+ def _check_mode_alias_targets_exist(self) -> CodeRouterConfig:
474
+ """v0.6-D: every ``mode_aliases`` value must point to a declared profile.
475
+
476
+ Same fast-fail philosophy as ``_check_default_profile_exists``: a
477
+ broken alias should 500 at load, not silently 400 for every
478
+ request that uses that mode.
479
+ """
480
+ names = {p.name for p in self.profiles}
481
+ bad = {mode: profile for mode, profile in self.mode_aliases.items() if profile not in names}
482
+ if bad:
483
+ raise ValueError(
484
+ f"mode_aliases points to unknown profile(s): {bad}. known profiles={sorted(names)}"
485
+ )
486
+ return self
487
+
488
+ def provider_by_name(self, name: str) -> ProviderConfig:
489
+ """Look up a provider config by name. Raises KeyError if not found."""
490
+ for p in self.providers:
491
+ if p.name == name:
492
+ return p
493
+ raise KeyError(f"Provider not found: {name!r}")
494
+
495
+ def profile_by_name(self, name: str) -> FallbackChain:
496
+ """Look up a profile (fallback chain) by name."""
497
+ for prof in self.profiles:
498
+ if prof.name == name:
499
+ return prof
500
+ raise KeyError(f"Profile not found: {name!r}")
501
+
502
+ def resolve_mode(self, mode: str) -> str:
503
+ """v0.6-D: resolve a mode alias to a profile name.
504
+
505
+ The startup validator guarantees every alias target exists in
506
+ ``profiles``, so callers can pass the returned value straight to
507
+ ``profile_by_name`` without a second existence check.
508
+
509
+ Raises ``KeyError`` when ``mode`` is not in ``mode_aliases`` —
510
+ the ingress layer catches it and returns 400 with the list of
511
+ available modes.
512
+ """
513
+ if mode in self.mode_aliases:
514
+ return self.mode_aliases[mode]
515
+ raise KeyError(f"Unknown mode alias: {mode!r}")
@@ -0,0 +1,7 @@
1
+ """Package-data resources for CodeRouter.
2
+
3
+ Currently holds the bundled ``model-capabilities.yaml`` registry (v0.7-A).
4
+ Kept as a real package (with ``__init__.py``) rather than a plain data
5
+ directory so that ``importlib.resources.files('coderouter.data')`` works
6
+ cleanly under every install mode (wheel / editable / zipapp).
7
+ """
@@ -0,0 +1,86 @@
1
+ # ============================================================
2
+ # CodeRouter — bundled model-capabilities.yaml
3
+ #
4
+ # Declarative capability registry — glob + first-match defaults per
5
+ # (kind, model) pair.
6
+ #
7
+ # Loaded by ``coderouter.config.capability_registry.CapabilityRegistry``
8
+ # at startup. Users can extend / override via
9
+ # ``~/.coderouter/model-capabilities.yaml`` (optional; merged on top of
10
+ # this file — user rules are evaluated first, so a user rule overrides
11
+ # a bundled rule for the same flag).
12
+ #
13
+ # Precedence for the capability gate (see coderouter/routing/capability.py):
14
+ # 1. providers.yaml ``capabilities.*`` per-provider explicit flag
15
+ # (highest priority — verbatim opt-in).
16
+ # 2. ``~/.coderouter/model-capabilities.yaml`` (user rules).
17
+ # 3. This file (bundled rules).
18
+ # 4. Unset ⇒ flag defaults to False (not-supported).
19
+ #
20
+ # Schema
21
+ # ------
22
+ # version: int — format version (currently 1)
23
+ # rules: list
24
+ # - match: str — fnmatch-style glob applied against ProviderConfig.model
25
+ # (case-sensitive). Supported wildcards: *, ?, [seq].
26
+ # kind: str — optional; restricts rule to this adapter kind.
27
+ # One of "anthropic", "openai_compat", "any".
28
+ # Default: "any".
29
+ # capabilities: — dict of flag overrides (all fields optional).
30
+ # thinking: bool — Anthropic `thinking: {type: enabled}` body field
31
+ # reasoning_passthrough: bool — opt OUT of the adapter's passive `reasoning` strip
32
+ # tools: bool — upstream reliably emits tool_calls
33
+ # max_context_tokens: int — declared model context window
34
+ #
35
+ # First-match semantics: rules within a file are evaluated top-to-bottom
36
+ # per flag; the first rule whose glob matches AND declares that flag
37
+ # determines the value. Rules that don't declare a flag are skipped for
38
+ # that flag's lookup (so a specific rule can override `thinking` while
39
+ # leaving `tools` to fall through to a broader later rule).
40
+ #
41
+ # When to edit this file vs. the user file:
42
+ # - Bundled (this file): shared defaults that ship with CodeRouter.
43
+ # Update when a new model family verifiably accepts a capability.
44
+ # - User file (~/.coderouter/model-capabilities.yaml): per-deployment
45
+ # overrides, e.g. "my local Ollama qwen3-coder:7b is reliable with
46
+ # tool calling, declare tools: true for it".
47
+ # ============================================================
48
+
49
+ version: 1
50
+
51
+ rules:
52
+ # ------------------------------------------------------------------
53
+ # Anthropic families — `thinking: {type: enabled}` support.
54
+ #
55
+ # Verified 2026-04 against api.anthropic.com (v0.5-A):
56
+ # claude-sonnet-4-5-* → 400 "adaptive thinking is not supported"
57
+ # claude-sonnet-4-6+ → accepts adaptive + budgeted thinking
58
+ # claude-opus-4-* → accepts (all 4.x opus)
59
+ # claude-haiku-4-* → accepts (all 4.x haiku)
60
+ #
61
+ # Forward-compat entry for 4-7 is included so a named-later model ships
62
+ # without requiring a CodeRouter update. When Anthropic releases 4-8
63
+ # or 5-0, add the glob here. The per-provider YAML escape hatch
64
+ # (``providers.yaml`` ``capabilities.thinking: true``) remains the
65
+ # interim path while a new family is un-listed.
66
+ # ------------------------------------------------------------------
67
+
68
+ - match: "claude-opus-4-*"
69
+ kind: anthropic
70
+ capabilities:
71
+ thinking: true
72
+
73
+ - match: "claude-sonnet-4-6*"
74
+ kind: anthropic
75
+ capabilities:
76
+ thinking: true
77
+
78
+ - match: "claude-sonnet-4-7*"
79
+ kind: anthropic
80
+ capabilities:
81
+ thinking: true
82
+
83
+ - match: "claude-haiku-4-*"
84
+ kind: anthropic
85
+ capabilities:
86
+ thinking: true