coderouter-cli 2.7.1__py3-none-any.whl → 2.7.2__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.
@@ -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\s+like|"
523
- r"looks?\s+like\s+this|written\s+as|the\s+format\s+is|"
524
- r"syntax\s+is|例えば|たとえば|のように書"
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,40}$",
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 immediately precedes position ``start``."""
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
- # Look only within the current line/clause (last ~80 chars, no newline jump
535
- # further than the immediate lead-in).
536
- tail = prefix[-80:]
537
- return bool(_PROSE_CUE_RE.search(tail))
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.1
3
+ Version: 2.7.2
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
@@ -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=Ex9cPdRFuX7UeVKbMQRctum2CbCupXu8pnJEpIc6Ifc,31818
70
- coderouter_cli-2.7.1.dist-info/METADATA,sha256=YE2Jk09KYcC_8Zfk6ZnYhxVtMFL1xzRcy9TPMHkiwNo,14857
71
- coderouter_cli-2.7.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
72
- coderouter_cli-2.7.1.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
73
- coderouter_cli-2.7.1.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
74
- coderouter_cli-2.7.1.dist-info/RECORD,,
69
+ coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
70
+ coderouter_cli-2.7.2.dist-info/METADATA,sha256=z-Kd6V1JathVCNb3Gg2UzvZmpXbBmCUahQ2XrjmbdZ0,14857
71
+ coderouter_cli-2.7.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
72
+ coderouter_cli-2.7.2.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
73
+ coderouter_cli-2.7.2.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
74
+ coderouter_cli-2.7.2.dist-info/RECORD,,