XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
from __future__ import annotations

import json
import re
from typing import Optional, Union

from ..typing import AsyncResult, Messages, MediaListType
from ..client.service import get_model_and_provider
from ..client.helper import filter_json
from .types import ProviderType
from .base_provider import AsyncGeneratorProvider, get_async_provider_method
from .response import ToolCalls, FinishReason, Usage, Reasoning, JsonConversation


def _strip_code_fences(text: str) -> str:
    """Remove markdown code fences (```json ... ``` or ``` ... ```) wrapping a payload."""
    if not text:
        return text
    text = text.strip()
    m = re.match(r"^```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```\s*$", text)
    if m:
        return m.group(1).strip()
    m = re.match(r"^```(?:json|JSON)?\s*\n?([\s\S]*)$", text)
    if m:
        return m.group(1).strip()
    return text


def _parse_json_maybe(s: str):
    """Best-effort extraction of a JSON object/array from a model response."""
    if not s:
        return None
    s = _strip_code_fences(s)
    if "</tool_response>" in s:
        s = s.split("</tool_response>", 1)[-1]
    s = s.strip()
    if s.startswith("{") and not s.endswith("}"):
        s += "}"
    try:
        return json.loads(s)
    except Exception:
        pass
    m = None
    if "{" in s and "}" in s:
        m = re.search(r"\{[\s\S]*\}", s)
    if m is None and "[" in s and "]" in s:
        m = re.search(r"\[[\s\S]*\]", s)
    if not m:
        return None
    try:
        return json.loads(m.group(0))
    except Exception:
        try:
            fixed = re.sub(r",\s*([}\]])", r"\1", m.group(0))
            return json.loads(fixed)
        except Exception:
            return None


def _stringify_tool_calls(tool_calls: list) -> str:
    """Render an assistant ``tool_calls`` list as a human-readable text block."""
    parts = []
    for tc in tool_calls:
        if not isinstance(tc, dict):
            continue
        fn = tc.get("function") if tc.get("type") == "function" else tc
        if not isinstance(fn, dict):
            continue
        name = fn.get("name") or tc.get("name") or "unknown"
        args = fn.get("arguments")
        if isinstance(args, str):
            args_str = args
        else:
            try:
                args_str = json.dumps(
                    args if isinstance(args, dict) else {}, ensure_ascii=True
                )
            except Exception:
                args_str = "{}"
        call_id = tc.get("id", "")
        header = f"[Tool call: {name}]"
        if call_id:
            header += f" (id={call_id})"
        parts.append(f"{header}\nArguments: {args_str}")
    return "\n\n".join(parts)


# Matches the text format produced by ``_stringify_tool_calls``:
#   [Tool call: NAME] (id=CALL_ID)
#   Arguments: { ... JSON ... }
# The ``(id=...)`` part is optional. The JSON arguments may span multiple
# lines and contain nested braces, so we balance them manually.
_TOOL_CALL_HEADER_RE = re.compile(
    r"\[\s*Tool\s*call\s*:\s*([^\]]+?)\s*\](?:\s*\(id=([^\)]*)\))?\s*\n\s*Arguments\s*:\s*",
    re.IGNORECASE,
)


def _extract_balanced_json(s: str, start: int) -> tuple[str, int]:
    """Return the balanced JSON object/array starting at ``s[start]`` and the
    index just past it. If ``s[start]`` is not ``{`` or ``[``, returns the
    remainder of the line as a best-effort argument string."""
    if start >= len(s):
        return "", start
    open_ch = s[start]
    if open_ch not in "{[":
        # Take until end of line as the argument text.
        end = s.find("\n", start)
        if end == -1:
            end = len(s)
        return s[start:end].strip(), end
    close_ch = "}" if open_ch == "{" else "]"
    depth = 0
    in_str = False
    escape = False
    i = start
    while i < len(s):
        ch = s[i]
        if in_str:
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == '"':
                in_str = False
        else:
            if ch == '"':
                in_str = True
            elif ch == open_ch:
                depth += 1
            elif ch == close_ch:
                depth -= 1
                if depth == 0:
                    return s[start : i + 1], i + 1
        i += 1
    # Unbalanced — return everything from start as a best effort.
    return s[start:].strip(), len(s)


