coderouter-cli 2.7.0__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/config/capability_registry.py +22 -0
- coderouter/config/schemas.py +78 -0
- coderouter/ingress/anthropic_routes.py +108 -0
- coderouter/logging.py +5 -0
- coderouter/routing/capability.py +217 -0
- coderouter/routing/fallback.py +152 -0
- coderouter/translation/tool_repair.py +644 -39
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/METADATA +28 -9
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/RECORD +12 -12
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,72 @@ 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
|
+
|
|
709
787
|
@model_validator(mode="after")
|
|
710
788
|
def _check_context_budget_thresholds_ordered(self) -> FallbackChain:
|
|
711
789
|
"""v2.0-F (mE): cross-field sanity of the context-budget ratios.
|
|
@@ -38,6 +38,8 @@ from coderouter.routing import (
|
|
|
38
38
|
NoProvidersAvailableError,
|
|
39
39
|
)
|
|
40
40
|
from coderouter.routing.auto_router import RESERVED_PROFILE_NAME, classify
|
|
41
|
+
from coderouter.token_estimation import extract_text_from_anthropic_request
|
|
42
|
+
from coderouter.token_estimation_accurate import count_tokens, is_accuracy_available
|
|
41
43
|
from coderouter.translation import (
|
|
42
44
|
AnthropicRequest,
|
|
43
45
|
AnthropicStreamEvent,
|
|
@@ -316,6 +318,112 @@ async def messages(
|
|
|
316
318
|
return anth_resp.model_dump(exclude_none=True)
|
|
317
319
|
|
|
318
320
|
|
|
321
|
+
def _resolve_count_tokens_tokenizer_path(
|
|
322
|
+
config: Any, profile: str | None
|
|
323
|
+
) -> str | None:
|
|
324
|
+
"""S1 (shim): best-effort resolve a local tokenizer.json for counting.
|
|
325
|
+
|
|
326
|
+
Routing for ``count_tokens`` is not a full chain resolution — we only
|
|
327
|
+
need *a* representative tokenizer for the profile the request would use.
|
|
328
|
+
We take the first provider of the resolved profile (or the default
|
|
329
|
+
profile) and return its ``tokenizer_path`` when declared. Any resolution
|
|
330
|
+
hiccup (unknown profile, empty chain, stub config in tests) returns
|
|
331
|
+
``None``, which makes :func:`count_tokens` fall back to the char/4
|
|
332
|
+
heuristic — a graceful degrade rather than an error.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
chosen = profile or config.default_profile
|
|
336
|
+
chain_cfg = config.profile_by_name(chosen)
|
|
337
|
+
for pname in getattr(chain_cfg, "providers", []) or []:
|
|
338
|
+
pconf = next((p for p in config.providers if p.name == pname), None)
|
|
339
|
+
if pconf is not None:
|
|
340
|
+
tok = getattr(pconf, "tokenizer_path", None)
|
|
341
|
+
if tok:
|
|
342
|
+
return tok
|
|
343
|
+
# First provider resolved but declares no tokenizer — stop
|
|
344
|
+
# here (don't scan the whole chain for an unrelated one).
|
|
345
|
+
return None
|
|
346
|
+
except (AttributeError, KeyError, ValueError, TypeError):
|
|
347
|
+
return None
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@router.post("/messages/count_tokens", response_model=None)
|
|
352
|
+
async def count_tokens_route(
|
|
353
|
+
payload: dict[str, Any],
|
|
354
|
+
request: Request,
|
|
355
|
+
x_coderouter_profile: str | None = Header(default=None, alias=_PROFILE_HEADER),
|
|
356
|
+
x_coderouter_mode: str | None = Header(default=None, alias=_MODE_HEADER),
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Anthropic ``POST /v1/messages/count_tokens`` — local token estimate.
|
|
359
|
+
|
|
360
|
+
Mirrors Anthropic's count_tokens endpoint: the request body has the
|
|
361
|
+
same shape as ``/v1/messages`` (minus the ``max_tokens`` requirement)
|
|
362
|
+
and the response is ``{"input_tokens": N}``. CodeRouter answers this
|
|
363
|
+
entirely locally — there is no upstream round-trip — using the same
|
|
364
|
+
text-extraction the language-tax / context-budget guards use, feeding
|
|
365
|
+
an accurate local tokenizer when the routed provider declares one and
|
|
366
|
+
the ``accuracy`` extra is installed, otherwise the char/4 heuristic.
|
|
367
|
+
|
|
368
|
+
Validation is deliberately looser than :class:`AnthropicRequest` (no
|
|
369
|
+
``max_tokens`` needed): ``model`` and a non-empty ``messages`` list are
|
|
370
|
+
required, everything else optional. Profile selection follows the same
|
|
371
|
+
body > profile-header > mode-header precedence as ``/messages`` so the
|
|
372
|
+
tokenizer resolves against the provider the real request would hit.
|
|
373
|
+
"""
|
|
374
|
+
config = request.app.state.config
|
|
375
|
+
|
|
376
|
+
if not isinstance(payload, dict):
|
|
377
|
+
raise HTTPException(status_code=400, detail="request body must be a JSON object")
|
|
378
|
+
|
|
379
|
+
messages = payload.get("messages")
|
|
380
|
+
if not isinstance(messages, list) or not messages:
|
|
381
|
+
raise HTTPException(
|
|
382
|
+
status_code=400,
|
|
383
|
+
detail="'messages' is required and must be a non-empty list",
|
|
384
|
+
)
|
|
385
|
+
if "model" not in payload:
|
|
386
|
+
raise HTTPException(status_code=400, detail="'model' is required")
|
|
387
|
+
|
|
388
|
+
# Profile selection — body field wins over header, mode header last
|
|
389
|
+
# (same policy as /messages). Kept minimal: we only need the profile to
|
|
390
|
+
# pick a representative tokenizer, so an unknown mode/profile degrades
|
|
391
|
+
# to the default rather than 400'ing the count.
|
|
392
|
+
profile = payload.get("profile") or x_coderouter_profile
|
|
393
|
+
if profile is None and x_coderouter_mode:
|
|
394
|
+
try:
|
|
395
|
+
profile = config.resolve_mode(x_coderouter_mode)
|
|
396
|
+
except (KeyError, AttributeError):
|
|
397
|
+
profile = None
|
|
398
|
+
|
|
399
|
+
# Combine system + messages (+ tool JSON length) into one text blob and
|
|
400
|
+
# count. tools contribute their JSON length as a coarse proxy for the
|
|
401
|
+
# schema tokens Anthropic would bill.
|
|
402
|
+
text = extract_text_from_anthropic_request(
|
|
403
|
+
system=payload.get("system"),
|
|
404
|
+
messages=messages,
|
|
405
|
+
)
|
|
406
|
+
tools = payload.get("tools")
|
|
407
|
+
if isinstance(tools, list) and tools:
|
|
408
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
409
|
+
text = f"{text}\n{json.dumps(tools, ensure_ascii=False)}"
|
|
410
|
+
|
|
411
|
+
tokenizer_path = _resolve_count_tokens_tokenizer_path(config, profile)
|
|
412
|
+
input_tokens = count_tokens(text, tokenizer_path=tokenizer_path)
|
|
413
|
+
# Report the method count_tokens *actually* used: the accurate backend
|
|
414
|
+
# only engages when a path is declared AND the optional ``tokenizers``
|
|
415
|
+
# dependency is importable (see token_estimation_accurate). Otherwise
|
|
416
|
+
# count_tokens transparently falls back to char/4 — so we must too, or
|
|
417
|
+
# the log would misreport a heuristic count as tokenizer-accurate.
|
|
418
|
+
method = "tokenizer" if (tokenizer_path and is_accuracy_available()) else "heuristic"
|
|
419
|
+
|
|
420
|
+
logger.info(
|
|
421
|
+
"count-tokens-served",
|
|
422
|
+
extra={"method": method, "input_tokens": input_tokens},
|
|
423
|
+
)
|
|
424
|
+
return {"input_tokens": input_tokens}
|
|
425
|
+
|
|
426
|
+
|
|
319
427
|
async def _anthropic_sse_iterator(
|
|
320
428
|
engine: FallbackEngine, anth_req: AnthropicRequest
|
|
321
429
|
) -> AsyncIterator[str]:
|
coderouter/logging.py
CHANGED
|
@@ -129,6 +129,7 @@ CapabilityDegradedReason = Literal[
|
|
|
129
129
|
"provider-does-not-support",
|
|
130
130
|
"translation-lossy",
|
|
131
131
|
"non-standard-field",
|
|
132
|
+
"unsupported-backend",
|
|
132
133
|
]
|
|
133
134
|
"""Why a capability was degraded.
|
|
134
135
|
|
|
@@ -142,6 +143,10 @@ CapabilityDegradedReason = Literal[
|
|
|
142
143
|
- ``non-standard-field``: upstream emits a field that is not in the spec
|
|
143
144
|
the ingress speaks, so we strip it on the response-side boundary.
|
|
144
145
|
v0.5-C reasoning field.
|
|
146
|
+
- ``unsupported-backend``: the request asks for a semantic the target
|
|
147
|
+
backend does not honor (S2 forced ``tool_choice``); with action
|
|
148
|
+
``warn`` the request is sent unchanged, with ``emulate`` a separate
|
|
149
|
+
``tool-choice-emulated`` line records the substituted directive.
|
|
145
150
|
"""
|
|
146
151
|
|
|
147
152
|
|
coderouter/routing/capability.py
CHANGED
|
@@ -38,6 +38,7 @@ Design decisions
|
|
|
38
38
|
from __future__ import annotations
|
|
39
39
|
|
|
40
40
|
import logging
|
|
41
|
+
from typing import Any
|
|
41
42
|
|
|
42
43
|
from coderouter.config.capability_registry import (
|
|
43
44
|
CapabilityRegistry,
|
|
@@ -63,13 +64,17 @@ __all__ = [
|
|
|
63
64
|
"CapabilityRegistry",
|
|
64
65
|
"ResolvedCapabilities",
|
|
65
66
|
"anthropic_request_has_cache_control",
|
|
67
|
+
"anthropic_request_has_forced_tool_choice",
|
|
66
68
|
"anthropic_request_requires_thinking",
|
|
67
69
|
"check_claude_code_chain_suitability",
|
|
70
|
+
"emulate_tool_choice",
|
|
68
71
|
"get_default_registry",
|
|
69
72
|
"log_capability_degraded",
|
|
70
73
|
"provider_supports_cache_control",
|
|
71
74
|
"provider_supports_thinking",
|
|
75
|
+
"provider_supports_tool_choice",
|
|
72
76
|
"reset_default_registry",
|
|
77
|
+
"strip_cache_control",
|
|
73
78
|
"strip_thinking",
|
|
74
79
|
]
|
|
75
80
|
|
|
@@ -308,6 +313,218 @@ def anthropic_request_has_cache_control(request: AnthropicRequest) -> bool:
|
|
|
308
313
|
return False
|
|
309
314
|
|
|
310
315
|
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# S2 (shim): tool_choice capability gate + emulation
|
|
318
|
+
#
|
|
319
|
+
# Anthropic's ``tool_choice`` forces the model to call a tool: ``{type:
|
|
320
|
+
# "any"}`` (call *some* provided tool) or ``{type: "tool", name: "X"}``
|
|
321
|
+
# (call the named tool). Native Anthropic honors this on the wire; most
|
|
322
|
+
# openai_compat backends silently ignore it after Anthropic → OpenAI
|
|
323
|
+
# translation, so the model can answer with plain text where the client
|
|
324
|
+
# expected a tool call. The gate here answers "does this provider honor a
|
|
325
|
+
# forced tool_choice?" with the same 3-tier resolution as the other v0.5
|
|
326
|
+
# gates. The fallback engine's ``tool_choice_action`` uses the answer to
|
|
327
|
+
# either warn or emulate the forcing via a system-prompt directive.
|
|
328
|
+
#
|
|
329
|
+
# ``auto`` / ``none`` / absent tool_choice never trips the gate — only the
|
|
330
|
+
# forcing modes matter (a non-forced choice loses nothing meaningful in
|
|
331
|
+
# translation).
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def provider_supports_tool_choice(
|
|
336
|
+
provider: ProviderConfig,
|
|
337
|
+
*,
|
|
338
|
+
registry: CapabilityRegistry | None = None,
|
|
339
|
+
) -> bool:
|
|
340
|
+
"""Does this provider honor a forced Anthropic ``tool_choice``?
|
|
341
|
+
|
|
342
|
+
Resolution order (mirrors :func:`provider_supports_cache_control`):
|
|
343
|
+
1. If ``provider.capabilities.tool_choice`` is True → True
|
|
344
|
+
(explicit per-provider opt-in from providers.yaml — highest
|
|
345
|
+
precedence).
|
|
346
|
+
2. Consult the capability registry for an explicit
|
|
347
|
+
``tool_choice: true|false`` declaration on this
|
|
348
|
+
``(kind, model)``. A registry value of ``False`` hard-disables
|
|
349
|
+
the capability even on a provider whose ``kind`` would normally
|
|
350
|
+
pass.
|
|
351
|
+
3. Fall back to the heuristic ``kind == "anthropic"`` — native
|
|
352
|
+
``/v1/messages`` passthrough forwards ``tool_choice`` verbatim,
|
|
353
|
+
whereas openai_compat translation drops the forcing semantics.
|
|
354
|
+
|
|
355
|
+
This routine does not inspect the request — it's a per-provider
|
|
356
|
+
capability. Combine with :func:`anthropic_request_has_forced_tool_choice`
|
|
357
|
+
in the engine to decide whether to warn / emulate. The ``registry``
|
|
358
|
+
kwarg is for tests; production callers pass nothing.
|
|
359
|
+
"""
|
|
360
|
+
if provider.capabilities.tool_choice:
|
|
361
|
+
return True
|
|
362
|
+
resolved = _resolve(provider, registry)
|
|
363
|
+
if resolved.tool_choice is True:
|
|
364
|
+
return True
|
|
365
|
+
if resolved.tool_choice is False:
|
|
366
|
+
return False
|
|
367
|
+
return provider.kind == "anthropic"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def anthropic_request_has_forced_tool_choice(request: AnthropicRequest) -> bool:
|
|
371
|
+
"""True iff the request forces a tool call via ``tool_choice``.
|
|
372
|
+
|
|
373
|
+
Anthropic's ``tool_choice.type`` is one of ``auto`` / ``none`` /
|
|
374
|
+
``any`` / ``tool``. Only ``any`` (force *some* tool) and ``tool``
|
|
375
|
+
(force the named tool) are "forcing" modes that a non-supporting
|
|
376
|
+
backend would silently ignore. ``auto`` / ``none`` / a missing field
|
|
377
|
+
return False — there is nothing to emulate.
|
|
378
|
+
"""
|
|
379
|
+
choice = request.tool_choice
|
|
380
|
+
if not isinstance(choice, dict):
|
|
381
|
+
return False
|
|
382
|
+
return choice.get("type") in ("any", "tool")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# English directive templates injected into the system prompt when
|
|
386
|
+
# emulating a forced tool_choice on a non-supporting backend. Kept as
|
|
387
|
+
# module constants so tests can assert the exact text and operators can
|
|
388
|
+
# grep for it.
|
|
389
|
+
_EMULATE_TOOL_DIRECTIVE = (
|
|
390
|
+
'\n\nIMPORTANT: You MUST respond by calling the tool named "{name}". '
|
|
391
|
+
"Do not respond with plain text."
|
|
392
|
+
)
|
|
393
|
+
_EMULATE_ANY_DIRECTIVE = (
|
|
394
|
+
"\n\nIMPORTANT: You MUST respond by calling one of the provided tools. "
|
|
395
|
+
"Do not respond with plain text."
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _inject_system_directive(system: Any, directive: str) -> str | list[dict[str, Any]]:
|
|
400
|
+
"""Append ``directive`` to an Anthropic ``system`` field.
|
|
401
|
+
|
|
402
|
+
Handles both wire shapes:
|
|
403
|
+
- ``str`` (short form) → returns ``system + directive`` (a new
|
|
404
|
+
``str``; ``None`` is treated as empty).
|
|
405
|
+
- ``list[dict]`` (block form) → returns a new list with the
|
|
406
|
+
directive appended as a trailing ``text`` block, so existing
|
|
407
|
+
blocks (including any ``cache_control`` markers) are untouched.
|
|
408
|
+
"""
|
|
409
|
+
if system is None:
|
|
410
|
+
return directive
|
|
411
|
+
if isinstance(system, str):
|
|
412
|
+
return system + directive
|
|
413
|
+
if isinstance(system, list):
|
|
414
|
+
return [*system, {"type": "text", "text": directive}]
|
|
415
|
+
# Unexpected shape — fall back to a fresh string so we never lose the
|
|
416
|
+
# forcing directive (the original object is left untouched upstream).
|
|
417
|
+
return directive
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def emulate_tool_choice(request: AnthropicRequest) -> AnthropicRequest:
|
|
421
|
+
"""Return a copy of ``request`` with tool_choice emulated in the prompt.
|
|
422
|
+
|
|
423
|
+
Used by the fallback engine's ``tool_choice_action: "emulate"`` path.
|
|
424
|
+
The returned request has (1) its ``tool_choice`` field removed and (2)
|
|
425
|
+
an English directive appended to ``system`` instructing the model to
|
|
426
|
+
call the requested tool. The directive text differs for ``type ==
|
|
427
|
+
"tool"`` (names the tool) vs ``type == "any"`` (any provided tool).
|
|
428
|
+
|
|
429
|
+
The original request is NOT mutated — the engine iterates a fallback
|
|
430
|
+
chain and a later capable provider must still receive the real
|
|
431
|
+
``tool_choice``. Like :func:`strip_thinking`, the CodeRouter-internal
|
|
432
|
+
``profile`` / ``anthropic_beta`` fields are preserved.
|
|
433
|
+
|
|
434
|
+
No-op-safe: when the request carries no forced tool_choice this still
|
|
435
|
+
returns a deep copy (callers gate on
|
|
436
|
+
:func:`anthropic_request_has_forced_tool_choice` first, so this is
|
|
437
|
+
just defensive).
|
|
438
|
+
"""
|
|
439
|
+
choice = request.tool_choice if isinstance(request.tool_choice, dict) else {}
|
|
440
|
+
ctype = choice.get("type")
|
|
441
|
+
if ctype == "tool":
|
|
442
|
+
name = choice.get("name", "")
|
|
443
|
+
directive = _EMULATE_TOOL_DIRECTIVE.format(name=name)
|
|
444
|
+
else: # "any" (or defensive fallback)
|
|
445
|
+
directive = _EMULATE_ANY_DIRECTIVE
|
|
446
|
+
|
|
447
|
+
dumped = request.model_dump()
|
|
448
|
+
dumped.pop("tool_choice", None)
|
|
449
|
+
dumped["system"] = _inject_system_directive(request.system, directive)
|
|
450
|
+
emulated = AnthropicRequest.model_validate(dumped)
|
|
451
|
+
emulated.profile = request.profile
|
|
452
|
+
emulated.anthropic_beta = request.anthropic_beta
|
|
453
|
+
return emulated
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# S3 (shim): cache_control strip
|
|
458
|
+
#
|
|
459
|
+
# The v0.5-B gate (above) is observability-only — it logs that cache_control
|
|
460
|
+
# will be lost in translation but never mutates the request, because most
|
|
461
|
+
# openai_compat backends simply ignore the unknown key. Some strict backends
|
|
462
|
+
# 400 instead. ``strip_cache_control`` proactively removes every
|
|
463
|
+
# ``cache_control`` key from a deep copy of the request so the marker never
|
|
464
|
+
# reaches such a backend. Returns ``(request, markers_removed)`` so the
|
|
465
|
+
# engine can log the count. The original request is not mutated (a later
|
|
466
|
+
# capable provider must still receive the markers).
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _strip_cache_control_from_blocks(blocks: Any) -> int:
|
|
471
|
+
"""Remove ``cache_control`` from each dict block in ``blocks`` in place.
|
|
472
|
+
|
|
473
|
+
Returns the number of keys removed. Non-list / non-dict entries are
|
|
474
|
+
ignored. Mutates the dicts in the given list (the caller passes a deep
|
|
475
|
+
copy's containers).
|
|
476
|
+
"""
|
|
477
|
+
removed = 0
|
|
478
|
+
if not isinstance(blocks, list):
|
|
479
|
+
return 0
|
|
480
|
+
for block in blocks:
|
|
481
|
+
if isinstance(block, dict) and "cache_control" in block:
|
|
482
|
+
del block["cache_control"]
|
|
483
|
+
removed += 1
|
|
484
|
+
return removed
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def strip_cache_control(request: AnthropicRequest) -> tuple[AnthropicRequest, int]:
|
|
488
|
+
"""Return ``(copy, markers_removed)`` with all cache_control keys gone.
|
|
489
|
+
|
|
490
|
+
Walks the same three locations as
|
|
491
|
+
:func:`anthropic_request_has_cache_control` — ``system`` blocks,
|
|
492
|
+
``tools[*]`` (where the marker rides via ``extra="allow"``), and each
|
|
493
|
+
list-form ``messages[*].content`` — and removes every ``cache_control``
|
|
494
|
+
key from a deep copy. The original request is untouched so the fallback
|
|
495
|
+
chain can hand the real markers to a later capable provider.
|
|
496
|
+
|
|
497
|
+
``markers_removed`` is the total count across all three locations,
|
|
498
|
+
surfaced by the engine in the ``cache-control-stripped`` log. When the
|
|
499
|
+
request carried no markers this returns ``(copy, 0)``.
|
|
500
|
+
"""
|
|
501
|
+
dumped = request.model_dump()
|
|
502
|
+
removed = 0
|
|
503
|
+
|
|
504
|
+
# system blocks (list form only — the str short form can't carry it).
|
|
505
|
+
removed += _strip_cache_control_from_blocks(dumped.get("system"))
|
|
506
|
+
|
|
507
|
+
# tool definitions — cache_control arrives via extra="allow".
|
|
508
|
+
tools = dumped.get("tools")
|
|
509
|
+
if isinstance(tools, list):
|
|
510
|
+
for tool in tools:
|
|
511
|
+
if isinstance(tool, dict) and "cache_control" in tool:
|
|
512
|
+
del tool["cache_control"]
|
|
513
|
+
removed += 1
|
|
514
|
+
|
|
515
|
+
# message content blocks (list form only).
|
|
516
|
+
messages = dumped.get("messages")
|
|
517
|
+
if isinstance(messages, list):
|
|
518
|
+
for msg in messages:
|
|
519
|
+
if isinstance(msg, dict):
|
|
520
|
+
removed += _strip_cache_control_from_blocks(msg.get("content"))
|
|
521
|
+
|
|
522
|
+
stripped = AnthropicRequest.model_validate(dumped)
|
|
523
|
+
stripped.profile = request.profile
|
|
524
|
+
stripped.anthropic_beta = request.anthropic_beta
|
|
525
|
+
return stripped, removed
|
|
526
|
+
|
|
527
|
+
|
|
311
528
|
# ---------------------------------------------------------------------------
|
|
312
529
|
# v1.7-B: claude_code_suitability startup check
|
|
313
530
|
#
|