Ctrl K
ComunidadGuías…

rex/llm_client.py

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

Archivo original ↗Preguntar a KiBot
"""Language model client with pluggable backends (Transformers, OpenAI, Echo)."""

from __future__ import annotations

import contextvars
import json
import sys
import threading
import types as _types
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from importlib import import_module
from importlib.util import find_spec
from typing import Any, Protocol, cast

from rex.assistant_errors import ConfigurationError
from rex.config import OPENROUTER_DEFAULT_BASE_URL, AppConfig, load_config
from rex.logging_utils import get_logger
from rex.retry import RetryConfig, with_retry

logger = get_logger(__name__)


def _module_available(name: str) -> bool:
    if name in sys.modules:
        return True
    return find_spec(name) is not None


TORCH_AVAILABLE = _module_available("torch")
TRANSFORMERS_AVAILABLE = _module_available("transformers")
OPENAI_AVAILABLE = _module_available("openai")
OLLAMA_AVAILABLE = _module_available("ollama")
ANTHROPIC_AVAILABLE = _module_available("anthropic")


@dataclass
class GenerationConfig:
    max_new_tokens: int
    temperature: float
    top_p: float
    top_k: int
    seed: int


class LLMStrategy(Protocol):
    name: str

    def generate(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **kwargs: Any,
    ) -> str: ...

    def stream(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **kwargs: Any,
    ) -> Iterator[str]:
        raise NotImplementedError


class EchoStrategy:
    name = "echo"

    def __init__(self, model_name: str) -> None:
        self.model_name = model_name

    def generate(
        self,
        prompt: str,
        _config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **_kwargs: Any,
    ) -> str:
        text = prompt.strip()
        if not text and messages:
            text = "\n".join(m.get("content", "").strip() for m in messages).strip()
        return f"[{self.model_name}] {text or '(silence)'}"

    def stream(
        self,
        prompt: str,
        _config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **_kwargs: Any,
    ) -> Iterator[str]:
        text = prompt.strip()
        if not text and messages:
            text = "\n".join(m.get("content", "").strip() for m in messages).strip()

        words = (text or "(silence)").split()
        for index, word in enumerate(words):
            suffix = " " if index < len(words) - 1 else ""
            yield f"{word}{suffix}"


class OfflineTransformersStrategy:
    name = "offline-transformers"

    def __init__(self, model_name: str) -> None:
        self.model_name = model_name

    def generate(
        self,
        prompt: str,
        _config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **_kwargs: Any,
    ) -> str:
        text = prompt.strip()
        if not text and messages:
            text = "\n".join(m.get("content", "").strip() for m in messages).strip()
        return f"(offline) {text or ''}".strip() or "(offline)"

    def stream(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **_kwargs: Any,
    ) -> Iterator[str]:
        yield self.generate(prompt, config, messages=messages)


@dataclass(frozen=True)
class _TransformersRuntime:
    torch: Any
    pipeline: Any
    tokenizer: Any


