coderouter-cli 1.7.0__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/__init__.py +17 -0
- coderouter/__main__.py +6 -0
- coderouter/adapters/__init__.py +23 -0
- coderouter/adapters/anthropic_native.py +502 -0
- coderouter/adapters/base.py +220 -0
- coderouter/adapters/openai_compat.py +395 -0
- coderouter/adapters/registry.py +17 -0
- coderouter/cli.py +345 -0
- coderouter/cli_stats.py +751 -0
- coderouter/config/__init__.py +10 -0
- coderouter/config/capability_registry.py +339 -0
- coderouter/config/env_file.py +295 -0
- coderouter/config/loader.py +73 -0
- coderouter/config/schemas.py +515 -0
- coderouter/data/__init__.py +7 -0
- coderouter/data/model-capabilities.yaml +86 -0
- coderouter/doctor.py +1596 -0
- coderouter/env_security.py +434 -0
- coderouter/errors.py +29 -0
- coderouter/ingress/__init__.py +5 -0
- coderouter/ingress/anthropic_routes.py +205 -0
- coderouter/ingress/app.py +144 -0
- coderouter/ingress/dashboard_routes.py +493 -0
- coderouter/ingress/metrics_routes.py +92 -0
- coderouter/ingress/openai_routes.py +153 -0
- coderouter/logging.py +315 -0
- coderouter/metrics/__init__.py +39 -0
- coderouter/metrics/collector.py +471 -0
- coderouter/metrics/prometheus.py +221 -0
- coderouter/output_filters.py +407 -0
- coderouter/routing/__init__.py +13 -0
- coderouter/routing/auto_router.py +244 -0
- coderouter/routing/capability.py +285 -0
- coderouter/routing/fallback.py +611 -0
- coderouter/translation/__init__.py +57 -0
- coderouter/translation/anthropic.py +204 -0
- coderouter/translation/convert.py +1291 -0
- coderouter/translation/tool_repair.py +236 -0
- coderouter_cli-1.7.0.dist-info/METADATA +509 -0
- coderouter_cli-1.7.0.dist-info/RECORD +43 -0
- coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
- coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
- coderouter_cli-1.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Tool-call repair: extract tool invocations that a model wrote as plain text.
|
|
2
|
+
|
|
3
|
+
Background
|
|
4
|
+
----------
|
|
5
|
+
Small coding models (e.g. qwen2.5-coder) sometimes respond to a tool-bearing
|
|
6
|
+
prompt by *writing* a JSON object describing the tool call in the assistant
|
|
7
|
+
message body instead of populating the structured `tool_calls` field. The
|
|
8
|
+
downstream Anthropic/OpenAI clients then see regular text and never execute
|
|
9
|
+
the tool.
|
|
10
|
+
|
|
11
|
+
This module scans assistant text for such embedded tool-call JSON and pulls
|
|
12
|
+
it back into the OpenAI-shape `tool_calls` list, so the rest of the
|
|
13
|
+
translation pipeline (`to_anthropic_response`, stream event emitter) can
|
|
14
|
+
produce real `tool_use` content blocks.
|
|
15
|
+
|
|
16
|
+
Recognised shapes
|
|
17
|
+
-----------------
|
|
18
|
+
1. Fenced code blocks:
|
|
19
|
+
```json
|
|
20
|
+
{"name": "Bash", "arguments": {"command": "pwd"}}
|
|
21
|
+
```
|
|
22
|
+
(the language tag is optional: ``` ...``` also works.)
|
|
23
|
+
2. Bare JSON objects embedded in text:
|
|
24
|
+
"Let me check the current directory. {\"name\":\"Bash\",\"arguments\":{}}"
|
|
25
|
+
3. Multiple JSON objects in sequence (for multi-call turns).
|
|
26
|
+
|
|
27
|
+
Each candidate is accepted only if it parses to one of:
|
|
28
|
+
{"name": <str>, "arguments": <dict | str>} # direct shape
|
|
29
|
+
{"function": {"name": <str>, "arguments": ...}} # OpenAI shape
|
|
30
|
+
|
|
31
|
+
If `allowed_tool_names` is provided, the `name` must be in that set;
|
|
32
|
+
otherwise any tool-shaped JSON is accepted. Passing the allow-list is
|
|
33
|
+
strongly recommended to avoid false positives (a model legitimately
|
|
34
|
+
discussing JSON in prose).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import re
|
|
41
|
+
import uuid
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
__all__ = ["repair_tool_calls_in_text"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Tool-call shape detection + normalisation
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _looks_like_tool_call(obj: Any, allowed: set[str] | None) -> tuple[str, Any] | None:
|
|
53
|
+
"""Return (name, arguments) if obj looks like a tool call, else None."""
|
|
54
|
+
if not isinstance(obj, dict):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Direct shape: {"name": "...", "arguments": ...}
|
|
58
|
+
name = obj.get("name")
|
|
59
|
+
if isinstance(name, str) and "arguments" in obj and (allowed is None or name in allowed):
|
|
60
|
+
return name, obj["arguments"]
|
|
61
|
+
|
|
62
|
+
# OpenAI function shape: {"function": {"name": "...", "arguments": ...}}
|
|
63
|
+
fn = obj.get("function")
|
|
64
|
+
if isinstance(fn, dict):
|
|
65
|
+
inner_name = fn.get("name")
|
|
66
|
+
if (
|
|
67
|
+
isinstance(inner_name, str)
|
|
68
|
+
and "arguments" in fn
|
|
69
|
+
and (allowed is None or inner_name in allowed)
|
|
70
|
+
):
|
|
71
|
+
return inner_name, fn["arguments"]
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
|
|
77
|
+
"""Build an OpenAI-shape tool_calls entry."""
|
|
78
|
+
if isinstance(arguments, str):
|
|
79
|
+
args_str = arguments
|
|
80
|
+
elif isinstance(arguments, dict):
|
|
81
|
+
args_str = json.dumps(arguments, ensure_ascii=False)
|
|
82
|
+
else:
|
|
83
|
+
# list / None / anything else — fall back to serialising what we got.
|
|
84
|
+
args_str = json.dumps(arguments, ensure_ascii=False)
|
|
85
|
+
return {
|
|
86
|
+
"id": f"call_{uuid.uuid4().hex[:16]}",
|
|
87
|
+
"type": "function",
|
|
88
|
+
"function": {"name": name, "arguments": args_str},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Scanners: fenced code blocks, then balanced braces in remaining text
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
# Match ```json ... ``` or ``` ... ``` with anything after the fence tag line.
|
|
97
|
+
# Group 1 captures the body.
|
|
98
|
+
_FENCED_RE = re.compile(
|
|
99
|
+
r"```(?:\w+)?[ \t]*\r?\n(.*?)\r?\n?```",
|
|
100
|
+
re.DOTALL,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _extract_fenced_blocks(text: str) -> tuple[str, list[str]]:
|
|
105
|
+
"""Pull ```...``` blocks out of text. Returns (text_without_fences, bodies)."""
|
|
106
|
+
bodies: list[str] = []
|
|
107
|
+
|
|
108
|
+
def _collect(match: re.Match[str]) -> str:
|
|
109
|
+
bodies.append(match.group(1))
|
|
110
|
+
return "" # remove the fenced block from the text entirely
|
|
111
|
+
|
|
112
|
+
cleaned = _FENCED_RE.sub(_collect, text)
|
|
113
|
+
return cleaned, bodies
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
117
|
+
"""Find top-level `{...}` JSON substrings by a brace-counter scan.
|
|
118
|
+
|
|
119
|
+
Returns a list of (start, end_exclusive, substring). Handles escape
|
|
120
|
+
sequences and string literals so braces inside JSON strings do not
|
|
121
|
+
confuse the counter. Malformed (unclosed) candidates are skipped.
|
|
122
|
+
"""
|
|
123
|
+
out: list[tuple[int, int, str]] = []
|
|
124
|
+
n = len(text)
|
|
125
|
+
i = 0
|
|
126
|
+
while i < n:
|
|
127
|
+
if text[i] != "{":
|
|
128
|
+
i += 1
|
|
129
|
+
continue
|
|
130
|
+
# Scan forward to find a balanced close.
|
|
131
|
+
depth = 0
|
|
132
|
+
j = i
|
|
133
|
+
in_str = False
|
|
134
|
+
escape = False
|
|
135
|
+
while j < n:
|
|
136
|
+
c = text[j]
|
|
137
|
+
if escape:
|
|
138
|
+
escape = False
|
|
139
|
+
elif in_str:
|
|
140
|
+
if c == "\\":
|
|
141
|
+
escape = True
|
|
142
|
+
elif c == '"':
|
|
143
|
+
in_str = False
|
|
144
|
+
else:
|
|
145
|
+
if c == '"':
|
|
146
|
+
in_str = True
|
|
147
|
+
elif c == "{":
|
|
148
|
+
depth += 1
|
|
149
|
+
elif c == "}":
|
|
150
|
+
depth -= 1
|
|
151
|
+
if depth == 0:
|
|
152
|
+
out.append((i, j + 1, text[i : j + 1]))
|
|
153
|
+
i = j + 1
|
|
154
|
+
break
|
|
155
|
+
j += 1
|
|
156
|
+
else:
|
|
157
|
+
# Ran off the end without closing — skip this `{` and move on.
|
|
158
|
+
i += 1
|
|
159
|
+
continue
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# Public API
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def repair_tool_calls_in_text(
|
|
169
|
+
text: str,
|
|
170
|
+
allowed_tool_names: list[str] | set[str] | None = None,
|
|
171
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
172
|
+
"""Extract embedded tool-call JSON from assistant text.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
(cleaned_text, tool_calls)
|
|
176
|
+
cleaned_text : the input with recognised tool-call JSON removed,
|
|
177
|
+
stripped of surrounding whitespace.
|
|
178
|
+
tool_calls : OpenAI-shape tool_calls entries, in the order they
|
|
179
|
+
appeared in the original text. Each entry has a
|
|
180
|
+
freshly minted `id` (the source JSON did not carry one).
|
|
181
|
+
|
|
182
|
+
If nothing repairable is found, returns (text, []).
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(text, str) or not text:
|
|
185
|
+
return text, []
|
|
186
|
+
|
|
187
|
+
allowed: set[str] | None = None if allowed_tool_names is None else set(allowed_tool_names)
|
|
188
|
+
|
|
189
|
+
extracted: list[dict[str, Any]] = []
|
|
190
|
+
|
|
191
|
+
# 1. Pull fenced code blocks out first — they're the most common shape
|
|
192
|
+
# when a chat-tuned model explains what it's doing.
|
|
193
|
+
cleaned, fenced_bodies = _extract_fenced_blocks(text)
|
|
194
|
+
for body in fenced_bodies:
|
|
195
|
+
body = body.strip()
|
|
196
|
+
if not body.startswith("{"):
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
obj = json.loads(body)
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
continue
|
|
202
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
203
|
+
if hit is not None:
|
|
204
|
+
name, args = hit
|
|
205
|
+
extracted.append(_normalise_to_openai_tool_call(name, args))
|
|
206
|
+
|
|
207
|
+
# 2. Scan remaining text for bare JSON objects.
|
|
208
|
+
# We walk from back to front so removals by slicing don't shift
|
|
209
|
+
# the indices of earlier matches.
|
|
210
|
+
candidates = _find_balanced_json_objects(cleaned)
|
|
211
|
+
# Tentatively evaluate each; keep only the ones that are tool-call-shaped.
|
|
212
|
+
spans_to_remove: list[tuple[int, int]] = []
|
|
213
|
+
repaired_from_bare: list[dict[str, Any]] = []
|
|
214
|
+
for start, end, substr in candidates:
|
|
215
|
+
try:
|
|
216
|
+
obj = json.loads(substr)
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
continue
|
|
219
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
220
|
+
if hit is None:
|
|
221
|
+
continue
|
|
222
|
+
name, args = hit
|
|
223
|
+
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
224
|
+
spans_to_remove.append((start, end))
|
|
225
|
+
|
|
226
|
+
# Remove the matched spans from the text back-to-front.
|
|
227
|
+
for start, end in reversed(spans_to_remove):
|
|
228
|
+
cleaned = cleaned[:start] + cleaned[end:]
|
|
229
|
+
|
|
230
|
+
extracted.extend(repaired_from_bare)
|
|
231
|
+
|
|
232
|
+
# Collapse the whitespace left behind by removals.
|
|
233
|
+
cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
|
|
234
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
235
|
+
|
|
236
|
+
return cleaned, extracted
|