def _parse_stringified_tool_calls(text: str) -> list[dict]:
    """Parse the human-readable ``[Tool call: NAME] (id=ID)\\nArguments: {JSON}``
    format back into a list of tool-call dicts.

    This handles the case where a web-API model echoes back the stringified
    tool-call format (the same format produced by ``_stringify_tool_calls``)
    instead of emitting a raw JSON tool-call object. Returns an empty list when
    no stringified tool calls are found.
    """
    calls: list[dict] = []
    if not text:
        return calls
    for m in _TOOL_CALL_HEADER_RE.finditer(text):
        name = (m.group(1) or "").strip()
        call_id = (m.group(2) or "").strip()
        args_start = m.end()
        args_str, _ = _extract_balanced_json(text, args_start)
        if not name:
            continue
        # ``arguments`` may be a JSON object, a JSON string, or plain text.
        arguments: object
        if args_str:
            try:
                arguments = json.loads(args_str)
            except Exception:
                arguments = args_str
        else:
            arguments = {}
        calls.append(
            {
                "name": name,
                "id": call_id or "",
                "arguments": arguments,
            }
        )
    return calls


def _stringify_tool_response(message: dict) -> str:
    """Render a ``role: tool`` message as a human-readable text block."""
    content = message.get("content")
    if isinstance(content, list):
        # Concatenate text parts from a multipart content list.
        text_parts = []
        for part in content:
            if isinstance(part, dict) and part.get("type") == "text":
                text_parts.append(part.get("text", ""))
            elif isinstance(part, str):
                text_parts.append(part)
        content = "\n".join(text_parts)
    if content is None:
        content = ""
    tool_call_id = message.get("tool_call_id", "")
    name = message.get("name", "")
    header = "[Tool response"
    if name:
        header += f": {name}"
    if tool_call_id:
        header += f" (id={tool_call_id})"
    header += "]"
    return f"{header}\n{content}"


def _extract_text(content) -> str:
    """Extract plain text from a message ``content`` field (str or multipart list)."""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for part in content:
            if isinstance(part, dict) and part.get("type") == "text":
                parts.append(part.get("text", ""))
            elif isinstance(part, str):
                parts.append(part)
        return "\n".join(parts)
    return ""


def _merge_messages_to_single_user(messages: Messages) -> Messages:
    """Merge all assistant and user messages into a single ``role: user`` message.

    Web-API providers (e.g. Qwen, Copilot) that rely on ``get_last_user_message``
    only forward the last user message to the server when no ``conversation``
    handle is available, dropping all prior context.  Folding the whole history
    into one user message ensures the model still sees the full conversation.
    """
    if not messages:
        return messages
    parts: list[str] = []
    for msg in messages:
        if not isinstance(msg, dict):
            continue
        role = msg.get("role")
        if role not in ("user", "assistant"):
            continue
        text = _extract_text(msg.get("content"))
        if not text:
            continue
        header = "User" if role == "user" else "Assistant"
        parts.append(f"[{header}]\n{text}")
    if not parts:
        return messages
    return [{"role": "user", "content": "\n\n".join(parts)}]


def _preprocess_tool_messages(messages: Messages) -> Messages:
    """Convert ``tool_calls`` on assistant messages and ``role: tool`` messages
    into readable text so web-only providers can follow the conversation history.

    Assistant messages keep their textual ``content`` (if any) and get the
    stringified tool calls appended. Tool response messages are rewritten as
    ``role: user`` messages with the stringified response as content.

    ``role: system`` messages are folded into the next ``role: user`` message
    (prefixed with a ``[System]`` header) so providers that only accept
    ``user``/``assistant`` roles still receive the system instructions. If no
    user message follows, the system content is emitted as a user message.
    """
    processed: Messages = []
    pending_system: list[str] = []
    for msg in messages:
        if not isinstance(msg, dict):
            # Flush any pending system text before non-dict entries.
            if pending_system:
                processed.append(
                    {
                        "role": "user",
                        "content": "[System]\n" + "\n".join(pending_system),
                    }
                )
                pending_system = []
            processed.append(msg)
            continue
        role = msg.get("role")
        tool_calls = msg.get("tool_calls")
        if role == "system":
            text = _extract_text(msg.get("content"))
            if text:
                pending_system.append(text)
            continue
        if role == "assistant" and isinstance(tool_calls, list) and tool_calls:
            new_msg = {k: v for k, v in msg.items() if k != "tool_calls"}
            text_parts = []
            existing_content = new_msg.get("content")
            if isinstance(existing_content, str) and existing_content.strip():
                text_parts.append(existing_content)
            elif isinstance(existing_content, list):
                for part in existing_content:
                    if isinstance(part, dict) and part.get("type") == "text":
                        t = part.get("text", "")
                        if t:
                            text_parts.append(t)
                    elif isinstance(part, str) and part:
                        text_parts.append(part)
            rendered = _stringify_tool_calls(tool_calls)
            if rendered:
                text_parts.append(rendered)
            new_msg["content"] = "\n\n".join(text_parts) if text_parts else rendered
            # Flush any pending system text before this assistant message.
            if pending_system:
                processed.append(
                    {
                        "role": "user",
                        "content": "[System]\n" + "\n".join(pending_system),
                    }
                )
                pending_system = []
            processed.append(new_msg)
        elif role == "tool":
            rendered = _stringify_tool_response(msg)
            # Fold any pending system text into this rewritten user message.
            if pending_system:
                rendered = "[System]\n" + "\n".join(pending_system) + "\n\n" + rendered
                pending_system = []
            processed.append({"role": "user", "content": rendered})
        elif role == "user":
            new_msg = dict(msg)
            if pending_system:
                existing = _extract_text(new_msg.get("content"))
                new_msg["content"] = (
                    "[System]\n"
                    + "\n".join(pending_system)
                    + (("\n\n" + existing) if existing else "")
                )
                pending_system = []
            processed.append(new_msg)
        else:
            # Flush any pending system text before unhandled roles.
            if pending_system:
                processed.append(
                    {
                        "role": "user",
                        "content": "[System]\n" + "\n".join(pending_system),
                    }
                )
                pending_system = []
            processed.append(msg)
    # Flush any trailing system text as a user message.
    if pending_system:
        processed.append(
            {"role": "user", "content": "[System]\n" + "\n".join(pending_system)}
        )
    return processed


