fix(agent): full-catalog dashboard search and working LLM retry

- search_dashboards: call /api/dashboards with page_context=other and
  page_size=100 so the profile 'My Dashboards Only' filter can no longer
  hide the whole catalog; parse available_total/effective_profile_filter
  and report hidden-by-filter instead of a false 'no dashboards' answer
- prefetch_dashboards: same full-catalog context; fix dead code where
  data=resp.json() sat after return '' inside the error branch, making
  every 200 response raise NameError and the prefetch always return ''
- llm-status: ?force=1 bypasses the 30s health cache so the 'Retry now'
  button performs a fresh probe instead of re-reading the stale status;
  frontend keeps a single retry interval (previously stacked intervals
  decayed the countdown faster than 1/s and fired duplicate probes)
- tests: agent tool/prefetch, backend route bypass + force param,
  frontend retry/force coverage
This commit is contained in:
2026-08-19 19:21:00 +03:00
parent 615f3ccd25
commit e291ba757f
11 changed files with 396 additions and 35 deletions

View File

@@ -206,30 +206,34 @@ async def prefetch_dashboards(env_id: str) -> str:
client = get_shared_http_client(timeout=10)
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params={"q": "", "env_id": env_id or ""},
# Full-catalog context: page_context=other disables the "My Dashboards Only"
# profile filter, so prefetch reflects the whole environment instead of the
# user's filtered view (which would report "No dashboards found." for every
# dashboard without matching owner metadata). page_size=100 avoids truncation.
params={"q": "", "env_id": env_id or "", "page_context": "other", "page_size": 100},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = data.get("total") or len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception as e:
logger.explore("Prefetch dashboards failed", payload={"env_id": env_id}, error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDashboards"})
return ""

View File

@@ -367,13 +367,26 @@ class SearchDashboardsInput(BaseModel):
# @BRIEF Search and list dashboards by name, with optional environment filter.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns formatted dashboard list string.
# @POST Response surfaces effective profile filter metadata so hidden dashboards are never reported as absent.
# @RATIONALE Agent search must see the full environment catalog: without page_context=other the backend
# applies the UI "My Dashboards Only" profile filter, which hides every dashboard whose owners/modified_by
# do not match the bound Superset user — including all dashboards lacking owner metadata — and the tool
# previously discarded available_total/effective_profile_filter, producing a false "no dashboards" answer.
# @SIDE_EFFECT HTTP GET to FastAPI /api/dashboards.
@tool(args_schema=SearchDashboardsInput)
async def search_dashboards(query: str, env_id: str | None = None) -> str:
"""Search and list dashboards by name, with optional environment filter."""
logger.reason("Search dashboards", payload={"query": query, "env_id": env_id},
extra={"src": "AgentChat.Tools.SearchDashboards"})
params = {"q": query, "env_id": env_id or ""}
# Full-catalog search: page_context=other disables the profile-default filter,
# page_size=100 avoids truncation for environments with more dashboards than
# the default page size (10).
params = {
"q": query,
"env_id": env_id or "",
"page_context": "other",
"page_size": 100,
}
resp = await _get("/api/dashboards", params=params)
if resp.status_code != 200:
logger.explore("Dashboard search failed",
@@ -386,14 +399,28 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
data = resp.json()
dashboards = data.get("dashboards", [])
total = data.get("total", 0)
available_total = data.get("available_total")
profile_filter = data.get("effective_profile_filter") or {}
filter_applied = bool(profile_filter.get("applied"))
env_label = env_id or "default"
if total == 0:
if isinstance(available_total, int) and available_total > 0:
logger.reflect("Dashboards hidden by profile filter",
payload={"query": query, "total": 0, "available_total": available_total, "filter_applied": filter_applied},
extra={"src": "AgentChat.Tools.SearchDashboards"})
return (
f"Found {available_total} dashboard(s) in environment '{env_label}', but all are hidden "
f"by the profile filter 'My Dashboards Only' (bound username: "
f"{profile_filter.get('username') or 'unknown'}). No dashboards are listed; "
f"use override_show_all=true or page_context=other to see the full catalog."
)
logger.reflect("No dashboards found",
payload={"query": query, "total": 0},
extra={"src": "AgentChat.Tools.SearchDashboards"})
return f"No dashboards found matching '{query}' in environment '{env_id or 'default'}'."
return f"No dashboards found matching '{query}' in environment '{env_label}'."
lines = [f"Found {total} dashboard(s) in environment '{env_id or 'default'}':"]
lines = [f"Found {total} dashboard(s) in environment '{env_label}':"]
for db in dashboards:
title = db.get("title", "Untitled")
owners = ", ".join(db.get("owners", [])) or "N/A"
@@ -401,6 +428,15 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
lines.append(f" - {title}")
lines.append(f" Owners: {owners}")
lines.append(f" Last modified: {modified}")
if filter_applied:
lines.append(
f"(Note: profile filter 'My Dashboards Only' active — showing only dashboards "
f"matching {profile_filter.get('username') or 'bound user'})"
)
if len(dashboards) < total:
lines.append(
f"(Note: showing first {len(dashboards)} of {total} dashboards — refine the query or paginate for more)"
)
logger.reflect("Dashboards listed",
payload={"query": query, "total": total},
extra={"src": "AgentChat.Tools.SearchDashboards"})

View File

@@ -228,6 +228,99 @@ async def test_search_dashboards_correct_url():
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards" in url
# Agent searches the full catalog: profile-default filter must be disabled
# and the page size raised above the default (10) to avoid truncation.
params = kwargs.get("params", {})
assert params.get("page_context") == "other"
assert params.get("page_size") == 100
assert params.get("q") == "dashboard-name"
assert params.get("env_id") == "prod"
@pytest.mark.anyio
async def test_search_dashboards_surfaces_profile_filter_hidden():
"""total=0 with available_total>0 must report hidden dashboards, not absence."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [],
"total": 0,
"available_total": 11,
"effective_profile_filter": {
"applied": True,
"username": "admin",
"match_logic": "owners_or_modified_by",
},
}
mock_resp = Mock(status_code=200, text="")
mock_resp.json.return_value = payload
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await search_dashboards.ainvoke({"query": "", "env_id": "ss-dev"})
assert "11" in result
assert "hidden" in result
assert "My Dashboards Only" in result
assert "admin" in result
assert "no dashboards found" not in result.lower() or "hidden" in result.lower()
@pytest.mark.anyio
async def test_search_dashboards_truncation_note():
"""When the API returns fewer items than total, the tool must say so."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [
{"title": f"Dashboard {i}", "owners": ["admin"], "last_modified": "2026-01-01T00:00:00"} for i in range(10)
],
"total": 15,
"available_total": 15,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_resp = Mock(status_code=200, text="")
mock_resp.json.return_value = payload
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await search_dashboards.ainvoke({"query": "", "env_id": "prod"})
assert "Found 15 dashboard(s)" in result
assert "showing first 10 of 15" in result
@pytest.mark.anyio
async def test_search_dashboards_empty_env_still_reports_absent():
"""A genuinely empty environment must still produce the 'no dashboards' message."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [],
"total": 0,
"available_total": 0,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_resp = Mock(status_code=200, text="")
mock_resp.json.return_value = payload
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await search_dashboards.ainvoke({"query": "x", "env_id": "prod"})
assert "No dashboards found matching 'x'" in result
# #endregion TestAgentChat.Tools.ToolContracts

View File

@@ -0,0 +1,99 @@
# #region Test.Agent.Persistence.PrefetchDashboards [C:3] [TYPE Module] [SEMANTICS test,agent,persistence,prefetch,dashboards]
# @BRIEF Tests for prefetch_dashboards — full-catalog context injection for the LLM.
# @RELATION BINDS_TO -> [AgentChat.Persistence]
# @TEST_EDGE: http_error -> non-200 returns empty string
# @TEST_EDGE: empty_catalog -> "No dashboards found." text
# @TEST_EDGE: full_catalog -> lists dashboards and requests page_context=other (profile filter bypass)
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.append(str(Path(__file__).resolve().parent.parent.parent / "src"))
import httpx
import pytest
os.environ.setdefault("FASTAPI_URL", "http://test-backend:8000")
os.environ.setdefault("SERVICE_JWT", "test-service-jwt")
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
def _mock_response(status_code=200, json_data=None):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.json.return_value = json_data or {}
return resp
@pytest.mark.anyio
async def test_prefetch_dashboards_uses_full_catalog_context():
"""prefetch_dashboards must request page_context=other + page_size=100 so the
"My Dashboards Only" profile filter cannot hide the whole catalog."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent._persistence import prefetch_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [
{"id": i, "title": f"Dash {i}", "slug": f"dash-{i}", "last_modified": "2026-01-01T00:00:00"}
for i in range(1, 12)
],
"total": 11,
"available_total": 11,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_resp = _mock_response(200, payload)
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get = AsyncMock(return_value=mock_resp)
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
result = await prefetch_dashboards("ss-dev")
call_kwargs = mock_client.get.call_args
assert call_kwargs is not None
_, kwargs = call_kwargs
params = kwargs.get("params", {})
assert params.get("page_context") == "other"
assert params.get("page_size") == 100
# The previous dead-code bug made every 200 response raise NameError and return "".
assert result != ""
assert "11 total" in result
assert "Dash 1" in result
assert "Dash 11" in result
@pytest.mark.anyio
async def test_prefetch_dashboards_http_error_returns_empty():
"""Non-200 response must degrade to an empty string, not raise."""
from ss_tools.agent._persistence import prefetch_dashboards
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get = AsyncMock(return_value=_mock_response(500, {}))
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
result = await prefetch_dashboards("ss-dev")
assert result == ""
@pytest.mark.anyio
async def test_prefetch_dashboards_empty_catalog():
"""A genuinely empty catalog returns the explicit no-dashboards marker."""
from ss_tools.agent._persistence import prefetch_dashboards
payload = {
"dashboards": [],
"total": 0,
"available_total": 0,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get = AsyncMock(return_value=_mock_response(200, payload))
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
result = await prefetch_dashboards("ss-dev")
assert result == "No dashboards found."
# #endregion Test.Agent.Persistence.PrefetchDashboards

View File

@@ -3,7 +3,7 @@
# @BRIEF Agent LLM provider health status endpoint — used by frontend for provider availability indicator.
# @RATIONALE Frontend performs health check at mount and auto-retries every 30s if provider unavailable.
# @RELATION DEPENDS_ON -> [ss_tools.shared._llm_health]
from fastapi import APIRouter
from fastapi import APIRouter, Query
router = APIRouter(prefix="/api/agent", tags=["agent-status"])
@@ -13,15 +13,17 @@ router = APIRouter(prefix="/api/agent", tags=["agent-status"])
# @BRIEF Return cached LLM provider health status (or trigger probe if cache expired).
# @POST Returns {"status": "ok"|"unavailable"|"timeout"|"auth_error",
# "last_error": str, "retry_after_s": int}
# @POST force=true bypasses the 30s status cache for an immediate probe (frontend
# "Retry now" button must get a fresh check, not the stale cached status).
# @RATIONALE Uses ss_tools.shared._llm_health instead of src.agent._llm_health
# because the agent code has been moved to a separate project. The shared
# module uses only openai+httpx (no gradio, no langchain), so it works in
# both backend and agent containers.
@router.get("/llm-status")
async def get_llm_status():
async def get_llm_status(force: bool = Query(False)):
"""Get cached LLM provider health status. Probes provider if cache expired."""
from ss_tools.shared._llm_health import _check_llm_provider_health, _llm_status
status = await _check_llm_provider_health()
status = await _check_llm_provider_health(force=force)
return {
"status": status,
"last_error": _llm_status.get("last_error", ""),

View File

@@ -107,4 +107,32 @@ class TestGetLlmStatus:
assert resp.status_code == 200
mock_check.assert_called_once()
def test_force_param_bypasses_cache(self):
"""?force=true must reach _check_llm_provider_health(force=True) so the
"Retry now" button gets a fresh probe instead of the 30s cached status."""
mock_check = AsyncMock(return_value="unavailable")
with (
patch("ss_tools.shared._llm_health._check_llm_provider_health", mock_check),
patch("ss_tools.shared._llm_health._llm_status", {"status": "unavailable", "last_error": "Provider unreachable"}),
):
client = _make_client()
resp = client.get("/api/agent/llm-status?force=true")
assert resp.status_code == 200
assert resp.json()["status"] == "unavailable"
mock_check.assert_called_once_with(force=True)
def test_force_false_default_passes_no_force(self):
"""Without the force param the health check is called with force=False (cached)."""
mock_check = AsyncMock(return_value="ok")
with (
patch("ss_tools.shared._llm_health._check_llm_provider_health", mock_check),
patch("ss_tools.shared._llm_health._llm_status", {"status": "ok", "last_error": ""}),
):
client = _make_client()
resp = client.get("/api/agent/llm-status")
assert resp.status_code == 200
mock_check.assert_called_once_with(force=False)
# #endregion Test.Api.AgentStatusRoutes

View File

@@ -407,6 +407,62 @@ class TestGetDashboards:
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_page_context_other_bypasses_profile_filter(self, base_mocks):
"""page_context=other must bypass the profile-default filter (agent full-catalog search)."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "other"}], "modified_by": None},
{"id": 2, "title": "Other Dash", "slug": "other", "owners": [], "modified_by": None},
]
mocks = dict(base_mocks)
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "testuser",
"superset_username_normalized": "testuser",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": False,
}
profile_svc.matches_dashboard_actor.return_value = False
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
MockPS.__module__ = "unittest.mock"
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=other&apply_profile_default=true")
assert resp.status_code == 200
data = resp.json()
# No profile filter applied on non-main page context: both dashboards visible.
assert data["total"] == 2
assert data["effective_profile_filter"]["applied"] is False
profile_svc.matches_dashboard_actor.assert_not_called()
def test_get_dashboards_override_show_all_bypasses_profile_filter(self, base_mocks):
"""override_show_all=true must bypass the profile-default filter (agent fallback)."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "other"}], "modified_by": None},
]
mocks = dict(base_mocks)
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "testuser",
"superset_username_normalized": "testuser",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": False,
}
profile_svc.matches_dashboard_actor.return_value = False
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
MockPS.__module__ = "unittest.mock"
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&override_show_all=true")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["effective_profile_filter"]["applied"] is False
profile_svc.matches_dashboard_actor.assert_not_called()
def test_get_dashboards_profile_error_fallback(self, base_mocks):
"""Profile service error falls back gracefully."""
mock_rs = base_mocks["resource_service"]

