This commit is contained in:
Mikan
2026-06-21 04:11:38 +03:00
parent bd85e186dc
commit 4dee4fb0a8
19 changed files with 1423 additions and 115 deletions

View File

@@ -31,6 +31,59 @@ from app.models import LlmCallLog
_logger = get_logger(__name__)
# Patterns for parsing tool calls emitted as text (some local models don't
# use the OpenAI function-calling format and instead emit calls as text).
import re as _re
_TOOL_CALL_PATTERNS = [
# call:name{json_args} or call:name(json_args)
_re.compile(r"call:(\w+)\s*[\{\(]([^}\)]*)[\}\)]"),
# <tool_call>name{args}</tool_call> or <tool_call>\n{"name": ..., "arguments": ...}\n</tool_call>
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
# name{"key": "value", ...} (function call style)
_re.compile(r"\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)"),
]
def _parse_text_tool_calls(content: str) -> list[dict]:
"""Parse tool calls emitted as text by some local models.
Handles patterns like:
- call:calc{"expression": "2+2"}
- <tool_call>calc{"expression": "2+2"}</tool_call>
- calc({"expression": "2+2"})
Returns a list of OpenAI-format tool_call dicts.
"""
if not content:
return []
calls: list[dict] = []
for pattern in _TOOL_CALL_PATTERNS:
for match in pattern.finditer(content):
name = match.group(1)
args_str = match.group(2).strip()
if not args_str:
args = {}
else:
try:
args = json.loads(args_str)
except json.JSONDecodeError:
# Try fixing common issues: single quotes, unquoted keys
try:
fixed = args_str.replace("'", '"')
# Add quotes around bare keys
fixed = _re.sub(r"(\w+)\s*:", r'"\1":', fixed)
args = json.loads(fixed)
except json.JSONDecodeError:
args = {"_raw": args_str}
calls.append({
"id": f"parsed_{len(calls)}",
"type": "function",
"function": {"name": name, "arguments": json.dumps(args)},
})
return calls
class LLMError(Exception):
"""Base LLM error."""
@@ -218,6 +271,18 @@ class LlmClient:
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
# If the model didn't return tool_calls in the OpenAI format but DID
# emit them as text (some local models use "call:name{args}" or
# "<tool_call>name{args}</tool_call>"), try to parse them out.
if tools and not msg.get("tool_calls"):
content = msg.get("content", "") or ""
parsed = _parse_text_tool_calls(content)
if parsed:
msg = dict(msg) # don't mutate the original
msg["tool_calls"] = parsed
if finish_reason == "stop":
finish_reason = "tool_calls"
log_id: uuid.UUID | None = None
if session is not None:
log_id = await self._write_log_safely(