84b3b7d8e8
Adds a complete pytest system test suite for the HoneyBiscuitWorkshop CI/CD system: - runner.sh: orchestrates build, Docker image creation, and test execution - Dockerfile: Arch Linux-based test image with Python venv - conftest.py: shared fixtures (baker_bin, run_baker, work_dir, llm_env) - probes/: environment probes for checking users, files, packages, processes - llm/: LLM-based log judgment system with caching for semantic test evaluation - fixtures/: test configs and scripts for prebake, bake, and finalize stages - test_prebake.py, test_bake.py, test_finalize.py: test suites for each pipeline stage Also refactors finalize.yml.tmpl notification config to use a templates system.
188 lines
6.3 KiB
Python
188 lines
6.3 KiB
Python
"""
|
|
LLM client supporting both Anthropic and OpenAI APIs.
|
|
|
|
Prefers Anthropic format (per project convention).
|
|
Falls back to OpenAI format if Anthropic is unavailable.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from .cache import LLMCache
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LLMClient:
|
|
"""LLM client with caching support. Prefers Anthropic API."""
|
|
|
|
def __init__(self, cache: LLMCache | None = None):
|
|
self.cache = cache or LLMCache()
|
|
self._anthropic_client = None
|
|
self._openai_client = None
|
|
self._init_clients()
|
|
|
|
def _init_clients(self):
|
|
"""Initialize API clients based on available environment variables."""
|
|
# Anthropic (preferred)
|
|
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
anthropic_base = os.environ.get("ANTHROPIC_BASE_URL")
|
|
if anthropic_key:
|
|
try:
|
|
import anthropic
|
|
|
|
kwargs: dict = {"api_key": anthropic_key}
|
|
if anthropic_base:
|
|
kwargs["base_url"] = anthropic_base
|
|
self._anthropic_client = anthropic.Anthropic(**kwargs)
|
|
logger.info("Anthropic client initialized")
|
|
except ImportError:
|
|
logger.warning("anthropic package not installed")
|
|
|
|
# OpenAI (fallback)
|
|
openai_key = os.environ.get("OPENAI_API_KEY")
|
|
openai_base = os.environ.get("OPENAI_BASE_URL")
|
|
if openai_key:
|
|
try:
|
|
import openai
|
|
|
|
kwargs = {"api_key": openai_key}
|
|
if openai_base:
|
|
kwargs["base_url"] = openai_base
|
|
self._openai_client = openai.OpenAI(**kwargs)
|
|
logger.info("OpenAI client initialized")
|
|
except ImportError:
|
|
logger.warning("openai package not installed")
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Check if any LLM client is available."""
|
|
return (
|
|
self._anthropic_client is not None or self._openai_client is not None
|
|
)
|
|
|
|
def complete(
|
|
self, prompt: str, system_prompt: str = "", max_tokens: int = 2000
|
|
) -> str:
|
|
"""Send a completion request, with caching.
|
|
|
|
Args:
|
|
prompt: User message.
|
|
system_prompt: System message (sets role / persona).
|
|
max_tokens: Max response tokens.
|
|
|
|
Returns:
|
|
LLM response text.
|
|
|
|
Raises:
|
|
RuntimeError: If no client is available.
|
|
"""
|
|
model = os.environ.get("MODEL", "deepseek-v4-flash")
|
|
|
|
# Check cache
|
|
cached = self.cache.get(prompt, model, system_prompt)
|
|
if cached:
|
|
logger.info("Cache hit for LLM request")
|
|
return cached["content"]
|
|
|
|
# Try Anthropic first
|
|
if self._anthropic_client:
|
|
content = self._call_anthropic(
|
|
prompt, system_prompt, model, max_tokens
|
|
)
|
|
elif self._openai_client:
|
|
content = self._call_openai(prompt, system_prompt, model, max_tokens)
|
|
else:
|
|
raise RuntimeError(
|
|
"No LLM client available. "
|
|
"Set ANTHROPIC_API_KEY or OPENAI_API_KEY."
|
|
)
|
|
|
|
# Cache the response
|
|
self.cache.set(prompt, model, system_prompt, {"content": content})
|
|
return content
|
|
|
|
# ------------------------------------------------------------------
|
|
# Private helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _call_anthropic(
|
|
self, prompt: str, system_prompt: str, model: str, max_tokens: int
|
|
) -> str:
|
|
"""Call Anthropic API."""
|
|
kwargs: dict = {
|
|
"model": model,
|
|
"max_tokens": max_tokens,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
}
|
|
if system_prompt:
|
|
kwargs["system"] = system_prompt
|
|
|
|
# Only add reasoning_effort if the model supports it
|
|
reasoning_effort = os.environ.get("REASONING_EFFORT")
|
|
if reasoning_effort:
|
|
try:
|
|
kwargs["reasoning_effort"] = reasoning_effort
|
|
response = self._anthropic_client.messages.create(**kwargs)
|
|
except Exception as e:
|
|
if (
|
|
"reasoning_effort" in str(e).lower()
|
|
or "unexpected" in str(e).lower()
|
|
):
|
|
del kwargs["reasoning_effort"]
|
|
response = self._anthropic_client.messages.create(**kwargs)
|
|
else:
|
|
raise
|
|
else:
|
|
response = self._anthropic_client.messages.create(**kwargs)
|
|
|
|
# Extract text from response content blocks
|
|
if hasattr(response, "content") and response.content:
|
|
block = response.content[0]
|
|
if hasattr(block, "text"):
|
|
return block.text
|
|
if hasattr(block, "type") and block.type == "thinking":
|
|
for b in response.content:
|
|
if hasattr(b, "text"):
|
|
return b.text
|
|
return str(block)
|
|
return str(response)
|
|
|
|
def _call_openai(
|
|
self, prompt: str, system_prompt: str, model: str, max_tokens: int
|
|
) -> str:
|
|
"""Call OpenAI-compatible API."""
|
|
messages: list[dict] = []
|
|
if system_prompt:
|
|
messages.append({"role": "system", "content": system_prompt})
|
|
messages.append({"role": "user", "content": prompt})
|
|
|
|
kwargs: dict = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"max_tokens": max_tokens,
|
|
}
|
|
|
|
# Only add reasoning_effort if supported
|
|
reasoning_effort = os.environ.get("REASONING_EFFORT")
|
|
if reasoning_effort:
|
|
try:
|
|
kwargs["reasoning_effort"] = reasoning_effort
|
|
response = self._openai_client.chat.completions.create(
|
|
**kwargs
|
|
)
|
|
except Exception as e:
|
|
if (
|
|
"reasoning_effort" in str(e).lower()
|
|
or "unexpected" in str(e).lower()
|
|
):
|
|
del kwargs["reasoning_effort"]
|
|
response = self._openai_client.chat.completions.create(
|
|
**kwargs
|
|
)
|
|
else:
|
|
raise
|
|
else:
|
|
response = self._openai_client.chat.completions.create(**kwargs)
|
|
|
|
return response.choices[0].message.content
|