FGAO-API / src /fgao /rag /llms.py
Angshul's picture
Upload 27 files
0703e2b verified
Raw History Blame Contribute Delete
3.73 kB
"""Generic LLM wrappers and coercion."""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import os
import threading
from typing import Any, Callable, Dict, Optional
async def acomplete(llm: Any, prompt: str) -> str:
"""Native ``acomplete`` when the LLM has one, otherwise ``complete`` in a worker thread."""
fn = getattr(llm, "acomplete", None)
if fn is not None:
return await fn(prompt)
return await asyncio.to_thread(llm.complete, prompt)
class FunctionLLM:
"""Wrap ``fn(prompt) -> str`` (or an ``async def`` function)."""
def __init__(self, fn: Callable[[str], Any]) -> None:
self.fn = fn
if inspect.iscoroutinefunction(fn):
self.acomplete = self._acomplete # type: ignore[assignment]
def complete(self, prompt: str) -> str:
if inspect.iscoroutinefunction(self.fn):
raise TypeError("this LLM is async-only; use the async API (ainvoke / abatch / astream)")
return str(self.fn(prompt))
async def _acomplete(self, prompt: str) -> str:
return str(await self.fn(prompt))
class CachedLLM:
"""Thread-safe prompt -> response cache in front of any LLM.
With ``path`` the cache persists as JSON (written after every new
response). Useful for reproducible runs and to avoid paying twice for the
same deterministic call.
"""
def __init__(self, llm: Any, path: Optional[str] = None) -> None:
self.llm = as_llm(llm)
self.path = path
self._lock = threading.Lock()
self.cache: Dict[str, Dict[str, str]] = {}
if path and os.path.exists(path):
with open(path, encoding="utf-8") as fh:
self.cache = json.load(fh)
self.hits = 0
self.misses = 0
@staticmethod
def key(prompt: str) -> str:
return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16]
def complete(self, prompt: str) -> str:
k = self.key(prompt)
with self._lock:
hit = self.cache.get(k)
if hit is not None:
self.hits += 1
return hit["response"]
response = self.llm.complete(prompt)
self._store(k, prompt, response)
return response
async def acomplete(self, prompt: str) -> str:
k = self.key(prompt)
with self._lock:
hit = self.cache.get(k)
if hit is not None:
self.hits += 1
return hit["response"]
response = await acomplete(self.llm, prompt)
self._store(k, prompt, response)
return response
def _store(self, k: str, prompt: str, response: str) -> None:
with self._lock:
self.misses += 1
self.cache[k] = {"prompt": prompt, "response": response}
if self.path:
tmp = self.path + ".tmp"
os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True)
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self.cache, fh, ensure_ascii=False)
os.replace(tmp, self.path)
def as_llm(x: Any):
"""Accept an object with ``complete``, a LangChain model, or ``fn(prompt) -> str``."""
if x is None:
raise ValueError("an LLM is required")
if hasattr(x, "complete") and callable(getattr(x, "complete")):
return x
if hasattr(x, "invoke") and callable(getattr(x, "invoke")):
from ..integrations.langchain import LangChainLLM
return LangChainLLM(x)
if callable(x):
return FunctionLLM(x)
raise TypeError(f"cannot use {type(x).__name__} as an LLM: give .complete(prompt), .invoke(prompt) or a function")