View File

@@ -957,7 +957,7 @@
status={model.llmEffectiveStatus}
message={model.llmBannerMessage}
retryCountdown={model.llmRetryCountdown}
onRetry={() => model.checkLlmStatus()}
onRetry={() => model.checkLlmStatus(true)}
onDismiss={() => model.llmBannerDismissed = true}
/>
</div>

View File

@@ -88,6 +88,10 @@ export class AgentChatModel {
llmBannerDismissed: boolean = $state(false);
llmRetryCountdown: number = $state(0);
llmBannerMessage: string = $state("");
/** Single auto-retry interval — restarted on every check so countdown ticks 1/s
* and retry probes never stack (multiple overlapping intervals previously made
* the countdown decay faster than real time and "Retry now" appear dead). */
private _llmRetryTimer: ReturnType<typeof setInterval> | null = null;
/**
* Effective LLM health for UI. `llmStatus` is a process-global, cached probe
@@ -1158,10 +1162,12 @@ export class AgentChatModel {
// ── LLM provider health ─────────────────────────────────────────
/** Check LLM provider connectivity via backend endpoint. */
async checkLlmStatus(): Promise<void> {
/** Check LLM provider connectivity via backend endpoint.
* force=true bypasses the backend 30s status cache so the "Retry now" button
* performs a fresh probe instead of re-reading the stale cached status. */
async checkLlmStatus(force = false): Promise<void> {
try {
const data = await fetchApi<Record<string, unknown>>("/agent/llm-status", { suppressToast: true });
const data = await fetchApi<Record<string, unknown>>(`/agent/llm-status${force ? "?force=1" : ""}`, { suppressToast: true });
const status = typeof data.status === "string" ? data.status : "unknown";
this.llmStatus = status;
if (status !== "ok" && !this.llmBannerDismissed) {
@@ -1171,6 +1177,7 @@ export class AgentChatModel {
this.llmBannerDismissed = false;
this.llmRetryCountdown = 0;
this.llmBannerMessage = "";
if (this._llmRetryTimer) { clearInterval(this._llmRetryTimer); this._llmRetryTimer = null; }
}
} catch {
this.llmStatus = "unknown";
@@ -1178,11 +1185,14 @@ export class AgentChatModel {
}
private _startRetryCountdown(seconds: number): void {
// Clear any previous interval first — stacking intervals made the countdown
// decay faster than 1/s and fired duplicate auto-retry probes.
if (this._llmRetryTimer) { clearInterval(this._llmRetryTimer); this._llmRetryTimer = null; }
this.llmRetryCountdown = seconds;
const interval = setInterval(() => {
this._llmRetryTimer = setInterval(() => {
this.llmRetryCountdown--;
if (this.llmRetryCountdown <= 0) {
clearInterval(interval);
if (this._llmRetryTimer) { clearInterval(this._llmRetryTimer); this._llmRetryTimer = null; }
this.checkLlmStatus();
}
}, 1000);

View File

@@ -1231,6 +1231,36 @@ describe("AgentChatModel — LLM Status", () => {
vi.useRealTimers();
});
it("_startRetryCountdown clears previous interval so countdown never stacks", async () => {
vi.useFakeTimers();
const checkSpy = vi.spyOn(model, "checkLlmStatus").mockResolvedValue();
// Two rapid starts (e.g. auto-retry + "Retry now" click) must not double the
// decrement rate — previously overlapping intervals decayed 2/s and fired
// duplicate probes, making the banner look stuck.
model["_startRetryCountdown"](5);
model["_startRetryCountdown"](5);
await vi.advanceTimersByTimeAsync(1000);
expect(model.llmRetryCountdown).toBe(4);
await vi.advanceTimersByTimeAsync(4000);
expect(model.llmRetryCountdown).toBe(0);
expect(checkSpy).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("checkLlmStatus(force=true) requests a fresh probe via force=1", async () => {
vi.mocked(fetchApi).mockResolvedValue({ status: "unavailable", retry_after_s: 30 });
await model.checkLlmStatus(true);
expect(fetchApi).toHaveBeenCalledWith("/agent/llm-status?force=1", { suppressToast: true });
expect(model.llmStatus).toBe("unavailable");
});
it("checkLlmStatus() default uses cached endpoint without force", async () => {
vi.mocked(fetchApi).mockResolvedValue({ status: "ok" });
await model.checkLlmStatus();
expect(fetchApi).toHaveBeenCalledWith("/agent/llm-status", { suppressToast: true });
expect(model.llmStatus).toBe("ok");
});
it("checkLlmStatus handles fetch failure", async () => {
vi.mocked(fetchApi).mockRejectedValue(new Error("Network fail"));
await model.checkLlmStatus();

View File

@@ -42,10 +42,13 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
# #region AgentChat.LlmHealth.Check [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,health,check]
async def _check_llm_provider_health() -> str:
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds."""
async def _check_llm_provider_health(force: bool = False) -> str:
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds
unless force=True bypasses the cache for an immediate probe (used by the
frontend "Retry now" button — without force a click inside the TTL window
returned the stale cached status and appeared to do nothing)."""
now = time.time()
if now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
if not force and now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
return _llm_status["status"]
# Fetch LLM config from backend's own API (same as agent container does)