class TransformersStrategy:
    name = "transformers"

    def __init__(self, model_name: str) -> None:
        if not (TORCH_AVAILABLE and TRANSFORMERS_AVAILABLE):
            raise ConfigurationError("Transformers backend requires `torch` and `transformers`.")
        try:
            torch = import_module("torch")
            transformers = import_module("transformers")
        except ImportError as exc:
            raise ConfigurationError(
                "Transformers backend requires `torch` and `transformers`."
            ) from exc

        from rex.runtime.warm import (
            WarmComponentSpec,
            default_idle_timeout,
            get_global_warm_runtime,
            warm_component_key,
        )

        device = 0 if torch.cuda.is_available() else -1
        manager = get_global_warm_runtime()
        component_name = warm_component_key("llm", model_name, device)

        def _load() -> _TransformersRuntime:
            tokenizer = transformers.AutoTokenizer.from_pretrained(model_name)
            model = transformers.AutoModelForCausalLM.from_pretrained(model_name)
            pipeline = transformers.pipeline(
                "text-generation",
                model=model,
                tokenizer=tokenizer,
                device=device,
            )
            if tokenizer.pad_token_id is None:
                tokenizer.pad_token_id = tokenizer.eos_token_id
            return _TransformersRuntime(torch=torch, pipeline=pipeline, tokenizer=tokenizer)

        manager.register_if_absent(
            WarmComponentSpec(
                name=component_name,
                loader=_load,
                estimated_cost_mb=2048.0,
                idle_timeout_s=default_idle_timeout(),
            )
        )
        manager.warm(component_name)
        self._warm_manager = manager
        self._warm_component_name = component_name

    def _runtime(self) -> _TransformersRuntime:
        return cast(_TransformersRuntime, self._warm_manager.get(self._warm_component_name))

    @property
    def _torch(self) -> Any:
        return self._runtime().torch

    @property
    def pipeline(self) -> Any:
        return self._runtime().pipeline

    @property
    def tokenizer(self) -> Any:
        return self._runtime().tokenizer

    def generate(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **kwargs: Any,
    ) -> str:
        with self._warm_manager.acquire(self._warm_component_name) as value:
            runtime = cast(_TransformersRuntime, value)
            torch = runtime.torch
            torch.manual_seed(config.seed)
            if torch.cuda.is_available():
                torch.cuda.manual_seed_all(config.seed)

            do_sample = config.temperature > 0 or config.top_p < 1.0
            generate_kwargs = {
                "max_new_tokens": config.max_new_tokens,
                "do_sample": do_sample,
                "pad_token_id": runtime.tokenizer.pad_token_id,
            }
            if do_sample:
                generate_kwargs.update(
                    {
                        "temperature": max(config.temperature, 1e-4),
                        "top_p": config.top_p,
                        "top_k": config.top_k,
                    }
                )

            outputs = runtime.pipeline(prompt, **generate_kwargs)
            text = outputs[0]["generated_text"]
            return text[len(prompt) :].strip() or "(silence)"


class OpenAIStrategy:
    name = "openai"

    def __init__(
        self,
        model_name: str,
        client_factory,
        retry_config: RetryConfig | None = None,
    ) -> None:
        self.model_name = model_name
        self._client_factory = client_factory
        self._cached_client = None
        self.tools: list[Any] = []  # Will be set by LanguageModel
        self._retry_config = retry_config or RetryConfig()

    def _get_client(self) -> Any:
        if self._cached_client is None:
            self._cached_client = self._client_factory()
        return self._cached_client

    def generate(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages=None,
        tools=None,
        user: str | None = None,
    ) -> Any:
        payload = messages or [{"role": "user", "content": prompt}]

        # Prepare API call parameters
        api_params: dict[str, Any] = {
            "model": self.model_name,
            "messages": payload,
            "temperature": config.temperature,
            "max_tokens": config.max_new_tokens,
            "top_p": config.top_p,
        }

        # Pass a stable user key so OpenClaw can maintain session state.
        if user is not None:
            api_params["user"] = user

        # Add tools if available
        if tools or self.tools:
            api_params["tools"] = tools or self.tools
            # Don't force tool use, let model decide
            # api_params["tool_choice"] = "auto"

        def _call() -> str:
            response = self._get_client().chat.completions.create(**api_params)
            message = response.choices[0].message

            # When tool_calls are present, serialize to JSON so generate() always returns str
            if hasattr(message, "tool_calls") and message.tool_calls:
                tool_calls_data = [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments,
                        },
                    }
                    for tc in message.tool_calls
                ]
                return json.dumps({"__tool_calls__": tool_calls_data})

            content = getattr(message, "content", "") or ""
            return content.strip() or "(silence)"

        return with_retry(_call, config=self._retry_config)

    def stream(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages=None,
        tools=None,
        user: str | None = None,
    ) -> Iterator[str]:
        payload = messages or [{"role": "user", "content": prompt}]
        api_params: dict[str, Any] = {
            "model": self.model_name,
            "messages": payload,
            "temperature": config.temperature,
            "max_tokens": config.max_new_tokens,
            "top_p": config.top_p,
            "stream": True,
        }

        if user is not None:
            api_params["user"] = user

        if tools or self.tools:
            api_params["tools"] = tools or self.tools

        stream = self._get_client().chat.completions.create(**api_params)
        for chunk in stream:
            choices = getattr(chunk, "choices", None)
            if not choices:
                continue
            delta = getattr(choices[0], "delta", None)
            if delta is None:
                continue

            content = getattr(delta, "content", None)
            if isinstance(content, str):
                if content:
                    yield content
                continue

            if isinstance(content, list):
                for item in content:
                    text = getattr(item, "text", None)
                    if text is None and isinstance(item, dict):
                        text = item.get("text")
                    if text:
                        yield str(text)


