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.
Files changed (43) hide show
  1. coderouter/__init__.py +17 -0
  2. coderouter/__main__.py +6 -0
  3. coderouter/adapters/__init__.py +23 -0
  4. coderouter/adapters/anthropic_native.py +502 -0
  5. coderouter/adapters/base.py +220 -0
  6. coderouter/adapters/openai_compat.py +395 -0
  7. coderouter/adapters/registry.py +17 -0
  8. coderouter/cli.py +345 -0
  9. coderouter/cli_stats.py +751 -0
  10. coderouter/config/__init__.py +10 -0
  11. coderouter/config/capability_registry.py +339 -0
  12. coderouter/config/env_file.py +295 -0
  13. coderouter/config/loader.py +73 -0
  14. coderouter/config/schemas.py +515 -0
  15. coderouter/data/__init__.py +7 -0
  16. coderouter/data/model-capabilities.yaml +86 -0
  17. coderouter/doctor.py +1596 -0
  18. coderouter/env_security.py +434 -0
  19. coderouter/errors.py +29 -0
  20. coderouter/ingress/__init__.py +5 -0
  21. coderouter/ingress/anthropic_routes.py +205 -0
  22. coderouter/ingress/app.py +144 -0
  23. coderouter/ingress/dashboard_routes.py +493 -0
  24. coderouter/ingress/metrics_routes.py +92 -0
  25. coderouter/ingress/openai_routes.py +153 -0
  26. coderouter/logging.py +315 -0
  27. coderouter/metrics/__init__.py +39 -0
  28. coderouter/metrics/collector.py +471 -0
  29. coderouter/metrics/prometheus.py +221 -0
  30. coderouter/output_filters.py +407 -0
  31. coderouter/routing/__init__.py +13 -0
  32. coderouter/routing/auto_router.py +244 -0
  33. coderouter/routing/capability.py +285 -0
  34. coderouter/routing/fallback.py +611 -0
  35. coderouter/translation/__init__.py +57 -0
  36. coderouter/translation/anthropic.py +204 -0
  37. coderouter/translation/convert.py +1291 -0
  38. coderouter/translation/tool_repair.py +236 -0
  39. coderouter_cli-1.7.0.dist-info/METADATA +509 -0
  40. coderouter_cli-1.7.0.dist-info/RECORD +43 -0
  41. coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
  42. coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
  43. 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