class ToolSupportProvider(AsyncGeneratorProvider):
    """Emulates OpenAI-style tool calling for providers that only expose web APIs.

    Injects a system prompt instructing the model to emit a JSON tool-call plan,
    delegates to the real provider, parses the JSON response and converts it into
    ``ToolCalls`` + ``FinishReason("tool_calls")`` chunks. ``Reasoning`` / thinking
    chunks emitted by the underlying provider are forwarded to the caller so the
    client still sees the model's reasoning.
    """

    working = True

    @staticmethod
    async def create_async_generator(
        model: str,
        messages: Messages,
        stream: bool = True,
        media: MediaListType = None,
        tools: list = None,
        tool_choice: Optional[Union[str, dict]] = None,
        response_format: dict = None,
        provider: Optional[Union[ProviderType, str]] = None,
        **kwargs,
    ) -> AsyncResult:
        if provider is None and ":" in model:
            provider, model = model.split(":", 1)
        model, provider = get_model_and_provider(
            model, provider, stream, logging=False, has_images=media is not None
        )
        tool_names: list[str] = []
        tool_schemas: dict[str, dict] = {}
        if tools:
            tool_defs = tools if isinstance(tools, list) else []
            for t in tool_defs:
                if not isinstance(t, dict) or t.get("type") != "function":
                    continue
                fn = t.get("function")
                if not isinstance(fn, dict):
                    continue
                name = fn.get("name")
                if not isinstance(name, str) or not name:
                    continue
                tool_names.append(name)
                params = fn.get("parameters")
                if isinstance(params, dict):
                    tool_schemas[name] = params

            if tool_names:
                # Only force JSON output when the caller hasn't requested a
                # specific format. Some web providers reject/ignore this field.
                if response_format is None:
                    response_format = {"type": "json"}

                lines = [
                    *getattr(provider, "tool_support_prompts", []),
                    "You have access to the following tools. When you decide a tool is needed, "
                    "respond with ONLY a valid JSON object (no markdown, no explanation) in this format:",
                    '{"tool_calls": [{"name": "TOOL_NAME", "arguments": {}}]}',
                    "You may include multiple tool calls in the array. The `arguments` value MUST be "
                    "a JSON object matching the tool's parameter schema.",
                    "If no tool is needed, respond normally with plain text. Don't try to call a tool, simply respond only with the JSON object.",
                    f"Available tools: {', '.join(tool_names)}",
                ]
                if tool_schemas:
                    lines.append(
                        "Tool parameter schemas (JSON Schema): "
                        f"{json.dumps(tool_schemas, ensure_ascii=True)}"
                    )
                if tool_choice is not None:
                    if tool_choice == "required":
                        lines.append(
                            "You MUST call at least one tool. Respond with the JSON tool-call object only."
                        )
                    elif tool_choice == "none":
                        lines.append(
                            "Do not call any tools. Respond with plain text only."
                        )
                    elif isinstance(tool_choice, dict):
                        fn = (
                            tool_choice.get("function")
                            if tool_choice.get("type") == "function"
                            else None
                        )
                        if isinstance(fn, dict) and fn.get("name"):
                            lines.append(f"You must call the tool `{fn['name']}`.")
                    else:
                        lines.append(f"Tool choice: {tool_choice}")
                messages = [{"role": "system", "content": "\n".join(lines)}] + messages

        # Rewrite any prior assistant tool_calls and tool-role response messages
        # into readable text so the underlying web provider can follow the
        # conversation history.
        messages = _preprocess_tool_messages(messages)

        # When no conversation handle is provided, web-API providers (e.g. Qwen,
        # Copilot) only forward the last user message to the server via
        # ``get_last_user_message``, dropping all prior context.  Merge the
        # whole history into a single user message so the model still sees the
        # full conversation.
        if kwargs.get("conversation") is None:
            messages = _merge_messages_to_single_user(messages)

        finish = None
        content_chunks: list[str] = []
        has_usage = False

        method = get_async_provider_method(provider)
        async for chunk in method(
            model=model,
            messages=messages,
            stream=stream,
            media=media,
            response_format=response_format,
            **kwargs,
        ):
            if isinstance(chunk, str):
                content_chunks.append(chunk)
            elif isinstance(chunk, Reasoning):
                # Forward thinking output to the client; do not mix it into
                # the content that will be parsed as a tool-call JSON.
                yield chunk
            elif isinstance(chunk, Usage):
                yield chunk
                has_usage = True
            elif isinstance(chunk, FinishReason):
                # Store the finish reason but keep consuming chunks so we don't
                # miss trailing ``Usage`` / ``JsonConversation`` chunks that
                # some providers (e.g. Qwen image-gen) emit *after* the finish.
                finish = chunk
            else:
                yield chunk

        if not has_usage:
            yield Usage(
                completion_tokens=len(content_chunks),
                total_tokens=len(content_chunks),
            )

        content = "".join(content_chunks)

        if tool_names:
            payload = filter_json(content)
            obj = _parse_json_maybe(payload)
            calls = None
            if isinstance(obj, dict) and isinstance(obj.get("tool_calls"), list):
                calls = obj.get("tool_calls")
            elif isinstance(obj, dict) and ("name" in obj or "tool" in obj):
                calls = [obj]
            elif isinstance(obj, list):
                calls = obj

            openai_calls = []
            if isinstance(calls, list):
                idx = -1
                for c in calls:
                    if not isinstance(c, dict):
                        continue
                    name = c.get("name") or c.get("tool")
                    if not isinstance(name, str) or not name or name not in tool_names:
                        continue
                    args = c.get("arguments")
                    if isinstance(args, str):
                        arguments_str = args
                    else:
                        try:
                            arguments_str = json.dumps(
                                args if isinstance(args, dict) else {},
                                ensure_ascii=True,
                            )
                        except Exception:
                            arguments_str = "{}"
                    idx += 1
                    openai_calls.append(
                        {
                            "index": idx,
                            "id": f"call_{idx}",
                            "type": "function",
                            "function": {"name": name, "arguments": arguments_str},
                        }
                    )

            # Fallback: the model may have echoed back the stringified
            # ``[Tool call: NAME] (id=ID)\nArguments: {JSON}`` format that
            # ``_stringify_tool_calls`` produces instead of emitting a raw
            # JSON tool-call object. Parse those too so the conversation can
            # continue with real tool execution.
            if not openai_calls:
                stringified = _parse_stringified_tool_calls(content)
                if stringified:
                    idx = -1
                    for c in stringified:
                        name = c.get("name")
                        if (
                            not isinstance(name, str)
                            or not name
                            or name not in tool_names
                        ):
                            continue
                        args = c.get("arguments")
                        if isinstance(args, str):
                            arguments_str = args
                        else:
                            try:
                                arguments_str = json.dumps(
                                    args if isinstance(args, dict) else {},
                                    ensure_ascii=True,
                                )
                            except Exception:
                                arguments_str = "{}"
                        idx += 1
                        openai_calls.append(
                            {
                                "index": idx,
                                "id": f"call_{idx}",
                                "type": "function",
                                "function": {"name": name, "arguments": arguments_str},
                            }
                        )

            if openai_calls:
                yield ToolCalls(openai_calls)
                yield FinishReason("tool_calls")
                return

        if content:
            yield content
        if finish is not None:
            yield finish