class OllamaStrategy:
    name = "ollama"

    def __init__(
        self,
        model_name: str,
        api_key: str | None = None,
        base_url: str = "http://localhost:11434",
        use_cloud: bool = False,
        retry_config: RetryConfig | None = None,
    ) -> None:
        if not OLLAMA_AVAILABLE:
            raise ConfigurationError("Ollama backend requires the `ollama` package.")
        try:
            ollama = import_module("ollama")
            client_cls = ollama.Client
        except Exception as exc:
            raise ConfigurationError("Ollama backend requires the `ollama` package.") from exc
        self.model_name = model_name
        self.base_url = base_url
        self.use_cloud = use_cloud
        self.api_key = api_key
        self._ollama = ollama
        self._client_cls = client_cls
        self._retry_config = retry_config or RetryConfig()

        if use_cloud and not api_key:
            raise ConfigurationError("Ollama cloud requires an API key.")

        try:
            if use_cloud:
                self._client = None  # cloud handled via ollama.chat
            else:
                self._client = self._client_cls(host=self.base_url)
                logger.info("Ollama local client initialized at %s", self.base_url)
        except Exception as exc:
            raise ConfigurationError(f"Failed to initialize Ollama client: {exc}") from exc

    def generate(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **kwargs: Any,
    ) -> str:
        payload = messages or [{"role": "user", "content": prompt}]
        options = {
            "temperature": config.temperature,
            "top_p": config.top_p,
            "top_k": config.top_k,
            "seed": config.seed,
        }

        def _call() -> str:
            if self.use_cloud:
                response = self._ollama.chat(
                    model=self.model_name,
                    messages=payload,
                    options=options,
                )
            else:
                response = self._client.chat(  # type: ignore[union-attr]
                    model=self.model_name,
                    messages=payload,
                    options=options,
                )

            # Log usage when token counts are available in the response
            try:
                from rex.llm_usage import record_usage

                if isinstance(response, dict):
                    eval_count = int(response.get("eval_count") or 0)
                    prompt_eval_count = int(response.get("prompt_eval_count") or 0)
                else:
                    eval_count = int(getattr(response, "eval_count", None) or 0)
                    prompt_eval_count = int(getattr(response, "prompt_eval_count", None) or 0)
                record_usage(
                    model=self.model_name,
                    prompt_tokens=prompt_eval_count,
                    completion_tokens=eval_count,
                )
            except Exception as _log_exc:  # noqa: BLE001
                logger.debug("Usage logging failed: %s", _log_exc)

            if isinstance(response, dict):
                content = response.get("message", {}).get("content", "")
            else:
                message = getattr(response, "message", None)
                content = str(getattr(message, "content", "") or "") if message else ""
            return content.strip() or "(silence)"

        try:
            return with_retry(_call, config=self._retry_config)
        except Exception as exc:
            # Connection errors: server not running or unreachable
            if isinstance(exc, ConnectionRefusedError):
                logger.error("Ollama connection failed: %s", exc)
                return "[Ollama: connection failed — is Ollama running?]"
            # httpx.ConnectError — ollama uses httpx internally; avoid top-level import
            exc_cls = type(exc)
            if exc_cls.__name__ == "ConnectError" and exc_cls.__module__ == "httpx":
                logger.error("Ollama connection failed: %s", exc)
                return "[Ollama: connection failed — is Ollama running?]"
            # 404 model-not-found (ollama.ResponseError carries status_code)
            if getattr(exc, "status_code", None) == 404:
                logger.error("Ollama model '%s' not found: %s", self.model_name, exc)
                return (
                    f"[Ollama: model '{self.model_name}' not found"
                    f" — run: ollama pull {self.model_name}]"
                )
            # All other errors
            logger.error("Ollama unexpected error (model=%s): %s", self.model_name, exc)
            return f"[Ollama: unexpected error: {exc}]"

    def stream(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages=None,
        **_kwargs: Any,
    ) -> Iterator[str]:
        payload = messages or [{"role": "user", "content": prompt}]
        options = {
            "temperature": config.temperature,
            "top_p": config.top_p,
            "top_k": config.top_k,
            "seed": config.seed,
        }

        try:
            if self.use_cloud:
                stream = self._ollama.chat(
                    model=self.model_name,
                    messages=payload,
                    options=options,
                    stream=True,
                )
            else:
                stream = self._client.chat(  # type: ignore[union-attr]
                    model=self.model_name,
                    messages=payload,
                    options=options,
                    stream=True,
                )

            for chunk in stream:
                content = ""
                if isinstance(chunk, dict):
                    content = str(chunk.get("message", {}).get("content", "") or "")
                else:
                    message = getattr(chunk, "message", None)
                    content = str(getattr(message, "content", "") or "")

                if content:
                    yield content
        except Exception as exc:
            logger.warning("Ollama stream interrupted (model=%s): %s", self.model_name, exc)
            return


