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
coderouter/metrics/prometheus.py
CHANGED
|
@@ -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=[((
|
|
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=[((
|
|
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=[((
|
|
428
|
+
samples=[((), drift_reload_ok)],
|
|
429
429
|
)
|
|
430
430
|
)
|
|
431
431
|
|
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
|
#
|