Files
wehub-resource-sync 25576b0be6
Checks (magui2.0) / python-lint (push) Failing after 1s
Checks (magui2.0) / python-test (push) Failing after 0s
Checks (magui2.0) / frontend-lint (push) Failing after 0s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
Checks (magui2.0) / python-format (push) Failing after 1s
CodeQL Advanced / Analyze (python) (push) Failing after 0s
Checks (magui2.0) / python-pyright (push) Failing after 1s
Checks (magui2.0) / frontend-format (push) Failing after 1s
Checks (magui2.0) / frontend-typecheck (push) Failing after 0s
Checks (magui2.0) / frontend-test (push) Failing after 2s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:24:32 +08:00

242 lines
8.7 KiB
Python

"""LLM client factory and wrapper.
Config format (from config.yaml):
provider: AzureOpenAIChatCompletionClient # or OpenAIChatCompletionClient
config:
model: gpt-4o
base_url: ...
api_key: ...
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Any, Union, cast
import httpx
import openai
from openai import AsyncOpenAI
if TYPE_CHECKING:
from .agents.web_surfer.fara._types import LLMMessage
# A short connect timeout surfaces "can't reach the server" (wrong base_url,
# VPN off) in seconds instead of hanging. The read timeout only guards against
# a server that accepted the request but never replies; a generous default
# tolerates cold starts, and the slow-model UI signal covers the wait.
_CONNECT_TIMEOUT_SECONDS = 5.0
_READ_TIMEOUT_SECONDS = 120.0
def _build_timeout() -> httpx.Timeout:
# Pool acquisition uses the short connect timeout so an unreachable server
# fails fast instead of waiting the full read timeout for a free
# connection. Writing the request body shares the generous read budget.
return httpx.Timeout(
connect=_CONNECT_TIMEOUT_SECONDS,
read=_READ_TIMEOUT_SECONDS,
write=_READ_TIMEOUT_SECONDS,
pool=_CONNECT_TIMEOUT_SECONDS,
)
def humanize_model_error(error: BaseException) -> str | None:
"""Translate a model-call exception into a user-readable message.
Follows the ``__cause__`` chain so transient errors wrapped in a
``RuntimeError`` are still recognized. Returns ``None`` for anything
unrecognized, leaving the caller to surface the raw text.
"""
seen: set[int] = set()
exc: BaseException | None = error
while exc is not None and id(exc) not in seen:
seen.add(id(exc))
# APITimeoutError subclasses APIConnectionError, so check it first.
if isinstance(exc, openai.APITimeoutError):
return (
"The model server did not respond in time. It may be overloaded "
"or still starting up — try again in a moment."
)
if isinstance(exc, openai.APIConnectionError):
host = _error_host(exc)
where = f" at {host}" if host else ""
return (
f"Could not reach the model server{where}. Check your network "
"connection, VPN, and the configured base_url."
)
if isinstance(exc, openai.AuthenticationError):
return "The model server rejected the API key. Check your API key configuration."
if isinstance(exc, openai.PermissionDeniedError):
return "The model server denied access. Check that the API key may use this model."
if isinstance(exc, openai.RateLimitError):
if getattr(exc, "code", None) == "insufficient_quota":
return "The model account is out of quota. Check the plan and billing."
return (
"The model server is rate limiting requests. Wait a moment and retry."
)
if isinstance(exc, openai.NotFoundError):
return "The configured model was not found on the server. Check the model name."
exc = exc.__cause__
return None
def _error_host(error: BaseException) -> str | None:
request = getattr(error, "request", None)
url = getattr(request, "url", None)
host = getattr(url, "host", None)
return str(host) if host else None
def is_retryable_model_error(error: BaseException) -> bool:
"""Whether a model-call error is transient and worth retrying.
Retryable (transient): plain 429 rate limits, request timeouts, 5xx.
Fatal (fail fast): connection errors (the SDK already retried and
still couldn't reach the server), auth / permission / not-found, and
quota exhaustion — retrying these can't help.
Shared by OmniAgent (``_responses._call_api``) and FaraWebSurfer
(``_fara_web_surfer`` run loop) so both classify network errors the
same way. Unrecognized errors are treated as fatal.
"""
# APITimeoutError subclasses APIConnectionError, so check it first.
if isinstance(error, openai.APITimeoutError):
return True
if isinstance(error, openai.APIConnectionError):
return False
if isinstance(error, openai.RateLimitError):
return getattr(error, "code", None) != "insufficient_quota"
if isinstance(error, openai.APIStatusError):
return error.status_code >= 500
return False
# ---------------------------------------------------------------------------
# ChatClient — thin AsyncOpenAI wrapper that converts LLMMessage to OpenAI format.
# ---------------------------------------------------------------------------
class ChatClient:
"""Holds AsyncOpenAI + model, converts LLMMessage to OpenAI format.
Usage:
client = create_client(config)
result = await client.create(messages)
"""
def __init__(self, client: AsyncOpenAI, model: str) -> None:
self._client = client
self.model = model
async def create(self, messages: list[LLMMessage], **kwargs: Any) -> str: # noqa: ANN401
"""Call LLM and return response text."""
openai_messages = [m.to_openai_dict() for m in messages]
response = await self._client.chat.completions.create( # type: ignore[reportUnknownMemberType]
model=self.model,
messages=openai_messages, # type: ignore[arg-type]
**kwargs,
)
return str(response.choices[0].message.content or "").strip() # type: ignore[reportUnknownMemberType]
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.close()
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
# Provider names that trigger Azure client creation
_AZURE_PROVIDERS = {
"AzureOpenAIChatCompletionClient",
"azure_openai_chat_completion_client",
"azure",
}
def create_client(config: Union[Any, None]) -> tuple[AsyncOpenAI, str]:
"""Create an AsyncOpenAI client from model config dict.
Args:
config: Top-level model config with ``provider`` and ``config`` keys.
Returns:
``(client, model_name)`` tuple.
"""
if config is None:
raise ValueError(
"Model client config is required — configure it in config.yaml"
)
if hasattr(config, "model_dump"):
config = config.model_dump()
all_config = cast(dict[str, Any], config)
provider: str = all_config.get("provider", "")
model_config: dict[str, Any] = all_config.get("config", {})
if provider in _AZURE_PROVIDERS:
return _create_azure(model_config)
return create_openai_client(model_config)
def create_openai_client(model_config: dict[str, Any]) -> tuple[AsyncOpenAI, str]:
model = model_config.get("model", "gpt-4.1-2025-04-14")
kwargs: dict[str, Any] = {"max_retries": 5, "timeout": _build_timeout()}
if model_config.get("base_url"):
kwargs["base_url"] = model_config["base_url"]
api_key = (
model_config.get("api_key") or os.environ.get("OPENAI_API_KEY") or "not-needed"
)
kwargs["api_key"] = api_key
return AsyncOpenAI(**kwargs), model
def _create_azure(model_config: dict[str, Any]) -> tuple[AsyncOpenAI, str]:
from azure.identity import (
AzureCliCredential,
ChainedTokenCredential,
ManagedIdentityCredential,
get_bearer_token_provider,
)
from openai import AsyncAzureOpenAI
model = model_config.get("model") or model_config.get("azure_deployment")
if not model:
raise ValueError("Missing 'model' or 'azure_deployment' in config")
azure_endpoint = model_config.get("azure_endpoint")
if not azure_endpoint:
raise ValueError("Missing 'azure_endpoint' in config")
azure_deployment = model_config.get("azure_deployment")
if not azure_deployment:
raise ValueError("Missing 'azure_deployment' in config")
api_version = model_config.get("api_version", "2024-12-01-preview")
token_provider_cfg = model_config.get("azure_ad_token_provider", {})
scopes = token_provider_cfg.get("config", {}).get("scopes", [])
if not scopes:
raise ValueError("Missing 'azure_ad_token_provider.config.scopes' in config")
credential = get_bearer_token_provider(
ChainedTokenCredential(AzureCliCredential(), ManagedIdentityCredential()),
scopes[0],
)
client = AsyncAzureOpenAI(
azure_endpoint=azure_endpoint,
azure_deployment=azure_deployment,
api_version=api_version,
azure_ad_token_provider=credential,
max_retries=5,
timeout=_build_timeout(),
)
return client, model # type: ignore[return-value]