Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

665 lines
21 KiB
Python

"""Unified services-layer LLM factory."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Mapping
import contextlib
from types import SimpleNamespace
from typing import Any, TypedDict
from deeptutor.config.settings import settings
from deeptutor.services.provider_registry import (
PROVIDERS,
canonical_provider_name,
find_by_model,
find_by_name,
find_gateway,
)
from .capabilities import supports_response_format, supports_vision
from .config import LLMConfig, get_llm_config
from .error_mapping import map_error
from .multimodal import prepare_multimodal_messages
from .provider_factory import get_runtime_provider
from .utils import is_local_llm_server
DEFAULT_MAX_RETRIES = settings.retry.max_retries
DEFAULT_RETRY_DELAY = settings.retry.base_delay
DEFAULT_EXPONENTIAL_BACKOFF = settings.retry.exponential_backoff
DEFAULT_STREAM_COALESCE_CHARS = 64
DEFAULT_STREAM_COALESCE_SECONDS = 0.04
STREAM_CONTROL_TOKENS = {"<think>", "</think>"}
CallKwargs = dict[str, Any]
class ApiProviderPreset(TypedDict, total=False):
"""Typed representation of API provider presets."""
name: str
base_url: str
requires_key: bool
models: list[str]
binding: str
class LocalProviderPreset(TypedDict, total=False):
"""Typed representation of local provider presets."""
name: str
base_url: str
requires_key: bool
default_key: str
binding: str
ProviderPreset = ApiProviderPreset | LocalProviderPreset
ProviderPresetMap = Mapping[str, ProviderPreset]
ProviderPresetBundle = Mapping[str, ProviderPresetMap]
def _build_retry_delays(
max_retries: int,
retry_delay: float,
exponential_backoff: bool,
) -> tuple[float, ...]:
if max_retries <= 0:
return ()
delays: list[float] = []
base = max(float(retry_delay), 0.0)
for attempt in range(max_retries):
delay = base * (2**attempt) if exponential_backoff else base
delays.append(min(delay, 120.0))
return tuple(delays)
def _resolve_provider_spec(
*,
binding: str | None,
model: str,
api_key: str,
base_url: str | None,
fallback: str | None,
):
explicit = find_by_name(binding)
gateway = find_gateway(
provider_name=explicit.name if explicit else None,
api_key=api_key or None,
api_base=base_url or None,
)
if explicit and gateway and explicit.name == "openai":
return gateway
if explicit:
return explicit
if gateway:
return gateway
model_spec = find_by_model(model)
if model_spec:
return model_spec
if is_local_llm_server(base_url):
if base_url and "11434" in base_url:
return find_by_name("ollama") or find_by_name("vllm")
return find_by_name("vllm") or find_by_name("ollama")
return find_by_name(fallback) or find_by_name("openai")
def _url_matches_current(explicit_url: str | None, current: LLMConfig) -> bool:
if explicit_url is None:
return True
return explicit_url in {
url for url in (current.base_url, current.effective_url) if url is not None
}
def _binding_matches_current(binding: str | None, current: LLMConfig) -> bool:
if not binding:
return True
canonical = canonical_provider_name(binding) or binding
return canonical in {current.binding, current.provider_name}
def _matching_current_config(
*,
model: str,
api_key: str,
base_url: str | None,
api_version: str | None,
binding: str | None,
) -> LLMConfig | None:
"""Return the active config when explicit call fields came from it.
Several agent call sites pass model/api_key/base_url/binding explicitly
after reading ``get_llm_config()``. Treat those fields as a partial
override, not as a request to drop profile-only settings such as
extra_headers or reasoning_effort.
"""
with contextlib.suppress(Exception):
current = get_llm_config()
if (
model == current.model
and api_key == current.api_key
and _url_matches_current(base_url, current)
and (api_version is None or api_version == current.api_version)
and _binding_matches_current(binding, current)
):
return current
return None
def _resolve_call_config(
*,
model: str | None,
api_key: str | None,
base_url: str | None,
api_version: str | None,
binding: str | None,
extra_headers: dict[str, str] | None,
reasoning_effort: str | None,
) -> tuple[LLMConfig, Any]:
if model and api_key is not None and (base_url is not None or binding is not None):
current = _matching_current_config(
model=model,
api_key=api_key,
base_url=base_url,
api_version=api_version,
binding=binding,
)
merged_headers = dict(current.extra_headers or {}) if current is not None else {}
if extra_headers:
merged_headers.update(extra_headers)
resolved_reasoning_effort = (
reasoning_effort
if reasoning_effort is not None
else current.reasoning_effort
if current is not None
else None
)
provider_spec = _resolve_provider_spec(
binding=binding,
model=model,
api_key=api_key,
base_url=base_url,
fallback=binding or "openai",
)
provider_name = (
provider_spec.name
if provider_spec is not None
else canonical_provider_name(binding) or binding or "openai"
)
provider_mode = provider_spec.mode if provider_spec is not None else "standard"
config = LLMConfig(
model=model,
api_key=api_key,
base_url=base_url,
effective_url=base_url,
binding=provider_name,
provider_name=provider_name,
provider_mode=provider_mode,
api_version=api_version,
extra_headers=merged_headers,
reasoning_effort=resolved_reasoning_effort,
)
return config, provider_spec
current = get_llm_config()
merged_headers = dict(getattr(current, "extra_headers", None) or {})
if extra_headers:
merged_headers.update(extra_headers)
resolved_model = model or current.model
resolved_api_key = current.api_key if api_key is None else api_key
resolved_base_url = base_url if base_url is not None else current.base_url
resolved_effective_url = base_url if base_url is not None else current.effective_url
resolved_api_version = api_version if api_version is not None else current.api_version
binding_hint = binding or current.provider_name or current.binding
provider_spec = _resolve_provider_spec(
binding=binding_hint,
model=resolved_model,
api_key=resolved_api_key,
base_url=resolved_effective_url,
fallback=current.provider_name or current.binding,
)
provider_name = provider_spec.name if provider_spec is not None else current.provider_name
provider_mode = provider_spec.mode if provider_spec is not None else current.provider_mode
resolved_binding = provider_name or binding_hint or current.binding or "openai"
config = current.model_copy(
update={
"model": resolved_model,
"api_key": resolved_api_key,
"base_url": resolved_base_url,
"effective_url": resolved_effective_url,
"binding": resolved_binding,
"provider_name": provider_name or resolved_binding,
"provider_mode": provider_mode,
"api_version": resolved_api_version,
"extra_headers": merged_headers,
"reasoning_effort": (
reasoning_effort if reasoning_effort is not None else current.reasoning_effort
),
}
)
return config, provider_spec
def _capability_binding(config: LLMConfig, provider_spec: Any) -> str:
backend = (
getattr(provider_spec, "backend", "openai_compat") if provider_spec else "openai_compat"
)
if backend == "anthropic":
return "anthropic"
if backend == "azure_openai":
return "azure_openai"
return (
getattr(provider_spec, "name", None) or config.provider_name or config.binding or "openai"
)
def _build_messages(
prompt: str,
system_prompt: str,
messages: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
if messages is not None:
return messages
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
def _coerce_stream_coalesce_chars(value: Any) -> int:
try:
return max(1, int(value))
except (TypeError, ValueError):
return DEFAULT_STREAM_COALESCE_CHARS
def _coerce_stream_coalesce_seconds(value: Any) -> float:
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return DEFAULT_STREAM_COALESCE_SECONDS
def _apply_inline_image_data(
messages: list[dict[str, Any]],
*,
binding: str,
model: str,
image_data: str | None,
image_mime_type: str = "image/png",
image_filename: str = "image.png",
) -> list[dict[str, Any]]:
if not image_data:
return messages
attachment = SimpleNamespace(
type="image",
base64=image_data,
filename=image_filename,
mime_type=image_mime_type,
)
result = prepare_multimodal_messages(messages, [attachment], binding=binding, model=model)
return result.messages
def _sanitize_call_kwargs(
*,
binding: str,
model: str,
kwargs: dict[str, Any],
) -> CallKwargs:
extra_kwargs = dict(kwargs)
for key in (
"messages",
"image_data",
"image_mime_type",
"image_filename",
"api_key",
"base_url",
"api_version",
"binding",
"extra_headers",
"reasoning_effort",
):
extra_kwargs.pop(key, None)
if not supports_response_format(binding, model):
extra_kwargs.pop("response_format", None)
return extra_kwargs
async def complete(
prompt: str,
system_prompt: str = "You are a helpful assistant.",
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
binding: str | None = None,
messages: list[dict[str, Any]] | None = None,
max_retries: int = DEFAULT_MAX_RETRIES,
retry_delay: float = DEFAULT_RETRY_DELAY,
exponential_backoff: bool = DEFAULT_EXPONENTIAL_BACKOFF,
**kwargs: Any,
) -> str:
caller_extra_headers = kwargs.pop("extra_headers", None)
reasoning_effort = kwargs.pop("reasoning_effort", None)
image_data = kwargs.pop("image_data", None)
image_mime_type = kwargs.pop("image_mime_type", "image/png")
image_filename = kwargs.pop("image_filename", "image.png")
config, provider_spec = _resolve_call_config(
model=model,
api_key=api_key,
base_url=base_url,
api_version=api_version,
binding=binding,
extra_headers=caller_extra_headers,
reasoning_effort=reasoning_effort,
)
provider = get_runtime_provider(config)
capability_binding = _capability_binding(config, provider_spec)
request_messages = _build_messages(prompt, system_prompt, messages)
request_messages = _apply_inline_image_data(
request_messages,
binding=capability_binding,
model=config.model,
image_data=image_data,
image_mime_type=str(image_mime_type or "image/png"),
image_filename=str(image_filename or "image.png"),
)
retry_delays = _build_retry_delays(max_retries, retry_delay, exponential_backoff)
extra_kwargs = _sanitize_call_kwargs(
binding=capability_binding, model=config.model, kwargs=kwargs
)
try:
response = await provider.chat_with_retry(
messages=request_messages,
model=config.model,
reasoning_effort=config.reasoning_effort,
retry_delays=retry_delays,
allow_image_fallback=not supports_vision(capability_binding, config.model),
**extra_kwargs,
)
except Exception as exc:
raise map_error(exc, provider=config.provider_name) from exc
if response.finish_reason == "error":
raise map_error(
RuntimeError(response.content or "LLM request failed"), provider=config.provider_name
)
return response.content or ""
async def stream(
prompt: str,
system_prompt: str = "You are a helpful assistant.",
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
binding: str | None = None,
messages: list[dict[str, Any]] | None = None,
max_retries: int = DEFAULT_MAX_RETRIES,
retry_delay: float = DEFAULT_RETRY_DELAY,
exponential_backoff: bool = DEFAULT_EXPONENTIAL_BACKOFF,
**kwargs: Any,
) -> AsyncGenerator[str, None]:
caller_extra_headers = kwargs.pop("extra_headers", None)
reasoning_effort = kwargs.pop("reasoning_effort", None)
image_data = kwargs.pop("image_data", None)
image_mime_type = kwargs.pop("image_mime_type", "image/png")
image_filename = kwargs.pop("image_filename", "image.png")
stream_coalesce_chars = _coerce_stream_coalesce_chars(
kwargs.pop("stream_coalesce_chars", DEFAULT_STREAM_COALESCE_CHARS)
)
stream_coalesce_seconds = _coerce_stream_coalesce_seconds(
kwargs.pop("stream_coalesce_seconds", DEFAULT_STREAM_COALESCE_SECONDS)
)
config, provider_spec = _resolve_call_config(
model=model,
api_key=api_key,
base_url=base_url,
api_version=api_version,
binding=binding,
extra_headers=caller_extra_headers,
reasoning_effort=reasoning_effort,
)
provider = get_runtime_provider(config)
capability_binding = _capability_binding(config, provider_spec)
request_messages = _build_messages(prompt, system_prompt, messages)
request_messages = _apply_inline_image_data(
request_messages,
binding=capability_binding,
model=config.model,
image_data=image_data,
image_mime_type=str(image_mime_type or "image/png"),
image_filename=str(image_filename or "image.png"),
)
retry_delays = _build_retry_delays(max_retries, retry_delay, exponential_backoff)
extra_kwargs = _sanitize_call_kwargs(
binding=capability_binding, model=config.model, kwargs=kwargs
)
queue: asyncio.Queue[str | BaseException | None] = asyncio.Queue()
saw_output = False
saw_content = False
in_think_block = False
async def _on_reasoning_delta(chunk: str) -> None:
nonlocal saw_output, in_think_block
if not chunk:
return
saw_output = True
if not in_think_block:
in_think_block = True
await queue.put("<think>")
await queue.put(chunk)
async def _on_content_delta(chunk: str) -> None:
nonlocal saw_output, saw_content, in_think_block
if not chunk:
return
saw_output = True
saw_content = True
if in_think_block:
in_think_block = False
await queue.put("</think>")
await queue.put(chunk)
async def _runner() -> None:
nonlocal in_think_block
try:
response = await provider.chat_stream_with_retry(
messages=request_messages,
model=config.model,
reasoning_effort=config.reasoning_effort,
on_content_delta=_on_content_delta,
on_reasoning_delta=_on_reasoning_delta,
retry_delays=retry_delays,
allow_image_fallback=not supports_vision(capability_binding, config.model),
**extra_kwargs,
)
if in_think_block:
in_think_block = False
await queue.put("</think>")
# Some providers synthesize a final response only after the stream.
# Do not replay reasoning_content as user-visible answer text.
if (
not saw_content
and response.content
and response.content != response.reasoning_content
):
saw_output = True
await queue.put(response.content)
if response.finish_reason == "error" and not saw_output:
await queue.put(
map_error(
RuntimeError(response.content or "LLM request failed"),
provider=config.provider_name,
)
)
except Exception as exc:
await queue.put(map_error(exc, provider=config.provider_name))
finally:
await queue.put(None)
task = asyncio.create_task(_runner())
try:
sent_first_text_chunk = False
buffered_chunks: list[str] = []
buffered_chars = 0
def _flush_buffer() -> str:
nonlocal buffered_chars
text = "".join(buffered_chunks)
buffered_chunks.clear()
buffered_chars = 0
return text
async def _queue_get(timeout: float | None = None) -> str | BaseException | None:
if timeout is None:
return await queue.get()
return await asyncio.wait_for(queue.get(), timeout=timeout)
while True:
item = await queue.get()
if item is None:
if buffered_chunks:
yield _flush_buffer()
break
if isinstance(item, BaseException):
if buffered_chunks:
yield _flush_buffer()
raise item
if item in STREAM_CONTROL_TOKENS or stream_coalesce_seconds <= 0:
if buffered_chunks:
yield _flush_buffer()
yield item
continue
if not sent_first_text_chunk:
sent_first_text_chunk = True
yield item
continue
buffered_chunks.append(item)
buffered_chars += len(item)
deadline = asyncio.get_running_loop().time() + stream_coalesce_seconds
while buffered_chunks and buffered_chars < stream_coalesce_chars:
timeout = deadline - asyncio.get_running_loop().time()
if timeout <= 0:
break
try:
next_item = await _queue_get(timeout)
except asyncio.TimeoutError:
break
if next_item is None:
if buffered_chunks:
yield _flush_buffer()
await task
return
if isinstance(next_item, BaseException):
if buffered_chunks:
yield _flush_buffer()
raise next_item
if next_item in STREAM_CONTROL_TOKENS:
if buffered_chunks:
yield _flush_buffer()
yield next_item
break
buffered_chunks.append(next_item)
buffered_chars += len(next_item)
if buffered_chunks:
yield _flush_buffer()
await task
finally:
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def fetch_models(
binding: str,
base_url: str,
api_key: str | None = None,
) -> list[str]:
if is_local_llm_server(base_url):
from . import local_provider
return await local_provider.fetch_models(base_url, api_key)
from . import cloud_provider
return await cloud_provider.fetch_models(base_url, api_key, binding)
def _build_api_provider_presets() -> dict[str, ApiProviderPreset]:
presets: dict[str, ApiProviderPreset] = {}
for spec in PROVIDERS:
if spec.is_local:
continue
presets[spec.name] = {
"name": spec.label,
"base_url": spec.default_api_base,
"requires_key": not spec.is_oauth,
"models": [],
"binding": spec.name,
}
return presets
def _build_local_provider_presets() -> dict[str, LocalProviderPreset]:
presets: dict[str, LocalProviderPreset] = {}
for spec in PROVIDERS:
if not spec.is_local:
continue
presets[spec.name] = {
"name": spec.label,
"base_url": spec.default_api_base,
"requires_key": False,
"default_key": "sk-no-key-required",
"binding": spec.name,
}
return presets
API_PROVIDER_PRESETS: dict[str, ApiProviderPreset] = _build_api_provider_presets()
LOCAL_PROVIDER_PRESETS: dict[str, LocalProviderPreset] = _build_local_provider_presets()
def get_provider_presets() -> ProviderPresetBundle:
return {
"api": API_PROVIDER_PRESETS,
"local": LOCAL_PROVIDER_PRESETS,
}
__all__ = [
"complete",
"stream",
"fetch_models",
"get_provider_presets",
"API_PROVIDER_PRESETS",
"LOCAL_PROVIDER_PRESETS",
"DEFAULT_MAX_RETRIES",
"DEFAULT_RETRY_DELAY",
"DEFAULT_EXPONENTIAL_BACKOFF",
"LLMFactory",
]
class LLMFactory:
"""Compatibility factory for legacy integrations."""
@staticmethod
def get_provider(config: LLMConfig):
return get_runtime_provider(config)