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