mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-07 03:11:15 +03:00
Miscellaneous fixes (#23258)
* render orphaned filter entries as collapsibles instead of the Key/Value editor * Symlink for various AI files * change replay confg dialog to platform aware sheet * change agents title * fix test * tweak collapsible * remove camera ui section in settings no point to having it anymore with profiles and camera management settings * fix admin response cache leak to non-admin users via nginx proxy_cache * add model fetcher endpoint for genai config ui --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
03f4f76b72
commit
8ea46e7c6c
+100
-1
@@ -34,15 +34,17 @@ from frigate.api.auth import (
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
GenAIProbeBody,
|
||||
MediaSyncBody,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import FrigateConfig, GenAIConfig, GenAIProviderEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
get_current_media_sync_job,
|
||||
get_media_sync_job_by_id,
|
||||
@@ -75,6 +77,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.app])
|
||||
|
||||
# Short timeout for the /genai/probe path. The probe is interactive — fail
|
||||
# fast on hung providers rather than holding an API worker thread.
|
||||
_PROBE_TIMEOUT_SECONDS = 10
|
||||
# Outer cap that returns control to the caller even if the underlying sync
|
||||
# HTTP call ignores its timeout. The sync work continues in the background
|
||||
# thread; only the response is bounded.
|
||||
_PROBE_OUTER_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
@router.get(
|
||||
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
||||
@@ -170,6 +180,95 @@ def genai_models(request: Request):
|
||||
return JSONResponse(content=request.app.genai_manager.list_models())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/genai/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Probe a GenAI provider without saving config",
|
||||
description=(
|
||||
"Builds a transient client from the request body and returns its "
|
||||
"available models. Used to validate provider credentials in the UI "
|
||||
"before saving the configuration."
|
||||
),
|
||||
)
|
||||
async def genai_probe(body: GenAIProbeBody):
|
||||
load_providers()
|
||||
|
||||
provider_cls = PROVIDERS.get(body.provider)
|
||||
if not provider_cls:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "message": "Unknown provider"},
|
||||
)
|
||||
|
||||
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
|
||||
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||
# in milliseconds and would clash with the plugin's own default.
|
||||
probe_provider_options: dict[str, Any] = dict(body.provider_options or {})
|
||||
if body.provider in (GenAIProviderEnum.openai, GenAIProviderEnum.azure_openai):
|
||||
probe_provider_options.setdefault("timeout", _PROBE_TIMEOUT_SECONDS)
|
||||
|
||||
try:
|
||||
transient_cfg = GenAIConfig(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
base_url=body.base_url,
|
||||
provider_options=probe_provider_options,
|
||||
# model is required by the schema but irrelevant for listing.
|
||||
model="probe",
|
||||
roles=[],
|
||||
)
|
||||
except ValidationError:
|
||||
logger.exception("GenAI probe: invalid configuration")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "message": "Invalid provider configuration"},
|
||||
)
|
||||
|
||||
try:
|
||||
client = provider_cls(
|
||||
transient_cfg,
|
||||
timeout=_PROBE_TIMEOUT_SECONDS,
|
||||
validate_model=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("GenAI probe: failed to construct client")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Failed to connect to provider",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
models = await asyncio.wait_for(
|
||||
asyncio.to_thread(client.list_models),
|
||||
timeout=_PROBE_OUTER_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Probe timed out"},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("GenAI probe: list_models failed")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Provider returned no models"},
|
||||
)
|
||||
|
||||
if not models:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": (
|
||||
"No models returned. Check the API key, base URL, and "
|
||||
"that the provider is reachable."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "models": models})
|
||||
|
||||
|
||||
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
||||
def config(request: Request):
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
|
||||
|
||||
class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
@@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel):
|
||||
skip_save: bool = False
|
||||
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
provider: GenAIProviderEnum
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
provider_options: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
password: str
|
||||
old_password: Optional[str] = None
|
||||
|
||||
@@ -37,7 +37,7 @@ class GenAIConfig(FrigateBaseModel):
|
||||
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
|
||||
)
|
||||
model: str = Field(
|
||||
default="gpt-4o",
|
||||
default="",
|
||||
title="Model",
|
||||
description="The model to use from the provider for generating descriptions or summaries.",
|
||||
)
|
||||
|
||||
@@ -50,9 +50,15 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
||||
class GenAIClient:
|
||||
"""Generative AI client for Frigate."""
|
||||
|
||||
def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
genai_config: GenAIConfig,
|
||||
timeout: int = 120,
|
||||
validate_model: bool = True,
|
||||
) -> None:
|
||||
self.genai_config: GenAIConfig = genai_config
|
||||
self.timeout = timeout
|
||||
self.validate_model = validate_model
|
||||
self.provider = self._init_provider()
|
||||
|
||||
def generate_review_description(
|
||||
|
||||
@@ -150,6 +150,10 @@ class LlamaCppClient(GenAIClient):
|
||||
else:
|
||||
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
||||
|
||||
if not self.validate_model:
|
||||
# Probe path
|
||||
return base_url
|
||||
|
||||
configured_model = self.genai_config.model
|
||||
info = self._get_model_info(base_url, configured_model)
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ class OllamaClient(GenAIClient):
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
if not self.validate_model:
|
||||
# Probe path
|
||||
return client
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
if response.get("error"):
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
@@ -71,3 +74,94 @@ class TestHttpApp(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST /genai/probe Endpoint ##################################################
|
||||
####################################################################################################################
|
||||
def test_genai_probe_requires_admin(self):
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={"provider": "openai"},
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_genai_probe_returns_models_from_transient_client(self):
|
||||
class FakeClient(GenAIClient):
|
||||
def list_models(self):
|
||||
return ["fake-model-a", "fake-model-b"]
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: FakeClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://example.invalid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"success": True,
|
||||
"models": ["fake-model-a", "fake-model-b"],
|
||||
}
|
||||
|
||||
def test_genai_probe_empty_list_is_treated_as_failure(self):
|
||||
# The plugin's list_models() returns [] on connection failure rather
|
||||
# than raising. The endpoint should surface that as success=false so
|
||||
# the UI can show a meaningful error.
|
||||
class EmptyClient(GenAIClient):
|
||||
def list_models(self):
|
||||
return []
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: EmptyClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={"provider": "openai"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["success"] is False
|
||||
assert "message" in payload
|
||||
|
||||
def test_genai_probe_handles_provider_failure(self):
|
||||
class FailingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
raise RuntimeError("provider unreachable")
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: FailingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={"provider": "openai"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["success"] is False
|
||||
assert "message" in payload
|
||||
|
||||
Reference in New Issue
Block a user