from abc import ABC, abstractmethod from typing import Any import httpx from fastapi import HTTPException from app.core.config import Settings, get_settings from app.modules.ai_agent.constants import ( AUTHORIZATION_BEARER_TEMPLATE, CHAT_USER_CONTENT_TEMPLATE, COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, DIRECT_LLM_API_KEY_MISSING, NOOP_PROVIDER_ANSWER, OPENCLAW_HERMES_PIPELINE, OPENCLAW_TOOL_COMPLETED_ANSWER, UNEXPECTED_HERMES_RESPONSE, AIDefault, AIChatRole, AIContextKey, AIErrorKey, AIHttpHeader, AIHttpPath, AIHttpPayloadKey, AIMemoryMode, AIProviderName, AIRequestKey, AIResponseKey, ) from app.modules.ai_agent.skills import AISkillId, get_ai_skill class AIAdapter(ABC): """Interface for model provider adapters.""" provider_name: str @abstractmethod def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: raise NotImplementedError class NoopAdapter(AIAdapter): """Deterministic adapter used when no model provider is configured.""" provider_name = AIProviderName.NOOP def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: return { AIResponseKey.ANSWER: NOOP_PROVIDER_ANSWER, AIResponseKey.RAW: { AIRequestKey.PROMPT: prompt, AIRequestKey.CONTEXT: context or {}, }, } class OpenClawAdapter(AIAdapter): """Adapter for the OpenClaw Gateway control-plane API.""" provider_name = AIProviderName.OPENCLAW def __init__(self, settings: Settings): self.settings = settings def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: context = context or {} tool = context.get(AIContextKey.OPENCLAW_TOOL) if not tool: raise HTTPException( status_code=400, detail=( "OpenClaw Gateway is not configured as a chat provider. " "Provide context.openclaw_tool for /tools/invoke, or use " "MODEL_PROVIDER=hermes/openclaw_hermes for AI answers." ), ) result = self.invoke_tool( tool=str(tool), action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, session_key=str( context.get(AIContextKey.OPENCLAW_SESSION_KEY) or AIDefault.SESSION_KEY_MAIN ), ) return { AIResponseKey.ANSWER: OPENCLAW_TOOL_COMPLETED_ANSWER, AIResponseKey.RAW: result, } def health(self) -> dict[str, Any]: """Check OpenClaw Gateway health endpoints.""" headers = self._headers() base_url = self._base_url() with httpx.Client(timeout=5, trust_env=False) as client: healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers) readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers) return { AIResponseKey.OK: healthz.status_code < 400 and readyz.status_code < 400, AIResponseKey.BASE_URL: base_url, AIResponseKey.HEALTHZ: _response_payload(healthz), AIResponseKey.READYZ: _response_payload(readyz), } def invoke_tool( self, tool: str, action: str = AIDefault.ACTION_JSON, args: dict[str, Any] | None = None, session_key: str = AIDefault.SESSION_KEY_MAIN, ) -> dict[str, Any]: """Invoke one OpenClaw Gateway tool through `/tools/invoke`.""" self._ensure_tool_allowed(tool, action) payload = { AIHttpPayloadKey.TOOL: tool, AIHttpPayloadKey.ACTION: action, AIHttpPayloadKey.ARGS: args or {}, AIHttpPayloadKey.SESSION_KEY: session_key, } url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}" with httpx.Client(timeout=120, trust_env=False) as client: response = client.post(url, json=payload, headers=self._headers()) if response.status_code >= 400: raise HTTPException(status_code=502, detail={AIErrorKey.OPENCLAW: response.text}) return _response_payload(response) def _base_url(self) -> str: return (self.settings.openclaw_http_url or self.settings.openclaw_base_url).rstrip("/") def _headers(self) -> dict[str, str]: token = self.settings.openclaw_gateway_token or self.settings.openclaw_api_key if not token: return {} return { AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token) } def _ensure_tool_allowed(self, tool: str, action: str) -> None: if tool not in set(self.settings.openclaw_allowed_tools): raise HTTPException(status_code=403, detail="OpenClaw tool is not allowed") if action not in set(self.settings.openclaw_allowed_actions): raise HTTPException(status_code=403, detail="OpenClaw action is not allowed") class HermesAdapter(AIAdapter): """Adapter for the Hermes OpenAI-compatible agent endpoint.""" provider_name = AIProviderName.HERMES def __init__(self, settings: Settings): self.settings = settings def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: url = f"{self.settings.hermes_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" headers = {} if self.settings.hermes_api_key: headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( token=self.settings.hermes_api_key ) if self.settings.hermes_session_id: headers[AIHttpHeader.HERMES_SESSION_ID] = self.settings.hermes_session_id payload = { AIHttpPayloadKey.MODEL: self.settings.hermes_model, AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), AIHttpPayloadKey.STREAM: False, } with httpx.Client(timeout=300, trust_env=False) as client: response = client.post(url, json=payload, headers=headers) if response.status_code >= 400: raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text}) data = response.json() try: answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ AIHttpPayloadKey.CONTENT ] except (KeyError, IndexError, TypeError) as exc: raise HTTPException( status_code=502, detail={ AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE, AIResponseKey.RAW: data, }, ) from exc return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} def health(self) -> dict[str, Any]: """Check Hermes Agent health without triggering a chat completion.""" url = f"{_service_root(self.settings.hermes_base_url, AIHttpPath.V1)}{AIHttpPath.HEALTH}" headers = {} if self.settings.hermes_api_key: headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( token=self.settings.hermes_api_key ) with httpx.Client(timeout=5, trust_env=False) as client: response = client.get(url, headers=headers) return { AIResponseKey.OK: response.status_code < 400, AIResponseKey.BASE_URL: self.settings.hermes_base_url.rstrip("/"), AIResponseKey.HEALTH: _response_payload(response), } class OpenClawHermesAdapter(AIAdapter): """Compose OpenClaw Gateway context with Hermes Agent answers.""" provider_name = AIProviderName.OPENCLAW_HERMES def __init__(self, settings: Settings): self.openclaw = OpenClawAdapter(settings) self.hermes = HermesAdapter(settings) def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: base_context = context or {} recall = self._recall_memory(prompt, base_context) openclaw = self._openclaw_context(base_context) hermes_context = { **base_context, AIContextKey.AGENT_PIPELINE: self.provider_name, AIContextKey.HERMES_MEMORY: recall[AIResponseKey.ANSWER], AIContextKey.OPENCLAW: openclaw, } hermes_result = self.hermes.ask(prompt, hermes_context) remember = self._remember_interaction( prompt, base_context, hermes_result[AIResponseKey.ANSWER], ) return { AIResponseKey.ANSWER: hermes_result[AIResponseKey.ANSWER], AIResponseKey.RAW: { AIResponseKey.PIPELINE: OPENCLAW_HERMES_PIPELINE, AIResponseKey.HERMES_RECALL: recall, AIResponseKey.OPENCLAW: openclaw, AIResponseKey.HERMES_ANSWER: hermes_result.get(AIResponseKey.RAW, {}), AIResponseKey.HERMES_REMEMBER: remember, }, } def _openclaw_context(self, context: dict[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {AIResponseKey.TOOL_INVOKED: False} try: result[AIResponseKey.HEALTH] = self.openclaw.health() except Exception as exc: result[AIResponseKey.HEALTH] = { AIResponseKey.OK: False, AIResponseKey.ERROR: _error_detail(exc), } tool = context.get(AIContextKey.OPENCLAW_TOOL) if not tool: return result try: result[AIResponseKey.TOOL_INVOKED] = True result[AIResponseKey.TOOL] = self.openclaw.invoke_tool( tool=str(tool), action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, session_key=str( context.get(AIContextKey.OPENCLAW_SESSION_KEY) or AIDefault.SESSION_KEY_MAIN ), ) except HTTPException: raise except Exception as exc: raise HTTPException( status_code=502, detail={AIErrorKey.OPENCLAW: _error_detail(exc)}, ) from exc return result def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]: recall_skill = get_ai_skill(AISkillId.HERMES_MEMORY_RECALL) try: result = self.hermes.ask( recall_skill.render(), { AIContextKey.MODE: AIMemoryMode.RECALL, AIContextKey.USER_PROMPT: prompt, AIContextKey.REQUEST_CONTEXT: context, }, ) except Exception as exc: # Hermes memory should not block OpenClaw execution. return { AIResponseKey.ANSWER: "", AIResponseKey.RAW: {}, AIResponseKey.ERROR: _error_detail(exc), } return { AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), } def _remember_interaction( self, prompt: str, context: dict[str, Any], answer: str, ) -> dict[str, Any]: remember_skill = get_ai_skill(AISkillId.HERMES_MEMORY_WRITE) try: result = self.hermes.ask( remember_skill.render(), { AIContextKey.MODE: AIMemoryMode.WRITE, AIContextKey.USER_PROMPT: prompt, AIContextKey.REQUEST_CONTEXT: context, AIContextKey.ASSISTANT_ANSWER: answer, }, ) except Exception as exc: return { AIResponseKey.OK: False, AIResponseKey.RAW: {}, AIResponseKey.ERROR: _error_detail(exc), } return { AIResponseKey.OK: True, AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], } class DirectLLMAdapter(AIAdapter): """Adapter for OpenAI-compatible chat completions APIs.""" provider_name = AIProviderName.DIRECT_LLM def __init__(self, settings: Settings): self.settings = settings def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: if not self.settings.direct_llm_api_key: raise HTTPException(status_code=503, detail=DIRECT_LLM_API_KEY_MISSING) url = f"{self.settings.direct_llm_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" headers = { AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format( token=self.settings.direct_llm_api_key ) } payload = { AIHttpPayloadKey.MODEL: self.settings.direct_llm_model, AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), } with httpx.Client(timeout=60, trust_env=False) as client: response = client.post(url, json=payload, headers=headers) if response.status_code >= 400: raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text}) data = response.json() answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ AIHttpPayloadKey.CONTENT ] return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} def get_adapter() -> AIAdapter: """Return the configured AI provider adapter.""" settings = get_settings() provider = settings.model_provider.lower() if provider == AIProviderName.OPENCLAW: return OpenClawAdapter(settings) if provider == AIProviderName.HERMES: return HermesAdapter(settings) if provider in { AIProviderName.OPENCLAW_HERMES, AIProviderName.OPENCLAW_HERMES_DASH, AIProviderName.HYBRID, }: return OpenClawHermesAdapter(settings) if provider == AIProviderName.DIRECT_LLM: return DirectLLMAdapter(settings) return NoopAdapter() def _error_detail(exc: Exception) -> Any: if isinstance(exc, HTTPException): return exc.detail return {AIResponseKey.TYPE: type(exc).__name__, AIResponseKey.MESSAGE: str(exc)} def _response_payload(response: httpx.Response) -> dict[str, Any]: try: data = response.json() except ValueError: data = {AIResponseKey.TEXT: response.text} return {AIResponseKey.STATUS_CODE: response.status_code, AIResponseKey.DATA: data} def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]: return [ { AIHttpPayloadKey.ROLE: AIChatRole.SYSTEM, AIHttpPayloadKey.CONTENT: COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, }, { AIHttpPayloadKey.ROLE: AIChatRole.USER, AIHttpPayloadKey.CONTENT: CHAT_USER_CONTENT_TEMPLATE.format( context=context or {}, task=prompt, ), }, ] def _service_root(base_url: str, suffix: str) -> str: root = base_url.rstrip("/") normalized_suffix = suffix.rstrip("/") if root.endswith(normalized_suffix): root = root[: -len(normalized_suffix)] return root.rstrip("/")