class AnthropicStrategy:
    name = "anthropic"

    def __init__(
        self,
        model_name: str,
        client_factory,
        retry_config: RetryConfig | None = None,
    ) -> None:
        self.model_name = model_name
        self._client_factory = client_factory
        self._cached_client = None
        self._retry_config = retry_config or RetryConfig()

    def _get_client(self) -> Any:
        if self._cached_client is None:
            self._cached_client = self._client_factory()
        return self._cached_client

    def generate(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages: list[dict[str, Any]] | None = None,
        **kwargs: Any,
    ) -> str:
        payload = messages or [{"role": "user", "content": prompt}]
        # Anthropic requires alternating user/assistant turns; filter system messages
        system_parts = [m["content"] for m in payload if m.get("role") == "system"]
        user_messages = [m for m in payload if m.get("role") != "system"]
        system_text = "\n".join(system_parts) if system_parts else None

        api_params: dict[str, Any] = {
            "model": self.model_name,
            "max_tokens": config.max_new_tokens,
            "messages": user_messages,
        }
        if system_text:
            api_params["system"] = system_text

        def _call() -> str:
            response = self._get_client().messages.create(**api_params)
            content = response.content[0].text if response.content else ""
            return content.strip() or "(silence)"

        return with_retry(_call, config=self._retry_config)

    def stream(
        self,
        prompt: str,
        config: GenerationConfig,
        *,
        messages=None,
        **_kwargs: Any,
    ) -> Iterator[str]:
        payload = messages or [{"role": "user", "content": prompt}]
        system_parts = [m["content"] for m in payload if m.get("role") == "system"]
        user_messages = [m for m in payload if m.get("role") != "system"]
        system_text = "\n".join(system_parts) if system_parts else None

        api_params: dict[str, Any] = {
            "model": self.model_name,
            "max_tokens": config.max_new_tokens,
            "messages": user_messages,
        }
        if system_text:
            api_params["system"] = system_text

        try:
            stream_manager = self._get_client().messages.stream(**api_params)
            with stream_manager as stream:
                for text in stream.text_stream:
                    if text:
                        yield str(text)
        except Exception as exc:
            logger.warning("Anthropic stream interrupted (model=%s): %s", self.model_name, exc)
            return


