From 3e3859b34d211e62f374e7f6bef5ebc2d32e1e2f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 14 Feb 2026 15:53:56 -0700 Subject: [PATCH] Support getting client via manager --- frigate/api/app.py | 2 + frigate/api/chat.py | 3 +- frigate/api/fastapi_app.py | 2 + frigate/api/review.py | 5 +-- frigate/config/config.py | 20 +++++++-- frigate/embeddings/maintainer.py | 5 ++- frigate/genai/__init__.py | 14 ------ frigate/genai/manager.py | 76 ++++++++++++++++++++++++++++---- 8 files changed, 93 insertions(+), 34 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 126c613a7..7570171cc 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -38,6 +38,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector +from frigate.genai import GenAIClientManager from frigate.jobs.media_sync import ( get_current_media_sync_job, get_media_sync_job_by_id, @@ -432,6 +433,7 @@ def config_set(request: Request, body: AppConfigSetBody): if body.requires_restart == 0 or body.update_topic: old_config: FrigateConfig = request.app.frigate_config request.app.frigate_config = config + request.app.genai_manager = GenAIClientManager(config) if body.update_topic: if body.update_topic.startswith("config/cameras/"): diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 1f5cc2297..415f422da 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -23,7 +23,6 @@ from frigate.api.defs.response.chat_response import ( ) from frigate.api.defs.tags import Tags from frigate.api.event import events -from frigate.genai import get_genai_client logger = logging.getLogger(__name__) @@ -383,7 +382,7 @@ async def chat_completion( 6. Repeats until final answer 7. Returns response to user """ - genai_client = get_genai_client(request.app.frigate_config) + genai_client = request.app.genai_manager.tool_client if not genai_client: return JSONResponse( content={ diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 496c8fada..3206c7b4a 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -33,6 +33,7 @@ from frigate.comms.event_metadata_updater import ( from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.embeddings import EmbeddingsContext +from frigate.genai import GenAIClientManager from frigate.ptz.onvif import OnvifController from frigate.stats.emitter import StatsEmitter from frigate.storage import StorageMaintainer @@ -134,6 +135,7 @@ def create_fastapi_app( app.include_router(record.router) # App Properties app.frigate_config = frigate_config + app.genai_manager = GenAIClientManager(frigate_config) app.embeddings = embeddings app.detected_frames_processor = detected_frames_processor app.storage_maintainer = storage_maintainer diff --git a/frigate/api/review.py b/frigate/api/review.py index 76619dcb2..d2e8063d5 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -33,7 +33,6 @@ from frigate.api.defs.response.review_response import ( ReviewSummaryResponse, ) from frigate.api.defs.tags import Tags -from frigate.config import FrigateConfig from frigate.embeddings import EmbeddingsContext from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum @@ -747,9 +746,7 @@ async def set_not_reviewed( description="Use GenAI to summarize review items over a period of time.", ) def generate_review_summary(request: Request, start_ts: float, end_ts: float): - config: FrigateConfig = request.app.frigate_config - - if not config.genai.provider: + if not request.app.genai_manager.vision_client: return JSONResponse( content=( { diff --git a/frigate/config/config.py b/frigate/config/config.py index 370c89458..e31e3d8c8 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -45,7 +45,7 @@ from .camera.audio import AudioConfig from .camera.birdseye import BirdseyeConfig from .camera.detect import DetectConfig from .camera.ffmpeg import FfmpegConfig -from .camera.genai import GenAIConfig +from .camera.genai import GenAIConfig, GenAIRoleEnum from .camera.motion import MotionConfig from .camera.notification import NotificationConfig from .camera.objects import FilterConfig, ObjectConfig @@ -347,9 +347,9 @@ class FrigateConfig(FrigateBaseModel): default_factory=ModelConfig, title="Detection model configuration." ) - # GenAI config - genai: GenAIConfig = Field( - default_factory=GenAIConfig, title="Generative AI configuration." + # GenAI config (named provider configs: name -> GenAIConfig) + genai: Dict[str, GenAIConfig] = Field( + default_factory=dict, title="Generative AI configuration (named providers)." ) # Camera config @@ -431,6 +431,18 @@ class FrigateConfig(FrigateBaseModel): # set notifications state self.notifications.enabled_in_config = self.notifications.enabled + # validate genai: each role (tools, vision, embeddings) at most once + role_to_name: dict[GenAIRoleEnum, str] = {} + for name, genai_cfg in self.genai.items(): + for role in genai_cfg.roles: + if role in role_to_name: + raise ValueError( + f"GenAI role '{role.value}' is assigned to both " + f"'{role_to_name[role]}' and '{name}'; each role must have " + "exactly one provider." + ) + role_to_name[role] = name + # set default min_score for object attributes for attribute in self.model.all_attributes: if not self.objects.filters.get(attribute): diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index bd707de15..39f85e8c0 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -59,7 +59,7 @@ from frigate.data_processing.real_time.license_plate import ( from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum -from frigate.genai import get_genai_client +from frigate.genai import GenAIClientManager from frigate.models import Event, Recordings, ReviewSegment, Trigger from frigate.util.builtin import serialize from frigate.util.file import get_event_thumbnail_bytes @@ -144,7 +144,8 @@ class EmbeddingMaintainer(threading.Thread): self.frame_manager = SharedMemoryFrameManager() self.detected_license_plates: dict[str, dict[str, Any]] = {} - self.genai_client = get_genai_client(config) + self.genai_manager = GenAIClientManager(config) + self.genai_client = self.genai_manager.vision_client # model runners to share between realtime and post processors if self.config.lpr.enabled: diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 5b38c67d0..cd89a9db1 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -23,7 +23,6 @@ __all__ = [ "GenAIConfig", "GenAIProviderEnum", "PROVIDERS", - "get_genai_client", "load_providers", "register_genai_provider", ] @@ -364,19 +363,6 @@ Guidelines: } -def get_genai_client(config: FrigateConfig) -> Optional[GenAIClient]: - """Get the GenAI client.""" - if not config.genai.provider: - return None - - load_providers() - provider = PROVIDERS.get(config.genai.provider) - if provider: - return provider(config.genai) - - return None - - def load_providers(): package_dir = os.path.dirname(__file__) for filename in os.listdir(package_dir): diff --git a/frigate/genai/manager.py b/frigate/genai/manager.py index 0f8fbf07e..51ceb11f7 100644 --- a/frigate/genai/manager.py +++ b/frigate/genai/manager.py @@ -1,13 +1,19 @@ """GenAI client manager for Frigate. -Manages GenAI provider clients and supports multiple providers. Configuration -is driven by FrigateConfig; _update_config is called on init and can be -called again when config is reloaded. +Manages GenAI provider clients from Frigate config. Configuration is read only +in _update_config(); no other code should read config.genai. Exposes clients +by role: tool_client, vision_client, embeddings_client. """ import logging +from typing import TYPE_CHECKING, Optional from frigate.config import FrigateConfig +from frigate.config.camera.genai import GenAIRoleEnum +from frigate.genai import PROVIDERS, load_providers + +if TYPE_CHECKING: + from frigate.genai import GenAIClient logger = logging.getLogger(__name__) @@ -17,13 +23,67 @@ class GenAIClientManager: def __init__(self, config: FrigateConfig) -> None: self._config = config + self._tool_client: Optional[GenAIClient] = None + self._vision_client: Optional[GenAIClient] = None + self._embeddings_client: Optional[GenAIClient] = None self._update_config() def _update_config(self) -> None: - """Update internal state from the current Frigate config. + """Build role clients from current Frigate config.genai. - Called from __init__ and can be called again when config is reloaded - to support multiple providers or config changes. + Called from __init__ and can be called again when config is reloaded. + Each role (tools, vision, embeddings) gets the client for the provider + that has that role in its roles list. """ - # Placeholder for multi-provider setup; will be extended in later refactor steps. - pass + + self._tool_client = None + self._vision_client = None + self._embeddings_client = None + + if not self._config.genai: + return + + load_providers() + + for _name, genai_cfg in self._config.genai.items(): + if not genai_cfg.provider: + continue + provider_cls = PROVIDERS.get(genai_cfg.provider) + if not provider_cls: + logger.warning( + "Unknown GenAI provider %s in config, skipping.", + genai_cfg.provider, + ) + continue + try: + client = provider_cls(genai_cfg) + except Exception as e: + logger.exception( + "Failed to create GenAI client for provider %s: %s", + genai_cfg.provider, + e, + ) + continue + + for role in genai_cfg.roles: + if role == GenAIRoleEnum.tools: + self._tool_client = client + elif role == GenAIRoleEnum.vision: + self._vision_client = client + elif role == GenAIRoleEnum.embeddings: + self._embeddings_client = client + + @property + def tool_client(self) -> "Optional[GenAIClient]": + """Client configured for the tools role (e.g. chat with function calling).""" + return self._tool_client + + @property + def vision_client(self) -> "Optional[GenAIClient]": + """Client configured for the vision role (e.g. review descriptions, object descriptions).""" + return self._vision_client + + @property + def embeddings_client(self) -> "Optional[GenAIClient]": + """Client configured for the embeddings role.""" + return self._embeddings_client