(itemRefs.current[index] = item)}
+ ref={(item) => {
+ itemRefs.current[index] = item;
+ }}
data-start={value.start_time}
className="relative flex flex-col rounded-lg"
>
From 65db9b0aec87b2957156ce2afe4415916a6ffe9c Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:11:32 -0600
Subject: [PATCH 7/9] Fixes (#22280)
* fix ollama chat tool calling
handle dict arguments, streaming fallback, and message format
* pin setuptools<81 to ensure pkg_resources remains available
When ensure_torch_dependencies() installs torch/torchvision via pip, it can upgrade setuptools to >=81.0.0, which removed the pkg_resources module. rknn-toolkit2 depends on pkg_resources internally, so subsequent RKNN conversion fails with No module named 'pkg_resources'.
---
frigate/genai/ollama.py | 70 +++++++++++++++++++++++++++-------
frigate/genai/utils.py | 27 +++++++------
frigate/util/rknn_converter.py | 1 +
3 files changed, 74 insertions(+), 24 deletions(-)
diff --git a/frigate/genai/ollama.py b/frigate/genai/ollama.py
index eb63f7fdb..e98f6ab07 100644
--- a/frigate/genai/ollama.py
+++ b/frigate/genai/ollama.py
@@ -1,5 +1,6 @@
"""Ollama Provider for Frigate AI."""
+import json
import logging
from typing import Any, Optional
@@ -108,7 +109,22 @@ class OllamaClient(GenAIClient):
if msg.get("name"):
msg_dict["name"] = msg["name"]
if msg.get("tool_calls"):
- msg_dict["tool_calls"] = msg["tool_calls"]
+ # Ollama requires tool call arguments as dicts, but the
+ # conversation format (OpenAI-style) stores them as JSON
+ # strings. Convert back to dicts for Ollama.
+ ollama_tool_calls = []
+ for tc in msg["tool_calls"]:
+ func = tc.get("function") or {}
+ args = func.get("arguments") or {}
+ if isinstance(args, str):
+ try:
+ args = json.loads(args)
+ except (json.JSONDecodeError, TypeError):
+ args = {}
+ ollama_tool_calls.append(
+ {"function": {"name": func.get("name", ""), "arguments": args}}
+ )
+ msg_dict["tool_calls"] = ollama_tool_calls
request_messages.append(msg_dict)
request_params: dict[str, Any] = {
@@ -120,25 +136,27 @@ class OllamaClient(GenAIClient):
request_params["stream"] = True
if tools:
request_params["tools"] = tools
- if tool_choice:
- request_params["tool_choice"] = (
- "none"
- if tool_choice == "none"
- else "required"
- if tool_choice == "required"
- else "auto"
- )
return request_params
def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Parse Ollama chat response into {content, tool_calls, finish_reason}."""
if not response or "message" not in response:
+ logger.debug("Ollama response empty or missing 'message' key")
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
message = response["message"]
+ logger.debug(
+ "Ollama response message keys: %s, content_len=%s, thinking_len=%s, "
+ "tool_calls=%s, done=%s",
+ list(message.keys()) if hasattr(message, "keys") else "N/A",
+ len(message.get("content", "") or "") if message.get("content") else 0,
+ len(message.get("thinking", "") or "") if message.get("thinking") else 0,
+ bool(message.get("tool_calls")),
+ response.get("done"),
+ )
content = message.get("content", "").strip() if message.get("content") else None
tool_calls = parse_tool_calls_from_message(message)
finish_reason = "error"
@@ -198,7 +216,13 @@ class OllamaClient(GenAIClient):
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
- """Stream chat with tools; yields content deltas then final message."""
+ """Stream chat with tools; yields content deltas then final message.
+
+ When tools are provided, Ollama streaming does not include tool_calls
+ in the response chunks. To work around this, we use a non-streaming
+ call when tools are present to ensure tool calls are captured, then
+ emit the content as a single delta followed by the final message.
+ """
if self.provider is None:
logger.warning(
"Ollama provider has not been initialized. Check your Ollama configuration."
@@ -213,6 +237,27 @@ class OllamaClient(GenAIClient):
)
return
try:
+ # Ollama does not return tool_calls in streaming mode, so fall
+ # back to a non-streaming call when tools are provided.
+ if tools:
+ logger.debug(
+ "Ollama: tools provided, using non-streaming call for tool support"
+ )
+ request_params = self._build_request_params(
+ messages, tools, tool_choice, stream=False
+ )
+ async_client = OllamaAsyncClient(
+ host=self.genai_config.base_url,
+ timeout=self.timeout,
+ )
+ response = await async_client.chat(**request_params)
+ result = self._message_from_response(response)
+ content = result.get("content")
+ if content:
+ yield ("content_delta", content)
+ yield ("message", result)
+ return
+
request_params = self._build_request_params(
messages, tools, tool_choice, stream=True
)
@@ -233,11 +278,10 @@ class OllamaClient(GenAIClient):
yield ("content_delta", delta)
if chunk.get("done"):
full_content = "".join(content_parts).strip() or None
- tool_calls = parse_tool_calls_from_message(msg)
final_message = {
"content": full_content,
- "tool_calls": tool_calls,
- "finish_reason": "tool_calls" if tool_calls else "stop",
+ "tool_calls": None,
+ "finish_reason": "stop",
}
break
diff --git a/frigate/genai/utils.py b/frigate/genai/utils.py
index 93d4552b9..44f982059 100644
--- a/frigate/genai/utils.py
+++ b/frigate/genai/utils.py
@@ -23,21 +23,26 @@ def parse_tool_calls_from_message(
if not raw or not isinstance(raw, list):
return None
result = []
- for tool_call in raw:
+ for idx, tool_call in enumerate(raw):
function_data = tool_call.get("function") or {}
- try:
- arguments_str = function_data.get("arguments") or "{}"
- arguments = json.loads(arguments_str)
- except (json.JSONDecodeError, KeyError, TypeError) as e:
- logger.warning(
- "Failed to parse tool call arguments: %s, tool: %s",
- e,
- function_data.get("name", "unknown"),
- )
+ raw_arguments = function_data.get("arguments") or {}
+ if isinstance(raw_arguments, dict):
+ arguments = raw_arguments
+ elif isinstance(raw_arguments, str):
+ try:
+ arguments = json.loads(raw_arguments)
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
+ logger.warning(
+ "Failed to parse tool call arguments: %s, tool: %s",
+ e,
+ function_data.get("name", "unknown"),
+ )
+ arguments = {}
+ else:
arguments = {}
result.append(
{
- "id": tool_call.get("id", ""),
+ "id": tool_call.get("id", "") or f"call_{idx}",
"name": function_data.get("name", ""),
"arguments": arguments,
}
diff --git a/frigate/util/rknn_converter.py b/frigate/util/rknn_converter.py
index f7ebbf5e6..5660c7601 100644
--- a/frigate/util/rknn_converter.py
+++ b/frigate/util/rknn_converter.py
@@ -110,6 +110,7 @@ def ensure_torch_dependencies() -> bool:
"pip",
"install",
"--break-system-packages",
+ "setuptools<81",
"torch",
"torchvision",
],
From 02678f4a097dcffe00a4bbbdc01ebc9c190a5b4c Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 5 Mar 2026 17:17:41 -0600
Subject: [PATCH 8/9] show log when anonymous users log in (#22254)
based on a cache key built from remote_addr and user agent, expires after 7 days by default
---
frigate/api/auth.py | 37 ++++++++++++++++++++++++++++++++++++-
1 file changed, 36 insertions(+), 1 deletion(-)
diff --git a/frigate/api/auth.py b/frigate/api/auth.py
index 04a5bd19a..39089b583 100644
--- a/frigate/api/auth.py
+++ b/frigate/api/auth.py
@@ -32,6 +32,12 @@ from frigate.models import User
logger = logging.getLogger(__name__)
+# In-memory cache to track which clients we've logged for an anonymous access event.
+# Keyed by a hashed value combining remote address + user-agent. The value is
+# an expiration timestamp (float).
+FIRST_LOAD_TTL_SECONDS = 60 * 60 * 24 * 7 # 7 days
+_first_load_seen: dict[str, float] = {}
+
def require_admin_by_default():
"""
@@ -284,6 +290,15 @@ def get_remote_addr(request: Request):
return remote_addr or "127.0.0.1"
+def _cleanup_first_load_seen() -> None:
+ """Cleanup expired entries in the in-memory first-load cache."""
+ now = time.time()
+ # Build list for removal to avoid mutating dict during iteration
+ expired = [k for k, exp in _first_load_seen.items() if exp <= now]
+ for k in expired:
+ del _first_load_seen[k]
+
+
def get_jwt_secret() -> str:
jwt_secret = None
# check env var
@@ -744,10 +759,30 @@ def profile(request: Request):
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
- return JSONResponse(
+ response = JSONResponse(
content={"username": username, "role": role, "allowed_cameras": allowed_cameras}
)
+ if username == "anonymous":
+ try:
+ remote_addr = get_remote_addr(request)
+ except Exception:
+ remote_addr = (
+ request.client.host if hasattr(request, "client") else "unknown"
+ )
+
+ ua = request.headers.get("user-agent", "")
+ key_material = f"{remote_addr}|{ua}"
+ cache_key = hashlib.sha256(key_material.encode()).hexdigest()
+
+ _cleanup_first_load_seen()
+ now = time.time()
+ if cache_key not in _first_load_seen:
+ _first_load_seen[cache_key] = now + FIRST_LOAD_TTL_SECONDS
+ logger.info(f"Anonymous user access from {remote_addr} ua={ua[:200]}")
+
+ return response
+
@router.get(
"/logout",
From 229436c94afe4ee642d9280d0380e808a0bb819f Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 5 Mar 2026 17:19:30 -0600
Subject: [PATCH 9/9] Add ability to clear region grids from the frontend
(#22277)
* backend
* frontend
* i18n
* tweaks
---
frigate/api/media.py | 18 +++
web/public/locales/en/views/settings.json | 13 +-
web/src/pages/Settings.tsx | 15 ++-
...ingsView.tsx => MediaSyncSettingsView.tsx} | 4 +-
.../views/settings/RegionGridSettingsView.tsx | 124 ++++++++++++++++++
5 files changed, 167 insertions(+), 7 deletions(-)
rename web/src/views/settings/{MaintenanceSettingsView.tsx => MediaSyncSettingsView.tsx} (99%)
create mode 100644 web/src/views/settings/RegionGridSettingsView.tsx
diff --git a/frigate/api/media.py b/frigate/api/media.py
index 3cfd97674..2ddabc631 100644
--- a/frigate/api/media.py
+++ b/frigate/api/media.py
@@ -24,6 +24,7 @@ from tzlocal import get_localzone_name
from frigate.api.auth import (
allow_any_authenticated,
require_camera_access,
+ require_role,
)
from frigate.api.defs.query.media_query_parameters import (
Extension,
@@ -1005,6 +1006,23 @@ def grid_snapshot(
)
+@router.delete(
+ "/{camera_name}/region_grid", dependencies=[Depends(require_role("admin"))]
+)
+def clear_region_grid(request: Request, camera_name: str):
+ """Clear the region grid for a camera."""
+ if camera_name not in request.app.frigate_config.cameras:
+ return JSONResponse(
+ content={"success": False, "message": "Camera not found"},
+ status_code=404,
+ )
+
+ Regions.delete().where(Regions.camera == camera_name).execute()
+ return JSONResponse(
+ content={"success": True, "message": "Region grid cleared"},
+ )
+
+
@router.get(
"/events/{event_id}/snapshot-clean.webp",
dependencies=[Depends(require_camera_access)],
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 0b61b278b..81c9b8075 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -83,7 +83,8 @@
"triggers": "Triggers",
"debug": "Debug",
"frigateplus": "Frigate+",
- "maintenance": "Maintenance"
+ "mediaSync": "Media sync",
+ "regionGrid": "Region grid"
},
"dialog": {
"unsavedChanges": {
@@ -1232,6 +1233,16 @@
"previews": "Previews",
"exports": "Exports",
"recordings": "Recordings"
+ },
+ "regionGrid": {
+ "title": "Region Grid",
+ "desc": "The region grid is an optimization that learns where objects of different sizes typically appear in each camera's field of view. Frigate uses this data to efficiently size detection regions. The grid is automatically built over time from tracked object data.",
+ "clear": "Clear region grid",
+ "clearConfirmTitle": "Clear Region Grid",
+ "clearConfirmDesc": "Clearing the region grid is not recommended unless you have recently changed your detector model size or have changed your camera's physical position and are having object tracking issues. The grid will be automatically rebuilt over time as objects are tracked. A Frigate restart is required for changes to take effect.",
+ "clearSuccess": "Region grid cleared successfully",
+ "clearError": "Failed to clear region grid",
+ "restartRequired": "Restart required for region grid changes to take effect"
}
},
"configForm": {
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index e686ea241..ea6c3d650 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -40,7 +40,8 @@ import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
-import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
+import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
+import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView";
import {
SingleSectionPage,
@@ -154,7 +155,8 @@ const allSettingsViews = [
"roles",
"notifications",
"frigateplus",
- "maintenance",
+ "mediaSync",
+ "regionGrid",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
@@ -444,7 +446,10 @@ const settingsGroups = [
},
{
label: "maintenance",
- items: [{ key: "maintenance", component: MaintenanceSettingsView }],
+ items: [
+ { key: "mediaSync", component: MediaSyncSettingsView },
+ { key: "regionGrid", component: RegionGridSettingsView },
+ ],
},
];
@@ -471,6 +476,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [
"masksAndZones",
"motionTuner",
"triggers",
+ "regionGrid",
];
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
@@ -478,7 +484,8 @@ const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
const LARGE_BOTTOM_MARGIN_PAGES = [
"masksAndZones",
"motionTuner",
- "maintenance",
+ "mediaSync",
+ "regionGrid",
];
// keys for camera sections
diff --git a/web/src/views/settings/MaintenanceSettingsView.tsx b/web/src/views/settings/MediaSyncSettingsView.tsx
similarity index 99%
rename from web/src/views/settings/MaintenanceSettingsView.tsx
rename to web/src/views/settings/MediaSyncSettingsView.tsx
index df1dd7b90..afbfe3ea1 100644
--- a/web/src/views/settings/MaintenanceSettingsView.tsx
+++ b/web/src/views/settings/MediaSyncSettingsView.tsx
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { MediaSyncStats } from "@/types/ws";
-export default function MaintenanceSettingsView() {
+export default function MediaSyncSettingsView() {
const { t } = useTranslation("views/settings");
const [selectedMediaTypes, setSelectedMediaTypes] = useState
([
"all",
@@ -103,7 +103,7 @@ export default function MaintenanceSettingsView() {
-
+
{t("maintenance.sync.title")}
diff --git a/web/src/views/settings/RegionGridSettingsView.tsx b/web/src/views/settings/RegionGridSettingsView.tsx
new file mode 100644
index 000000000..8ab571a3f
--- /dev/null
+++ b/web/src/views/settings/RegionGridSettingsView.tsx
@@ -0,0 +1,124 @@
+import Heading from "@/components/ui/heading";
+import { Button, buttonVariants } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Toaster } from "@/components/ui/sonner";
+import { useCallback, useContext, useState } from "react";
+import { useTranslation } from "react-i18next";
+import axios from "axios";
+import { toast } from "sonner";
+import { StatusBarMessagesContext } from "@/context/statusbar-provider";
+import { cn } from "@/lib/utils";
+
+type RegionGridSettingsViewProps = {
+ selectedCamera: string;
+};
+
+export default function RegionGridSettingsView({
+ selectedCamera,
+}: RegionGridSettingsViewProps) {
+ const { t } = useTranslation("views/settings");
+ const { addMessage } = useContext(StatusBarMessagesContext)!;
+ const [isConfirmOpen, setIsConfirmOpen] = useState(false);
+ const [isClearing, setIsClearing] = useState(false);
+ const [imageKey, setImageKey] = useState(0);
+
+ const handleClear = useCallback(async () => {
+ setIsClearing(true);
+
+ try {
+ await axios.delete(`${selectedCamera}/region_grid`);
+ toast.success(t("maintenance.regionGrid.clearSuccess"), {
+ position: "top-center",
+ });
+ setImageKey((prev) => prev + 1);
+ addMessage(
+ "region_grid_restart",
+ t("maintenance.regionGrid.restartRequired"),
+ undefined,
+ "region_grid_settings",
+ );
+ } catch {
+ toast.error(t("maintenance.regionGrid.clearError"), {
+ position: "top-center",
+ });
+ } finally {
+ setIsClearing(false);
+ setIsConfirmOpen(false);
+ }
+ }, [selectedCamera, t, addMessage]);
+
+ return (
+ <>
+
+
+
+
+ {t("maintenance.regionGrid.title")}
+
+
+
+
+
{t("maintenance.regionGrid.desc")}
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+ {t("maintenance.regionGrid.clearConfirmTitle")}
+
+
+ {t("maintenance.regionGrid.clearConfirmDesc")}
+
+
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+ {t("maintenance.regionGrid.clear")}
+
+
+
+
+ >
+ );
+}