_STRATEGIES: dict[str, type[Any]] = {
    EchoStrategy.name: EchoStrategy,
    TransformersStrategy.name: TransformersStrategy,
}


def register_strategy(name: str, strategy: type[Any]) -> None:
    _STRATEGIES[name] = strategy


class LanguageModel:
    def __init__(self, config: AppConfig | None = None, **overrides) -> None:
        self.config = config or load_config()
        llm_config = self.config.llm
        if llm_config is None:
            raise ConfigurationError("LLM configuration is unavailable.")
        self.provider = str(overrides.get("provider") or llm_config.llm_provider).lower()
        if self.provider == "openai":
            self.model_name = (
                overrides.get("model") or self.config.openai_model or self.config.llm_model
            )
        elif self.provider == "openrouter":
            self.model_name = (
                overrides.get("model") or self.config.openrouter_model or self.config.llm_model
            )
        elif self.provider == "anthropic":
            self.model_name = (
                overrides.get("model") or self.config.anthropic_model or self.config.llm_model
            )
        else:
            self.model_name = overrides.get("model") or self.config.llm_model
        self.api_key: str | None
        self.api_base_url: str | None
        if self.provider == "openrouter":
            self.api_key = overrides.get("openrouter_api_key") or self.config.openrouter_api_key
            self.api_base_url = OPENROUTER_DEFAULT_BASE_URL
        else:
            self.api_key = overrides.get("openai_api_key") or self.config.openai_api_key
            self.api_base_url = overrides.get("base_url") or self.config.openai_base_url
        self.anthropic_api_key = overrides.get("anthropic_api_key") or self.config.anthropic_api_key

        self.generation = GenerationConfig(
            max_new_tokens=overrides.get("max_new_tokens", self.config.llm_max_tokens),
            temperature=overrides.get("temperature", self.config.llm_temperature),
            top_p=overrides.get("top_p", self.config.llm_top_p),
            top_k=overrides.get("top_k", self.config.llm_top_k),
            seed=overrides.get("seed", self.config.llm_seed),
        )

        self._openai_client = None
        self._anthropic_client = None
        self._tools: list[Any] = []  # OpenAI tool definitions
        self._tool_functions: dict[str, Any] = {}  # Map of tool name -> callable
        from rex.runtime.warm import get_global_warm_runtime

        # Establish process-wide warm-cache policy before constructing model strategies.
        get_global_warm_runtime(self.config)
        self._request_model: contextvars.ContextVar[str | None] = contextvars.ContextVar(
            f"askrex_llm_model_{id(self)}", default=None
        )
        self._strategy_lock = threading.Lock()
        self.strategy = self._init_strategy(self.model_name)
        self._strategy_cache: dict[str, LLMStrategy] = {self.model_name: self.strategy}

    def _ensure_anthropic_client(self) -> Any:
        if self._anthropic_client is not None:
            return self._anthropic_client
        if not self.anthropic_api_key:
            raise ConfigurationError("Missing Anthropic API key.")
        if not ANTHROPIC_AVAILABLE:
            raise ConfigurationError("Anthropic backend requires the `anthropic` package.")
        try:
            Anthropic = import_module("anthropic").Anthropic
        except Exception as exc:
            raise ConfigurationError("Anthropic backend requires the `anthropic` package.") from exc
        self._anthropic_client = Anthropic(api_key=self.anthropic_api_key)
        return self._anthropic_client

    def _init_strategy(self, model_name: str | None = None) -> LLMStrategy:
        model_name = model_name or self.model_name
        if self.provider in {"openai", "openrouter"}:
            return cast(LLMStrategy, OpenAIStrategy(model_name, self._ensure_openai_client))

        if self.provider == "anthropic":
            return cast(LLMStrategy, AnthropicStrategy(model_name, self._ensure_anthropic_client))

        if self.provider == "ollama":
            return cast(
                LLMStrategy,
                OllamaStrategy(
                    model_name,
                    api_key=self.config.ollama_api_key,
                    base_url=self.config.ollama_base_url,
                    use_cloud=self.config.ollama_use_cloud,
                ),
            )

        strategy_cls = _STRATEGIES.get(self.provider)
        if strategy_cls is None:
            logger.warning("Unknown LLM provider '%s'. Falling back to echo mode.", self.provider)
            return cast(LLMStrategy, EchoStrategy(model_name))

        if strategy_cls is TransformersStrategy and not (
            TORCH_AVAILABLE and TRANSFORMERS_AVAILABLE
        ):
            logger.warning(
                "Transformers missing; using offline fallback for model '%s'.", model_name
            )
            return cast(LLMStrategy, OfflineTransformersStrategy(model_name))

        try:
            return cast(LLMStrategy, strategy_cls(model_name))
        except Exception as exc:
            logger.warning(
                "LLM backend init failed (%s). Falling back to echo. (%s)", self.provider, exc
            )
            return cast(LLMStrategy, EchoStrategy(model_name))

    def set_request_model(self, model_name: str) -> contextvars.Token[str | None]:
        """Set a model override for the current request context only."""
        return self._request_model.set(model_name)

    def reset_request_model(self, token: contextvars.Token[str | None]) -> None:
        """Restore the previous request-local model override."""
        self._request_model.reset(token)

    def active_model_name(self) -> str:
        """Return the request-local model when set, otherwise the configured base model."""
        request_model = getattr(self, "_request_model", None)
        if request_model is None:
            return self.model_name
        return request_model.get() or self.model_name

    def _strategy_for_request(self) -> LLMStrategy:
        model_name = self.active_model_name()
        strategy_cache = getattr(self, "_strategy_cache", None)
        strategy_lock = getattr(self, "_strategy_lock", None)
        if strategy_cache is None or strategy_lock is None:
            return self.strategy
        cached = strategy_cache.get(model_name)
        if cached is not None:
            return cast(LLMStrategy, cached)
        with strategy_lock:
            cached = strategy_cache.get(model_name)
            if cached is None:
                cached = self._init_strategy(model_name)
                if hasattr(cached, "tools"):
                    cached.tools = self._tools
                strategy_cache[model_name] = cached
            return cast(LLMStrategy, cached)

    def _ensure_openai_client(self):
        if self._openai_client is not None:
            return self._openai_client
        base_url = self.api_base_url
        api_key = self.api_key
        if self.provider == "openrouter":
            if not api_key:
                raise ConfigurationError("Missing OpenRouter API key.")
        elif not api_key and base_url:
            api_key = "local"
        if not api_key:
            raise ConfigurationError("Missing OpenAI API key.")
        if not OPENAI_AVAILABLE:
            raise ConfigurationError("OpenAI backend requires the `openai` package.")
        try:
            OpenAI = import_module("openai").OpenAI
        except Exception as exc:
            raise ConfigurationError("OpenAI backend requires the `openai` package.") from exc
        client_kwargs: dict[str, Any] = {"api_key": api_key}
        if base_url:
            client_kwargs["base_url"] = base_url
        if self.provider == "openrouter":
            client_kwargs["default_headers"] = {"X-OpenRouter-Title": "AskRex Assistant"}
        self._openai_client = OpenAI(**client_kwargs)
        return self._openai_client

    def register_tool(
        self, name: str, description: str, parameters: dict[str, Any], function: Any
    ) -> None:
        """Register a tool/function that the LLM can call.

        Args:
            name: Function name (e.g., "web_search")
            description: What the function does
            parameters: JSON schema for parameters
            function: Callable to execute when tool is invoked
        """
        tool_def = {
            "type": "function",
            "function": {
                "name": name,
                "description": description,
                "parameters": parameters,
            },
        }
        self._tools.append(tool_def)
        self._tool_functions[name] = function

        # Update strategy if it supports tools
        if hasattr(self.strategy, "tools"):
            self.strategy.tools = self._tools

        logger.info(f"Registered tool: {name}")

    def _execute_tool_call(self, tool_call: Any) -> str:
        """Execute a tool call and return the result."""
        import json

        function_name = tool_call.function.name
        function_args = json.loads(tool_call.function.arguments)

        logger.info(f"Executing tool: {function_name} with args: {function_args}")

        if function_name not in self._tool_functions:
            return f"Error: Unknown function {function_name}"

        from rex.mobile_api.action_context import (  # noqa: PLC0415
            MobileActionDeniedError,
            authorized_mobile_tool,
        )

        try:
            with authorized_mobile_tool(function_name, arguments=function_args):
                result = self._tool_functions[function_name](**function_args)
            return str(result) if result is not None else "No results found"
        except MobileActionDeniedError:
            raise
        except Exception as e:
            logger.error("Tool execution failed: %s", type(e).__name__)
            return f"Error executing {function_name}"

    def _format_messages(self, messages: Sequence[dict[str, str]]) -> str:
        return "\n".join(
            f"{m.get('role', 'user').capitalize()}: {m.get('content', '').strip()}"
            for m in messages
            if isinstance(m, dict)
        ).strip()

    def generate(
        self,
        prompt: str | None = None,
        *,
        messages: Sequence[dict[str, Any]] | None = None,
        config: GenerationConfig | None = None,
        max_tool_rounds: int = 3,
    ) -> str:
        if messages is not None:
            prompt_text = self._format_messages(messages)
            normalized_messages: list[dict[str, Any]] = [
                {"role": str(m.get("role", "")), "content": str(m.get("content", ""))}
                for m in messages
                if isinstance(m, dict)
            ]
        elif isinstance(prompt, str):
            prompt_text = prompt
            normalized_messages = [{"role": "user", "content": prompt}]
        else:
            raise ValueError("Prompt or messages must be provided.")

        if not prompt_text.strip():
            raise ValueError("Prompt must not be empty.")

        strategy = self._strategy_for_request()

        # Derive a stable user key for OpenAI/OpenClaw session persistence.
        _user_key: str | None = None
        if self.provider == "openai":
            uid = getattr(self.config, "user_id", "default")
            profile = getattr(self.config, "active_profile", "default")
            _user_key = uid if uid and uid != "default" else profile

        # Tool calling loop (only for OpenAI strategy with tools)
        if self.provider == "openai" and self._tools:
            current_messages = list(normalized_messages)

            for round_num in range(max_tool_rounds):
                try:
                    _tool_extra: dict[str, Any] = {}
                    if _user_key is not None:
                        _tool_extra["user"] = _user_key
                    result = strategy.generate(
                        prompt_text,
                        config or self.generation,
                        messages=current_messages,
                        tools=self._tools,
                        **_tool_extra,
                    )
                except TypeError:
                    # Fallback for strategies that don't support tools parameter
                    result = strategy.generate(
                        prompt_text, config or self.generation, messages=current_messages
                    )

                # Check if result is a JSON-serialized tool call response
                _tc_parsed: dict[str, Any] | None = None
                if isinstance(result, str):
                    try:
                        _maybe = json.loads(result)
                        if isinstance(_maybe, dict) and "__tool_calls__" in _maybe:
                            _tc_parsed = _maybe
                    except (json.JSONDecodeError, ValueError):
                        pass

                if _tc_parsed is not None:
                    tc_list: list[dict[str, Any]] = _tc_parsed["__tool_calls__"]
                    logger.info(
                        f"Tool call round {round_num + 1}: Model requested {len(tc_list)} tool(s)"
                    )

                    # Add assistant message to conversation
                    current_messages.append(
                        {
                            "role": "assistant",
                            "content": "",
                            "tool_calls": tc_list,
                        }
                    )

                    # Execute each tool call and add results
                    for tc_data in tc_list:
                        tool_call = _types.SimpleNamespace(
                            id=tc_data["id"],
                            function=_types.SimpleNamespace(
                                name=tc_data["function"]["name"],
                                arguments=tc_data["function"]["arguments"],
                            ),
                        )
                        tool_result = self._execute_tool_call(tool_call)
                        current_messages.append(
                            {
                                "role": "tool",
                                "tool_call_id": tc_data["id"],
                                "content": tool_result,
                            }
                        )

                    # Continue loop to get final response with tool results
                    continue

                # No tool calls - return final response
                return result.strip() if isinstance(result, str) else str(result).strip()

            # Max rounds reached
            logger.warning(f"Max tool rounds ({max_tool_rounds}) reached")
            return "I apologize, but I encountered too many tool calls. Please try rephrasing your question."

        # No tools or not OpenAI - use original simple generation
        try:
            try:
                extra: dict[str, Any] = {}
                if _user_key is not None:
                    extra["user"] = _user_key
                result = strategy.generate(
                    prompt_text,
                    config or self.generation,
                    messages=normalized_messages,
                    **extra,
                )
                return result.strip() if isinstance(result, str) else str(result).strip()
            except TypeError:
                result = strategy.generate(prompt_text, config or self.generation)
                return result.strip() if isinstance(result, str) else str(result).strip()
        except Exception as exc:
            from rex.dep_errors import LMStudioConnectionError, is_connection_error

            if (
                is_connection_error(exc)
                and self.provider == "openai"
                and self.config.openai_base_url
            ):
                url = self.config.openai_base_url
                raise LMStudioConnectionError(
                    f"Rex can't reach LM Studio at {url}. Is LM Studio running?"
                ) from exc
            raise

    def stream(
        self,
        prompt: str | None = None,
        *,
        messages: Sequence[dict[str, Any]] | None = None,
        config: GenerationConfig | None = None,
    ) -> Iterator[str]:
        generation_config = config or self.generation
        if messages is not None:
            prompt_text = self._format_messages(messages)
            normalized_messages = [
                {"role": str(m.get("role", "")), "content": str(m.get("content", ""))}
                for m in messages
                if isinstance(m, dict)
            ]
        elif isinstance(prompt, str):
            prompt_text = prompt
            normalized_messages = [{"role": "user", "content": prompt}]
        else:
            raise ValueError("Prompt or messages must be provided.")

        if not prompt_text.strip():
            raise ValueError("Prompt must not be empty.")

        strategy = self._strategy_for_request()

        def _generate_once() -> Iterator[str]:
            if messages is not None:
                completion = self.generate(messages=normalized_messages, config=generation_config)
            else:
                completion = self.generate(prompt_text, config=generation_config)
            if completion:
                yield completion

        extra: dict[str, Any] = {}
        if self.provider == "openai":
            uid = getattr(self.config, "user_id", "default")
            profile = getattr(self.config, "active_profile", "default")
            user_key = uid if uid and uid != "default" else profile
            if user_key:
                extra["user"] = user_key
            if self._tools:
                extra["tools"] = self._tools

        stream_fn = getattr(strategy, "stream", None)
        if not callable(stream_fn):
            return _generate_once()

        try:
            token_iterator = stream_fn(
                prompt_text,
                generation_config,
                messages=normalized_messages,
                **extra,
            )
        except TypeError:
            try:
                token_iterator = stream_fn(prompt_text, generation_config)
            except NotImplementedError:
                return _generate_once()
        except NotImplementedError:
            return _generate_once()

        def _guarded_stream() -> Iterator[str]:
            try:
                yield from token_iterator
            except NotImplementedError:
                yield from _generate_once()

        return _guarded_stream()


__all__ = [
    "LanguageModel",
    "GenerationConfig",
    "register_strategy",
    "TORCH_AVAILABLE",
    "TRANSFORMERS_AVAILABLE",
    "RetryConfig",
]