* Load operation validator extension in main entry point Enable the operation validator extension to be loaded from environment configuration and passed to MemoryEngine, allowing pre/post operation hooks for usage metering, rate limiting, and audit logging. * Fix reflect background task authentication and add internal flag - Pass API key to background opinion storage task for proper auth - Add internal flag to RequestContext for tracking internal operations - Background opinion storage now authenticates correctly with tenant * Add api_key_id to RequestContext for usage tracking - Add api_key_id field to RequestContext to track which API key was used - Enables per-API-key usage analytics in the metering system * Fix HTTP error handling for authentication and validation errors - Add status_code parameter to ValidationResult and OperationValidationError - Convert OperationValidationError to HTTPException with proper status codes - Fix authentication errors to return 401 instead of raising internal errors - Re-raise HTTPException in exception handlers to prevent swallowing errors * Fix AuthenticationError handling in memory engine - Raise AuthenticationError from memory_engine._authenticate_tenant instead of HTTPException so unit tests pass - Add AuthenticationError handling in HTTP layer to convert to 401 responses - Fixes failing TestMemoryEngineTenantAuth tests * Add global exception handler for AuthenticationError Returns proper 401 status code for all authentication failures across all endpoints, not just the ones with explicit handlers. * Simplify exception handling: use global AuthenticationError handler - Remove redundant individual exception handlers - Add 'except AuthenticationError: raise' before generic Exception handlers to let global handler process auth errors uniformly * Refactor background tasks to use tenant_id instead of api_key This makes the core more generic - it passes tenant_id (which is extension-agnostic) rather than api_key (which is cloud-specific). - Add tenant_id field to RequestContext - Pass tenant_id instead of api_key to background tasks - Extensions can check internal=True with tenant_id to bypass normal auth * Fix exception propagation: include HTTPException in re-raise After cleanup of redundant exception handlers, 404 errors were returning 500 because HTTPException was caught by the generic except Exception handler. Fixed by combining AuthenticationError and HTTPException in the re-raise pattern.
857 lines
38 KiB
Python
857 lines
38 KiB
Python
"""
|
|
LLM wrapper for unified configuration across providers.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from google import genai
|
|
from google.genai import errors as genai_errors
|
|
from google.genai import types as genai_types
|
|
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
|
|
|
from ..config import (
|
|
DEFAULT_LLM_MAX_CONCURRENT,
|
|
DEFAULT_LLM_TIMEOUT,
|
|
ENV_LLM_MAX_CONCURRENT,
|
|
ENV_LLM_TIMEOUT,
|
|
)
|
|
|
|
# Seed applied to every Groq request for deterministic behavior.
|
|
DEFAULT_LLM_SEED = 4242
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Disable httpx logging
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
|
# Global semaphore to limit concurrent LLM requests across all instances
|
|
# Set HINDSIGHT_API_LLM_MAX_CONCURRENT=1 for local LLMs (LM Studio, Ollama)
|
|
_llm_max_concurrent = int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT)))
|
|
_global_llm_semaphore = asyncio.Semaphore(_llm_max_concurrent)
|
|
|
|
|
|
class OutputTooLongError(Exception):
|
|
"""
|
|
Bridge exception raised when LLM output exceeds token limits.
|
|
|
|
This wraps provider-specific errors (e.g., OpenAI's LengthFinishReasonError)
|
|
to allow callers to handle output length issues without depending on
|
|
provider-specific implementations.
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
class LLMProvider:
|
|
"""
|
|
Unified LLM provider.
|
|
|
|
Supports OpenAI, Groq, Ollama (OpenAI-compatible), and Gemini.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider: str,
|
|
api_key: str,
|
|
base_url: str,
|
|
model: str,
|
|
reasoning_effort: str = "low",
|
|
):
|
|
"""
|
|
Initialize LLM provider.
|
|
|
|
Args:
|
|
provider: Provider name ("openai", "groq", "ollama", "gemini", "anthropic", "lmstudio").
|
|
api_key: API key.
|
|
base_url: Base URL for the API.
|
|
model: Model name.
|
|
reasoning_effort: Reasoning effort level for supported providers.
|
|
"""
|
|
self.provider = provider.lower()
|
|
self.api_key = api_key
|
|
self.base_url = base_url
|
|
self.model = model
|
|
self.reasoning_effort = reasoning_effort
|
|
|
|
# Validate provider
|
|
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio"]
|
|
if self.provider not in valid_providers:
|
|
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
|
|
|
# Set default base URLs
|
|
if not self.base_url:
|
|
if self.provider == "groq":
|
|
self.base_url = "https://api.groq.com/openai/v1"
|
|
elif self.provider == "ollama":
|
|
self.base_url = "http://localhost:11434/v1"
|
|
elif self.provider == "lmstudio":
|
|
self.base_url = "http://localhost:1234/v1"
|
|
|
|
# Validate API key (not needed for ollama or lmstudio)
|
|
if self.provider not in ("ollama", "lmstudio") and not self.api_key:
|
|
raise ValueError(f"API key not found for {self.provider}")
|
|
|
|
# Get timeout config (set HINDSIGHT_API_LLM_TIMEOUT for local LLMs that need longer timeouts)
|
|
self.timeout = float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
|
|
|
|
# Create client based on provider
|
|
self._client = None
|
|
self._gemini_client = None
|
|
self._anthropic_client = None
|
|
|
|
if self.provider == "gemini":
|
|
self._gemini_client = genai.Client(api_key=self.api_key)
|
|
elif self.provider == "anthropic":
|
|
from anthropic import AsyncAnthropic
|
|
|
|
# Only pass base_url if it's set (Anthropic uses default URL otherwise)
|
|
anthropic_kwargs = {"api_key": self.api_key}
|
|
if self.base_url:
|
|
anthropic_kwargs["base_url"] = self.base_url
|
|
if self.timeout:
|
|
anthropic_kwargs["timeout"] = self.timeout
|
|
self._anthropic_client = AsyncAnthropic(**anthropic_kwargs)
|
|
elif self.provider in ("ollama", "lmstudio"):
|
|
# Use dummy key if not provided for local
|
|
api_key = self.api_key or "local"
|
|
client_kwargs = {"api_key": api_key, "base_url": self.base_url, "max_retries": 0}
|
|
if self.timeout:
|
|
client_kwargs["timeout"] = self.timeout
|
|
self._client = AsyncOpenAI(**client_kwargs)
|
|
else:
|
|
# Only pass base_url if it's set (OpenAI uses default URL otherwise)
|
|
client_kwargs = {"api_key": self.api_key, "max_retries": 0}
|
|
if self.base_url:
|
|
client_kwargs["base_url"] = self.base_url
|
|
if self.timeout:
|
|
client_kwargs["timeout"] = self.timeout
|
|
self._client = AsyncOpenAI(**client_kwargs)
|
|
|
|
async def verify_connection(self) -> None:
|
|
"""
|
|
Verify that the LLM provider is configured correctly by making a simple test call.
|
|
|
|
Raises:
|
|
RuntimeError: If the connection test fails.
|
|
"""
|
|
try:
|
|
logger.info(
|
|
f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}..."
|
|
)
|
|
await self.call(
|
|
messages=[{"role": "user", "content": "Say 'ok'"}],
|
|
max_completion_tokens=100,
|
|
max_retries=2,
|
|
initial_backoff=0.5,
|
|
max_backoff=2.0,
|
|
)
|
|
# If we get here without exception, the connection is working
|
|
logger.info(f"LLM verified: {self.provider}/{self.model}")
|
|
except Exception as e:
|
|
raise RuntimeError(f"LLM connection verification failed for {self.provider}/{self.model}: {e}") from e
|
|
|
|
async def call(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any | None = None,
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "memory",
|
|
max_retries: int = 10,
|
|
initial_backoff: float = 1.0,
|
|
max_backoff: float = 60.0,
|
|
skip_validation: bool = False,
|
|
strict_schema: bool = False,
|
|
) -> Any:
|
|
"""
|
|
Make an LLM API call with retry logic.
|
|
|
|
Args:
|
|
messages: List of message dicts with 'role' and 'content'.
|
|
response_format: Optional Pydantic model for structured output.
|
|
max_completion_tokens: Maximum tokens in response.
|
|
temperature: Sampling temperature (0.0-2.0).
|
|
scope: Scope identifier for tracking.
|
|
max_retries: Maximum retry attempts.
|
|
initial_backoff: Initial backoff time in seconds.
|
|
max_backoff: Maximum backoff time in seconds.
|
|
skip_validation: Return raw JSON without Pydantic validation.
|
|
strict_schema: Use strict JSON schema enforcement (OpenAI only). Guarantees all required fields.
|
|
|
|
Returns:
|
|
Parsed response if response_format is provided, otherwise text content.
|
|
|
|
Raises:
|
|
OutputTooLongError: If output exceeds token limits.
|
|
Exception: Re-raises API errors after retries exhausted.
|
|
"""
|
|
async with _global_llm_semaphore:
|
|
start_time = time.time()
|
|
|
|
# Handle Gemini provider separately
|
|
if self.provider == "gemini":
|
|
return await self._call_gemini(
|
|
messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time
|
|
)
|
|
|
|
# Handle Anthropic provider separately
|
|
if self.provider == "anthropic":
|
|
return await self._call_anthropic(
|
|
messages,
|
|
response_format,
|
|
max_completion_tokens,
|
|
max_retries,
|
|
initial_backoff,
|
|
max_backoff,
|
|
skip_validation,
|
|
start_time,
|
|
)
|
|
|
|
# Handle Ollama with native API for structured output (better schema enforcement)
|
|
if self.provider == "ollama" and response_format is not None:
|
|
return await self._call_ollama_native(
|
|
messages,
|
|
response_format,
|
|
max_completion_tokens,
|
|
temperature,
|
|
max_retries,
|
|
initial_backoff,
|
|
max_backoff,
|
|
skip_validation,
|
|
start_time,
|
|
)
|
|
|
|
call_params = {
|
|
"model": self.model,
|
|
"messages": messages,
|
|
}
|
|
|
|
# Check if model supports reasoning parameter (o1, o3, gpt-5 families)
|
|
model_lower = self.model.lower()
|
|
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3", "deepseek"])
|
|
|
|
# For GPT-4 and GPT-4.1 models, cap max_completion_tokens to 32000
|
|
# For GPT-4o models, cap to 16384
|
|
is_gpt4_model = any(x in model_lower for x in ["gpt-4.1", "gpt-4-"])
|
|
is_gpt4o_model = "gpt-4o" in model_lower
|
|
if max_completion_tokens is not None:
|
|
if is_gpt4o_model and max_completion_tokens > 16384:
|
|
max_completion_tokens = 16384
|
|
elif is_gpt4_model and max_completion_tokens > 32000:
|
|
max_completion_tokens = 32000
|
|
# For reasoning models, max_completion_tokens includes reasoning + output tokens
|
|
# Enforce minimum of 16000 to ensure enough space for both
|
|
if is_reasoning_model and max_completion_tokens < 16000:
|
|
max_completion_tokens = 16000
|
|
call_params["max_completion_tokens"] = max_completion_tokens
|
|
|
|
# GPT-5/o1/o3 family doesn't support custom temperature (only default 1)
|
|
if temperature is not None and not is_reasoning_model:
|
|
call_params["temperature"] = temperature
|
|
|
|
# Set reasoning_effort for reasoning models (OpenAI gpt-5, o1, o3)
|
|
if is_reasoning_model:
|
|
call_params["reasoning_effort"] = self.reasoning_effort
|
|
|
|
# Provider-specific parameters
|
|
if self.provider == "groq":
|
|
call_params["seed"] = DEFAULT_LLM_SEED
|
|
extra_body = {"service_tier": "auto"}
|
|
# Only add reasoning parameters for reasoning models
|
|
if is_reasoning_model:
|
|
extra_body["include_reasoning"] = False
|
|
call_params["extra_body"] = extra_body
|
|
|
|
last_exception = None
|
|
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
if response_format is not None:
|
|
schema = None
|
|
if hasattr(response_format, "model_json_schema"):
|
|
schema = response_format.model_json_schema()
|
|
|
|
if strict_schema and schema is not None:
|
|
# Use OpenAI's strict JSON schema enforcement
|
|
# This guarantees all required fields are returned
|
|
call_params["response_format"] = {
|
|
"type": "json_schema",
|
|
"json_schema": {
|
|
"name": "response",
|
|
"strict": True,
|
|
"schema": schema,
|
|
},
|
|
}
|
|
else:
|
|
# Soft enforcement: add schema to prompt and use json_object mode
|
|
if schema is not None:
|
|
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
|
|
|
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
|
|
call_params["messages"][0]["content"] += schema_msg
|
|
elif call_params["messages"]:
|
|
call_params["messages"][0]["content"] = (
|
|
schema_msg + "\n\n" + call_params["messages"][0]["content"]
|
|
)
|
|
if self.provider not in ("lmstudio", "ollama"):
|
|
call_params["response_format"] = {"type": "json_object"}
|
|
|
|
logger.debug(f"Sending request to {self.provider}/{self.model} (timeout={self.timeout})")
|
|
response = await self._client.chat.completions.create(**call_params)
|
|
logger.debug(f"Received response from {self.provider}/{self.model}")
|
|
|
|
content = response.choices[0].message.content
|
|
|
|
# For local models, they may wrap JSON in markdown code blocks
|
|
if self.provider in ("lmstudio", "ollama"):
|
|
clean_content = content
|
|
if "```json" in content:
|
|
clean_content = content.split("```json")[1].split("```")[0].strip()
|
|
elif "```" in content:
|
|
clean_content = content.split("```")[1].split("```")[0].strip()
|
|
try:
|
|
json_data = json.loads(clean_content)
|
|
except json.JSONDecodeError:
|
|
# Fallback to parsing raw content
|
|
json_data = json.loads(content)
|
|
else:
|
|
# Log raw LLM response for debugging JSON parse issues
|
|
try:
|
|
json_data = json.loads(content)
|
|
except json.JSONDecodeError as json_err:
|
|
# Truncate content for logging (first 500 and last 200 chars)
|
|
content_preview = content[:500] if content else "<empty>"
|
|
if content and len(content) > 700:
|
|
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
|
logger.warning(
|
|
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
|
f" Model: {self.provider}/{self.model}\n"
|
|
f" Content length: {len(content) if content else 0} chars\n"
|
|
f" Content preview: {content_preview!r}\n"
|
|
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
|
|
)
|
|
# Retry on JSON parse errors - LLM may return valid JSON on next attempt
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
last_exception = json_err
|
|
continue
|
|
else:
|
|
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
|
|
raise
|
|
|
|
if skip_validation:
|
|
result = json_data
|
|
else:
|
|
result = response_format.model_validate(json_data)
|
|
else:
|
|
response = await self._client.chat.completions.create(**call_params)
|
|
result = response.choices[0].message.content
|
|
|
|
# Log slow calls
|
|
duration = time.time() - start_time
|
|
usage = response.usage
|
|
if duration > 10.0:
|
|
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
|
|
cached_tokens = 0
|
|
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
|
|
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
|
|
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
|
|
logger.info(
|
|
f"slow llm call: model={self.provider}/{self.model}, "
|
|
f"input_tokens={usage.prompt_tokens}, output_tokens={usage.completion_tokens}, "
|
|
f"total_tokens={usage.total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
|
|
)
|
|
|
|
return result
|
|
|
|
except LengthFinishReasonError as e:
|
|
logger.warning(f"LLM output exceeded token limits: {str(e)}")
|
|
raise OutputTooLongError(
|
|
"LLM output exceeded token limits. Input may need to be split into smaller chunks."
|
|
) from e
|
|
|
|
except APIConnectionError as e:
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
status_code = getattr(e, "status_code", None) or getattr(
|
|
getattr(e, "response", None), "status_code", None
|
|
)
|
|
logger.warning(
|
|
f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}"
|
|
)
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(f"Connection error after {max_retries + 1} attempts: {str(e)}")
|
|
raise
|
|
|
|
except APIStatusError as e:
|
|
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries
|
|
if e.status_code in (401, 403):
|
|
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
|
|
raise
|
|
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
|
sleep_time = backoff + jitter
|
|
await asyncio.sleep(sleep_time)
|
|
else:
|
|
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}")
|
|
raise
|
|
|
|
if last_exception:
|
|
raise last_exception
|
|
raise RuntimeError("LLM call failed after all retries with no exception captured")
|
|
|
|
async def _call_anthropic(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any | None,
|
|
max_completion_tokens: int | None,
|
|
max_retries: int,
|
|
initial_backoff: float,
|
|
max_backoff: float,
|
|
skip_validation: bool,
|
|
start_time: float,
|
|
) -> Any:
|
|
"""Handle Anthropic-specific API calls."""
|
|
from anthropic import APIConnectionError, APIStatusError, RateLimitError
|
|
|
|
# Convert OpenAI-style messages to Anthropic format
|
|
system_prompt = None
|
|
anthropic_messages = []
|
|
|
|
for msg in messages:
|
|
role = msg.get("role", "user")
|
|
content = msg.get("content", "")
|
|
|
|
if role == "system":
|
|
if system_prompt:
|
|
system_prompt += "\n\n" + content
|
|
else:
|
|
system_prompt = content
|
|
else:
|
|
anthropic_messages.append({"role": role, "content": content})
|
|
|
|
# Add JSON schema instruction if response_format is provided
|
|
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
|
schema = response_format.model_json_schema()
|
|
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
|
if system_prompt:
|
|
system_prompt += schema_msg
|
|
else:
|
|
system_prompt = schema_msg
|
|
|
|
# Prepare parameters
|
|
call_params = {
|
|
"model": self.model,
|
|
"messages": anthropic_messages,
|
|
"max_tokens": max_completion_tokens if max_completion_tokens is not None else 4096,
|
|
}
|
|
|
|
if system_prompt:
|
|
call_params["system"] = system_prompt
|
|
|
|
last_exception = None
|
|
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
response = await self._anthropic_client.messages.create(**call_params)
|
|
|
|
# Anthropic response content is a list of blocks
|
|
content = ""
|
|
for block in response.content:
|
|
if block.type == "text":
|
|
content += block.text
|
|
|
|
if response_format is not None:
|
|
# Models may wrap JSON in markdown code blocks
|
|
clean_content = content
|
|
if "```json" in content:
|
|
clean_content = content.split("```json")[1].split("```")[0].strip()
|
|
elif "```" in content:
|
|
clean_content = content.split("```")[1].split("```")[0].strip()
|
|
|
|
try:
|
|
json_data = json.loads(clean_content)
|
|
except json.JSONDecodeError:
|
|
# Fallback to parsing raw content if markdown stripping failed
|
|
json_data = json.loads(content)
|
|
|
|
if skip_validation:
|
|
result = json_data
|
|
else:
|
|
result = response_format.model_validate(json_data)
|
|
else:
|
|
result = content
|
|
|
|
# Log slow calls
|
|
duration = time.time() - start_time
|
|
if duration > 10.0:
|
|
input_tokens = response.usage.input_tokens
|
|
output_tokens = response.usage.output_tokens
|
|
logger.info(
|
|
f"slow llm call: model={self.provider}/{self.model}, "
|
|
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
|
f"time={duration:.3f}s"
|
|
)
|
|
|
|
return result
|
|
|
|
except json.JSONDecodeError as e:
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
logger.warning("Anthropic returned invalid JSON, retrying...")
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(f"Anthropic returned invalid JSON after {max_retries + 1} attempts")
|
|
raise
|
|
|
|
except (APIConnectionError, RateLimitError, APIStatusError) as e:
|
|
# Fast fail on 401/403
|
|
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
|
|
logger.error(f"Anthropic auth error (HTTP {e.status_code}), not retrying: {str(e)}")
|
|
raise
|
|
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
# Check if it's a rate limit or server error
|
|
should_retry = isinstance(e, (APIConnectionError, RateLimitError)) or (
|
|
isinstance(e, APIStatusError) and e.status_code >= 500
|
|
)
|
|
|
|
if should_retry:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
|
await asyncio.sleep(backoff + jitter)
|
|
continue
|
|
|
|
logger.error(f"Anthropic API error after {max_retries + 1} attempts: {str(e)}")
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during Anthropic call: {type(e).__name__}: {str(e)}")
|
|
raise
|
|
|
|
if last_exception:
|
|
raise last_exception
|
|
raise RuntimeError("Anthropic call failed after all retries")
|
|
|
|
async def _call_ollama_native(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any,
|
|
max_completion_tokens: int | None,
|
|
temperature: float | None,
|
|
max_retries: int,
|
|
initial_backoff: float,
|
|
max_backoff: float,
|
|
skip_validation: bool,
|
|
start_time: float,
|
|
) -> Any:
|
|
"""
|
|
Call Ollama using native API with JSON schema enforcement.
|
|
|
|
Ollama's native API supports passing a full JSON schema in the 'format' parameter,
|
|
which provides better structured output control than the OpenAI-compatible API.
|
|
"""
|
|
# Get the JSON schema from the Pydantic model
|
|
schema = response_format.model_json_schema() if hasattr(response_format, "model_json_schema") else None
|
|
|
|
# Build the base URL for Ollama's native API
|
|
# Default OpenAI-compatible URL is http://localhost:11434/v1
|
|
# Native API is at http://localhost:11434/api/chat
|
|
base_url = self.base_url or "http://localhost:11434/v1"
|
|
if base_url.endswith("/v1"):
|
|
native_url = base_url[:-3] + "/api/chat"
|
|
else:
|
|
native_url = base_url.rstrip("/") + "/api/chat"
|
|
|
|
# Build request payload
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": messages,
|
|
"stream": False,
|
|
}
|
|
|
|
# Add schema as format parameter for structured output
|
|
if schema:
|
|
payload["format"] = schema
|
|
|
|
# Add optional parameters with optimized defaults for Ollama
|
|
# Benchmarking shows num_ctx=16384 + num_batch=512 is optimal
|
|
options = {
|
|
"num_ctx": 16384, # 16k context window for larger prompts
|
|
"num_batch": 512, # Optimal batch size for prompt processing
|
|
}
|
|
if max_completion_tokens:
|
|
options["num_predict"] = max_completion_tokens
|
|
if temperature is not None:
|
|
options["temperature"] = temperature
|
|
payload["options"] = options
|
|
|
|
last_exception = None
|
|
|
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
response = await client.post(native_url, json=payload)
|
|
response.raise_for_status()
|
|
|
|
result = response.json()
|
|
content = result.get("message", {}).get("content", "")
|
|
|
|
# Parse JSON response
|
|
try:
|
|
json_data = json.loads(content)
|
|
except json.JSONDecodeError as json_err:
|
|
content_preview = content[:500] if content else "<empty>"
|
|
if content and len(content) > 700:
|
|
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
|
logger.warning(
|
|
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
|
f" Model: ollama/{self.model}\n"
|
|
f" Content length: {len(content) if content else 0} chars\n"
|
|
f" Content preview: {content_preview!r}"
|
|
)
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
last_exception = json_err
|
|
continue
|
|
else:
|
|
raise
|
|
|
|
# Validate against Pydantic model or return raw JSON
|
|
if skip_validation:
|
|
return json_data
|
|
else:
|
|
return response_format.model_validate(json_data)
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
logger.warning(
|
|
f"Ollama HTTP error (attempt {attempt + 1}/{max_retries + 1}): {e.response.status_code}"
|
|
)
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(f"Ollama HTTP error after {max_retries + 1} attempts: {e}")
|
|
raise
|
|
|
|
except httpx.RequestError as e:
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
logger.warning(f"Ollama connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(f"Ollama connection error after {max_retries + 1} attempts: {e}")
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during Ollama call: {type(e).__name__}: {e}")
|
|
raise
|
|
|
|
if last_exception:
|
|
raise last_exception
|
|
raise RuntimeError("Ollama call failed after all retries")
|
|
|
|
async def _call_gemini(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any | None,
|
|
max_retries: int,
|
|
initial_backoff: float,
|
|
max_backoff: float,
|
|
skip_validation: bool,
|
|
start_time: float,
|
|
) -> Any:
|
|
"""Handle Gemini-specific API calls."""
|
|
# Convert OpenAI-style messages to Gemini format
|
|
system_instruction = None
|
|
gemini_contents = []
|
|
|
|
for msg in messages:
|
|
role = msg.get("role", "user")
|
|
content = msg.get("content", "")
|
|
|
|
if role == "system":
|
|
if system_instruction:
|
|
system_instruction += "\n\n" + content
|
|
else:
|
|
system_instruction = content
|
|
elif role == "assistant":
|
|
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
|
|
else:
|
|
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
|
|
|
|
# Add JSON schema instruction if response_format is provided
|
|
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
|
schema = response_format.model_json_schema()
|
|
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
|
if system_instruction:
|
|
system_instruction += schema_msg
|
|
else:
|
|
system_instruction = schema_msg
|
|
|
|
# Build generation config
|
|
config_kwargs = {}
|
|
if system_instruction:
|
|
config_kwargs["system_instruction"] = system_instruction
|
|
if response_format is not None:
|
|
config_kwargs["response_mime_type"] = "application/json"
|
|
config_kwargs["response_schema"] = response_format
|
|
|
|
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
|
|
|
|
last_exception = None
|
|
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
response = await self._gemini_client.aio.models.generate_content(
|
|
model=self.model,
|
|
contents=gemini_contents,
|
|
config=generation_config,
|
|
)
|
|
|
|
content = response.text
|
|
|
|
# Handle empty response
|
|
if content is None:
|
|
block_reason = None
|
|
if hasattr(response, "candidates") and response.candidates:
|
|
candidate = response.candidates[0]
|
|
if hasattr(candidate, "finish_reason"):
|
|
block_reason = candidate.finish_reason
|
|
|
|
if attempt < max_retries:
|
|
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...")
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts")
|
|
|
|
if response_format is not None:
|
|
json_data = json.loads(content)
|
|
if skip_validation:
|
|
result = json_data
|
|
else:
|
|
result = response_format.model_validate(json_data)
|
|
else:
|
|
result = content
|
|
|
|
# Log slow calls
|
|
duration = time.time() - start_time
|
|
if duration > 10.0 and hasattr(response, "usage_metadata") and response.usage_metadata:
|
|
usage = response.usage_metadata
|
|
logger.info(
|
|
f"slow llm call: model={self.provider}/{self.model}, "
|
|
f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}, "
|
|
f"time={duration:.3f}s"
|
|
)
|
|
|
|
return result
|
|
|
|
except json.JSONDecodeError as e:
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
logger.warning("Gemini returned invalid JSON, retrying...")
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts")
|
|
raise
|
|
|
|
except genai_errors.APIError as e:
|
|
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries
|
|
if e.code in (401, 403):
|
|
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
|
|
raise
|
|
|
|
# Retry on retryable errors (rate limits, server errors, and other client errors like 400)
|
|
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
|
await asyncio.sleep(backoff + jitter)
|
|
else:
|
|
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
|
|
raise
|
|
else:
|
|
logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}")
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}")
|
|
raise
|
|
|
|
if last_exception:
|
|
raise last_exception
|
|
raise RuntimeError("Gemini call failed after all retries")
|
|
|
|
@classmethod
|
|
def for_memory(cls) -> "LLMProvider":
|
|
"""Create provider for memory operations from environment variables."""
|
|
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
|
|
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY")
|
|
if not api_key:
|
|
raise ValueError("HINDSIGHT_API_LLM_API_KEY environment variable is required")
|
|
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
|
|
model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")
|
|
|
|
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="low")
|
|
|
|
@classmethod
|
|
def for_answer_generation(cls) -> "LLMProvider":
|
|
"""Create provider for answer generation. Falls back to memory config if not set."""
|
|
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
|
|
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
|
|
if not api_key:
|
|
raise ValueError(
|
|
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required"
|
|
)
|
|
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
|
|
model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
|
|
|
|
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
|
|
|
|
@classmethod
|
|
def for_judge(cls) -> "LLMProvider":
|
|
"""Create provider for judge/evaluator operations. Falls back to memory config if not set."""
|
|
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
|
|
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
|
|
if not api_key:
|
|
raise ValueError(
|
|
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required"
|
|
)
|
|
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
|
|
model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
|
|
|
|
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
|
|
|
|
|
|
# Backwards compatibility alias
|
|
LLMConfig = LLMProvider
|