mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
211 lines
7.0 KiB
Python
211 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
from urllib import error, parse, request
|
|
|
|
|
|
class LLMClientError(RuntimeError):
|
|
"""Raised when an LLM request fails."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LLMConfiguration:
|
|
base_url: str
|
|
api_key: str
|
|
model: str
|
|
timeout: float = 30.0
|
|
|
|
def is_configured(self) -> bool:
|
|
return bool(self.base_url.strip() and self.model.strip())
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LLMToolCall:
|
|
name: str
|
|
arguments: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LLMCompletion:
|
|
content: Optional[str]
|
|
tool_calls: Tuple[LLMToolCall, ...]
|
|
|
|
|
|
_DEFAULT_HEADERS = {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
|
|
def _normalized_base_url(base_url: str) -> str:
|
|
trimmed = (base_url or "").strip()
|
|
if not trimmed:
|
|
raise LLMClientError("LLM base URL is required")
|
|
if not trimmed.endswith("/"):
|
|
trimmed += "/"
|
|
return trimmed
|
|
|
|
|
|
def _build_url(base_url: str, path: str) -> str:
|
|
normalized = _normalized_base_url(base_url)
|
|
trimmed_path = path.lstrip("/")
|
|
parsed = parse.urlparse(normalized)
|
|
if parsed.path.rstrip("/").lower().endswith("/v1") and trimmed_path.startswith(
|
|
"v1/"
|
|
):
|
|
trimmed_path = trimmed_path[len("v1/") :]
|
|
return parse.urljoin(normalized, trimmed_path)
|
|
|
|
|
|
def _build_headers(api_key: str) -> Dict[str, str]:
|
|
headers = dict(_DEFAULT_HEADERS)
|
|
token = (api_key or "").strip()
|
|
if token and token.lower() != "ollama":
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
|
|
def _perform_request(
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
headers: Optional[Mapping[str, str]] = None,
|
|
payload: Optional[Mapping[str, Any]] = None,
|
|
timeout: float = 30.0,
|
|
) -> Any:
|
|
data_bytes: Optional[bytes] = None
|
|
if payload is not None:
|
|
data_bytes = json.dumps(payload).encode("utf-8")
|
|
request_headers = dict(headers or {})
|
|
req = request.Request(
|
|
url, data=data_bytes, headers=request_headers, method=method.upper()
|
|
)
|
|
try:
|
|
with request.urlopen(req, timeout=timeout) as response:
|
|
body = response.read()
|
|
except error.HTTPError as exc: # pragma: no cover - defensive network guard
|
|
message = exc.read().decode("utf-8", "ignore") if exc.fp else exc.reason
|
|
raise LLMClientError(f"LLM request failed ({exc.code}): {message}") from exc
|
|
except error.URLError as exc: # pragma: no cover - defensive network guard
|
|
raise LLMClientError(f"LLM request failed: {exc.reason}") from exc
|
|
except Exception as exc: # pragma: no cover - defensive network guard
|
|
raise LLMClientError("LLM request failed") from exc
|
|
|
|
if not body:
|
|
return None
|
|
try:
|
|
return json.loads(body.decode("utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise LLMClientError("LLM response was not valid JSON") from exc
|
|
|
|
|
|
def list_models(configuration: LLMConfiguration) -> List[Dict[str, str]]:
|
|
if not configuration.is_configured() and not configuration.base_url.strip():
|
|
raise LLMClientError("LLM configuration is incomplete")
|
|
url = _build_url(configuration.base_url, "v1/models")
|
|
headers = _build_headers(configuration.api_key)
|
|
payload = _perform_request(
|
|
"GET", url, headers=headers, timeout=configuration.timeout
|
|
)
|
|
if not isinstance(payload, Mapping):
|
|
raise LLMClientError("Unexpected response when listing models")
|
|
data = payload.get("data")
|
|
if not isinstance(data, list):
|
|
return []
|
|
models: List[Dict[str, str]] = []
|
|
for entry in data:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
identifier = str(entry.get("id") or "").strip()
|
|
if not identifier:
|
|
continue
|
|
description = str(entry.get("name") or entry.get("description") or identifier)
|
|
models.append({"id": identifier, "label": description})
|
|
return models
|
|
|
|
|
|
def generate_completion(
|
|
configuration: LLMConfiguration,
|
|
*,
|
|
system_message: str,
|
|
user_message: str,
|
|
temperature: float = 0.2,
|
|
max_tokens: Optional[int] = None,
|
|
tools: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
tool_choice: Optional[Mapping[str, Any]] = None,
|
|
response_format: Optional[Mapping[str, Any]] = None,
|
|
) -> LLMCompletion:
|
|
if not configuration.is_configured():
|
|
raise LLMClientError("LLM configuration is incomplete")
|
|
|
|
url = _build_url(configuration.base_url, "v1/chat/completions")
|
|
headers = _build_headers(configuration.api_key)
|
|
payload: Dict[str, Any] = {
|
|
"model": configuration.model,
|
|
"messages": [
|
|
{"role": "system", "content": system_message},
|
|
{"role": "user", "content": user_message},
|
|
],
|
|
"temperature": temperature,
|
|
}
|
|
if max_tokens is not None:
|
|
payload["max_tokens"] = max_tokens
|
|
if tools:
|
|
payload["tools"] = list(tools)
|
|
if tool_choice:
|
|
payload["tool_choice"] = dict(tool_choice)
|
|
if response_format:
|
|
payload["response_format"] = dict(response_format)
|
|
|
|
response = _perform_request(
|
|
"POST", url, headers=headers, payload=payload, timeout=configuration.timeout
|
|
)
|
|
if not isinstance(response, Mapping):
|
|
raise LLMClientError("Unexpected response from LLM")
|
|
choices = response.get("choices")
|
|
if not isinstance(choices, list) or not choices:
|
|
raise LLMClientError("LLM response did not include choices")
|
|
first = choices[0]
|
|
if not isinstance(first, Mapping):
|
|
raise LLMClientError("LLM response choice was invalid")
|
|
message = first.get("message")
|
|
content: Optional[str] = None
|
|
tool_calls: List[LLMToolCall] = []
|
|
if isinstance(message, Mapping):
|
|
content = message.get("content")
|
|
if isinstance(content, str):
|
|
stripped = content.strip()
|
|
if stripped:
|
|
content = stripped
|
|
else:
|
|
content = None
|
|
tool_call_entries = message.get("tool_calls")
|
|
if isinstance(tool_call_entries, list):
|
|
for entry in tool_call_entries:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
fn = entry.get("function")
|
|
if not isinstance(fn, Mapping):
|
|
continue
|
|
name = str(fn.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
args = fn.get("arguments", "")
|
|
if isinstance(args, (dict, list)):
|
|
arguments = json.dumps(args)
|
|
else:
|
|
arguments = str(args)
|
|
tool_calls.append(LLMToolCall(name=name, arguments=arguments))
|
|
if content:
|
|
return LLMCompletion(content=content, tool_calls=tuple(tool_calls))
|
|
text = first.get("text")
|
|
if isinstance(text, str):
|
|
stripped = text.strip()
|
|
if stripped:
|
|
content = stripped
|
|
if content or tool_calls:
|
|
return LLMCompletion(content=content, tool_calls=tuple(tool_calls))
|
|
raise LLMClientError("LLM response did not include text content")
|