From 225e01497986f2fdff2162931eb8930e8cbf8db2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:53:45 -0500 Subject: [PATCH] keep Frigate+ model references when saving the models section `/api/config` served a Frigate+ model's path as the resolved `/config/model_cache/` file, and since the models list is saved whole, editing any model in the settings UI wrote that cache path back to the config in place of `plus://`. After a restart the model loaded as a custom model with the default labelmap. The config API now reports the `plus://` reference the model was configured with, and the models section drops the fields the Frigate+ model info supplies (size, tensor, pixel format, dtype, and type) instead of pinning them in the config. --- frigate/api/app.py | 5 +++ frigate/detectors/detector_config.py | 7 ++++ frigate/test/http_api/test_http_app.py | 42 ++++++++++++++++++- .../sections/section-special-cases.ts | 27 ++++++++++++ .../theme/fields/ModelSourcePicker.tsx | 4 +- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 62676bbdef..9cd1c8fe3b 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -393,6 +393,11 @@ def config(request: Request): model_dict["non_logo_attributes"] = model.non_logo_attributes model_dict["labelmap"] = model.merged_labelmap + # report the configured reference rather than the resolved cache path, + # so saving the config back doesn't lose the Frigate+ model + if model.plus_id: + model_dict["path"] = f"plus://{model.plus_id}" + if not config["plus"]["enabled"]: continue diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index 1193ba0740..2362c073cc 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -123,6 +123,7 @@ class ModelConfig(BaseModel): _all_attributes: list[str] = PrivateAttr() _all_attribute_logos: list[str] = PrivateAttr() _model_hash: str = PrivateAttr() + _plus_id: str | None = PrivateAttr(default=None) @property def merged_labelmap(self) -> dict[int, str]: @@ -148,6 +149,11 @@ class ModelConfig(BaseModel): def model_hash(self) -> str: return self._model_hash + @property + def plus_id(self) -> str | None: + """The Frigate+ model id, once a plus:// path has been resolved.""" + return self._plus_id + def __init__(self, **config): super().__init__(**config) @@ -178,6 +184,7 @@ class ModelConfig(BaseModel): os.makedirs(MODEL_CACHE_DIR, exist_ok=True) model_id = self.path[7:] + self._plus_id = model_id self.path = os.path.join(MODEL_CACHE_DIR, model_id) model_info_path = f"{self.path}.json" diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index ef5b99ad08..3462a4f108 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -1,8 +1,10 @@ +import json +import os from unittest.mock import Mock, patch import frigate.genai from frigate.config import GenAIProviderEnum -from frigate.const import REDACTED_CREDENTIAL_SENTINEL +from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL from frigate.genai import GenAIClient from frigate.models import Event, Recordings, ReviewSegment from frigate.stats.emitter import StatsEmitter @@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp): mqtt = response.json()["mqtt"] assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL + def test_config_response_keeps_plus_model_reference(self): + model_id = "test_plus_reference" + model_path = os.path.join(MODEL_CACHE_DIR, model_id) + os.makedirs(MODEL_CACHE_DIR, exist_ok=True) + + with open(model_path, "w") as f: + f.write("model") + + with open(f"{model_path}.json", "w") as f: + json.dump( + { + "id": model_id, + "type": "ssd", + "supportedDetectors": ["cpu"], + "width": 320, + "height": 320, + "inputShape": "nhwc", + "pixelFormat": "rgb", + "labelMap": {"0": "person"}, + }, + f, + ) + + self.addCleanup(os.remove, model_path) + self.addCleanup(os.remove, f"{model_path}.json") + self.minimal_config["models"] = [ + {"path": f"plus://{model_id}", "devices": ["cpu"]} + ] + app = super().create_app() + + with AuthTestClient(app) as client: + response = client.get("/config") + assert response.status_code == 200 + assert response.json()["models"][0]["path"] == f"plus://{model_id}" + + # detection still loads the resolved cache file + assert app.frigate_config.models[0].path == model_path + #################################################################################################################### ################################### POST /genai/probe Endpoint ################################################## #################################################################################################################### diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index 08f7d8c736..96c1d8244f 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -7,6 +7,7 @@ */ import { RJSFSchema } from "@rjsf/utils"; +import { omit } from "lodash"; import { applySchemaDefaults } from "@/lib/config-schema"; import { isJsonObject } from "@/lib/utils"; import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm"; @@ -352,6 +353,17 @@ export function synthesizeMissingFilters( return { ...(data as JsonObject), filters: newFilters }; } +// the backend fills these from the Frigate+ model info when it loads a +// plus:// model, so saving them would only pin values Frigate+ owns +const PLUS_SUPPLIED_MODEL_FIELDS = [ + "width", + "height", + "input_tensor", + "input_pixel_format", + "input_dtype", + "model_type", +]; + /** * Sanitize overrides payloads for section-specific quirks. */ @@ -360,6 +372,21 @@ export function sanitizeOverridesForSection( level: string, overrides: unknown, ): unknown { + // the models list is saved whole + if (sectionPath === "models" && Array.isArray(overrides)) { + return overrides.map((model) => { + if ( + !isJsonObject(model) || + typeof model.path !== "string" || + !model.path.startsWith("plus://") + ) { + return model; + } + + return omit(model, PLUS_SUPPLIED_MODEL_FIELDS); + }); + } + if (!overrides || !isJsonObject(overrides)) { return overrides; } diff --git a/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx b/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx index 8aa60fb468..70d1bbbbfc 100644 --- a/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx +++ b/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx @@ -38,9 +38,7 @@ function plusModelId(path: unknown): string | undefined { type ModelSourcePickerProps = { path: unknown; - // Frigate+ metadata the backend attaches to a saved model, and the only - // reliable signal that one is active: it resolves `plus://` to a local - // cache path before serving the config back + // Frigate+ metadata the backend attaches to a saved model plus?: { id: string } | null; // the detector this model runs on, used to filter incompatible Plus models detector?: string;