Files
2026-05-12 19:40:31 +09:00

471 lines
16 KiB
Python

"""Language Model (LLM) integration tool for OntoCast.
This module provides integration with various language models through LangChain,
supporting both OpenAI and Ollama providers. It enables text generation and
structured data extraction capabilities with optional caching support.
Cache Usage:
The LLM tool supports caching of responses to avoid redundant API calls.
Caching uses a shared Cacher instance that manages cache directories for all tools.
The cache directory is managed by the shared Cacher class and follows these rules:
```python
from ontocast.tool.llm import LLMTool
from ontocast.config import LLMConfig
from ontocast.tool.cache import Cacher
# Create shared cache instance
shared_cache = Cacher()
# Create LLM tool with shared cache
llm_tool = await LLMTool.acreate(
config=LLMConfig(...),
cache=shared_cache
)
```
Default cache locations:
- Tests: .test_cache/llm/ in the current working directory
- Windows: %USERPROFILE%\\AppData\\Local\\ontocast\\llm\
- Unix/Linux: ~/.cache/ontocast/llm/ (or $XDG_CACHE_HOME/ontocast/llm/)
Cache files are stored as JSON files with filenames based on SHA256 hashes
of the prompt and LLM configuration. This ensures that identical prompts
with the same configuration will return cached responses.
The shared Cacher automatically manages subdirectories for different tools,
ensuring organized cache storage while maintaining a single cache instance.
"""
import asyncio
import logging
from functools import wraps
from typing import Any, Callable, Type, TypeVar
from langchain_core.language_models import BaseChatModel
from langchain_core.messages.ai import AIMessage
from langchain_core.output_parsers import PydanticOutputParser
from langchain_ollama import ChatOllama
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field, SecretStr
from ontocast.config import LLMConfig, LLMProvider
from .cache import Cacher, ToolCacher
from .onto import Tool
T = TypeVar("T", bound=BaseModel)
logger = logging.getLogger(__name__)
def track_llm_usage(func: Callable) -> Callable:
"""Decorator to track LLM usage automatically."""
@wraps(func)
def wrapper(self, *args, **kwargs):
# Get prompt for character counting
prompt = args[0] if args else ""
prompt_str = (
self._prompt_to_string(prompt)
if hasattr(self, "_prompt_to_string")
else str(prompt)
)
# Call the original function
result = func(self, *args, **kwargs)
# Track usage if budget tracker is available in the tool
if hasattr(self, "budget_tracker") and self.budget_tracker is not None:
chars_sent = len(prompt_str)
chars_received = (
len(result.content)
if hasattr(result, "content") and result.content
else 0
)
self.budget_tracker.add_usage(chars_sent, chars_received)
return result
@wraps(func)
async def async_wrapper(self, *args, **kwargs):
# Get prompt for character counting
prompt = args[0] if args else ""
prompt_str = (
self._prompt_to_string(prompt)
if hasattr(self, "_prompt_to_string")
else str(prompt)
)
# Call the original function
result = await func(self, *args, **kwargs)
# Track usage if budget tracker is available in the tool
if hasattr(self, "budget_tracker") and self.budget_tracker is not None:
chars_sent = len(prompt_str)
chars_received = (
len(result.content)
if hasattr(result, "content") and result.content
else len(str(result))
)
self.budget_tracker.add_usage(chars_sent, chars_received)
return result
return async_wrapper if asyncio.iscoroutinefunction(func) else wrapper
class LLMTool(Tool):
"""Tool for interacting with language models.
This class provides a unified interface for working with different language model
providers (OpenAI, Ollama) through LangChain. It supports both synchronous and
asynchronous operations.
Attributes:
config: LLMConfig object containing all LLM settings.
cache: Cacher instance for caching LLM responses.
"""
config: LLMConfig = Field(default_factory=LLMConfig)
cache: Any = Field(default=None, exclude=True)
budget_tracker: Any = Field(default=None, exclude=True)
def __init__(
self,
cache: Cacher | None = None,
budget_tracker: Any = None,
**kwargs,
):
"""Initialize the LLM tool.
Args:
cache: Optional shared Cacher instance. If None, creates a new one.
budget_tracker: Optional budget tracker instance for usage statistics.
**kwargs: Additional keyword arguments passed to the parent class.
"""
super().__init__(**kwargs)
self._llm = None
self.budget_tracker = budget_tracker
# Initialize cache - use shared cacher or create new one
if cache is not None:
self.cache = ToolCacher(cache, "llm")
else:
# Fallback for backward compatibility
shared_cache = Cacher()
self.cache = ToolCacher(shared_cache, "llm")
@classmethod
def create(
cls,
config: LLMConfig,
cache: Cacher | None = None,
budget_tracker: Any = None,
**kwargs,
):
"""Create a new LLM tool instance synchronously.
Args:
config: LLMConfig object containing LLM settings.
cache: Optional shared Cacher instance.
budget_tracker: Optional budget tracker instance for usage statistics.
**kwargs: Additional keyword arguments for initialization.
Returns:
LLMTool: A new instance of the LLM tool.
"""
return asyncio.run(
cls.acreate(
config=config, cache=cache, budget_tracker=budget_tracker, **kwargs
)
)
@classmethod
async def acreate(
cls,
config: LLMConfig,
cache: Cacher | None = None,
budget_tracker: Any = None,
**kwargs,
):
"""Create a new LLM tool instance asynchronously.
Args:
config: LLMConfig object containing LLM settings.
cache: Optional shared Cacher instance.
budget_tracker: Optional budget tracker instance for usage statistics.
**kwargs: Additional keyword arguments for initialization.
Returns:
LLMTool: A new instance of the LLM tool.
"""
# Create and initialize the instance with the config
self = cls(config=config, cache=cache, budget_tracker=budget_tracker, **kwargs)
await self.setup()
return self
async def setup(self):
"""Set up the language model based on the configured provider.
Raises:
ValueError: If the provider is not supported.
"""
if self.config.provider == LLMProvider.OPENAI:
if self.config.model_name.startswith("gpt-5"):
self.config.temperature = 1.0
logger.warning(
f"Setting temperature to {self.config.temperature} for gpt-5 class "
f"model {self.config.model_name}"
)
self._llm = ChatOpenAI(
model=self.config.model_name, # type: ignore
temperature=self.config.temperature,
base_url=self.config.base_url, # type: ignore
api_key=(
SecretStr(self.config.api_key) if self.config.api_key else None
), # type: ignore
)
elif self.config.provider == LLMProvider.OLLAMA:
self._llm = ChatOllama(
model=self.config.model_name,
base_url=self.config.base_url,
temperature=self.config.temperature,
)
else:
raise ValueError(f"Unsupported provider: {self.config.provider}")
@track_llm_usage
async def __call__(self, *args: Any, **kwds: Any) -> Any:
"""Call the language model directly (asynchronous).
Args:
*args: Positional arguments passed to the LLM.
**kwds: Keyword arguments passed to the LLM.
Returns:
Any: The LLM's response.
"""
# Extract prompt from args (first argument is typically the prompt)
prompt = args[0] if args else ""
# Prepare configuration for caching
config_dict = {
"provider": self.config.provider,
"model_name": self.config.model_name,
"temperature": self.config.temperature,
"base_url": self.config.base_url,
}
# Check cache first
cached_response = self.cache.get(prompt, config=config_dict, **kwds)
if cached_response is not None:
prompt_str = self._prompt_to_string(prompt)
logger.debug(f"Cache hit for __call__: {prompt_str[:50]}...")
# Return a mock BaseMessage object with the cached content
content = cached_response["content"]
content_str = content if isinstance(content, str) else str(content)
return AIMessage(content=content_str)
# Generate new response
prompt_str = self._prompt_to_string(prompt)
logger.debug(
f"Cache miss, calling LLM for __call__, prompt size {len(prompt_str[:50])}..."
)
response = await self.llm.ainvoke(*args, **kwds)
# Cache the response
response_data = {
"content": response.content,
"prompt": self._prompt_to_string(prompt),
"kwargs": kwds,
}
self.cache.set(prompt, response_data, config=config_dict, **kwds)
return response
@track_llm_usage
async def acall(self, *args: Any, **kwds: Any) -> Any:
"""Call the language model directly (asynchronous).
Args:
*args: Positional arguments passed to the LLM.
**kwds: Keyword arguments passed to the LLM.
Returns:
Any: The LLM's response.
"""
# Extract prompt from args (first argument is typically the prompt)
prompt = args[0] if args else ""
# Prepare configuration for caching
config_dict = {
"provider": self.config.provider,
"model_name": self.config.model_name,
"temperature": self.config.temperature,
"base_url": self.config.base_url,
}
# Check cache first
cached_response = self.cache.get(prompt, config=config_dict, **kwds)
if cached_response is not None:
prompt_str = self._prompt_to_string(prompt)
logger.debug(f"Cache hit for acall: {prompt_str[:50]}...")
# Return a mock BaseMessage object with the cached content
content = cached_response["content"]
content_str = content if isinstance(content, str) else str(content)
return AIMessage(content=content_str)
# Generate new response
prompt_str = self._prompt_to_string(prompt)
logger.debug(f"Cache miss, calling LLM for acall: {prompt_str[:50]}...")
response = await self.llm.ainvoke(*args, **kwds)
# Cache the response
response_data = {
"content": response.content,
"prompt": self._prompt_to_string(prompt),
"kwargs": kwds,
}
self.cache.set(prompt, response_data, config=config_dict, **kwds)
return response
@property
def llm(self) -> BaseChatModel:
"""Get the underlying language model instance.
Returns:
BaseChatModel: The configured language model.
Raises:
RuntimeError: If the LLM has not been properly initialized.
"""
if self._llm is None:
raise RuntimeError(
"LLM resource not properly initialized. Call setup() first."
)
return self._llm
def _prompt_to_string(self, prompt) -> str:
"""Convert various prompt types to string for caching.
Args:
prompt: The prompt object (string, StringPromptValue, etc.)
Returns:
str: String representation of the prompt.
"""
if isinstance(prompt, str):
return prompt
elif hasattr(prompt, "to_string"):
return prompt.to_string()
elif hasattr(prompt, "text"):
return prompt.text
elif hasattr(prompt, "content"):
return prompt.content
else:
return str(prompt)
@track_llm_usage
async def complete(self, prompt: str, **kwargs) -> Any:
"""Generate a completion for the given prompt.
Args:
prompt: The input prompt for generation.
**kwargs: Additional keyword arguments for generation.
Returns:
Any: The generated completion.
"""
# Prepare configuration for caching
config_dict = {
"provider": self.config.provider,
"model_name": self.config.model_name,
"temperature": self.config.temperature,
"base_url": self.config.base_url,
}
# Check cache first
cached_response = self.cache.get(prompt, config=config_dict, **kwargs)
if cached_response is not None:
logger.debug(f"Cache hit for prompt: {prompt[:50]}...")
content = cached_response["content"]
return content if isinstance(content, str) else str(content)
# Generate new response
logger.debug(f"Cache miss, calling LLM for prompt: {prompt[:50]}...")
response = await self.llm.ainvoke(prompt, **kwargs)
# Cache the response
response_data = {
"content": response.content,
"prompt": self._prompt_to_string(prompt),
"kwargs": kwargs,
}
self.cache.set(prompt, response_data, config=config_dict, **kwargs)
return response.content
@track_llm_usage
async def extract(self, prompt: str, output_schema: Type[T], **kwargs) -> T:
"""Extract structured data from the prompt according to a schema.
Args:
prompt: The input prompt for extraction.
output_schema: The Pydantic model class defining the output structure.
**kwargs: Additional keyword arguments for extraction.
Returns:
T: The extracted data conforming to the output schema.
"""
parser = PydanticOutputParser(pydantic_object=output_schema)
format_instructions = parser.get_format_instructions()
full_prompt = f"{prompt}\n\n{format_instructions}"
# Prepare configuration for caching
config_dict = {
"provider": self.config.provider,
"model_name": self.config.model_name,
"temperature": self.config.temperature,
"base_url": self.config.base_url,
"output_schema": output_schema.__name__,
}
# Check cache first
cached_response = self.cache.get(full_prompt, config=config_dict, **kwargs)
if cached_response is not None:
logger.debug(f"Cache hit for extraction: {prompt[:50]}...")
# Parse the cached content
content = cached_response["content"]
if isinstance(content, str):
return parser.parse(content)
else:
# Fallback: convert to string if it's not already
return parser.parse(str(content))
# Generate new response
logger.debug(f"Cache miss, calling LLM for extraction: {prompt[:50]}...")
response = await self.llm.ainvoke(full_prompt, **kwargs)
# Cache the response
response_data = {
"content": response.content,
"prompt": self._prompt_to_string(full_prompt),
"output_schema": output_schema.__name__,
"kwargs": kwargs,
}
self.cache.set(full_prompt, response_data, config=config_dict, **kwargs)
content = response.content
return parser.parse(content if isinstance(content, str) else str(content))