From 07ca84ad0ac0260285cf030ca285c3879e6d3a05 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 May 2026 08:47:49 -0500 Subject: [PATCH] add model fetcher endpoint for genai config ui --- docs/static/frigate-api.yaml | 74 ++++ frigate/api/app.py | 101 +++++- frigate/api/defs/request/app_body.py | 9 + frigate/config/camera/genai.py | 2 +- frigate/genai/__init__.py | 8 +- frigate/genai/plugins/llama_cpp.py | 4 + frigate/genai/plugins/ollama.py | 3 + frigate/test/http_api/test_http_app.py | 96 +++++- web/public/locales/en/views/settings.json | 11 +- .../theme/widgets/GenAIModelWidget.tsx | 325 ++++++++++++++---- 10 files changed, 561 insertions(+), 72 deletions(-) diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 9c4e44051a..605eff92c3 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2058,6 +2058,47 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" + /genai/models: + get: + tags: + - App + summary: List available GenAI models + description: Returns available models for each configured GenAI provider. + operationId: genai_models_genai_models_get + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + /genai/probe: + post: + tags: + - App + 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. Requires admin role. + operationId: genai_probe_genai_probe_post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GenAIProbeBody" + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" /vainfo: get: tags: @@ -7031,6 +7072,39 @@ components: "john_doe": ["face1.webp", "face2.jpg"], "jane_smith": ["face3.png"] } + GenAIProbeBody: + properties: + provider: + type: string + enum: + - openai + - azure_openai + - gemini + - ollama + - llamacpp + title: Provider + description: GenAI provider to probe + api_key: + anyOf: + - type: string + - type: "null" + title: API Key + description: API key for the provider (when applicable) + base_url: + anyOf: + - type: string + - type: "null" + title: Base URL + description: Base URL for self-hosted or compatible providers + provider_options: + type: object + title: Provider Options + description: Additional provider-specific options + default: {} + type: object + required: + - provider + title: GenAIProbeBody GenerateObjectExamplesBody: properties: model_name: diff --git a/frigate/api/app.py b/frigate/api/app.py index 4fac58a715..2ba016d63e 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -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 diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index d9d11fd019..2c37f6ae4d 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -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 diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index 902c94c42b..5b94755723 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -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.", ) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 864092df58..28a6844d95 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -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( diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py index 830dd6817b..2dddf5244e 100644 --- a/frigate/genai/plugins/llama_cpp.py +++ b/frigate/genai/plugins/llama_cpp.py @@ -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) diff --git a/frigate/genai/plugins/ollama.py b/frigate/genai/plugins/ollama.py index a6f6d8ddd5..0f95dd3f9d 100644 --- a/frigate/genai/plugins/ollama.py +++ b/frigate/genai/plugins/ollama.py @@ -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"): diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index 2be0e65da8..3198515267 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -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 diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9f842b79c0..65a3430269 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1557,9 +1557,14 @@ "searchPlaceholder": "Search...", "addCustomLabel": "Add custom label...", "genaiModel": { - "placeholder": "Select model…", - "search": "Search models…", - "noModels": "No models available" + "placeholder": "Select or enter a model…", + "search": "Search or enter a model…", + "noModels": "No models available", + "available": "Available models", + "useCustom": "Use \"{{value}}\"", + "refresh": "Refresh models", + "probeFailed": "Failed to probe models", + "fetchedModels": "Successfully fetched model list" } }, "globalConfig": { diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx index 3be8c0fe3b..294d061166 100644 --- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx +++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx @@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react"; import type { WidgetProps } from "@rjsf/utils"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; -import { Check, ChevronsUpDown } from "lucide-react"; +import axios from "axios"; +import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react"; +import { LuCheck } from "react-icons/lu"; import { cn } from "@/lib/utils"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import { Command, @@ -19,9 +22,17 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import type { ConfigFormContext } from "@/types/configForm"; +import type { ConfigFormContext, JsonObject } from "@/types/configForm"; import { getSizedFieldClassName } from "../utils"; +type ProbeResponse = + | { success: true; models: string[] } + | { success: false; message: string }; + +type ProbeStatus = "idle" | "probing" | "success" | "error"; + +const PROBE_SUCCESS_INDICATOR_MS = 3000; + /** * Extract the provider config entry name from the RJSF widget id. * Widget ids look like "root_myProvider_model". @@ -41,6 +52,7 @@ export function GenAIModelWidget(props: WidgetProps) { const { id, value, disabled, readonly, onChange, options, registry } = props; const { t } = useTranslation(["views/settings"]); const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); const fieldClassName = getSizedFieldClassName(options, "sm"); const providerKey = useMemo(() => getProviderKey(id), [id]); @@ -77,78 +89,261 @@ export function GenAIModelWidget(props: WidgetProps) { } }, [configFingerprint, mutateModels]); - const models = useMemo(() => { + const fetchedModels = useMemo(() => { if (!allModels || !providerKey) return []; return allModels[providerKey] ?? []; }, [allModels, providerKey]); + const [probeStatus, setProbeStatus] = useState("idle"); + const [probeError, setProbeError] = useState(null); + const [probedModels, setProbedModels] = useState(null); + const probeSuccessTimerRef = useRef | null>( + null, + ); + + const probing = probeStatus === "probing"; + + // Reset probe results if the provider entry name changes + useEffect(() => { + setProbedModels(null); + setProbeError(null); + setProbeStatus("idle"); + if (probeSuccessTimerRef.current) { + clearTimeout(probeSuccessTimerRef.current); + probeSuccessTimerRef.current = null; + } + }, [providerKey]); + + useEffect(() => { + return () => { + if (probeSuccessTimerRef.current) { + clearTimeout(probeSuccessTimerRef.current); + } + }; + }, []); + + const models = probedModels ?? fetchedModels; + + const trimmedSearch = searchValue.trim(); + const matchesFetched = useMemo( + () => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()), + [models, trimmedSearch], + ); + const showCustomOption = trimmedSearch.length > 0 && !matchesFetched; + + // Read the live form values for this provider so probe sends the user's + // in-flight edits, not the saved config (which may not exist yet). + const formEntry = useMemo(() => { + if (!providerKey) return null; + const formData = formContext?.formData as JsonObject | undefined; + const entry = formData?.[providerKey]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null; + } + return entry as JsonObject; + }, [providerKey, formContext?.formData]); + + const formProvider = + typeof formEntry?.provider === "string" ? formEntry.provider : null; + const canProbe = Boolean(formProvider) && !probing; + + const probe = async () => { + if (!formEntry || !formProvider) return; + if (probeSuccessTimerRef.current) { + clearTimeout(probeSuccessTimerRef.current); + probeSuccessTimerRef.current = null; + } + setProbeStatus("probing"); + setProbeError(null); + try { + const res = await axios.post("genai/probe", { + provider: formProvider, + api_key: + typeof formEntry.api_key === "string" ? formEntry.api_key : null, + base_url: + typeof formEntry.base_url === "string" ? formEntry.base_url : null, + provider_options: + formEntry.provider_options && + typeof formEntry.provider_options === "object" && + !Array.isArray(formEntry.provider_options) + ? (formEntry.provider_options as JsonObject) + : {}, + }); + if (res.data.success) { + setProbedModels(res.data.models); + setProbeStatus("success"); + probeSuccessTimerRef.current = setTimeout(() => { + setProbeStatus("idle"); + probeSuccessTimerRef.current = null; + }, PROBE_SUCCESS_INDICATOR_MS); + } else { + setProbedModels([]); + setProbeError(res.data.message); + setProbeStatus("error"); + } + } catch { + setProbedModels(null); + setProbeError( + t("configForm.genaiModel.probeFailed", { + ns: "views/settings", + defaultValue: "Failed to probe models", + }), + ); + setProbeStatus("error"); + } + }; + + const commit = (next: string) => { + onChange(next); + setSearchValue(""); + setOpen(false); + }; + const currentLabel = typeof value === "string" && value ? value : undefined; + const refreshLabel = t("configForm.genaiModel.refresh", { + ns: "views/settings", + defaultValue: "Refresh models", + }); + return ( - - - - - - - - - {models.length > 0 ? ( - - {models.map((model) => ( - { - onChange(model); - setOpen(false); - }} - > - - {model} - - ))} - - ) : ( -
- {t("configForm.genaiModel.noModels", { + +
- )} -
-
-
-
+ + + + + + { + if (e.key === "Enter" && showCustomOption) { + e.preventDefault(); + commit(trimmedSearch); + } + }} + /> + + {showCustomOption && ( + + commit(trimmedSearch)} + > + + {t("configForm.genaiModel.useCustom", { + ns: "views/settings", + value: trimmedSearch, + defaultValue: 'Use "{{value}}"', + })} + + + )} + {models.length > 0 ? ( + + {models.map((model) => ( + commit(model)} + > + + {model} + + ))} + + ) : !showCustomOption ? ( +
+ {t("configForm.genaiModel.noModels", { + ns: "views/settings", + defaultValue: "No models available", + })} +
+ ) : null} +
+
+
+ + + +
+ {probeStatus === "success" && ( + + + {t("configForm.genaiModel.fetchedModels", { + ns: "views/settings", + defaultValue: "Successfully fetched model list", + })} + + )} + {probeStatus === "error" && probeError && ( + {probeError} + )} +
+ ); }