coderouter-cli 2.7.1__py3-none-any.whl → 2.7.3__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/schemas.py +51 -0
- coderouter/logging.py +40 -0
- coderouter/metrics/collector.py +29 -0
- coderouter/routing/fallback.py +216 -5
- coderouter/translation/tool_repair.py +633 -9
- {coderouter_cli-2.7.1.dist-info → coderouter_cli-2.7.3.dist-info}/METADATA +1 -1
- {coderouter_cli-2.7.1.dist-info → coderouter_cli-2.7.3.dist-info}/RECORD +10 -10
- {coderouter_cli-2.7.1.dist-info → coderouter_cli-2.7.3.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.7.1.dist-info → coderouter_cli-2.7.3.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.7.1.dist-info → coderouter_cli-2.7.3.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -718,6 +718,57 @@ class FallbackChain(BaseModel):
|
|
|
718
718
|
),
|
|
719
719
|
)
|
|
720
720
|
|
|
721
|
+
# --- ⑧ (empty-response): per-request empty-response fallback ------------
|
|
722
|
+
#
|
|
723
|
+
# Some local backends (observed: gemma4:26b on ``no_tool_temptation``
|
|
724
|
+
# prompts) return a 200 with a structurally-valid but *content-empty*
|
|
725
|
+
# Anthropic response — no tool_use and no non-whitespace text — for a
|
|
726
|
+
# fraction of requests. The drift guard's ``empty_response_rate`` is a
|
|
727
|
+
# windowed aggregate (default threshold 0.3, ``min_window_fill`` 6): it
|
|
728
|
+
# promotes/reloads a backend once the *rate* is bad, but cannot rescue a
|
|
729
|
+
# single blank turn in-flight. This knob adds a per-request in-flight
|
|
730
|
+
# fallback that re-dispatches the *same* request to the next provider in
|
|
731
|
+
# the chain the moment an empty response is detected.
|
|
732
|
+
#
|
|
733
|
+
# Design: "empty" is judged on *content*, not usage.output_tokens (which
|
|
734
|
+
# some backends report unreliably). A response is empty when its content
|
|
735
|
+
# list is empty, or every block is either a whitespace-only ``text`` block
|
|
736
|
+
# or a ``thinking`` block — i.e. nothing the client can act on. A single
|
|
737
|
+
# ``tool_use`` block or one non-whitespace ``text`` block makes it non-empty.
|
|
738
|
+
#
|
|
739
|
+
# * ``off`` — no detection, no fallback, no log. Backward-compatible
|
|
740
|
+
# default (byte-for-byte identical to pre-⑧ behavior).
|
|
741
|
+
# * ``warn`` — detect + emit an ``empty-response-detected`` log line
|
|
742
|
+
# only; the empty response is returned unchanged.
|
|
743
|
+
# * ``fallback`` — on an empty response, log ``empty-response-detected``
|
|
744
|
+
# and continue to the next provider (the empty response
|
|
745
|
+
# is *not* recorded as an error). If every provider in
|
|
746
|
+
# the chain returns empty, the last empty response is
|
|
747
|
+
# returned as-is (a 200 blank is a legitimate answer)
|
|
748
|
+
# with ``chain_exhausted=True`` on the log line. On the
|
|
749
|
+
# streaming path, ``fallback`` buffers events until real
|
|
750
|
+
# content is observed, so an empty stream can be swapped
|
|
751
|
+
# to the next provider without the client seeing bytes.
|
|
752
|
+
#
|
|
753
|
+
# Default ``off`` preserves complete backward compatibility; the original
|
|
754
|
+
# request object is never mutated, so a later provider always receives the
|
|
755
|
+
# untouched request.
|
|
756
|
+
empty_response_action: Literal["off", "warn", "fallback"] = Field(
|
|
757
|
+
default="off",
|
|
758
|
+
description=(
|
|
759
|
+
"⑧ (empty-response): action when a provider returns a 200 with "
|
|
760
|
+
"empty content (no tool_use and no non-whitespace text; a "
|
|
761
|
+
"thinking-only response counts as empty). ``off`` (default) does "
|
|
762
|
+
"nothing. ``warn`` emits an ``empty-response-detected`` log only "
|
|
763
|
+
"and returns the empty response. ``fallback`` re-dispatches the "
|
|
764
|
+
"same request to the next provider (empty is not counted as an "
|
|
765
|
+
"error); if the whole chain returns empty, the last empty response "
|
|
766
|
+
"is returned unchanged. Streaming buffers events until real "
|
|
767
|
+
"content appears so empty streams can be swapped provider-side "
|
|
768
|
+
"without the client seeing any bytes."
|
|
769
|
+
),
|
|
770
|
+
)
|
|
771
|
+
|
|
721
772
|
# --- S2 (shim): tool_choice capability gate + emulation -----------------
|
|
722
773
|
#
|
|
723
774
|
# Anthropic clients (Claude Code, SDKs) can pin the model to a specific
|
coderouter/logging.py
CHANGED
|
@@ -1372,6 +1372,46 @@ def log_partial_stitch_surfaced(
|
|
|
1372
1372
|
)
|
|
1373
1373
|
|
|
1374
1374
|
|
|
1375
|
+
# ---------------------------------------------------------------------------
|
|
1376
|
+
# ⑧ (empty-response): per-request empty-response fallback log shape
|
|
1377
|
+
#
|
|
1378
|
+
# Fired when a provider returns a 200 with content-empty output (no
|
|
1379
|
+
# tool_use and no non-whitespace text). The single event lane carries the
|
|
1380
|
+
# ``action`` (warn|fallback) that triggered it, whether the empty response
|
|
1381
|
+
# came from the streaming or non-streaming path, and ``chain_exhausted``
|
|
1382
|
+
# — True only when every provider in the chain returned empty and the last
|
|
1383
|
+
# empty response is being returned to the client as-is.
|
|
1384
|
+
# ---------------------------------------------------------------------------
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def log_empty_response_detected(
|
|
1388
|
+
logger: logging.Logger,
|
|
1389
|
+
provider: str,
|
|
1390
|
+
*,
|
|
1391
|
+
action: str,
|
|
1392
|
+
stream: bool,
|
|
1393
|
+
chain_exhausted: bool = False,
|
|
1394
|
+
) -> None:
|
|
1395
|
+
"""Emit an ``empty-response-detected`` warn line.
|
|
1396
|
+
|
|
1397
|
+
Fired when ``empty_response_action`` is ``warn`` or ``fallback`` and a
|
|
1398
|
+
provider returns a structurally-valid but content-empty response. The
|
|
1399
|
+
``action`` field records which knob value produced the line; ``stream``
|
|
1400
|
+
distinguishes the SSE path from the non-streaming path; ``chain_exhausted``
|
|
1401
|
+
is True only when the whole chain returned empty and the last empty
|
|
1402
|
+
response is being returned unchanged.
|
|
1403
|
+
"""
|
|
1404
|
+
logger.warning(
|
|
1405
|
+
"empty-response-detected",
|
|
1406
|
+
extra={
|
|
1407
|
+
"provider": provider,
|
|
1408
|
+
"action": action,
|
|
1409
|
+
"stream": stream,
|
|
1410
|
+
"chain_exhausted": chain_exhausted,
|
|
1411
|
+
},
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
|
|
1375
1415
|
# ---------------------------------------------------------------------------
|
|
1376
1416
|
# v2.0-I: Continuous probe log shapes
|
|
1377
1417
|
#
|
coderouter/metrics/collector.py
CHANGED
|
@@ -52,6 +52,7 @@ Event inventory (dispatch table in :meth:`MetricsCollector._dispatch`)
|
|
|
52
52
|
``drift-promoted`` (v2.0-G) → ``drift_promoted_total``
|
|
53
53
|
``drift-reload-attempted`` → ``drift_reload_total`` / success
|
|
54
54
|
``partial-stitch-surfaced`` → ``partial_stitch_surfaced_total`` (v2.0-H)
|
|
55
|
+
``empty-response-detected`` → ``empty_responses_total`` + per-provider (⑧)
|
|
55
56
|
``probe-completed`` (v2.0-I) → ``probe_total`` / ``probe_success`` / ``probe_failure``
|
|
56
57
|
+ per-provider latency gauge
|
|
57
58
|
``probe-round-completed`` → ``probe_rounds_total`` (v2.0-I)
|
|
@@ -109,6 +110,7 @@ _KNOWN_EVENTS: Final[frozenset[str]] = frozenset(
|
|
|
109
110
|
"drift-promoted",
|
|
110
111
|
"drift-reload-attempted",
|
|
111
112
|
"partial-stitch-surfaced",
|
|
113
|
+
"empty-response-detected",
|
|
112
114
|
"probe-completed",
|
|
113
115
|
"probe-round-completed",
|
|
114
116
|
"probe-capabilities-drift",
|
|
@@ -272,6 +274,13 @@ class MetricsCollector(logging.Handler):
|
|
|
272
274
|
# client instead of a generic error event.
|
|
273
275
|
self._partial_stitch_surfaced_total: int = 0
|
|
274
276
|
|
|
277
|
+
# ⑧ (empty-response): count of per-request empty-response detections
|
|
278
|
+
# (both ``warn`` and ``fallback`` actions). Aggregate + per-provider
|
|
279
|
+
# so a dashboard can spot which backend blanks out. Counts every
|
|
280
|
+
# detection event, including the terminal chain-exhausted one.
|
|
281
|
+
self._empty_responses_total: int = 0
|
|
282
|
+
self._empty_responses_by_provider: Counter[str] = Counter()
|
|
283
|
+
|
|
275
284
|
# v2.0-I: continuous probe counters. Per-provider probe attempts
|
|
276
285
|
# and outcomes, plus a round counter for the dashboard's
|
|
277
286
|
# "probes/min" panel.
|
|
@@ -329,6 +338,7 @@ class MetricsCollector(logging.Handler):
|
|
|
329
338
|
"drift-promoted": self._on_drift_promoted,
|
|
330
339
|
"drift-reload-attempted": self._on_drift_reload_attempted,
|
|
331
340
|
"partial-stitch-surfaced": self._on_partial_stitch_surfaced,
|
|
341
|
+
"empty-response-detected": self._on_empty_response_detected,
|
|
332
342
|
"probe-completed": self._on_probe_completed,
|
|
333
343
|
"probe-round-completed": self._on_probe_round_completed,
|
|
334
344
|
"probe-capabilities-drift": self._on_probe_capabilities_drift,
|
|
@@ -670,6 +680,17 @@ class MetricsCollector(logging.Handler):
|
|
|
670
680
|
self._partial_stitch_surfaced_total += 1
|
|
671
681
|
self._push_recent("partial-stitch-surfaced", extras, record)
|
|
672
682
|
|
|
683
|
+
def _on_empty_response_detected(
|
|
684
|
+
self, extras: dict[str, Any], record: logging.LogRecord
|
|
685
|
+
) -> None:
|
|
686
|
+
# ⑧ (empty-response): a content-empty 200 was detected under a
|
|
687
|
+
# ``warn`` / ``fallback`` action.
|
|
688
|
+
self._empty_responses_total += 1
|
|
689
|
+
provider = _str(extras.get("provider"))
|
|
690
|
+
if provider:
|
|
691
|
+
self._empty_responses_by_provider[provider] += 1
|
|
692
|
+
self._push_recent("empty-response-detected", extras, record)
|
|
693
|
+
|
|
673
694
|
def _on_probe_completed(
|
|
674
695
|
self, extras: dict[str, Any], record: logging.LogRecord
|
|
675
696
|
) -> None:
|
|
@@ -888,6 +909,11 @@ class MetricsCollector(logging.Handler):
|
|
|
888
909
|
"drift_reload_success_total": self._drift_reload_success_total,
|
|
889
910
|
# v2.0-H (L6): partial stitch surfaced.
|
|
890
911
|
"partial_stitch_surfaced_total": self._partial_stitch_surfaced_total,
|
|
912
|
+
# ⑧ (empty-response): per-request empty-response fallback.
|
|
913
|
+
"empty_responses_total": self._empty_responses_total,
|
|
914
|
+
"empty_responses_by_provider": dict(
|
|
915
|
+
self._empty_responses_by_provider
|
|
916
|
+
),
|
|
891
917
|
# v2.0-I: continuous probe counters.
|
|
892
918
|
"probe_total": dict(self._probe_total),
|
|
893
919
|
"probe_success": dict(self._probe_success),
|
|
@@ -1042,6 +1068,9 @@ class MetricsCollector(logging.Handler):
|
|
|
1042
1068
|
self._tokens_saved_by_mechanism.clear()
|
|
1043
1069
|
# v2.0-H (L6)
|
|
1044
1070
|
self._partial_stitch_surfaced_total = 0
|
|
1071
|
+
# ⑧ (empty-response)
|
|
1072
|
+
self._empty_responses_total = 0
|
|
1073
|
+
self._empty_responses_by_provider.clear()
|
|
1045
1074
|
# v2.0-I
|
|
1046
1075
|
self._probe_total.clear()
|
|
1047
1076
|
self._probe_success.clear()
|
coderouter/routing/fallback.py
CHANGED
|
@@ -76,6 +76,7 @@ from coderouter.logging import (
|
|
|
76
76
|
log_chain_memory_pressure_blocked,
|
|
77
77
|
log_chain_paid_gate_blocked,
|
|
78
78
|
log_demote_unhealthy_provider,
|
|
79
|
+
log_empty_response_detected,
|
|
79
80
|
log_memory_pressure_detected,
|
|
80
81
|
log_skip_budget_exceeded,
|
|
81
82
|
log_skip_memory_pressure,
|
|
@@ -973,6 +974,82 @@ def _warn_if_uniform_auth_failure(errors: list[AdapterError], *, profile: str) -
|
|
|
973
974
|
)
|
|
974
975
|
|
|
975
976
|
|
|
977
|
+
# ---------------------------------------------------------------------------
|
|
978
|
+
# ⑧ (empty-response): content-based emptiness judgement + stream buffering
|
|
979
|
+
# ---------------------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _block_field(block: Any, key: str) -> Any:
|
|
983
|
+
"""Read ``key`` from a content block whether it is a dict or a model.
|
|
984
|
+
|
|
985
|
+
Anthropic content blocks reach the engine as plain dicts (openai_compat
|
|
986
|
+
translation, most test fakes) or as pydantic-ish objects (native
|
|
987
|
+
passthrough). This mirrors the accessor pattern already used by the
|
|
988
|
+
drift fingerprint at the success-path tail.
|
|
989
|
+
"""
|
|
990
|
+
if isinstance(block, dict):
|
|
991
|
+
return block.get(key)
|
|
992
|
+
return getattr(block, key, None)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _anthropic_response_is_empty(resp: AnthropicResponse) -> bool:
|
|
996
|
+
"""Return True when ``resp`` carries no client-actionable content.
|
|
997
|
+
|
|
998
|
+
"Empty" is judged on content, never on ``usage.output_tokens`` (some
|
|
999
|
+
backends report that unreliably). A response is empty when:
|
|
1000
|
+
- its ``content`` list is empty / falsy, or
|
|
1001
|
+
- every block is either a whitespace-only ``text`` block or a
|
|
1002
|
+
``thinking`` block.
|
|
1003
|
+
|
|
1004
|
+
A single ``tool_use`` block, or one ``text`` block with any
|
|
1005
|
+
non-whitespace character, makes the response non-empty. Unknown block
|
|
1006
|
+
types are treated conservatively as *content* (non-empty) so a novel
|
|
1007
|
+
actionable block is never silently swallowed.
|
|
1008
|
+
"""
|
|
1009
|
+
content = getattr(resp, "content", None)
|
|
1010
|
+
if not content:
|
|
1011
|
+
return True
|
|
1012
|
+
for block in content:
|
|
1013
|
+
btype = _block_field(block, "type")
|
|
1014
|
+
if btype == "text":
|
|
1015
|
+
text = _block_field(block, "text") or ""
|
|
1016
|
+
if text.strip():
|
|
1017
|
+
return False
|
|
1018
|
+
# whitespace-only text → keep scanning
|
|
1019
|
+
continue
|
|
1020
|
+
if btype == "thinking":
|
|
1021
|
+
# thinking-only carries nothing the client can act on
|
|
1022
|
+
continue
|
|
1023
|
+
# tool_use or any other (unknown → conservatively non-empty) block
|
|
1024
|
+
return False
|
|
1025
|
+
return True
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _stream_event_is_real_content(event: AnthropicStreamEvent) -> bool:
|
|
1029
|
+
"""Return True when ``event`` is the first byte of actionable content.
|
|
1030
|
+
|
|
1031
|
+
Real content is either a ``content_block_start`` opening a ``tool_use``
|
|
1032
|
+
block, or a ``content_block_delta`` carrying a non-whitespace text
|
|
1033
|
+
delta. ``message_start`` / ``ping`` / empty-text openers do not count —
|
|
1034
|
+
they are the buffered preamble that an empty stream also emits.
|
|
1035
|
+
"""
|
|
1036
|
+
data = getattr(event, "data", None) or {}
|
|
1037
|
+
etype = data.get("type") if isinstance(data, dict) else None
|
|
1038
|
+
if etype == "content_block_start":
|
|
1039
|
+
block = data.get("content_block") or {}
|
|
1040
|
+
return isinstance(block, dict) and block.get("type") == "tool_use"
|
|
1041
|
+
if etype == "content_block_delta":
|
|
1042
|
+
delta = data.get("delta") or {}
|
|
1043
|
+
if isinstance(delta, dict):
|
|
1044
|
+
# text_delta with non-whitespace text is real content;
|
|
1045
|
+
# input_json_delta (tool args) is real content too.
|
|
1046
|
+
if delta.get("type") == "text_delta":
|
|
1047
|
+
return bool((delta.get("text") or "").strip())
|
|
1048
|
+
if delta.get("type") == "input_json_delta":
|
|
1049
|
+
return True
|
|
1050
|
+
return False
|
|
1051
|
+
|
|
1052
|
+
|
|
976
1053
|
class FallbackEngine:
|
|
977
1054
|
"""Sequential fallback router — the core of CodeRouter.
|
|
978
1055
|
|
|
@@ -1717,6 +1794,20 @@ class FallbackEngine:
|
|
|
1717
1794
|
getattr(profile, "cache_control_action", "off"),
|
|
1718
1795
|
)
|
|
1719
1796
|
|
|
1797
|
+
def _resolve_empty_response_action(self, profile_name: str | None) -> str:
|
|
1798
|
+
"""⑧ (empty-response): resolve ``empty_response_action`` for a profile.
|
|
1799
|
+
|
|
1800
|
+
Defaults to ``off`` so a missing / stub profile (e.g. a
|
|
1801
|
+
``__new__``-constructed test engine) yields the backward-compatible
|
|
1802
|
+
no-op. Resolved once at the top of each Anthropic entry point.
|
|
1803
|
+
"""
|
|
1804
|
+
chosen = profile_name or self.config.default_profile
|
|
1805
|
+
try:
|
|
1806
|
+
profile = self.config.profile_by_name(chosen)
|
|
1807
|
+
except (KeyError, ValueError):
|
|
1808
|
+
return "off"
|
|
1809
|
+
return getattr(profile, "empty_response_action", "off")
|
|
1810
|
+
|
|
1720
1811
|
def _resolve_chain(self, profile_name: str | None) -> list[BaseAdapter]:
|
|
1721
1812
|
"""Return the list of adapters to try, in order, for this profile.
|
|
1722
1813
|
|
|
@@ -2431,6 +2522,11 @@ class FallbackEngine:
|
|
|
2431
2522
|
tool_choice_action, cache_control_action = self._resolve_shim_actions(
|
|
2432
2523
|
request.profile
|
|
2433
2524
|
)
|
|
2525
|
+
# ⑧ (empty-response): resolve the per-request empty-response action
|
|
2526
|
+
# once. ``last_empty_resp`` holds the most recent content-empty 200
|
|
2527
|
+
# under ``fallback`` so a fully-empty chain returns it verbatim.
|
|
2528
|
+
empty_response_action = self._resolve_empty_response_action(request.profile)
|
|
2529
|
+
last_empty_resp: AnthropicResponse | None = None
|
|
2434
2530
|
|
|
2435
2531
|
for adapter, will_degrade in chain:
|
|
2436
2532
|
is_native = isinstance(adapter, AnthropicAdapter)
|
|
@@ -2585,6 +2681,26 @@ class FallbackEngine:
|
|
|
2585
2681
|
stream=False,
|
|
2586
2682
|
response_fingerprint=_fp(_fp_text) if _fp_text else None,
|
|
2587
2683
|
)
|
|
2684
|
+
# ⑧ (empty-response): per-request empty-response handling. Runs
|
|
2685
|
+
# after the drift observation above (which still gets the real
|
|
2686
|
+
# output_tokens=0 signal for the windowed empty_response_rate)
|
|
2687
|
+
# but before the cache-observed / observer fanout, so a
|
|
2688
|
+
# ``fallback`` swap does not emit a "completed" line for a
|
|
2689
|
+
# response the client never sees.
|
|
2690
|
+
if empty_response_action != "off" and _anthropic_response_is_empty(resp):
|
|
2691
|
+
if empty_response_action == "fallback":
|
|
2692
|
+
# Remember this empty response so a fully-empty chain can
|
|
2693
|
+
# return it verbatim, then try the next provider. Not
|
|
2694
|
+
# recorded in ``errors`` — a 200 blank is not a failure.
|
|
2695
|
+
last_empty_resp = resp
|
|
2696
|
+
log_empty_response_detected(
|
|
2697
|
+
logger, adapter.name, action="fallback", stream=False
|
|
2698
|
+
)
|
|
2699
|
+
continue
|
|
2700
|
+
# ``warn``: log only; fall through and return the empty resp.
|
|
2701
|
+
log_empty_response_detected(
|
|
2702
|
+
logger, adapter.name, action="warn", stream=False
|
|
2703
|
+
)
|
|
2588
2704
|
# v1.9-A: pair every successful Anthropic response with a
|
|
2589
2705
|
# cache-observed log line. Native Anthropic / LM Studio
|
|
2590
2706
|
# /v1/messages report cache_read_input_tokens /
|
|
@@ -2625,6 +2741,20 @@ class FallbackEngine:
|
|
|
2625
2741
|
)
|
|
2626
2742
|
return resp
|
|
2627
2743
|
|
|
2744
|
+
# ⑧ (empty-response): the chain is exhausted. Under ``fallback``,
|
|
2745
|
+
# if every provider that answered returned an empty 200, return the
|
|
2746
|
+
# last empty response verbatim (a 200 blank is a legitimate answer)
|
|
2747
|
+
# rather than raising — errors is empty in that case anyway.
|
|
2748
|
+
if last_empty_resp is not None:
|
|
2749
|
+
log_empty_response_detected(
|
|
2750
|
+
logger,
|
|
2751
|
+
last_empty_resp.coderouter_provider or "unknown",
|
|
2752
|
+
action="fallback",
|
|
2753
|
+
stream=False,
|
|
2754
|
+
chain_exhausted=True,
|
|
2755
|
+
)
|
|
2756
|
+
return last_empty_resp
|
|
2757
|
+
|
|
2628
2758
|
profile = request.profile or self.config.default_profile
|
|
2629
2759
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
2630
2760
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
@@ -2676,6 +2806,15 @@ class FallbackEngine:
|
|
|
2676
2806
|
tool_choice_action, cache_control_action = self._resolve_shim_actions(
|
|
2677
2807
|
request.profile
|
|
2678
2808
|
)
|
|
2809
|
+
# ⑧ (empty-response): resolve the per-request empty-response action.
|
|
2810
|
+
# Only ``fallback`` changes the streaming path (it buffers events
|
|
2811
|
+
# until real content appears); ``off`` / ``warn`` stream unchanged.
|
|
2812
|
+
# ``last_empty_stream_buffer`` holds the buffered preamble of the
|
|
2813
|
+
# most recent empty stream so a fully-empty chain can flush it and
|
|
2814
|
+
# terminate normally rather than raising.
|
|
2815
|
+
empty_response_action = self._resolve_empty_response_action(request.profile)
|
|
2816
|
+
empty_fallback = empty_response_action == "fallback"
|
|
2817
|
+
last_empty_stream_buffer: list[AnthropicStreamEvent] | None = None
|
|
2679
2818
|
|
|
2680
2819
|
for adapter, will_degrade in chain:
|
|
2681
2820
|
is_native = isinstance(adapter, AnthropicAdapter)
|
|
@@ -2828,15 +2967,59 @@ class FallbackEngine:
|
|
|
2828
2967
|
self._observe_provider_success(
|
|
2829
2968
|
adapter.name, profile=request.profile
|
|
2830
2969
|
)
|
|
2831
|
-
|
|
2832
|
-
|
|
2970
|
+
# ⑧ (empty-response): under ``fallback`` we withhold the opening
|
|
2971
|
+
# events (message_start / empty content_block_start / ping) from
|
|
2972
|
+
# the client until the *first real content* event is observed.
|
|
2973
|
+
# Because no bytes have reached the client, an empty stream can
|
|
2974
|
+
# still be swapped for the next provider. ``off`` / ``warn`` keep
|
|
2975
|
+
# the legacy immediate-yield behavior (byte-for-byte unchanged).
|
|
2976
|
+
#
|
|
2977
|
+
# ``content_started`` flips True the moment real content is seen
|
|
2978
|
+
# (or immediately, when the action is not ``fallback``); from
|
|
2979
|
+
# then on events pass straight through and the mid-stream guard
|
|
2980
|
+
# is live exactly as before.
|
|
2981
|
+
content_started = not empty_fallback
|
|
2982
|
+
buffer: list[AnthropicStreamEvent] = []
|
|
2833
2983
|
# Mid-stream guard identical to stream() — any error after the
|
|
2834
|
-
# first event is terminal.
|
|
2984
|
+
# first *forwarded* event is terminal.
|
|
2835
2985
|
try:
|
|
2836
|
-
|
|
2986
|
+
# Seed the loop with the already-fetched ``first`` event,
|
|
2987
|
+
# then drain the rest of the iterator.
|
|
2988
|
+
pending = first
|
|
2989
|
+
iterator = event_iter.__aiter__()
|
|
2990
|
+
while True:
|
|
2991
|
+
ev = pending
|
|
2837
2992
|
acc.observe(ev)
|
|
2838
|
-
|
|
2993
|
+
if content_started:
|
|
2994
|
+
yield ev
|
|
2995
|
+
else:
|
|
2996
|
+
buffer.append(ev)
|
|
2997
|
+
if _stream_event_is_real_content(ev):
|
|
2998
|
+
# Real content arrived — flush the withheld
|
|
2999
|
+
# preamble in order, then switch to passthrough.
|
|
3000
|
+
content_started = True
|
|
3001
|
+
for buffered_ev in buffer:
|
|
3002
|
+
yield buffered_ev
|
|
3003
|
+
buffer = []
|
|
3004
|
+
try:
|
|
3005
|
+
pending = await iterator.__anext__()
|
|
3006
|
+
except StopAsyncIteration:
|
|
3007
|
+
break
|
|
2839
3008
|
except AdapterError as exc:
|
|
3009
|
+
if not content_started:
|
|
3010
|
+
# Empty-stream failure before any real content under
|
|
3011
|
+
# ``fallback``: nothing reached the client, so treat it
|
|
3012
|
+
# like an empty response and try the next provider. Do
|
|
3013
|
+
# NOT raise MidStreamError (that would surface to the
|
|
3014
|
+
# client). Record the error-only observation for adaptive.
|
|
3015
|
+
self._adaptive.record_attempt(
|
|
3016
|
+
adapter.name, latency_ms=None, success=False
|
|
3017
|
+
)
|
|
3018
|
+
log_empty_response_detected(
|
|
3019
|
+
logger, adapter.name, action="fallback", stream=True
|
|
3020
|
+
)
|
|
3021
|
+
last_empty_stream_buffer = buffer
|
|
3022
|
+
continue
|
|
2840
3023
|
# M2: mid-stream failure — record an error-only
|
|
2841
3024
|
# observation (no latency; first-event success already
|
|
2842
3025
|
# recorded) so adaptive error rate reflects the breakage.
|
|
@@ -2868,6 +3051,17 @@ class FallbackEngine:
|
|
|
2868
3051
|
raise MidStreamError(
|
|
2869
3052
|
adapter.name, exc, partial_content=acc.partial_content
|
|
2870
3053
|
) from exc
|
|
3054
|
+
# ⑧ (empty-response): the stream ended cleanly but no real
|
|
3055
|
+
# content was ever observed under ``fallback`` — the preamble is
|
|
3056
|
+
# still buffered (never sent). Treat as an empty response: keep
|
|
3057
|
+
# the buffer for a possible chain-exhausted flush and try the
|
|
3058
|
+
# next provider.
|
|
3059
|
+
if empty_fallback and not content_started:
|
|
3060
|
+
log_empty_response_detected(
|
|
3061
|
+
logger, adapter.name, action="fallback", stream=True
|
|
3062
|
+
)
|
|
3063
|
+
last_empty_stream_buffer = buffer
|
|
3064
|
+
continue
|
|
2871
3065
|
# v2.0-G (L4): drift detection observation (stream success).
|
|
2872
3066
|
# P1-4: compute response fingerprint for goal_progress_stall.
|
|
2873
3067
|
_stream_fp_text = " ".join(
|
|
@@ -2923,6 +3117,23 @@ class FallbackEngine:
|
|
|
2923
3117
|
)
|
|
2924
3118
|
return
|
|
2925
3119
|
|
|
3120
|
+
# ⑧ (empty-response): the chain is exhausted under ``fallback`` and
|
|
3121
|
+
# every provider produced an empty stream. Flush the last buffered
|
|
3122
|
+
# (empty) preamble so the client gets a well-formed, terminating SSE
|
|
3123
|
+
# sequence (message_start … message_stop) instead of an error — a
|
|
3124
|
+
# 200 blank is a legitimate answer. errors is empty in that case.
|
|
3125
|
+
if last_empty_stream_buffer is not None:
|
|
3126
|
+
log_empty_response_detected(
|
|
3127
|
+
logger,
|
|
3128
|
+
"unknown",
|
|
3129
|
+
action="fallback",
|
|
3130
|
+
stream=True,
|
|
3131
|
+
chain_exhausted=True,
|
|
3132
|
+
)
|
|
3133
|
+
for buffered_ev in last_empty_stream_buffer:
|
|
3134
|
+
yield buffered_ev
|
|
3135
|
+
return
|
|
3136
|
+
|
|
2926
3137
|
profile = request.profile or self.config.default_profile
|
|
2927
3138
|
_warn_if_uniform_auth_failure(errors, profile=profile)
|
|
2928
3139
|
raise NoProvidersAvailableError(profile=profile, errors=errors)
|
|
@@ -27,6 +27,30 @@ Recognised shapes
|
|
|
27
27
|
<echo message="probe"/> (attribute form)
|
|
28
28
|
<tool>{"name": "echo", "arguments": {...}}</tool> (wrapper form)
|
|
29
29
|
<echo>{"message": "probe"}</echo> (named-wrapper form)
|
|
30
|
+
5. Nested-XML name-attribute forms (R4a), where the tool name lives in a
|
|
31
|
+
``name`` attribute rather than the tag itself (L2 default-temp residual):
|
|
32
|
+
<tools><function name="echo" arguments='{...}'/></tools> (container form)
|
|
33
|
+
<function name="echo" arguments='{...}'/> (bare call-tag form)
|
|
34
|
+
<tool name="read_file" args='{...}'/> (args alias)
|
|
35
|
+
The container/call tags are a fixed known set; the ``name`` attribute must
|
|
36
|
+
be allow-listed and the ``arguments``/``args`` value is delegated to R1.
|
|
37
|
+
6. JSON envelope forms (R4b), where the model echoes a response wrapper:
|
|
38
|
+
{"tool_calls": [{"name": "echo", "arguments": {...}}, ...]} (OpenAI list)
|
|
39
|
+
{"function_call": {"name": ..., "arguments": "<JSON string>"}} (legacy)
|
|
40
|
+
The envelope is unwrapped and each inner object is run through the same
|
|
41
|
+
shape + allow-list validation; the legacy ``arguments`` string is
|
|
42
|
+
double-parsed. Works both fenced and bare.
|
|
43
|
+
7. Call-syntax forms (R4c), the "name + parens + args" family, recognised
|
|
44
|
+
ONLY inside a fenced block or on its own standalone line (never inline in
|
|
45
|
+
prose):
|
|
46
|
+
print(default_api.echo(message="probe")) (Gemma tool_code idiom)
|
|
47
|
+
echo(message="probe") (python kwargs)
|
|
48
|
+
echo(message: 'demo') (colon-separated kwargs)
|
|
49
|
+
write_note({"path": "a", "text": "b"}) (single JSON-object arg)
|
|
50
|
+
Guards: the function name must be allow-listed, the argument list must
|
|
51
|
+
parse completely (a broken inner JSON is left alone rather than executed
|
|
52
|
+
with corrupt args), and an explanatory example cue preceding the fence
|
|
53
|
+
suppresses extraction.
|
|
30
54
|
|
|
31
55
|
Each candidate is accepted only if it parses to one of:
|
|
32
56
|
{"name": <str>, "arguments": <dict | str>} # direct shape
|
|
@@ -64,6 +88,7 @@ name that is not in the allow-list is still never repaired.
|
|
|
64
88
|
|
|
65
89
|
from __future__ import annotations
|
|
66
90
|
|
|
91
|
+
import contextlib
|
|
67
92
|
import json
|
|
68
93
|
import re
|
|
69
94
|
import uuid
|
|
@@ -137,6 +162,71 @@ def _looks_like_tool_call(obj: Any, allowed: set[str] | None) -> tuple[str, Any]
|
|
|
137
162
|
return name, args
|
|
138
163
|
|
|
139
164
|
|
|
165
|
+
# R4b: JSON envelope keys whose value carries the actual tool call(s).
|
|
166
|
+
# {"tool_calls": [ {call}, {call}, ... ]} -> a list of calls
|
|
167
|
+
# {"function_call": {call}} -> a single call
|
|
168
|
+
# These are the OpenAI response-envelope shapes a model sometimes echoes back
|
|
169
|
+
# into the text body verbatim. The envelope wrapper is unwrapped and each inner
|
|
170
|
+
# object is validated by the same _looks_like_tool_call predicate.
|
|
171
|
+
_ENVELOPE_LIST_KEY = "tool_calls"
|
|
172
|
+
_ENVELOPE_SINGLE_KEY = "function_call"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _expand_tool_call_envelope(
|
|
176
|
+
obj: Any, allowed: set[str] | None
|
|
177
|
+
) -> list[tuple[str, Any]] | None:
|
|
178
|
+
"""Unwrap an R4b JSON envelope into a list of (name, arguments) tuples.
|
|
179
|
+
|
|
180
|
+
Recognises exactly two response-envelope wrappers:
|
|
181
|
+
- ``{"tool_calls": [ ... ]}`` (OpenAI tool_calls list)
|
|
182
|
+
- ``{"function_call": { ... }}`` (OpenAI legacy single call)
|
|
183
|
+
|
|
184
|
+
The dict must carry the envelope key and nothing else that would make it
|
|
185
|
+
ambiguous with a plain tool-call object (i.e. it must NOT itself already
|
|
186
|
+
look like a direct call). Every inner element must resolve to an
|
|
187
|
+
allow-listed call, otherwise the whole envelope is declined (all-or-nothing
|
|
188
|
+
keeps false positives at zero — a half-recognised wrapper is suspicious).
|
|
189
|
+
|
|
190
|
+
Returns the list of calls, or None if ``obj`` is not an envelope. An empty
|
|
191
|
+
envelope (no valid inner calls) also returns None.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(obj, dict):
|
|
194
|
+
return None
|
|
195
|
+
# If the object is itself a direct tool call, it is not an envelope; let the
|
|
196
|
+
# ordinary single-call path handle it (avoids double extraction).
|
|
197
|
+
if _looks_like_tool_call(obj, allowed) is not None:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
if _ENVELOPE_LIST_KEY in obj:
|
|
201
|
+
raw = obj[_ENVELOPE_LIST_KEY]
|
|
202
|
+
if not isinstance(raw, list) or not raw:
|
|
203
|
+
return None
|
|
204
|
+
calls: list[tuple[str, Any]] = []
|
|
205
|
+
for item in raw:
|
|
206
|
+
hit = _looks_like_tool_call(item, allowed)
|
|
207
|
+
if hit is None:
|
|
208
|
+
return None # all-or-nothing
|
|
209
|
+
calls.append(hit)
|
|
210
|
+
return calls or None
|
|
211
|
+
|
|
212
|
+
if _ENVELOPE_SINGLE_KEY in obj:
|
|
213
|
+
inner = obj[_ENVELOPE_SINGLE_KEY]
|
|
214
|
+
hit = _looks_like_tool_call(inner, allowed)
|
|
215
|
+
if hit is None:
|
|
216
|
+
return None
|
|
217
|
+
name, args = hit
|
|
218
|
+
# Legacy shape carries arguments as a JSON *string*; double-parse it so
|
|
219
|
+
# the normaliser emits a proper arguments object where possible. If the
|
|
220
|
+
# inner string is not valid JSON, keep it verbatim (bug-for-bug parity
|
|
221
|
+
# with bare_json_04's string-arguments behaviour).
|
|
222
|
+
if isinstance(args, str):
|
|
223
|
+
with contextlib.suppress(ValueError):
|
|
224
|
+
args = _parse_json_object(args)
|
|
225
|
+
return [(name, args)]
|
|
226
|
+
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
140
230
|
def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
|
|
141
231
|
"""Build an OpenAI-shape tool_calls entry."""
|
|
142
232
|
if isinstance(arguments, str):
|
|
@@ -373,12 +463,30 @@ def _extract_tool_call_fenced_blocks(
|
|
|
373
463
|
body = match.group(2).strip()
|
|
374
464
|
if lang in _CODE_FENCE_LANGS:
|
|
375
465
|
return match.group(0) # protected source-code fence
|
|
466
|
+
# R4c: call-syntax family (name(...)) inside a non-code fence. Only
|
|
467
|
+
# attempted when the body is a single call line and its name is
|
|
468
|
+
# allow-listed with fully-parseable arguments; an example cue on the
|
|
469
|
+
# line(s) preceding the fence suppresses it.
|
|
470
|
+
if not body.startswith("{") and allowed is not None:
|
|
471
|
+
if not _preceded_by_prose_cue_before_fence(text, match.start()):
|
|
472
|
+
call_hits = _extract_call_syntax_lines(body, allowed)
|
|
473
|
+
if call_hits:
|
|
474
|
+
for name, args in call_hits:
|
|
475
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
476
|
+
return ""
|
|
477
|
+
return match.group(0) # keep other non-JSON fenced blocks (code)
|
|
376
478
|
if not body.startswith("{"):
|
|
377
479
|
return match.group(0) # keep non-JSON fenced blocks (e.g. code)
|
|
378
480
|
try:
|
|
379
481
|
obj = _parse_json_object(body)
|
|
380
482
|
except ValueError:
|
|
381
483
|
return match.group(0) # keep unparseable fenced blocks
|
|
484
|
+
# R4b: response-envelope wrappers ({"tool_calls": [...]}, ...).
|
|
485
|
+
envelope = _expand_tool_call_envelope(obj, allowed)
|
|
486
|
+
if envelope is not None:
|
|
487
|
+
for name, args in envelope:
|
|
488
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
489
|
+
return ""
|
|
382
490
|
hit = _looks_like_tool_call(obj, allowed)
|
|
383
491
|
if hit is None:
|
|
384
492
|
return match.group(0) # keep non-tool-call JSON blocks
|
|
@@ -405,6 +513,17 @@ def _protected_code_spans(text: str) -> list[tuple[int, int]]:
|
|
|
405
513
|
return spans
|
|
406
514
|
|
|
407
515
|
|
|
516
|
+
def _all_fence_spans(text: str) -> list[tuple[int, int]]:
|
|
517
|
+
"""Byte spans of *every* fenced block (any language tag or none).
|
|
518
|
+
|
|
519
|
+
Used by the standalone-line R4c scanner: a call line that survives inside a
|
|
520
|
+
fence was already offered to the fenced R4c path (and, if suppressed by an
|
|
521
|
+
example cue, must stay suppressed), so the standalone scanner must never
|
|
522
|
+
reach inside any fence.
|
|
523
|
+
"""
|
|
524
|
+
return [(m.start(), m.end()) for m in _FENCED_RE.finditer(text)]
|
|
525
|
+
|
|
526
|
+
|
|
408
527
|
def _in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
|
|
409
528
|
return any(start <= pos < end for start, end in spans)
|
|
410
529
|
|
|
@@ -514,27 +633,389 @@ def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
|
|
|
514
633
|
# If one of these phrases appears immediately before a bare JSON object (within
|
|
515
634
|
# a short window, on the same clause), the object is being *described* rather
|
|
516
635
|
# than emitted as a call. Conservative: only suppresses, never forces a repair.
|
|
636
|
+
#
|
|
637
|
+
# The vocabulary is deliberately broad — documentation / example / "here is the
|
|
638
|
+
# format" framings all mark the following block as *illustrative*. It stays
|
|
639
|
+
# clear of tokens that appear in genuine descriptive prose which nonetheless
|
|
640
|
+
# precedes a real call (e.g. "The `echo` function echoes back the provided
|
|
641
|
+
# message." for python_call_04), so words like ``function`` / ``message`` are
|
|
642
|
+
# NOT cues.
|
|
517
643
|
_PROSE_CUE_RE = re.compile(
|
|
518
644
|
r"(?:"
|
|
519
645
|
r"for\s+example|for\s+instance|e\.?g\.?|such\s+as|"
|
|
520
646
|
r"you\s+(?:would|could|can|might)\s+write|"
|
|
521
647
|
r"you\s+would\s+(?:use|call)|"
|
|
522
|
-
r"(?:is|are)\s+documented(?:\s+as)?|documented
|
|
523
|
-
r"looks?\s+like
|
|
524
|
-
r"
|
|
648
|
+
r"(?:is|are)\s+documented(?:\s+as)?|documented(?:\s+like)?|"
|
|
649
|
+
r"looks?\s+like|written\s+as|"
|
|
650
|
+
r"as\s+follows|"
|
|
651
|
+
r"(?:calling\s+)?convention|"
|
|
652
|
+
r"below\s+is|"
|
|
653
|
+
r"(?:this|the|that)\s+format|the\s+format\s+is|"
|
|
654
|
+
r"sample|payload|"
|
|
655
|
+
r"syntax|signature|"
|
|
656
|
+
r"look(?:s|ing)?\s+in|"
|
|
657
|
+
r"例えば|たとえば|のように書"
|
|
525
658
|
r")"
|
|
526
|
-
r"[^\n{]{0,
|
|
659
|
+
r"[^\n{]{0,80}$",
|
|
527
660
|
re.IGNORECASE,
|
|
528
661
|
)
|
|
529
662
|
|
|
530
663
|
|
|
531
664
|
def _preceded_by_prose_cue(text: str, start: int) -> bool:
|
|
532
|
-
"""True if a documentation/example cue
|
|
665
|
+
"""True if a documentation/example cue precedes position ``start``.
|
|
666
|
+
|
|
667
|
+
Two windows are checked so a cue on the *previous* line still guards a call
|
|
668
|
+
that a model placed on its own line under an introductory sentence
|
|
669
|
+
(``"Here's a sample payload:\n{...}"`` — negative_19 / negative_22):
|
|
670
|
+
|
|
671
|
+
1. the current same-clause lead-in — the object's own line, explicitly
|
|
672
|
+
sliced from the last newline, which catches inline "..., e.g. {...}"
|
|
673
|
+
framings; and
|
|
674
|
+
2. the immediately preceding non-blank line, matched whole, which catches
|
|
675
|
+
"Here's how it looks in this format:" one line above the object.
|
|
676
|
+
|
|
677
|
+
Design note (colon-terminated lead-ins): a legitimate call announcement such
|
|
678
|
+
as ``"I'll call it now:"`` ends in a cue-adjacent colon and, when it carries
|
|
679
|
+
a cue word (e.g. "sample", "payload", "format"), is deliberately suppressed
|
|
680
|
+
together with the illustrative ones. Under the FP-0%-first principle this
|
|
681
|
+
trade-off is accepted on purpose: losing the occasional genuine "here it
|
|
682
|
+
comes" preamble is preferable to executing a described example as a real
|
|
683
|
+
call. (The bare cue guard here still keys off the *vocabulary*, so a
|
|
684
|
+
cue-free colon lead-in like "Here's the tool call:" stays repairable; the
|
|
685
|
+
unconditional colon rule lives only on the call-syntax fence path.)
|
|
686
|
+
|
|
687
|
+
Conservative: only ever suppresses, never forces a repair.
|
|
688
|
+
"""
|
|
533
689
|
prefix = text[:start]
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
|
|
537
|
-
|
|
690
|
+
# (1) same-clause lead-in. Slice the object's *own* line explicitly from the
|
|
691
|
+
# last newline so the cue anchor is measured against the current line's
|
|
692
|
+
# real end, not the whole-string end. Anchoring on ``prefix[-200:]``
|
|
693
|
+
# alone breaks when the object sits at a line start (prefix ends "\n\n"):
|
|
694
|
+
# the ``[^\n{]`` tail then collapses to zero width and the anchor can no
|
|
695
|
+
# longer reach a cue that lives before the newline. Isolating the current
|
|
696
|
+
# line keeps window (1) strictly same-line, leaving cross-line cues to
|
|
697
|
+
# window (2).
|
|
698
|
+
nl = prefix.rfind("\n")
|
|
699
|
+
current_line = prefix[nl + 1 :] if nl != -1 else prefix
|
|
700
|
+
if _PROSE_CUE_RE.search(current_line[-200:]):
|
|
701
|
+
return True
|
|
702
|
+
# (2) the nearest non-blank line above the object.
|
|
703
|
+
lines = prefix.splitlines()
|
|
704
|
+
while lines and not lines[-1].strip():
|
|
705
|
+
lines.pop()
|
|
706
|
+
# Drop the current (partial) line the object sits on — but ONLY when it
|
|
707
|
+
# actually exists. When ``prefix`` ends in a newline the object is at a line
|
|
708
|
+
# start, so ``splitlines()`` already excludes any current-line residue and
|
|
709
|
+
# the last surviving entry *is* the lead-in line; popping it unconditionally
|
|
710
|
+
# would discard the cue line itself (negative_19 / negative_23 off-by-one).
|
|
711
|
+
if lines and not prefix.endswith("\n"):
|
|
712
|
+
lines.pop()
|
|
713
|
+
while lines and not lines[-1].strip():
|
|
714
|
+
lines.pop()
|
|
715
|
+
if lines:
|
|
716
|
+
lead = lines[-1].strip()
|
|
717
|
+
if _PROSE_CUE_RE.search(lead[-200:]):
|
|
718
|
+
return True
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _preceded_by_prose_cue_before_fence(text: str, fence_start: int) -> bool:
|
|
723
|
+
"""True if the last non-blank line before a fence is an example cue.
|
|
724
|
+
|
|
725
|
+
The bare-JSON cue guard (:func:`_preceded_by_prose_cue`) only looks at the
|
|
726
|
+
immediate same-clause lead-in, which does not span the blank line(s) that
|
|
727
|
+
separate an introductory sentence from a following ```...``` fence. R4c
|
|
728
|
+
call-syntax fences are commonly introduced on a *separate* line
|
|
729
|
+
("For example, you would write:\\n\\n```..."), so this looks back across
|
|
730
|
+
blank lines to the nearest non-empty line and applies the same cue regex.
|
|
731
|
+
|
|
732
|
+
This is the discriminator between negative_15 (example-cue + fenced call ->
|
|
733
|
+
suppress) and python_call_04 (descriptive prose + fenced call -> repair).
|
|
734
|
+
Conservative: only ever suppresses, never forces a repair.
|
|
735
|
+
"""
|
|
736
|
+
prefix = text[:fence_start]
|
|
737
|
+
lines = prefix.splitlines()
|
|
738
|
+
# Drop the (possibly empty) trailing fragment and any blank separator lines
|
|
739
|
+
# so we land on the last line carrying actual prose.
|
|
740
|
+
while lines and not lines[-1].strip():
|
|
741
|
+
lines.pop()
|
|
742
|
+
if not lines:
|
|
743
|
+
return False
|
|
744
|
+
lead = lines[-1].strip()
|
|
745
|
+
# A lead-in line ending in a colon ("... as follows:", "... this format:",
|
|
746
|
+
# a long "For example, ...:") is an introduction to an illustrative block,
|
|
747
|
+
# so a call-syntax fence that follows it is being *shown*, not invoked. This
|
|
748
|
+
# is a general signal that does not depend on the cue vocabulary and so is
|
|
749
|
+
# robust to paraphrase and to arbitrarily long lead-in lines (negative_18 /
|
|
750
|
+
# 20 / 21). It is applied ONLY on this call-syntax fence path — bare JSON /
|
|
751
|
+
# JSON-envelope fences are introduced by legitimate colon lead-ins too
|
|
752
|
+
# ("Here's the tool call:", "First:") and must stay repairable.
|
|
753
|
+
if lead.endswith((":", ":")): # noqa: RUF001 — full-width colon (U+FF1A) is intentional
|
|
754
|
+
return True
|
|
755
|
+
# Otherwise fall back to the cue vocabulary anywhere in the lead-in line.
|
|
756
|
+
return bool(_PROSE_CUE_RE.search(lead[-200:]))
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ------------------------------------------------------------------
|
|
760
|
+
# R4c: call-syntax family (name + parens + args)
|
|
761
|
+
# ------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
# Head of a call line: an optional ``print(`` wrapper, an optional
|
|
764
|
+
# ``default_api.`` prefix (Gemma tool_code idiom), then the tool name and its
|
|
765
|
+
# opening paren. Anchored at the start of a (stripped) line so an inline
|
|
766
|
+
# mid-prose call is never a candidate.
|
|
767
|
+
_CALL_HEAD_RE = re.compile(
|
|
768
|
+
r"^(?P<print>print\s*\(\s*)?"
|
|
769
|
+
r"(?:default_api\s*\.\s*)?"
|
|
770
|
+
r"(?P<name>[A-Za-z_]\w*)\s*\("
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# A single ``key = value`` / ``key : value`` kwargs pair.
|
|
774
|
+
_CALL_KWARG_RE = re.compile(r"^(?P<key>[A-Za-z_]\w*)\s*(?:=|:)\s*(?P<val>.*)$", re.DOTALL)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _find_matching_paren(s: str, open_idx: str | int) -> int:
|
|
778
|
+
"""Index of the ``)`` matching the ``(`` at ``open_idx``, or -1.
|
|
779
|
+
|
|
780
|
+
Balances parentheses while respecting single/double quoted strings (so a
|
|
781
|
+
paren inside a string literal does not close the call).
|
|
782
|
+
"""
|
|
783
|
+
depth = 0
|
|
784
|
+
i = int(open_idx)
|
|
785
|
+
n = len(s)
|
|
786
|
+
quote: str | None = None
|
|
787
|
+
escape = False
|
|
788
|
+
while i < n:
|
|
789
|
+
c = s[i]
|
|
790
|
+
if escape:
|
|
791
|
+
escape = False
|
|
792
|
+
elif quote is not None:
|
|
793
|
+
if c == "\\":
|
|
794
|
+
escape = True
|
|
795
|
+
elif c == quote:
|
|
796
|
+
quote = None
|
|
797
|
+
else:
|
|
798
|
+
if c == '"' or c == "'":
|
|
799
|
+
quote = c
|
|
800
|
+
elif c == "(":
|
|
801
|
+
depth += 1
|
|
802
|
+
elif c == ")":
|
|
803
|
+
depth -= 1
|
|
804
|
+
if depth == 0:
|
|
805
|
+
return i
|
|
806
|
+
i += 1
|
|
807
|
+
return -1
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _split_top_level_commas(body: str) -> list[str] | None:
|
|
811
|
+
"""Split ``body`` on top-level commas, respecting quotes and brackets.
|
|
812
|
+
|
|
813
|
+
Returns the list of segments, or None if the string/bracket nesting is
|
|
814
|
+
unbalanced (which means the arguments are corrupt and must not be repaired).
|
|
815
|
+
"""
|
|
816
|
+
parts: list[str] = []
|
|
817
|
+
cur: list[str] = []
|
|
818
|
+
depth = 0
|
|
819
|
+
quote: str | None = None
|
|
820
|
+
escape = False
|
|
821
|
+
for c in body:
|
|
822
|
+
if escape:
|
|
823
|
+
cur.append(c)
|
|
824
|
+
escape = False
|
|
825
|
+
continue
|
|
826
|
+
if quote is not None:
|
|
827
|
+
cur.append(c)
|
|
828
|
+
if c == "\\":
|
|
829
|
+
escape = True
|
|
830
|
+
elif c == quote:
|
|
831
|
+
quote = None
|
|
832
|
+
continue
|
|
833
|
+
if c == '"' or c == "'":
|
|
834
|
+
quote = c
|
|
835
|
+
cur.append(c)
|
|
836
|
+
continue
|
|
837
|
+
if c in "([{":
|
|
838
|
+
depth += 1
|
|
839
|
+
cur.append(c)
|
|
840
|
+
continue
|
|
841
|
+
if c in ")]}":
|
|
842
|
+
depth -= 1
|
|
843
|
+
cur.append(c)
|
|
844
|
+
continue
|
|
845
|
+
if c == "," and depth == 0:
|
|
846
|
+
parts.append("".join(cur))
|
|
847
|
+
cur = []
|
|
848
|
+
continue
|
|
849
|
+
cur.append(c)
|
|
850
|
+
if quote is not None or depth != 0:
|
|
851
|
+
return None
|
|
852
|
+
tail = "".join(cur).strip()
|
|
853
|
+
if tail:
|
|
854
|
+
parts.append(tail)
|
|
855
|
+
return parts
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _parse_call_value(raw: str) -> tuple[bool, Any]:
|
|
859
|
+
"""Parse a single kwargs value. Returns (ok, value).
|
|
860
|
+
|
|
861
|
+
Tries strict JSON, then the lenient JSON pipe (which normalises single
|
|
862
|
+
quotes, unquoted words, etc.). ``ok`` is False when the value cannot be
|
|
863
|
+
parsed at all, so a corrupt argument fails the whole call (guard b).
|
|
864
|
+
"""
|
|
865
|
+
v = raw.strip()
|
|
866
|
+
if not v:
|
|
867
|
+
return False, None
|
|
868
|
+
try:
|
|
869
|
+
return True, json.loads(v)
|
|
870
|
+
except json.JSONDecodeError:
|
|
871
|
+
pass
|
|
872
|
+
try:
|
|
873
|
+
return True, _lenient_json_loads(v)
|
|
874
|
+
except ValueError:
|
|
875
|
+
return False, None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _parse_call_args(body: str, name: str) -> tuple[str, Any] | None:
|
|
879
|
+
"""Parse a call's argument list into (name, arguments) or None.
|
|
880
|
+
|
|
881
|
+
Handles all three R4c argument styles with a single splitter:
|
|
882
|
+
- ``key="value"`` / ``key=value`` (Python kwargs)
|
|
883
|
+
- ``key: 'value'`` (colon-separated, Ruby/Swift flavour)
|
|
884
|
+
- ``({...})`` a single JSON-object positional argument (JS flavour)
|
|
885
|
+
|
|
886
|
+
Every argument must parse completely; a broken inner value causes the whole
|
|
887
|
+
call to be declined (guard b — a form-only fix with corrupt args is more
|
|
888
|
+
harmful than no repair).
|
|
889
|
+
"""
|
|
890
|
+
body = body.strip()
|
|
891
|
+
if not body:
|
|
892
|
+
# Zero-arg call: valid, empty arguments object.
|
|
893
|
+
return name, {}
|
|
894
|
+
|
|
895
|
+
# Single JSON-object positional argument: write_note({...}).
|
|
896
|
+
if body.startswith("{"):
|
|
897
|
+
try:
|
|
898
|
+
obj = _parse_json_object(body)
|
|
899
|
+
except ValueError:
|
|
900
|
+
return None
|
|
901
|
+
if not isinstance(obj, dict):
|
|
902
|
+
return None
|
|
903
|
+
return name, obj
|
|
904
|
+
|
|
905
|
+
segments = _split_top_level_commas(body)
|
|
906
|
+
if not segments:
|
|
907
|
+
return None
|
|
908
|
+
out: dict[str, Any] = {}
|
|
909
|
+
for seg in segments:
|
|
910
|
+
m = _CALL_KWARG_RE.match(seg.strip())
|
|
911
|
+
if not m:
|
|
912
|
+
return None # positional args / unparseable -> decline
|
|
913
|
+
ok, val = _parse_call_value(m.group("val"))
|
|
914
|
+
if not ok:
|
|
915
|
+
return None
|
|
916
|
+
out[m.group("key")] = val
|
|
917
|
+
return name, out
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _extract_one_call_line(line: str, allowed: set[str]) -> tuple[str, Any] | None:
|
|
921
|
+
"""Extract a single call-syntax invocation from one standalone line.
|
|
922
|
+
|
|
923
|
+
The line must be *entirely* a call (optionally wrapped in ``print(...)``);
|
|
924
|
+
trailing garbage after the closing paren disqualifies it, so an inline
|
|
925
|
+
call embedded in a sentence is never matched. The name must be allow-listed
|
|
926
|
+
and the arguments must parse fully.
|
|
927
|
+
"""
|
|
928
|
+
stripped = line.strip()
|
|
929
|
+
m = _CALL_HEAD_RE.match(stripped)
|
|
930
|
+
if not m:
|
|
931
|
+
return None
|
|
932
|
+
name = m.group("name")
|
|
933
|
+
if name not in allowed:
|
|
934
|
+
return None
|
|
935
|
+
open_idx = m.end() - 1
|
|
936
|
+
close_idx = _find_matching_paren(stripped, open_idx)
|
|
937
|
+
if close_idx < 0:
|
|
938
|
+
return None
|
|
939
|
+
inner = stripped[open_idx + 1 : close_idx]
|
|
940
|
+
rest = stripped[close_idx + 1 :].strip()
|
|
941
|
+
if m.group("print"):
|
|
942
|
+
# A print(...) wrapper must be closed by exactly one trailing ')'.
|
|
943
|
+
if not rest.startswith(")"):
|
|
944
|
+
return None
|
|
945
|
+
rest = rest[1:].strip()
|
|
946
|
+
if rest:
|
|
947
|
+
return None # trailing garbage -> not a clean standalone call
|
|
948
|
+
return _parse_call_args(inner, name)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _extract_call_syntax_lines(
|
|
952
|
+
block: str, allowed: set[str]
|
|
953
|
+
) -> list[tuple[str, Any]]:
|
|
954
|
+
"""Extract call-syntax invocations from the non-blank lines of ``block``.
|
|
955
|
+
|
|
956
|
+
Used for fence interiors (one or more call lines). Every non-blank line
|
|
957
|
+
must resolve to an allow-listed call with fully-parseable arguments; if any
|
|
958
|
+
line fails, the whole block is declined (all-or-nothing keeps a mixed
|
|
959
|
+
code/call fence from being partially executed).
|
|
960
|
+
"""
|
|
961
|
+
lines = [ln for ln in block.splitlines() if ln.strip()]
|
|
962
|
+
if not lines:
|
|
963
|
+
return []
|
|
964
|
+
hits: list[tuple[str, Any]] = []
|
|
965
|
+
for ln in lines:
|
|
966
|
+
hit = _extract_one_call_line(ln, allowed)
|
|
967
|
+
if hit is None:
|
|
968
|
+
return []
|
|
969
|
+
hits.append(hit)
|
|
970
|
+
return hits
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _extract_r4c_standalone_lines(
|
|
974
|
+
text: str,
|
|
975
|
+
allowed: set[str],
|
|
976
|
+
protected: list[tuple[int, int]],
|
|
977
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
978
|
+
"""Extract R4c call-syntax invocations that stand alone on their own line.
|
|
979
|
+
|
|
980
|
+
Complements the fenced R4c path: a call like ``write_note({...})`` may
|
|
981
|
+
appear bare (no fence) as the whole response or on its own line. Each
|
|
982
|
+
candidate line must be *entirely* a call (``_extract_one_call_line``
|
|
983
|
+
enforces the no-trailing-garbage rule), so an inline call embedded in a
|
|
984
|
+
sentence is never a candidate. Lines inside a protected code fence, or
|
|
985
|
+
introduced by an example cue, are skipped.
|
|
986
|
+
|
|
987
|
+
Returns (text_with_call_lines_removed, tool_calls).
|
|
988
|
+
"""
|
|
989
|
+
if "(" not in text:
|
|
990
|
+
return text, []
|
|
991
|
+
tool_calls: list[dict[str, Any]] = []
|
|
992
|
+
out_lines: list[str] = []
|
|
993
|
+
pos = 0
|
|
994
|
+
changed = False
|
|
995
|
+
for line in text.splitlines(keepends=True):
|
|
996
|
+
line_start = pos
|
|
997
|
+
pos += len(line)
|
|
998
|
+
stripped = line.strip()
|
|
999
|
+
if not stripped:
|
|
1000
|
+
out_lines.append(line)
|
|
1001
|
+
continue
|
|
1002
|
+
if _in_spans(line_start, protected):
|
|
1003
|
+
out_lines.append(line)
|
|
1004
|
+
continue
|
|
1005
|
+
if _preceded_by_prose_cue(text, line_start):
|
|
1006
|
+
out_lines.append(line)
|
|
1007
|
+
continue
|
|
1008
|
+
hit = _extract_one_call_line(stripped, allowed)
|
|
1009
|
+
if hit is None:
|
|
1010
|
+
out_lines.append(line)
|
|
1011
|
+
continue
|
|
1012
|
+
name, args = hit
|
|
1013
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
1014
|
+
changed = True
|
|
1015
|
+
# Drop the call line entirely (keep a trailing newline structure sane).
|
|
1016
|
+
if not changed:
|
|
1017
|
+
return text, []
|
|
1018
|
+
return "".join(out_lines), tool_calls
|
|
538
1019
|
|
|
539
1020
|
|
|
540
1021
|
# ------------------------------------------------------------------
|
|
@@ -606,6 +1087,121 @@ _XML_WRAPPER_RE = re.compile(
|
|
|
606
1087
|
)
|
|
607
1088
|
_XML_ATTR_RE = re.compile(r"([\w.:-]+)\s*=\s*\"([^\"]*)\"")
|
|
608
1089
|
|
|
1090
|
+
# R4a: nested-XML name-attribute forms.
|
|
1091
|
+
# Known call/container tags whose ``name`` attribute (not the tag itself)
|
|
1092
|
+
# carries the tool name. Restricting to this fixed set — rather than treating
|
|
1093
|
+
# any tag with a ``name`` attribute as a call — keeps arbitrary markup (e.g.
|
|
1094
|
+
# <input name="q"/>) from being mistaken for a tool call.
|
|
1095
|
+
_R4A_CALL_TAGS = frozenset({"function", "tool", "function_call", "invoke", "tool_call"})
|
|
1096
|
+
# Container tags that merely wrap one or more call tags; scanned through, never
|
|
1097
|
+
# themselves a call.
|
|
1098
|
+
_R4A_CONTAINER_TAGS = frozenset({"tools", "tool_calls", "function_calls", "invoke"})
|
|
1099
|
+
# A self-closing call tag: <function name="..." arguments='...'/>. Attribute
|
|
1100
|
+
# values may be single- OR double-quoted; the ``arguments`` value is captured
|
|
1101
|
+
# raw and delegated to the JSON pipe (R1/R2). ``\1`` on the quote char keeps the
|
|
1102
|
+
# value greedy up to the matching closing quote.
|
|
1103
|
+
_R4A_ATTR_RE = re.compile(
|
|
1104
|
+
r"""([\w.:-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')""",
|
|
1105
|
+
)
|
|
1106
|
+
_R4A_SELFCLOSE_RE = re.compile(
|
|
1107
|
+
r"""<([A-Za-z_][\w.-]*)((?:\s+[\w.:-]+\s*=\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))+)\s*/>""",
|
|
1108
|
+
)
|
|
1109
|
+
# R4a attribute keys that hold the tool name / the arguments payload.
|
|
1110
|
+
_R4A_NAME_ATTRS = ("name",)
|
|
1111
|
+
_R4A_ARG_ATTRS = ("arguments", "args", "parameters", "input")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _r4a_parse_attrs(attr_blob: str) -> dict[str, str]:
|
|
1115
|
+
"""Parse an XML attribute blob into {key: value}, unescaping quoted values.
|
|
1116
|
+
|
|
1117
|
+
A double-quoted value may carry backslash-escaped inner quotes
|
|
1118
|
+
(``arguments="{\\"command\\": ...}"``); those are unescaped so the value is
|
|
1119
|
+
valid JSON before it reaches the parse pipe. Single-quoted values are taken
|
|
1120
|
+
verbatim (their inner double quotes are already literal JSON).
|
|
1121
|
+
"""
|
|
1122
|
+
out: dict[str, str] = {}
|
|
1123
|
+
for m in _R4A_ATTR_RE.finditer(attr_blob):
|
|
1124
|
+
key = m.group(1)
|
|
1125
|
+
if m.group(2) is not None: # double-quoted
|
|
1126
|
+
val = m.group(2).replace('\\"', '"').replace("\\\\", "\\")
|
|
1127
|
+
else: # single-quoted
|
|
1128
|
+
val = m.group(3) or ""
|
|
1129
|
+
out[key] = val
|
|
1130
|
+
return out
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _r4a_build_call(
|
|
1134
|
+
attrs: dict[str, str], allowed: set[str]
|
|
1135
|
+
) -> tuple[str, Any] | None:
|
|
1136
|
+
"""Turn parsed R4a attributes into (name, arguments) if valid, else None."""
|
|
1137
|
+
name = None
|
|
1138
|
+
for k in _R4A_NAME_ATTRS:
|
|
1139
|
+
if k in attrs:
|
|
1140
|
+
name = attrs[k]
|
|
1141
|
+
break
|
|
1142
|
+
if not name or name not in allowed:
|
|
1143
|
+
return None
|
|
1144
|
+
args: Any = {}
|
|
1145
|
+
for k in _R4A_ARG_ATTRS:
|
|
1146
|
+
if k in attrs:
|
|
1147
|
+
raw = attrs[k].strip()
|
|
1148
|
+
if not raw:
|
|
1149
|
+
args = {}
|
|
1150
|
+
break
|
|
1151
|
+
try:
|
|
1152
|
+
args = _parse_json_object(raw)
|
|
1153
|
+
except ValueError:
|
|
1154
|
+
return None # malformed arguments -> decline (safe side)
|
|
1155
|
+
break
|
|
1156
|
+
return name, args
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _extract_r4a_nested_xml(
|
|
1160
|
+
text: str,
|
|
1161
|
+
allowed: set[str] | None,
|
|
1162
|
+
guard: list[tuple[int, int]],
|
|
1163
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
1164
|
+
"""Extract R4a nested-XML name-attribute tool calls.
|
|
1165
|
+
|
|
1166
|
+
Handles both the bare call tag (``<function name=.../>``) and the same tag
|
|
1167
|
+
inside a container (``<tools>...</tools>``). Containers are transparent —
|
|
1168
|
+
the scanner simply finds every allow-listed call tag, wherever it sits, and
|
|
1169
|
+
removes it; a lone empty ``<tools></tools>`` shell left behind is trimmed by
|
|
1170
|
+
residue cleanup. Reasoning-block / example-cue guards are honoured.
|
|
1171
|
+
|
|
1172
|
+
Returns (text_with_calls_removed, tool_calls).
|
|
1173
|
+
"""
|
|
1174
|
+
if allowed is None or "<" not in text:
|
|
1175
|
+
return text, []
|
|
1176
|
+
tool_calls: list[dict[str, Any]] = []
|
|
1177
|
+
removals: list[tuple[int, int]] = []
|
|
1178
|
+
for m in _R4A_SELFCLOSE_RE.finditer(text):
|
|
1179
|
+
tag = m.group(1).lower()
|
|
1180
|
+
if tag not in _R4A_CALL_TAGS:
|
|
1181
|
+
continue
|
|
1182
|
+
if _in_spans(m.start(), guard) or _preceded_by_prose_cue(text, m.start()):
|
|
1183
|
+
continue
|
|
1184
|
+
attrs = _r4a_parse_attrs(m.group(2) or "")
|
|
1185
|
+
hit = _r4a_build_call(attrs, allowed)
|
|
1186
|
+
if hit is None:
|
|
1187
|
+
continue
|
|
1188
|
+
name, args = hit
|
|
1189
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
1190
|
+
removals.append((m.start(), m.end()))
|
|
1191
|
+
|
|
1192
|
+
if not removals:
|
|
1193
|
+
return text, []
|
|
1194
|
+
# Also sweep up now-empty container shells around removed calls.
|
|
1195
|
+
cleaned = text
|
|
1196
|
+
removals.sort()
|
|
1197
|
+
for s, e in reversed(removals):
|
|
1198
|
+
cleaned = cleaned[:s] + cleaned[e:]
|
|
1199
|
+
for ctag in _R4A_CONTAINER_TAGS:
|
|
1200
|
+
cleaned = re.sub(
|
|
1201
|
+
rf"<{ctag}\s*>\s*</{ctag}\s*>", "", cleaned, flags=re.IGNORECASE
|
|
1202
|
+
)
|
|
1203
|
+
return cleaned, tool_calls
|
|
1204
|
+
|
|
609
1205
|
|
|
610
1206
|
def _known_non_tool_tag_ranges(text: str) -> list[tuple[int, int]]:
|
|
611
1207
|
"""Ranges covered by <think>...</think> (and similar) reasoning blocks.
|
|
@@ -785,6 +1381,26 @@ def repair_tool_calls_in_text(
|
|
|
785
1381
|
cleaned, xml_tool_calls = _extract_xml_tool_calls(cleaned, allowed, protected)
|
|
786
1382
|
extracted.extend(xml_tool_calls)
|
|
787
1383
|
|
|
1384
|
+
# 2b. R4a: nested-XML name-attribute forms (<function name=.../> etc.).
|
|
1385
|
+
# Same guards as R3: protected code fences + reasoning blocks are
|
|
1386
|
+
# shielded, and an example cue immediately before a call tag suppresses
|
|
1387
|
+
# it.
|
|
1388
|
+
protected = _protected_code_spans(cleaned)
|
|
1389
|
+
r4a_guard = protected + _known_non_tool_tag_ranges(cleaned)
|
|
1390
|
+
cleaned, r4a_tool_calls = _extract_r4a_nested_xml(cleaned, allowed, r4a_guard)
|
|
1391
|
+
extracted.extend(r4a_tool_calls)
|
|
1392
|
+
|
|
1393
|
+
# 2c. R4c: standalone call-syntax lines outside any fence
|
|
1394
|
+
# (e.g. ``write_note({...})`` alone on a line). Fenced call-syntax was
|
|
1395
|
+
# already handled in step 1. Only whole-line calls with an allow-listed
|
|
1396
|
+
# name and fully-parseable arguments are taken; inline / mid-prose forms
|
|
1397
|
+
# are never candidates.
|
|
1398
|
+
if allowed is not None:
|
|
1399
|
+
cleaned, r4c_tool_calls = _extract_r4c_standalone_lines(
|
|
1400
|
+
cleaned, allowed, _all_fence_spans(cleaned)
|
|
1401
|
+
)
|
|
1402
|
+
extracted.extend(r4c_tool_calls)
|
|
1403
|
+
|
|
788
1404
|
# 3. Scan remaining text for bare JSON objects (strict then lenient).
|
|
789
1405
|
# Skip anything inside a protected code fence or introduced by a prose
|
|
790
1406
|
# example cue. Walk back-to-front so slicing removals don't shift the
|
|
@@ -803,6 +1419,14 @@ def repair_tool_calls_in_text(
|
|
|
803
1419
|
obj = json.loads(substr)
|
|
804
1420
|
except json.JSONDecodeError:
|
|
805
1421
|
continue
|
|
1422
|
+
# R4b: response-envelope wrappers ({"tool_calls": [...]}, ...) expand to
|
|
1423
|
+
# one or more inner calls before the direct-shape check.
|
|
1424
|
+
envelope = _expand_tool_call_envelope(obj, allowed)
|
|
1425
|
+
if envelope is not None:
|
|
1426
|
+
for name, args in envelope:
|
|
1427
|
+
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
1428
|
+
spans_to_remove.append((start, end))
|
|
1429
|
+
continue
|
|
806
1430
|
hit = _looks_like_tool_call(obj, allowed)
|
|
807
1431
|
if hit is None:
|
|
808
1432
|
continue
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.3
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -10,7 +10,7 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
|
|
|
10
10
|
coderouter/gguf_introspect.py,sha256=FZO14STLSp94Rfo5AInGwYUOpfjiXOW6CH5RiczTWDE,9514
|
|
11
11
|
coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
|
|
12
12
|
coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
|
|
13
|
-
coderouter/logging.py,sha256=
|
|
13
|
+
coderouter/logging.py,sha256=91cveN8SCJEMNz9DGi6TrFSlc8cx0wdUkOdSLWLA-z8,56144
|
|
14
14
|
coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
|
|
15
15
|
coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
|
|
16
16
|
coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
|
|
@@ -23,7 +23,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
|
|
|
23
23
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
24
24
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
25
25
|
coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
|
|
26
|
-
coderouter/config/schemas.py,sha256=
|
|
26
|
+
coderouter/config/schemas.py,sha256=f46bpmSzZ_zJRhHbo9-inp-mAW86WZAg6hfSxgPaEcc,77741
|
|
27
27
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
28
28
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
29
29
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
@@ -45,7 +45,7 @@ coderouter/ingress/launcher_routes.py,sha256=1bU245mEauCMpEOsq0LY-8FKKQIJWJhDe3C
|
|
|
45
45
|
coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
|
|
46
46
|
coderouter/ingress/openai_routes.py,sha256=Mjsk3jwot4qP9f5xshrmR1iuHMW-9sQdsfHMN_Sx5hc,9294
|
|
47
47
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
48
|
-
coderouter/metrics/collector.py,sha256=
|
|
48
|
+
coderouter/metrics/collector.py,sha256=wdGuX5yOBWEHCz1KVIfaUBZP5NXW3U34yyYlLGwgoCE,61212
|
|
49
49
|
coderouter/metrics/prometheus.py,sha256=THYkvrcpQK3ZNW-BV0h3O-PoT2ppNZ0dapQuu1nyHwE,24410
|
|
50
50
|
coderouter/plugins/__init__.py,sha256=76hMLe5dV_ilripHXzWn3HSYoIALjzlw4EJVyI-GyIM,1974
|
|
51
51
|
coderouter/plugins/base.py,sha256=n9hsck2NCSqi6oeHIumKC5zhQ8JGwCXUz7J5AZQCQss,5772
|
|
@@ -56,7 +56,7 @@ coderouter/routing/adaptive.py,sha256=G2o377twGSjbUh65wiIFx6klnpFGjsD_nI3oDvcBwh
|
|
|
56
56
|
coderouter/routing/auto_router.py,sha256=y4v0c8u5F9f98Vmhx1vRcKPiOgAvpzbFqr6TIh058h0,13341
|
|
57
57
|
coderouter/routing/budget.py,sha256=PblmVKJGs_BwNa9uDHAA8hmZ4XIVKv38mHAeU0V3OMs,8451
|
|
58
58
|
coderouter/routing/capability.py,sha256=rRQhzTgQYFTFDNYcSLKvq3xjdw6B1w7T-3jS7PTMI14,27864
|
|
59
|
-
coderouter/routing/fallback.py,sha256=
|
|
59
|
+
coderouter/routing/fallback.py,sha256=vjnDv3z88v76ZnF2Sg1xYUOkKfe0jZm14OtoOrQA7Dc,139707
|
|
60
60
|
coderouter/state/__init__.py,sha256=XoGcPmmBQSiZWML2S0juSveQ78xfhtdeCliNnVyzu7E,1088
|
|
61
61
|
coderouter/state/audit_log.py,sha256=n7vuDsTfd5iuNUJhlRQPakJ2tMSsT9EfgPCzs1GEaac,10941
|
|
62
62
|
coderouter/state/replay.py,sha256=Z_YHKroTKZdrL8qObFxcoLOAQWWXZvXFdLfxzvBhEJg,11230
|
|
@@ -66,9 +66,9 @@ coderouter/state/suggest_rules.py,sha256=FvdhEvao5NvdKp9zs8AkcoFKHY4yqqXY2HekvSj
|
|
|
66
66
|
coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8IYOtG8,1788
|
|
67
67
|
coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
|
|
68
68
|
coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
|
|
69
|
-
coderouter/translation/tool_repair.py,sha256=
|
|
70
|
-
coderouter_cli-2.7.
|
|
71
|
-
coderouter_cli-2.7.
|
|
72
|
-
coderouter_cli-2.7.
|
|
73
|
-
coderouter_cli-2.7.
|
|
74
|
-
coderouter_cli-2.7.
|
|
69
|
+
coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
|
|
70
|
+
coderouter_cli-2.7.3.dist-info/METADATA,sha256=EqYCtqhIooSQ806Ps89T9XIxVKRhWHiyyTyJsMpu_Lg,14857
|
|
71
|
+
coderouter_cli-2.7.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
72
|
+
coderouter_cli-2.7.3.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
73
|
+
coderouter_cli-2.7.3.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
74
|
+
coderouter_cli-2.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|