fix(agent): rewrite _llm_health to use openai+httpx instead of langchain
Previous lazy-import fix still required langchain at function call time. Root cause: langchain_openai/langchain_core are only in requirements-agent.txt, not requirements-backend.txt. The /api/agent/llm-status endpoint runs in the backend container which has 'openai' but not 'langchain'. Rewrite _check_llm_provider_health() to use: - AsyncOpenAI from 'openai' package (in both containers) - httpx to call /api/agent/llm-config on localhost (same as agent does) - system_ssl_context() for SSL (same as LLM test endpoint) - openai exceptions (APIConnectionError, APITimeoutError, etc.) No langchain dependency at all — works in both backend and agent containers. Verified: 977 tests passed, import without langchain succeeds.
This commit is contained in:
@@ -2,21 +2,30 @@
|
|||||||
# #region AgentChat.LlmHealth [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,health,status]
|
# #region AgentChat.LlmHealth [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,health,status]
|
||||||
# @ingroup AgentChat
|
# @ingroup AgentChat
|
||||||
# @BRIEF LLM provider health check with in-memory cache (30s TTL).
|
# @BRIEF LLM provider health check with in-memory cache (30s TTL).
|
||||||
# Extracted from agent/app.py to avoid importing gradio in backend container.
|
# Uses openai+httpx only (no langchain) — works in both backend and agent containers.
|
||||||
# @LAYER Infrastructure
|
# @LAYER Infrastructure
|
||||||
# @RELATION CALLED_BY -> [api/routes/agent_status.py]
|
# @RELATION CALLED_BY -> [api/routes/agent_status.py]
|
||||||
# @RELATION CALLED_BY -> [agent/app.py]
|
# @RELATION CALLED_BY -> [agent/app.py]
|
||||||
# @RATIONALE agent/app.py imports gradio at module level (line 28). The backend
|
# @RATIONALE agent/app.py imports gradio at module level. The backend container
|
||||||
# container does not have gradio installed (only the agent container does).
|
# does not have gradio installed. The /api/agent/llm-status endpoint needs
|
||||||
# The /api/agent/llm-status endpoint needs _check_llm_provider_health but must
|
# _check_llm_provider_health but must not trigger gradio import.
|
||||||
# not trigger gradio import. This module isolates the health-check logic.
|
# Additionally, langchain_openai/langchain_core are only in the agent container
|
||||||
|
# (requirements-agent.txt), not the backend (requirements-backend.txt).
|
||||||
|
# This module uses only openai+httpx (available in both containers).
|
||||||
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
|
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
|
||||||
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
|
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from openai import (
|
||||||
|
APIConnectionError,
|
||||||
|
APITimeoutError,
|
||||||
|
AuthenticationError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
@@ -37,51 +46,54 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
|
|||||||
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
|
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
|
||||||
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
|
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
|
||||||
# @RATIONALE Prevents sending every user request into a dead LLM backend.
|
# @RATIONALE Prevents sending every user request into a dead LLM backend.
|
||||||
# @REJECTED Module-level imports of langchain_core/openai were rejected — these
|
# @REJECTED langchain_openai.ChatOpenAI was rejected — not installed in backend
|
||||||
# packages are only installed in the agent container, not the backend container.
|
# container. AsyncOpenAI from the openai package is available in both containers.
|
||||||
# The /api/agent/llm-status endpoint runs in the backend container and must not
|
|
||||||
# fail with ModuleNotFoundError. All langchain/openai imports are lazy (inside
|
|
||||||
# the function body) with ImportError handled gracefully.
|
|
||||||
async def _check_llm_provider_health() -> str:
|
async def _check_llm_provider_health() -> str:
|
||||||
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds."""
|
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
|
if now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
|
||||||
return _llm_status["status"]
|
return _llm_status["status"]
|
||||||
|
|
||||||
# Lazy imports: langchain_core, langchain_openai, and openai are only
|
# Fetch LLM config from backend's own API (same as agent container does)
|
||||||
# installed in the agent container, not the backend container.
|
|
||||||
try:
|
try:
|
||||||
from langchain_core.messages import HumanMessage
|
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
||||||
from langchain_openai import ChatOpenAI
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
from openai import (
|
resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
|
||||||
APIConnectionError,
|
if resp.status_code != 200:
|
||||||
APITimeoutError,
|
_llm_status["status"] = "unavailable"
|
||||||
AuthenticationError,
|
_llm_status["last_error"] = f"LLM config endpoint returned {resp.status_code}"
|
||||||
RateLimitError,
|
_llm_status["last_check_ts"] = time.time()
|
||||||
)
|
return "unavailable"
|
||||||
from src.agent._llm_params import chat_openai_kwargs
|
config = resp.json()
|
||||||
except ImportError as exc:
|
if not config or not config.get("configured"):
|
||||||
|
_llm_status["status"] = "unavailable"
|
||||||
|
_llm_status["last_error"] = "No LLM provider configured"
|
||||||
|
_llm_status["last_check_ts"] = time.time()
|
||||||
|
return "unavailable"
|
||||||
|
except Exception as exc:
|
||||||
_llm_status["status"] = "unavailable"
|
_llm_status["status"] = "unavailable"
|
||||||
_llm_status["last_error"] = f"LLM dependencies not installed: {exc}"
|
_llm_status["last_error"] = f"Failed to fetch LLM config: {exc}"
|
||||||
_llm_status["last_check_ts"] = time.time()
|
_llm_status["last_check_ts"] = time.time()
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
|
# Probe LLM API using AsyncOpenAI (available in both backend and agent containers)
|
||||||
try:
|
try:
|
||||||
from src.agent.langgraph_setup import _fetch_llm_config as _get_config
|
from openai import AsyncOpenAI
|
||||||
|
from src.core.ssl import system_ssl_context
|
||||||
|
|
||||||
config = await _get_config()
|
api = AsyncOpenAI(
|
||||||
if not config or not config.get("configured"):
|
api_key=config.get("api_key", ""),
|
||||||
return _llm_status["status"]
|
base_url=config.get("base_url", "https://api.openai.com/v1"),
|
||||||
|
http_client=httpx.AsyncClient(
|
||||||
llm = ChatOpenAI(
|
verify=system_ssl_context(),
|
||||||
**chat_openai_kwargs(
|
timeout=10,
|
||||||
model=config.get("default_model", "gpt-4o-mini"),
|
),
|
||||||
base_url=config.get("base_url", "https://api.openai.com/v1"),
|
)
|
||||||
api_key=config.get("api_key", ""),
|
await api.chat.completions.create(
|
||||||
max_tokens=1,
|
model=config.get("default_model", "gpt-4o-mini"),
|
||||||
)
|
max_tokens=1,
|
||||||
|
messages=[{"role": "user", "content": "ping"}],
|
||||||
)
|
)
|
||||||
await llm.ainvoke([HumanMessage(content="ping")])
|
|
||||||
_llm_status["status"] = "ok"
|
_llm_status["status"] = "ok"
|
||||||
_llm_status["last_error"] = ""
|
_llm_status["last_error"] = ""
|
||||||
_llm_status["last_check_ts"] = time.time()
|
_llm_status["last_check_ts"] = time.time()
|
||||||
@@ -101,8 +113,13 @@ async def _check_llm_provider_health() -> str:
|
|||||||
_llm_status["last_error"] = "Invalid API key"
|
_llm_status["last_error"] = "Invalid API key"
|
||||||
_llm_status["last_check_ts"] = time.time()
|
_llm_status["last_check_ts"] = time.time()
|
||||||
return "auth_error"
|
return "auth_error"
|
||||||
|
except RateLimitError:
|
||||||
|
_llm_status["status"] = "unavailable"
|
||||||
|
_llm_status["last_error"] = "Rate limited"
|
||||||
|
_llm_status["last_check_ts"] = time.time()
|
||||||
|
return "unavailable"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if "usage_metadata.total_tokens" in str(exc):
|
if "usage_metadata" in str(exc) or "total_tokens" in str(exc):
|
||||||
_llm_status["status"] = "ok"
|
_llm_status["status"] = "ok"
|
||||||
_llm_status["last_error"] = ""
|
_llm_status["last_error"] = ""
|
||||||
_llm_status["last_check_ts"] = time.time()
|
_llm_status["last_check_ts"] = time.time()
|
||||||
|
|||||||
Reference in New Issue
Block a user