Ctrl K
ComunidadGuías…

src/llm/client.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""LLM 客户端 —— 通过 aiohttp 直连 OpenAI 兼容 API。"""

from __future__ import annotations

import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any

import aiohttp

from src.llm.openai_transport import OpenAIChatTransport
from src.llm.generation_options import apply_anthropic_options, validate_generation_options

logger = logging.getLogger("trpg")

MAX_RETRIES = 3          # 总共尝试次数(含首次)
BASE_DELAY = 2.0         # 基础重试间隔(秒)
LENGTH_RETRY_FACTOR = 2    # finish_reason=length 时重试放大 max_tokens 的倍数
LENGTH_RETRY_MAX_MULT = 4  # 最大放大到原始 max_tokens 的多少倍

_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)


def strip_reasoning_from_content(text: str) -> str:
    """删除 OpenAI 兼容代理混入正文的 reasoning 思考块,返回最终正文。

    处理完整 ``<think>…</think>``(含多个块)、孤立 ``</think>`` 以及未闭合的
    ``<think>`` 尾部;正常正文原样保留。清洗后没有正文时抛 ValueError,由
    call()/call_stream() 的既有失败重试逻辑接手,确保思考内容不会作为正文
    保存进 GM log 或展示给玩家。
    """
    cleaned = _THINK_BLOCK_RE.sub("", str(text or ""))
    cleaned = cleaned.replace("</think>", "")
    cleaned = cleaned.split("<think>", 1)[0].strip()
    if not cleaned:
        raise ValueError("模型未返回最终正文")
    return cleaned


def _disable_thinking_for_deepseek_v4(provider: "ProviderConfig") -> bool:
    """DiceFrame 对官方 DeepSeek V4 使用非思考模式。

    DeepSeek V4 默认开启思考模式;当前客户端不消费 ``reasoning_content``,
    该模式还不接受强制 ``tool_choice``,会让普通正文和严格 JSON 的输出预算
    被推理耗尽。仅针对官方端点和 V4 模型显式关闭,避免向其他 OpenAI 兼容
    供应商发送私有参数。
    """
    return (
        provider.model_name.strip().lower().startswith("deepseek-v4")
        and "api.deepseek.com" in provider.base_url.strip().lower()
    )


def length_retry_budgets(base_max_tokens: int) -> tuple[int, ...]:
    """返回输出截断时共用的 token 预算序列。"""
    if base_max_tokens <= 0:
        raise ValueError("base_max_tokens 必须大于 0")

    budgets = [base_max_tokens]
    limit = base_max_tokens * LENGTH_RETRY_MAX_MULT
    while len(budgets) < MAX_RETRIES:
        bumped = min(budgets[-1] * LENGTH_RETRY_FACTOR, limit)
        if bumped <= budgets[-1]:
            break
        budgets.append(bumped)
    return tuple(budgets)


# 按 HTTP 状态码的退避策略
_RETRY_BACKOFF: dict[int, float] = {
    429: 5.0,     # Rate Limit → 较长等待
    503: 3.0,     # Service Unavailable → 中等等待
    502: 2.0,     # Bad Gateway
    504: 2.0,     # Gateway Timeout
}

_RETRYABLE_STATUSES = frozenset(_RETRY_BACKOFF) | {408}  # 408 Timeout 也可重试


class OutputTruncatedError(ValueError):
    """模型输出被 max_tokens 截断(finish_reason=length / stop_reason=max_tokens)。

    即使已经返回部分正文也不能当作完整结果;call() 据此在重试时自动提高
    max_tokens,而不是保存缺少结尾或结构化状态的半截回复。
    """

    def __init__(self, finish_reason: str = "length"):
        self.finish_reason = finish_reason
        super().__init__(f"模型未返回最终正文 (finish_reason={finish_reason})")


@dataclass
class ProviderConfig:
    """模型供应配置。"""
    provider_name: str
    base_url: str
    api_key: str
    model_name: str
    api_format: str = "openai"
    fallback: bool = False


@dataclass
class LLMResponse:
    """LLM 调用结果。"""
    content: str                # 完整响应文本
    narration: str              # 叙事文本(JSON 块之前的部分)
    state_update: dict | None   # 解析出的状态更新
    memory_delta: dict | None   # 解析出的记忆变更
    info_asymmetry: dict | None  # 解析出的信息不对称
    plot_update: dict | None    # 解析出的剧情推进
    total_tokens: int           # 实际消耗的 token 数
    is_narration_only: bool     # JSON 解析失败,仅叙事
    provider_used: str          # 实际使用的供应商名称
    token_budget_initial: int = 0  # 本次调用最初配置的最大输出 token
    token_budget_used: int = 0     # 成功响应实际使用的最大输出 token 档位
    system_notices: list[str] = field(default_factory=list)  # 不属于 GM 正文的系统提示
    story: dict[str, Any] | None = None  # Validated public presentation, independent of rule effects.


@dataclass
class LLMToolResponse:
    """一次工具规划调用的结构化结果。"""

    tool_calls: list[dict[str, Any]]
    total_tokens: int
    provider_used: str
    native_tools: bool = True
    content: str = ""


class LLMToolCallError(RuntimeError):
    """Provider failure with HTTP facts preserved for application feedback."""

    def __init__(self, message: str, status_codes: frozenset[int]):
        super().__init__(message)
        self.status_codes = status_codes


class LLMClient:
    """OpenAI 兼容 API 的异步 HTTP 客户端。

    支持多供应商配置、自动重试、失败降级到备用模型。
    """

    def __init__(
        self,
        providers: list[ProviderConfig],
        default: str,
        proxy_url: str = "",
        request_timeout_seconds: float = 120,
        generation_options: dict[str, Any] | None = None,
    ):
        if not providers:
            raise ValueError("至少需要配置一个模型供应商")
        if not 10 <= float(request_timeout_seconds) <= 600:
            raise ValueError("模型请求超时必须在 10–600 秒之间")
        self.providers = {p.provider_name: p for p in providers}
        self.default = default if default in self.providers else providers[0].provider_name
        self.proxy_url = proxy_url.strip()
        self.request_timeout_seconds = float(request_timeout_seconds)
        self.request_timeout = aiohttp.ClientTimeout(total=self.request_timeout_seconds)
        self._session: aiohttp.ClientSession | None = None
        self._native_tool_unsupported: set[str] = set()
        # 强制 tool_choice 被拒但 tool_choice=auto 可用的供应商(如思考模式模型)
        self._native_tool_auto: set[str] = set()
        self.generation_options = validate_generation_options({} if generation_options is None else generation_options)
        self._openai_transport = OpenAIChatTransport(self.generation_options)

    def _anthropic_body(self, body: dict[str, Any]) -> dict[str, Any]:
        apply_anthropic_options(body, self.generation_options)
        return body

    async def _get_session(self) -> aiohttp.ClientSession:
        """获取或懒创建复用的 HTTP session。"""
        if self._session is None or self._session.closed:
            self._session = aiohttp.ClientSession()
        return self._session

    async def close(self) -> None:
        if self._session and not self._session.closed:
            await self._session.close()
            self._session = None

    async def call_tools(
        self,
        system_prompt: str,
        user_message: str,
        *,
        tools: list[dict[str, Any]],
        max_tokens: int = 4096,
        temperature: float = 0.1,
        force_tool_choice: bool = True,
    ) -> LLMToolResponse:
        """调用模型工具;不支持原生 tools 的供应商自动回退严格 JSON。

        回退仍只产生工具参数,绝不让模型产生或指定骰面。所有调用失败时抛出,
        由上层决定是否进入离线兼容路径。

        思考模式模型常拒绝强制 tool_choice 但接受 tool_choice=auto:此类供应商
        先用 auto 重试原生调用,仍失败才走 JSON 回退;JSON 回退截断时按
        length_retry_budgets 放大 max_tokens 重试。显式 ``force_tool_choice=False``
        时始终请求原生 auto;该选择本身不会把供应商记为不支持强制调用。
        """
        primary = self.providers[self.default]
        ordered = [primary] + [
            provider for provider in self.providers.values()
            if provider.fallback and provider.provider_name != primary.provider_name
        ]
        errors: list[str] = []
        status_codes: set[int] = set()
        for provider in ordered:
            forced = force_tool_choice and provider.provider_name not in self._native_tool_unsupported
            if not force_tool_choice or forced or provider.provider_name in self._native_tool_auto:
                try:
                    native_result = await self._call_tools_one(
                        provider,
                        system_prompt,
                        user_message,
                        tools,
                        temperature,
                        max_tokens,
                        force_tool_choice=forced,
                    )
                    if force_tool_choice and not native_result.tool_calls:
                        raise ValueError("供应商未返回必需的工具调用")
                    return native_result
                except Exception as exc:
                    if isinstance(exc, aiohttp.ClientResponseError):
                        status_codes.add(exc.status)
                    errors.append(f"{provider.provider_name}: {exc}")
                    if forced and "does not support this tool_choice" in str(exc):
                        self._native_tool_unsupported.add(provider.provider_name)
                        try:
                            result = await self._call_tools_one(
                                provider,
                                system_prompt,
                                user_message,
                                tools,
                                temperature,
                                max_tokens,
                                force_tool_choice=False,
                            )
                            if not result.tool_calls:
                                raise ValueError("供应商未返回必需的工具调用")
                            self._native_tool_auto.add(provider.provider_name)
                            return result
                        except Exception as auto_exc:
                            if isinstance(auto_exc, aiohttp.ClientResponseError):
                                status_codes.add(auto_exc.status)
                            errors.append(f"{provider.provider_name} (tool_choice=auto): {auto_exc}")
                    logger.warning("模型原生工具调用失败,尝试 JSON 回退 (%s): %s", provider.provider_name, exc)
            try:
                tool_names = [
                    str(tool.get("function", {}).get("name") or "tool")
                    for tool in tools
                ]
                if force_tool_choice:
                    tool_instruction = (
                        f"name={tool_names[0]} and arguments matching that tool's schema."
                    )
                else:
                    tool_instruction = (
                        "name matching one of the available tools "
                        f"({', '.join(tool_names)}) and arguments matching that tool's schema."
                    )
                fallback_prompt = (
                    system_prompt
                    + "\n\nNative function calling is unavailable. Adjudicate the supplied actions first, "
                    + "then return only one JSON object with a tool_calls array. Each item must contain "
                    + tool_instruction + " "
                    + "Never copy an empty structural example when the supplied context supports a concrete result. "
                    + "Required tool schema: "
                    + json.dumps(tools, ensure_ascii=False, separators=(",", ":"))
                )
                budgets = length_retry_budgets(max_tokens)
                response = None
                for attempt, budget in enumerate(budgets, 1):
                    try:
                        response = await self._call_one(
                            provider,
                            fallback_prompt,
                            user_message,
                            temperature,
                            budget,
                            True,
                        )
                        break
                    except OutputTruncatedError:
                        if attempt >= len(budgets):
                            raise
                        logger.warning(
                            "JSON 回退输出截断,提高 max_tokens 重试 (%s): %d -> %d",
                            provider.provider_name, budget, budgets[attempt],
                        )
                        await asyncio.sleep(BASE_DELAY * 0.5)
                calls = _parse_json_tool_calls(response.content, allow_legacy=force_tool_choice)
                if not force_tool_choice:
                    calls = _validate_tool_calls(calls, tools)
                if not calls:
                    raise ValueError("模型未返回必需的工具调用")
                return LLMToolResponse(
                    tool_calls=calls,
                    total_tokens=response.total_tokens,
                    provider_used=provider.provider_name,
                    native_tools=False,
                )
            except Exception as exc:
                if isinstance(exc, aiohttp.ClientResponseError):
                    status_codes.add(exc.status)
                errors.append(f"{provider.provider_name} JSON fallback: {exc}")
                logger.warning("模型 JSON 工具回退失败 (%s): %s", provider.provider_name, exc)
        raise LLMToolCallError("所有模型工具调用均失败: " + "; ".join(errors), frozenset(status_codes))

    async def _call_tools_one(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        tools: list[dict[str, Any]],
        temperature: float,
        max_tokens: int,
        force_tool_choice: bool = True,
    ) -> LLMToolResponse:
        if (provider.api_format or "openai").strip().lower() == "anthropic":
            return await self._call_tools_anthropic(
                provider, system_prompt, user_message, tools, temperature, max_tokens,
                force_tool_choice=force_tool_choice,
            )
        return await self._call_tools_openai(
            provider, system_prompt, user_message, tools, temperature, max_tokens,
            force_tool_choice=force_tool_choice,
        )

    async def _call_tools_openai(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        tools: list[dict[str, Any]],
        temperature: float,
        max_tokens: int,
        force_tool_choice: bool = True,
    ) -> LLMToolResponse:
        url = provider.base_url.rstrip("/")
        if not url.endswith("/chat/completions"):
            url += "/chat/completions"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {provider.api_key}",
        }
        body: dict[str, Any] = {
            "model": provider.model_name,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_message},
            ],
            "temperature": temperature,
            "max_tokens": max_tokens,
            "tools": tools,
        }
        if _disable_thinking_for_deepseek_v4(provider):
            body["thinking"] = {"type": "disabled"}
        if force_tool_choice:
            body["tool_choice"] = {
                "type": "function",
                "function": {"name": str(tools[0].get("function", {}).get("name") or "")},
            }
        else:
            body["tool_choice"] = "auto"
            body["parallel_tool_calls"] = False
        session = await self._get_session()
        request_kwargs = {"proxy": self.proxy_url} if self.proxy_url else {}
        async with self._openai_transport.post(
            session, url,
            json=body,
            headers=headers,
            timeout=self.request_timeout,
            **request_kwargs,
        ) as resp:
            if resp.status != 200:
                error_text = await resp.text()
                raise aiohttp.ClientResponseError(
                    resp.request_info,
                    resp.history,
                    status=resp.status,
                    message=error_text[:300],
                    headers=resp.headers,
                )
            data = await resp.json()
        message = data.get("choices", [{}])[0].get("message", {})
        calls = _normalize_openai_tool_calls(message.get("tool_calls") or [])
        content = _openai_text_content(message.get("content"))
        if not calls and content:
            if not force_tool_choice:
                explicit_calls = _parse_explicit_json_tool_calls(content)
                if explicit_calls is not None:
                    return LLMToolResponse(
                        tool_calls=explicit_calls,
                        total_tokens=int(data.get("usage", {}).get("total_tokens", 0) or 0),
                        provider_used=provider.provider_name,
                        native_tools=True,
                        content=content,
                    )
                return LLMToolResponse(
                    tool_calls=[],
                    total_tokens=int(data.get("usage", {}).get("total_tokens", 0) or 0),
                    provider_used=provider.provider_name,
                    native_tools=True,
                    content=content,
                )
            calls = _parse_json_tool_calls(content)
        if not calls:
            raise ValueError("供应商未返回强制工具调用")
        return LLMToolResponse(
            tool_calls=calls,
            total_tokens=int(data.get("usage", {}).get("total_tokens", 0) or 0),
            provider_used=provider.provider_name,
            native_tools=True,
            content=content,
        )

    async def _call_tools_anthropic(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        tools: list[dict[str, Any]],
        temperature: float,
        max_tokens: int,
        force_tool_choice: bool = True,
    ) -> LLMToolResponse:
        headers = {
            "Content-Type": "application/json",
            "x-api-key": provider.api_key,
            "anthropic-version": "2023-06-01",
        }
        anthropic_tools = []
        for tool in tools:
            function = tool.get("function", {})
            anthropic_tools.append({
                "name": function.get("name", ""),
                "description": function.get("description", ""),
                "input_schema": function.get("parameters", {"type": "object"}),
            })
        body = {
            "model": provider.model_name,
            "system": system_prompt,
            "messages": [{"role": "user", "content": user_message}],
            "temperature": temperature,
            "max_tokens": max_tokens,
            "tools": anthropic_tools,
        }
        if force_tool_choice:
            body["tool_choice"] = {
                "type": "tool",
                "name": str(anthropic_tools[0].get("name") or ""),
            }
        else:
            body["tool_choice"] = {"type": "auto"}
        session = await self._get_session()
        request_kwargs = {"proxy": self.proxy_url} if self.proxy_url else {}
        async with session.post(
            _anthropic_messages_url(provider.base_url),
            json=self._anthropic_body(body),
            headers=headers,
            timeout=self.request_timeout,
            **request_kwargs,
        ) as resp:
            if resp.status != 200:
                error_text = await resp.text()
                raise aiohttp.ClientResponseError(
                    resp.request_info,
                    resp.history,
                    status=resp.status,
                    message=error_text[:300],
                    headers=resp.headers,
                )
            data = await resp.json()
        calls = [
            {"name": str(block.get("name") or ""), "arguments": block.get("input") or {}}
            for block in data.get("content", [])
            if isinstance(block, dict) and block.get("type") == "tool_use"
        ]
        content = _anthropic_text_content(data)
        if not calls and content and not force_tool_choice:
            return LLMToolResponse(
                tool_calls=[],
                total_tokens=(
                    int(data.get("usage", {}).get("input_tokens", 0) or 0)
                    + int(data.get("usage", {}).get("output_tokens", 0) or 0)
                ),
                provider_used=provider.provider_name,
                native_tools=True,
                content=content,
            )
        if not calls:
            raise ValueError("供应商未返回强制工具调用")
        usage = data.get("usage", {})
        total_tokens = int(usage.get("input_tokens", 0) or 0) + int(usage.get("output_tokens", 0) or 0)
        return LLMToolResponse(calls, total_tokens, provider.provider_name, True, content)

    async def call(
        self,
        system_prompt: str,
        user_message: str,
        *,
        temperature: float = 0.7,
        max_tokens: int = 4096,
        force_provider: str | None = None,
        json_mode: bool = False,
    ) -> LLMResponse:
        """调用 LLM,失败时自动降级到 fallback 模型。

        Args:
            system_prompt: GM 系统提示词
            user_message: 拼接好的上下文
            temperature: 生成温度
            max_tokens: 最大输出 token 数
            force_provider: 强制使用指定供应商(跳过低负载均衡)
            json_mode: 启用 JSON 模式(DeepSeek/OpenAI 兼容的 structured output)

        Returns:
            LLMResponse

        Raises:
            RuntimeError: 所有供应商均失败
        """
        primary = self.providers[force_provider or self.default]
        ordered = [primary] + [
            p for p in self.providers.values()
            if p.fallback and p.provider_name != primary.provider_name
        ]

        last_error = None
        last_backoff = BASE_DELAY
        retry_budgets = length_retry_budgets(max_tokens)
        current_max_tokens = retry_budgets[0]
        for attempt_num in range(1, MAX_RETRIES + 1):
            for provider in ordered:
                try:
                    response = await self._call_one(
                        provider, system_prompt, user_message,
                        temperature, current_max_tokens, json_mode,
                    )
                    response.token_budget_initial = max_tokens
                    response.token_budget_used = current_max_tokens
                    return response
                except OutputTruncatedError as exc:
                    last_error = exc
                    logger.warning(
                        "LLM 输出被 max_tokens 截断 (attempt=%d, provider=%s, max_tokens=%d): %s",
                        attempt_num, provider.provider_name, current_max_tokens, exc,
                    )
                    continue
                except aiohttp.ClientResponseError as exc:
                    last_error = exc
                    status = exc.status
                    if status in _RETRYABLE_STATUSES:
                        last_backoff = _RETRY_BACKOFF.get(status, BASE_DELAY) * attempt_num
                        logger.warning(
                            "LLM HTTP %d (attempt=%d, provider=%s): %s, %0.1fs后重试",
                            status, attempt_num, provider.provider_name, exc, last_backoff,
                        )
                    else:
                        logger.warning(
                            "LLM HTTP %d (attempt=%d, provider=%s): %s (不可重试,跳过该供应商)",
                            status, attempt_num, provider.provider_name, exc,
                        )
                        continue
                except asyncio.TimeoutError as exc:
                    last_error = exc
                    last_backoff = BASE_DELAY * attempt_num * 0.5
                    logger.warning(
                        "LLM 超时 (attempt=%d, provider=%s): %0.1fs后重试",
                        attempt_num, provider.provider_name, last_backoff,
                    )
                except Exception as exc:
                    last_error = exc
                    logger.warning(
                        "LLM 调用失败 (attempt=%d, provider=%s): %s",
                        attempt_num, provider.provider_name, exc,
                    )
                    continue
            if attempt_num < MAX_RETRIES:
                if isinstance(last_error, OutputTruncatedError):
                    bumped = retry_budgets[
                        min(attempt_num, len(retry_budgets) - 1)
                    ]
                    if bumped > current_max_tokens:
                        logger.info(
                            "输出截断,下次重试提高 max_tokens: %d -> %d",
                            current_max_tokens, bumped,
                        )
                        current_max_tokens = bumped
                    await asyncio.sleep(BASE_DELAY * 0.5)
                else:
                    await asyncio.sleep(last_backoff)

        raise RuntimeError(f"所有模型供应商均调用失败: {last_error}") from last_error

    async def call_stream(
        self,
        system_prompt: str,
        user_message: str,
        *,
        temperature: float = 0.7,
        max_tokens: int = 4096,
        force_provider: str | None = None,
        json_mode: bool = False,
        on_delta=None,
    ) -> LLMResponse:
        """流式调用 LLM,逐段文本通过 on_delta 回调推送,返回与 call() 相同的 LLMResponse。

        on_delta 为可选的 async callable,签名为 ``async def on_delta(text: str) -> None``;
        每收到一段正文就调用一次,供上层做实时打字机展示。返回值与 call() 一致,
        内部累积完整正文后走同一个 _to_response 解析,保证流式/非流式结果同构。

        供应商在开始流式前失败(连接/HTTP 错误/超时)时按 fallback 顺序与退避重试,
        行为与 call() 一致;流式过程中途失败则抛出,由调用方决定是否重试。
        finish_reason=length 时即使已有部分正文也抛 OutputTruncatedError,不在内部
        放大预算,由调用方提高 max_tokens 后重新调用(与 call() 的截断处理对齐)。
        """
        primary = self.providers[force_provider or self.default]
        ordered = [primary] + [
            p for p in self.providers.values()
            if p.fallback and p.provider_name != primary.provider_name
        ]

        last_error = None
        last_backoff = BASE_DELAY
        for attempt_num in range(1, MAX_RETRIES + 1):
            for provider in ordered:
                try:
                    content, total_tokens, _finish_reason = await self._stream_one(
                        provider, system_prompt, user_message,
                        temperature, max_tokens, json_mode, on_delta,
                    )
                    response = self._to_response(content, total_tokens, provider.provider_name)
                    response.token_budget_initial = max_tokens
                    response.token_budget_used = max_tokens
                    return response
                except OutputTruncatedError:
                    raise
                except aiohttp.ClientResponseError as exc:
                    last_error = exc
                    status = exc.status
                    if status in _RETRYABLE_STATUSES:
                        last_backoff = _RETRY_BACKOFF.get(status, BASE_DELAY) * attempt_num
                        logger.warning(
                            "LLM 流式 HTTP %d (attempt=%d, provider=%s): %s, %0.1fs后重试",
                            status, attempt_num, provider.provider_name, exc, last_backoff,
                        )
                    else:
                        logger.warning(
                            "LLM 流式 HTTP %d (attempt=%d, provider=%s): %s (不可重试,跳过该供应商)",
                            status, attempt_num, provider.provider_name, exc,
                        )
                        continue
                except asyncio.TimeoutError as exc:
                    last_error = exc
                    last_backoff = BASE_DELAY * attempt_num * 0.5
                    logger.warning(
                        "LLM 流式超时 (attempt=%d, provider=%s): %0.1fs后重试",
                        attempt_num, provider.provider_name, last_backoff,
                    )
                except Exception as exc:
                    last_error = exc
                    logger.warning(
                        "LLM 流式调用失败 (attempt=%d, provider=%s): %s",
                        attempt_num, provider.provider_name, exc,
                    )
                    continue
            if attempt_num < MAX_RETRIES:
                await asyncio.sleep(last_backoff)

        raise RuntimeError(f"所有模型供应商均流式调用失败: {last_error}") from last_error

    async def _call_one(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        temperature: float,
        max_tokens: int,
        json_mode: bool = False,
    ) -> LLMResponse:
        api_format = (provider.api_format or "openai").strip().lower()
        if api_format == "anthropic":
            return await self._call_anthropic(
                provider, system_prompt, user_message,
                temperature, max_tokens, json_mode,
            )
        return await self._call_openai_compatible(
            provider, system_prompt, user_message,
            temperature, max_tokens, json_mode,
        )

    async def _call_openai_compatible(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        temperature: float,
        max_tokens: int,
        json_mode: bool = False,
    ) -> LLMResponse:
        url = provider.base_url.rstrip("/")
        if not url.endswith("/chat/completions"):
            url += "/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {provider.api_key}",
        }
        body = {
            "model": provider.model_name,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_message},
            ],
            "temperature": temperature,
            "max_tokens": max_tokens,
        }
        if _disable_thinking_for_deepseek_v4(provider):
            body["thinking"] = {"type": "disabled"}
        # JSON 模式:DeepSeek/OpenAI 兼容的 structured output
        if json_mode:
            body["response_format"] = {"type": "json_object"}

        session = await self._get_session()
        request_kwargs = {"proxy": self.proxy_url} if self.proxy_url else {}
        async with self._openai_transport.post(session, url, json=body, headers=headers,
                                timeout=self.request_timeout,
                                **request_kwargs) as resp:
            if resp.status != 200:
                error_text = await resp.text()
                raise aiohttp.ClientResponseError(
                    resp.request_info, resp.history,
                    status=resp.status, message=error_text[:300],
                    headers=resp.headers,
                )
            data = await resp.json()

        choice = data["choices"][0]
        finish_reason = str(choice.get("finish_reason") or "unknown")
        if finish_reason == "length":
            raise OutputTruncatedError(finish_reason)
        # 部分代理会把 <think>…</think> 思考混进 content,先清洗再判空
        content = strip_reasoning_from_content(choice["message"].get("content") or "")
        total_tokens = data.get("usage", {}).get("total_tokens", 0)
        return self._to_response(content, total_tokens, provider.provider_name)

    async def _call_anthropic(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        temperature: float,
        max_tokens: int,
        json_mode: bool = False,
    ) -> LLMResponse:
        url = _anthropic_messages_url(provider.base_url)
        system = system_prompt
        if json_mode:
            system = f"{system_prompt}\n\nReturn only valid JSON. Do not wrap it in Markdown."

        headers = {
            "Content-Type": "application/json",
            "x-api-key": provider.api_key,
            "anthropic-version": "2023-06-01",
        }
        body = {
            "model": provider.model_name,
            "system": system,
            "messages": [
                {"role": "user", "content": user_message},
            ],
            "max_tokens": max_tokens,
        }

        session = await self._get_session()
        request_kwargs = {"proxy": self.proxy_url} if self.proxy_url else {}
        async with session.post(url, json=self._anthropic_body(body), headers=headers,
                                timeout=self.request_timeout,
                                **request_kwargs) as resp:
            if resp.status != 200:
                error_text = await resp.text()
                raise aiohttp.ClientResponseError(
                    resp.request_info, resp.history,
                    status=resp.status, message=error_text[:300],
                    headers=resp.headers,
                )
            data = await resp.json()

        content = _anthropic_text_content(data)
        if data.get("stop_reason") == "max_tokens":
            raise OutputTruncatedError("max_tokens")
        if not content.strip():
            raise ValueError(
                f"模型未返回最终正文 (stop_reason={data.get('stop_reason') or 'unknown'})"
            )
        usage = data.get("usage", {})
        total_tokens = int(usage.get("input_tokens", 0) or 0) + int(usage.get("output_tokens", 0) or 0)
        return self._to_response(content, total_tokens, provider.provider_name)

    async def _stream_one(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        temperature: float,
        max_tokens: int,
        json_mode: bool,
        on_delta=None,
    ) -> tuple[str, int, str]:
        api_format = (provider.api_format or "openai").strip().lower()
        if api_format == "anthropic":
            return await self._stream_anthropic(
                provider, system_prompt, user_message,
                temperature, max_tokens, json_mode, on_delta,
            )
        return await self._stream_openai_compatible(
            provider, system_prompt, user_message,
            temperature, max_tokens, json_mode, on_delta,
        )

    async def _stream_openai_compatible(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        temperature: float,
        max_tokens: int,
        json_mode: bool = False,
        on_delta=None,
    ) -> tuple[str, int, str]:
        url = provider.base_url.rstrip("/")
        if not url.endswith("/chat/completions"):
            url += "/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {provider.api_key}",
        }
        body = {
            "model": provider.model_name,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_message},
            ],
            "temperature": temperature,
            "max_tokens": max_tokens,
            "stream": True,
            "stream_options": {"include_usage": True},
        }
        if _disable_thinking_for_deepseek_v4(provider):
            body["thinking"] = {"type": "disabled"}
        # JSON 模式:DeepSeek/OpenAI 兼容的 structured output
        if json_mode:
            body["response_format"] = {"type": "json_object"}

        session = await self._get_session()
        request_kwargs = {"proxy": self.proxy_url} if self.proxy_url else {}
        content_parts: list[str] = []
        finish_reason = "stop"
        total_tokens = 0
        async with self._openai_transport.post(session, url, json=body, headers=headers,
                                timeout=self.request_timeout,
                                **request_kwargs) as resp:
            if resp.status != 200:
                error_text = await resp.text()
                raise aiohttp.ClientResponseError(
                    resp.request_info, resp.history,
                    status=resp.status, message=error_text[:300],
                    headers=resp.headers,
                )
            async for raw_line in resp.content:
                line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
                if not line.startswith("data:"):
                    continue
                payload = line[len("data:"):].strip()
                if not payload:
                    continue
                if payload == "[DONE]":
                    break
                try:
                    chunk = json.loads(payload)
                except json.JSONDecodeError:
                    continue
                choices = chunk.get("choices") or []
                if choices:
                    choice = choices[0]
                    delta = choice.get("delta") or {}
                    text = delta.get("content") or ""
                    if text:
                        content_parts.append(text)
                        if on_delta:
                            await on_delta(text)
                    if choice.get("finish_reason"):
                        finish_reason = choice["finish_reason"]
                usage = chunk.get("usage")
                if isinstance(usage, dict):
                    total_tokens = int(usage.get("total_tokens", 0) or 0)

        content = "".join(content_parts)
        if finish_reason == "length":
            raise OutputTruncatedError(finish_reason)
        # on_delta 已按原始增量转发(玩家可见内容由上层 _NarrationDeltaFilter 过滤);
        # 累积正文同样清洗,保证 response.content / GM log 不含思考内容
        content = strip_reasoning_from_content(content)
        return content, total_tokens, finish_reason

    async def _stream_anthropic(
        self,
        provider: ProviderConfig,
        system_prompt: str,
        user_message: str,
        temperature: float,
        max_tokens: int,
        json_mode: bool = False,
        on_delta=None,
    ) -> tuple[str, int, str]:
        url = _anthropic_messages_url(provider.base_url)
        system = system_prompt
        if json_mode:
            system = f"{system_prompt}\n\nReturn only valid JSON. Do not wrap it in Markdown."

        headers = {
            "Content-Type": "application/json",
            "x-api-key": provider.api_key,
            "anthropic-version": "2023-06-01",
        }
        body = {
            "model": provider.model_name,
            "system": system,
            "messages": [
                {"role": "user", "content": user_message},
            ],
            "max_tokens": max_tokens,
            "stream": True,
        }

        session = await self._get_session()
        request_kwargs = {"proxy": self.proxy_url} if self.proxy_url else {}
        content_parts: list[str] = []
        stop_reason = "end_turn"
        input_tokens = 0
        output_tokens = 0
        async with session.post(url, json=self._anthropic_body(body), headers=headers,
                                timeout=self.request_timeout,
                                **request_kwargs) as resp:
            if resp.status != 200:
                error_text = await resp.text()
                raise aiohttp.ClientResponseError(
                    resp.request_info, resp.history,
                    status=resp.status, message=error_text[:300],
                    headers=resp.headers,
                )
            async for raw_line in resp.content:
                line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
                if not line.startswith("data:"):
                    continue
                payload = line[len("data:"):].strip()
                if not payload:
                    continue
                try:
                    chunk = json.loads(payload)
                except json.JSONDecodeError:
                    continue
                etype = chunk.get("type")
                if etype == "message_start":
                    message = chunk.get("message") or {}
                    usage = message.get("usage") or {}
                    input_tokens = int(usage.get("input_tokens", 0) or 0)
                elif etype == "content_block_delta":
                    delta = chunk.get("delta") or {}
                    if delta.get("type") == "text_delta":
                        text = delta.get("text") or ""
                        if text:
                            content_parts.append(text)
                            if on_delta:
                                await on_delta(text)
                elif etype == "message_delta":
                    delta = chunk.get("delta") or {}
                    if delta.get("stop_reason"):
                        stop_reason = delta["stop_reason"]
                    usage = chunk.get("usage") or {}
                    output_tokens = int(usage.get("output_tokens", 0) or 0)

        content = "".join(content_parts)
        if stop_reason == "max_tokens":
            raise OutputTruncatedError("max_tokens")
        if not content.strip():
            raise ValueError(f"模型未返回最终正文 (stop_reason={stop_reason})")
        total_tokens = input_tokens + output_tokens
        return content, total_tokens, stop_reason

    @staticmethod
    def _to_response(content: str, total_tokens: int, provider_used: str) -> LLMResponse:

        # 解析输出
        from .parser import parse_llm_response
        result = parse_llm_response(content)

        return LLMResponse(
            content=content,
            narration=result.narration,
            state_update=result.state_update,
            memory_delta=result.memory_delta,
            info_asymmetry=result.info_asymmetry,
            plot_update=result.plot_update,
            total_tokens=total_tokens,
            is_narration_only=result.is_narration_only,
            provider_used=provider_used,
        )


def _anthropic_messages_url(base_url: str) -> str:
    url = (base_url or "https://api.anthropic.com").strip().rstrip("/")
    if url.endswith("/v1/messages"):
        return url
    if url.endswith("/v1"):
        return f"{url}/messages"
    return f"{url}/v1/messages"


def _anthropic_text_content(data: dict) -> str:
    parts: list[str] = []
    for block in data.get("content", []):
        if isinstance(block, dict) and block.get("type") == "text":
            parts.append(str(block.get("text") or ""))
    return "\n".join(part for part in parts if part).strip()


def _openai_text_content(value: Any) -> str:
    """Extract text from string or content-part OpenAI message payloads."""
    if isinstance(value, str):
        return value.strip()
    if not isinstance(value, list):
        return ""
    parts: list[str] = []
    for block in value:
        if isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
            text = str(block.get("text") or "").strip()
            if text:
                parts.append(text)
    return "\n".join(parts).strip()


def _tool_arguments(value: Any) -> dict[str, Any]:
    if isinstance(value, dict):
        return value
    if isinstance(value, str):
        parsed = json.loads(value)
        if isinstance(parsed, dict):
            return parsed
    raise ValueError("工具 arguments 必须是 JSON object")


def _normalize_openai_tool_calls(raw_calls: list[Any]) -> list[dict[str, Any]]:
    calls: list[dict[str, Any]] = []
    for raw in raw_calls:
        if not isinstance(raw, dict):
            continue
        function = raw.get("function") if isinstance(raw.get("function"), dict) else raw
        name = str(function.get("name") or "").strip()
        if not name:
            continue
        calls.append({"name": name, "arguments": _tool_arguments(function.get("arguments") or {})})
    return calls


def _parse_json_tool_calls(content: str, *, allow_legacy: bool = True) -> list[dict[str, Any]]:
    text = str(content or "").strip()
    if text.startswith("```"):
        text = text.split("\n", 1)[-1]
        text = text.rsplit("```", 1)[0].strip()
    payload = json.loads(text)
    raw_calls = payload.get("tool_calls") if isinstance(payload, dict) else None
    if allow_legacy and raw_calls is None and isinstance(payload, dict) and "checks" in payload:
        raw_calls = [{"name": "dice_checks", "arguments": payload}]
    if not isinstance(raw_calls, list):
        raise ValueError("JSON 回退缺少 tool_calls")
    return _normalize_openai_tool_calls(raw_calls)


def _parse_explicit_json_tool_calls(content: str) -> list[dict[str, Any]] | None:
    """Parse only JSON content with an explicit top-level ``tool_calls`` field."""
    text = str(content or "").strip()
    if text.startswith("```"):
        text = text.split("\n", 1)[-1]
        text = text.rsplit("```", 1)[0].strip()
    try:
        payload = json.loads(text)
    except (TypeError, json.JSONDecodeError):
        return None
    if not isinstance(payload, dict) or "tool_calls" not in payload:
        return None
    raw_calls = payload.get("tool_calls")
    if not isinstance(raw_calls, list):
        raise ValueError("JSON 工具调用的 tool_calls 必须是 array")
    calls = _normalize_openai_tool_calls(raw_calls)
    if not calls:
        raise ValueError("JSON 工具调用缺少有效调用")
    return calls


def _validate_tool_calls(
    calls: list[dict[str, Any]],
    tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """Validate fallback calls against the registered tool names and required fields."""
    schemas: dict[str, dict[str, Any]] = {}
    for tool in tools:
        function = tool.get("function") if isinstance(tool, dict) else None
        if not isinstance(function, dict):
            continue
        name = str(function.get("name") or "").strip()
        if name:
            schema = function.get("parameters")
            schemas[name] = schema if isinstance(schema, dict) else {}
    if not calls:
        raise ValueError("JSON 回退缺少 tool_calls")
    for call in calls:
        name = str(call.get("name") or "").strip()
        if name not in schemas:
            raise ValueError(f"JSON 回退调用了未注册工具: {name or '<empty>'}")
        arguments = call.get("arguments")
        if not isinstance(arguments, dict):
            raise ValueError(f"JSON 回退工具参数不是 object: {name}")
    return calls