diff --git a/danyapi/api/openai.py b/danyapi/api/openai.py index 44bd084..954a832 100644 --- a/danyapi/api/openai.py +++ b/danyapi/api/openai.py @@ -178,16 +178,6 @@ class ResponsesRequest(BaseModel): search: bool | None = None -def _find_tool_marker(text: str, start: int = 0) -> int: - markers = ('{"tool_calls"', " search_from: - delta["content"] = content_buf[search_from:marker_pos] - content_shown_len = marker_pos - tool_hidden = True + visible, content_shown_len, tool_hidden = toolemu.tool_visible(content_buf, content_shown_len, tool_hidden, tool_schemas) + if visible: + delta["content"] = visible else: delta["content"] = c_diff if r_diff: @@ -2564,17 +2546,9 @@ async def _stream_openai( if c_diff: if tool_mode: content_buf += c_diff - if not tool_hidden: - search_from = content_shown_len - marker_pos = _find_tool_marker(content_buf, search_from) - if marker_pos < 0: - delta2["content"] = content_buf[search_from:] - content_shown_len = len(content_buf) - else: - if marker_pos > search_from: - delta2["content"] = content_buf[search_from:marker_pos] - content_shown_len = marker_pos - tool_hidden = True + visible, content_shown_len, tool_hidden = toolemu.tool_visible(content_buf, content_shown_len, tool_hidden, tool_schemas) + if visible: + delta2["content"] = visible else: delta2["content"] = c_diff if r_diff: @@ -2671,26 +2645,17 @@ async def _stream_openai( ) if tool_mode: content_buf += cont_rec.content - if not tool_hidden: - search_from = content_shown_len - marker_pos = _find_tool_marker(content_buf, search_from) - if marker_pos < 0: - c_visible = cont_rec.content - content_shown_len = len(content_buf) - else: - c_visible = content_buf[search_from:marker_pos] - content_shown_len = marker_pos - tool_hidden = True - if c_visible: - yield _sse( - { - "id": chunk_id, - "object": "chat.completion.chunk", - "created": created, - "model": model, - "choices": [{"index": 0, "delta": {"content": c_visible}, "finish_reason": None}], - } - ) + c_visible, content_shown_len, tool_hidden = toolemu.tool_visible(content_buf, content_shown_len, tool_hidden, tool_schemas) + if c_visible: + yield _sse( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{"index": 0, "delta": {"content": c_visible}, "finish_reason": None}], + } + ) else: yield _sse( { @@ -2768,26 +2733,17 @@ async def _stream_openai( ) if tool_mode: content_buf += rec.content - if not tool_hidden: - search_from = content_shown_len - marker_pos = _find_tool_marker(content_buf, search_from) - if marker_pos < 0: - r_visible = rec.content - content_shown_len = len(content_buf) - else: - r_visible = content_buf[search_from:marker_pos] - content_shown_len = marker_pos - tool_hidden = True - if r_visible: - yield _sse( - { - "id": chunk_id, - "object": "chat.completion.chunk", - "created": created, - "model": model, - "choices": [{"index": 0, "delta": {"content": r_visible}, "finish_reason": None}], - } - ) + r_visible, content_shown_len, tool_hidden = toolemu.tool_visible(content_buf, content_shown_len, tool_hidden, tool_schemas) + if r_visible: + yield _sse( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{"index": 0, "delta": {"content": r_visible}, "finish_reason": None}], + } + ) else: yield _sse( { diff --git a/danyapi/qwen/api.py b/danyapi/qwen/api.py index 7cc7dc9..9aae060 100644 --- a/danyapi/qwen/api.py +++ b/danyapi/qwen/api.py @@ -71,17 +71,6 @@ def _is_retryable_http(exc: HTTPException) -> bool: "tokenlimit", ) -_TOOL_MARKERS = ('{"tool_calls"', "") - - -def _tool_marker_pos(text: str) -> int: - found = -1 - for marker in _TOOL_MARKERS: - pos = text.find(marker) - if pos != -1 and (found == -1 or pos < found): - found = pos - return found - def _append_image_markdown(prompt: str, messages: list[Any] | None) -> str: if not messages: @@ -597,7 +586,7 @@ async def stream_openai( rec: QwenStreamReconstructor | None = None content_buf = "" content_shown_len = 0 - tool_marker_pos = -1 + tool_hidden = False role_sent = False stop_response_id: str | None = None had_cached_session = bool(existing_sid) and account.sessions.get(existing_sid) is not None @@ -660,7 +649,7 @@ async def stream_openai( got_content = False role_sent = False content_shown_len = 0 - tool_marker_pos = -1 + tool_hidden = False stopped = False try: async for chunk in resp.aiter_bytes(): @@ -691,15 +680,9 @@ async def stream_openai( if c_diff: if tool_mode: content_buf += c_diff - if tool_marker_pos == -1: - tool_marker_pos = _tool_marker_pos(content_buf) - if tool_marker_pos == -1: - shown = content_buf[content_shown_len:] - else: - shown = content_buf[content_shown_len:tool_marker_pos] + shown, content_shown_len, tool_hidden = toolemu.tool_visible(content_buf, content_shown_len, tool_hidden, tool_schemas) if shown: delta["content"] = shown - content_shown_len += len(shown) else: delta["content"] = c_diff if r_diff: @@ -747,15 +730,9 @@ async def stream_openai( if c_diff: if tool_mode: content_buf += c_diff - if tool_marker_pos == -1: - tool_marker_pos = _tool_marker_pos(content_buf) - if tool_marker_pos == -1: - shown = content_buf[content_shown_len:] - else: - shown = content_buf[content_shown_len:tool_marker_pos] + shown, content_shown_len, tool_hidden = toolemu.tool_visible(content_buf, content_shown_len, tool_hidden, tool_schemas) if shown: delta2["content"] = shown - content_shown_len += len(shown) else: delta2["content"] = c_diff if r_diff: diff --git a/danyapi/tools.py b/danyapi/tools.py index 720392d..002ddb8 100644 --- a/danyapi/tools.py +++ b/danyapi/tools.py @@ -7,6 +7,7 @@ from collections.abc import Iterator from dataclasses import dataclass from difflib import get_close_matches +from functools import lru_cache from typing import Any _DSML_PIPE = r"|\u00a6\u01c0\u01c1\u05c0\u2016\u2223\u2502\u2551\u2758\ufe31\uff5c" @@ -275,6 +276,220 @@ def _strip_dsml(text: str) -> str: return _DSML_NAKED.sub(" ", result) +TOOL_STREAM_TAGS = ( + "tool_calls", + "tool_call", + "function_calls", + "function_call", + "functions", + "function", + "tools", + "calls", + "_calls", + "toolinvoke", + "tool_invoke", + "use_tool", + "tool_use", + "invoke", + "call", + "action", + "run", +) + +TOOL_STREAM_JSON_KEYS = ( + "tool_calls", + "calls", + "_calls", + "function_call", + "tool_name", + "name", + "tool", + "action", + "call", +) + +TOOL_STREAM_MARKERS = tuple([f'{{"{key}"' for key in TOOL_STREAM_JSON_KEYS] + [f"<{tag}" for tag in TOOL_STREAM_TAGS] + ["tool_calls:", "[{"]) + +TOOL_STREAM_MARKER_MAX = max(len(marker) for marker in TOOL_STREAM_MARKERS) + +_TOOL_STREAM_TAG_RE = re.compile( + r"<\s*/?\s*(?:" + "|".join(TOOL_STREAM_TAGS) + r")\b[^<>]*>", + re.IGNORECASE, +) +_TOOL_STREAM_JSON_RE = re.compile(r"\{\s*['\"]?(?:" + "|".join(TOOL_STREAM_JSON_KEYS) + r")['\"]?\s*:") +_TOOL_STREAM_ARRAY_RE = re.compile(r"\[\s*\{") +_TOOL_STREAM_YAML_RE = re.compile(r"(?m)^[ \t]*tool_calls\s*:") +_TOOL_STREAM_NAME_ATTR_RE = re.compile( + r"<\s*/?\s*(?!(?:" + "|".join(sorted(_XML_HTML_TAGS)) + r")\b)[A-Za-z_][A-Za-z0-9_.-]*[^<>]*\bname\s*=", + re.IGNORECASE, +) +_DSML_STREAM_START = re.compile( + r"<\s*/?\s*(?:[|]|[^\x00-\x7f]){1,8}\s*DSML\s*(?:[|]|[^\x00-\x7f]){1,8}", + re.IGNORECASE | re.DOTALL, +) + + +@lru_cache(maxsize=64) +def _stream_patterns(names: tuple[str, ...]) -> tuple[re.Pattern[str], ...]: + patterns = [ + _TOOL_STREAM_TAG_RE, + _TOOL_STREAM_JSON_RE, + _TOOL_STREAM_ARRAY_RE, + _TOOL_STREAM_YAML_RE, + _TOOL_STREAM_NAME_ATTR_RE, + _DSML_STREAM_START, + ] + if names: + escaped = "|".join(re.escape(name) for name in names) + patterns.append(re.compile(rf"<\s*/?\s*(?:{escaped})\b", re.IGNORECASE)) + patterns.append(re.compile(rf"(?m)^[ \t]*(?:{escaped})[ \t]*\(", re.IGNORECASE)) + return tuple(patterns) + + +def _stream_names(tool_schemas: dict[str, dict[str, Any]] | None) -> tuple[str, ...]: + if not tool_schemas: + return () + return tuple(sorted(name.lower() for name in tool_schemas if isinstance(name, str) and name)) + + +def _literal_hold(text: str, start: int) -> int: + tail_from = max(start, len(text) - TOOL_STREAM_MARKER_MAX + 1) + for index in range(tail_from, len(text)): + suffix = text[index:] + if any(marker.startswith(suffix) for marker in TOOL_STREAM_MARKERS): + return index + return -1 + + +def _json_hold(text: str, start: int) -> int: + brace = text.rfind("{") + if brace < start or "}" in text[brace:]: + return -1 + body = text[brace + 1 :].lstrip() + if not body: + return brace + if body[0] in "'\"": + body = body[1:] + key = body.lower() + if any(candidate.startswith(key) for candidate in TOOL_STREAM_JSON_KEYS): + return brace + return -1 + + +def _tag_hold(text: str, start: int, names: tuple[str, ...]) -> int: + lt = text.rfind("<") + if lt < start: + return -1 + tail = text[lt:] + if ">" in tail: + return -1 + body = tail[1:].lstrip() + if body.startswith("/"): + body = body[1:].lstrip() + if not body: + return lt + first = body[0] + if first == "|" or ord(first) > 127: + return lt + chars: list[str] = [] + for char in body: + if char.isascii() and (char.isalnum() or char in "_-."): + chars.append(char) + else: + break + name = "".join(chars).lower() + if not name: + return -1 + if name in _XML_HTML_TAGS: + return -1 + for candidate in TOOL_STREAM_TAGS: + if candidate.startswith(name): + return lt + for candidate in names: + if candidate.startswith(name): + return lt + lowered = body.lower() + for suffix in ("name", "nam", "na", "n"): + if lowered.endswith(suffix): + before = lowered[: len(lowered) - len(suffix)] + if not before or before[-1] in " \t_-\"'=<>": + return lt + if "name" in lowered: + return lt + return -1 + + +def _array_hold(text: str, start: int) -> int: + bracket = text.rfind("[") + if bracket < start or "]" in text[bracket:]: + return -1 + if not text[bracket + 1 :].strip(): + return bracket + return -1 + + +def _python_hold(text: str, start: int, names: tuple[str, ...]) -> int: + if not names: + return -1 + line_start = text.rfind("\n") + 1 + if line_start < start: + return -1 + line = text[line_start:].lstrip().lower() + if not line: + return -1 + for name in names: + if (name + "(").startswith(line): + return line_start + return -1 + + +def tool_call_boundary( + text: str, + start: int = 0, + tool_schemas: dict[str, dict[str, Any]] | None = None, +) -> tuple[int, bool]: + names = _stream_names(tool_schemas) + best = -1 + complete = False + for pattern in _stream_patterns(names): + match = pattern.search(text, start) + if match is not None and (best == -1 or match.start() < best): + best = match.start() + complete = True + for marker in TOOL_STREAM_MARKERS: + pos = text.find(marker, start) + if pos != -1 and (best == -1 or pos < best): + best = pos + complete = True + hold = -1 + for candidate in ( + _literal_hold(text, start), + _json_hold(text, start), + _tag_hold(text, start, names), + _array_hold(text, start), + _python_hold(text, start, names), + ): + if candidate != -1 and (hold == -1 or candidate < hold): + hold = candidate + if hold != -1 and (best == -1 or hold < best): + return hold, False + return best, complete + + +def tool_visible( + content_buf: str, + shown: int, + hidden: bool, + tool_schemas: dict[str, dict[str, Any]] | None = None, +) -> tuple[str, int, bool]: + if hidden: + return "", shown, True + boundary, complete = tool_call_boundary(content_buf, shown, tool_schemas) + if boundary < 0: + return content_buf[shown:], len(content_buf), False + return content_buf[shown:boundary], boundary, complete + + TOOL_CALL_INSTRUCTION = ( "{functions}\n\n" "To call a function, reply with ONLY:\n"