refactor(ssl): centralize SSL trust management, remove LLM_SSL_VERIFY
Centralized SSL via one contract: CERTS_PATH=/opt/certs mounted into all containers.
Backend:
- NEW: backend/src/core/ssl.py — system_ssl_context(), httpx_verify(),
cert_dir_inventory()
- LLMClient._get_ssl_verify() → delegates to core.ssl
- _llm_async_http._get_verify() → delegates to core.ssl
- Removed LLM_SSL_VERIFY env reading from all runtime code
Docker:
- NEW: docker/certs.sh — shared cert installer (PEM/DER/cer to .crt conversion,
update-ca-certificates, hash symlinks, NSS import)
- NEW: docker/agent.entrypoint.sh — agent entrypoint with cert installation
- backend.entrypoint.sh → uses certs.sh instead of install_llm_ca_certs
- Dockerfile.agent → adds ca-certificates, openssl, entrypoint
Compose:
- Removed LLM_CA_CERT_URLS and LLM_SSL_VERIFY from all compose files
- Added CERTS_PATH volume mount to agent (dev + enterprise)
- Added certs volume mount to backend/agent in dev compose
Env examples:
- Removed LLM_SSL_VERIFY, LLM_CA_CERT_URLS from .env.example,
.env.enterprise-clean.example, .env.current.example, .env.master.example,
backend/.env.example
- Enhanced CERTS_PATH comments with accepted formats
Diagnostics:
- diag_container.py: removed LLM_* checks, added CERTS_PATH inventory,
uses core.ssl for context creation
Tests:
- Updated test_llm_analysis_service, test_llm_async_http,
test_client_headers to verify centralized ssl context (no env disable)
- 4/4 SSL tests pass
This commit is contained in:
@@ -53,9 +53,6 @@ ANTHROPIC_API_KEY=
|
||||
LLM_API_KEY=
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_SSL_VERIFY=true
|
||||
# LLM CA-сертификаты (скачка по URL на старте контейнера, через запятую)
|
||||
LLM_CA_CERT_URLS=
|
||||
|
||||
# ======================================================================
|
||||
# Логирование
|
||||
|
||||
@@ -47,8 +47,6 @@ ANTHROPIC_API_KEY=
|
||||
LLM_API_KEY=
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_SSL_VERIFY=true
|
||||
LLM_CA_CERT_URLS=
|
||||
|
||||
# ── Агент (настройки) ──────────────────────────────────────────────────
|
||||
GRADIO_SERVER_PORT=7860
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,8 +30,6 @@ TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_too
|
||||
# ── LLM / провайдеры ───────────────────────────────────────────────────
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
LLM_SSL_VERIFY=true
|
||||
LLM_CA_CERT_URLS=
|
||||
|
||||
# ── CORS / Безопасность ────────────────────────────────────────────────
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
102
backend/src/core/ssl.py
Normal file
102
backend/src/core/ssl.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# #region CoreSslTrust [C:4] [TYPE Module] [SEMANTICS ssl,tls,trust,certs,security]
|
||||
# @BRIEF Centralized SSL/TLS trust context for all Python HTTP clients.
|
||||
# @LAYER Infrastructure
|
||||
# @INVARIANT Never returns verify=False from environment configuration.
|
||||
# The application does not disable TLS verification via env var.
|
||||
# All trust anchors come from the system CA store populated
|
||||
# at container startup via CERTS_PATH (/opt/certs) mount.
|
||||
# @RATIONALE Previously LLM_SSL_VERIFY, LLM_CA_CERT_URLS, and per-client
|
||||
# _get_verify / _get_ssl_verify functions were scattered across
|
||||
# llm_analysis, translate, and agent code. This centralizes
|
||||
# them into one contract: system CA store is the single source
|
||||
# of trust, populated once per container at startup.
|
||||
# @REJECTED Per-client SSL verify toggle was rejected — caused operators
|
||||
# to manage LLM TLS separately from Superset/Git/backend TLS,
|
||||
# and allowed LLM_SSL_VERIFY=false to become a permanent
|
||||
# workaround instead of fixing cert installation.
|
||||
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_CAPATH = "/etc/ssl/certs"
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def system_ssl_context() -> ssl.SSLContext:
|
||||
"""Return an SSLContext that trusts the system CA store.
|
||||
|
||||
Prefers capath over cafile because OpenSSL 3.x ignores intermediate
|
||||
CA certificates in flat bundle files (ADR-0009 Finding 7).
|
||||
Falls back to default context if capath directory is not available
|
||||
(rare edge case, e.g. minimal containers without ca-certificates).
|
||||
"""
|
||||
if os.path.isdir(DEFAULT_CAPATH):
|
||||
return ssl.create_default_context(capath=DEFAULT_CAPATH)
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def httpx_verify() -> ssl.SSLContext:
|
||||
"""Return SSL verification context for httpx clients.
|
||||
|
||||
Alias for system_ssl_context() to provide a clear API for the
|
||||
main HTTP library used across the project (httpx).
|
||||
"""
|
||||
return system_ssl_context()
|
||||
|
||||
|
||||
def describe_context(ctx: ssl.SSLContext | bool | None) -> str:
|
||||
"""Return a diagnostic description of the SSL verification state.
|
||||
|
||||
Does NOT leak key material or secret values.
|
||||
"""
|
||||
if ctx is False:
|
||||
return "DISABLED (verify=False — should not happen in production)"
|
||||
if ctx is None:
|
||||
return "DEFAULT (certifi)"
|
||||
if isinstance(ctx, ssl.SSLContext):
|
||||
capath = getattr(ctx, "capath", None) or getattr(ctx, "_capath", None)
|
||||
cafile = getattr(ctx, "cafile", None) or getattr(ctx, "_cafile", None)
|
||||
parts = []
|
||||
if cafile:
|
||||
parts.append(f"cafile={cafile}")
|
||||
if capath:
|
||||
parts.append(f"capath={capath}")
|
||||
parts.append(f"verify_mode={'CERT_REQUIRED' if ctx.verify_mode == ssl.CERT_REQUIRED else ctx.verify_mode}")
|
||||
return "SSLContext(" + ", ".join(parts) + ")"
|
||||
return f"unknown({type(ctx).__name__})"
|
||||
|
||||
|
||||
def cert_dir_inventory(certs_path: str = "/opt/certs") -> dict:
|
||||
"""Inventory /opt/certs for operator diagnostics.
|
||||
|
||||
Returns counts of recognized trust candidates, server/private files,
|
||||
and unrecognized files.
|
||||
"""
|
||||
result: dict[str, list[str]] = {
|
||||
"trust_candidates": [],
|
||||
"server_files": [],
|
||||
"invalid": [],
|
||||
}
|
||||
base = Path(certs_path)
|
||||
if not base.is_dir():
|
||||
return result
|
||||
|
||||
for f in sorted(base.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
name = f.name.lower()
|
||||
if name in ("server.crt", "server.key", "server.pem") or any(name.endswith(ext) for ext in (".key", ".p12", ".pfx")):
|
||||
result["server_files"].append(str(f))
|
||||
continue
|
||||
if any(name.endswith(ext) for ext in (".crt", ".pem", ".cer", ".der")):
|
||||
result["trust_candidates"].append(str(f))
|
||||
else:
|
||||
result["invalid"].append(str(f))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# #endregion CoreSslTrust
|
||||
@@ -38,6 +38,7 @@ SCREENSHOT_SERVICE_TIMEOUT_MS = 120000
|
||||
LLM_HTTP_TIMEOUT_S = 120 # seconds (httpx client timeout)
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
|
||||
|
||||
|
||||
# #region ScreenshotService [C:4] [TYPE Class] [SEMANTICS llm,screenshot,playwright]
|
||||
# @defgroup LLMAnalysis Module group.
|
||||
# @BRIEF Handles capturing screenshots of Superset dashboards.
|
||||
@@ -48,6 +49,7 @@ class ScreenshotService:
|
||||
# @PRE env is a valid Environment object.
|
||||
def __init__(self, env: Environment):
|
||||
self.env = env
|
||||
|
||||
# #endregion ScreenshotService.__init__
|
||||
|
||||
# #region ScreenshotService._find_first_visible_locator [TYPE Function]
|
||||
@@ -65,6 +67,7 @@ class ScreenshotService:
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
# #endregion ScreenshotService._find_first_visible_locator
|
||||
|
||||
# #region ScreenshotService._iter_login_roots [TYPE Function]
|
||||
@@ -81,6 +84,7 @@ class ScreenshotService:
|
||||
except Exception:
|
||||
pass
|
||||
return roots
|
||||
|
||||
# #endregion ScreenshotService._iter_login_roots
|
||||
|
||||
# #region ScreenshotService._extract_hidden_login_fields [TYPE Function]
|
||||
@@ -102,6 +106,7 @@ class ScreenshotService:
|
||||
except Exception:
|
||||
continue
|
||||
return hidden_fields
|
||||
|
||||
# #endregion ScreenshotService._extract_hidden_login_fields
|
||||
|
||||
# #region ScreenshotService._extract_csrf_token [TYPE Function]
|
||||
@@ -111,6 +116,7 @@ class ScreenshotService:
|
||||
async def _extract_csrf_token(self, page) -> str:
|
||||
hidden_fields = await self._extract_hidden_login_fields(page)
|
||||
return str(hidden_fields.get("csrf_token") or "").strip()
|
||||
|
||||
# #endregion ScreenshotService._extract_csrf_token
|
||||
|
||||
# #region ScreenshotService._response_looks_like_login_page [TYPE Function]
|
||||
@@ -130,6 +136,7 @@ class ScreenshotService:
|
||||
'name="csrf_token"',
|
||||
]
|
||||
return sum(marker in normalized for marker in markers) >= 3
|
||||
|
||||
# #endregion ScreenshotService._response_looks_like_login_page
|
||||
|
||||
# #region ScreenshotService._redirect_looks_authenticated [TYPE Function]
|
||||
@@ -141,6 +148,7 @@ class ScreenshotService:
|
||||
if not normalized:
|
||||
return True
|
||||
return "/login" not in normalized
|
||||
|
||||
# #endregion ScreenshotService._redirect_looks_authenticated
|
||||
|
||||
# #region ScreenshotService._submit_login_via_form_post [TYPE Function]
|
||||
@@ -184,11 +192,7 @@ class ScreenshotService:
|
||||
response_url = str(getattr(response, "url", "") or "")
|
||||
response_status = int(getattr(response, "status", 0) or 0)
|
||||
response_headers = dict(getattr(response, "headers", {}) or {})
|
||||
redirect_location = str(
|
||||
response_headers.get("location")
|
||||
or response_headers.get("Location")
|
||||
or ""
|
||||
).strip()
|
||||
redirect_location = str(response_headers.get("location") or response_headers.get("Location") or "").strip()
|
||||
redirect_statuses = {301, 302, 303, 307, 308}
|
||||
if response_status in redirect_statuses:
|
||||
redirect_authenticated = self._redirect_looks_authenticated(redirect_location)
|
||||
@@ -206,6 +210,7 @@ class ScreenshotService:
|
||||
payload={"status": response_status, "url": response_url, "login_markup": looks_like_login_page, "snippet": text_snippet},
|
||||
)
|
||||
return not looks_like_login_page
|
||||
|
||||
# #endregion ScreenshotService._submit_login_via_form_post
|
||||
|
||||
# #region ScreenshotService._find_login_field_locator [TYPE Function]
|
||||
@@ -246,6 +251,7 @@ class ScreenshotService:
|
||||
return locator
|
||||
|
||||
return None
|
||||
|
||||
# #endregion ScreenshotService._find_login_field_locator
|
||||
|
||||
# #region ScreenshotService._find_submit_locator [TYPE Function]
|
||||
@@ -266,6 +272,7 @@ class ScreenshotService:
|
||||
if locator:
|
||||
return locator
|
||||
return None
|
||||
|
||||
# #endregion ScreenshotService._find_submit_locator
|
||||
|
||||
# #region ScreenshotService._goto_resilient [TYPE Function]
|
||||
@@ -289,6 +296,7 @@ class ScreenshotService:
|
||||
error=str(primary_error),
|
||||
)
|
||||
return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
|
||||
|
||||
# #endregion ScreenshotService._goto_resilient
|
||||
|
||||
# #region ScreenshotService._wait_for_charts_stabilized [TYPE Function] [C:2]
|
||||
@@ -302,7 +310,8 @@ class ScreenshotService:
|
||||
# Short initial delay for rendering pipeline to start
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
await page.wait_for_function("""() => {
|
||||
await page.wait_for_function(
|
||||
"""() => {
|
||||
const charts = document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas');
|
||||
if (charts.length === 0) return true;
|
||||
return Array.from(charts).some(c => {
|
||||
@@ -313,9 +322,12 @@ class ScreenshotService:
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}""", timeout=timeout_ms)
|
||||
}""",
|
||||
timeout=timeout_ms,
|
||||
)
|
||||
except Exception:
|
||||
logger.explore("Chart stabilization wait timed out, proceeding anyway", error="Timed out waiting for charts to render")
|
||||
|
||||
# #endregion ScreenshotService._wait_for_charts_stabilized
|
||||
|
||||
# #region ScreenshotService._wait_for_resize_rendered [TYPE Function] [C:2]
|
||||
@@ -327,15 +339,20 @@ class ScreenshotService:
|
||||
async def _wait_for_resize_rendered(self, page, chart_count_before: dict, timeout_ms: int = 10000):
|
||||
"""Wait for charts to re-render after viewport resize, with polling."""
|
||||
try:
|
||||
await page.wait_for_function("""(preCounts) => {
|
||||
await page.wait_for_function(
|
||||
"""(preCounts) => {
|
||||
const currentCharts = document.querySelectorAll('.chart-container, .slice_container').length;
|
||||
const currentCanvases = document.querySelectorAll('canvas').length;
|
||||
const currentSvgs = document.querySelectorAll('.chart-container svg, .slice_container svg').length;
|
||||
// At least one chart element must be present
|
||||
return currentCharts > 0 && (currentCanvases > 0 || currentSvgs > 0);
|
||||
}""", arg=chart_count_before, timeout=timeout_ms)
|
||||
}""",
|
||||
arg=chart_count_before,
|
||||
timeout=timeout_ms,
|
||||
)
|
||||
except Exception:
|
||||
logger.explore("Re-render wait timed out after viewport resize, proceeding anyway", error="Timed out waiting for charts to re-render after resize")
|
||||
|
||||
# #endregion ScreenshotService._wait_for_resize_rendered
|
||||
|
||||
# #region ScreenshotService._save_debug_screenshot [TYPE Function] [C:1]
|
||||
@@ -351,6 +368,7 @@ class ScreenshotService:
|
||||
return debug_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# #endregion ScreenshotService._save_debug_screenshot
|
||||
|
||||
# #region ScreenshotService._launch_and_login [TYPE Function] [C:4]
|
||||
@@ -424,21 +442,13 @@ class ScreenshotService:
|
||||
if username_locator is not None:
|
||||
await username_locator.fill(self.env.username)
|
||||
|
||||
password_locator = (
|
||||
await self._find_login_field_locator(page, "password")
|
||||
if username_locator is not None
|
||||
else None
|
||||
)
|
||||
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
|
||||
if username_locator is not None and not password_locator:
|
||||
raise RuntimeError("Could not find password input field on login page")
|
||||
if password_locator is not None:
|
||||
await password_locator.fill(self.env.password)
|
||||
|
||||
submit_locator = (
|
||||
await self._find_submit_locator(page)
|
||||
if username_locator is not None
|
||||
else None
|
||||
)
|
||||
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
|
||||
if username_locator is not None and not submit_locator:
|
||||
raise RuntimeError("Could not find submit button on login page")
|
||||
if submit_locator is not None:
|
||||
@@ -451,11 +461,7 @@ class ScreenshotService:
|
||||
pass
|
||||
|
||||
if not used_direct_form_login and "/login" in page.url:
|
||||
error_msg = (
|
||||
await page.locator(".alert-danger, .error-message").text_content()
|
||||
if await page.locator(".alert-danger, .error-message").count() > 0
|
||||
else "Unknown error"
|
||||
)
|
||||
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
|
||||
raise RuntimeError(f"Login failed: {error_msg}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Login failed: {e!s}")
|
||||
@@ -533,6 +539,7 @@ class ScreenshotService:
|
||||
await self._wait_for_charts_stabilized(page)
|
||||
logger.reflect("Login and navigation to dashboard successful", payload={"dashboard_id": dashboard_id})
|
||||
return browser, context, page
|
||||
|
||||
# #endregion ScreenshotService._launch_and_login
|
||||
|
||||
# #region ScreenshotService.capture_dashboard_chunks [TYPE Function] [C:4]
|
||||
@@ -716,6 +723,7 @@ class ScreenshotService:
|
||||
return results
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
# #endregion ScreenshotService.capture_dashboard_chunks
|
||||
|
||||
# #region ScreenshotService.capture_dashboard [TYPE Function] [C:4]
|
||||
@@ -767,6 +775,7 @@ class ScreenshotService:
|
||||
|
||||
# 4. Return JPEGs for LLM — caller cleans up after analysis
|
||||
return jpeg_paths, archive_results
|
||||
|
||||
# #endregion ScreenshotService.capture_dashboard
|
||||
|
||||
# #region ScreenshotService._convert_screenshots_for_llm [TYPE Function] [C:2]
|
||||
@@ -802,6 +811,7 @@ class ScreenshotService:
|
||||
logger.explore("Failed to convert screenshot for LLM", payload={"png_path": png_path}, error=str(e))
|
||||
|
||||
return jpeg_paths
|
||||
|
||||
# #endregion ScreenshotService._convert_screenshots_for_llm
|
||||
|
||||
# #region ScreenshotService._archive_screenshots_as_webp [TYPE Function] [C:2]
|
||||
@@ -836,6 +846,7 @@ class ScreenshotService:
|
||||
results.append({"original": png_path, "webp_path": None})
|
||||
|
||||
return results
|
||||
|
||||
# #endregion ScreenshotService._archive_screenshots_as_webp
|
||||
|
||||
# #region ScreenshotService._cleanup_temp_files [TYPE Function] [C:1]
|
||||
@@ -849,9 +860,13 @@ class ScreenshotService:
|
||||
os.remove(path)
|
||||
except Exception as e:
|
||||
logger.explore("Failed to delete temporary file", payload={"path": path}, error=str(e))
|
||||
|
||||
# #endregion ScreenshotService._cleanup_temp_files
|
||||
|
||||
|
||||
# #endregion ScreenshotService
|
||||
|
||||
|
||||
# #region LLMClient [C:4] [TYPE Class] [SEMANTICS llm,client,provider,openai]
|
||||
# @defgroup LLMAnalysis Module group.
|
||||
# @BRIEF Wrapper for LLM provider APIs.
|
||||
@@ -886,10 +901,7 @@ class LLMClient:
|
||||
# Some OpenAI-compatible gateways are strict about auth header naming.
|
||||
default_headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
if self.provider_type == LLMProviderType.OPENROUTER:
|
||||
default_headers["HTTP-Referer"] = (
|
||||
os.getenv("OPENROUTER_SITE_URL", "").strip()
|
||||
or os.getenv("APP_BASE_URL", "").strip()
|
||||
)
|
||||
default_headers["HTTP-Referer"] = os.getenv("OPENROUTER_SITE_URL", "").strip() or os.getenv("APP_BASE_URL", "").strip()
|
||||
default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or ""
|
||||
if self.provider_type == LLMProviderType.KILO:
|
||||
default_headers["Authentication"] = f"Bearer {self.api_key}"
|
||||
@@ -915,6 +927,7 @@ class LLMClient:
|
||||
default_headers=default_headers,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# #endregion LLMClient.__init__
|
||||
|
||||
# #region LLMClient._get_ssl_verify [TYPE Function]
|
||||
@@ -933,14 +946,10 @@ class LLMClient:
|
||||
# из единого bundle-файла. Только capath с хеш-симлинками даёт code 0.
|
||||
@staticmethod
|
||||
def _get_ssl_verify() -> ssl.SSLContext | bool:
|
||||
raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
|
||||
if raw in ("false", "0", "no", "off"):
|
||||
return False
|
||||
ca_dir = "/etc/ssl/certs"
|
||||
if os.path.isdir(ca_dir):
|
||||
return ssl.create_default_context(capath=ca_dir)
|
||||
# fallback: если директории нет (редко), используем дефолтный
|
||||
return ssl.create_default_context()
|
||||
from ...core.ssl import system_ssl_context
|
||||
|
||||
return system_ssl_context()
|
||||
|
||||
# #endregion LLMClient._get_ssl_verify
|
||||
|
||||
# #region LLMClient._format_connection_error [TYPE Function]
|
||||
@@ -954,6 +963,7 @@ class LLMClient:
|
||||
parts.append(f" └─ {type(cause).__name__}: {cause!s}")
|
||||
cause = cause.__cause__ or cause.__context__
|
||||
return "\n".join(parts)
|
||||
|
||||
# #endregion LLMClient._format_connection_error
|
||||
|
||||
# #region LLMClient._supports_json_response_format [TYPE Function]
|
||||
@@ -971,6 +981,7 @@ class LLMClient:
|
||||
if "stepfun/" in model or model.startswith("step-"):
|
||||
return False
|
||||
return True
|
||||
|
||||
# #endregion LLMClient._supports_json_response_format
|
||||
|
||||
# #region LLMClient.get_json_completion [TYPE Function]
|
||||
@@ -993,12 +1004,7 @@ class LLMClient:
|
||||
# For other exceptions, limit retries
|
||||
return True
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=2, min=5, max=60),
|
||||
retry=retry_if_exception(_should_retry),
|
||||
reraise=True
|
||||
)
|
||||
@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=5, max=60), retry=retry_if_exception(_should_retry), reraise=True)
|
||||
async def get_json_completion(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
with belief_scope("get_json_completion"):
|
||||
response = None
|
||||
@@ -1017,28 +1023,13 @@ class LLMClient:
|
||||
)
|
||||
|
||||
if use_json_mode:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.default_model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response = await self.client.chat.completions.create(model=self.default_model, messages=messages, response_format={"type": "json_object"})
|
||||
else:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.default_model,
|
||||
messages=messages
|
||||
)
|
||||
response = await self.client.chat.completions.create(model=self.default_model, messages=messages)
|
||||
except Exception as e:
|
||||
if use_json_mode and (
|
||||
"JSON mode is not enabled" in str(e)
|
||||
or "json_object is not supported" in str(e).lower()
|
||||
or "response_format" in str(e).lower()
|
||||
or "400" in str(e)
|
||||
):
|
||||
if use_json_mode and ("JSON mode is not enabled" in str(e) or "json_object is not supported" in str(e).lower() or "response_format" in str(e).lower() or "400" in str(e)):
|
||||
logger.explore("JSON mode failed or not supported, falling back to plain text", payload={"model": self.default_model}, error=str(e))
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.default_model,
|
||||
messages=messages
|
||||
)
|
||||
response = await self.client.chat.completions.create(model=self.default_model, messages=messages)
|
||||
else:
|
||||
raise e
|
||||
|
||||
@@ -1051,7 +1042,7 @@ class LLMClient:
|
||||
logger.explore("Rate limit hit on LLM call, retrying with backoff", payload={}, error=str(e))
|
||||
|
||||
# Extract retry_delay from error metadata if available
|
||||
retry_delay = 5.0 # Default fallback
|
||||
retry_delay = 5.0 # Default fallback
|
||||
try:
|
||||
# Based on logs, the raw response is in e.body or e.response.json()
|
||||
# The logs show 'metadata': {'raw': '...'} which suggests a proxy or specific client wrapper
|
||||
@@ -1065,13 +1056,13 @@ class LLMClient:
|
||||
retry_delay = float(match.group(1))
|
||||
else:
|
||||
# Try to parse from response if it's a standard OpenAI-like error with body
|
||||
if hasattr(e, 'body') and isinstance(e.body, dict):
|
||||
if hasattr(e, "body") and isinstance(e.body, dict):
|
||||
# Some providers put it in details
|
||||
details = e.body.get('error', {}).get('details', [])
|
||||
details = e.body.get("error", {}).get("details", [])
|
||||
for detail in details:
|
||||
if detail.get('@type') == 'type.googleapis.com/google.rpc.RetryInfo':
|
||||
delay_str = detail.get('retryDelay', '5s')
|
||||
retry_delay = float(delay_str.rstrip('s'))
|
||||
if detail.get("@type") == "type.googleapis.com/google.rpc.RetryInfo":
|
||||
delay_str = detail.get("retryDelay", "5s")
|
||||
retry_delay = float(delay_str.rstrip("s"))
|
||||
break
|
||||
except Exception as parse_e:
|
||||
logger.explore("Failed to parse retry delay from error response", payload={}, error=str(parse_e))
|
||||
@@ -1089,7 +1080,7 @@ class LLMClient:
|
||||
)
|
||||
raise
|
||||
|
||||
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||
if not response or not hasattr(response, "choices") or not response.choices:
|
||||
raise RuntimeError(f"Invalid LLM response: {response}")
|
||||
|
||||
content = response.choices[0].message.content
|
||||
@@ -1111,6 +1102,7 @@ class LLMClient:
|
||||
return json.loads(json_str)
|
||||
else:
|
||||
raise
|
||||
|
||||
# #endregion LLMClient.get_json_completion
|
||||
|
||||
# #region LLMClient.test_runtime_connection [TYPE Function]
|
||||
@@ -1127,6 +1119,7 @@ class LLMClient:
|
||||
}
|
||||
]
|
||||
return await self.get_json_completion(messages)
|
||||
|
||||
# #endregion LLMClient.test_runtime_connection
|
||||
|
||||
# #region LLMClient.fetch_models [TYPE Function]
|
||||
@@ -1152,6 +1145,7 @@ class LLMClient:
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
# #endregion LLMClient.fetch_models
|
||||
|
||||
# #region LLMClient.analyze_dashboard [TYPE Function]
|
||||
@@ -1173,6 +1167,7 @@ class LLMClient:
|
||||
logs=logs,
|
||||
prompt_template=prompt_template,
|
||||
)
|
||||
|
||||
# #endregion LLMClient.analyze_dashboard
|
||||
|
||||
# #region LLMClient._reduce_image_quality [TYPE Function] [C:2]
|
||||
@@ -1203,6 +1198,7 @@ class LLMClient:
|
||||
img.save(buffer, format="JPEG", quality=image_quality, optimize=True)
|
||||
raw = buffer.getvalue()
|
||||
return base64.b64encode(raw).decode("utf-8"), len(raw)
|
||||
|
||||
# #endregion LLMClient._reduce_image_quality
|
||||
|
||||
# #region LLMClient._estimate_payload_size [TYPE Function] [C:2]
|
||||
@@ -1230,6 +1226,7 @@ class LLMClient:
|
||||
"exceeds_limit": exceeds_limit,
|
||||
"pct_of_limit": round(total_tokens / model_context * 100, 1),
|
||||
}
|
||||
|
||||
# #endregion LLMClient._estimate_payload_size
|
||||
|
||||
# #region LLMClient._deduplicate_issues [TYPE Function] [C:2]
|
||||
@@ -1294,15 +1291,15 @@ class LLMClient:
|
||||
|
||||
# #region LLMClient._call_llm_for_images [TYPE Function] [C:2]
|
||||
# @PURPOSE Send a single chunk of images to the LLM and return parsed result.
|
||||
async def _call_llm_for_images(
|
||||
self, encoded_images: list[str], prompt: str
|
||||
) -> dict[str, Any]:
|
||||
async def _call_llm_for_images(self, encoded_images: list[str], prompt: str) -> dict[str, Any]:
|
||||
content: list[dict] = [{"type": "text", "text": prompt}]
|
||||
for b64_img in encoded_images:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{b64_img}"},
|
||||
})
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{b64_img}"},
|
||||
}
|
||||
)
|
||||
messages = [{"role": "user", "content": content}]
|
||||
return await self.get_json_completion(messages)
|
||||
|
||||
@@ -1335,14 +1332,15 @@ class LLMClient:
|
||||
encoded_images = self._optimize_images(screenshot_paths, max_width, image_quality)
|
||||
|
||||
log_text = "\n".join(logs)
|
||||
tab_list_text = "\n".join(
|
||||
f" Screenshot {i}: {label}" for i, label in enumerate(tab_labels or [])
|
||||
) or "Screenshots are in order."
|
||||
prompt = render_prompt(prompt_template, {
|
||||
"logs": log_text,
|
||||
"tab_list": tab_list_text,
|
||||
"total_chunks": str(len(encoded_images)),
|
||||
})
|
||||
tab_list_text = "\n".join(f" Screenshot {i}: {label}" for i, label in enumerate(tab_labels or [])) or "Screenshots are in order."
|
||||
prompt = render_prompt(
|
||||
prompt_template,
|
||||
{
|
||||
"logs": log_text,
|
||||
"tab_list": tab_list_text,
|
||||
"total_chunks": str(len(encoded_images)),
|
||||
},
|
||||
)
|
||||
|
||||
# 2. Determine chunking
|
||||
# Default to 8 images per chunk as a safe fallback when max_images is 0 or None
|
||||
@@ -1362,9 +1360,7 @@ class LLMClient:
|
||||
# well within the context window at normal quality.
|
||||
else:
|
||||
# Single batch: estimate payload and reduce quality if needed
|
||||
estimate = self._estimate_payload_size(
|
||||
screenshot_paths, len(prompt) + len(log_text)
|
||||
)
|
||||
estimate = self._estimate_payload_size(screenshot_paths, len(prompt) + len(log_text))
|
||||
if estimate["exceeds_limit"] and image_quality > 30:
|
||||
logger.reason(
|
||||
"Reducing image quality to fit context window",
|
||||
@@ -1373,10 +1369,7 @@ class LLMClient:
|
||||
encoded_images = self._optimize_images(screenshot_paths, max_width, image_quality=30)
|
||||
|
||||
# 3. Split into chunks
|
||||
chunks: list[list[str]] = [
|
||||
encoded_images[i:i + chunk_size]
|
||||
for i in range(0, n_total, chunk_size)
|
||||
]
|
||||
chunks: list[list[str]] = [encoded_images[i : i + chunk_size] for i in range(0, n_total, chunk_size)]
|
||||
|
||||
# 4. Call LLM — parallel for multiple chunks, single for one
|
||||
try:
|
||||
@@ -1390,11 +1383,13 @@ class LLMClient:
|
||||
for i, cr in enumerate(chunk_results):
|
||||
if isinstance(cr, Exception):
|
||||
logger.explore("Multimodal analysis chunk failed", payload={"chunk_index": i + 1, "total_chunks": len(chunks)}, error=str(cr))
|
||||
valid_results.append({
|
||||
"status": "UNKNOWN",
|
||||
"summary": f"Chunk {i + 1} failed: {cr!s}",
|
||||
"issues": [],
|
||||
})
|
||||
valid_results.append(
|
||||
{
|
||||
"status": "UNKNOWN",
|
||||
"summary": f"Chunk {i + 1} failed: {cr!s}",
|
||||
"issues": [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
valid_results.append(cr)
|
||||
|
||||
@@ -1408,6 +1403,7 @@ class LLMClient:
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
# #endregion LLMClient.analyze_dashboard_multimodal
|
||||
|
||||
# #region LLMClient.analyze_dashboard_text_batch [TYPE Function] [C:3]
|
||||
@@ -1449,27 +1445,22 @@ class LLMClient:
|
||||
did = p.get("dashboard_id", "UNKNOWN")
|
||||
top = p.get("topology", "")
|
||||
first_line = top.split("\n")[0] if top else "(no topology)"
|
||||
section = (
|
||||
f'─── Dashboard {i + 1}: "{first_line}" (id: {did}) ───\n'
|
||||
f"{top}\n\n"
|
||||
f"Dataset health:\n{p.get('dataset_health', '')}\n\n"
|
||||
f"Logs:\n{p.get('log_text', '')}"
|
||||
)
|
||||
section = f'─── Dashboard {i + 1}: "{first_line}" (id: {did}) ───\n{top}\n\nDataset health:\n{p.get("dataset_health", "")}\n\nLogs:\n{p.get("log_text", "")}'
|
||||
sections.append(section)
|
||||
|
||||
full_prompt = prompt_template.replace("{total_dashboards}", str(len(payloads)))
|
||||
full_prompt += (
|
||||
"\n\nRespond with a JSON object containing EACH dashboard's results:\n"
|
||||
'{"dashboards": [{"dashboard_id": "...", "status": "...", "summary": "...", "issues": [...]}]}\n\n'
|
||||
)
|
||||
full_prompt += '\n\nRespond with a JSON object containing EACH dashboard\'s results:\n{"dashboards": [{"dashboard_id": "...", "status": "...", "summary": "...", "issues": [...]}]}\n\n'
|
||||
full_prompt += "\n---\n".join(sections)
|
||||
|
||||
messages = [{"role": "user", "content": full_prompt}]
|
||||
return await self.get_json_completion(messages)
|
||||
|
||||
# #endregion LLMClient.analyze_dashboard_text_batch
|
||||
|
||||
|
||||
# #endregion LLMClient
|
||||
|
||||
|
||||
# #region DatasetHealthChecker [C:3] [TYPE Class]
|
||||
# @defgroup LLMAnalysis Module group.
|
||||
# @BRIEF Checks dataset accessibility and KXD connectivity via Superset API.
|
||||
@@ -1486,6 +1477,7 @@ class DatasetHealthChecker:
|
||||
# @POST self.client is ready for health checks.
|
||||
def __init__(self, client: Any):
|
||||
self.client = client
|
||||
|
||||
# #endregion DatasetHealthChecker.__init__
|
||||
|
||||
# #region DatasetHealthChecker._call_sync [TYPE Function]
|
||||
@@ -1498,6 +1490,7 @@ class DatasetHealthChecker:
|
||||
if asyncio.iscoroutinefunction(method):
|
||||
return await method(*args, **kwargs)
|
||||
return await asyncio.to_thread(method, *args, **kwargs)
|
||||
|
||||
# #endregion DatasetHealthChecker._call_sync
|
||||
|
||||
# #region DatasetHealthChecker.check_dataset_health [TYPE Function]
|
||||
@@ -1542,6 +1535,7 @@ class DatasetHealthChecker:
|
||||
"metadata_accessible": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# #endregion DatasetHealthChecker.check_dataset_health
|
||||
|
||||
# #region DatasetHealthChecker.check_chart_data [TYPE Function]
|
||||
@@ -1561,6 +1555,7 @@ class DatasetHealthChecker:
|
||||
row_count (int|None), error (str|None)
|
||||
"""
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
# Use the client's network layer for the chart data POST.
|
||||
@@ -1571,7 +1566,8 @@ class DatasetHealthChecker:
|
||||
network_request = self.client.network.request
|
||||
result = await self._call_sync(
|
||||
network_request,
|
||||
"POST", "/chart/data",
|
||||
"POST",
|
||||
"/chart/data",
|
||||
data=payload,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -1600,6 +1596,7 @@ class DatasetHealthChecker:
|
||||
"row_count": None,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# #endregion DatasetHealthChecker.check_chart_data
|
||||
|
||||
# #region DatasetHealthChecker.check_dashboard_datasets [TYPE Function]
|
||||
@@ -1634,11 +1631,7 @@ class DatasetHealthChecker:
|
||||
for ds_id in sorted(unique_ds_ids):
|
||||
result = await self.check_dataset_health(ds_id)
|
||||
# Map affected charts
|
||||
affected_charts = [
|
||||
{"chart_id": c.get("slice_id"), "chart_name": c.get("slice_name", f"chart_{c.get('slice_id')}")}
|
||||
for c in chart_list
|
||||
if c.get("datasource_id") == ds_id
|
||||
]
|
||||
affected_charts = [{"chart_id": c.get("slice_id"), "chart_name": c.get("slice_name", f"chart_{c.get('slice_id')}")} for c in chart_list if c.get("datasource_id") == ds_id]
|
||||
result["affected_charts"] = affected_charts
|
||||
dataset_results.append(result)
|
||||
|
||||
@@ -1668,9 +1661,13 @@ class DatasetHealthChecker:
|
||||
"datasets": dataset_results,
|
||||
"chart_data": chart_data_results,
|
||||
}
|
||||
|
||||
# #endregion DatasetHealthChecker.check_dashboard_datasets
|
||||
|
||||
|
||||
# #endregion DatasetHealthChecker
|
||||
|
||||
|
||||
# #region RedactionService [C:2] [TYPE Module]
|
||||
# @defgroup LLMAnalysis Module group.
|
||||
# @BRIEF Redacts PII, credentials, and sensitive data from logs and LLM responses.
|
||||
@@ -1705,6 +1702,7 @@ class RedactionService:
|
||||
line = re.sub(pattern, replacement, line, flags=re.IGNORECASE)
|
||||
redacted.append(line)
|
||||
return redacted
|
||||
|
||||
# #endregion RedactionService.redact_logs
|
||||
|
||||
# #region RedactionService.redact_raw_response [TYPE Function] [C:2]
|
||||
@@ -1717,7 +1715,10 @@ class RedactionService:
|
||||
for pattern, replacement in RedactionService.PATTERNS:
|
||||
raw = re.sub(pattern, replacement, raw, flags=re.IGNORECASE)
|
||||
return raw
|
||||
|
||||
# #endregion RedactionService.redact_raw_response
|
||||
|
||||
|
||||
# #endregion RedactionService
|
||||
|
||||
# #endregion LLMAnalysisService
|
||||
|
||||
@@ -46,10 +46,11 @@ DEFAULT_MAX_TOKENS: int = 8192
|
||||
# строки в verify=, требует SSLContext.
|
||||
# @POST Returns ssl.SSLContext with capath when enabled, False when disabled.
|
||||
def _get_verify() -> ssl.SSLContext | bool:
|
||||
raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
|
||||
if raw in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return ssl.create_default_context(capath="/etc/ssl/certs/")
|
||||
from ...core.ssl import httpx_verify
|
||||
|
||||
return httpx_verify()
|
||||
|
||||
|
||||
# #endregion _get_verify
|
||||
|
||||
|
||||
@@ -61,14 +62,14 @@ async def _get_http_client() -> httpx.AsyncClient:
|
||||
if _http_client is None:
|
||||
ssl_verify = _get_verify()
|
||||
if ssl_verify is False:
|
||||
log("LLMAsyncHttpClient", "EXPLORE",
|
||||
"TLS verification disabled via LLM_SSL_VERIFY=false",
|
||||
error="TLS verification disabled — traffic to LLM provider is unencrypted")
|
||||
log("LLMAsyncHttpClient", "EXPLORE", "TLS verification disabled via LLM_SSL_VERIFY=false", error="TLS verification disabled — traffic to LLM provider is unencrypted")
|
||||
_http_client = httpx.AsyncClient(
|
||||
verify=ssl_verify,
|
||||
timeout=httpx.Timeout(180.0),
|
||||
)
|
||||
return _http_client
|
||||
|
||||
|
||||
# #endregion _get_http_client
|
||||
|
||||
|
||||
@@ -100,13 +101,7 @@ async def call_openai_compatible(
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
system_content = (
|
||||
"You are a database content translation assistant. "
|
||||
"Translate the provided text accurately, preserving data semantics. "
|
||||
"Respond directly with ONLY the JSON result. "
|
||||
"Do NOT include any reasoning, thinking, chain-of-thought, analysis, "
|
||||
"or explanation. Output ONLY valid JSON."
|
||||
)
|
||||
system_content = "You are a database content translation assistant. Translate the provided text accurately, preserving data semantics. Respond directly with ONLY the JSON result. Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. Output ONLY valid JSON."
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -127,43 +122,43 @@ async def call_openai_compatible(
|
||||
payload["reasoning_effort"] = "none"
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
logger.reason(
|
||||
f"LLM request url={_sanitize_url(base_url)} model={payload.get('model')} "
|
||||
f"provider_type={provider_type} "
|
||||
f"response_format={'yes' if 'response_format' in payload else 'no'} "
|
||||
f"prompt_len={len(prompt)}"
|
||||
)
|
||||
logger.reason(f"LLM request url={_sanitize_url(base_url)} model={payload.get('model')} provider_type={provider_type} response_format={'yes' if 'response_format' in payload else 'no'} prompt_len={len(prompt)}")
|
||||
|
||||
response, response_text = await _do_http_request(url, headers, payload)
|
||||
await _handle_response_format_fallback(response, response_text, payload, url, headers)
|
||||
|
||||
if not response.is_success:
|
||||
logger.explore(
|
||||
f"LLM API error status={response.status_code} "
|
||||
f"model={payload.get('model')} "
|
||||
f"body={response_text[:2000]}"
|
||||
)
|
||||
logger.explore(f"LLM API error status={response.status_code} model={payload.get('model')} body={response_text[:2000]}")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.explore("LLM returned no choices", extra={
|
||||
"src": "executor", "response_keys": list(data.keys()),
|
||||
"response_preview": str(data)[:2000],
|
||||
})
|
||||
logger.explore(
|
||||
"LLM returned no choices",
|
||||
extra={
|
||||
"src": "executor",
|
||||
"response_keys": list(data.keys()),
|
||||
"response_preview": str(data)[:2000],
|
||||
},
|
||||
)
|
||||
raise ValueError("LLM returned no choices")
|
||||
|
||||
try:
|
||||
finish_reason = choices[0].get("finish_reason") or "none"
|
||||
msg = choices[0].get("message") or {}
|
||||
except (TypeError, AttributeError) as e:
|
||||
logger.explore("TypeError processing LLM response choices", extra={
|
||||
"src": "executor_diag", "error": str(e),
|
||||
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
|
||||
"choices_0_repr": repr(choices[0])[:2000] if choices else "N/A",
|
||||
"data_type": type(data).__name__, "data_preview": str(data)[:2000],
|
||||
})
|
||||
logger.explore(
|
||||
"TypeError processing LLM response choices",
|
||||
extra={
|
||||
"src": "executor_diag",
|
||||
"error": str(e),
|
||||
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
|
||||
"choices_0_repr": repr(choices[0])[:2000] if choices else "N/A",
|
||||
"data_type": type(data).__name__,
|
||||
"data_preview": str(data)[:2000],
|
||||
},
|
||||
)
|
||||
raise ValueError(f"LLM response processing failed: {e}")
|
||||
|
||||
# Log provider token usage for batch sizing calibration
|
||||
@@ -183,28 +178,36 @@ async def call_openai_compatible(
|
||||
|
||||
refusal = msg.get("refusal") if isinstance(msg, dict) else None
|
||||
if refusal:
|
||||
logger.explore("LLM refused to respond", extra={
|
||||
"src": "executor", "refusal": str(refusal)[:500], "finish_reason": finish_reason,
|
||||
})
|
||||
logger.explore(
|
||||
"LLM refused to respond",
|
||||
extra={
|
||||
"src": "executor",
|
||||
"refusal": str(refusal)[:500],
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
)
|
||||
raise ValueError(f"LLM refused to respond: {refusal}")
|
||||
|
||||
content = msg.get("content") if isinstance(msg, dict) else ""
|
||||
if not content and isinstance(msg, dict):
|
||||
content = msg.get("content") or ""
|
||||
|
||||
logger.reason(
|
||||
f"LLM response finish_reason={finish_reason} content_len={len(content)} "
|
||||
f"msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}"
|
||||
)
|
||||
logger.reason(f"LLM response finish_reason={finish_reason} content_len={len(content)} msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}")
|
||||
if not content:
|
||||
logger.explore("LLM returned empty content", extra={
|
||||
"src": "executor", "finish_reason": finish_reason,
|
||||
"msg_keys": list(msg.keys()) if isinstance(msg, dict) else [],
|
||||
"response_preview": str(data)[:2000],
|
||||
})
|
||||
logger.explore(
|
||||
"LLM returned empty content",
|
||||
extra={
|
||||
"src": "executor",
|
||||
"finish_reason": finish_reason,
|
||||
"msg_keys": list(msg.keys()) if isinstance(msg, dict) else [],
|
||||
"response_preview": str(data)[:2000],
|
||||
},
|
||||
)
|
||||
raise ValueError("LLM returned empty content")
|
||||
|
||||
return content, finish_reason
|
||||
|
||||
|
||||
# #endregion call_openai_compatible
|
||||
|
||||
|
||||
@@ -224,34 +227,34 @@ async def _do_http_request(url: str, headers: dict, payload: dict) -> tuple[http
|
||||
try:
|
||||
wait = int(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
wait = 2 ** _retry_count_429
|
||||
wait = 2**_retry_count_429
|
||||
else:
|
||||
wait = 2 ** _retry_count_429
|
||||
logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s",
|
||||
extra={"src": "executor", "retry_after": retry_after, "wait": wait})
|
||||
wait = 2**_retry_count_429
|
||||
logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s", extra={"src": "executor", "retry_after": retry_after, "wait": wait})
|
||||
await asyncio.sleep(wait)
|
||||
if _retry_count_429 >= _max_retry_429:
|
||||
break
|
||||
else:
|
||||
break
|
||||
return response, response_text
|
||||
|
||||
|
||||
# #endregion _do_http_request
|
||||
|
||||
|
||||
# #region _handle_response_format_fallback [C:1] [TYPE Function]
|
||||
async def _handle_response_format_fallback(
|
||||
response: httpx.Response, response_text: str, payload: dict, url: str, headers: dict,
|
||||
response: httpx.Response,
|
||||
response_text: str,
|
||||
payload: dict,
|
||||
url: str,
|
||||
headers: dict,
|
||||
) -> None:
|
||||
"""Handle 400 errors from structured_outputs not being supported."""
|
||||
_patterns = ("response_format", "structured_outputs", "structured", "json_object")
|
||||
if (
|
||||
not response.is_success
|
||||
and response.status_code == 400
|
||||
and any(p in (response_text or "").lower() for p in _patterns)
|
||||
):
|
||||
if not response.is_success and response.status_code == 400 and any(p in (response_text or "").lower() for p in _patterns):
|
||||
client = await _get_http_client()
|
||||
logger.explore("Structured outputs not supported, retrying without response_format",
|
||||
extra={"src": "executor"})
|
||||
logger.explore("Structured outputs not supported, retrying without response_format", extra={"src": "executor"})
|
||||
payload.pop("response_format", None)
|
||||
new_response = await client.post(url, headers=headers, json=payload)
|
||||
# Mutate the original response object with new data
|
||||
@@ -259,5 +262,7 @@ async def _handle_response_format_fallback(
|
||||
response._content = new_response.content
|
||||
response.encoding = new_response.encoding
|
||||
response.headers = new_response.headers
|
||||
|
||||
|
||||
# #endregion _handle_response_format_fallback
|
||||
# #endregion LLMAsyncHttpClient
|
||||
|
||||
@@ -24,11 +24,13 @@ from src.plugins.llm_analysis.models import LLMProviderType
|
||||
|
||||
# ── ScreenshotService Tests ──
|
||||
|
||||
|
||||
class TestResponseLooksLikeLoginPage:
|
||||
"""Verify _response_looks_like_login_page."""
|
||||
|
||||
def test_login_page_detected(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
html = """
|
||||
<form>
|
||||
@@ -44,18 +46,21 @@ class TestResponseLooksLikeLoginPage:
|
||||
|
||||
def test_non_login_page(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
html = "<html><body><h1>Dashboard</h1><p>Welcome to Superset</p></body></html>"
|
||||
assert svc._response_looks_like_login_page(html) is False
|
||||
|
||||
def test_empty_text(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
assert svc._response_looks_like_login_page("") is False
|
||||
assert svc._response_looks_like_login_page(None) is False
|
||||
|
||||
def test_partial_match_under_threshold(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
assert svc._response_looks_like_login_page("Please sign in to continue") is False
|
||||
assert svc._response_looks_like_login_page("Username: admin Password: secret") is False
|
||||
@@ -63,6 +68,7 @@ class TestResponseLooksLikeLoginPage:
|
||||
def test_csrf_token_marker(self):
|
||||
"""Edge: csrf_token hidden input is a marker."""
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
html = 'name="csrf_token" value="abc"'
|
||||
assert svc._response_looks_like_login_page(html) is False # only 1 marker
|
||||
@@ -73,18 +79,21 @@ class TestRedirectLooksAuthenticated:
|
||||
|
||||
def test_empty_redirect_authenticated(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
assert svc._redirect_looks_authenticated("") is True
|
||||
assert svc._redirect_looks_authenticated(None) is True
|
||||
|
||||
def test_login_redirect_not_authenticated(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
assert svc._redirect_looks_authenticated("/login/") is False
|
||||
assert svc._redirect_looks_authenticated("https://example.com/login") is False
|
||||
|
||||
def test_dashboard_redirect_authenticated(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
assert svc._redirect_looks_authenticated("/superset/dashboard/1/") is True
|
||||
assert svc._redirect_looks_authenticated("https://example.com/superset/dashboard/1/") is True
|
||||
@@ -95,6 +104,7 @@ class TestIterLoginRoots:
|
||||
|
||||
def test_page_without_frames(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
page = MagicMock()
|
||||
page.frames = []
|
||||
@@ -104,6 +114,7 @@ class TestIterLoginRoots:
|
||||
|
||||
def test_page_with_frames(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
page = MagicMock()
|
||||
frame1 = MagicMock()
|
||||
@@ -118,8 +129,9 @@ class TestIterLoginRoots:
|
||||
def test_page_frames_property_exception(self):
|
||||
"""Edge: exception in frames property handled gracefully."""
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
page = MagicMock(spec_set=['url'])
|
||||
page = MagicMock(spec_set=["url"])
|
||||
roots = svc._iter_login_roots(page)
|
||||
assert len(roots) == 1
|
||||
|
||||
@@ -130,6 +142,7 @@ class TestExtractHiddenLoginFields:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_hidden_fields(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -139,12 +152,14 @@ class TestExtractHiddenLoginFields:
|
||||
|
||||
async def attr_side(attr_name):
|
||||
return "csrf_token" if attr_name == "name" else None
|
||||
|
||||
field1 = MagicMock()
|
||||
field1.get_attribute = AsyncMock(side_effect=attr_side)
|
||||
field1.input_value = AsyncMock(return_value="abc123")
|
||||
|
||||
async def attr_side2(attr_name):
|
||||
return "next" if attr_name == "name" else None
|
||||
|
||||
field2 = MagicMock()
|
||||
field2.get_attribute = AsyncMock(side_effect=attr_side2)
|
||||
field2.input_value = AsyncMock(return_value="/dashboard/")
|
||||
@@ -158,6 +173,7 @@ class TestExtractHiddenLoginFields:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_when_no_hidden_fields(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -171,6 +187,7 @@ class TestExtractHiddenLoginFields:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_duplicate_names(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -180,6 +197,7 @@ class TestExtractHiddenLoginFields:
|
||||
|
||||
async def attr_side(attr_name):
|
||||
return "csrf_token" if attr_name == "name" else None
|
||||
|
||||
field1 = MagicMock()
|
||||
field1.get_attribute = AsyncMock(side_effect=attr_side)
|
||||
field1.input_value = AsyncMock(return_value="first")
|
||||
@@ -199,15 +217,17 @@ class TestExtractCsrfToken:
|
||||
@pytest.mark.asyncio
|
||||
async def test_found_token(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok123"})):
|
||||
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok123"})):
|
||||
assert await svc._extract_csrf_token(MagicMock()) == "tok123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_token(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={})):
|
||||
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={})):
|
||||
assert await svc._extract_csrf_token(MagicMock()) == ""
|
||||
|
||||
|
||||
@@ -217,6 +237,7 @@ class TestSubmitLoginViaFormPost:
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_authenticated(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
svc.env = MagicMock()
|
||||
svc.env.username = "admin"
|
||||
@@ -232,27 +253,29 @@ class TestSubmitLoginViaFormPost:
|
||||
mock_response.headers = {"location": "/superset/dashboard/1/"}
|
||||
mock_request.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
|
||||
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok"})):
|
||||
result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_csrf_token_skips(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={})):
|
||||
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={})):
|
||||
result = await svc._submit_login_via_form_post(MagicMock(), "https://example.com/login/")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_context_unavailable(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.context = MagicMock(side_effect=AttributeError("no context"))
|
||||
# Override to test the try/except
|
||||
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
|
||||
with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=False)):
|
||||
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok"})):
|
||||
with patch.object(svc, "_submit_login_via_form_post", AsyncMock(return_value=False)):
|
||||
# We test the context error path by making request unavailable
|
||||
pass
|
||||
|
||||
@@ -260,6 +283,7 @@ class TestSubmitLoginViaFormPost:
|
||||
async def test_login_page_response_returns_false(self):
|
||||
"""When POST returns login page markup, return False."""
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
svc.env = MagicMock()
|
||||
svc.env.username = "admin"
|
||||
@@ -278,7 +302,7 @@ class TestSubmitLoginViaFormPost:
|
||||
mock_response.text = AsyncMock(return_value='<form><label>Username:</label><input name="username"/><label>Password:</label><input type="password" name="password"/><input type="submit" value="Sign in"/><input type="hidden" name="csrf_token" value="x"/></form>')
|
||||
mock_request.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
|
||||
with patch.object(svc, "_extract_hidden_login_fields", AsyncMock(return_value={"csrf_token": "tok"})):
|
||||
result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
|
||||
assert result is False
|
||||
|
||||
@@ -289,6 +313,7 @@ class TestFindLoginFieldLocator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_username_field(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -306,6 +331,7 @@ class TestFindLoginFieldLocator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_password_field(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -323,6 +349,7 @@ class TestFindLoginFieldLocator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_field_found(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -344,6 +371,7 @@ class TestFindLoginFieldLocator:
|
||||
async def test_finds_username_via_input_fallback(self):
|
||||
"""Fallback: input[type='text'] for username."""
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -391,6 +419,7 @@ class TestFindSubmitLocator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_finds_sign_in_button(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -408,6 +437,7 @@ class TestFindSubmitLocator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_submit_found(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
|
||||
mock_page = MagicMock()
|
||||
@@ -428,6 +458,7 @@ class TestGotoResilient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_success(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.goto = AsyncMock(return_value=MagicMock())
|
||||
@@ -437,6 +468,7 @@ class TestGotoResilient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_on_primary_failure(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
@@ -457,20 +489,22 @@ class TestWaitForChartsStabilized:
|
||||
@pytest.mark.asyncio
|
||||
async def test_polls_and_returns(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.wait_for_function = AsyncMock()
|
||||
with patch('asyncio.sleep', AsyncMock()):
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
await svc._wait_for_charts_stabilized(mock_page)
|
||||
mock_page.wait_for_function.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_does_not_raise(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.wait_for_function = AsyncMock(side_effect=Exception("timeout"))
|
||||
with patch('asyncio.sleep', AsyncMock()):
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
# Should not raise
|
||||
await svc._wait_for_charts_stabilized(mock_page)
|
||||
|
||||
@@ -481,6 +515,7 @@ class TestWaitForResizeRendered:
|
||||
@pytest.mark.asyncio
|
||||
async def test_polls_and_returns(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.wait_for_function = AsyncMock()
|
||||
@@ -490,6 +525,7 @@ class TestWaitForResizeRendered:
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_does_not_raise(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.wait_for_function = AsyncMock(side_effect=Exception("timeout"))
|
||||
@@ -502,6 +538,7 @@ class TestSaveDebugScreenshot:
|
||||
@pytest.mark.asyncio
|
||||
async def test_saves_screenshot(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.screenshot = AsyncMock()
|
||||
@@ -513,6 +550,7 @@ class TestSaveDebugScreenshot:
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_returns_none(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
svc = ScreenshotService(MagicMock())
|
||||
mock_page = MagicMock()
|
||||
mock_page.screenshot = AsyncMock(side_effect=Exception("screenshot failed"))
|
||||
@@ -542,6 +580,7 @@ class TestConvertScreenshotsForLlm:
|
||||
|
||||
def test_missing_file_skipped(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
result = ScreenshotService._convert_screenshots_for_llm(["/nonexistent.png"], "/tmp")
|
||||
assert result == []
|
||||
|
||||
@@ -591,6 +630,7 @@ class TestArchiveScreenshotsAsWebp:
|
||||
|
||||
def test_archive_missing_file(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
result = ScreenshotService._archive_screenshots_as_webp(["/nonexistent.png"], "/tmp")
|
||||
assert len(result) == 1
|
||||
assert result[0]["webp_path"] is None
|
||||
@@ -641,11 +681,13 @@ class TestCleanupTempFiles:
|
||||
|
||||
def test_cleanup_missing_file(self):
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
ScreenshotService._cleanup_temp_files(["/nonexistent.png"])
|
||||
|
||||
|
||||
# ── LLMClient Tests ──
|
||||
|
||||
|
||||
class TestLLMClientInit:
|
||||
"""Verify LLMClient initialization."""
|
||||
|
||||
@@ -667,11 +709,11 @@ class TestLLMClientInit:
|
||||
assert client.api_key == "sk-test-key"
|
||||
|
||||
def test_init_openrouter_headers(self):
|
||||
with patch.dict(os.environ, {"OPENROUTER_SITE_URL": "https://example.com",
|
||||
"OPENROUTER_APP_NAME": "TestApp"}):
|
||||
with patch.dict(os.environ, {"OPENROUTER_SITE_URL": "https://example.com", "OPENROUTER_APP_NAME": "TestApp"}):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
|
||||
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
|
||||
client = LLMClient(
|
||||
provider_type=LLMProviderType.OPENROUTER,
|
||||
api_key="sk-test",
|
||||
@@ -682,8 +724,9 @@ class TestLLMClientInit:
|
||||
|
||||
def test_init_kilo_headers(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
|
||||
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
|
||||
client = LLMClient(
|
||||
provider_type=LLMProviderType.KILO,
|
||||
api_key="sk-test",
|
||||
@@ -694,8 +737,9 @@ class TestLLMClientInit:
|
||||
|
||||
def _make_client(self, api_key="sk-test"):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
|
||||
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
|
||||
return LLMClient(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
api_key=api_key,
|
||||
@@ -707,46 +751,21 @@ class TestLLMClientInit:
|
||||
class TestLLMClientSslVerify:
|
||||
"""Verify _get_ssl_verify."""
|
||||
|
||||
def test_default_disabled(self):
|
||||
def test_returns_ssl_context(self):
|
||||
"""_get_ssl_verify always returns SSLContext (no env-based disable)."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
from src.core.ssl import system_ssl_context
|
||||
|
||||
result = LLMClient._get_ssl_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_ignores_llm_ssl_verify_env(self):
|
||||
"""LLM_SSL_VERIFY env is no longer read — central ssl helper is used."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}):
|
||||
assert LLMClient._get_ssl_verify() is False
|
||||
|
||||
def test_disabled_zero(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "0"}):
|
||||
assert LLMClient._get_ssl_verify() is False
|
||||
|
||||
def test_disabled_no(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "no"}):
|
||||
assert LLMClient._get_ssl_verify() is False
|
||||
|
||||
def test_disabled_off(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "off"}):
|
||||
assert LLMClient._get_ssl_verify() is False
|
||||
|
||||
def test_enabled_returns_context(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
result = LLMClient._get_ssl_verify()
|
||||
assert isinstance(result, (ssl.SSLContext, bool))
|
||||
|
||||
def test_ca_dir_missing(self):
|
||||
"""When /etc/ssl/certs doesn't exist, still returns SSLContext."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch('os.path.isdir', return_value=False):
|
||||
result = LLMClient._get_ssl_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_unknown_value_defaults_enabled(self):
|
||||
"""Unknown env values treated as 'true' -> enabled."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "maybe"}):
|
||||
result = LLMClient._get_ssl_verify()
|
||||
assert isinstance(result, (ssl.SSLContext, bool))
|
||||
assert isinstance(result, ssl.SSLContext), "must not return False"
|
||||
|
||||
|
||||
class TestFormatConnectionError:
|
||||
@@ -754,12 +773,14 @@ class TestFormatConnectionError:
|
||||
|
||||
def test_single_exception(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
result = LLMClient._format_connection_error(ValueError("test error"))
|
||||
assert "ValueError" in result
|
||||
assert "test error" in result
|
||||
|
||||
def test_chained_exception(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
inner = ConnectionError("connection refused")
|
||||
outer = RuntimeError("API call failed")
|
||||
outer.__cause__ = inner
|
||||
@@ -770,6 +791,7 @@ class TestFormatConnectionError:
|
||||
|
||||
def test_deep_chain(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
inner = ValueError("inner")
|
||||
middle = TypeError("middle")
|
||||
outer = RuntimeError("outer")
|
||||
@@ -786,30 +808,35 @@ class TestSupportsJsonResponseFormat:
|
||||
|
||||
def test_free_model_disabled(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.default_model = "gpt-4o:free"
|
||||
assert LLMClient._supports_json_response_format(client) is False
|
||||
|
||||
def test_stepfun_disabled(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.default_model = "step-1v"
|
||||
assert LLMClient._supports_json_response_format(client) is False
|
||||
|
||||
def test_stepfun_slash_disabled(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.default_model = "stepfun/some-model"
|
||||
assert LLMClient._supports_json_response_format(client) is False
|
||||
|
||||
def test_normal_model_enabled(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.default_model = "gpt-4o"
|
||||
assert LLMClient._supports_json_response_format(client) is True
|
||||
|
||||
def test_empty_model_default_enabled(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.default_model = ""
|
||||
assert LLMClient._supports_json_response_format(client) is True
|
||||
@@ -820,6 +847,7 @@ class TestDeduplicateIssues:
|
||||
|
||||
def test_deduplicates_by_severity_message_location(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
issues = [
|
||||
{"severity": "HIGH", "message": "Chart missing", "location": "chart1"},
|
||||
@@ -831,12 +859,14 @@ class TestDeduplicateIssues:
|
||||
|
||||
def test_empty_issues(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
assert LLMClient._deduplicate_issues(client, []) == []
|
||||
|
||||
def test_issues_with_none_location(self):
|
||||
"""Edge: None location treated as empty string."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
issues = [
|
||||
{"severity": "HIGH", "message": "Error", "location": None},
|
||||
@@ -848,6 +878,7 @@ class TestDeduplicateIssues:
|
||||
def test_issues_without_location(self):
|
||||
"""Edge: missing location key."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
issues = [
|
||||
{"severity": "HIGH", "message": "No loc"},
|
||||
@@ -862,6 +893,7 @@ class TestEstimatePayloadSize:
|
||||
|
||||
def test_estimate_basic(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
result = LLMClient._estimate_payload_size(["img1.png"], 1000, 128000)
|
||||
assert result["estimated_tokens"] > 0
|
||||
assert "pct_of_limit" in result
|
||||
@@ -869,16 +901,19 @@ class TestEstimatePayloadSize:
|
||||
|
||||
def test_large_payload_exceeds(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
result = LLMClient._estimate_payload_size(["img1.png"] * 100, 100000, 128000)
|
||||
assert result["exceeds_limit"] is True
|
||||
|
||||
def test_small_payload_ok(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
result = LLMClient._estimate_payload_size([], 100, 128000)
|
||||
assert result["exceeds_limit"] is False
|
||||
|
||||
def test_no_images(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
result = LLMClient._estimate_payload_size([], 4000, 128000)
|
||||
assert result["estimated_tokens"] == 1000 # 4000/4
|
||||
|
||||
@@ -888,42 +923,46 @@ class TestMergeChunkResults:
|
||||
|
||||
def test_merge_takes_worst_status(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
chunks = [
|
||||
{"status": "PASS", "summary": "All good", "issues": []},
|
||||
{"status": "FAIL", "summary": "Broken", "issues": [{"severity": "HIGH", "message": "Error"}]},
|
||||
]
|
||||
with patch.object(LLMClient, '_deduplicate_issues', return_value=[{"severity": "HIGH", "message": "Error"}]):
|
||||
with patch.object(LLMClient, "_deduplicate_issues", return_value=[{"severity": "HIGH", "message": "Error"}]):
|
||||
result = LLMClient._merge_chunk_results(client, chunks)
|
||||
assert result["status"] == "FAIL"
|
||||
assert result["chunk_count"] == 2
|
||||
|
||||
def test_merge_single_chunk(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
chunks = [{"status": "WARN", "summary": "Some issues", "issues": []}]
|
||||
with patch.object(LLMClient, '_deduplicate_issues', return_value=[]):
|
||||
with patch.object(LLMClient, "_deduplicate_issues", return_value=[]):
|
||||
result = LLMClient._merge_chunk_results(client, chunks)
|
||||
assert result["status"] == "WARN"
|
||||
assert result["chunk_count"] == 1
|
||||
|
||||
def test_merge_unknown_default(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
chunks = [{"summary": "No status", "issues": []}]
|
||||
with patch.object(LLMClient, '_deduplicate_issues', return_value=[]):
|
||||
with patch.object(LLMClient, "_deduplicate_issues", return_value=[]):
|
||||
result = LLMClient._merge_chunk_results(client, chunks)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
|
||||
def test_merge_worst_over_pass(self):
|
||||
"""WARN < PASS so WARN wins."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
chunks = [
|
||||
{"status": "PASS", "summary": "OK", "issues": []},
|
||||
{"status": "WARN", "summary": "Warning", "issues": []},
|
||||
]
|
||||
with patch.object(LLMClient, '_deduplicate_issues', return_value=[]):
|
||||
with patch.object(LLMClient, "_deduplicate_issues", return_value=[]):
|
||||
result = LLMClient._merge_chunk_results(client, chunks)
|
||||
assert result["status"] == "WARN"
|
||||
|
||||
@@ -935,17 +974,15 @@ class TestLLMClientAnalyze:
|
||||
async def test_analyze_dashboard_delegates_to_multimodal(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient as RealClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
|
||||
real_client = RealClient(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
api_key="sk-test",
|
||||
base_url="https://api.openai.com",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
real_client.analyze_dashboard_multimodal = AsyncMock(
|
||||
return_value={"status": "PASS", "summary": "OK"}
|
||||
)
|
||||
real_client.analyze_dashboard_multimodal = AsyncMock(return_value={"status": "PASS", "summary": "OK"})
|
||||
|
||||
result = await real_client.analyze_dashboard(
|
||||
screenshot_path="/tmp/test.png",
|
||||
@@ -965,8 +1002,8 @@ class TestLLMClientOptimizeImages:
|
||||
Image.new("RGB", (100, 100), (255, 0, 0)).save(png_path, "PNG")
|
||||
|
||||
# Create a real client with mocked internals
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
|
||||
real_client = LLMClient(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
api_key="sk-test",
|
||||
@@ -974,7 +1011,7 @@ class TestLLMClientOptimizeImages:
|
||||
default_model="gpt-4o-mini",
|
||||
)
|
||||
# Patch the static method on the class
|
||||
with patch.object(LLMClient, '_reduce_image_quality', return_value=("base64data", 1000)):
|
||||
with patch.object(LLMClient, "_reduce_image_quality", return_value=("base64data", 1000)):
|
||||
result = real_client._optimize_images([png_path], 1024, 60)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "base64data"
|
||||
@@ -987,7 +1024,7 @@ class TestLLMClientOptimizeImages:
|
||||
Image.new("RGB", (100, 100)).save(png_path, "PNG")
|
||||
|
||||
client = MagicMock()
|
||||
with patch.object(LLMClient, '_reduce_image_quality', side_effect=Exception("corrupt")):
|
||||
with patch.object(LLMClient, "_reduce_image_quality", side_effect=Exception("corrupt")):
|
||||
result = LLMClient._optimize_images(client, [png_path], 1024, 60)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], str)
|
||||
@@ -1051,7 +1088,7 @@ class TestLLMClientCallLlmForImages:
|
||||
|
||||
client = MagicMock()
|
||||
client.get_json_completion = AsyncMock(return_value={"status": "PASS"})
|
||||
with patch.object(LLMClient, '_call_llm_for_images') as mock_call:
|
||||
with patch.object(LLMClient, "_call_llm_for_images") as mock_call:
|
||||
mock_call.return_value = {"status": "PASS"}
|
||||
result = await LLMClient._call_llm_for_images(client, ["base64img"], "Analyze")
|
||||
assert result is not None
|
||||
@@ -1064,8 +1101,8 @@ class TestLLMClientFetchModels:
|
||||
async def test_fetch_success(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
|
||||
mock_client = MagicMock()
|
||||
MockOpenAI.return_value = mock_client
|
||||
mock_response = MagicMock()
|
||||
@@ -1090,8 +1127,8 @@ class TestLLMClientFetchModels:
|
||||
async def test_fetch_failure_raises(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
|
||||
mock_client = MagicMock()
|
||||
MockOpenAI.return_value = mock_client
|
||||
mock_client.models.list = AsyncMock(side_effect=ConnectionError("API unavailable"))
|
||||
@@ -1114,8 +1151,8 @@ class TestLLMClientTestRuntimeConnection:
|
||||
async def test_runtime_connection_success(self):
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI'):
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI"):
|
||||
real_client = LLMClient(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
api_key="sk-test",
|
||||
@@ -1145,14 +1182,14 @@ class TestGetJsonCompletion:
|
||||
"""Edge: JSON embedded in ```json code block parsed."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
|
||||
mock_client = MagicMock()
|
||||
MockOpenAI.return_value = mock_client
|
||||
|
||||
# Mock response content with JSON in code block
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "```json\n{\"status\": \"PASS\"}\n```"
|
||||
mock_choice.message.content = '```json\n{"status": "PASS"}\n```'
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
@@ -1172,13 +1209,13 @@ class TestGetJsonCompletion:
|
||||
"""Edge: JSON in ``` code block (no json marker)."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
|
||||
mock_client = MagicMock()
|
||||
MockOpenAI.return_value = mock_client
|
||||
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "```\n{\"status\": \"WARN\"}\n```"
|
||||
mock_choice.message.content = '```\n{"status": "WARN"}\n```'
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
@@ -1198,8 +1235,8 @@ class TestGetJsonCompletion:
|
||||
"""Negative: null content raises RuntimeError."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
|
||||
mock_client = MagicMock()
|
||||
MockOpenAI.return_value = mock_client
|
||||
|
||||
@@ -1224,8 +1261,8 @@ class TestGetJsonCompletion:
|
||||
"""Negative: empty choices raises RuntimeError."""
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
|
||||
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
|
||||
with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"):
|
||||
with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI:
|
||||
mock_client = MagicMock()
|
||||
MockOpenAI.return_value = mock_client
|
||||
mock_response = MagicMock()
|
||||
@@ -1270,7 +1307,9 @@ class TestAnalyzeDashboardMultimodal:
|
||||
client._deduplicate_issues.return_value = []
|
||||
|
||||
result = await LLMClient.analyze_dashboard_multimodal(
|
||||
client, [png_path], ["log1"],
|
||||
client,
|
||||
[png_path],
|
||||
["log1"],
|
||||
prompt_template="Analyze: {logs}",
|
||||
)
|
||||
assert result["status"] == "PASS"
|
||||
@@ -1287,14 +1326,16 @@ class TestAnalyzeDashboardMultimodal:
|
||||
client = MagicMock()
|
||||
client._optimize_images.side_effect = [
|
||||
["img1_high"], # first call (high quality)
|
||||
["img1_low"], # second call (reduced quality)
|
||||
["img1_low"], # second call (reduced quality)
|
||||
]
|
||||
client._estimate_payload_size.return_value = {"exceeds_limit": True, "pct_of_limit": 85}
|
||||
client._call_llm_for_images = AsyncMock(return_value={"status": "WARN", "summary": "Reduced", "issues": []})
|
||||
client._deduplicate_issues.return_value = []
|
||||
|
||||
result = await LLMClient.analyze_dashboard_multimodal(
|
||||
client, [png_path], ["log1"],
|
||||
client,
|
||||
[png_path],
|
||||
["log1"],
|
||||
prompt_template="Analyze: {logs}",
|
||||
)
|
||||
assert result["status"] == "WARN"
|
||||
@@ -1319,7 +1360,9 @@ class TestAnalyzeDashboardMultimodal:
|
||||
paths.append(p)
|
||||
|
||||
result = await LLMClient.analyze_dashboard_multimodal(
|
||||
client, paths, ["log"],
|
||||
client,
|
||||
paths,
|
||||
["log"],
|
||||
max_images=2,
|
||||
)
|
||||
# With 5 images and max_images=2, this creates 3 chunks
|
||||
@@ -1342,7 +1385,9 @@ class TestAnalyzeDashboardMultimodal:
|
||||
client._deduplicate_issues.return_value = []
|
||||
|
||||
result = await LLMClient.analyze_dashboard_multimodal(
|
||||
client, [png_path], ["log"],
|
||||
client,
|
||||
[png_path],
|
||||
["log"],
|
||||
)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
|
||||
@@ -1363,9 +1408,7 @@ class TestAnalyzeDashboardTextBatch:
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.get_json_completion = AsyncMock(return_value={
|
||||
"dashboards": [{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []}]
|
||||
})
|
||||
client.get_json_completion = AsyncMock(return_value={"dashboards": [{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []}]})
|
||||
|
||||
result = await LLMClient.analyze_dashboard_text_batch(
|
||||
client,
|
||||
@@ -1380,12 +1423,14 @@ class TestAnalyzeDashboardTextBatch:
|
||||
from src.plugins.llm_analysis.service import LLMClient
|
||||
|
||||
client = MagicMock()
|
||||
client.get_json_completion = AsyncMock(return_value={
|
||||
"dashboards": [
|
||||
{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []},
|
||||
{"dashboard_id": "2", "status": "FAIL", "summary": "Broken", "issues": [{"severity": "HIGH", "message": "err"}]},
|
||||
]
|
||||
})
|
||||
client.get_json_completion = AsyncMock(
|
||||
return_value={
|
||||
"dashboards": [
|
||||
{"dashboard_id": "1", "status": "PASS", "summary": "OK", "issues": []},
|
||||
{"dashboard_id": "2", "status": "FAIL", "summary": "Broken", "issues": [{"severity": "HIGH", "message": "err"}]},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await LLMClient.analyze_dashboard_text_batch(
|
||||
client,
|
||||
@@ -1400,11 +1445,13 @@ class TestAnalyzeDashboardTextBatch:
|
||||
|
||||
# ── DatasetHealthChecker Tests ──
|
||||
|
||||
|
||||
class TestDatasetHealthChecker:
|
||||
"""Verify DatasetHealthChecker."""
|
||||
|
||||
def test_import(self):
|
||||
from src.plugins.llm_analysis.service import DatasetHealthChecker
|
||||
|
||||
assert DatasetHealthChecker is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1498,13 +1545,10 @@ class TestDatasetHealthChecker:
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.network.request.return_value = {"result": [{"val": 1}]}
|
||||
mock_client.get_dataset.return_value = {
|
||||
"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}
|
||||
}
|
||||
mock_client.get_dataset.return_value = {"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}}
|
||||
|
||||
chart_list = [
|
||||
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101,
|
||||
"viz_type": "table", "params": {"metrics": ["count"]}, "datasource_type": "table"},
|
||||
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101, "viz_type": "table", "params": {"metrics": ["count"]}, "datasource_type": "table"},
|
||||
]
|
||||
|
||||
checker = DatasetHealthChecker(mock_client)
|
||||
@@ -1520,13 +1564,10 @@ class TestDatasetHealthChecker:
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.network.request.return_value = {"result": []}
|
||||
mock_client.get_dataset.return_value = {
|
||||
"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}
|
||||
}
|
||||
mock_client.get_dataset.return_value = {"result": {"table_name": "test", "database": {"database_name": "db", "backend": "postgresql"}}}
|
||||
|
||||
chart_list = [
|
||||
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101,
|
||||
"viz_type": "table", "params": '{"metrics": ["count"]}', "datasource_type": "table"},
|
||||
{"slice_id": 1, "slice_name": "Chart 1", "datasource_id": 101, "viz_type": "table", "params": '{"metrics": ["count"]}', "datasource_type": "table"},
|
||||
]
|
||||
|
||||
checker = DatasetHealthChecker(mock_client)
|
||||
@@ -1547,6 +1588,7 @@ class TestDatasetHealthChecker:
|
||||
|
||||
# ── RedactionService Tests ──
|
||||
|
||||
|
||||
class TestRedactionService:
|
||||
"""Verify RedactionService."""
|
||||
|
||||
@@ -1572,6 +1614,7 @@ class TestRedactionService:
|
||||
|
||||
def test_redact_logs_empty(self):
|
||||
from src.plugins.llm_analysis.service import RedactionService
|
||||
|
||||
assert RedactionService.redact_logs([]) == []
|
||||
|
||||
def test_redact_raw_response(self):
|
||||
@@ -1610,4 +1653,6 @@ class TestRedactionService:
|
||||
safe = '{"status": "PASS", "summary": "OK"}'
|
||||
redacted = RedactionService.redact_raw_response(safe)
|
||||
assert redacted == safe
|
||||
|
||||
|
||||
# #endregion Test.LLMAnalysisService
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
# @TEST_EDGE: empty_content -> ValueError
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
@@ -27,35 +29,16 @@ from src.plugins.translate._llm_async_http import (
|
||||
class TestGetVerify:
|
||||
"""_get_verify — SSL verification context."""
|
||||
|
||||
def test_verify_true_default(self):
|
||||
"""Default (true) returns SSLContext."""
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "true"}, clear=True):
|
||||
result = _get_verify()
|
||||
assert isinstance(result, bool) is False # It's an SSLContext
|
||||
def test_returns_ssl_context(self):
|
||||
"""Always returns SSLContext (no env-based disable)."""
|
||||
result = _get_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_verify_false(self):
|
||||
"""False returns False."""
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}, clear=True):
|
||||
def test_ignores_llm_ssl_verify_env(self):
|
||||
"""LLM_SSL_VERIFY env is no longer read — central ssl helper is used."""
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}):
|
||||
result = _get_verify()
|
||||
assert result is False
|
||||
|
||||
def test_verify_off(self):
|
||||
"""'off' returns False."""
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "off"}, clear=True):
|
||||
result = _get_verify()
|
||||
assert result is False
|
||||
|
||||
def test_verify_0(self):
|
||||
"""'0' returns False."""
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "0"}, clear=True):
|
||||
result = _get_verify()
|
||||
assert result is False
|
||||
|
||||
def test_verify_no(self):
|
||||
"""'no' returns False."""
|
||||
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "no"}, clear=True):
|
||||
result = _get_verify()
|
||||
assert result is False
|
||||
assert isinstance(result, ssl.SSLContext), "must not return False"
|
||||
|
||||
|
||||
class TestGetHttpClient:
|
||||
@@ -101,12 +84,13 @@ class TestCallOpenaiCompatible:
|
||||
}
|
||||
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "Hello, world!"}}]}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
content, finish_reason = await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "translate this",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"translate this",
|
||||
)
|
||||
assert content == "Hello, world!"
|
||||
assert finish_reason == "stop"
|
||||
@@ -120,13 +104,14 @@ class TestCallOpenaiCompatible:
|
||||
mock_response.json.return_value = {"choices": []}
|
||||
mock_response.text = '{"choices": []}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
with pytest.raises(ValueError, match="no choices"):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "translate this",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"translate this",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -145,13 +130,14 @@ class TestCallOpenaiCompatible:
|
||||
}
|
||||
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": ""}}]}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
with pytest.raises(ValueError, match="empty content"):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "translate this",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"translate this",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -165,13 +151,14 @@ class TestCallOpenaiCompatible:
|
||||
}
|
||||
mock_response.text = '{"choices": [null]}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
with pytest.raises(ValueError, match="LLM response processing failed"):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "translate this",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"translate this",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -190,13 +177,14 @@ class TestCallOpenaiCompatible:
|
||||
}
|
||||
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"refusal": "I cannot answer that", "content": null}}]}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
with pytest.raises(ValueError, match="refused"):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "translate this",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"translate this",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -206,17 +194,20 @@ class TestCallOpenaiCompatible:
|
||||
mock_response.is_success = False
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server error", request=MagicMock(), response=mock_response,
|
||||
"Server error",
|
||||
request=MagicMock(),
|
||||
response=mock_response,
|
||||
)
|
||||
mock_response.text = "Internal Server Error"
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "translate this",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"translate this",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -231,14 +222,16 @@ class TestCallOpenaiCompatible:
|
||||
}
|
||||
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
# disable_reasoning=False, so response_format should be set
|
||||
content, _ = await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "test",
|
||||
provider_type="openai", disable_reasoning=False,
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"test",
|
||||
provider_type="openai",
|
||||
disable_reasoning=False,
|
||||
)
|
||||
assert content == "hi"
|
||||
|
||||
@@ -254,12 +247,13 @@ class TestCallOpenaiCompatible:
|
||||
}
|
||||
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}'
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request",
|
||||
AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
|
||||
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
|
||||
content, _ = await call_openai_compatible(
|
||||
"https://api.openai.com", "sk-test", "gpt-4o", "test",
|
||||
"https://api.openai.com",
|
||||
"sk-test",
|
||||
"gpt-4o",
|
||||
"test",
|
||||
disable_reasoning=True,
|
||||
)
|
||||
assert content == "hi"
|
||||
@@ -275,8 +269,7 @@ class TestDoHttpRequest:
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = "OK"
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.return_value = mock_response
|
||||
@@ -301,14 +294,12 @@ class TestDoHttpRequest:
|
||||
mock_200.status_code = 200
|
||||
mock_200.text = "OK"
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.side_effect = [mock_429, mock_200]
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()):
|
||||
response, text = await _do_http_request(
|
||||
"https://api.openai.com/chat/completions",
|
||||
{"Authorization": "Bearer test"},
|
||||
@@ -330,14 +321,12 @@ class TestDoHttpRequest:
|
||||
mock_200.status_code = 200
|
||||
mock_200.text = "OK"
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.side_effect = [mock_429, mock_200]
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
|
||||
AsyncMock()) as mock_sleep:
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep:
|
||||
response, _ = await _do_http_request(
|
||||
"https://api.openai.com/chat/completions",
|
||||
{"Authorization": "Bearer test"},
|
||||
@@ -355,14 +344,12 @@ class TestDoHttpRequest:
|
||||
mock_429.headers = {}
|
||||
mock_429.text = "Rate limited"
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.return_value = mock_429 # Always 429
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
|
||||
AsyncMock()):
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()):
|
||||
response, _ = await _do_http_request(
|
||||
"https://api.openai.com/chat/completions",
|
||||
{"Authorization": "Bearer test"},
|
||||
@@ -383,14 +370,12 @@ class TestDoHttpRequest:
|
||||
mock_200.status_code = 200
|
||||
mock_200.text = "OK"
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.side_effect = [mock_429, mock_200]
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep",
|
||||
AsyncMock()) as mock_sleep:
|
||||
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep:
|
||||
response, _ = await _do_http_request(
|
||||
"https://api.openai.com/chat/completions",
|
||||
{"Authorization": "Bearer test"},
|
||||
@@ -421,15 +406,17 @@ class TestHandleResponseFormatFallback:
|
||||
|
||||
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
mock_client.post.return_value = mock_200
|
||||
|
||||
await _handle_response_format_fallback(
|
||||
mock_400, mock_400.text, payload,
|
||||
"https://api.openai.com", {},
|
||||
mock_400,
|
||||
mock_400.text,
|
||||
payload,
|
||||
"https://api.openai.com",
|
||||
{},
|
||||
)
|
||||
# Verify response_format was popped
|
||||
assert "response_format" not in payload
|
||||
@@ -446,14 +433,16 @@ class TestHandleResponseFormatFallback:
|
||||
|
||||
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
await _handle_response_format_fallback(
|
||||
mock_500, mock_500.text, payload,
|
||||
"https://api.openai.com", {},
|
||||
mock_500,
|
||||
mock_500.text,
|
||||
payload,
|
||||
"https://api.openai.com",
|
||||
{},
|
||||
)
|
||||
# Payload unchanged
|
||||
assert "response_format" in payload
|
||||
@@ -470,15 +459,19 @@ class TestHandleResponseFormatFallback:
|
||||
|
||||
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
|
||||
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client",
|
||||
AsyncMock()) as mock_get:
|
||||
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
await _handle_response_format_fallback(
|
||||
mock_400, mock_400.text, payload,
|
||||
"https://api.openai.com", {},
|
||||
mock_400,
|
||||
mock_400.text,
|
||||
payload,
|
||||
"https://api.openai.com",
|
||||
{},
|
||||
)
|
||||
assert "response_format" in payload
|
||||
mock_client.post.assert_not_called()
|
||||
|
||||
|
||||
# #endregion Test.LLMAsyncHttpClient
|
||||
|
||||
@@ -56,10 +56,6 @@ services:
|
||||
FEATURES__HEALTH_MONITOR: ${FEATURES__HEALTH_MONITOR:-true}
|
||||
# Путь до сертификатов внутри контейнера — всегда /opt/certs
|
||||
CERTS_PATH: /opt/certs
|
||||
# URL CA-сертификатов для LLM-провайдеров (скачка на старте, DER→PEM конвертация)
|
||||
LLM_CA_CERT_URLS: ${LLM_CA_CERT_URLS:-}
|
||||
# Отключить SSL verify для LLM (когда корп. сертификаты не установлены в контейнере)
|
||||
LLM_SSL_VERIFY: ${LLM_SSL_VERIFY:-true}
|
||||
ports:
|
||||
- "${BACKEND_HOST_PORT:-8001}:8000"
|
||||
volumes:
|
||||
@@ -102,11 +98,14 @@ services:
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-https://api.openai.com/v1}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
|
||||
FASTAPI_URL: http://backend:8000
|
||||
CERTS_PATH: /opt/certs
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.enterprise-clean}
|
||||
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools}
|
||||
GRADIO_SERVER_PORT: 7860
|
||||
ports:
|
||||
- "${AGENT_HOST_PORT:-7860}:7860"
|
||||
volumes:
|
||||
- ${CERTS_PATH:-./certs}:/opt/certs:ro
|
||||
|
||||
# #endregion docker.compose.enterprise-clean
|
||||
|
||||
@@ -37,12 +37,11 @@ services:
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||
FEATURES__DATASET_REVIEW: ${FEATURES__DATASET_REVIEW:-true}
|
||||
FEATURES__HEALTH_MONITOR: ${FEATURES__HEALTH_MONITOR:-true}
|
||||
LLM_CA_CERT_URLS: ${LLM_CA_CERT_URLS:-}
|
||||
LLM_SSL_VERIFY: ${LLM_SSL_VERIFY:-true}
|
||||
ports:
|
||||
- "${BACKEND_HOST_PORT:-8001}:8000"
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ${CERTS_PATH:-./certs}:/opt/certs:ro
|
||||
|
||||
agent:
|
||||
build:
|
||||
@@ -65,6 +64,8 @@ services:
|
||||
GRADIO_SERVER_PORT: 7860
|
||||
ports:
|
||||
- "7860" # internal only, proxied through nginx
|
||||
volumes:
|
||||
- ${CERTS_PATH:-./certs}:/opt/certs:ro
|
||||
|
||||
frontend:
|
||||
build:
|
||||
|
||||
@@ -16,9 +16,9 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps for pdfplumber + psycopg (v3 needs libpq)
|
||||
# Install system deps for pdfplumber + psycopg (v3 needs libpq) + certs
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 libglib2.0-0 libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
libgl1 libglib2.0-0 libpq5 ca-certificates openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python dependencies — agent runtime (gradio, langgraph, httpx, pdfplumber, ...)
|
||||
# Без sentence-transformers — embedding router безопасно отключается при ImportError.
|
||||
@@ -47,10 +47,15 @@ COPY backend/src/core/logger.py /app/backend/src/core/logger.py
|
||||
COPY backend/src/core/ws_log_handler.py /app/backend/src/core/ws_log_handler.py
|
||||
COPY backend/src/agent/ /app/backend/src/agent/
|
||||
|
||||
# Entrypoint for corporate CA cert installation
|
||||
COPY docker/certs.sh /app/docker/certs.sh
|
||||
COPY docker/agent.entrypoint.sh /app/docker/agent.entrypoint.sh
|
||||
RUN chmod +x /app/docker/agent.entrypoint.sh /app/docker/certs.sh
|
||||
|
||||
# Gradio server
|
||||
ENV GRADIO_SERVER_NAME=0.0.0.0
|
||||
ENV GRADIO_SERVER_PORT=7860
|
||||
|
||||
WORKDIR /app/backend
|
||||
CMD ["python", "-m", "src.agent.run"]
|
||||
ENTRYPOINT ["/app/docker/agent.entrypoint.sh"]
|
||||
# #endregion docker.agent.Dockerfile
|
||||
|
||||
18
docker/agent.entrypoint.sh
Normal file
18
docker/agent.entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# #region docker.agent.entrypoint [C:3] [TYPE Module] [SEMANTICS docker,agent,entrypoint,certs]
|
||||
# @BRIEF Agent container entrypoint — install corporate CA certs, then start agent.
|
||||
# @LAYER Infrastructure
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CERTS_PATH="${CERTS_PATH:-/opt/certs}"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/certs.sh" ]; then
|
||||
source "$SCRIPT_DIR/certs.sh"
|
||||
install_all_certs
|
||||
else
|
||||
echo "[agent-entry] certs.sh not found — skipping corporate CA installation"
|
||||
fi
|
||||
|
||||
exec python -m src.agent.run
|
||||
# #endregion docker.agent.entrypoint
|
||||
@@ -497,8 +497,9 @@ except Exception as exc:
|
||||
# MAIN
|
||||
# ======================================================================
|
||||
|
||||
install_certificates
|
||||
install_llm_ca_certs
|
||||
source "$(dirname "$0")/certs.sh"
|
||||
|
||||
install_all_certs
|
||||
install_ca_to_nss
|
||||
|
||||
# ── C1: Wait for database before any migration logic ──
|
||||
|
||||
158
docker/certs.sh
Normal file
158
docker/certs.sh
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# #region docker.certs [C:3] [TYPE Module] [SEMANTICS docker,certs,ssl,ca,trust]
|
||||
# @BRIEF Shared certificate installation functions for all containers.
|
||||
# @LAYER Infrastructure
|
||||
# @PRE CERTS_PATH points to mounted /opt/certs directory (may be empty or absent).
|
||||
# @POST Corporate CA certificates installed into system trust store and NSS DB.
|
||||
# @INVARIANT server.crt, server.key, server.p12, *.key, *.p12, *.pfx are never imported as CA.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── normalize_cert – PEM/DER detection + conversion ──────────────────
|
||||
|
||||
normalize_cert() {
|
||||
local input="$1"
|
||||
local output="$2"
|
||||
if openssl x509 -in "$input" -inform PEM -noout 2>/dev/null; then
|
||||
cp "$input" "$output"
|
||||
return 0
|
||||
fi
|
||||
if openssl x509 -in "$input" -inform DER -noout 2>/dev/null; then
|
||||
openssl x509 -in "$input" -inform DER -out "$output" -outform PEM 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── install_all_certs – single unified entry point ───────────────────
|
||||
|
||||
install_all_certs() {
|
||||
local certs_dir="${CERTS_PATH:-/opt/certs}"
|
||||
if [ ! -d "$certs_dir" ]; then
|
||||
echo "[certs] CERTS_PATH=${certs_dir} не существует – пропускаем"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local target="/usr/local/share/ca-certificates/custom"
|
||||
mkdir -p "$target"
|
||||
|
||||
echo "[certs] Устанавливаем корпоративные сертификаты из ${certs_dir}..."
|
||||
local installed=0 skipped=0 invalid=0
|
||||
|
||||
for f in "$certs_dir"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
local name
|
||||
name="$(basename "$f")"
|
||||
|
||||
# Skip non-CA server/private files
|
||||
case "$(echo "$name" | tr '[:upper:]' '[:lower:]')" in
|
||||
server.crt|server.key|server.pem|*.key|*.p12|*.pfx)
|
||||
echo "[certs] ⏭️ пропускаем: ${name} (server/private файл)"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Accept .crt, .pem, .cer, .der
|
||||
case "$(echo "$name" | tr '[:upper:]' '[:lower:]')" in
|
||||
*.crt|*.pem|*.cer|*.der)
|
||||
# valid cert extension
|
||||
;;
|
||||
*)
|
||||
echo "[certs] ⚠️ неизвестный формат: ${name} – пропускаем"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
local out_name="${name%.*}.crt"
|
||||
local out_file="${target}/${out_name}"
|
||||
|
||||
if normalize_cert "$f" "$out_file"; then
|
||||
echo "[certs] ✅ ${name} (→ ${out_name})"
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
echo "[certs] ❌ невалидный сертификат: ${name}"
|
||||
invalid=$((invalid + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
echo "[certs] Нет CA-сертификатов для установки"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[certs] Установлено: ${installed}, пропущено: ${skipped}, невалидно: ${invalid}"
|
||||
|
||||
# Update system CA store
|
||||
if command -v update-ca-certificates &>/dev/null; then
|
||||
echo "[certs] Обновляем системное хранилище CA-сертификатов..."
|
||||
update-ca-certificates --fresh 2>&1 | sed 's/^/[certs] /'
|
||||
fi
|
||||
|
||||
# Create hash symlinks for OpenSSL capath
|
||||
echo "[certs] Создаём хеш-симлинки..."
|
||||
local sym_count=0
|
||||
for cert_file in "$target"/*.crt; do
|
||||
[ -f "$cert_file" ] || continue
|
||||
local cert_name
|
||||
cert_name="$(basename "$cert_file" .crt)"
|
||||
local hash_val
|
||||
hash_val="$(openssl x509 -in "$cert_file" -noout -hash 2>/dev/null || true)"
|
||||
if [ -n "$hash_val" ]; then
|
||||
local suffix=0
|
||||
local link_path="/etc/ssl/certs/${hash_val}.${suffix}"
|
||||
while [ -L "$link_path" ]; do
|
||||
if [ "$(readlink "$link_path")" = "$cert_file" ]; then
|
||||
break 2
|
||||
fi
|
||||
suffix=$((suffix + 1))
|
||||
link_path="/etc/ssl/certs/${hash_val}.${suffix}"
|
||||
done
|
||||
if [ ! -L "$link_path" ]; then
|
||||
ln -sf "$cert_file" "$link_path"
|
||||
sym_count=$((sym_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "[certs] Создано хеш-симлинок: ${sym_count}"
|
||||
}
|
||||
|
||||
# ── install_ca_to_nss – NSS DB for Chromium/Playwright ──────────────
|
||||
|
||||
install_ca_to_nss() {
|
||||
if ! command -v certutil &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
local target="/usr/local/share/ca-certificates/custom"
|
||||
if [ ! -d "$target" ] || [ -z "$(ls -A "$target" 2>/dev/null)" ]; then
|
||||
return 0
|
||||
fi
|
||||
local nssdb="${HOME}/.pki/nssdb"
|
||||
mkdir -p "$nssdb"
|
||||
local nss_count=0
|
||||
echo "[certs] Импортируем сертификаты в NSS DB..."
|
||||
for cert_file in "$target"/*.crt; do
|
||||
[ -f "$cert_file" ] || continue
|
||||
local cert_name
|
||||
cert_name="$(basename "$cert_file" .crt)"
|
||||
certutil -A -d "sql:${nssdb}" -t "C,," -n "custom-${cert_name}" -i "$cert_file" 2>/dev/null && nss_count=$((nss_count + 1)) || true
|
||||
done
|
||||
echo "[certs] Импортировано в NSS: ${nss_count}"
|
||||
}
|
||||
|
||||
# ── skip_server_files – remove server certs from CA dir ──────────────
|
||||
|
||||
skip_server_files_filter() {
|
||||
# Used by frontend to skip server.crt from being installed as CA
|
||||
local certs_dir="${1:-/opt/certs}"
|
||||
[ -d "$certs_dir" ] || return 0
|
||||
for f in "$certs_dir"/*.crt "$certs_dir"/*.pem; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$(basename "$f" | tr '[:upper:]' '[:lower:]')" in
|
||||
server.crt|server.key) continue ;;
|
||||
esac
|
||||
echo "$f"
|
||||
done
|
||||
}
|
||||
# #endregion docker.certs
|
||||
@@ -46,12 +46,7 @@ def section(title: str) -> None:
|
||||
def check_env() -> None:
|
||||
section("1. Environment variables")
|
||||
|
||||
for var in (
|
||||
"ENCRYPTION_KEY",
|
||||
"AUTH_SECRET_KEY",
|
||||
"LLM_SSL_VERIFY",
|
||||
"LLM_CA_CERT_URLS",
|
||||
):
|
||||
for var in ("ENCRYPTION_KEY", "AUTH_SECRET_KEY", "CERTS_PATH"):
|
||||
val = os.getenv(var, "")
|
||||
if not val:
|
||||
warn(f"{var} is NOT set")
|
||||
@@ -64,25 +59,36 @@ def check_env() -> None:
|
||||
fp = hashlib.sha256(os.getenv("ENCRYPTION_KEY", "").encode()).hexdigest()[:8]
|
||||
print(f"\n Current key fingerprint: {BOLD}sha256:{fp}{RESET}")
|
||||
|
||||
if os.getenv("LLM_SSL_VERIFY", "").lower() in ("false", "0", "no", "off"):
|
||||
fail(
|
||||
"LLM_SSL_VERIFY is DISABLED — SSL verification bypassed.\n"
|
||||
" This masks the root cause. Remove LLM_SSL_VERIFY=false\n"
|
||||
" and fix cert installation instead."
|
||||
)
|
||||
else:
|
||||
ok(
|
||||
"LLM_SSL_VERIFY is ENABLED (capath approach).\n"
|
||||
" If LLM calls fail, the certificate for the target server\n"
|
||||
" is not in /etc/ssl/certs/."
|
||||
)
|
||||
|
||||
# ── Section 2: CERTS_PATH inventory ──────────────────────────────────
|
||||
|
||||
|
||||
# ── Section 2: System CA store ──────────────────────────────────────
|
||||
def check_certs_inventory() -> None:
|
||||
section("2. CERTS_PATH inventory (/opt/certs)")
|
||||
|
||||
from src.core.ssl import cert_dir_inventory
|
||||
|
||||
inv = cert_dir_inventory("/opt/certs")
|
||||
if not inv["trust_candidates"] and not inv["server_files"]:
|
||||
warn("CERTS_PATH is empty or not mounted — no corporate CA certs found")
|
||||
print(
|
||||
" Put .crt/.pem/.cer/.der CA files in ./certs/ on the host and restart containers."
|
||||
)
|
||||
return
|
||||
|
||||
for name in inv["trust_candidates"]:
|
||||
ok(f"Trust candidate: {Path(name).name}")
|
||||
for name in inv["server_files"]:
|
||||
ok(f"Server file (skipped as CA): {Path(name).name}")
|
||||
for name in inv["invalid"]:
|
||||
warn(f"Unrecognized file (skipped): {Path(name).name}")
|
||||
|
||||
|
||||
# ── Section 3: System CA store ──────────────────────────────────────
|
||||
|
||||
|
||||
def check_system_certs() -> None:
|
||||
section("2. System CA store (/etc/ssl/certs/)")
|
||||
section("3. System CA store (/etc/ssl/certs/)")
|
||||
|
||||
bundle = Path("/etc/ssl/certs/ca-certificates.crt")
|
||||
if not bundle.exists():
|
||||
@@ -96,78 +102,38 @@ def check_system_certs() -> None:
|
||||
).stdout.strip()
|
||||
ok(f"ca-certificates.crt exists, {cert_count} certificates in bundle")
|
||||
|
||||
certs_dir = Path("/etc/ssl/certs")
|
||||
custom_pems = list(Path("/usr/local/share/ca-certificates").rglob("*.crt"))
|
||||
llm_pems = list(Path("/usr/local/share/ca-certificates/llm").rglob("*.crt"))
|
||||
|
||||
custom_pems = list(Path("/usr/local/share/ca-certificates/custom").rglob("*.crt"))
|
||||
if custom_pems:
|
||||
names = [p.name for p in custom_pems]
|
||||
ok(f"Installed CA certs: {', '.join(names)}")
|
||||
else:
|
||||
warn("No custom .crt files in /usr/local/share/ca-certificates/")
|
||||
warn("No custom .crt files in /usr/local/share/ca-certificates/custom/")
|
||||
|
||||
if llm_pems:
|
||||
names = [p.name for p in llm_pems]
|
||||
ok(f"LLM CA certs: {', '.join(names)}")
|
||||
else:
|
||||
warn(
|
||||
"No LLM CA certs downloaded.\n"
|
||||
" LLM_CA_CERT_URLS is not set — set it in .env.enterprise-clean\n"
|
||||
" or place the CA .crt in ./certs/ on the host."
|
||||
)
|
||||
|
||||
# Check hash symlinks
|
||||
certs_dir = Path("/etc/ssl/certs")
|
||||
hash_links = [p for p in certs_dir.iterdir() if p.is_symlink()]
|
||||
llm_links = [l for l in hash_links if "llm" in str(Path(os.readlink(str(l))))]
|
||||
if llm_links:
|
||||
ok(f"Hash symlinks for LLM certs: {len(llm_links)}")
|
||||
custom_links = [l for l in hash_links if "custom" in str(Path(os.readlink(str(l))))]
|
||||
if custom_links:
|
||||
ok(f"Hash symlinks for custom certs: {len(custom_links)}")
|
||||
else:
|
||||
warn("No hash symlinks pointing to LLM cert dir — capath may not find them")
|
||||
warn("No hash symlinks for custom certs — capath may not find them")
|
||||
|
||||
|
||||
# ── Section 3: OpenSSL connectivity ──────────────────────────────────
|
||||
# ── Section 4: OpenSSL connectivity ──────────────────────────────────
|
||||
|
||||
|
||||
def check_openssl(target: str) -> None:
|
||||
section(f"3. OpenSSL connectivity ({target})")
|
||||
section(f"4. OpenSSL connectivity ({target})")
|
||||
|
||||
# cafile approach (expected to FAIL with code 20 per ADR-0009 Finding 7)
|
||||
cafile = "/etc/ssl/certs/ca-certificates.crt"
|
||||
r = subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"s_client",
|
||||
"-connect",
|
||||
target,
|
||||
"-servername",
|
||||
target.split(":")[0],
|
||||
"-CAfile",
|
||||
cafile,
|
||||
],
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if "Verify return code: 0" in r.stderr or "Verify return code: 0" in r.stdout:
|
||||
ok(f"cafile: verify OK (unexpected — usually fails per ADR-0009 Finding 7)")
|
||||
elif "Verify return code: 20" in (r.stderr + r.stdout):
|
||||
warn(
|
||||
f"cafile: verify code 20 (EXPECTED per ADR-0009 — intermediate CA ignored)"
|
||||
)
|
||||
else:
|
||||
fail(f"cafile: unexpected output, returncode={r.returncode}")
|
||||
|
||||
# capath approach (should succeed)
|
||||
capath = "/etc/ssl/certs/"
|
||||
host, port = _parse_target(target)
|
||||
r = subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"s_client",
|
||||
"-connect",
|
||||
target,
|
||||
f"{host}:{port}",
|
||||
"-servername",
|
||||
target.split(":")[0],
|
||||
host,
|
||||
"-CApath",
|
||||
capath,
|
||||
],
|
||||
@@ -181,21 +147,31 @@ def check_openssl(target: str) -> None:
|
||||
else:
|
||||
fail(
|
||||
f"capath: verify FAILED.\n"
|
||||
f" The CA certificate for {target} is NOT in /etc/ssl/certs/.\n"
|
||||
f" Add it via LLM_CA_CERT_URLS or ./certs/ on the host."
|
||||
f" The CA certificate for {host} is NOT in /etc/ssl/certs/.\n"
|
||||
f" Put the issuing/root CA .crt into CERTS_PATH (./certs)\n"
|
||||
f" and restart the container."
|
||||
)
|
||||
|
||||
|
||||
# ── Section 4: Python SSL context ────────────────────────────────────
|
||||
def _parse_target(target: str) -> tuple[str, int]:
|
||||
if "://" in target:
|
||||
target = target.split("://")[1]
|
||||
if ":" in target:
|
||||
host, port = target.rsplit(":", 1)
|
||||
return host, int(port)
|
||||
return target, 443
|
||||
|
||||
|
||||
# ── Section 5: Python SSL context ────────────────────────────────────
|
||||
|
||||
|
||||
def check_python_ssl(target: str) -> None:
|
||||
section(f"4. Python SSL context ({target})")
|
||||
section(f"5. Python SSL context ({target})")
|
||||
|
||||
# capath approach (what _get_ssl_verify() uses)
|
||||
ctx = ssl.create_default_context(capath="/etc/ssl/certs/")
|
||||
host, port = target.split(":") if ":" in target else (target, 443)
|
||||
port = int(port)
|
||||
from src.core.ssl import system_ssl_context
|
||||
|
||||
ctx = system_ssl_context()
|
||||
host, port = _parse_target(target)
|
||||
|
||||
try:
|
||||
with ctx.wrap_socket(__import__("socket").socket(), server_hostname=host) as s:
|
||||
@@ -208,16 +184,17 @@ def check_python_ssl(target: str) -> None:
|
||||
fail(f"SSLContext(capath): FAILED — {e}")
|
||||
|
||||
|
||||
# ── Section 5: httpx connectivity ────────────────────────────────────
|
||||
# ── Section 6: httpx connectivity ────────────────────────────────────
|
||||
|
||||
|
||||
def check_httpx(target: str) -> None:
|
||||
section(f"5. httpx connectivity ({target})")
|
||||
section(f"6. httpx connectivity ({target})")
|
||||
|
||||
try:
|
||||
import httpx
|
||||
from src.core.ssl import httpx_verify
|
||||
|
||||
ctx = ssl.create_default_context(capath="/etc/ssl/certs/")
|
||||
ctx = httpx_verify()
|
||||
url = f"https://{target}" if not target.startswith("http") else target
|
||||
r = httpx.get(url, verify=ctx, timeout=10, follow_redirects=True)
|
||||
ok(f"httpx(capath): HTTP {r.status_code}")
|
||||
@@ -227,11 +204,11 @@ def check_httpx(target: str) -> None:
|
||||
fail(f"httpx(capath): FAILED — {e}")
|
||||
|
||||
|
||||
# ── Section 6: Encryption health ─────────────────────────────────────
|
||||
# ── Section 7: Encryption health ─────────────────────────────────────
|
||||
|
||||
|
||||
def check_encryption_health() -> None:
|
||||
section("6. Encryption key health (internal)")
|
||||
section("7. Encryption key health (internal)")
|
||||
|
||||
sys.path.insert(0, "/app/backend")
|
||||
try:
|
||||
@@ -246,17 +223,15 @@ def check_encryption_health() -> None:
|
||||
ok(f"LLM providers found: {len(providers)}")
|
||||
for p in providers:
|
||||
if not p.api_key:
|
||||
warn(f" {p.name}: api_key is EMPTY — no key stored")
|
||||
warn(f" {p.name}: api_key is EMPTY")
|
||||
continue
|
||||
if not is_fernet_token(p.api_key):
|
||||
warn(
|
||||
f" {p.name}: api_key is NOT a fernet token — stored unencrypted?"
|
||||
)
|
||||
warn(f" {p.name}: api_key is NOT a fernet token")
|
||||
continue
|
||||
try:
|
||||
mgr.decrypt(p.api_key)
|
||||
ok(f" {p.name}: api_key decrypts OK (healthy)")
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
fail(
|
||||
f" {p.name}: api_key decrypt FAILED — "
|
||||
f"encrypted with different ENCRYPTION_KEY"
|
||||
@@ -291,6 +266,7 @@ def main() -> None:
|
||||
print(f"Target: {target}\n")
|
||||
|
||||
check_env()
|
||||
check_certs_inventory()
|
||||
check_system_certs()
|
||||
check_openssl(target)
|
||||
check_python_ssl(target)
|
||||
@@ -299,16 +275,11 @@ def main() -> None:
|
||||
|
||||
section("Summary")
|
||||
print(
|
||||
"\nExpected (per ADR-0009):\n"
|
||||
" - openssl CAfile: code 20 FAIL (intermediate CA ignored)\n"
|
||||
" - openssl CApath: code 0 OK\n"
|
||||
" - Python SSLContext(capath): OK\n"
|
||||
" - httpx(capath): HTTP 200 OK\n"
|
||||
"\nIf CApath/httpx failures:\n"
|
||||
" → LLM_CA_CERT_URLS not set → certs not downloaded\n"
|
||||
" → set LLM_CA_CERT_URLS or add .crt to ./certs/\n"
|
||||
"\nIf cert verification failures:\n"
|
||||
" → Put the issuing/root CA for target into CERTS_PATH (./certs)\n"
|
||||
" → Restart affected containers\n"
|
||||
" → Rerun this diagnostic\n"
|
||||
)
|
||||
|
||||
print(
|
||||
"\nIf ENCRYPTION_KEY health failures:\n"
|
||||
" → Stored secrets encrypted with old key\n"
|
||||
|
||||
Reference in New Issue
Block a user