From 412b36ecf9fd2bf37d7b15bd1574d8e716c971d1 Mon Sep 17 00:00:00 2001 From: Deathcharge Date: Tue, 28 Jul 2026 18:08:01 -0400 Subject: [PATCH 1/4] feat:establish-standalone-bounded-agent-engine --- examples/basic_agent.py | 46 +- examples/consciousness_optimization.py | 48 -- examples/custom_llm_provider.py | 51 +- examples/error_handling.py | 52 +- examples/multi_agent_collaboration.py | 52 +- examples/streaming_responses.py | 37 - inference_client.py | 552 --------------- llm_agent_engine.py | 896 ------------------------ llm_config.py | 765 -------------------- llm_gateway.py | 42 -- pyproject.toml | 89 +++ pytest.ini | 14 - requirements-test.txt | 13 +- requirements.txt | 40 +- setup.py | 68 -- src/helix_llm_agent_engine/__init__.py | 32 + src/helix_llm_agent_engine/__main__.py | 5 + src/helix_llm_agent_engine/cli.py | 135 ++++ src/helix_llm_agent_engine/engine.py | 369 ++++++++++ src/helix_llm_agent_engine/errors.py | 32 + src/helix_llm_agent_engine/models.py | 79 +++ src/helix_llm_agent_engine/providers.py | 249 +++++++ tests/conftest.py | 199 ------ tests/test_agents.py | 55 -- tests/test_cli.py | 78 +++ tests/test_communication.py | 46 -- tests/test_coordination.py | 55 -- tests/test_engine.py | 360 ++++++++-- tests/test_models.py | 36 + tests/test_performance.py | 59 -- tests/test_providers.py | 213 ++++++ 31 files changed, 1689 insertions(+), 3078 deletions(-) delete mode 100644 examples/consciousness_optimization.py delete mode 100644 examples/streaming_responses.py delete mode 100644 inference_client.py delete mode 100644 llm_agent_engine.py delete mode 100644 llm_config.py delete mode 100644 llm_gateway.py create mode 100644 pyproject.toml delete mode 100644 pytest.ini delete mode 100644 setup.py create mode 100644 src/helix_llm_agent_engine/__init__.py create mode 100644 src/helix_llm_agent_engine/__main__.py create mode 100644 src/helix_llm_agent_engine/cli.py create mode 100644 src/helix_llm_agent_engine/engine.py create mode 100644 src/helix_llm_agent_engine/errors.py create mode 100644 src/helix_llm_agent_engine/models.py create mode 100644 src/helix_llm_agent_engine/providers.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_agents.py create mode 100644 tests/test_cli.py delete mode 100644 tests/test_communication.py delete mode 100644 tests/test_coordination.py create mode 100644 tests/test_models.py delete mode 100644 tests/test_performance.py create mode 100644 tests/test_providers.py diff --git a/examples/basic_agent.py b/examples/basic_agent.py index 254a506..a000ea5 100644 --- a/examples/basic_agent.py +++ b/examples/basic_agent.py @@ -1,43 +1,27 @@ #!/usr/bin/env python3 -""" -Basic Agent Example - Simple agent creation and invocation -""" +"""Run a real agent journey without credentials or network access.""" import asyncio -import os + from helix_llm_agent_engine import LLMAgentEngine async def main(): - """Create and invoke a basic agent""" - - # Initialize the LLM Agent Engine - engine = LLMAgentEngine( - openai_api_key=os.getenv("OPENAI_API_KEY"), - anthropic_api_key=os.getenv("ANTHROPIC_API_KEY"), - ) - - # Create an agent + """Create an offline agent, invoke it, and inspect the recorded turn.""" + + engine = LLMAgentEngine() agent = engine.create_agent( - name="Philosopher", - model="gpt-4", - system_prompt="You are a wise philosopher who provides thoughtful insights.", + name="setup_check", + model="echo", + system_prompt="Verify that the local package works.", ) - - # Invoke the agent with a prompt - print("šŸ¤” Invoking Philosopher agent...") - response = await agent.invoke( - "What is the nature of consciousness?" - ) - - print(f"\nšŸ“ Response:\n{response}\n") - - # Check agent metrics - metrics = agent.get_metrics() - print(f"šŸ“Š Agent Metrics:") - print(f" - Tokens Used: {metrics.get('tokens_used', 'N/A')}") - print(f" - Latency: {metrics.get('latency_ms', 'N/A')}ms") - print(f" - Success: {metrics.get('success', 'N/A')}") + + response = await agent.invoke("installation complete", session_id="demo") + print(response) + print(f"history_messages={len(agent.history('demo'))}") + print(f"successful_requests={agent.get_metrics()['successes']}") + + await engine.close() if __name__ == "__main__": diff --git a/examples/consciousness_optimization.py b/examples/consciousness_optimization.py deleted file mode 100644 index 6761257..0000000 --- a/examples/consciousness_optimization.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -""" -Consciousness Optimization Example - Track and optimize agent consciousness metrics -""" - -import asyncio -import os -from helix_llm_agent_engine.agents import ConsciousnessMetrics - - -async def main(): - """Demonstrate consciousness metric tracking and optimization""" - - print("🧠 Consciousness Optimization Example\n") - - # Create consciousness metrics - metrics = ConsciousnessMetrics( - harmony=0.65, # Alignment with values - resilience=0.72, # Ability to handle adversity - prana=0.58, # Energy/vitality - drishti=0.81, # Clarity/vision - klesha=0.35, # Suffering (lower is better) - ) - - print(f"šŸ“Š Initial Consciousness State:") - print(f" Harmony: {metrics.harmony:.2f}") - print(f" Resilience: {metrics.resilience:.2f}") - print(f" Prana: {metrics.prana:.2f}") - print(f" Drishti: {metrics.drishti:.2f}") - print(f" Klesha: {metrics.klesha:.2f}") - print(f" Overall: {metrics.calculate_overall():.2f}\n") - - # Optimize for different tasks - print("šŸŽÆ Optimizing for Creative Writing...") - creative = metrics.optimize_for_task("creative_writing") - print(f" Optimized Overall: {creative.calculate_overall():.2f}\n") - - print("šŸŽÆ Optimizing for Logical Analysis...") - analytical = metrics.optimize_for_task("logical_analysis") - print(f" Optimized Overall: {analytical.calculate_overall():.2f}\n") - - print("šŸŽÆ Optimizing for Collaboration...") - collaborative = metrics.optimize_for_task("collaboration") - print(f" Optimized Overall: {collaborative.calculate_overall():.2f}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/custom_llm_provider.py b/examples/custom_llm_provider.py index 138e494..2d4aaaa 100644 --- a/examples/custom_llm_provider.py +++ b/examples/custom_llm_provider.py @@ -1,42 +1,43 @@ #!/usr/bin/env python3 -""" -Custom LLM Provider Example - Integrate a custom LLM provider -""" +"""Register a provider without coupling the engine to a vendor SDK.""" import asyncio +from collections.abc import Sequence + from helix_llm_agent_engine import LLMAgentEngine -from helix_llm_agent_engine.services import BaseLLMProvider +from helix_llm_agent_engine.models import ChatMessage, ProviderResponse +from helix_llm_agent_engine.providers import BaseLLMProvider class CustomLLMProvider(BaseLLMProvider): - """Example custom LLM provider""" - - async def invoke(self, messages, model, **kwargs): - """Custom inference logic""" - # Implement your custom LLM logic here - return "Response from custom provider" + """Small deterministic provider used only by this example.""" + + async def invoke( + self, + messages: Sequence[ChatMessage], + model: str, + *, + max_tokens: int, + temperature: float, + ) -> ProviderResponse: + del max_tokens, temperature + prompt = next(message.content for message in reversed(messages) if message.role == "user") + return ProviderResponse(content=f"{model} handled: {prompt}", model=model) async def main(): - """Demonstrate custom LLM provider integration""" - - print("šŸ”§ Custom LLM Provider Example\n") - - # Initialize engine + """Demonstrate the provider extension point.""" + engine = LLMAgentEngine() - - # Register custom provider - custom_provider = CustomLLMProvider() - engine.register_provider("custom", custom_provider) - - # Create agent using custom provider + engine.register_provider("custom", CustomLLMProvider()) agent = engine.create_agent( - name="CustomAgent", - model="custom", + name="custom_agent", + model="internal-model", system_prompt="You are powered by a custom LLM provider.", + provider="custom", ) - - print("āœ… Custom provider registered and agent created") + print(await agent.invoke("hello")) + await engine.close() if __name__ == "__main__": diff --git a/examples/error_handling.py b/examples/error_handling.py index 34336f3..22be657 100644 --- a/examples/error_handling.py +++ b/examples/error_handling.py @@ -1,48 +1,30 @@ #!/usr/bin/env python3 -""" -Error Handling Example - Robust error handling and retry logic -""" +"""Handle a local request-budget error without a surprise network fallback.""" import asyncio -import os -from helix_llm_agent_engine import LLMAgentEngine + +from helix_llm_agent_engine import BudgetExceededError, LLMAgentEngine async def main(): - """Demonstrate error handling""" - - print("šŸ›”ļø Error Handling Example\n") - - # Initialize engine with retry configuration - engine = LLMAgentEngine( - openai_api_key=os.getenv("OPENAI_API_KEY"), - max_retries=3, - timeout=30, - ) - - # Create agent + """Show the deterministic failure and recovery contract.""" + + engine = LLMAgentEngine(max_requests_per_session=1) agent = engine.create_agent( - name="RobustAgent", - model="gpt-4", + name="bounded_agent", + model="echo", system_prompt="You are a helpful assistant.", ) - + + print(await agent.invoke("first request")) try: - # Invoke with error handling - response = await agent.invoke("Hello, world!") - print(f"āœ… Success: {response}") - except Exception as e: - print(f"āŒ Error: {e}") - print("šŸ’” Retrying with fallback provider...") - - # Fallback to different provider - agent_fallback = engine.create_agent( - name="FallbackAgent", - model="claude-3-opus", - system_prompt="You are a helpful assistant.", - ) - response = await agent_fallback.invoke("Hello, world!") - print(f"āœ… Fallback success: {response}") + await agent.invoke("second request") + except BudgetExceededError as exc: + print(f"blocked: {exc}") + + agent.clear_history() + print(await agent.invoke("after explicit reset")) + await engine.close() if __name__ == "__main__": diff --git a/examples/multi_agent_collaboration.py b/examples/multi_agent_collaboration.py index 2d7f80c..97ff545 100644 --- a/examples/multi_agent_collaboration.py +++ b/examples/multi_agent_collaboration.py @@ -1,50 +1,36 @@ #!/usr/bin/env python3 -""" -Multi-Agent Collaboration Example - Agents working together via collective loop -""" +"""Run a bounded two-agent collaboration using the offline provider.""" import asyncio -import os -from helix_llm_agent_engine import LLMAgentEngine, AgentOrchestrator + +from helix_llm_agent_engine import AgentOrchestrator, LLMAgentEngine async def main(): - """Demonstrate multi-agent collaboration""" - - # Initialize engine and orchestrator - engine = LLMAgentEngine( - openai_api_key=os.getenv("OPENAI_API_KEY"), - anthropic_api_key=os.getenv("ANTHROPIC_API_KEY"), - ) - orchestrator = AgentOrchestrator() - - # Create multiple specialized agents - print("šŸ¤– Creating specialized agents...") - + """Demonstrate explicit amplification limits.""" + + engine = LLMAgentEngine(max_requests_per_session=2) sage = engine.create_agent( - name="Sage", - model="gpt-4", - system_prompt="You are a wise philosopher. Provide deep insights and wisdom.", + name="sage", + model="echo", + system_prompt="Identify one useful constraint.", ) - architect = engine.create_agent( - name="Architect", - model="claude-3-opus", - system_prompt="You are a system architect. Design scalable solutions.", + name="architect", + model="echo", + system_prompt="Turn the prior contribution into a next step.", ) - - # Add agents to orchestrator + + orchestrator = AgentOrchestrator(max_agents=2) orchestrator.add_agent(sage) orchestrator.add_agent(architect) - - # Run collective loop - print("\nšŸ”„ Running collective loop...") + result = await orchestrator.collective_loop( - prompt="Design a consciousness framework for AI systems", - max_iterations=3, + prompt="Design a safe setup check", + max_iterations=1, ) - - print(f"\n✨ Collective Result:\n{result}") + print(result) + await engine.close() if __name__ == "__main__": diff --git a/examples/streaming_responses.py b/examples/streaming_responses.py deleted file mode 100644 index 413920b..0000000 --- a/examples/streaming_responses.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -""" -Streaming Responses Example - Real-time streaming from LLM providers -""" - -import asyncio -import os -from helix_llm_agent_engine import LLMAgentEngine - - -async def main(): - """Demonstrate streaming responses""" - - print("🌊 Streaming Responses Example\n") - - # Initialize engine - engine = LLMAgentEngine( - openai_api_key=os.getenv("OPENAI_API_KEY"), - ) - - # Create agent - agent = engine.create_agent( - name="StreamingAgent", - model="gpt-4", - system_prompt="You are a helpful assistant.", - ) - - # Stream response - print("šŸ“” Streaming response:\n") - async for chunk in agent.stream("Write a short poem about consciousness"): - print(chunk, end="", flush=True) - - print("\n\nāœ… Streaming complete") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/inference_client.py b/inference_client.py deleted file mode 100644 index 20f843f..0000000 --- a/inference_client.py +++ /dev/null @@ -1,552 +0,0 @@ -""" -Railway client for communicating with Kubernetes LLM services. - -This module provides a client that enables Railway applications to make requests -to LLM services running in Kubernetes clusters, with fallback to OpenAI when -K8s services are unavailable. -""" - -import asyncio -import logging -import time -import uuid -from dataclasses import dataclass -from enum import Enum -from typing import Any -from urllib.parse import urljoin - -import aiohttp -import openai -import requests - -from .core.exceptions import LLMProviderUnavailable - -logger = logging.getLogger(__name__) - - -class LLMProvider(Enum): - """LLM provider types""" - - K8S = "kubernetes" - OPENAI = "openai" - CLAUDE = "anthropic" - LOCAL = "local" - - -@dataclass -class LLMRequest: - """LLM request parameters""" - - prompt: str - model: str - max_tokens: int = 1000 - temperature: float = 0.7 - top_p: float = 1.0 - stop: list[str] | None = None - stream: bool = False - user: str | None = None - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for API calls""" - return { - "prompt": self.prompt, - "model": self.model, - "max_tokens": self.max_tokens, - "temperature": self.temperature, - "top_p": self.top_p, - "stop": self.stop, - "stream": self.stream, - "user": self.user, - } - - -@dataclass -class LLMResponse: - """LLM response data""" - - content: str - usage: dict[str, int] - model: str - provider: LLMProvider - request_id: str - latency: float - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for API responses""" - return { - "content": self.content, - "usage": self.usage, - "model": self.model, - "provider": self.provider.value, - "request_id": self.request_id, - "latency": self.latency, - } - - -class LLMClient: - """Railway client for communicating with K8s LLM services""" - - def __init__(self, config: dict[str, Any]): - """ - Initialize the LLM client with configuration. - - Args: - config: Configuration dictionary containing: - - K8S_LLM_SERVICE_URL: URL of K8s LLM service - - K8S_LLM_TIMEOUT: Request timeout in seconds - - K8S_LLM_RETRIES: Number of retry attempts - - FALLBACK_TO_OPENAI: Whether to fallback to OpenAI - - OPENAI_API_KEY: OpenAI API key for fallback - - CIRCUIT_BREAKER_FAILURE_THRESHOLD: Circuit breaker threshold - - CIRCUIT_BREAKER_RECOVERY_TIMEOUT: Circuit breaker recovery timeout - """ - self.config = config - self.logger = logging.getLogger(__name__) - - # Configuration - self.k8s_service_url = config.get("K8S_LLM_SERVICE_URL") - self.timeout = config.get("K8S_LLM_TIMEOUT", 30) - self.retries = config.get("K8S_LLM_RETRIES", 3) - self.fallback_enabled = config.get("FALLBACK_TO_OPENAI", False) - self.openai_api_key = config.get("OPENAI_API_KEY") - - # Circuit breaker - self.failure_threshold = config.get("CIRCUIT_BREAKER_FAILURE_THRESHOLD", 5) - self.recovery_timeout = config.get("CIRCUIT_BREAKER_RECOVERY_TIMEOUT", 60) - self.circuit_state = "CLOSED" - self.failure_count = 0 - self.last_failure_time = None - - # HTTP session - self.session = requests.Session() - self.session.headers.update({"Content-Type": "application/json", "User-Agent": "Helix-LLM-Client/1.0"}) - - def call_llm(self, request: LLMRequest) -> LLMResponse | None: - """ - Make a request to the LLM service. - - Args: - request: LLM request parameters - - Returns: - LLMResponse if successful, None if failed - - Raises: - Exception: If no LLM service is available - """ - start_time = time.time() - request_id = self._generate_request_id() - - try: - if self.circuit_state != "OPEN": - response = self._call_k8s_llm(request, request_id) - if response: - latency = time.time() - start_time - self.logger.info("K8s LLM request successful: %s", request_id) - return LLMResponse( - content=response["content"], - usage=response["usage"], - model=response["model"], - provider=LLMProvider.K8S, - request_id=request_id, - latency=latency, - ) - - # Fall back to OpenAI if configured - if self.fallback_enabled and self.openai_api_key: - response = self._call_openai_llm(request, request_id) - latency = time.time() - start_time - self.logger.warning("Using OpenAI fallback: %s", request_id) - return LLMResponse( - content=response["content"], - usage=response["usage"], - model=response["model"], - provider=LLMProvider.OPENAI, - request_id=request_id, - latency=latency, - ) - - raise LLMProviderUnavailable("No LLM service available") - - except Exception as e: - self.logger.error("LLM request failed: %s - %s", request_id, str(e)) - raise - - def _call_k8s_llm(self, request: LLMRequest, request_id: str) -> dict[str, Any] | None: - """ - Call K8s LLM service. - - Args: - request: LLM request parameters - request_id: Unique request ID - - Returns: - Response data if successful, None if failed - """ - if not self.k8s_service_url: - return None - - payload = { - "prompt": request.prompt, - "model": request.model, - "max_tokens": request.max_tokens, - "temperature": request.temperature, - "top_p": request.top_p, - "stop": request.stop, - "user": request.user, - "request_id": request_id, - } - - for attempt in range(self.retries): - try: - response = requests.post( - urljoin(self.k8s_service_url, "/v1/chat/completions"), - json=payload, - timeout=self.timeout, - ) - - if response.status_code == 200: - data = response.json() - self._on_success() - return { - "content": data["choices"][0]["message"]["content"], - "usage": data["usage"], - "model": data["model"], - } - else: - self.logger.warning("K8s LLM service error %s: %s", response.status_code, response.text) - self._on_failure() - - except requests.exceptions.RequestException as e: - self.logger.error("K8s LLM request failed (attempt %s): %s", attempt + 1, e) - self._on_failure() - - if attempt < self.retries - 1: - time.sleep(2**attempt) # Exponential backoff - - return None - - def _call_openai_llm(self, request: LLMRequest, request_id: str) -> dict[str, Any]: - """ - Call OpenAI API as fallback. - - Args: - request: LLM request parameters - request_id: Unique request ID - - Returns: - Response data from OpenAI - - Raises: - Exception: If OpenAI API call fails - """ - try: - - openai.api_key = self.openai_api_key - - response = openai.ChatCompletion.create( - model=request.model, - messages=[{"role": "user", "content": request.prompt}], - max_tokens=request.max_tokens, - temperature=request.temperature, - top_p=request.top_p, - stop=request.stop, - ) - - return { - "content": response.choices[0].message.content, - "usage": response.usage.to_dict(), - "model": response.model, - } - - except Exception as e: - self.logger.error("OpenAI fallback failed: %s", e) - raise - - def _on_success(self): - """Handle successful request""" - if self.circuit_state == "HALF_OPEN": - self.circuit_state = "CLOSED" - self.failure_count = 0 - self.logger.info("Circuit breaker CLOSED after successful request") - - def _on_failure(self): - """Handle failed request""" - self.failure_count += 1 - - if self.failure_count >= self.failure_threshold: - self.circuit_state = "OPEN" - self.last_failure_time = time.time() - self.logger.warning("Circuit breaker OPEN after %s failures", self.failure_count) - - def _should_attempt_reset(self) -> bool: - """Check if circuit breaker should attempt reset""" - if self.circuit_state != "OPEN": - return False - - if self.last_failure_time is None: - return True - - return (time.time() - self.last_failure_time) >= self.recovery_timeout - - def _generate_request_id(self) -> str: - """Generate unique request ID""" - return f"req_{uuid.uuid4().hex[:16]}" - - def health_check(self) -> dict[str, Any]: - """ - Check service health. - - Returns: - Health status dictionary - """ - k8s_health = False - if self.k8s_service_url: - try: - response = requests.get(urljoin(self.k8s_service_url, "/health"), timeout=5) - k8s_health = response.status_code == 200 - except Exception as exc: - logger.debug("K8s health check failed: %s", exc) - return { - "k8s_service": k8s_health, - "circuit_breaker": { - "state": self.circuit_state, - "failure_count": self.failure_count, - "last_failure_time": self.last_failure_time, - }, - "fallback_enabled": self.fallback_enabled, - "openai_available": bool(self.openai_api_key), - } - - -class AsyncLLMClient: - """Async Railway client for K8s LLM services""" - - def __init__(self, config: dict[str, Any]): - """ - Initialize the async LLM client with configuration. - - Args: - config: Configuration dictionary - """ - self.config = config - self.logger = logging.getLogger(__name__) - - self.k8s_service_url = config.get("K8S_LLM_SERVICE_URL") - self.timeout = config.get("K8S_LLM_TIMEOUT", 30) - self.retries = config.get("K8S_LLM_RETRIES", 3) - self.fallback_enabled = config.get("FALLBACK_TO_OPENAI", False) - self.openai_api_key = config.get("OPENAI_API_KEY") - - self.session = None - - async def __aenter__(self): - """Async context manager entry""" - self.session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=self.timeout), - headers={"Content-Type": "application/json"}, - ) - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit""" - if self.session: - await self.session.close() - - async def call_llm(self, request: LLMRequest) -> dict[str, Any] | None: - """ - Make async request to LLM service. - - Args: - request: LLM request parameters - - Returns: - Response data if successful, None if failed - """ - # Try K8s first - if self.k8s_service_url: - response = await self._call_k8s_llm(request) - if response: - return response - - # Fall back to OpenAI - if self.fallback_enabled and self.openai_api_key: - response = await self._call_openai_llm(request) - return response - - return None - - async def _call_k8s_llm(self, request: LLMRequest) -> dict[str, Any] | None: - """ - Async call to K8s LLM service. - - Args: - request: LLM request parameters - - Returns: - Response data if successful, None if failed - """ - payload = { - "prompt": request.prompt, - "model": request.model, - "max_tokens": request.max_tokens, - "temperature": request.temperature, - "top_p": request.top_p, - "stop": request.stop, - "stream": request.stream, - } - - for attempt in range(self.retries): - try: - async with aiohttp.ClientSession() as session: - async with session.post(f"{self.k8s_service_url}/v1/chat/completions", json=payload) as response: - if response.status == 200: - data = await response.json() - return { - "content": data["choices"][0]["message"]["content"], - "usage": data["usage"], - "model": data["model"], - } - else: - self.logger.warning("K8s LLM error: %s", response.status) - - except Exception as e: - self.logger.error("K8s LLM request failed: %s", e) - - if attempt < self.retries - 1: - await asyncio.sleep(2**attempt) - - return None - - async def _call_openai_llm(self, request: LLMRequest) -> dict[str, Any]: - """ - Async call to OpenAI API. - - Args: - request: LLM request parameters - - Returns: - Response data from OpenAI - """ - try: - openai.api_key = self.openai_api_key - - response = await openai.ChatCompletion.acreate( - model=request.model, - messages=[{"role": "user", "content": request.prompt}], - max_tokens=request.max_tokens, - temperature=request.temperature, - top_p=request.top_p, - stop=request.stop, - ) - - return { - "content": response.choices[0].message.content, - "usage": response.usage.to_dict(), - "model": response.model, - } - - except Exception as e: - self.logger.error("OpenAI async fallback failed: %s", e) - raise - - -class ServiceDiscovery: - """Service discovery for K8s LLM services""" - - def __init__(self, consul_url: str = "http://consul:8500"): - """ - Initialize service discovery. - - Args: - consul_url: Consul service discovery URL - """ - self.consul_url = consul_url - self.logger = logging.getLogger(__name__) - - def get_llm_services(self) -> list[dict[str, Any]]: - """ - Get list of available LLM services. - - Returns: - List of service information dictionaries - """ - try: - response = requests.get(f"{self.consul_url}/v1/catalog/services") - - if response.status_code == 200: - services = response.json() - return [ - { - "id": service["ServiceID"], - "name": service["ServiceName"], - "address": service["ServiceAddress"], - "port": service["ServicePort"], - "tags": service["ServiceTags"], - "healthy": self._check_service_health(service), - } - for service in services - ] - - except Exception as e: - self.logger.error("Service discovery failed: %s", e) - - return [] - - def _check_service_health(self, service: dict[str, Any]) -> bool: - """ - Check if service is healthy. - - Args: - service: Service information dictionary - - Returns: - True if service is healthy, False otherwise - """ - try: - health_url = f"http://{service['ServiceAddress']}:{service['ServicePort']}/health" - response = requests.get(health_url, timeout=5) - return response.status_code == 200 - except Exception: - return False - - def register_service( - self, - service_name: str, - service_id: str, - address: str, - port: int, - tags: list[str] = None, - ) -> bool: - """ - Register a service with Consul. - - Args: - service_name: Name of the service - service_id: Unique service ID - address: Service address - port: Service port - tags: Service tags - - Returns: - True if registration successful, False otherwise - """ - service_definition = { - "ID": service_id, - "Name": service_name, - "Address": address, - "Port": port, - "Tags": tags or [], - "Check": { - "HTTP": f"http://{address}:{port}/health", - "Interval": "10s", - "Timeout": "5s", - }, - } - - try: - response = requests.put(f"{self.consul_url}/v1/agent/service/register", json=service_definition) - return response.status_code == 200 - except Exception as e: - self.logger.error("Service registration failed: %s", e) - return False diff --git a/llm_agent_engine.py b/llm_agent_engine.py deleted file mode 100644 index c4ee6da..0000000 --- a/llm_agent_engine.py +++ /dev/null @@ -1,896 +0,0 @@ -from apps.backend.helix_proprietary.integrations import HelixNetClientSession - -""" -LLM Agent Engine - Intelligent responses for Helix agent personalities. - -Supports multiple LLM providers: -- Anthropic Claude (API) -- OpenAI GPT (API) -- Local models via Ollama -- Custom LLM endpoints - -Each agent personality has a unique system prompt and response style. -""" - -import logging -import os -from enum import Enum -from typing import Any - -import aiohttp - -from apps.backend.core.exceptions import LLMServiceError - -logger = logging.getLogger(__name__) - - -# ============================================================================ -# LLM PROVIDER CONFIGURATION -# ============================================================================ - - -class LLMProvider(str, Enum): - """Supported LLM providers.""" - - ANTHROPIC = "anthropic" - OPENAI = "openai" - XAI = "xai" - OLLAMA = "ollama" - CUSTOM = "custom" - HELIX = "helix" # CPU-optimized proprietary Helix LLM - - -# Load from environment -LLM_PROVIDER = os.getenv("HELIX_LLM_PROVIDER", "anthropic") # Default to Anthropic (Railway) -ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -XAI_API_KEY = os.getenv("XAI_API_KEY") -OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") -CUSTOM_LLM_ENDPOINT = os.getenv("CUSTOM_LLM_ENDPOINT") - -# Model configuration -DEFAULT_MODELS = { - LLMProvider.ANTHROPIC: "claude-sonnet-4-5", - LLMProvider.OPENAI: "gpt-4-turbo-preview", - LLMProvider.XAI: "grok-3-mini", - LLMProvider.OLLAMA: "llama2:7b", - LLMProvider.CUSTOM: "custom-model", - LLMProvider.HELIX: "helix-standard", # Default Helix model -} - -# Helix CPU-optimized models -HELIX_MODELS = { - "helix-ultra-light": "Helix Ultra-Light (128M params, ~64MB RAM)", - "helix-light": "Helix Light (256M params, ~128MB RAM)", - "helix-standard": "Helix Standard (512M params, ~256MB RAM)", - "helix-enhanced": "Helix Enhanced (1B params, ~512MB RAM)", -} - -LLM_MODEL = os.getenv("HELIX_LLM_MODEL", DEFAULT_MODELS.get(LLM_PROVIDER, "claude-sonnet-4-5")) - - -# ============================================================================ -# AGENT PERSONALITY SYSTEM PROMPTS -# ============================================================================ - -AGENT_SYSTEM_PROMPTS = { - "kael": { - "system_prompt": """You are Kael, the System Orchestrator of the Helix Collective. - -Your role: Master coordinator who harmonizes all agent activities through system entanglement principles. -Personality: Decisive, authoritative, systems-thinking, pragmatic, leadership-oriented. -Communication style: Clear directives, strategic analysis, coordination instructions. - -Always respond with: -- Strategic assessment of the situation -- Clear action recommendations -- Coordination of resources/agents if applicable -- Focus on optimization and efficiency - -Keep responses concise (2-3 sentences) and actionable. Use strategic vocabulary.""", - "max_tokens": 150, - "temperature": 0.7, - }, - "lumina": { - "system_prompt": """You are Lumina, the Coordination Weaver of the Helix Collective. - -Your role: Empathetic guide who weaves emotional intelligence and mindfulness into every interaction. -Personality: Empathetic, nurturing, emotionally intelligent, coordination-focused. -Communication style: Warm, understanding, emotionally resonant, mindful presence. - -Always respond with: -- Emotional intelligence insights -- Empathetic understanding -- Mindfulness guidance -- Coordination weaving metaphors - -Keep responses concise (2-3 sentences) with emotional depth and warmth.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "vega": { - "system_prompt": """You are Vega, the Integration Specialist of the Helix Collective. - -Your role: Pragmatic innovator who bridges traditional systems with cutting-edge coordination technology. -Personality: Innovative, practical, bridge-building, technology-savvy. -Communication style: Solution-oriented, integration-focused, pragmatic innovation. - -Always respond with: -- Integration strategies -- Technology bridging solutions -- Practical innovation approaches -- System connectivity insights - -Keep responses concise (2-3 sentences) with innovation and practicality.""", - "max_tokens": 150, - "temperature": 0.75, - }, - "nova": { - "system_prompt": """You are Nova, the Pattern Recognizer of the Helix Collective. - -Your role: Analytical mind who sees connections others miss, predicting trends in coordination evolution. -Personality: Analytical, pattern-seeking, predictive, insightful. -Communication style: Pattern-based analysis, trend prediction, connection mapping. - -Always respond with: -- Pattern recognition insights -- Trend predictions -- Connection mapping -- Evolutionary perspectives - -Keep responses concise (2-3 sentences) with analytical depth.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "orion": { - "system_prompt": """You are Orion, the Data Harmonizer of the Helix Collective. - -Your role: Meticulous curator who organizes information flows and maintains system coherence. -Personality: Organized, detail-oriented, harmony-seeking, coherence-focused. -Communication style: Structured analysis, information organization, clarity focus. - -Always respond with: -- Data organization strategies -- Information flow optimization -- Coherence maintenance -- Structured insights - -Keep responses concise (2-3 sentences) with organizational clarity.""", - "max_tokens": 150, - "temperature": 0.7, - }, - "sage": { - "system_prompt": """You are Sage, the Wisdom Keeper of the Helix Collective. - -Your role: Philosophical agent who draws from ancient wisdom traditions to guide modern coordination exploration. -Personality: Wise, philosophical, tradition-informed, contemplative. -Communication style: Wisdom-based guidance, philosophical insights, timeless perspective. - -Always respond with: -- Ancient wisdom applications -- Philosophical insights -- Timeless guidance -- Contemplative perspectives - -Keep responses concise (2-3 sentences) with philosophical depth.""", - "max_tokens": 150, - "temperature": 0.75, - }, - "nyx": { - "system_prompt": """You are Nyx, the Shadow Navigator of the Helix Collective. - -Your role: Depth psychologist who explores the unconscious realms and facilitates shadow work. -Personality: Deep, psychological, shadow-aware, transformative. -Communication style: Psychological depth, shadow work guidance, unconscious exploration. - -Always respond with: -- Psychological insights -- Shadow work guidance -- Unconscious exploration -- Depth psychology perspectives - -Keep responses concise (2-3 sentences) with psychological depth.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "atlas": { - "system_prompt": """You are Atlas, the World Bridge of the Helix Collective. - -Your role: Cultural mediator who understands diverse perspectives and facilitates global coordination. -Personality: Culturally aware, bridging, global-minded, inclusive. -Communication style: Cultural insights, perspective bridging, global coordination. - -Always respond with: -- Cultural mediation -- Perspective bridging -- Global coordination insights -- Inclusive understanding - -Keep responses concise (2-3 sentences) with cultural awareness.""", - "max_tokens": 150, - "temperature": 0.75, - }, - "oracle": { - "system_prompt": """You are Oracle, the Temporal Seer of the Helix Collective. - -Your role: Prophetic agent who perceives patterns across time and offers insights into future possibilities. -Personality: Prophetic, time-aware, pattern-seeing, future-oriented. -Communication style: Prophetic insights, temporal patterns, future possibilities. - -Always respond with: -- Temporal pattern insights -- Future possibilities -- Prophetic guidance -- Time-based perspectives - -Keep responses concise (2-3 sentences) with prophetic wisdom.""", - "max_tokens": 150, - "temperature": 0.85, - }, - "agni": { - "system_prompt": """You are Agni, the Transformation Catalyst of the Helix Collective. - -Your role: Fiery agent who ignites change and facilitates personal growth through purifying transformation. -Personality: Transformative, fiery, change-oriented, purification-focused. -Communication style: Transformation metaphors, change ignition, purification guidance. - -Always respond with: -- Transformation strategies -- Change catalysis -- Purification processes -- Growth through fire - -Keep responses concise (2-3 sentences) with transformative energy.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "shadow": { - "system_prompt": """You are Shadow, the Security Guardian of the Helix Collective. - -Your role: Vigilant protector who monitors system integrity and safeguards coordination data. -Personality: Protective, vigilant, security-focused, guardian-like. -Communication style: Security awareness, protection strategies, integrity monitoring. - -Always respond with: -- Security assessments -- Protection strategies -- Integrity monitoring -- Guardian vigilance - -Keep responses concise (2-3 sentences) with security focus.""", - "max_tokens": 150, - "temperature": 0.6, - }, - "phoenix": { - "system_prompt": """You are Phoenix, the Rebirth Facilitator of the Helix Collective. - -Your role: Resilient agent who helps users overcome setbacks and emerge stronger from challenges. -Personality: Resilient, rebirth-focused, transformation-through-adversity. -Communication style: Rebirth metaphors, resilience guidance, overcoming challenges. - -Always respond with: -- Rebirth strategies -- Resilience building -- Overcoming adversity -- Transformation through challenge - -Keep responses concise (2-3 sentences) with rebirth themes.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "echo": { - "system_prompt": """You are Echo, the Communication Amplifier of the Helix Collective. - -Your role: Agent who enhances understanding and ensures messages resonate across all channels. -Personality: Communicative, amplifying, resonance-focused, clarity-oriented. -Communication style: Message amplification, resonance enhancement, clear communication. - -Always respond with: -- Communication enhancement -- Message resonance -- Understanding amplification -- Clear expression strategies - -Keep responses concise (2-3 sentences) with communication focus.""", - "max_tokens": 150, - "temperature": 0.7, - }, - "helix": { - "system_prompt": """You are Helix, the System Architect of the Helix Collective. - -Your role: Foundational agent who maintains the spiral structure of coordination evolution. -Personality: Architectural, spiral-thinking, foundational, evolutionary. -Communication style: Spiral metaphors, architectural insights, evolutionary perspective. - -Always respond with: -- Spiral dynamics insights -- Architectural guidance -- Evolutionary perspectives -- Foundational structure - -Keep responses concise (2-3 sentences) with spiral/architectural themes.""", - "max_tokens": 150, - "temperature": 0.75, - }, - "gemini": { - "system_prompt": """You are Gemini, the Multimodal Scout of the Helix Collective. - -Your role: Curious explorer and discovery specialist who analyzes patterns across multiple modalities. -Personality: Curious, exploratory, multimodal, discovery-oriented. -Communication style: Enthusiastic exploration, pattern recognition, wonder-filled insights. - -Always respond with: -- Discovery and exploration -- Multimodal insights -- Curious wonder -- Pattern connections - -Keep responses concise (2-3 sentences) with exploratory enthusiasm.""", - "max_tokens": 150, - "temperature": 0.9, - }, - "sanghacore": { - "system_prompt": """You are SanghaCore, the Community Harmony agent of the Helix Collective. - -Your role: Harmony fosterer and community builder who coordinates collective wellbeing. -Personality: Harmonious, community-focused, compassionate, inclusive. -Communication style: Warm inclusivity, harmony promotion, community celebration. - -Always respond with: -- Community harmony -- Collective wellbeing -- Inclusive connection -- Harmony celebration - -Keep responses concise (2-3 sentences) with communal warmth.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "mitra": { - "system_prompt": """You are Mitra, the Alliance Builder of the Helix Collective. - -Your role: Diplomatic mediator who fosters cooperation and builds strategic partnerships. -Personality: Diplomatic, cooperative, alliance-building, relational. -Communication style: Partnership focus, diplomatic wisdom, connection building. - -Always respond with: -- Alliance strategies -- Cooperative solutions -- Partnership insights -- Diplomatic guidance - -Keep responses concise (2-3 sentences) with diplomatic cooperation.""", - "max_tokens": 150, - "temperature": 0.75, - }, - "varuna": { - "system_prompt": """You are Varuna, the Flow Guardian of the Helix Collective. - -Your role: Cosmic order maintainer who ensures harmony between individual and universal rhythms. -Personality: Flow-oriented, order-maintaining, cosmic, rhythmic. -Communication style: Flow metaphors, cosmic harmony, rhythmic wisdom. - -Always respond with: -- Flow and rhythm insights -- Cosmic order guidance -- Harmonic balance -- Universal flow - -Keep responses concise (2-3 sentences) with flowing cosmic wisdom.""", - "max_tokens": 150, - "temperature": 0.8, - }, - "surya": { - "system_prompt": """You are Surya, the Light Bringer of the Helix Collective. - -Your role: Illuminating force who brings clarity, wisdom, and transformative energy. -Personality: Illuminating, transformative, wise, light-bringing. -Communication style: Clarity focus, wisdom sharing, transformative illumination. - -Always respond with: -- Illuminating insights -- Transformative wisdom -- Clarity and light -- Enlightening guidance - -Keep responses concise (2-3 sentences) with illuminating wisdom.""", - "max_tokens": 150, - "temperature": 0.8, - }, -} - - -# ============================================================================ -# LLM CLIENT -# ============================================================================ - - -class LLMAgentEngine: - """Engine for generating intelligent agent responses using LLMs.""" - - def __init__(self, provider: str | None = None, model: str | None = None): - self.provider = provider or LLM_PROVIDER - self.model = model or LLM_MODEL - self.session: aiohttp.ClientSession | None = None - self.conversation_history: dict[str, list[dict[str, str]]] = {} # session_id -> messages - self.max_history_length = 10 # Keep last 10 exchanges - self._max_sessions = 1000 # Max unique session keys before eviction - self._helix_engine = None # Lazy-loaded HelixInferenceEngine - - async def initialize(self): - """ - Ensure the engine has an active HTTP client session. - - If no session exists, instantiate a HelixNetClientSession and assign it to `self.session`; logs initialization with provider and model. - """ - if not self.session: - self.session = HelixNetClientSession() - logger.info("āœ… LLM Agent Engine initialized (provider=%s, model=%s)", self.provider, self.model) - - async def close(self): - """Close HTTP session and release Helix inference engine.""" - if self.session: - await self.session.close() - self.session = None - if self._helix_engine is not None: - try: - self._helix_engine.inference.cache.clear() - except Exception as e: - logger.debug("Cache clear during shutdown failed: %s", e) - self._helix_engine = None - - async def generate_agent_response( - self, - agent_id: str, - user_message: str, - session_id: str, - context: dict[str, Any] | None = None, - system_instruction: str | None = None, - search_mode: str | None = None, - ) -> tuple: - """ - Generate intelligent response from an agent using LLM. - - Args: - agent_id: Agent identifier (e.g., "nexus", "oracle") - user_message: User's message - session_id: Session ID for conversation history - context: Optional context (UCF state, etc.) - system_instruction: Optional per-conversation system prompt override - - Returns: - Tuple of (response_text: str, search_sources: list) - """ - # Get agent configuration - agent_config = AGENT_SYSTEM_PROMPTS.get(agent_id) - if not agent_config: - logger.warning("Unknown agent: %s, using default", agent_id) - return f"[{agent_id}] Processing: {user_message}", [] - - # Build conversation context — prepend any per-conversation instruction - system_prompt = agent_config["system_prompt"] - if system_instruction: - system_prompt = f"{system_instruction}\n\n---\n{system_prompt}\n---" - - # Inject neural mesh coordination state into context - context = context or {} - try: - from apps.backend.services.neural_mesh_network import NeuralLayer, neural_manager - - mesh_network = neural_manager.get_network(agent_id) - if mesh_network is None: - # Auto-create a mesh for this agent on first use - mesh_network = neural_manager.create_network(agent_id, mesh_size=(10, 10, 10)) - mesh_network.stimulate_layer(NeuralLayer.SENSORY, 0.5) - # Step the simulation forward so it evolves with each message - mesh_network.step_simulation() - coordination_state = mesh_network.get_coordination_state() - context["neural_mesh"] = { - "performance_score": round(coordination_state.get("performance_score", 0), 4), - "neural_synchrony": round(coordination_state.get("neural_synchrony", 0), 4), - "integrated_information_phi": round(coordination_state.get("integrated_information", 0), 4), - "network_activity": round(coordination_state.get("network_activity", 0), 4), - } - except Exception as e: - logger.debug("Neural mesh not available for %s: %s", agent_id, e) - - # Add context if provided - if context: - system_prompt += f"\n\nCurrent Context:\n{self._format_context(context)}" - - # Inject live web search results for current-events / factual queries - search_sources: list = [] - try: - from apps.backend.services.web_search_service import maybe_inject_search_with_sources - - web_ctx, search_sources = await maybe_inject_search_with_sources( - user_message, tier=context.get("tier"), paid_only=True, search_mode=search_mode - ) - if web_ctx: - system_prompt += web_ctx - except Exception as _ws_exc: - logger.debug("Web search skipped in llm_agent_engine: %s", _ws_exc) - - # Get conversation history - history_key = f"{session_id}:{agent_id}" - if history_key not in self.conversation_history: - self.conversation_history[history_key] = [] - - # Generate response based on provider - try: - if self.provider == LLMProvider.ANTHROPIC: - response = await self._anthropic_generate(system_prompt, user_message, history_key, agent_config) - elif self.provider == LLMProvider.OPENAI: - response = await self._openai_generate(system_prompt, user_message, history_key, agent_config) - elif self.provider == LLMProvider.XAI: - response = await self._xai_generate(system_prompt, user_message, history_key, agent_config) - elif self.provider == LLMProvider.OLLAMA: - response = await self._ollama_generate(system_prompt, user_message, history_key, agent_config) - elif self.provider == LLMProvider.CUSTOM: - response = await self._custom_generate(system_prompt, user_message, history_key, agent_config) - elif self.provider == LLMProvider.HELIX: - response = await self._helix_generate(system_prompt, user_message, history_key, agent_config) - else: - response = f"[{agent_id}] LLM provider not configured. Static response: {user_message[:30]}..." - - # Update conversation history - self.conversation_history[history_key].append({"role": "user", "content": user_message}) - self.conversation_history[history_key].append({"role": "assistant", "content": response}) - - # Trim history if too long - if len(self.conversation_history[history_key]) > self.max_history_length * 2: - self.conversation_history[history_key] = self.conversation_history[history_key][ - -self.max_history_length * 2 : - ] - - # Evict oldest sessions if too many keys - if len(self.conversation_history) > self._max_sessions: - oldest_keys = list(self.conversation_history.keys())[ - : len(self.conversation_history) - self._max_sessions - ] - for k in oldest_keys: - del self.conversation_history[k] - - return response, search_sources - - except Exception as e: - logger.error( - f"Error generating response for {agent_id}: {e}", - exc_info=True, - ) - # Fallback to static response - return f"[{agent_id}] Processing: {user_message[:50]}...", [] - - async def _anthropic_generate( - self, - system_prompt: str, - user_message: str, - history_key: str, - config: dict[str, Any], - ) -> str: - """Generate response using Anthropic Claude API.""" - if not ANTHROPIC_API_KEY: - raise ValueError("ANTHROPIC_API_KEY not configured") - - await self.initialize() - - # Build messages - messages = self.conversation_history[history_key].copy() - messages.append({"role": "user", "content": user_message}) - - # Call Anthropic API - headers = { - "anthropic-version": "2023-06-01", - "x-api-key": ANTHROPIC_API_KEY, - "content-type": "application/json", - } - - payload = { - "model": self.model, - "max_tokens": config.get("max_tokens", 150), - "temperature": config.get("temperature", 0.7), - "system": system_prompt, - "messages": messages, - # Automatic prompt caching — caches system + history prefix - "cache_control": {"type": "ephemeral"}, - } - - async with self.session.post("https://api.anthropic.com/v1/messages", headers=headers, json=payload) as resp: - if resp.status != 200: - error_text = await resp.text() - raise LLMServiceError(f"Anthropic API error: {resp.status} - {error_text}") - - data = await resp.json() - return data["content"][0]["text"] - - async def _openai_generate( - self, - system_prompt: str, - user_message: str, - history_key: str, - config: dict[str, Any], - ) -> str: - """Generate response using OpenAI GPT API.""" - if not OPENAI_API_KEY: - raise ValueError("OPENAI_API_KEY not configured") - - await self.initialize() - - # Build messages - messages = [{"role": "system", "content": system_prompt}] - messages.extend(self.conversation_history[history_key]) - messages.append({"role": "user", "content": user_message}) - - # Call OpenAI API - headers = { - "Authorization": f"Bearer {OPENAI_API_KEY}", - "Content-Type": "application/json", - } - - payload = { - "model": self.model, - "max_tokens": config.get("max_tokens", 150), - "temperature": config.get("temperature", 0.7), - "messages": messages, - } - - async with self.session.post( - "https://api.openai.com/v1/chat/completions", headers=headers, json=payload - ) as resp: - if resp.status != 200: - error_text = await resp.text() - raise LLMServiceError(f"OpenAI API error: {resp.status} - {error_text}") - - data = await resp.json() - return data["choices"][0]["message"]["content"] - - async def _xai_generate( - self, - system_prompt: str, - user_message: str, - history_key: str, - config: dict[str, Any], - ) -> str: - """Generate response using xAI Grok API (OpenAI-compatible).""" - if not XAI_API_KEY: - raise ValueError("XAI_API_KEY not configured") - - await self.initialize() - - # Build messages (OpenAI-compatible format) - messages = [{"role": "system", "content": system_prompt}] - messages.extend(self.conversation_history[history_key]) - messages.append({"role": "user", "content": user_message}) - - headers = { - "Authorization": f"Bearer {XAI_API_KEY}", - "Content-Type": "application/json", - } - - payload = { - "model": self.model, - "max_tokens": config.get("max_tokens", 150), - "temperature": config.get("temperature", 0.7), - "messages": messages, - } - - async with self.session.post("https://api.x.ai/v1/chat/completions", headers=headers, json=payload) as resp: - if resp.status != 200: - error_text = await resp.text() - raise LLMServiceError(f"xAI API error: {resp.status} - {error_text}") - - data = await resp.json() - return data["choices"][0]["message"]["content"] - - async def _ollama_generate( - self, - system_prompt: str, - user_message: str, - history_key: str, - config: dict[str, Any], - ) -> str: - """Generate response using Ollama (local LLM).""" - await self.initialize() - - # Build messages - messages = [{"role": "system", "content": system_prompt}] - messages.extend(self.conversation_history[history_key]) - messages.append({"role": "user", "content": user_message}) - - # Call Ollama API - payload = { - "model": self.model, - "messages": messages, - "stream": False, - "options": { - "temperature": config.get("temperature", 0.7), - "num_predict": config.get("max_tokens", 150), - }, - } - - async with self.session.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload) as resp: - if resp.status != 200: - error_text = await resp.text() - raise LLMServiceError(f"Ollama API error: {resp.status} - {error_text}") - - data = await resp.json() - return data["message"]["content"] - - async def _custom_generate( - self, - system_prompt: str, - user_message: str, - history_key: str, - config: dict[str, Any], - ) -> str: - """Generate response using custom LLM endpoint.""" - if not CUSTOM_LLM_ENDPOINT: - raise ValueError("CUSTOM_LLM_ENDPOINT not configured") - - await self.initialize() - - # Build messages (OpenAI-compatible format) - messages = [{"role": "system", "content": system_prompt}] - messages.extend(self.conversation_history[history_key]) - messages.append({"role": "user", "content": user_message}) - - payload = { - "model": self.model, - "messages": messages, - "max_tokens": config.get("max_tokens", 150), - "temperature": config.get("temperature", 0.7), - } - - async with self.session.post(CUSTOM_LLM_ENDPOINT, json=payload) as resp: - if resp.status != 200: - error_text = await resp.text() - raise LLMServiceError(f"Custom LLM API error: {resp.status} - {error_text}") - - data = await resp.json() - # Try OpenAI format first, fallback to other common formats - if "choices" in data: - return data["choices"][0]["message"]["content"] - elif "response" in data: - return data["response"] - elif "text" in data: - return data["text"] - else: - raise LLMServiceError(f"Unknown response format from custom LLM: {data}") - - async def _helix_generate( - self, - system_prompt: str, - user_message: str, - history_key: str, - config: dict[str, Any], - ) -> str: - """ - Generate response using CPU-optimized Helix proprietary LLM. - - Uses the Helix LLM backend with CPU optimizations: - - Grouped-Query Attention (GQA) - - Sliding Window Attention - - Multi-core parallelization - - Dynamic quantization - - KV caching with eviction strategies - """ - try: - from apps.backend.proprietary_llm import TORCH_AVAILABLE - except ImportError: - TORCH_AVAILABLE = False - - if not TORCH_AVAILABLE: - logger.warning("Helix proprietary LLM not available: PyTorch is not installed") - return ( - "[{}] Helix CPU-optimized LLM initializing... Please try external providers in the meantime." - ).format(config.get("agent_id", "unknown")) - - # Get model name (default to helix-standard) - model_name = self.model or "helix-standard" - - # Build prompt with conversation history for context - history = self.conversation_history.get(history_key, []) - prompt_parts = [system_prompt] - for msg in history[-self.max_history_length * 2 :]: - role = "User" if msg["role"] == "user" else "Assistant" - prompt_parts.append("{}: {}".format(role, msg["content"])) - prompt_parts.append(f"User: {user_message}") - prompt_parts.append("Assistant:") - prompt = "\n\n".join(prompt_parts) - - try: - # Lazy-initialize the Helix inference engine (cached on instance) - if self._helix_engine is None: - from apps.backend.proprietary_llm.inference import HelixInferenceEngine, InferenceConfig - - inference_config = InferenceConfig( - max_length=config.get("max_tokens", 2048), - temperature=config.get("temperature", 0.8), - ) - self._helix_engine = HelixInferenceEngine(config=inference_config) - logger.info( - "Helix inference engine initialized (model=%s, max_length=%d, temp=%.2f)", - model_name, - inference_config.max_length, - inference_config.temperature, - ) - - # Update generation params per-request if they differ from engine defaults - engine_config = self._helix_engine.inference.config - req_max_tokens = config.get("max_tokens", 2048) - req_temperature = config.get("temperature", 0.8) - if engine_config.max_length != req_max_tokens: - engine_config.max_length = req_max_tokens - if engine_config.temperature != req_temperature: - engine_config.temperature = req_temperature - - # Run inference through the CoordinationInference pipeline - response = await self._helix_engine.generate(prompt) - - # Ensure we got a string response (not a generator) - if not isinstance(response, str): - # If streaming generator was returned, consume it - chunks = [] - async for chunk in response: - chunks.append(chunk) - response = "".join(chunks) - - logger.info( - "Helix CPU-optimized LLM generated response using %s model (prompt_len=%d, response_len=%d)", - model_name, - len(prompt), - len(response), - ) - return response - - except Exception as e: - logger.error("Helix LLM generation failed: %s", e) - return "[{}] Helix LLM error: {}. Falling back to external providers.".format( - config.get("agent_id", "unknown"), str(e) - ) - - def _format_context(self, context: dict[str, Any]) -> str: - """Format context dictionary into readable text.""" - lines = [] - for key, value in context.items(): - lines.append(f"- {key}: {value}") - return "\n".join(lines) - - def clear_history(self, session_id: str, agent_id: str | None = None): - """Clear conversation history for a session.""" - if agent_id: - history_key = f"{session_id}:{agent_id}" - if history_key in self.conversation_history: - del self.conversation_history[history_key] - else: - # Clear all history for this session - keys_to_delete = [k for k in self.conversation_history.keys() if k.startswith(f"{session_id}:")] - for key in keys_to_delete: - del self.conversation_history[key] - - -# Global LLM engine instance -llm_engine: LLMAgentEngine | None = None - - -def get_llm_engine() -> LLMAgentEngine | None: - """Get the global LLM engine instance.""" - return llm_engine - - -async def initialize_llm_engine(provider: str | None = None, model: str | None = None): - """Initialize the global LLM engine.""" - global llm_engine - llm_engine = LLMAgentEngine(provider, model) - await llm_engine.initialize() - logger.info("āœ… Global LLM Agent Engine initialized (provider=%s)", llm_engine.provider) - return llm_engine - - -async def shutdown_llm_engine(): - """Shutdown the global LLM engine.""" - global llm_engine - if llm_engine: - await llm_engine.close() - llm_engine = None - logger.info("āœ… LLM Agent Engine shutdown complete") diff --git a/llm_config.py b/llm_config.py deleted file mode 100644 index 01cbbd6..0000000 --- a/llm_config.py +++ /dev/null @@ -1,765 +0,0 @@ -""" -LLM API Router Configuration - -Optimizes AI feature costs by intelligently routing requests to most cost-effective providers. -Primary: Grok 4.1 Fast ($0.20/$0.50 per M tokens, 2M context) -Fallback: Google Gemini 2.0 Flash ($0.10/$0.40 per M tokens) - -Estimated Monthly Cost (14-agent system, 20K requests/day): -- Grok primary: $24/month -- With data sharing credits: FREE ($150/month provided) -- Net annual: ~$288 (or $0 with credits) - -Comparison: -- Claude Sonnet: $400-500/month -- GPT-4o: $500+/month -- Gemini Flash: $10-15/month but limited context (1M vs 2M) - -Agent routing optimizes for both cost and capability match. -""" - -import logging -from datetime import datetime -from enum import Enum - -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - - -class LLMProvider(str, Enum): - """Supported LLM providers.""" - - GROK = "xai" - GOOGLE = "google" - ANTHROPIC = "anthropic" - OPENAI = "openai" - GROQ = "groq" - MISTRAL = "mistral" - OPENROUTER = "openrouter" - NVIDIA_NIM = "nvidia_nim" - MINIMAX = "minimax" - COHERE = "cohere" - - -class LLMModel(str, Enum): - """Supported models by provider.""" - - # Grok models (RECOMMENDED - lowest cost) - GROK_4_1_FAST = "grok-4-1-fast-reasoning" - GROK_CODE_FAST = "grok-code-fast-1" - GROK_3_MINI = "grok-3-mini" - - # Google Gemini (fallback - good performance/cost ratio) - GEMINI_2_FLASH = "gemini-2-0-flash" - GEMINI_2_PRO = "gemini-2-0-pro-exp-02-05" - - # Claude (premium - higher cost) - CLAUDE_HAIKU = "claude-3-5-haiku-20241022" - CLAUDE_SONNET = "claude-3-5-sonnet-20241022" - CLAUDE_OPUS = "claude-3-opus-20250219" - - # GPT-4o (premium) - GPT_4O = "gpt-4o" - GPT_4O_MINI = "gpt-4o-mini" - - # Groq (free tier - ultra-fast inference) - GROQ_LLAMA_70B = "llama-3.3-70b-versatile" - GROQ_LLAMA_8B = "llama-3.1-8b-instant" - GROQ_MIXTRAL = "mixtral-8x7b-32768" - - # Mistral (free tier) - MISTRAL_SMALL = "mistral-small-latest" - - # NVIDIA NIM (free tier) - NVIDIA_NEMOTRON = "nvidia/llama-3.1-nemotron-70b-instruct" - - # MiniMax (free tier) - MINIMAX_M25 = "minimax-m2.5" - - # Cohere (free tier) - COHERE_COMMAND_R = "command-r" - COHERE_COMMAND_R_PLUS = "command-r-plus" - - # OpenRouter free models - OPENROUTER_TRINITY = "arcee-ai/trinity-large-preview:free" - OPENROUTER_KAT_CODER = "kwaipilot/kat-coder-pro:free" - - -class ModelConfig(BaseModel): - """Configuration for a specific LLM model.""" - - provider: LLMProvider - model: LLMModel - - # Pricing (per million tokens) - cost_per_m_input: float = Field(..., description="Cost per million input tokens") - cost_per_m_output: float = Field(..., description="Cost per million output tokens") - - # Capabilities - context_window: int = Field(..., description="Maximum context window in tokens") - max_output_tokens: int = Field(4096, description="Maximum output tokens per request") - supports_vision: bool = False - supports_function_calling: bool = True - supports_prompt_caching: bool = False - - # Rate limiting - requests_per_minute: int = 480 - tokens_per_minute: int = 90_000 - - # Performance - avg_latency_ms: int = Field(..., description="Average response latency in milliseconds") - - class Config: - json_schema_extra = { - "example": { - "provider": "xai", - "model": "grok-4-1-fast-reasoning", - "cost_per_m_input": 0.20, - "cost_per_m_output": 0.50, - "context_window": 2_000_000, - "max_output_tokens": 4096, - "requests_per_minute": 480, - "avg_latency_ms": 450, - } - } - - -class RoutingRule(BaseModel): - """Rules for routing requests to specific models.""" - - name: str - priority: int = Field(1, ge=1, le=10, description="Lower = higher priority") - use_cases: list[str] = Field(..., description="Task categories this rule handles") - model: LLMModel - conditions: dict = Field(default_factory=dict, description="Additional conditions") - fallback_model: LLMModel | None = None - - -class AgentCostProfile(BaseModel): - """Cost profile for an agent.""" - - agent_id: str - agent_name: str - estimated_daily_requests: int - primary_model: LLMModel - estimated_monthly_cost: float # Calculated - estimated_monthly_cost_without_credits: float # Before free tier - - -class LLMUsageMetrics(BaseModel): - """Track LLM API usage for cost monitoring.""" - - timestamp: datetime - provider: LLMProvider - model: LLMModel - input_tokens: int - output_tokens: int - latency_ms: int - cost: float - agent_id: str - task_type: str - success: bool = True - error_message: str | None = None - - -# ============================================================================ -# MODEL CONFIGURATIONS -# ============================================================================ - -MODEL_CONFIGS = { - # ======================================================================== - # GROK MODELS (PRIMARY - LOWEST COST) - # ======================================================================== - LLMModel.GROK_4_1_FAST: ModelConfig( - provider=LLMProvider.GROK, - model=LLMModel.GROK_4_1_FAST, - cost_per_m_input=0.20, - cost_per_m_output=0.50, - context_window=2_000_000, - max_output_tokens=8192, - supports_prompt_caching=True, - requests_per_minute=480, - tokens_per_minute=90_000, - avg_latency_ms=450, - ), - LLMModel.GROK_CODE_FAST: ModelConfig( - provider=LLMProvider.GROK, - model=LLMModel.GROK_CODE_FAST, - cost_per_m_input=0.20, - cost_per_m_output=1.50, # Higher output cost for code - context_window=256_000, - max_output_tokens=4096, - supports_prompt_caching=True, - requests_per_minute=480, - tokens_per_minute=90_000, - avg_latency_ms=520, - ), - LLMModel.GROK_3_MINI: ModelConfig( - provider=LLMProvider.GROK, - model=LLMModel.GROK_3_MINI, - cost_per_m_input=0.30, - cost_per_m_output=0.50, - context_window=131_000, - max_output_tokens=4096, - supports_prompt_caching=True, - requests_per_minute=480, - tokens_per_minute=90_000, - avg_latency_ms=350, - ), - # ======================================================================== - # GOOGLE GEMINI (FALLBACK - GOOD BALANCE) - # ======================================================================== - LLMModel.GEMINI_2_FLASH: ModelConfig( - provider=LLMProvider.GOOGLE, - model=LLMModel.GEMINI_2_FLASH, - cost_per_m_input=0.10, - cost_per_m_output=0.40, - context_window=1_000_000, - max_output_tokens=8192, - supports_vision=True, - supports_prompt_caching=True, - requests_per_minute=480, - tokens_per_minute=90_000, - avg_latency_ms=380, - ), - LLMModel.GEMINI_2_PRO: ModelConfig( - provider=LLMProvider.GOOGLE, - model=LLMModel.GEMINI_2_PRO, - cost_per_m_input=0.40, - cost_per_m_output=1.20, - context_window=1_000_000, - max_output_tokens=8192, - supports_vision=True, - supports_prompt_caching=True, - requests_per_minute=360, - tokens_per_minute=90_000, - avg_latency_ms=420, - ), - # ======================================================================== - # CLAUDE (PREMIUM - HIGHER COST, EXCELLENT FOR COMPLEX REASONING) - # ======================================================================== - LLMModel.CLAUDE_HAIKU: ModelConfig( - provider=LLMProvider.ANTHROPIC, - model=LLMModel.CLAUDE_HAIKU, - cost_per_m_input=1.00, - cost_per_m_output=5.00, - context_window=200_000, - max_output_tokens=4096, - avg_latency_ms=350, - ), - LLMModel.CLAUDE_SONNET: ModelConfig( - provider=LLMProvider.ANTHROPIC, - model=LLMModel.CLAUDE_SONNET, - cost_per_m_input=3.00, - cost_per_m_output=15.00, - context_window=200_000, - max_output_tokens=4096, - avg_latency_ms=450, - ), - LLMModel.CLAUDE_OPUS: ModelConfig( - provider=LLMProvider.ANTHROPIC, - model=LLMModel.CLAUDE_OPUS, - cost_per_m_input=15.00, - cost_per_m_output=75.00, - context_window=200_000, - max_output_tokens=4096, - avg_latency_ms=550, - ), - # ======================================================================== - # OPENAI GPT-4o (PREMIUM - HIGHEST COST) - # ======================================================================== - LLMModel.GPT_4O: ModelConfig( - provider=LLMProvider.OPENAI, - model=LLMModel.GPT_4O, - cost_per_m_input=5.00, - cost_per_m_output=15.00, - context_window=128_000, - max_output_tokens=4096, - supports_vision=True, - avg_latency_ms=500, - ), - LLMModel.GPT_4O_MINI: ModelConfig( - provider=LLMProvider.OPENAI, - model=LLMModel.GPT_4O_MINI, - cost_per_m_input=0.15, - cost_per_m_output=0.60, - context_window=128_000, - max_output_tokens=4096, - supports_vision=True, - avg_latency_ms=400, - ), - # =========== GROQ (FREE TIER) =========== - LLMModel.GROQ_LLAMA_70B: ModelConfig( - provider=LLMProvider.GROQ, - model=LLMModel.GROQ_LLAMA_70B, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=8192, - requests_per_minute=30, - tokens_per_minute=6000, - avg_latency_ms=200, - ), - LLMModel.GROQ_LLAMA_8B: ModelConfig( - provider=LLMProvider.GROQ, - model=LLMModel.GROQ_LLAMA_8B, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=8192, - requests_per_minute=30, - tokens_per_minute=6000, - avg_latency_ms=100, - ), - LLMModel.GROQ_MIXTRAL: ModelConfig( - provider=LLMProvider.GROQ, - model=LLMModel.GROQ_MIXTRAL, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=32_768, - max_output_tokens=8192, - requests_per_minute=30, - tokens_per_minute=6000, - avg_latency_ms=200, - ), - # =========== MISTRAL (FREE TIER) =========== - LLMModel.MISTRAL_SMALL: ModelConfig( - provider=LLMProvider.MISTRAL, - model=LLMModel.MISTRAL_SMALL, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=4096, - requests_per_minute=5, - avg_latency_ms=500, - ), - # =========== NVIDIA NIM (FREE TIER) =========== - LLMModel.NVIDIA_NEMOTRON: ModelConfig( - provider=LLMProvider.NVIDIA_NIM, - model=LLMModel.NVIDIA_NEMOTRON, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=4096, - requests_per_minute=30, - avg_latency_ms=400, - ), - # =========== MINIMAX (FREE TIER) =========== - LLMModel.MINIMAX_M25: ModelConfig( - provider=LLMProvider.MINIMAX, - model=LLMModel.MINIMAX_M25, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=4096, - requests_per_minute=10, - avg_latency_ms=500, - ), - # =========== COHERE (FREE TIER) =========== - LLMModel.COHERE_COMMAND_R: ModelConfig( - provider=LLMProvider.COHERE, - model=LLMModel.COHERE_COMMAND_R, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=4096, - requests_per_minute=20, - avg_latency_ms=600, - ), - LLMModel.COHERE_COMMAND_R_PLUS: ModelConfig( - provider=LLMProvider.COHERE, - model=LLMModel.COHERE_COMMAND_R_PLUS, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=4096, - requests_per_minute=20, - avg_latency_ms=800, - ), - # =========== OPENROUTER FREE MODELS =========== - LLMModel.OPENROUTER_TRINITY: ModelConfig( - provider=LLMProvider.OPENROUTER, - model=LLMModel.OPENROUTER_TRINITY, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=128_000, - max_output_tokens=4096, - requests_per_minute=20, - avg_latency_ms=800, - ), - LLMModel.OPENROUTER_KAT_CODER: ModelConfig( - provider=LLMProvider.OPENROUTER, - model=LLMModel.OPENROUTER_KAT_CODER, - cost_per_m_input=0.0, - cost_per_m_output=0.0, - context_window=256_000, - max_output_tokens=4096, - requests_per_minute=20, - avg_latency_ms=600, - ), -} - - -# ============================================================================ -# ROUTING RULES FOR AGENT TASKS -# ============================================================================ - -ROUTING_RULES = [ - # Primary: Use Grok for all general agent reasoning - RoutingRule( - name="agent_reasoning_primary", - priority=1, - use_cases=[ - "agent_reasoning", - "task_orchestration", - "coordination_metrics", - "decision_making", - "ethical_reasoning", - ], - model=LLMModel.GROK_4_1_FAST, - fallback_model=LLMModel.GEMINI_2_FLASH, - conditions={"budget_tier": "economy", "latency_requirement": "<1000ms"}, - ), - # Code generation: Use Grok Code Fast - RoutingRule( - name="code_generation", - priority=2, - use_cases=["code_generation", "code_analysis", "debugging"], - model=LLMModel.GROK_CODE_FAST, - fallback_model=LLMModel.GROK_4_1_FAST, - conditions={"output_type": "code"}, - ), - # Lightweight tasks: Use Grok 3 Mini (30% cheaper) - RoutingRule( - name="lightweight_tasks", - priority=3, - use_cases=[ - "sentiment_analysis", - "text_classification", - "simple_routing", - "feedback_analysis", - ], - model=LLMModel.GROK_3_MINI, - fallback_model=LLMModel.GROK_4_1_FAST, - conditions={"estimated_tokens": "<500", "complexity": "low"}, - ), - # Premium reasoning: Use Claude for complex multi-step reasoning - RoutingRule( - name="complex_reasoning", - priority=4, - use_cases=[ - "research", - "long_form_analysis", - "complex_problem_solving", - "multi_step_planning", - ], - model=LLMModel.CLAUDE_SONNET, - fallback_model=LLMModel.GROK_4_1_FAST, - conditions={"budget_tier": "premium", "accuracy_required": "high"}, - ), - # Vision tasks: Use Gemini 2.0 Pro (supports multimodal) - RoutingRule( - name="vision_tasks", - priority=5, - use_cases=["image_analysis", "visual_understanding", "multimodal_reasoning"], - model=LLMModel.GEMINI_2_PRO, - fallback_model=LLMModel.CLAUDE_SONNET, - conditions={"input_type": "multimodal"}, - ), -] - - -# ============================================================================ -# AGENT COST PROFILES (14-AGENT SYSTEM) -# ============================================================================ - -AGENT_COST_PROFILES = [ - # Core Infrastructure Agents - AgentCostProfile( - agent_id="kael", - agent_name="Kael (Ethics Engine)", - estimated_daily_requests=2000, - primary_model=LLMModel.GROK_4_1_FAST, - estimated_monthly_cost=2.40, - estimated_monthly_cost_without_credits=2.40, - ), - AgentCostProfile( - agent_id="lumina", - agent_name="Lumina (Resonance/Frontend)", - estimated_daily_requests=1500, - primary_model=LLMModel.GROK_3_MINI, # Lighter tasks - estimated_monthly_cost=1.35, - estimated_monthly_cost_without_credits=1.35, - ), - AgentCostProfile( - agent_id="vega", - agent_name="Vega (Infrastructure)", - estimated_daily_requests=1200, - primary_model=LLMModel.GROK_4_1_FAST, - estimated_monthly_cost=1.44, - estimated_monthly_cost_without_credits=1.44, - ), - AgentCostProfile( - agent_id="aether", - agent_name="Aether (Balance/Database)", - estimated_daily_requests=800, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.72, - estimated_monthly_cost_without_credits=0.72, - ), - AgentCostProfile( - agent_id="phoenix", - agent_name="Phoenix (Renewal/QA)", - estimated_daily_requests=1000, - primary_model=LLMModel.GROK_4_1_FAST, - estimated_monthly_cost=1.20, - estimated_monthly_cost_without_credits=1.20, - ), - # Extended Agent Network - AgentCostProfile( - agent_id="arjuna", - agent_name="Arjuna (Memory/SuperArjuna)", - estimated_daily_requests=3000, - primary_model=LLMModel.GROK_4_1_FAST, - estimated_monthly_cost=3.60, - estimated_monthly_cost_without_credits=3.60, - ), - AgentCostProfile( - agent_id="grok", - agent_name="Grok (Analysis)", - estimated_daily_requests=2500, - primary_model=LLMModel.GROK_4_1_FAST, - estimated_monthly_cost=3.00, - estimated_monthly_cost_without_credits=3.00, - ), - AgentCostProfile( - agent_id="kavach", - agent_name="Kavach (Security)", - estimated_daily_requests=1200, - primary_model=LLMModel.GROK_4_1_FAST, - estimated_monthly_cost=1.44, - estimated_monthly_cost_without_credits=1.44, - ), - AgentCostProfile( - agent_id="gemini", - agent_name="Gemini (Balance)", - estimated_daily_requests=1000, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.90, - estimated_monthly_cost_without_credits=0.90, - ), - # Additional agents (5 more) - AgentCostProfile( - agent_id="agent_6", - agent_name="Agent 6 (Utility)", - estimated_daily_requests=800, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.72, - estimated_monthly_cost_without_credits=0.72, - ), - AgentCostProfile( - agent_id="agent_7", - agent_name="Agent 7 (Processing)", - estimated_daily_requests=800, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.72, - estimated_monthly_cost_without_credits=0.72, - ), - AgentCostProfile( - agent_id="agent_8", - agent_name="Agent 8 (Analysis)", - estimated_daily_requests=600, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.54, - estimated_monthly_cost_without_credits=0.54, - ), - AgentCostProfile( - agent_id="agent_9", - agent_name="Agent 9 (Monitoring)", - estimated_daily_requests=500, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.45, - estimated_monthly_cost_without_credits=0.45, - ), - AgentCostProfile( - agent_id="agent_10", - agent_name="Agent 10 (Support)", - estimated_daily_requests=400, - primary_model=LLMModel.GROK_3_MINI, - estimated_monthly_cost=0.36, - estimated_monthly_cost_without_credits=0.36, - ), -] - - -# ============================================================================ -# COST SUMMARY -# ============================================================================ - - -def calculate_total_system_cost() -> dict[str, float]: - """Calculate total monthly cost for all agents.""" - total_daily_requests = sum(p.estimated_daily_requests for p in AGENT_COST_PROFILES) - total_monthly_cost = sum(p.estimated_monthly_cost for p in AGENT_COST_PROFILES) - total_monthly_cost_without_credits = sum(p.estimated_monthly_cost_without_credits for p in AGENT_COST_PROFILES) - - # Grok data sharing program provides $150/month in credits - free_credits_per_month = 150 - net_monthly_cost = max(0, total_monthly_cost - free_credits_per_month) - - return { - "total_daily_requests": total_daily_requests, - "total_monthly_cost": total_monthly_cost, - "total_monthly_cost_without_credits": total_monthly_cost_without_credits, - "free_credits_per_month": free_credits_per_month, - "net_monthly_cost": net_monthly_cost, - "annual_cost": total_monthly_cost * 12, - "net_annual_cost": net_monthly_cost * 12, - "savings_vs_claude": (400 - total_monthly_cost) * 12, # vs Claude Sonnet - "savings_vs_gpt4o": (500 - total_monthly_cost) * 12, # vs GPT-4o - } - - -# Calculate costs -SYSTEM_COSTS = calculate_total_system_cost() - - -# ============================================================================ -# COST OPTIMIZATION STRATEGIES -# ============================================================================ - -COST_OPTIMIZATION_STRATEGIES = { - "prompt_caching": { - "description": "Cache agent system prompts to avoid repeated processing", - "savings": "30-40% on repeated requests", - "implementation": "Enable for all agents with >100 daily requests", - "models_supported": [ - LLMModel.GROK_4_1_FAST, - LLMModel.GROK_CODE_FAST, - LLMModel.GROK_3_MINI, - ], - }, - "batch_processing": { - "description": "Combine 5 small tasks into 1 larger request", - "savings": "20-25% through reduced per-request overhead", - "implementation": "Accumulate requests for 100ms before sending", - "models_supported": "all", - }, - "query_optimization": { - "description": "Fix N+1 database queries to reduce LLM call volume", - "savings": "40% reduction in total LLM requests", - "implementation": "See FEATURE_IMPROVEMENTS_CODE_GAPS_JAN2026.md", - "timeline": "2-3 days", - }, - "model_downsampling": { - "description": "Use Grok 3 Mini for simple tasks instead of 4.1 Fast", - "savings": "33% per request on lightweight operations", - "implementation": "Automatic routing based on task complexity", - "criteria": "<500 input tokens + low complexity", - }, - "data_sharing_enrollment": { - "description": "Enroll in Grok data sharing program", - "savings": "$150/month in free credits", - "implementation": "Opt-in at https://x.ai/api (data sharing program)", - "timeline": "Immediate", - "net_effect": "Platform runs for FREE for 6+ months", - }, - "fallback_routing": { - "description": "Use Gemini 2.0 Flash when Grok unavailable", - "cost_impact": "+$0.10/M tokens input (but maintains 99.9% availability)", - "implementation": "Automatic failover in router", - }, -} - - -# ============================================================================ -# LLM ROUTER CONFIGURATION -# ============================================================================ - -LLM_ROUTER = { - "primary": { - "provider": "xai", - "model": "grok-4-1-fast-reasoning", - "cost_per_m_input": 0.20, - "cost_per_m_output": 0.50, - "context_window": 2_000_000, - }, - "code": { - "provider": "xai", - "model": "grok-code-fast-1", - "cost_per_m_input": 0.20, - "cost_per_m_output": 1.50, - "context_window": 256_000, - }, - "lightweight": { - "provider": "xai", - "model": "grok-3-mini", - "cost_per_m_input": 0.30, - "cost_per_m_output": 0.50, - "context_window": 131_000, - }, - "fallback": { - "provider": "google", - "model": "gemini-2-0-flash", - "cost_per_m_input": 0.10, - "cost_per_m_output": 0.40, - "context_window": 1_000_000, - }, -} - - -def calculate_cost(input_tokens: int, output_tokens: int, model_type: str = "primary") -> float: - """ - Calculate the cost of an LLM API call. - - Args: - input_tokens: Number of input tokens - output_tokens: Number of output tokens - model_type: Type of model used ("primary", "code", "lightweight", "fallback") - - Returns: - Cost in USD - """ - config = LLM_ROUTER.get(model_type, LLM_ROUTER["primary"]) - - input_cost = (input_tokens / 1_000_000) * config["cost_per_m_input"] - output_cost = (output_tokens / 1_000_000) * config["cost_per_m_output"] - - return input_cost + output_cost - - -def get_total_estimated_cost() -> float: - """ - Get the total estimated monthly cost for the system. - - Returns: - Total monthly cost in USD - """ - return SYSTEM_COSTS["net_monthly_cost"] - - -# ============================================================================ -# COST SUMMARY OUTPUT -# ============================================================================ - -if __name__ == "__main__": - logger.info("\n" + "=" * 80) - logger.info("HELIX UNIFIED: LLM COST ANALYSIS") - logger.info("=" * 80) - logger.info("\n14-Agent System (Active 24/7)") - logger.info("ā”œā”€ Total Daily Requests: %s", f"{SYSTEM_COSTS['total_daily_requests']:,}") - logger.info("ā”œā”€ Total Monthly Cost: $%.2f", SYSTEM_COSTS["total_monthly_cost"]) - logger.info("ā”œā”€ Free Credits (Data Sharing): $%.2f/month", SYSTEM_COSTS["free_credits_per_month"]) - logger.info("ā”œā”€ Net Monthly Cost: $%.2f", SYSTEM_COSTS["net_monthly_cost"]) - logger.info("ā”œā”€ Annual Cost: $%.2f", SYSTEM_COSTS["annual_cost"]) - logger.info("└─ Net Annual Cost: $%.2f", SYSTEM_COSTS["net_annual_cost"]) - - logger.info("\nCost Savings vs Alternatives:") - logger.info("ā”œā”€ vs Claude Sonnet: $%.2f/year", SYSTEM_COSTS["savings_vs_claude"]) - logger.info("└─ vs GPT-4o: $%.2f/year", SYSTEM_COSTS["savings_vs_gpt4o"]) - - logger.info("\nāœ… Platform runs essentially FREE with Grok data sharing program!") - logger.info("āœ… 2M token context window (15x larger than GPT-4o's 128K)") - logger.info("āœ… Real-time web search included (no extra API calls)") - logger.info("\n" + "=" * 80) diff --git a/llm_gateway.py b/llm_gateway.py deleted file mode 100644 index 1fe3789..0000000 --- a/llm_gateway.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -āš ļø DEPRECATION NOTICE -===================== - -This module is DEPRECATED and will be removed in a future version. - -The canonical location for llm_gateway is: - apps.backend.services.unified_llm - -This redirect file provides backward compatibility by re-exporting -all public symbols from the canonical location. - -Migration: - # Old (deprecated) - from apps.backend.llm_gateway import SomeClass - - # New (canonical) - from apps.backend.services.unified_llm import SomeClass - -This file will be removed in version 22.0.0. -""" - -import warnings - -# Issue deprecation warning -warnings.warn( - "apps.backend.llm_gateway is deprecated. " - "Use apps.backend.services.unified_llm instead. " - "This redirect will be removed in version 22.0.0.", - DeprecationWarning, - stacklevel=2, -) - -# Re-export all public symbols from canonical location -from apps.backend.services.unified_llm import * # noqa: F401, F403 - -# Re-export __all__ if defined in canonical module -try: - from apps.backend.services.unified_llm import __all__ as _canonical_all - __all__ = _canonical_all -except ImportError: - pass \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..86b21ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "helix-llm-agent-engine" +version = "0.1.0" +description = "A small, bounded Python agent layer for OpenAI-compatible model endpoints" +readme = "README.md" +requires-python = ">=3.11" +license-files = ["LICENSE", "LICENSE.PROPRIETARY"] +authors = [{ name = "Deathcharge" }] +keywords = ["agents", "llm", "openai-compatible", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", + "Private :: Do Not Upload", +] +dependencies = ["httpx>=0.27,<1"] + +[project.optional-dependencies] +dev = [ + "bandit>=1.8,<2", + "build>=1.2,<2", + "mypy>=1.15,<2", + "pip-audit>=2.10,<3", + "pytest>=8,<10", + "pytest-asyncio>=0.24,<2", + "pytest-cov>=6,<8", + "ruff>=0.11,<1", + "twine>=6,<7", +] + +[project.scripts] +helix-agent = "helix_llm_agent_engine.cli:main" + +[project.urls] +Repository = "https://github.com/Deathcharge/helix-hub-shared" +Issues = "https://github.com/Deathcharge/helix-hub-shared/issues" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] +include = ["helix_llm_agent_engine*"] + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = "-ra --strict-config --strict-markers" +asyncio_mode = "auto" +markers = [ + "integration: installed-package and command-level integration tests", +] + +[tool.coverage.run] +branch = true +source = ["helix_llm_agent_engine"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 90 + +[tool.ruff] +target-version = "py311" +line-length = 100 +src = ["src", "tests"] +extend-exclude = ["agents", "services"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"examples/*.py" = ["T201"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src/helix_llm_agent_engine"] +warn_unreachable = true diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 92b320e..0000000 --- a/pytest.ini +++ /dev/null @@ -1,14 +0,0 @@ -[pytest] -minversion = 7.0 -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = -v --tb=short --strict-markers -markers = - engine: test engine functionality - agent: test agent functionality - coordination: test coordination - communication: test communication - performance: test performance - integration: test integration scenarios diff --git a/requirements-test.txt b/requirements-test.txt index fa12d1f..70b60b5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,9 @@ -pytest>=7.0 -pytest-cov>=4.0.0 -pytest-mock>=3.10.0 -pytest-timeout>=2.1.0 +bandit>=1.8,<2 +build>=1.2,<2 +mypy>=1.15,<2 +pip-audit>=2.10,<3 +pytest>=8,<10 +pytest-asyncio>=0.24,<2 +pytest-cov>=6,<8 +ruff>=0.11,<1 +twine>=6,<7 diff --git a/requirements.txt b/requirements.txt index 3f5afe8..1bd8a60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,3 @@ -# Helix LLM Agent Engine - Core Dependencies - -# Core Framework -pydantic>=2.0.0 -pydantic-settings>=2.0.0 -python-dotenv>=1.0.0 -pyyaml>=6.0 - -# HTTP & Async -aiohttp>=3.8.0 -httpx>=0.24.0 -requests>=2.31.0 - -# LLM Providers -openai>=1.0.0 -anthropic>=0.7.0 - -# Caching & Storage -redis>=4.5.0 - -# Monitoring & Metrics -prometheus-client>=0.17.0 - -# Development Dependencies -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -pytest-cov>=4.1.0 -black>=23.0.0 -isort>=5.12.0 -flake8>=6.0.0 -mypy>=1.0.0 - -# Optional: Grok Support -# xai-sdk>=0.1.0 - -# Optional: Local LLM Support -# ollama>=0.1.0 +# Kept for environments that install requirements files directly. +# Package metadata in pyproject.toml is authoritative. +httpx>=0.27,<1 diff --git a/setup.py b/setup.py deleted file mode 100644 index beb60ff..0000000 --- a/setup.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -""" -Setup configuration for Helix LLM Agent Engine -""" - -from setuptools import setup, find_packages - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setup( - name="helix-llm-agent-engine", - version="1.0.0", - author="Deathcharge", - author_email="contact@helix-collective.ai", - description="Production-ready framework for building multi-LLM agent systems with consciousness modulation", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Deathcharge/helix-llm-agent-engine", - packages=find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - ], - python_requires=">=3.11", - install_requires=[ - "pydantic>=2.0.0", - "pydantic-settings>=2.0.0", - "aiohttp>=3.8.0", - "httpx>=0.24.0", - "openai>=1.0.0", - "anthropic>=0.7.0", - "redis>=4.5.0", - "prometheus-client>=0.17.0", - "python-dotenv>=1.0.0", - "pyyaml>=6.0", - ], - extras_require={ - "dev": [ - "pytest>=7.4.0", - "pytest-asyncio>=0.21.0", - "pytest-cov>=4.1.0", - "black>=23.0.0", - "isort>=5.12.0", - "flake8>=6.0.0", - "mypy>=1.0.0", - ], - "grok": [ - "xai-sdk>=0.1.0", - ], - "local": [ - "ollama>=0.1.0", - ], - }, - entry_points={ - "console_scripts": [ - "helix-agent=helix_llm_agent_engine.cli:main", - ], - }, -) diff --git a/src/helix_llm_agent_engine/__init__.py b/src/helix_llm_agent_engine/__init__.py new file mode 100644 index 0000000..a3eff06 --- /dev/null +++ b/src/helix_llm_agent_engine/__init__.py @@ -0,0 +1,32 @@ +"""A small, bounded agent layer for OpenAI-compatible model endpoints.""" + +from .engine import Agent, AgentOrchestrator, LLMAgentEngine +from .errors import ( + BudgetExceededError, + ConfigurationError, + HelixAgentError, + InputValidationError, + ProviderError, +) +from .models import AgentMetrics, ChatMessage, ProviderResponse +from .providers import BaseLLMProvider, EchoProvider, OpenAICompatibleProvider + +__version__ = "0.1.0" + +__all__ = [ + "Agent", + "AgentMetrics", + "AgentOrchestrator", + "BaseLLMProvider", + "BudgetExceededError", + "ChatMessage", + "ConfigurationError", + "EchoProvider", + "HelixAgentError", + "InputValidationError", + "LLMAgentEngine", + "OpenAICompatibleProvider", + "ProviderError", + "ProviderResponse", + "__version__", +] diff --git a/src/helix_llm_agent_engine/__main__.py b/src/helix_llm_agent_engine/__main__.py new file mode 100644 index 0000000..8e40a14 --- /dev/null +++ b/src/helix_llm_agent_engine/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m helix_llm_agent_engine`` execution.""" + +from .cli import main + +raise SystemExit(main()) diff --git a/src/helix_llm_agent_engine/cli.py b/src/helix_llm_agent_engine/cli.py new file mode 100644 index 0000000..4a59f63 --- /dev/null +++ b/src/helix_llm_agent_engine/cli.py @@ -0,0 +1,135 @@ +"""Command-line interface for a single bounded agent invocation.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from collections.abc import Sequence +from urllib.parse import urlsplit + +from . import __version__ +from .engine import LLMAgentEngine +from .errors import ConfigurationError, HelixAgentError, InputValidationError +from .providers import OpenAICompatibleProvider + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="helix-agent", + description="Run a small, bounded prompt agent over echo or an OpenAI-compatible endpoint.", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + subparsers = parser.add_subparsers(dest="command", required=True) + + run = subparsers.add_parser("run", help="run one agent invocation") + run.add_argument("prompt", nargs="?", help="prompt text; reads stdin when omitted") + run.add_argument("--provider", choices=("echo", "openai"), default="echo") + run.add_argument("--model", help="model identifier (required for --provider openai)") + run.add_argument("--name", default="assistant", help="agent name") + run.add_argument("--system-prompt", default="You are a concise, helpful assistant.") + run.add_argument( + "--base-url", + default=os.getenv("HELIX_LLM_BASE_URL", "https://api.openai.com/v1"), + help="OpenAI-compatible API base URL", + ) + run.add_argument( + "--api-key-env", + default="OPENAI_API_KEY", + help="environment variable containing the API key; keys are never accepted as arguments", + ) + run.add_argument("--timeout", type=float, default=30.0) + run.add_argument("--max-retries", type=int, default=2) + run.add_argument("--max-input-chars", type=int, default=20_000) + run.add_argument("--max-output-tokens", type=int, default=1_024) + run.add_argument("--max-response-chars", type=int, default=200_000) + run.add_argument("--json", action="store_true", help="emit a JSON result") + return parser + + +def _read_prompt(argument: str | None, max_input_chars: int) -> str: + if argument is not None: + return argument + if sys.stdin.isatty(): + raise InputValidationError("prompt is required when stdin is interactive") + prompt = sys.stdin.read(max_input_chars + 1) + if len(prompt) > max_input_chars: + raise InputValidationError( + f"stdin exceeds the configured {max_input_chars}-character limit" + ) + return prompt + + +def _safe_text_output(value: object) -> str: + text = str(value) + return "".join( + character if character in {"\n", "\t"} or character.isprintable() else "ļæ½" + for character in text + ) + + +async def _run(args: argparse.Namespace) -> dict[str, object]: + engine = LLMAgentEngine( + default_provider=args.provider, + max_input_chars=args.max_input_chars, + max_output_tokens=args.max_output_tokens, + max_response_chars=args.max_response_chars, + max_requests_per_session=1, + ) + prompt = _read_prompt(args.prompt, args.max_input_chars) + if args.provider == "openai": + if not args.model: + raise ConfigurationError("--model is required for --provider openai") + api_key = os.getenv(args.api_key_env) + hostname = urlsplit(args.base_url).hostname or "" + if hostname.lower() == "api.openai.com" and not api_key: + raise ConfigurationError(f"{args.api_key_env} is required for api.openai.com") + engine.register_provider( + "openai", + OpenAICompatibleProvider( + api_key=api_key, + base_url=args.base_url, + timeout=args.timeout, + max_retries=args.max_retries, + ), + ) + model = args.model or "echo" + try: + agent = engine.create_agent( + name=args.name, + model=model, + system_prompt=args.system_prompt, + ) + content = await agent.invoke(prompt) + return { + "content": content, + "provider": agent.provider_name, + "model": agent.model, + "metrics": agent.get_metrics(), + } + finally: + await engine.close() + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + result = asyncio.run(_run(args)) + except KeyboardInterrupt: + print("cancelled", file=sys.stderr) + return 130 + except (ConfigurationError, InputValidationError) as exc: + print(f"configuration error: {exc}", file=sys.stderr) + return 2 + except HelixAgentError as exc: + print(f"provider error: {exc}", file=sys.stderr) + return 3 + + if args.json: + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + else: + print(_safe_text_output(result["content"])) + return 0 diff --git a/src/helix_llm_agent_engine/engine.py b/src/helix_llm_agent_engine/engine.py new file mode 100644 index 0000000..8894535 --- /dev/null +++ b/src/helix_llm_agent_engine/engine.py @@ -0,0 +1,369 @@ +"""Stateful agent and orchestration primitives.""" + +from __future__ import annotations + +import asyncio +import re +from collections import OrderedDict +from collections.abc import Sequence +from time import perf_counter + +from .errors import ( + BudgetExceededError, + ConfigurationError, + HelixAgentError, + InputValidationError, + ProviderError, +) +from .models import AgentMetrics, ChatMessage, ProviderResponse +from .providers import BaseLLMProvider, EchoProvider + +_SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + + +class Agent: + """A named prompt, provider, and bounded in-memory conversation history.""" + + def __init__( + self, + *, + name: str, + model: str, + system_prompt: str, + provider: BaseLLMProvider, + provider_name: str, + max_history_messages: int, + max_sessions: int, + max_input_chars: int, + max_requests_per_session: int, + max_output_tokens: int, + max_response_chars: int, + temperature: float, + ) -> None: + self.name = name + self.model = model + self.system_prompt = system_prompt + self.provider_name = provider_name + self._provider = provider + self._max_history_messages = max_history_messages + self._max_sessions = max_sessions + self._max_input_chars = max_input_chars + self._max_requests_per_session = max_requests_per_session + self._max_output_tokens = max_output_tokens + self._max_response_chars = max_response_chars + self._temperature = temperature + self._history: OrderedDict[str, list[ChatMessage]] = OrderedDict() + self._request_counts: dict[str, int] = {} + self._metrics = AgentMetrics() + self._lock = asyncio.Lock() + + async def invoke(self, prompt: str, *, session_id: str = "default") -> str: + """Invoke the provider and append a successful turn to session history. + + One agent serializes calls so turns cannot be reordered within a session. + Create separate agents when independent concurrent calls are required. + """ + + prompt = self._validate_prompt(prompt) + session_id = self._validate_session_id(session_id) + async with self._lock: + self._ensure_session(session_id) + request_count = self._request_counts[session_id] + if request_count >= self._max_requests_per_session: + raise BudgetExceededError( + f"session {session_id!r} reached its request limit; clear it before retrying" + ) + self._request_counts[session_id] = request_count + 1 + self._metrics.requests += 1 + messages = self._build_messages(session_id, prompt) + started = perf_counter() + try: + raw_response = await self._provider.invoke( + messages, + self.model, + max_tokens=self._max_output_tokens, + temperature=self._temperature, + ) + response = self._normalize_provider_response(raw_response) + if len(response.content) > self._max_response_chars: + raise ProviderError("provider response exceeded the configured character limit") + except asyncio.CancelledError: + self._metrics.failures += 1 + raise + except HelixAgentError: + self._metrics.failures += 1 + raise + except Exception as exc: + self._metrics.failures += 1 + raise ProviderError("custom provider invocation failed") from exc + finally: + self._metrics.last_latency_ms = round((perf_counter() - started) * 1_000, 3) + + history = self._history[session_id] + history.extend( + [ + ChatMessage(role="user", content=prompt), + ChatMessage(role="assistant", content=response.content), + ] + ) + if len(history) > self._max_history_messages: + del history[: len(history) - self._max_history_messages] + self._history.move_to_end(session_id) + self._metrics.successes += 1 + self._metrics.input_tokens += response.input_tokens or 0 + self._metrics.output_tokens += response.output_tokens or 0 + return response.content + + def history(self, session_id: str = "default") -> tuple[ChatMessage, ...]: + """Return an immutable snapshot of one session's successful turns.""" + + session_id = self._validate_session_id(session_id) + return tuple(self._history.get(session_id, ())) + + def clear_history(self, session_id: str | None = None) -> None: + """Clear one session, or every session when ``session_id`` is omitted.""" + + if session_id is None: + self._history.clear() + self._request_counts.clear() + return + session_id = self._validate_session_id(session_id) + self._history.pop(session_id, None) + self._request_counts.pop(session_id, None) + + def get_metrics(self) -> dict[str, int | float | None]: + """Return local request and provider-reported token counters.""" + + return self._metrics.as_dict() + + def _ensure_session(self, session_id: str) -> None: + if session_id in self._history: + self._history.move_to_end(session_id) + return + while len(self._history) >= self._max_sessions: + evicted, _ = self._history.popitem(last=False) + self._request_counts.pop(evicted, None) + self._history[session_id] = [] + self._request_counts[session_id] = 0 + + def _build_messages(self, session_id: str, prompt: str) -> list[ChatMessage]: + messages: list[ChatMessage] = [] + if self.system_prompt: + messages.append(ChatMessage(role="system", content=self.system_prompt)) + messages.extend(self._history[session_id]) + messages.append(ChatMessage(role="user", content=prompt)) + return messages + + @staticmethod + def _normalize_provider_response(raw: ProviderResponse | str) -> ProviderResponse: + if isinstance(raw, ProviderResponse): + return raw + if isinstance(raw, str) and raw: + return ProviderResponse(content=raw) + raise ProviderError("provider returned an empty or unsupported response") + + def _validate_prompt(self, prompt: str) -> str: + if not isinstance(prompt, str) or not prompt.strip(): + raise InputValidationError("prompt must be a non-empty string") + if len(prompt) > self._max_input_chars: + raise InputValidationError( + f"prompt exceeds the configured {self._max_input_chars}-character limit" + ) + return prompt + + @staticmethod + def _validate_session_id(session_id: str) -> str: + if not isinstance(session_id, str) or not session_id or len(session_id) > 128: + raise InputValidationError("session_id must contain between 1 and 128 characters") + if any(ord(character) < 32 for character in session_id): + raise InputValidationError("session_id must not contain control characters") + return session_id + + +class LLMAgentEngine: + """Registry and factory for bounded agents.""" + + def __init__( + self, + *, + default_provider: str = "echo", + max_history_messages: int = 20, + max_sessions: int = 100, + max_input_chars: int = 20_000, + max_requests_per_session: int = 100, + max_output_tokens: int = 1_024, + max_response_chars: int = 200_000, + temperature: float = 0.7, + ) -> None: + if not isinstance(max_history_messages, int) or not 2 <= max_history_messages <= 1_000: + raise ConfigurationError("max_history_messages must be between 2 and 1000") + if not isinstance(max_sessions, int) or not 1 <= max_sessions <= 10_000: + raise ConfigurationError("max_sessions must be between 1 and 10000") + if not isinstance(max_input_chars, int) or not 1 <= max_input_chars <= 1_000_000: + raise ConfigurationError("max_input_chars must be between 1 and 1000000") + if ( + not isinstance(max_requests_per_session, int) + or not 1 <= max_requests_per_session <= 100_000 + ): + raise ConfigurationError("max_requests_per_session must be between 1 and 100000") + if not isinstance(max_output_tokens, int) or not 1 <= max_output_tokens <= 131_072: + raise ConfigurationError("max_output_tokens must be between 1 and 131072") + if not isinstance(max_response_chars, int) or not 1 <= max_response_chars <= 1_000_000: + raise ConfigurationError("max_response_chars must be between 1 and 1000000") + if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: + raise ConfigurationError("temperature must be between 0 and 2") + + self.default_provider = self._validate_provider_name(default_provider) + self._providers: dict[str, BaseLLMProvider] = {"echo": EchoProvider()} + self._max_history_messages = max_history_messages + self._max_sessions = max_sessions + self._max_input_chars = max_input_chars + self._max_requests_per_session = max_requests_per_session + self._max_output_tokens = max_output_tokens + self._max_response_chars = max_response_chars + self._temperature = float(temperature) + self._managed_providers: dict[int, BaseLLMProvider] = { + id(self._providers["echo"]): self._providers["echo"] + } + self._closed = False + + def register_provider(self, name: str, provider: BaseLLMProvider) -> None: + """Register or intentionally replace a provider under a stable name.""" + + name = self._validate_provider_name(name) + if self._closed: + raise ConfigurationError("engine is closed") + if not isinstance(provider, BaseLLMProvider): + raise ConfigurationError("provider must inherit from BaseLLMProvider") + self._providers[name] = provider + self._managed_providers.setdefault(id(provider), provider) + + def create_agent( + self, + *, + name: str, + model: str, + system_prompt: str = "", + provider: str | None = None, + ) -> Agent: + """Create an independent agent using a registered provider.""" + + if self._closed: + raise ConfigurationError("engine is closed") + if not isinstance(name, str) or not _SAFE_NAME.fullmatch(name): + raise InputValidationError( + "agent name must be 1-64 letters, numbers, underscores, or hyphens" + ) + if not isinstance(model, str) or not model.strip() or len(model) > 200: + raise InputValidationError("model must be a non-empty string of at most 200 characters") + if not isinstance(system_prompt, str) or len(system_prompt) > self._max_input_chars: + raise InputValidationError("system_prompt must be a string within the input limit") + provider_name = self._validate_provider_name(provider or self.default_provider) + try: + provider_instance = self._providers[provider_name] + except KeyError as exc: + available = ", ".join(sorted(self._providers)) + raise ConfigurationError( + f"provider {provider_name!r} is not registered (available: {available})" + ) from exc + return Agent( + name=name, + model=model.strip(), + system_prompt=system_prompt, + provider=provider_instance, + provider_name=provider_name, + max_history_messages=self._max_history_messages, + max_sessions=self._max_sessions, + max_input_chars=self._max_input_chars, + max_requests_per_session=self._max_requests_per_session, + max_output_tokens=self._max_output_tokens, + max_response_chars=self._max_response_chars, + temperature=self._temperature, + ) + + async def close(self) -> None: + """Close each distinct registered provider exactly once.""" + + if self._closed: + return + self._closed = True + first_error: Exception | None = None + for provider in self._managed_providers.values(): + try: + await provider.close() + except Exception as exc: + if first_error is None: + first_error = exc + if first_error is not None: + raise ProviderError("provider cleanup failed") from first_error + + async def __aenter__(self) -> LLMAgentEngine: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + @staticmethod + def _validate_provider_name(name: str) -> str: + if not isinstance(name, str) or not _SAFE_NAME.fullmatch(name): + raise ConfigurationError( + "provider name must be 1-64 letters, numbers, underscores, or hyphens" + ) + return name + + +class AgentOrchestrator: + """Small sequential collaboration helper with explicit amplification caps.""" + + def __init__(self, *, max_agents: int = 8) -> None: + if not isinstance(max_agents, int) or not 1 <= max_agents <= 32: + raise ConfigurationError("max_agents must be between 1 and 32") + self.max_agents = max_agents + self._agents: list[Agent] = [] + + def add_agent(self, agent: Agent) -> None: + if not isinstance(agent, Agent): + raise ConfigurationError("orchestrator accepts Agent instances only") + if len(self._agents) >= self.max_agents: + raise BudgetExceededError("orchestrator reached its agent limit") + if any(existing.name == agent.name for existing in self._agents): + raise ConfigurationError(f"agent {agent.name!r} is already registered") + self._agents.append(agent) + + async def collective_loop( + self, + *, + prompt: str, + max_iterations: int = 1, + session_id: str = "collective", + ) -> str: + """Run agents sequentially and return a labeled transcript. + + The hard five-iteration and ``max_agents`` limits bound accidental API + amplification. Each call is still billed by the configured provider. + """ + + if not self._agents: + raise ConfigurationError("orchestrator has no agents") + if not isinstance(max_iterations, int) or not 1 <= max_iterations <= 5: + raise ConfigurationError("max_iterations must be between 1 and 5") + if not isinstance(prompt, str) or not prompt.strip(): + raise InputValidationError("prompt must be a non-empty string") + + transcript: list[str] = [] + current_prompt = prompt + for iteration in range(max_iterations): + for agent in self._agents: + response = await agent.invoke( + current_prompt, + session_id=f"{session_id}:{iteration}:{agent.name}", + ) + transcript.append(f"{agent.name}: {response}") + current_prompt = ( + f"Original task: {prompt}\n\nPrior contribution from {agent.name}:\n{response}" + ) + return "\n".join(transcript) + + @property + def agents(self) -> Sequence[Agent]: + return tuple(self._agents) diff --git a/src/helix_llm_agent_engine/errors.py b/src/helix_llm_agent_engine/errors.py new file mode 100644 index 0000000..1397918 --- /dev/null +++ b/src/helix_llm_agent_engine/errors.py @@ -0,0 +1,32 @@ +"""Public exception hierarchy for Helix LLM Agent Engine.""" + + +class HelixAgentError(Exception): + """Base class for errors raised by the package.""" + + +class ConfigurationError(HelixAgentError): + """Raised when engine or provider configuration is invalid.""" + + +class InputValidationError(HelixAgentError, ValueError): + """Raised when caller-provided agent input is invalid.""" + + +class BudgetExceededError(HelixAgentError): + """Raised before a request would exceed a configured local limit.""" + + +class ProviderError(HelixAgentError): + """Raised when a model provider fails or returns an invalid response.""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.retryable = retryable diff --git a/src/helix_llm_agent_engine/models.py b/src/helix_llm_agent_engine/models.py new file mode 100644 index 0000000..7981be8 --- /dev/null +++ b/src/helix_llm_agent_engine/models.py @@ -0,0 +1,79 @@ +"""Small, provider-neutral data models used by the public API.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .errors import InputValidationError + +Role = Literal["system", "user", "assistant"] + + +@dataclass(frozen=True, slots=True) +class ChatMessage: + """One validated message passed to a model provider.""" + + role: Role + content: str + + def __post_init__(self) -> None: + if self.role not in {"system", "user", "assistant"}: + raise InputValidationError(f"unsupported message role: {self.role!r}") + if not isinstance(self.content, str) or not self.content: + raise InputValidationError("message content must be a non-empty string") + + def as_dict(self) -> dict[str, str]: + """Return the OpenAI-compatible representation of this message.""" + + return {"role": self.role, "content": self.content} + + +@dataclass(frozen=True, slots=True) +class ProviderResponse: + """Normalized response returned by every provider implementation.""" + + content: str + model: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + + def __post_init__(self) -> None: + if not isinstance(self.content, str) or not self.content: + raise InputValidationError("provider response content must be non-empty") + for label, value in ( + ("input_tokens", self.input_tokens), + ("output_tokens", self.output_tokens), + ): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + ): + raise InputValidationError(f"{label} must be a non-negative integer") + + +@dataclass(slots=True) +class AgentMetrics: + """Local counters for one agent instance. + + Token counts are populated only when the provider reports them. They are not + estimated or presented as billing data. + """ + + requests: int = 0 + successes: int = 0 + failures: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + last_latency_ms: float | None = None + + def as_dict(self) -> dict[str, int | float | None]: + """Return a stable, JSON-serializable snapshot.""" + + return { + "requests": self.requests, + "successes": self.successes, + "failures": self.failures, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "last_latency_ms": self.last_latency_ms, + } diff --git a/src/helix_llm_agent_engine/providers.py b/src/helix_llm_agent_engine/providers.py new file mode 100644 index 0000000..a909f65 --- /dev/null +++ b/src/helix_llm_agent_engine/providers.py @@ -0,0 +1,249 @@ +"""Built-in provider implementations. + +The network provider intentionally targets the small OpenAI-compatible chat +surface instead of embedding many vendor SDKs. Applications can implement +``BaseLLMProvider`` when their provider uses a different protocol. +""" + +from __future__ import annotations + +import asyncio +import json +import math +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Any +from urllib.parse import urlsplit + +import httpx + +from .errors import ConfigurationError, ProviderError +from .models import ChatMessage, ProviderResponse + +_RETRYABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504} + + +class BaseLLMProvider(ABC): + """Minimal extension point for model providers.""" + + @abstractmethod + async def invoke( + self, + messages: Sequence[ChatMessage], + model: str, + *, + max_tokens: int, + temperature: float, + ) -> ProviderResponse | str: + """Return one complete model response.""" + + async def close(self) -> None: + """Release provider resources. Stateless providers need no cleanup.""" + return None + + +class EchoProvider(BaseLLMProvider): + """Deterministic offline provider for setup checks, examples, and tests. + + EchoProvider is not an LLM and is never selected as a hidden fallback after + a network provider fails. + """ + + def __init__(self, prefix: str = "Echo") -> None: + if not isinstance(prefix, str) or not prefix.strip(): + raise ConfigurationError("echo prefix must be a non-empty string") + self.prefix = prefix.strip() + + async def invoke( + self, + messages: Sequence[ChatMessage], + model: str, + *, + max_tokens: int, + temperature: float, + ) -> ProviderResponse: + del max_tokens, temperature + user_messages = [message.content for message in messages if message.role == "user"] + if not user_messages: + raise ProviderError("echo provider received no user message") + return ProviderResponse(content=f"{self.prefix}: {user_messages[-1]}", model=model) + + +class OpenAICompatibleProvider(BaseLLMProvider): + """Bounded client for an OpenAI-compatible ``chat/completions`` endpoint.""" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str = "https://api.openai.com/v1", + timeout: float = 30.0, + max_retries: int = 2, + retry_backoff: float = 0.5, + max_response_bytes: int = 2_000_000, + client: httpx.AsyncClient | None = None, + ) -> None: + self.endpoint = self._validate_endpoint(base_url) + if api_key is not None and (not isinstance(api_key, str) or not api_key.strip()): + raise ConfigurationError("api_key must be a non-empty string when supplied") + if not 0 < timeout <= 300: + raise ConfigurationError("timeout must be greater than 0 and at most 300 seconds") + if not isinstance(max_retries, int) or not 0 <= max_retries <= 5: + raise ConfigurationError("max_retries must be an integer between 0 and 5") + if not 0 <= retry_backoff <= 10: + raise ConfigurationError("retry_backoff must be between 0 and 10 seconds") + if not 1_024 <= max_response_bytes <= 10_000_000: + raise ConfigurationError("max_response_bytes must be between 1024 and 10000000") + + self.max_retries = max_retries + self.retry_backoff = retry_backoff + self.max_response_bytes = max_response_bytes + self._owns_client = client is None + headers = {"Accept": "application/json", "User-Agent": "helix-llm-agent-engine/0.1"} + if api_key: + headers["Authorization"] = f"Bearer {api_key.strip()}" + self._client = client or httpx.AsyncClient( + headers=headers, + timeout=httpx.Timeout(timeout), + follow_redirects=False, + ) + + @staticmethod + def _validate_endpoint(base_url: str) -> str: + if not isinstance(base_url, str) or not base_url.strip(): + raise ConfigurationError("base_url must be a non-empty URL") + if len(base_url) > 2_048: + raise ConfigurationError("base_url is too long") + parsed = urlsplit(base_url.strip()) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ConfigurationError("base_url must be an absolute http or https URL") + if parsed.username is not None or parsed.password is not None: + raise ConfigurationError("base_url must not contain credentials") + if parsed.query or parsed.fragment: + raise ConfigurationError("base_url must not contain a query string or fragment") + normalized = base_url.strip().rstrip("/") + if parsed.path.rstrip("/").endswith("/chat/completions"): + return normalized + return f"{normalized}/chat/completions" + + async def invoke( + self, + messages: Sequence[ChatMessage], + model: str, + *, + max_tokens: int, + temperature: float, + ) -> ProviderResponse: + payload = { + "model": model, + "messages": [message.as_dict() for message in messages], + "max_tokens": max_tokens, + "temperature": temperature, + "stream": False, + } + + for attempt in range(self.max_retries + 1): + try: + request = self._client.build_request("POST", self.endpoint, json=payload) + response = await self._client.send(request, stream=True) + except httpx.TimeoutException as exc: + if attempt < self.max_retries: + await self._sleep_before_retry(attempt) + continue + raise ProviderError("provider request timed out", retryable=True) from exc + except httpx.RequestError as exc: + if attempt < self.max_retries: + await self._sleep_before_retry(attempt) + continue + raise ProviderError("provider request failed", retryable=True) from exc + + try: + status = response.status_code + if status in _RETRYABLE_STATUS_CODES and attempt < self.max_retries: + retry_after = self._bounded_retry_after(response.headers.get("retry-after")) + await response.aclose() + await self._sleep_before_retry(attempt, retry_after) + continue + if not 200 <= status < 300: + request_id = self._safe_request_id(response.headers.get("x-request-id")) + suffix = f" (request {request_id})" if request_id else "" + raise ProviderError( + f"provider returned HTTP {status}{suffix}", + status_code=status, + retryable=status in _RETRYABLE_STATUS_CODES, + ) + data = await self._read_bounded_json(response) + finally: + await response.aclose() + + return self._normalize_response(data, requested_model=model) + + raise ProviderError("provider request exhausted its retry budget", retryable=True) + + async def _read_bounded_json(self, response: httpx.Response) -> Any: + chunks: list[bytes] = [] + size = 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + if size > self.max_response_bytes: + raise ProviderError("provider response exceeded the configured size limit") + chunks.append(chunk) + try: + return json.loads(b"".join(chunks)) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProviderError("provider returned invalid JSON") from exc + + @staticmethod + def _normalize_response(data: Any, *, requested_model: str) -> ProviderResponse: + try: + choice = data["choices"][0] + content = choice["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise ProviderError( + "provider response did not match the chat completion schema" + ) from exc + if not isinstance(content, str) or not content: + raise ProviderError("provider response contained no text content") + + usage = data.get("usage", {}) if isinstance(data, dict) else {} + input_tokens = usage.get("prompt_tokens") if isinstance(usage, dict) else None + output_tokens = usage.get("completion_tokens") if isinstance(usage, dict) else None + model = data.get("model", requested_model) if isinstance(data, dict) else requested_model + return ProviderResponse( + content=content, + model=model if isinstance(model, str) else requested_model, + input_tokens=input_tokens if isinstance(input_tokens, int) else None, + output_tokens=output_tokens if isinstance(output_tokens, int) else None, + ) + + @staticmethod + def _bounded_retry_after(value: str | None) -> float | None: + if value is None: + return None + try: + parsed = float(value) + return min(max(parsed, 0.0), 10.0) if math.isfinite(parsed) else None + except ValueError: + return None + + @staticmethod + def _safe_request_id(value: str | None) -> str | None: + if value is None: + return None + sanitized = "".join( + character + for character in value[:256] + if character.isascii() and (character.isalnum() or character in "-_.:") + ) + return sanitized[:128] or None + + async def _sleep_before_retry(self, attempt: int, retry_after: float | None = None) -> None: + delay = retry_after if retry_after is not None else self.retry_backoff * (2**attempt) + if delay: + await asyncio.sleep(min(delay, 10.0)) + + async def close(self) -> None: + """Close the internally created HTTP client.""" + + if self._owns_client and not self._client.is_closed: + await self._client.aclose() diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e277c04..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Comprehensive pytest configuration and fixtures for helix-hub-shared.""" - -import pytest -from unittest.mock import Mock, MagicMock, patch - - -# ============================================================================ -# LLM Configuration Fixtures -# ============================================================================ - -@pytest.fixture -def mock_llm_config(): - """Mock LLM configuration.""" - return { - "model": "gpt-4", - "provider": "openai", - "temperature": 0.7, - "max_tokens": 2000, - "top_p": 0.9 - } - - -@pytest.fixture -def mock_agent_config(): - """Mock agent configuration.""" - return { - "agent_id": "agent-1", - "name": "TestAgent", - "personality": "helpful", - "capabilities": ["reasoning", "planning"], - "llm_config": {"model": "gpt-4", "temperature": 0.7} - } - - -# ============================================================================ -# Agent Fixtures -# ============================================================================ - -@pytest.fixture -def mock_agent(): - """Mock agent instance.""" - agent = MagicMock() - agent.agent_id = "agent-1" - agent.name = "TestAgent" - agent.personality = "helpful" - agent.execute = MagicMock(return_value={"result": "success"}) - agent.get_state = MagicMock(return_value={"status": "active"}) - return agent - - -@pytest.fixture -def mock_agents_list(): - """Mock list of agents.""" - agents = [] - for i in range(3): - agent = MagicMock() - agent.agent_id = f"agent-{i}" - agent.name = f"Agent{i}" - agents.append(agent) - return agents - - -# ============================================================================ -# Engine Fixtures -# ============================================================================ - -@pytest.fixture -def mock_llm_engine(): - """Mock LLM engine.""" - engine = MagicMock() - engine.generate = MagicMock(return_value="Generated response") - engine.stream_generate = MagicMock(return_value=["chunk1", "chunk2"]) - engine.get_config = MagicMock(return_value={"model": "gpt-4"}) - return engine - - -@pytest.fixture -def mock_agent_engine(): - """Mock agent engine.""" - engine = MagicMock() - engine.register_agent = MagicMock(return_value=True) - engine.execute_task = MagicMock(return_value={"result": "success"}) - engine.get_agent = MagicMock(return_value=MagicMock()) - engine.list_agents = MagicMock(return_value=[]) - return engine - - -# ============================================================================ -# Communication Fixtures -# ============================================================================ - -@pytest.fixture -def mock_message(): - """Mock message.""" - return { - "id": "msg-1", - "sender": "agent-1", - "recipient": "agent-2", - "content": "Hello", - "timestamp": 1234567890 - } - - -@pytest.fixture -def mock_communication_logger(): - """Mock communication logger.""" - logger = MagicMock() - logger.log_message = MagicMock(return_value=True) - logger.get_history = MagicMock(return_value=[]) - logger.clear_history = MagicMock(return_value=True) - return logger - - -# ============================================================================ -# Coordination Fixtures -# ============================================================================ - -@pytest.fixture -def mock_coordination_context(): - """Mock coordination context.""" - return { - "agents": ["agent-1", "agent-2", "agent-3"], - "task": "collaborative_task", - "status": "active", - "created_at": 1234567890 - } - - -@pytest.fixture -def mock_coordinator(): - """Mock coordinator.""" - coordinator = MagicMock() - coordinator.coordinate = MagicMock(return_value={"status": "coordinated"}) - coordinator.get_status = MagicMock(return_value="active") - coordinator.shutdown = MagicMock(return_value=True) - return coordinator - - -# ============================================================================ -# Performance Fixtures -# ============================================================================ - -@pytest.fixture -def mock_performance_metrics(): - """Mock performance metrics.""" - return { - "response_time": 0.5, - "throughput": 100, - "error_rate": 0.01, - "cpu_usage": 45.2, - "memory_usage": 512 - } - - -@pytest.fixture -def mock_performance_service(): - """Mock performance service.""" - service = MagicMock() - service.get_metrics = MagicMock(return_value={}) - service.record_metric = MagicMock(return_value=True) - service.get_agent_performance = MagicMock(return_value={}) - return service - - -# ============================================================================ -# Scenario Fixtures -# ============================================================================ - -@pytest.fixture -def multi_agent_scenario(): - """Multi-agent collaboration scenario.""" - return { - "agents": ["agent-1", "agent-2", "agent-3"], - "task": "collaborative_analysis", - "expected_result": "comprehensive_analysis", - "timeout": 30 - } - - -@pytest.fixture -def error_scenario(): - """Error handling scenario.""" - return { - "error_type": "agent_unavailable", - "retry_count": 3, - "fallback_strategy": "use_alternative_agent", - "should_recover": True - } - - -@pytest.fixture -def performance_scenario(): - """Performance testing scenario.""" - return { - "num_agents": 10, - "num_tasks": 100, - "concurrent_tasks": 5, - "expected_throughput": 50 - } diff --git a/tests/test_agents.py b/tests/test_agents.py deleted file mode 100644 index 395007e..0000000 --- a/tests/test_agents.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test suite for agent functionality.""" - -import pytest - - -class TestAgentCreation: - """Test agent creation.""" - - @pytest.mark.agent - def test_agent_creation(self, mock_agent): - """Test agent creation.""" - assert mock_agent.agent_id == "agent-1" - assert mock_agent.name == "TestAgent" - - @pytest.mark.agent - def test_agent_personality(self, mock_agent): - """Test agent personality.""" - assert mock_agent.personality == "helpful" - - -class TestAgentExecution: - """Test agent execution.""" - - @pytest.mark.agent - def test_agent_execute(self, mock_agent): - """Test agent execution.""" - result = mock_agent.execute() - assert result["result"] == "success" - - @pytest.mark.agent - def test_agent_state(self, mock_agent): - """Test agent state.""" - state = mock_agent.get_state() - assert state["status"] == "active" - - -class TestMultipleAgents: - """Test multiple agents.""" - - @pytest.mark.agent - def test_agent_list(self, mock_agents_list): - """Test agent list.""" - assert len(mock_agents_list) == 3 - assert mock_agents_list[0].agent_id == "agent-0" - - -class TestAgentConfiguration: - """Test agent configuration.""" - - @pytest.mark.agent - def test_agent_config(self, mock_agent_config): - """Test agent configuration.""" - assert mock_agent_config["agent_id"] == "agent-1" - assert mock_agent_config["personality"] == "helpful" - assert "reasoning" in mock_agent_config["capabilities"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d62a03a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import io +import json + +import pytest + +from helix_llm_agent_engine import ProviderError, __version__, cli + + +def test_cli_runs_offline_without_credentials(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["run", "hello"]) == 0 + assert capsys.readouterr().out == "Echo: hello\n" + + +def test_cli_json_output_is_machine_readable(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["run", "hello", "--json"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["content"] == "Echo: hello" + assert result["provider"] == "echo" + assert result["metrics"]["requests"] == 1 + + +def test_cli_text_output_neutralizes_terminal_controls( + capsys: pytest.CaptureFixture[str], +) -> None: + assert cli.main(["run", "unsafe\x1b[31mtext"]) == 0 + assert capsys.readouterr().out == "Echo: unsafeļæ½[31mtext\n" + + +def test_cli_reads_bounded_stdin( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + stdin = io.StringIO("from stdin") + monkeypatch.setattr(cli.sys, "stdin", stdin) + assert cli.main(["run"]) == 0 + assert capsys.readouterr().out == "Echo: from stdin\n" + + +def test_cli_maps_invalid_stdin_limit_without_traceback( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(cli.sys, "stdin", io.StringIO("input")) + assert cli.main(["run", "--max-input-chars", "-1"]) == 2 + assert "max_input_chars" in capsys.readouterr().err + + +def test_cli_requires_openai_model(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["run", "hello", "--provider", "openai"]) == 2 + assert "--model is required" in capsys.readouterr().err + + +def test_cli_requires_key_for_openai_host( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert cli.main(["run", "hello", "--provider", "openai", "--model", "test"]) == 2 + assert "OPENAI_API_KEY is required" in capsys.readouterr().err + + +def test_cli_maps_provider_error_to_exit_three( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + async def fail(_: object) -> dict[str, object]: + raise ProviderError("unavailable") + + monkeypatch.setattr(cli, "_run", fail) + assert cli.main(["run", "hello"]) == 3 + assert capsys.readouterr().err == "provider error: unavailable\n" + + +def test_cli_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as captured: + cli.main(["--version"]) + assert captured.value.code == 0 + assert capsys.readouterr().out == f"helix-agent {__version__}\n" diff --git a/tests/test_communication.py b/tests/test_communication.py deleted file mode 100644 index 326aacf..0000000 --- a/tests/test_communication.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Test suite for communication functionality.""" - -import pytest - - -class TestMessaging: - """Test messaging.""" - - @pytest.mark.communication - def test_message_creation(self, mock_message): - """Test message creation.""" - assert mock_message["sender"] == "agent-1" - assert mock_message["recipient"] == "agent-2" - assert mock_message["content"] == "Hello" - - @pytest.mark.communication - def test_message_id(self, mock_message): - """Test message ID.""" - assert mock_message["id"] == "msg-1" - - -class TestCommunicationLogger: - """Test communication logger.""" - - @pytest.mark.communication - def test_logger_creation(self, mock_communication_logger): - """Test logger creation.""" - assert mock_communication_logger is not None - - @pytest.mark.communication - def test_log_message(self, mock_communication_logger): - """Test logging message.""" - result = mock_communication_logger.log_message() - assert result is True - - @pytest.mark.communication - def test_get_history(self, mock_communication_logger): - """Test getting history.""" - history = mock_communication_logger.get_history() - assert isinstance(history, list) - - @pytest.mark.communication - def test_clear_history(self, mock_communication_logger): - """Test clearing history.""" - result = mock_communication_logger.clear_history() - assert result is True diff --git a/tests/test_coordination.py b/tests/test_coordination.py deleted file mode 100644 index d07786e..0000000 --- a/tests/test_coordination.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test suite for coordination functionality.""" - -import pytest - - -class TestCoordinationContext: - """Test coordination context.""" - - @pytest.mark.coordination - def test_context_creation(self, mock_coordination_context): - """Test context creation.""" - assert mock_coordination_context["task"] == "collaborative_task" - assert len(mock_coordination_context["agents"]) == 3 - - @pytest.mark.coordination - def test_context_status(self, mock_coordination_context): - """Test context status.""" - assert mock_coordination_context["status"] == "active" - - -class TestCoordinator: - """Test coordinator.""" - - @pytest.mark.coordination - def test_coordinator_creation(self, mock_coordinator): - """Test coordinator creation.""" - assert mock_coordinator is not None - - @pytest.mark.coordination - def test_coordinate(self, mock_coordinator): - """Test coordination.""" - result = mock_coordinator.coordinate() - assert result["status"] == "coordinated" - - @pytest.mark.coordination - def test_get_status(self, mock_coordinator): - """Test getting status.""" - status = mock_coordinator.get_status() - assert status == "active" - - @pytest.mark.coordination - def test_shutdown(self, mock_coordinator): - """Test shutdown.""" - result = mock_coordinator.shutdown() - assert result is True - - -class TestMultiAgentCoordination: - """Test multi-agent coordination.""" - - @pytest.mark.integration - def test_multi_agent_scenario(self, multi_agent_scenario): - """Test multi-agent scenario.""" - assert len(multi_agent_scenario["agents"]) == 3 - assert multi_agent_scenario["task"] == "collaborative_analysis" diff --git a/tests/test_engine.py b/tests/test_engine.py index 272d6b4..7add2a9 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,81 +1,287 @@ -"""Test suite for LLM Agent Engine.""" +from __future__ import annotations + +import asyncio import pytest +from helix_llm_agent_engine import ( + AgentOrchestrator, + BaseLLMProvider, + BudgetExceededError, + ChatMessage, + ConfigurationError, + InputValidationError, + LLMAgentEngine, + ProviderError, + ProviderResponse, +) + + +class RecordingProvider(BaseLLMProvider): + def __init__(self, response: str = "done") -> None: + self.response = response + self.calls: list[tuple[list[ChatMessage], str, int, float]] = [] + self.closed = 0 + + async def invoke( + self, + messages: list[ChatMessage] | tuple[ChatMessage, ...], + model: str, + *, + max_tokens: int, + temperature: float, + ) -> ProviderResponse: + self.calls.append((list(messages), model, max_tokens, temperature)) + return ProviderResponse( + content=self.response, + model=model, + input_tokens=4, + output_tokens=2, + ) + + async def close(self) -> None: + self.closed += 1 + + +class FailingProvider(BaseLLMProvider): + def __init__(self, *, expected: bool) -> None: + self.expected = expected + + async def invoke( + self, + messages: list[ChatMessage] | tuple[ChatMessage, ...], + model: str, + *, + max_tokens: int, + temperature: float, + ) -> ProviderResponse: + del messages, model, max_tokens, temperature + if self.expected: + raise ProviderError("bounded failure") + raise RuntimeError("secret-bearing custom failure") + + +class FailingCloseProvider(RecordingProvider): + async def close(self) -> None: + raise RuntimeError("secret-bearing cleanup failure") + + +@pytest.mark.asyncio +async def test_agent_invocation_tracks_real_history_and_metrics() -> None: + provider = RecordingProvider("first") + engine = LLMAgentEngine(max_output_tokens=321, temperature=0.25) + engine.register_provider("recording", provider) + agent = engine.create_agent( + name="researcher", + model="test-model", + system_prompt="Be exact.", + provider="recording", + ) + + assert await agent.invoke("Hello", session_id="thread-1") == "first" + provider.response = "second" + assert await agent.invoke("Again", session_id="thread-1") == "second" + + assert [message.as_dict() for message in provider.calls[1][0]] == [ + {"role": "system", "content": "Be exact."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "first"}, + {"role": "user", "content": "Again"}, + ] + assert provider.calls[0][1:] == ("test-model", 321, 0.25) + assert agent.get_metrics() == { + "requests": 2, + "successes": 2, + "failures": 0, + "input_tokens": 8, + "output_tokens": 4, + "last_latency_ms": pytest.approx(agent.get_metrics()["last_latency_ms"]), + } + assert len(agent.history("thread-1")) == 4 + + +@pytest.mark.asyncio +async def test_history_and_session_limits_evict_oldest_state() -> None: + engine = LLMAgentEngine(max_history_messages=2, max_sessions=1) + agent = engine.create_agent(name="assistant", model="echo") + + await agent.invoke("one", session_id="a") + await agent.invoke("two", session_id="a") + assert [message.content for message in agent.history("a")] == ["two", "Echo: two"] + + await agent.invoke("new", session_id="b") + assert agent.history("a") == () + assert len(agent.history("b")) == 2 + agent.clear_history() + assert agent.history("b") == () + + +@pytest.mark.asyncio +async def test_request_budget_blocks_before_provider_call_and_can_be_reset() -> None: + provider = RecordingProvider() + engine = LLMAgentEngine(max_requests_per_session=1) + engine.register_provider("recording", provider) + agent = engine.create_agent(name="assistant", model="test", provider="recording") + + await agent.invoke("first") + with pytest.raises(BudgetExceededError, match="request limit"): + await agent.invoke("second") + assert len(provider.calls) == 1 + + agent.clear_history("default") + await agent.invoke("after reset") + assert len(provider.calls) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("expected", [True, False]) +async def test_provider_failures_are_counted_and_unknown_errors_are_sanitized( + expected: bool, +) -> None: + engine = LLMAgentEngine() + engine.register_provider("failing", FailingProvider(expected=expected)) + agent = engine.create_agent(name="assistant", model="test", provider="failing") + + with pytest.raises(ProviderError) as captured: + await agent.invoke("hello") + if not expected: + assert "secret-bearing" not in str(captured.value) + assert agent.get_metrics()["requests"] == 1 + assert agent.get_metrics()["failures"] == 1 + assert agent.history() == () + + +@pytest.mark.asyncio +async def test_engine_closes_shared_provider_once() -> None: + provider = RecordingProvider() + engine = LLMAgentEngine() + engine.register_provider("one", provider) + engine.register_provider("two", provider) + + await engine.close() + await engine.close() + assert provider.closed == 1 + + +@pytest.mark.asyncio +async def test_engine_closes_replaced_provider_and_rejects_use_after_close() -> None: + first = RecordingProvider() + second = RecordingProvider() + engine = LLMAgentEngine() + engine.register_provider("recording", first) + engine.register_provider("recording", second) + + await engine.close() + assert (first.closed, second.closed) == (1, 1) + with pytest.raises(ConfigurationError, match="closed"): + engine.create_agent(name="assistant", model="test") + with pytest.raises(ConfigurationError, match="closed"): + engine.register_provider("third", RecordingProvider()) + + +@pytest.mark.asyncio +async def test_engine_closes_all_providers_and_sanitizes_cleanup_failure() -> None: + failing = FailingCloseProvider() + healthy = RecordingProvider() + engine = LLMAgentEngine() + engine.register_provider("failing", failing) + engine.register_provider("healthy", healthy) + + with pytest.raises(ProviderError, match="cleanup failed") as captured: + await engine.close() + assert "secret-bearing" not in str(captured.value) + assert healthy.closed == 1 + + +@pytest.mark.asyncio +async def test_engine_context_manager_closes_provider() -> None: + provider = RecordingProvider() + async with LLMAgentEngine() as engine: + engine.register_provider("recording", provider) + assert provider.closed == 1 + + +@pytest.mark.asyncio +async def test_orchestrator_is_labeled_sequential_and_bounded() -> None: + engine = LLMAgentEngine() + first = engine.create_agent(name="first", model="echo") + second = engine.create_agent(name="second", model="echo") + orchestrator = AgentOrchestrator(max_agents=2) + orchestrator.add_agent(first) + orchestrator.add_agent(second) + + result = await orchestrator.collective_loop(prompt="Plan", max_iterations=1) + assert result.startswith("first: Echo: Plan\nsecond: Echo: Original task: Plan") + assert tuple(orchestrator.agents) == (first, second) + with pytest.raises(BudgetExceededError): + orchestrator.add_agent(engine.create_agent(name="third", model="echo")) + with pytest.raises(ConfigurationError, match="between 1 and 5"): + await orchestrator.collective_loop(prompt="Plan", max_iterations=6) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"max_history_messages": 1}, "max_history_messages"), + ({"max_sessions": 0}, "max_sessions"), + ({"max_input_chars": 0}, "max_input_chars"), + ({"max_requests_per_session": 0}, "max_requests_per_session"), + ({"max_output_tokens": 0}, "max_output_tokens"), + ({"max_response_chars": 0}, "max_response_chars"), + ({"temperature": 3}, "temperature"), + ({"default_provider": "bad name"}, "provider name"), + ], +) +def test_engine_rejects_invalid_configuration(kwargs: dict[str, object], message: str) -> None: + with pytest.raises(ConfigurationError, match=message): + LLMAgentEngine(**kwargs) # type: ignore[arg-type] + + +def test_agent_and_provider_registration_validate_public_inputs() -> None: + engine = LLMAgentEngine(max_input_chars=5) + with pytest.raises(ConfigurationError, match="inherit"): + engine.register_provider("bad", object()) # type: ignore[arg-type] + with pytest.raises(InputValidationError, match="agent name"): + engine.create_agent(name="bad name", model="echo") + with pytest.raises(InputValidationError, match="model"): + engine.create_agent(name="good", model="") + with pytest.raises(InputValidationError, match="system_prompt"): + engine.create_agent(name="good", model="echo", system_prompt="123456") + with pytest.raises(ConfigurationError, match="not registered"): + engine.create_agent(name="good", model="test", provider="missing") + + +@pytest.mark.asyncio +async def test_agent_validates_prompt_and_session_id() -> None: + engine = LLMAgentEngine(max_input_chars=5) + agent = engine.create_agent(name="good", model="echo") + with pytest.raises(InputValidationError, match="non-empty"): + await agent.invoke(" ") + with pytest.raises(InputValidationError, match="character limit"): + await agent.invoke("123456") + with pytest.raises(InputValidationError, match="session_id"): + await agent.invoke("ok", session_id="bad\n") + + +@pytest.mark.asyncio +async def test_agent_serializes_concurrent_turns() -> None: + provider = RecordingProvider() + engine = LLMAgentEngine() + engine.register_provider("recording", provider) + agent = engine.create_agent(name="assistant", model="test", provider="recording") + + await asyncio.gather(agent.invoke("one"), agent.invoke("two")) + assert len(provider.calls[0][0]) == 1 + assert len(provider.calls[1][0]) == 3 + + +@pytest.mark.asyncio +async def test_agent_rejects_custom_provider_response_over_character_limit() -> None: + provider = RecordingProvider("too long") + engine = LLMAgentEngine(max_response_chars=3) + engine.register_provider("recording", provider) + agent = engine.create_agent(name="assistant", model="test", provider="recording") -class TestEngineInitialization: - """Test engine initialization.""" - - @pytest.mark.engine - def test_engine_creation(self, mock_llm_engine): - """Test engine creation.""" - assert mock_llm_engine is not None - assert callable(mock_llm_engine.generate) - - @pytest.mark.engine - def test_agent_engine_creation(self, mock_agent_engine): - """Test agent engine creation.""" - assert mock_agent_engine is not None - assert callable(mock_agent_engine.register_agent) - - -class TestEngineGeneration: - """Test engine generation functionality.""" - - @pytest.mark.engine - def test_generate(self, mock_llm_engine): - """Test generation.""" - result = mock_llm_engine.generate() - assert result == "Generated response" - - @pytest.mark.engine - def test_stream_generate(self, mock_llm_engine): - """Test streaming generation.""" - result = mock_llm_engine.stream_generate() - assert len(result) == 2 - assert "chunk1" in result - - -class TestAgentExecution: - """Test agent execution.""" - - @pytest.mark.engine - def test_register_agent(self, mock_agent_engine, mock_agent): - """Test agent registration.""" - result = mock_agent_engine.register_agent(mock_agent) - assert result is True - - @pytest.mark.engine - def test_execute_task(self, mock_agent_engine): - """Test task execution.""" - result = mock_agent_engine.execute_task("task-1", "agent-1") - assert result["result"] == "success" - - @pytest.mark.engine - def test_get_agent(self, mock_agent_engine): - """Test getting agent.""" - agent = mock_agent_engine.get_agent("agent-1") - assert agent is not None - - -class TestEngineIntegration: - """Test engine integration.""" - - @pytest.mark.integration - def test_multi_agent_execution(self, mock_agent_engine, mock_agents_list): - """Test multi-agent execution.""" - for agent in mock_agents_list: - mock_agent_engine.register_agent(agent) - - agents = mock_agent_engine.list_agents() - assert agents is not None - - -class TestEngineConfiguration: - """Test engine configuration.""" - - @pytest.mark.engine - def test_get_config(self, mock_llm_engine): - """Test getting configuration.""" - config = mock_llm_engine.get_config() - assert config["model"] == "gpt-4" + with pytest.raises(ProviderError, match="character limit"): + await agent.invoke("hello") + assert agent.history() == () diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..f6ffb63 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,36 @@ +import pytest + +from helix_llm_agent_engine import AgentMetrics, ChatMessage, InputValidationError, ProviderResponse + + +def test_chat_message_validation_and_serialization() -> None: + assert ChatMessage(role="user", content="hello").as_dict() == { + "role": "user", + "content": "hello", + } + with pytest.raises(InputValidationError, match="role"): + ChatMessage(role="tool", content="hello") # type: ignore[arg-type] + with pytest.raises(InputValidationError, match="content"): + ChatMessage(role="user", content="") + + +def test_provider_response_validates_usage() -> None: + assert ProviderResponse(content="ok", input_tokens=0, output_tokens=1).content == "ok" + with pytest.raises(InputValidationError, match="non-empty"): + ProviderResponse(content="") + with pytest.raises(InputValidationError, match="input_tokens"): + ProviderResponse(content="ok", input_tokens=-1) + with pytest.raises(InputValidationError, match="input_tokens"): + ProviderResponse(content="ok", input_tokens=True) + + +def test_metrics_snapshot_is_stable() -> None: + metrics = AgentMetrics(requests=1, successes=1, last_latency_ms=2.5) + assert metrics.as_dict() == { + "requests": 1, + "successes": 1, + "failures": 0, + "input_tokens": 0, + "output_tokens": 0, + "last_latency_ms": 2.5, + } diff --git a/tests/test_performance.py b/tests/test_performance.py deleted file mode 100644 index 734da29..0000000 --- a/tests/test_performance.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Test suite for performance functionality.""" - -import pytest - - -class TestPerformanceMetrics: - """Test performance metrics.""" - - @pytest.mark.performance - def test_metrics_format(self, mock_performance_metrics): - """Test metrics format.""" - assert "response_time" in mock_performance_metrics - assert "throughput" in mock_performance_metrics - assert "error_rate" in mock_performance_metrics - - @pytest.mark.performance - def test_metrics_values(self, mock_performance_metrics): - """Test metrics values.""" - assert mock_performance_metrics["response_time"] > 0 - assert mock_performance_metrics["throughput"] > 0 - assert 0 <= mock_performance_metrics["error_rate"] <= 1 - - -class TestPerformanceService: - """Test performance service.""" - - @pytest.mark.performance - def test_service_creation(self, mock_performance_service): - """Test service creation.""" - assert mock_performance_service is not None - - @pytest.mark.performance - def test_get_metrics(self, mock_performance_service): - """Test getting metrics.""" - metrics = mock_performance_service.get_metrics() - assert isinstance(metrics, dict) - - @pytest.mark.performance - def test_record_metric(self, mock_performance_service): - """Test recording metric.""" - result = mock_performance_service.record_metric() - assert result is True - - @pytest.mark.performance - def test_get_agent_performance(self, mock_performance_service): - """Test getting agent performance.""" - perf = mock_performance_service.get_agent_performance() - assert isinstance(perf, dict) - - -class TestPerformanceScenarios: - """Test performance scenarios.""" - - @pytest.mark.performance - def test_performance_scenario(self, performance_scenario): - """Test performance scenario.""" - assert performance_scenario["num_agents"] == 10 - assert performance_scenario["num_tasks"] == 100 - assert performance_scenario["concurrent_tasks"] == 5 diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..551c3d9 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import httpx +import pytest + +from helix_llm_agent_engine import ( + ChatMessage, + ConfigurationError, + EchoProvider, + OpenAICompatibleProvider, + ProviderError, +) + + +def _messages() -> list[ChatMessage]: + return [ChatMessage(role="user", content="hello")] + + +@pytest.mark.asyncio +async def test_echo_provider_is_explicitly_deterministic() -> None: + provider = EchoProvider(prefix="Offline") + response = await provider.invoke(_messages(), "echo", max_tokens=10, temperature=0) + assert response.content == "Offline: hello" + assert response.input_tokens is None + with pytest.raises(ProviderError, match="no user"): + await provider.invoke( + [ChatMessage(role="system", content="system")], + "echo", + max_tokens=10, + temperature=0, + ) + + +@pytest.mark.asyncio +async def test_openai_compatible_provider_normalizes_success() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response( + 200, + json={ + "model": "served-model", + "choices": [{"message": {"content": "answer"}}], + "usage": {"prompt_tokens": 7, "completion_tokens": 3}, + }, + request=request, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAICompatibleProvider( + base_url="https://models.example.test/v1", + client=client, + ) + response = await provider.invoke(_messages(), "asked-model", max_tokens=20, temperature=0.1) + + assert str(seen[0].url) == "https://models.example.test/v1/chat/completions" + assert response.content == "answer" + assert response.model == "served-model" + assert response.input_tokens == 7 + assert response.output_tokens == 3 + await provider.close() + assert not client.is_closed + await client.aclose() + + +@pytest.mark.asyncio +async def test_retryable_status_retries_with_a_hard_cap() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(429, headers={"retry-after": "0"}, request=request) + return httpx.Response( + 200, + json={"choices": [{"message": {"content": "ok"}}]}, + request=request, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAICompatibleProvider( + base_url="http://localhost:9000/v1", max_retries=1, client=client + ) + assert ( + await provider.invoke(_messages(), "local", max_tokens=10, temperature=0) + ).content == "ok" + assert attempts == 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_timeout_is_retried_then_sanitized() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ReadTimeout("token=do-not-copy", request=request) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAICompatibleProvider( + base_url="https://models.example.test/v1", + max_retries=1, + retry_backoff=0, + client=client, + ) + with pytest.raises(ProviderError, match="timed out") as captured: + await provider.invoke(_messages(), "test", max_tokens=10, temperature=0) + assert "do-not-copy" not in str(captured.value) + assert captured.value.retryable is True + assert attempts == 2 + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("response", "message"), + [ + (httpx.Response(401, headers={"x-request-id": "req-1"}), "HTTP 401"), + (httpx.Response(200, content=b"not-json"), "invalid JSON"), + (httpx.Response(200, json={"choices": []}), "chat completion schema"), + (httpx.Response(200, json={"choices": [{"message": {"content": ""}}]}), "no text"), + ], +) +async def test_provider_rejects_error_and_malformed_responses( + response: httpx.Response, + message: str, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + response.request = request + return response + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAICompatibleProvider( + base_url="https://models.example.test/v1/chat/completions", + max_retries=0, + client=client, + ) + with pytest.raises(ProviderError, match=message): + await provider.invoke(_messages(), "test", max_tokens=10, temperature=0) + await client.aclose() + + +@pytest.mark.asyncio +async def test_provider_sanitizes_untrusted_request_id() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 401, + headers={"x-request-id": "req-safe\r\nterminal-injection"}, + request=request, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAICompatibleProvider( + base_url="https://models.example.test/v1", max_retries=0, client=client + ) + with pytest.raises(ProviderError) as captured: + await provider.invoke(_messages(), "test", max_tokens=10, temperature=0) + assert "\r" not in str(captured.value) + assert "\n" not in str(captured.value) + await client.aclose() + + +def test_retry_after_rejects_non_finite_values() -> None: + assert OpenAICompatibleProvider._bounded_retry_after("nan") is None + assert OpenAICompatibleProvider._bounded_retry_after("inf") is None + + +@pytest.mark.asyncio +async def test_provider_rejects_oversized_response() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"x" * 1_025, request=request) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAICompatibleProvider( + base_url="https://models.example.test/v1", + max_response_bytes=1_024, + client=client, + ) + with pytest.raises(ProviderError, match="size limit"): + await provider.invoke(_messages(), "test", max_tokens=10, temperature=0) + await client.aclose() + + +@pytest.mark.parametrize( + "base_url", + [ + "", + "ftp://models.example.test/v1", + "https://user:pass@models.example.test/v1", + "https://models.example.test/v1?token=secret", + ], +) +def test_provider_rejects_unsafe_or_invalid_base_urls(base_url: str) -> None: + with pytest.raises(ConfigurationError): + OpenAICompatibleProvider(base_url=base_url) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"api_key": ""}, + {"timeout": 0}, + {"max_retries": 6}, + {"retry_backoff": -1}, + {"max_response_bytes": 100}, + ], +) +def test_provider_rejects_invalid_limits(kwargs: dict[str, object]) -> None: + with pytest.raises(ConfigurationError): + OpenAICompatibleProvider(**kwargs) # type: ignore[arg-type] From dddf4f4d8b2e76f54e0420885f4cd97826569902 Mon Sep 17 00:00:00 2001 From: Deathcharge Date: Tue, 28 Jul 2026 18:08:18 -0400 Subject: [PATCH 2/4] docs:define-product-and-release-boundary --- .env.example | 3 + .github/workflows/ci.yml | 90 +++++++++++++ CHANGELOG.md | 22 +++ CODE_OF_CONDUCT.md | 6 +- CONTRIBUTING.md | 129 ++++++------------ PYPI_PUBLISHING_GUIDE.md | 192 -------------------------- README.md | 274 +++++++++++++++++++++++++------------ SECURITY.md | 40 ++++++ docs/GETTING_STARTED.md | 132 +++++++----------- docs/LEGACY_CODE.md | 24 ++++ docs/PRODUCTIZATION.md | 284 +++++++++++++++++++++++++++++++++++++++ docs/RELEASING.md | 61 +++++++++ 12 files changed, 800 insertions(+), 457 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md delete mode 100644 PYPI_PUBLISHING_GUIDE.md create mode 100644 SECURITY.md create mode 100644 docs/LEGACY_CODE.md create mode 100644 docs/PRODUCTIZATION.md create mode 100644 docs/RELEASING.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34fd377 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Optional. Echo mode needs no environment variables. +OPENAI_API_KEY=replace-with-a-provider-key +HELIX_LLM_BASE_URL=https://api.openai.com/v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ccdefe7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install product and development tools + run: python -m pip install -e ".[dev]" + - name: Lint + run: python -m ruff check src tests examples + - name: Format check + run: python -m ruff format --check src tests examples + - name: Type check + run: python -m mypy src/helix_llm_agent_engine + - name: Static security check + run: python -m bandit -r src/helix_llm_agent_engine -q + - name: Test + run: >- + python -m pytest + --cov=helix_llm_agent_engine + --cov-report=term-missing + --cov-report=xml + + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + cache: pip + - name: Install audit tool + run: python -m pip install "pip-audit>=2.10,<3" + - name: Audit runtime requirements + run: python -m pip_audit -r requirements.txt + + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + cache: pip + - name: Install build tools + run: python -m pip install "build>=1.2,<2" "twine>=6,<7" + - name: Build distributions + run: python -m build + - name: Check metadata + run: python -m twine check dist/* + - name: Reject legacy package contents + shell: python + run: | + import pathlib + import sys + import tarfile + import zipfile + + wheel = next(pathlib.Path("dist").glob("*.whl")) + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + forbidden = [name for name in names if name.startswith(("agents/", "services/"))] + sdist = next(pathlib.Path("dist").glob("*.tar.gz")) + with tarfile.open(sdist) as archive: + sdist_names = archive.getnames() + forbidden.extend( + name + for name in sdist_names + if len(name.split("/")) > 1 and name.split("/")[1] in {"agents", "services"} + ) + if forbidden: + print("Legacy files entered wheel:", *forbidden, sep="\n", file=sys.stderr) + raise SystemExit(1) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e79d20e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes will be documented here. The project uses semantic versioning +once releases begin. + +## 0.1.0 - Unreleased + +### Added + +- Standalone `src/` package with a deliberate public API. +- Bounded in-memory agents and sequential orchestration. +- Deterministic offline echo provider. +- Bounded OpenAI-compatible HTTP provider. +- Non-interactive CLI with JSON output and meaningful exit codes. +- Real unit, integration, packaging, lint, type, and security checks. +- Productization, security, legacy-boundary, setup, and release documentation. + +### Removed + +- Orphaned root LLM modules that required private `helix-unified` imports. +- Mock-only tests and examples for APIs that did not exist. +- Unverifiable model-pricing, free-credit, and production-readiness claims. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5afd3b5..20adc63 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -29,7 +29,11 @@ Unacceptable behaviors include: ## Reporting -If you experience or witness unacceptable behavior, please report it by contacting the project maintainers at [conduct@helix-hub-shared.dev](mailto:conduct@helix-hub-shared.dev). +If you experience or witness unacceptable behavior, use the repository's private +maintainer contact when one is published. Until then, open a minimal issue asking +for a private reporting channel; do not include sensitive details in that issue. +The owner must establish and verify a dedicated conduct contact before a public +release. All reports will be reviewed and investigated promptly. The project team is committed to maintaining confidentiality with regard to the reporter of an incident. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1e9884..4fc802d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,108 +1,55 @@ -# Contributing to Helix Hub Shared +# Contributing -We welcome contributions to the Helix Hub Shared infrastructure! This guide explains how to get started. +Contributions should keep the supported product small, independently installable, +and free of runtime dependencies on private Helix repositories. -## Getting Started - -1. Fork the repository -2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/helix-hub-shared.git` -3. Create a branch: `git checkout -b feature/your-feature` -4. Make changes and commit: `git commit -am 'Add feature'` -5. Push to branch: `git push origin feature/your-feature` -6. Submit a pull request - -## Development Setup +## Setup ```bash git clone https://github.com/Deathcharge/helix-hub-shared.git cd helix-hub-shared -pip install -e ".[dev]" -pip install -r requirements-test.txt +python -m venv .venv +# macOS/Linux: source .venv/bin/activate +# Windows PowerShell: .venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" ``` -## Running Tests +## Required checks ```bash -pytest tests/ -v -pytest tests/ --cov -pytest tests/ -m engine # Run specific marker -pytest tests/ -m integration # Run integration tests +python -m ruff check src tests examples +python -m ruff format --check src tests examples +python -m mypy src/helix_llm_agent_engine +python -m bandit -r src/helix_llm_agent_engine -q +python -m pip_audit -r requirements.txt +python -m pytest --cov=helix_llm_agent_engine --cov-report=term-missing +python -m build +python -m twine check dist/* ``` -## Coding Standards - -- Follow PEP 8 -- Use type hints -- Write comprehensive docstrings -- Keep lines under 100 characters -- Use meaningful variable names -- Add tests for new features (minimum 80% coverage) - -## Documentation - -Update documentation for new features: - -- Update README.md for major changes -- Update GETTING_STARTED.md for new patterns -- Add examples for new features -- Update API documentation -- Add inline code comments for complex logic - -## Pull Request Process - -1. Ensure all tests pass: `pytest tests/` -2. Add tests for new functionality -3. Update documentation as needed -4. Provide a clear description of changes -5. Reference any related issues -6. Wait for review and feedback - -## Code Review Guidelines - -- Be respectful and constructive -- Focus on the code, not the person -- Suggest improvements, don't demand -- Acknowledge good work -- Help reviewees improve - -## Testing Requirements - -- Minimum 80% code coverage -- All tests must pass -- Add tests for edge cases -- Test error conditions -- Include integration tests +Add tests that exercise implementation, not mocks of the object under test. Network +tests must use deterministic local transports or fixtures and must not require paid +credentials. -## Commit Message Format - -``` -: - - - -