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.
@@ -407,7 +407,7 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
407
407
  "Number of times a drifted provider was demoted "
408
408
  "(promote/reload action fired)."
409
409
  ),
410
- samples=[(((),), drift_promoted)],
410
+ samples=[((), drift_promoted)],
411
411
  )
412
412
  )
413
413
  drift_reload = counters.get("drift_reload_total", 0)
@@ -416,7 +416,7 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
416
416
  _counter(
417
417
  name="drift_reload_total",
418
418
  help_text="Ollama KV cache flush attempts (reload action).",
419
- samples=[(((),), drift_reload)],
419
+ samples=[((), drift_reload)],
420
420
  )
421
421
  )
422
422
  drift_reload_ok = counters.get("drift_reload_success_total", 0)
@@ -425,7 +425,7 @@ def format_prometheus(snapshot: dict[str, Any]) -> str:
425
425
  _counter(
426
426
  name="drift_reload_success_total",
427
427
  help_text="Successful Ollama KV cache flush attempts.",
428
- samples=[(((),), drift_reload_ok)],
428
+ samples=[((), drift_reload_ok)],
429
429
  )
430
430
  )
431
431
 
@@ -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
  #