coderouter-cli 2.6.1__py3-none-any.whl → 2.7.1__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/adapters/anthropic_native.py +66 -30
- coderouter/adapters/base.py +46 -3
- coderouter/adapters/openai_compat.py +53 -11
- coderouter/config/capability_registry.py +22 -0
- coderouter/config/schemas.py +232 -0
- coderouter/guards/context_budget.py +114 -19
- coderouter/guards/continuous_probe.py +9 -1
- coderouter/guards/memory_pressure.py +16 -2
- coderouter/guards/self_healing.py +14 -0
- coderouter/ingress/anthropic_routes.py +223 -1
- coderouter/ingress/app.py +162 -1
- coderouter/ingress/launcher_routes.py +187 -11
- coderouter/ingress/openai_routes.py +77 -7
- coderouter/logging.py +29 -3
- coderouter/metrics/collector.py +454 -245
- coderouter/metrics/prometheus.py +3 -3
- coderouter/routing/capability.py +217 -0
- coderouter/routing/fallback.py +539 -27
- coderouter/state/audit_log.py +69 -7
- coderouter/state/request_log.py +70 -7
- coderouter/translation/convert.py +112 -9
- coderouter/translation/tool_repair.py +671 -50
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/METADATA +28 -9
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/RECORD +27 -27
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.6.1.dist-info → coderouter_cli-2.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -35,6 +35,7 @@ from collections.abc import AsyncIterator
|
|
|
35
35
|
from typing import Any
|
|
36
36
|
|
|
37
37
|
import httpx
|
|
38
|
+
from pydantic import ValidationError
|
|
38
39
|
|
|
39
40
|
from coderouter.adapters.base import (
|
|
40
41
|
AdapterError,
|
|
@@ -68,6 +69,26 @@ _RETRYABLE_STATUSES = {404, 408, 425, 429, 500, 502, 503, 504}
|
|
|
68
69
|
_DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def anthropic_messages_url(base_url: str) -> str:
|
|
73
|
+
"""Resolve the ``/v1/messages`` endpoint URL from a ``base_url``.
|
|
74
|
+
|
|
75
|
+
``base_url`` may be given with or without a trailing ``/v1`` — users
|
|
76
|
+
may point it at either ``https://api.anthropic.com`` or
|
|
77
|
+
``https://api.anthropic.com/v1`` (LM Studio and similar servers
|
|
78
|
+
commonly advertise the latter). We normalize by stripping a trailing
|
|
79
|
+
``/v1`` so appending ``/v1/messages`` always yields a valid,
|
|
80
|
+
non-duplicated URL.
|
|
81
|
+
|
|
82
|
+
Shared by :class:`AnthropicAdapter` and the continuous-probe guard so
|
|
83
|
+
both hit the exact same endpoint (see bug H4: a mismatch made the
|
|
84
|
+
probe target ``/v1/v1/messages`` and 404 on healthy backends).
|
|
85
|
+
"""
|
|
86
|
+
base = base_url.rstrip("/")
|
|
87
|
+
if base.endswith("/v1"):
|
|
88
|
+
base = base[: -len("/v1")]
|
|
89
|
+
return f"{base}/v1/messages"
|
|
90
|
+
|
|
91
|
+
|
|
71
92
|
class AnthropicAdapter(BaseAdapter):
|
|
72
93
|
"""Native Anthropic Messages API adapter (passthrough).
|
|
73
94
|
|
|
@@ -87,15 +108,10 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
87
108
|
|
|
88
109
|
``base_url`` may be given with or without a trailing ``/v1`` —
|
|
89
110
|
we normalize by stripping it so appending ``/v1/messages``
|
|
90
|
-
always yields a valid URL.
|
|
111
|
+
always yields a valid URL. Delegates to the shared
|
|
112
|
+
:func:`anthropic_messages_url` helper.
|
|
91
113
|
"""
|
|
92
|
-
|
|
93
|
-
# Users may point base_url at either `https://api.anthropic.com`
|
|
94
|
-
# or `https://api.anthropic.com/v1`. We normalize to the former so
|
|
95
|
-
# we can always append /v1/messages.
|
|
96
|
-
if base.endswith("/v1"):
|
|
97
|
-
base = base[: -len("/v1")]
|
|
98
|
-
return f"{base}/v1/messages"
|
|
114
|
+
return anthropic_messages_url(str(self.config.base_url))
|
|
99
115
|
|
|
100
116
|
def _headers(self, request: AnthropicRequest | None = None) -> dict[str, str]:
|
|
101
117
|
"""Build Anthropic-native HTTP headers, including beta-header forwarding.
|
|
@@ -161,21 +177,21 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
161
177
|
that auth works and the endpoint is reachable.
|
|
162
178
|
"""
|
|
163
179
|
try:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
resp = await self.client().post(
|
|
181
|
+
self._url(),
|
|
182
|
+
headers=self._headers(),
|
|
183
|
+
json={
|
|
184
|
+
"model": self.config.model,
|
|
185
|
+
"max_tokens": 1,
|
|
186
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
187
|
+
},
|
|
188
|
+
timeout=5.0,
|
|
189
|
+
)
|
|
190
|
+
# 200 is clearly healthy. 4xx auth errors indicate the
|
|
191
|
+
# endpoint is reachable even if the key is bad — still a
|
|
192
|
+
# "the server answered" signal for healthcheck purposes.
|
|
193
|
+
# 5xx is upstream trouble.
|
|
194
|
+
return resp.status_code < 500
|
|
179
195
|
except httpx.HTTPError:
|
|
180
196
|
return False
|
|
181
197
|
|
|
@@ -331,8 +347,9 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
331
347
|
timeout = self.effective_timeout(overrides)
|
|
332
348
|
|
|
333
349
|
try:
|
|
334
|
-
|
|
335
|
-
|
|
350
|
+
resp = await self.client().post(
|
|
351
|
+
url, json=payload, headers=self._headers(request), timeout=timeout
|
|
352
|
+
)
|
|
336
353
|
except httpx.TimeoutException as exc:
|
|
337
354
|
raise AdapterError(
|
|
338
355
|
f"timeout contacting {url}", provider=self.name, retryable=True
|
|
@@ -394,7 +411,22 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
394
411
|
# (future additions like thinking blocks) pass through via
|
|
395
412
|
# extra="allow" on AnthropicResponse.
|
|
396
413
|
data["coderouter_provider"] = self.name
|
|
397
|
-
|
|
414
|
+
# M6: guard the model validation. A 200 body that isn't a valid
|
|
415
|
+
# Anthropic Messages response (missing ``content``/``id``, an
|
|
416
|
+
# error envelope returned with a 200, etc.) would otherwise raise
|
|
417
|
+
# a bare pydantic ValidationError that escapes the engine's
|
|
418
|
+
# AdapterError-based retry/fallback path. Convert to a retryable
|
|
419
|
+
# AdapterError so the chain can fall through (mirrors the
|
|
420
|
+
# invalid-JSON branch above, but retryable — a malformed shape is
|
|
421
|
+
# usually transient upstream noise rather than a config fault).
|
|
422
|
+
try:
|
|
423
|
+
return AnthropicResponse.model_validate(data)
|
|
424
|
+
except ValidationError as exc:
|
|
425
|
+
raise AdapterError(
|
|
426
|
+
f"malformed response shape from upstream: {exc}",
|
|
427
|
+
provider=self.name,
|
|
428
|
+
retryable=True,
|
|
429
|
+
) from exc
|
|
398
430
|
|
|
399
431
|
async def stream_anthropic(
|
|
400
432
|
self,
|
|
@@ -424,10 +456,14 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
424
456
|
logged_flag: list[bool] = [False]
|
|
425
457
|
|
|
426
458
|
try:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
459
|
+
# H3: stream over the shared client (pool / keep-alive / TLS
|
|
460
|
+
# reuse). Only the ``stream(...)`` context is scoped here; the
|
|
461
|
+
# client persists and is closed via ``aclose`` on shutdown. The
|
|
462
|
+
# ``async with`` releases the response's borrowed connection even
|
|
463
|
+
# if the caller abandons this generator mid-stream — no GC leak.
|
|
464
|
+
async with self.client().stream(
|
|
465
|
+
"POST", url, json=payload, headers=self._headers(request), timeout=timeout
|
|
466
|
+
) as resp:
|
|
431
467
|
if resp.status_code >= 400:
|
|
432
468
|
body = await resp.aread()
|
|
433
469
|
raise AdapterError(
|
coderouter/adapters/base.py
CHANGED
|
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
|
|
|
11
11
|
from collections.abc import AsyncIterator
|
|
12
12
|
from typing import Any, Literal
|
|
13
13
|
|
|
14
|
+
import httpx
|
|
14
15
|
from pydantic import BaseModel, ConfigDict, Field
|
|
15
16
|
|
|
16
17
|
from coderouter.config.schemas import ProviderConfig
|
|
@@ -164,17 +165,59 @@ class BaseAdapter(ABC):
|
|
|
164
165
|
def __init__(self, config: ProviderConfig) -> None:
|
|
165
166
|
"""Bind the adapter to a :class:`ProviderConfig`.
|
|
166
167
|
|
|
167
|
-
Subclasses do not need to override this
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
Subclasses do not need to override this. A single shared
|
|
169
|
+
:class:`httpx.AsyncClient` is created lazily on first use (see
|
|
170
|
+
:meth:`client`) and reused across every call on this adapter so
|
|
171
|
+
the connection pool, HTTP keep-alive, and TLS session are all
|
|
172
|
+
reused. Per-call timeouts are still honored by passing
|
|
173
|
+
``timeout=`` to ``client.post`` / ``client.stream`` — they do
|
|
174
|
+
NOT require a fresh client.
|
|
170
175
|
"""
|
|
171
176
|
self.config = config
|
|
177
|
+
# Shared client, created lazily inside the running event loop the
|
|
178
|
+
# first time an adapter method needs it (see ``client``). Kept as
|
|
179
|
+
# ``None`` until then so constructing an adapter has no I/O cost and
|
|
180
|
+
# is safe outside an event loop (e.g. at import / config time).
|
|
181
|
+
self._client: httpx.AsyncClient | None = None
|
|
172
182
|
|
|
173
183
|
@property
|
|
174
184
|
def name(self) -> str:
|
|
175
185
|
"""Shortcut for ``self.config.name`` — used in log trails and errors."""
|
|
176
186
|
return self.config.name
|
|
177
187
|
|
|
188
|
+
# ---- H3: shared HTTP client (connection-pool / keep-alive reuse) ----
|
|
189
|
+
def client(self) -> httpx.AsyncClient:
|
|
190
|
+
"""Return the shared :class:`httpx.AsyncClient`, creating it lazily.
|
|
191
|
+
|
|
192
|
+
The client is created on first use so construction happens inside
|
|
193
|
+
the running event loop and carries no I/O cost at adapter-build
|
|
194
|
+
time. Subsequent calls return the same instance, which is what
|
|
195
|
+
lets httpx reuse pooled connections, keep-alive, and the TLS
|
|
196
|
+
session across requests.
|
|
197
|
+
|
|
198
|
+
No default timeout is baked in here: every call site passes an
|
|
199
|
+
explicit per-call ``timeout=`` (resolved from the active profile
|
|
200
|
+
via :meth:`effective_timeout`), so leaving the client timeout
|
|
201
|
+
unset avoids a surprising default clamping long-running calls.
|
|
202
|
+
"""
|
|
203
|
+
if self._client is None:
|
|
204
|
+
self._client = httpx.AsyncClient(timeout=None)
|
|
205
|
+
return self._client
|
|
206
|
+
|
|
207
|
+
async def aclose(self) -> None:
|
|
208
|
+
"""Close the shared HTTP client and drop the reference.
|
|
209
|
+
|
|
210
|
+
Idempotent: safe to call when no client was ever created, and
|
|
211
|
+
safe to call more than once. Invoked from the app lifespan
|
|
212
|
+
shutdown path so pooled connections are released cleanly rather
|
|
213
|
+
than left to garbage collection. After ``aclose`` a later call
|
|
214
|
+
re-creates the client on demand via :meth:`client`.
|
|
215
|
+
"""
|
|
216
|
+
client = self._client
|
|
217
|
+
self._client = None
|
|
218
|
+
if client is not None:
|
|
219
|
+
await client.aclose()
|
|
220
|
+
|
|
178
221
|
# ---- v0.6-B override resolution helpers -----------------------------
|
|
179
222
|
def effective_timeout(self, overrides: ProviderCallOverrides | None) -> float:
|
|
180
223
|
"""Profile override wins when set; else provider default."""
|
|
@@ -19,6 +19,7 @@ from collections.abc import AsyncIterator
|
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
21
|
import httpx
|
|
22
|
+
from pydantic import ValidationError
|
|
22
23
|
|
|
23
24
|
from coderouter.adapters.base import (
|
|
24
25
|
AdapterError,
|
|
@@ -197,9 +198,8 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
197
198
|
base = str(self.config.base_url).rstrip("/")
|
|
198
199
|
url = f"{base}/models"
|
|
199
200
|
try:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return resp.status_code < 500
|
|
201
|
+
resp = await self.client().get(url, headers=self._headers(), timeout=5.0)
|
|
202
|
+
return resp.status_code < 500
|
|
203
203
|
except httpx.HTTPError:
|
|
204
204
|
return False
|
|
205
205
|
|
|
@@ -221,8 +221,9 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
221
221
|
payload = self._payload(request, stream=False, overrides=overrides)
|
|
222
222
|
timeout = self.effective_timeout(overrides)
|
|
223
223
|
try:
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
resp = await self.client().post(
|
|
225
|
+
url, json=payload, headers=self._headers(), timeout=timeout
|
|
226
|
+
)
|
|
226
227
|
except httpx.TimeoutException as exc:
|
|
227
228
|
raise AdapterError(
|
|
228
229
|
f"timeout contacting {url}", provider=self.name, retryable=True
|
|
@@ -287,7 +288,22 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
287
288
|
|
|
288
289
|
# Tag the response with which provider answered
|
|
289
290
|
data.setdefault("object", "chat.completion")
|
|
290
|
-
|
|
291
|
+
# M6: a syntactically valid JSON body may still not be a valid
|
|
292
|
+
# Chat Completions response (e.g. an OpenAI-compat server that
|
|
293
|
+
# returns ``{"error": ...}`` with a 200, or omits ``choices``).
|
|
294
|
+
# pydantic raises ValidationError here; if left uncaught it would
|
|
295
|
+
# propagate past the engine's AdapterError-based retry/fallback
|
|
296
|
+
# logic. Convert to a retryable AdapterError so the chain can fall
|
|
297
|
+
# through to the next provider (mirrors the invalid-JSON branch,
|
|
298
|
+
# but retryable since a malformed shape is often transient).
|
|
299
|
+
try:
|
|
300
|
+
return ChatResponse(coderouter_provider=self.name, **data)
|
|
301
|
+
except ValidationError as exc:
|
|
302
|
+
raise AdapterError(
|
|
303
|
+
f"malformed response shape from upstream: {exc}",
|
|
304
|
+
provider=self.name,
|
|
305
|
+
retryable=True,
|
|
306
|
+
) from exc
|
|
291
307
|
|
|
292
308
|
async def stream(
|
|
293
309
|
self,
|
|
@@ -312,6 +328,10 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
312
328
|
# produce dozens of duplicate log lines.
|
|
313
329
|
strip_reasoning = not self.config.capabilities.reasoning_passthrough
|
|
314
330
|
reasoning_logged = False
|
|
331
|
+
# M6: one-shot dedupe flag for the malformed-chunk warning. A single
|
|
332
|
+
# broken stream can emit many non-conforming chunks; we skip each one
|
|
333
|
+
# but log only on the first to avoid a log flood.
|
|
334
|
+
malformed_chunk_logged = False
|
|
315
335
|
|
|
316
336
|
# v1.0-A: stateful output_filters chain for the duration of this
|
|
317
337
|
# stream. Handles `<think>...</think>` / stop markers that split
|
|
@@ -325,10 +345,16 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
325
345
|
# seen chunk's id/model so the flush emission looks native.
|
|
326
346
|
last_chunk_template: dict[str, Any] | None = None
|
|
327
347
|
try:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
348
|
+
# H3: stream over the shared client so the connection pool /
|
|
349
|
+
# keep-alive / TLS session are reused. Only the ``stream(...)``
|
|
350
|
+
# context is entered here — the client itself outlives the
|
|
351
|
+
# stream and is closed via ``aclose`` on app shutdown. The
|
|
352
|
+
# ``async with`` guarantees the response (and its borrowed
|
|
353
|
+
# connection) is released even if the consumer abandons this
|
|
354
|
+
# generator mid-stream, so no connection leaks on GC.
|
|
355
|
+
async with self.client().stream(
|
|
356
|
+
"POST", url, json=payload, headers=self._headers(), timeout=timeout
|
|
357
|
+
) as resp:
|
|
332
358
|
if resp.status_code >= 400:
|
|
333
359
|
body = await resp.aread()
|
|
334
360
|
raise AdapterError(
|
|
@@ -375,7 +401,23 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
375
401
|
if isinstance(content, str) and content:
|
|
376
402
|
delta["content"] = filter_chain.feed(content)
|
|
377
403
|
last_chunk_template = payload_obj
|
|
378
|
-
|
|
404
|
+
# M6: a chunk may be valid JSON yet not a valid
|
|
405
|
+
# StreamChunk (e.g. a mid-stream ``{"error": ...}``
|
|
406
|
+
# frame, or a chunk missing required fields). Rather
|
|
407
|
+
# than let the ValidationError abort the whole stream
|
|
408
|
+
# (bypassing the engine's AdapterError handling), skip
|
|
409
|
+
# the bad chunk and keep consuming. Warn once per stream.
|
|
410
|
+
try:
|
|
411
|
+
chunk = StreamChunk(**payload_obj)
|
|
412
|
+
except ValidationError as exc:
|
|
413
|
+
if not malformed_chunk_logged:
|
|
414
|
+
logger.warning(
|
|
415
|
+
"malformed-stream-chunk",
|
|
416
|
+
extra={"provider": self.name, "error": str(exc)},
|
|
417
|
+
)
|
|
418
|
+
malformed_chunk_logged = True
|
|
419
|
+
continue
|
|
420
|
+
yield chunk
|
|
379
421
|
|
|
380
422
|
# v1.0-A: flush the chain at end-of-stream. If filters held
|
|
381
423
|
# back a partial-tag suffix that turned out NOT to be a tag,
|
|
@@ -115,6 +115,20 @@ class RegistryCapabilities(BaseModel):
|
|
|
115
115
|
"startup check)."
|
|
116
116
|
),
|
|
117
117
|
)
|
|
118
|
+
tool_choice: bool | None = Field(
|
|
119
|
+
default=None,
|
|
120
|
+
description=(
|
|
121
|
+
"S2 (shim): does the upstream honor a forced Anthropic "
|
|
122
|
+
"``tool_choice`` (``{type: any}`` / ``{type: tool}``) on the "
|
|
123
|
+
"wire? When True, the fallback engine's ``tool_choice_action`` "
|
|
124
|
+
"leaves the request untouched. ``None`` (default) = no opinion "
|
|
125
|
+
"→ the gate falls back to ``provider.kind == 'anthropic'``. "
|
|
126
|
+
"``False`` hard-disables even on an anthropic-kind provider "
|
|
127
|
+
"(useful when a specific model regresses). Independent from "
|
|
128
|
+
"``providers.yaml capabilities.tool_choice`` (explicit "
|
|
129
|
+
"per-provider opt-in, highest precedence)."
|
|
130
|
+
),
|
|
131
|
+
)
|
|
118
132
|
cache_control: bool | None = Field(
|
|
119
133
|
default=None,
|
|
120
134
|
description=(
|
|
@@ -201,6 +215,7 @@ class ResolvedCapabilities:
|
|
|
201
215
|
max_context_tokens: int | None = None
|
|
202
216
|
claude_code_suitability: Literal["ok", "degraded"] | None = None
|
|
203
217
|
cache_control: bool | None = None
|
|
218
|
+
tool_choice: bool | None = None
|
|
204
219
|
|
|
205
220
|
|
|
206
221
|
# ---------------------------------------------------------------------------
|
|
@@ -253,6 +268,7 @@ class CapabilityRegistry:
|
|
|
253
268
|
resolved_max_ctx: int | None = None
|
|
254
269
|
resolved_suitability: Literal["ok", "degraded"] | None = None
|
|
255
270
|
resolved_cache_control: bool | None = None
|
|
271
|
+
resolved_tool_choice: bool | None = None
|
|
256
272
|
|
|
257
273
|
thinking_locked = False
|
|
258
274
|
reasoning_locked = False
|
|
@@ -260,6 +276,7 @@ class CapabilityRegistry:
|
|
|
260
276
|
max_ctx_locked = False
|
|
261
277
|
suitability_locked = False
|
|
262
278
|
cache_control_locked = False
|
|
279
|
+
tool_choice_locked = False
|
|
263
280
|
|
|
264
281
|
for rule in self._rules:
|
|
265
282
|
if not rule.kind_matches(kind):
|
|
@@ -285,6 +302,9 @@ class CapabilityRegistry:
|
|
|
285
302
|
if not cache_control_locked and caps.cache_control is not None:
|
|
286
303
|
resolved_cache_control = caps.cache_control
|
|
287
304
|
cache_control_locked = True
|
|
305
|
+
if not tool_choice_locked and caps.tool_choice is not None:
|
|
306
|
+
resolved_tool_choice = caps.tool_choice
|
|
307
|
+
tool_choice_locked = True
|
|
288
308
|
if (
|
|
289
309
|
thinking_locked
|
|
290
310
|
and reasoning_locked
|
|
@@ -292,6 +312,7 @@ class CapabilityRegistry:
|
|
|
292
312
|
and max_ctx_locked
|
|
293
313
|
and suitability_locked
|
|
294
314
|
and cache_control_locked
|
|
315
|
+
and tool_choice_locked
|
|
295
316
|
):
|
|
296
317
|
break
|
|
297
318
|
|
|
@@ -302,6 +323,7 @@ class CapabilityRegistry:
|
|
|
302
323
|
max_context_tokens=resolved_max_ctx,
|
|
303
324
|
claude_code_suitability=resolved_suitability,
|
|
304
325
|
cache_control=resolved_cache_control,
|
|
326
|
+
tool_choice=resolved_tool_choice,
|
|
305
327
|
)
|
|
306
328
|
|
|
307
329
|
# ------------------------------------------------------------------
|
coderouter/config/schemas.py
CHANGED
|
@@ -43,6 +43,18 @@ class Capabilities(BaseModel):
|
|
|
43
43
|
# you explicitly want the raw reasoning text to flow to the client
|
|
44
44
|
# (e.g. CodeRouter is fronting a reasoning-aware downstream).
|
|
45
45
|
reasoning_passthrough: bool = False
|
|
46
|
+
# S2 (shim): Anthropic's ``tool_choice`` forcing modes (``{type: any}``
|
|
47
|
+
# / ``{type: tool, name: ...}``). Only a subset of backends honor a
|
|
48
|
+
# forced tool_choice on the wire; openai_compat translation drops it.
|
|
49
|
+
# This narrow per-model flag mirrors ``thinking`` / ``prompt_cache``:
|
|
50
|
+
# when unset (None), the capability gate falls back to the registry and
|
|
51
|
+
# then to a ``kind == "anthropic"`` heuristic (see
|
|
52
|
+
# ``coderouter/routing/capability.py``). Motivation: let the fallback
|
|
53
|
+
# engine's ``tool_choice_action`` emulate forced tool calls via a
|
|
54
|
+
# system-prompt directive on backends that would otherwise silently
|
|
55
|
+
# ignore the field. Backward compatible — None leaves v2.x behavior
|
|
56
|
+
# untouched (no gate, no emulation).
|
|
57
|
+
tool_choice: bool | None = None
|
|
46
58
|
# v1.0+ fields, declared early so providers.yaml can future-proof
|
|
47
59
|
reasoning_control: Literal["none", "openai", "anthropic", "provider_specific"] = "none"
|
|
48
60
|
mcp: Literal["none", "anthropic", "provider_specific"] = "none"
|
|
@@ -706,6 +718,129 @@ class FallbackChain(BaseModel):
|
|
|
706
718
|
),
|
|
707
719
|
)
|
|
708
720
|
|
|
721
|
+
# --- S2 (shim): tool_choice capability gate + emulation -----------------
|
|
722
|
+
#
|
|
723
|
+
# Anthropic clients (Claude Code, SDKs) can pin the model to a specific
|
|
724
|
+
# tool with ``tool_choice: {type: "tool", name: "X"}`` or force *some*
|
|
725
|
+
# tool with ``{type: "any"}``. Native Anthropic honors this; most
|
|
726
|
+
# openai_compat backends silently ignore it after translation, so the
|
|
727
|
+
# model may answer with plain text where the client expected a tool
|
|
728
|
+
# call. This knob decides what the fallback engine does when a forced
|
|
729
|
+
# tool_choice request is routed to a provider that does not support it
|
|
730
|
+
# (per ``provider_supports_tool_choice``):
|
|
731
|
+
#
|
|
732
|
+
# * ``off`` — no detection / no mutation / no log. Backward-compat
|
|
733
|
+
# default (identical to pre-shim behavior).
|
|
734
|
+
# * ``warn`` — emit a ``capability-degraded`` log line only; the
|
|
735
|
+
# request is sent unchanged.
|
|
736
|
+
# * ``emulate`` — strip the ``tool_choice`` field and inject an English
|
|
737
|
+
# directive into the system prompt instructing the
|
|
738
|
+
# model to call the requested tool. Best-effort
|
|
739
|
+
# forcing for backends without native support. The
|
|
740
|
+
# original request object is left untouched so a later
|
|
741
|
+
# capable provider in the chain still receives the real
|
|
742
|
+
# ``tool_choice``.
|
|
743
|
+
tool_choice_action: Literal["off", "warn", "emulate"] = Field(
|
|
744
|
+
default="off",
|
|
745
|
+
description=(
|
|
746
|
+
"S2 (shim): action when a request carries a forced "
|
|
747
|
+
"``tool_choice`` (``{type: any}`` or ``{type: tool}``) and the "
|
|
748
|
+
"target provider does not support it. ``off`` (default) leaves "
|
|
749
|
+
"the request unchanged. ``warn`` emits a ``capability-degraded`` "
|
|
750
|
+
"log only. ``emulate`` strips ``tool_choice`` and injects a "
|
|
751
|
+
"system-prompt directive to coax the model into calling the "
|
|
752
|
+
"tool. Per-provider — capable providers are never mutated."
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# --- S3 (shim): cache_control strip -------------------------------------
|
|
757
|
+
#
|
|
758
|
+
# By default cache_control markers are simply lost during Anthropic →
|
|
759
|
+
# OpenAI translation and the ``capability-degraded`` gate logs it
|
|
760
|
+
# (v0.5-B, observability only). Some strict openai_compat backends,
|
|
761
|
+
# however, 400 when an unexpected ``cache_control`` key rides along on a
|
|
762
|
+
# content block (rather than silently ignoring it). ``strip`` proactively
|
|
763
|
+
# removes those keys from a deep copy of the request before dispatch to
|
|
764
|
+
# a non-supporting provider, so the marker never reaches the wire.
|
|
765
|
+
#
|
|
766
|
+
# * ``off`` — legacy behavior: leave the request as-is and rely on
|
|
767
|
+
# the existing ``capability-degraded`` observability log.
|
|
768
|
+
# * ``strip`` — deep-copy the request and remove every ``cache_control``
|
|
769
|
+
# key from system / tools / message blocks before sending
|
|
770
|
+
# to a non-supporting provider; emit a
|
|
771
|
+
# ``cache-control-stripped`` log with the marker count.
|
|
772
|
+
# Does NOT emit a tokens-saved event (this is not token
|
|
773
|
+
# savings, just a wire-compatibility strip).
|
|
774
|
+
cache_control_action: Literal["off", "strip"] = Field(
|
|
775
|
+
default="off",
|
|
776
|
+
description=(
|
|
777
|
+
"S3 (shim): action when a request carries ``cache_control`` "
|
|
778
|
+
"markers and the target provider does not support them. ``off`` "
|
|
779
|
+
"(default) keeps legacy behavior (marker dropped in translation, "
|
|
780
|
+
"``capability-degraded`` log only). ``strip`` removes the "
|
|
781
|
+
"``cache_control`` keys from a deep copy before dispatch and "
|
|
782
|
+
"emits a ``cache-control-stripped`` log. Per-provider — capable "
|
|
783
|
+
"providers are never mutated."
|
|
784
|
+
),
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
@model_validator(mode="after")
|
|
788
|
+
def _check_context_budget_thresholds_ordered(self) -> FallbackChain:
|
|
789
|
+
"""v2.0-F (mE): cross-field sanity of the context-budget ratios.
|
|
790
|
+
|
|
791
|
+
The three ratios describe a strict staircase: a warning fires
|
|
792
|
+
first, then trimming kicks in at a higher usage, and trimming
|
|
793
|
+
reclaims space down to a target below the trim point. If they are
|
|
794
|
+
mis-ordered the guard is either a dead knob (warn above trim never
|
|
795
|
+
fires) or an infinite trim loop (target >= trim never converges).
|
|
796
|
+
Same fast-fail philosophy as ``_check_default_profile_exists`` —
|
|
797
|
+
surface the mistake at load with a concrete pointer to the fix
|
|
798
|
+
rather than at the first request that trips the guard.
|
|
799
|
+
|
|
800
|
+
Required invariants:
|
|
801
|
+
* ``context_budget_warn_threshold`` <= ``context_budget_trim_threshold``
|
|
802
|
+
* ``context_budget_trim_target`` < ``context_budget_trim_threshold``
|
|
803
|
+
"""
|
|
804
|
+
if self.context_budget_warn_threshold > self.context_budget_trim_threshold:
|
|
805
|
+
raise ValueError(
|
|
806
|
+
f"profile {self.name!r}: context_budget_warn_threshold "
|
|
807
|
+
f"({self.context_budget_warn_threshold}) must be <= "
|
|
808
|
+
f"context_budget_trim_threshold "
|
|
809
|
+
f"({self.context_budget_trim_threshold}) — the warning has to "
|
|
810
|
+
f"fire at or before trimming, otherwise it can never fire. "
|
|
811
|
+
f"Lower warn_threshold or raise trim_threshold."
|
|
812
|
+
)
|
|
813
|
+
if self.context_budget_trim_target >= self.context_budget_trim_threshold:
|
|
814
|
+
raise ValueError(
|
|
815
|
+
f"profile {self.name!r}: context_budget_trim_target "
|
|
816
|
+
f"({self.context_budget_trim_target}) must be < "
|
|
817
|
+
f"context_budget_trim_threshold "
|
|
818
|
+
f"({self.context_budget_trim_threshold}) — trimming must "
|
|
819
|
+
f"reclaim space *below* the trigger point, otherwise trim "
|
|
820
|
+
f"never converges. Lower trim_target below trim_threshold."
|
|
821
|
+
)
|
|
822
|
+
return self
|
|
823
|
+
|
|
824
|
+
@model_validator(mode="after")
|
|
825
|
+
def _check_recovery_probe_interval_ordered(self) -> FallbackChain:
|
|
826
|
+
"""v2.0-J (mE): the recovery-probe backoff floor must not exceed its cap.
|
|
827
|
+
|
|
828
|
+
``recovery_probe_initial_s`` is the first probe interval and each
|
|
829
|
+
failed probe doubles it up to ``recovery_probe_max_s``. When the
|
|
830
|
+
initial interval already exceeds the max, the exponential-backoff
|
|
831
|
+
ceiling is below its own floor — a nonsensical configuration that
|
|
832
|
+
the backoff loop would silently clamp. Surface it at load instead.
|
|
833
|
+
"""
|
|
834
|
+
if self.recovery_probe_initial_s > self.recovery_probe_max_s:
|
|
835
|
+
raise ValueError(
|
|
836
|
+
f"profile {self.name!r}: recovery_probe_initial_s "
|
|
837
|
+
f"({self.recovery_probe_initial_s}) must be <= "
|
|
838
|
+
f"recovery_probe_max_s ({self.recovery_probe_max_s}) — the "
|
|
839
|
+
f"initial probe interval cannot exceed the backoff ceiling. "
|
|
840
|
+
f"Lower recovery_probe_initial_s or raise recovery_probe_max_s."
|
|
841
|
+
)
|
|
842
|
+
return self
|
|
843
|
+
|
|
709
844
|
|
|
710
845
|
# ---------------------------------------------------------------------------
|
|
711
846
|
# v1.6-A: auto_router — declarative request-body classifier
|
|
@@ -720,6 +855,14 @@ class RuleMatcher(BaseModel):
|
|
|
720
855
|
adding a new optional field — the single-field invariant enforces
|
|
721
856
|
discriminated-union semantics without pydantic's tagged-union syntax.
|
|
722
857
|
|
|
858
|
+
Boolean matchers (``has_image`` / ``has_tools``) only carry meaning at
|
|
859
|
+
``True``: the runtime evaluator matches with ``is True`` (see
|
|
860
|
+
``coderouter.routing.auto_router._match_rule``), so a ``False`` value
|
|
861
|
+
would construct without error yet never match anything — a dead rule
|
|
862
|
+
that silently shadows nothing and confuses operators. ``_exactly_one``
|
|
863
|
+
therefore rejects ``False`` for these fields at load (``None`` remains
|
|
864
|
+
the "unset" sentinel).
|
|
865
|
+
|
|
723
866
|
Variants (v1.6-A):
|
|
724
867
|
|
|
725
868
|
- ``has_image: True`` — any ``image_url`` / ``image`` /
|
|
@@ -822,8 +965,29 @@ class RuleMatcher(BaseModel):
|
|
|
822
965
|
"cjk_ratio_min",
|
|
823
966
|
)
|
|
824
967
|
|
|
968
|
+
# Boolean matcher fields carry meaning only at ``True`` — the runtime
|
|
969
|
+
# evaluator uses ``is True`` — so a ``False`` value is a dead rule that
|
|
970
|
+
# never matches. ``_exactly_one`` rejects it explicitly (see below).
|
|
971
|
+
_BOOL_MATCHER_FIELDS: tuple[str, ...] = ("has_image", "has_tools")
|
|
972
|
+
|
|
825
973
|
@model_validator(mode="after")
|
|
826
974
|
def _exactly_one(self) -> Self:
|
|
975
|
+
# Reject the dead-rule shape first so the error names the specific
|
|
976
|
+
# field the operator got wrong, rather than the generic
|
|
977
|
+
# "exactly one" message (``False`` counts as "set" below).
|
|
978
|
+
false_bools = [
|
|
979
|
+
name
|
|
980
|
+
for name in self._BOOL_MATCHER_FIELDS
|
|
981
|
+
if getattr(self, name) is False
|
|
982
|
+
]
|
|
983
|
+
if false_bools:
|
|
984
|
+
raise ValueError(
|
|
985
|
+
f"RuleMatcher boolean matcher(s) set to False: {false_bools}. "
|
|
986
|
+
f"These matchers are evaluated with ``is True``, so a False "
|
|
987
|
+
f"value is a dead rule that never matches (it would silently "
|
|
988
|
+
f"shadow nothing). Use True to match, or omit the field "
|
|
989
|
+
f"(leave it None) to not use this matcher."
|
|
990
|
+
)
|
|
827
991
|
set_fields = [
|
|
828
992
|
name for name in self._MATCHER_FIELDS if getattr(self, name) is not None
|
|
829
993
|
]
|
|
@@ -1391,6 +1555,74 @@ class CodeRouterConfig(BaseModel):
|
|
|
1391
1555
|
)
|
|
1392
1556
|
return self
|
|
1393
1557
|
|
|
1558
|
+
@model_validator(mode="after")
|
|
1559
|
+
def _check_names_unique(self) -> CodeRouterConfig:
|
|
1560
|
+
"""mE: provider and profile names must each be unique.
|
|
1561
|
+
|
|
1562
|
+
``provider_by_name`` / ``profile_by_name`` return the *first*
|
|
1563
|
+
match, so a duplicate name silently shadows every later entry with
|
|
1564
|
+
the same key — the operator's second ``local:`` block is loaded,
|
|
1565
|
+
validated, and then never reachable. Reject duplicates at load with
|
|
1566
|
+
the offending names, matching the fast-fail philosophy of the other
|
|
1567
|
+
``_check_*`` validators.
|
|
1568
|
+
"""
|
|
1569
|
+
seen_p: set[str] = set()
|
|
1570
|
+
dup_p: list[str] = []
|
|
1571
|
+
for p in self.providers:
|
|
1572
|
+
if p.name in seen_p and p.name not in dup_p:
|
|
1573
|
+
dup_p.append(p.name)
|
|
1574
|
+
seen_p.add(p.name)
|
|
1575
|
+
if dup_p:
|
|
1576
|
+
raise ValueError(
|
|
1577
|
+
f"duplicate provider name(s): {sorted(dup_p)}. Provider names "
|
|
1578
|
+
f"must be unique — a duplicate silently shadows the later "
|
|
1579
|
+
f"entry (provider_by_name returns the first match). Rename or "
|
|
1580
|
+
f"remove the duplicate(s)."
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
seen_f: set[str] = set()
|
|
1584
|
+
dup_f: list[str] = []
|
|
1585
|
+
for prof in self.profiles:
|
|
1586
|
+
if prof.name in seen_f and prof.name not in dup_f:
|
|
1587
|
+
dup_f.append(prof.name)
|
|
1588
|
+
seen_f.add(prof.name)
|
|
1589
|
+
if dup_f:
|
|
1590
|
+
raise ValueError(
|
|
1591
|
+
f"duplicate profile name(s): {sorted(dup_f)}. Profile names "
|
|
1592
|
+
f"must be unique — a duplicate silently shadows the later "
|
|
1593
|
+
f"entry (profile_by_name returns the first match). Rename or "
|
|
1594
|
+
f"remove the duplicate(s)."
|
|
1595
|
+
)
|
|
1596
|
+
return self
|
|
1597
|
+
|
|
1598
|
+
@model_validator(mode="after")
|
|
1599
|
+
def _check_profile_providers_exist(self) -> CodeRouterConfig:
|
|
1600
|
+
"""mE: every provider named in a profile chain must be declared.
|
|
1601
|
+
|
|
1602
|
+
Previously a typo in ``profiles[].providers`` was tolerated at load
|
|
1603
|
+
and only surfaced at runtime as a ``skip-unknown-provider`` warning;
|
|
1604
|
+
if *every* entry in a chain was typo'd the profile silently had no
|
|
1605
|
+
usable providers and only failed once fully drained. Rejecting at
|
|
1606
|
+
load — same philosophy as ``_check_default_profile_exists`` and
|
|
1607
|
+
``_check_mode_alias_targets_exist`` — turns a silent-until-drained
|
|
1608
|
+
misconfig into a startup error naming the profile and the bad
|
|
1609
|
+
provider(s).
|
|
1610
|
+
"""
|
|
1611
|
+
names = {p.name for p in self.providers}
|
|
1612
|
+
errors: list[str] = []
|
|
1613
|
+
for prof in self.profiles:
|
|
1614
|
+
missing = [name for name in prof.providers if name not in names]
|
|
1615
|
+
if missing:
|
|
1616
|
+
errors.append(f"{prof.name!r} -> {missing}")
|
|
1617
|
+
if errors:
|
|
1618
|
+
raise ValueError(
|
|
1619
|
+
f"profile(s) reference undeclared provider(s): "
|
|
1620
|
+
f"{'; '.join(errors)}. known providers={sorted(names)}. "
|
|
1621
|
+
f"Fix the typo in profiles[].providers or add the missing "
|
|
1622
|
+
f"provider(s)."
|
|
1623
|
+
)
|
|
1624
|
+
return self
|
|
1625
|
+
|
|
1394
1626
|
def provider_by_name(self, name: str) -> ProviderConfig:
|
|
1395
1627
|
"""Look up a provider config by name. Raises KeyError if not found."""
|
|
1396
1628
|
for p in self.providers:
|