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
|
@@ -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
|