mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-05 05:10:33 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3abd472a | ||
|
|
9fe12ed0b7 |
+1
-3
@@ -1,6 +1,5 @@
|
|||||||
"""Chat and LLM tool calling APIs."""
|
"""Chat and LLM tool calling APIs."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -1654,8 +1653,7 @@ async def chat_completion(
|
|||||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||||
f"with {len(conversation)} message(s) in conversation"
|
f"with {len(conversation)} message(s) in conversation"
|
||||||
)
|
)
|
||||||
response = await asyncio.to_thread(
|
response = genai_client.chat_with_tools(
|
||||||
genai_client.chat_with_tools,
|
|
||||||
messages=conversation,
|
messages=conversation,
|
||||||
tools=llm_tools if llm_tools else None,
|
tools=llm_tools if llm_tools else None,
|
||||||
tool_choice="auto",
|
tool_choice="auto",
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
|
|||||||
return payload or MQTT_NO_MODES
|
return payload or MQTT_NO_MODES
|
||||||
|
|
||||||
|
|
||||||
|
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
|
||||||
|
"""Return the default Birdseye activity modes."""
|
||||||
|
return [BirdseyeModeEnum.all_objects]
|
||||||
|
|
||||||
|
|
||||||
class BirdseyeLayoutConfig(FrigateBaseModel):
|
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||||
scaling_factor: float = Field(
|
scaling_factor: float = Field(
|
||||||
default=2.0,
|
default=2.0,
|
||||||
@@ -79,7 +84,7 @@ class BirdseyeConfig(FrigateBaseModel):
|
|||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
modes: list[BirdseyeModeEnum] = Field(
|
modes: list[BirdseyeModeEnum] = Field(
|
||||||
default=[BirdseyeModeEnum.all_objects],
|
default_factory=default_birdseye_modes,
|
||||||
title="Activity types",
|
title="Activity types",
|
||||||
description="Activity types that include cameras in Birdseye.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
@@ -134,7 +139,7 @@ class BirdseyeCameraConfig(BaseModel):
|
|||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
modes: list[BirdseyeModeEnum] = Field(
|
modes: list[BirdseyeModeEnum] = Field(
|
||||||
default=[BirdseyeModeEnum.all_objects],
|
default_factory=default_birdseye_modes,
|
||||||
title="Activity types",
|
title="Activity types",
|
||||||
description="Activity types that include cameras in Birdseye.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ overrides what is genuinely Azure-specific:
|
|||||||
|
|
||||||
- Client construction: parses ``api-version`` out of the configured
|
- Client construction: parses ``api-version`` out of the configured
|
||||||
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
|
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
|
||||||
(or :class:`openai.AsyncAzureOpenAI` for streaming chat) with
|
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||||
``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
|
||||||
malformed; :class:`GenAIClientManager` catches the exception and
|
malformed; :class:`GenAIClientManager` catches the exception and
|
||||||
disables the provider.
|
disables the provider.
|
||||||
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
||||||
@@ -30,10 +29,9 @@ the ``transcribe`` role:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from openai import AsyncAzureOpenAI, AzureOpenAI
|
from openai import AzureOpenAI
|
||||||
|
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
from frigate.genai import register_genai_provider
|
from frigate.genai import register_genai_provider
|
||||||
@@ -46,8 +44,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class AzureOpenAIClient(OpenAIClient):
|
class AzureOpenAIClient(OpenAIClient):
|
||||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||||
|
|
||||||
def _client_options(self) -> dict[str, Any]:
|
def _init_provider(self) -> AzureOpenAI:
|
||||||
"""Build the Azure client options from the configured base_url."""
|
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||||
query_params = parse_qs(parsed_url.query)
|
query_params = parse_qs(parsed_url.query)
|
||||||
api_version = query_params.get("api-version", [None])[0]
|
api_version = query_params.get("api-version", [None])[0]
|
||||||
@@ -55,19 +53,13 @@ class AzureOpenAIClient(OpenAIClient):
|
|||||||
if not api_version:
|
if not api_version:
|
||||||
raise ValueError("Azure OpenAI base_url is missing api-version.")
|
raise ValueError("Azure OpenAI base_url is missing api-version.")
|
||||||
|
|
||||||
return {
|
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||||
"api_key": self.genai_config.api_key,
|
|
||||||
"api_version": api_version,
|
|
||||||
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _init_provider(self) -> AzureOpenAI:
|
return AzureOpenAI(
|
||||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
api_key=self.genai_config.api_key,
|
||||||
return AzureOpenAI(**self._client_options())
|
api_version=api_version,
|
||||||
|
azure_endpoint=azure_endpoint,
|
||||||
def _init_async_provider(self) -> AsyncAzureOpenAI:
|
)
|
||||||
"""Create an async Azure client for streaming chat."""
|
|
||||||
return AsyncAzureOpenAI(**self._client_options())
|
|
||||||
|
|
||||||
def get_context_size(self) -> int:
|
def get_context_size(self) -> int:
|
||||||
"""Azure does not reliably surface per-model context size; use 128K."""
|
"""Azure does not reliably surface per-model context size; use 128K."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from httpx import TimeoutException
|
from httpx import TimeoutException
|
||||||
from openai import AsyncOpenAI, OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
from frigate.genai import GenAIClient, register_genai_provider
|
from frigate.genai import GenAIClient, register_genai_provider
|
||||||
@@ -15,7 +15,6 @@ from frigate.genai.utils import interleave_images
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# gpt-transcribe replaced the singular `language` field with a `languages` array
|
# gpt-transcribe replaced the singular `language` field with a `languages` array
|
||||||
# and rejects a request that sends both. Older transcription models
|
# and rejects a request that sends both. Older transcription models
|
||||||
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
|
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
|
||||||
@@ -46,8 +45,8 @@ class OpenAIClient(GenAIClient):
|
|||||||
provider: OpenAI
|
provider: OpenAI
|
||||||
context_size: int | None = None
|
context_size: int | None = None
|
||||||
|
|
||||||
def _client_options(self) -> dict[str, Any]:
|
def _init_provider(self) -> OpenAI:
|
||||||
"""Build the constructor options shared by the sync and async clients.
|
"""Initialize the client.
|
||||||
|
|
||||||
Subclasses (e.g. Azure) should raise on configuration errors; the
|
Subclasses (e.g. Azure) should raise on configuration errors; the
|
||||||
manager catches construction failures and disables the provider.
|
manager catches construction failures and disables the provider.
|
||||||
@@ -63,19 +62,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
if self.genai_config.base_url:
|
if self.genai_config.base_url:
|
||||||
provider_opts["base_url"] = self.genai_config.base_url
|
provider_opts["base_url"] = self.genai_config.base_url
|
||||||
|
|
||||||
return {"api_key": self.genai_config.api_key, **provider_opts}
|
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
|
||||||
|
|
||||||
def _init_provider(self) -> OpenAI:
|
|
||||||
"""Initialize the client."""
|
|
||||||
return OpenAI(**self._client_options())
|
|
||||||
|
|
||||||
def _init_async_provider(self) -> AsyncOpenAI:
|
|
||||||
"""Create an async client for streaming chat.
|
|
||||||
|
|
||||||
A new client is created per stream because its connection pool binds
|
|
||||||
to the event loop that first uses it.
|
|
||||||
"""
|
|
||||||
return AsyncOpenAI(**self._client_options())
|
|
||||||
|
|
||||||
def _send(
|
def _send(
|
||||||
self,
|
self,
|
||||||
@@ -425,13 +412,9 @@ class OpenAIClient(GenAIClient):
|
|||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
usage_stats: dict[str, Any] | None = None
|
usage_stats: dict[str, Any] | None = None
|
||||||
|
|
||||||
# Exiting these blocks, including when the caller disconnects and
|
stream = self.provider.chat.completions.create(**request_params)
|
||||||
# the generator is cancelled, closes the response and its connection
|
|
||||||
async with (
|
for chunk in stream:
|
||||||
self._init_async_provider() as client,
|
|
||||||
await client.chat.completions.create(**request_params) as stream,
|
|
||||||
):
|
|
||||||
async for chunk in stream:
|
|
||||||
chunk_usage = getattr(chunk, "usage", None)
|
chunk_usage = getattr(chunk, "usage", None)
|
||||||
if chunk_usage is not None:
|
if chunk_usage is not None:
|
||||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||||
@@ -448,9 +431,9 @@ class OpenAIClient(GenAIClient):
|
|||||||
|
|
||||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||||
# depending on the server)
|
# depending on the server)
|
||||||
reasoning_delta = getattr(
|
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
|
||||||
delta, "reasoning_content", None
|
delta, "reasoning", None
|
||||||
) or getattr(delta, "reasoning", None)
|
)
|
||||||
if reasoning_delta:
|
if reasoning_delta:
|
||||||
reasoning_parts.append(reasoning_delta)
|
reasoning_parts.append(reasoning_delta)
|
||||||
yield ("reasoning_delta", reasoning_delta)
|
yield ("reasoning_delta", reasoning_delta)
|
||||||
|
|||||||
@@ -233,8 +233,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
and camera_config.onvif.autotracking.enabled_in_config
|
and camera_config.onvif.autotracking.enabled_in_config
|
||||||
):
|
):
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
self._autotracker_setup(camera_config, camera, startup=True),
|
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||||
self.onvif.loop,
|
|
||||||
)
|
)
|
||||||
# Wait for the coroutine to complete
|
# Wait for the coroutine to complete
|
||||||
future.result()
|
future.result()
|
||||||
@@ -262,9 +261,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
logger.info("Exiting autotracker...")
|
logger.info("Exiting autotracker...")
|
||||||
|
|
||||||
async def _autotracker_setup(
|
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||||
self, camera_config: CameraConfig, camera: str, startup: bool = False
|
|
||||||
):
|
|
||||||
logger.debug(f"{camera}: Autotracker init")
|
logger.debug(f"{camera}: Autotracker init")
|
||||||
|
|
||||||
self.tracked_object[camera] = None
|
self.tracked_object[camera] = None
|
||||||
@@ -301,6 +298,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.onvif.cams[camera]["init"]:
|
||||||
await self.onvif.get_camera_status(camera)
|
await self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
# movement queue with asyncio on OnvifController loop
|
# movement queue with asyncio on OnvifController loop
|
||||||
@@ -317,7 +315,7 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
self.zoom_time[camera],
|
self.zoom_time[camera],
|
||||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||||
|
|
||||||
if startup and camera_config.onvif.autotracking.calibrate_on_startup:
|
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||||
await self._calibrate_camera(camera)
|
await self._calibrate_camera(camera)
|
||||||
|
|
||||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||||
@@ -333,7 +331,6 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||||
autotracking_config,
|
autotracking_config,
|
||||||
)
|
)
|
||||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/state", "OFF", retain=True)
|
|
||||||
|
|
||||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
||||||
self.tracked_object_metrics[camera] = {}
|
self.tracked_object_metrics[camera] = {}
|
||||||
@@ -1272,9 +1269,6 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# Wait for the coroutine to complete
|
# Wait for the coroutine to complete
|
||||||
future.result()
|
future.result()
|
||||||
|
|
||||||
if not camera_config.onvif.autotracking.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.calibrating[camera]:
|
if self.calibrating[camera]:
|
||||||
logger.debug(f"{camera}: Calibrating camera")
|
logger.debug(f"{camera}: Calibrating camera")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -145,72 +145,12 @@ def _openai_chunk(content=None, tool_calls=None, finish_reason=None, usage=None)
|
|||||||
return SimpleNamespace(choices=[choice], usage=usage)
|
return SimpleNamespace(choices=[choice], usage=usage)
|
||||||
|
|
||||||
|
|
||||||
class _FakeAsyncOpenAIStream:
|
|
||||||
"""Mimics openai.AsyncStream: async iterable and async context manager.
|
|
||||||
|
|
||||||
With ``stall_after`` set, the stream stops producing after that many
|
|
||||||
chunks, like a model that stops sending tokens, and sets ``stalled``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, chunks, stall_after=None):
|
|
||||||
self._chunks = chunks
|
|
||||||
self._stall_after = stall_after
|
|
||||||
self.stalled = asyncio.Event()
|
|
||||||
self.close = AsyncMock()
|
|
||||||
|
|
||||||
async def __aiter__(self):
|
|
||||||
for i, chunk in enumerate(self._chunks):
|
|
||||||
if i == self._stall_after:
|
|
||||||
self.stalled.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *exc):
|
|
||||||
await self.close()
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeAsyncOpenAI:
|
|
||||||
"""Mimics openai.AsyncOpenAI: async context manager with a completions API."""
|
|
||||||
|
|
||||||
def __init__(self, create):
|
|
||||||
self.chat = SimpleNamespace(completions=SimpleNamespace(create=create))
|
|
||||||
self.close = AsyncMock()
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *exc):
|
|
||||||
await self.close()
|
|
||||||
|
|
||||||
|
|
||||||
class TestOpenAIProvider(unittest.TestCase):
|
class TestOpenAIProvider(unittest.TestCase):
|
||||||
def _client(self):
|
def _client(self):
|
||||||
return _make_client(
|
return _make_client(
|
||||||
"openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1"
|
"openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _streaming_client(self, create):
|
|
||||||
"""Build a client whose async completions call is ``create``."""
|
|
||||||
client = self._client()
|
|
||||||
async_client = _FakeAsyncOpenAI(create)
|
|
||||||
client._init_async_provider = MagicMock(return_value=async_client)
|
|
||||||
return client, async_client
|
|
||||||
|
|
||||||
def test_async_client_uses_configured_options(self):
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
|
||||||
client = self._client()
|
|
||||||
async_client = client._init_async_provider()
|
|
||||||
try:
|
|
||||||
self.assertIsInstance(async_client, AsyncOpenAI)
|
|
||||||
self.assertEqual(async_client.base_url, client.provider.base_url)
|
|
||||||
self.assertEqual(async_client.api_key, "k")
|
|
||||||
finally:
|
|
||||||
asyncio.run(async_client.close())
|
|
||||||
|
|
||||||
def test_stream_tool_call_arguments_are_dict(self):
|
def test_stream_tool_call_arguments_are_dict(self):
|
||||||
# Arguments arrive split across chunks, as the real API streams them.
|
# Arguments arrive split across chunks, as the real API streams them.
|
||||||
chunks = [
|
chunks = [
|
||||||
@@ -222,9 +162,8 @@ class TestOpenAIProvider(unittest.TestCase):
|
|||||||
_openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]),
|
_openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]),
|
||||||
_openai_chunk(finish_reason="tool_calls"),
|
_openai_chunk(finish_reason="tool_calls"),
|
||||||
]
|
]
|
||||||
client, _ = self._streaming_client(
|
client = self._client()
|
||||||
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
|
client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
|
||||||
)
|
|
||||||
|
|
||||||
final = _final_message(_collect(client, SIMPLE_MESSAGES))
|
final = _final_message(_collect(client, SIMPLE_MESSAGES))
|
||||||
self.assertEqual(final["finish_reason"], "tool_calls")
|
self.assertEqual(final["finish_reason"], "tool_calls")
|
||||||
@@ -238,9 +177,8 @@ class TestOpenAIProvider(unittest.TestCase):
|
|||||||
_openai_chunk(content="lo"),
|
_openai_chunk(content="lo"),
|
||||||
_openai_chunk(finish_reason="stop"),
|
_openai_chunk(finish_reason="stop"),
|
||||||
]
|
]
|
||||||
client, _ = self._streaming_client(
|
client = self._client()
|
||||||
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
|
client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
|
||||||
)
|
|
||||||
|
|
||||||
events = _collect(client, SIMPLE_MESSAGES)
|
events = _collect(client, SIMPLE_MESSAGES)
|
||||||
deltas = [v for (k, v) in events if k == "content_delta"]
|
deltas = [v for (k, v) in events if k == "content_delta"]
|
||||||
@@ -248,84 +186,14 @@ class TestOpenAIProvider(unittest.TestCase):
|
|||||||
self.assertEqual(_final_message(events)["content"], "hello")
|
self.assertEqual(_final_message(events)["content"], "hello")
|
||||||
|
|
||||||
def test_multimodal_message_does_not_crash(self):
|
def test_multimodal_message_does_not_crash(self):
|
||||||
client, _ = self._streaming_client(
|
client = self._client()
|
||||||
AsyncMock(
|
client.provider.chat.completions.create = MagicMock(
|
||||||
return_value=_FakeAsyncOpenAIStream(
|
return_value=iter([_openai_chunk(content="ok", finish_reason="stop")])
|
||||||
[_openai_chunk(content="ok", finish_reason="stop")]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# Passing the OpenAI-native multimodal list through must not raise.
|
# Passing the OpenAI-native multimodal list through must not raise.
|
||||||
final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
|
final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
|
||||||
self.assertEqual(final["content"], "ok")
|
self.assertEqual(final["content"], "ok")
|
||||||
|
|
||||||
def test_stream_uses_async_client_and_closes_it(self):
|
|
||||||
# The blocking sync client must never be used on the event loop.
|
|
||||||
stream = _FakeAsyncOpenAIStream(
|
|
||||||
[_openai_chunk(content="ok", finish_reason="stop")]
|
|
||||||
)
|
|
||||||
create = AsyncMock(return_value=stream)
|
|
||||||
client, async_client = self._streaming_client(create)
|
|
||||||
client.provider.chat.completions.create = MagicMock()
|
|
||||||
|
|
||||||
final = _final_message(_collect(client, SIMPLE_MESSAGES))
|
|
||||||
|
|
||||||
self.assertEqual(final["content"], "ok")
|
|
||||||
self.assertTrue(create.await_args.kwargs["stream"])
|
|
||||||
client.provider.chat.completions.create.assert_not_called()
|
|
||||||
stream.close.assert_awaited_once()
|
|
||||||
async_client.close.assert_awaited_once()
|
|
||||||
|
|
||||||
def test_client_closed_when_caller_leaves_while_opening(self):
|
|
||||||
# Cancelling before the response arrives must still close the client.
|
|
||||||
async def _run():
|
|
||||||
opening = asyncio.Event()
|
|
||||||
|
|
||||||
async def hang(**kwargs):
|
|
||||||
opening.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
client, async_client = self._streaming_client(hang)
|
|
||||||
gen = client.chat_with_tools_stream(
|
|
||||||
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
|
|
||||||
)
|
|
||||||
task = asyncio.create_task(gen.__anext__())
|
|
||||||
await opening.wait()
|
|
||||||
task.cancel()
|
|
||||||
with self.assertRaises(asyncio.CancelledError):
|
|
||||||
await asyncio.wait_for(task, timeout=1)
|
|
||||||
return async_client
|
|
||||||
|
|
||||||
async_client = asyncio.run(_run())
|
|
||||||
async_client.close.assert_awaited_once()
|
|
||||||
|
|
||||||
def test_stream_closed_when_caller_leaves_mid_stream(self):
|
|
||||||
# Cancelling while waiting on a stalled model must close the stream
|
|
||||||
# right away instead of waiting for the next chunk.
|
|
||||||
async def _run():
|
|
||||||
stream = _FakeAsyncOpenAIStream(
|
|
||||||
[_openai_chunk(content="a"), _openai_chunk(finish_reason="stop")],
|
|
||||||
stall_after=1,
|
|
||||||
)
|
|
||||||
client, async_client = self._streaming_client(
|
|
||||||
AsyncMock(return_value=stream)
|
|
||||||
)
|
|
||||||
gen = client.chat_with_tools_stream(
|
|
||||||
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
|
|
||||||
)
|
|
||||||
first = await gen.__anext__()
|
|
||||||
task = asyncio.create_task(gen.__anext__())
|
|
||||||
await stream.stalled.wait()
|
|
||||||
task.cancel()
|
|
||||||
with self.assertRaises(asyncio.CancelledError):
|
|
||||||
await asyncio.wait_for(task, timeout=1)
|
|
||||||
return first, stream, async_client
|
|
||||||
|
|
||||||
first, stream, async_client = asyncio.run(_run())
|
|
||||||
self.assertEqual(first, ("content_delta", "a"))
|
|
||||||
stream.close.assert_awaited_once()
|
|
||||||
async_client.close.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gemini
|
# Gemini
|
||||||
@@ -1072,17 +940,6 @@ class TestAzureOpenAITranscribe(unittest.TestCase):
|
|||||||
self.assertIsInstance(client.provider, AzureOpenAI)
|
self.assertIsInstance(client.provider, AzureOpenAI)
|
||||||
self.assertTrue(client.supports_transcription)
|
self.assertTrue(client.supports_transcription)
|
||||||
|
|
||||||
def test_streaming_routes_through_async_azure_client(self):
|
|
||||||
from openai import AsyncAzureOpenAI
|
|
||||||
|
|
||||||
client = self._client()
|
|
||||||
async_client = client._init_async_provider()
|
|
||||||
try:
|
|
||||||
self.assertIsInstance(async_client, AsyncAzureOpenAI)
|
|
||||||
self.assertEqual(async_client.base_url, client.provider.base_url)
|
|
||||||
finally:
|
|
||||||
asyncio.run(async_client.close())
|
|
||||||
|
|
||||||
def test_transcribe_inherited(self):
|
def test_transcribe_inherited(self):
|
||||||
client = self._client()
|
client = self._client()
|
||||||
create = MagicMock(return_value="azure text")
|
create = MagicMock(return_value="azure text")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Section reset button visibility -- MEDIUM tier.
|
|
||||||
*
|
|
||||||
* Reset deletes the section from config.yml, which fails when the key isn't
|
|
||||||
* in the file. The button only shows when the section's values must have
|
|
||||||
* come from the YAML.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { resolve, dirname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { test, expect, type FrigateApp } from "../../fixtures/frigate-test";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import {
|
|
||||||
configFactory,
|
|
||||||
type DeepPartial,
|
|
||||||
} from "../../fixtures/mock-data/config";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const CONFIG_SCHEMA = JSON.parse(
|
|
||||||
readFileSync(
|
|
||||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
|
||||||
"utf-8",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function openSettings(
|
|
||||||
app: FrigateApp,
|
|
||||||
url: string,
|
|
||||||
overrides: DeepPartial<ReturnType<typeof configFactory>> = {},
|
|
||||||
) {
|
|
||||||
const { page } = app;
|
|
||||||
const config = configFactory(overrides);
|
|
||||||
|
|
||||||
await page.route("**/api/config/schema.json", (route) =>
|
|
||||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
|
||||||
);
|
|
||||||
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
|
||||||
await page.route("**/api/config/raw_paths", (route) =>
|
|
||||||
route.fulfill({ json: { cameras: {}, go2rtc: { streams: {} } } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.goto(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetToDefault = (page: Page) =>
|
|
||||||
page.getByRole("button", { name: "Reset to Default" });
|
|
||||||
const resetToGlobal = (page: Page) =>
|
|
||||||
page.getByRole("button", { name: "Reset to Global" });
|
|
||||||
|
|
||||||
test.describe("section reset visibility @medium", () => {
|
|
||||||
test("global section at its defaults hides reset", async ({ frigateApp }) => {
|
|
||||||
await openSettings(frigateApp, "/settings?page=globalRecording");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Retention", { exact: false }).first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("global section with a changed value shows reset", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await openSettings(frigateApp, "/settings?page=globalRecording", {
|
|
||||||
record: { expire_interval: 30 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(resetToDefault(frigateApp.page)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("global ffmpeg with only resolved hwaccel args hides reset", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await openSettings(frigateApp, "/settings?page=globalFfmpeg");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page
|
|
||||||
.getByText("Hardware acceleration", { exact: false })
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("camera live with only backend streams hides reset", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await openSettings(
|
|
||||||
frigateApp,
|
|
||||||
"/settings?page=cameraLivePlayback&camera=front_door",
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByRole("switch", {
|
|
||||||
name: "Enable transcoded streams",
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("camera live with a quality override shows reset", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await openSettings(
|
|
||||||
frigateApp,
|
|
||||||
"/settings?page=cameraLivePlayback&camera=front_door",
|
|
||||||
{ cameras: { front_door: { live: { quality: 4 } } } },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(resetToGlobal(frigateApp.page)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("camera ffmpeg never shows reset", async ({ frigateApp }) => {
|
|
||||||
await openSettings(
|
|
||||||
frigateApp,
|
|
||||||
"/settings?page=cameraFfmpeg&camera=front_door",
|
|
||||||
{ cameras: { front_door: { ffmpeg: { output_args: { record: "x" } } } } },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page
|
|
||||||
.getByText("Hardware acceleration", { exact: false })
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -53,7 +53,6 @@ const audio: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["num_threads"],
|
restartRequired: ["num_threads"],
|
||||||
runtimeFields: ["filters"],
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["num_threads"],
|
restartRequired: ["num_threads"],
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
hiddenFields: ["retry_interval"],
|
hiddenFields: ["retry_interval"],
|
||||||
advancedFields: ["path", "global_args", "gpu"],
|
advancedFields: ["path", "global_args", "gpu"],
|
||||||
overrideFields: [
|
overrideFields: [
|
||||||
|
"inputs",
|
||||||
"path",
|
"path",
|
||||||
"global_args",
|
"global_args",
|
||||||
"input_args",
|
"input_args",
|
||||||
@@ -168,7 +169,6 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
runtimeFields: ["hwaccel_args"],
|
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"hwaccel_args",
|
"hwaccel_args",
|
||||||
"path",
|
"path",
|
||||||
@@ -194,8 +194,6 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
// reset would delete the camera's inputs along with its overrides
|
|
||||||
hideReset: true,
|
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const live: SectionConfigOverrides = {
|
|||||||
fieldGroups: {},
|
fieldGroups: {},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config"],
|
||||||
advancedFields: ["height", "quality"],
|
advancedFields: ["height", "quality"],
|
||||||
overrideFields: ["transcode", "height", "quality"],
|
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["streams", "height", "quality"],
|
restartRequired: ["streams", "height", "quality"],
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const modelFields = [
|
|||||||
const models: SectionConfigOverrides = {
|
const models: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/object_detectors",
|
sectionDocs: "/configuration/object_detectors",
|
||||||
hideReset: true,
|
|
||||||
// the default-model rule must be enforced as the list is edited, not
|
// the default-model rule must be enforced as the list is edited, not
|
||||||
// only when the form is submitted
|
// only when the form is submitted
|
||||||
liveValidate: true,
|
liveValidate: true,
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ const record: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
runtimeFields: ["export.hwaccel_args"],
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import get from "lodash/get";
|
import get from "lodash/get";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import omit from "lodash/omit";
|
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -55,7 +54,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { cn, isJsonObject } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ConfigSectionData,
|
ConfigSectionData,
|
||||||
HiddenFieldEntry,
|
HiddenFieldEntry,
|
||||||
@@ -126,10 +125,6 @@ export interface SectionConfig {
|
|||||||
fieldMessages?: FieldConditionalMessage[];
|
fieldMessages?: FieldConditionalMessage[];
|
||||||
/** Maps whose key order is saved (e.g. live stream ladder order) */
|
/** Maps whose key order is saved (e.g. live stream ladder order) */
|
||||||
orderedMaps?: string[];
|
orderedMaps?: string[];
|
||||||
/** Fields the backend resolves at runtime, ignored when checking for reset */
|
|
||||||
runtimeFields?: string[];
|
|
||||||
/** Hide the Reset to Global / Reset to Default button */
|
|
||||||
hideReset?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseSectionProps {
|
export interface BaseSectionProps {
|
||||||
@@ -200,17 +195,6 @@ export interface CreateSectionOptions {
|
|||||||
|
|
||||||
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
|
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
|
||||||
|
|
||||||
// applySchemaDefaults only descends into objects present in the data, and
|
|
||||||
// default_factory objects carry no schema default, so keep the nesting.
|
|
||||||
function objectShape(value: unknown): Record<string, unknown> {
|
|
||||||
if (!isJsonObject(value)) return {};
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value)
|
|
||||||
.filter(([, v]) => isJsonObject(v))
|
|
||||||
.map(([k, v]) => [k, objectShape(v)]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfigSection({
|
export function ConfigSection({
|
||||||
sectionPath,
|
sectionPath,
|
||||||
defaultConfig,
|
defaultConfig,
|
||||||
@@ -508,15 +492,6 @@ export function ConfigSection({
|
|||||||
[rawFormData, sanitizeSectionData],
|
[rawFormData, sanitizeSectionData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A section at its schema defaults may be absent from the YAML, and
|
|
||||||
// deleting an absent key fails. Non-default values must come from the YAML.
|
|
||||||
const globalDiffersFromDefaults = useMemo(() => {
|
|
||||||
if (!modifiedSchema) return true;
|
|
||||||
const current = omit(compareBaseData, sectionConfig.runtimeFields ?? []);
|
|
||||||
const defaults = applySchemaDefaults(modifiedSchema, objectShape(current));
|
|
||||||
return buildOverrides(current, undefined, defaults) !== undefined;
|
|
||||||
}, [compareBaseData, modifiedSchema, sectionConfig.runtimeFields]);
|
|
||||||
|
|
||||||
const orderedMaps = sectionConfig.orderedMaps;
|
const orderedMaps = sectionConfig.orderedMaps;
|
||||||
const buildSectionOverrides = useCallback(
|
const buildSectionOverrides = useCallback(
|
||||||
(current: unknown, base: unknown, defaults: unknown) =>
|
(current: unknown, base: unknown, defaults: unknown) =>
|
||||||
@@ -1158,8 +1133,7 @@ export function ConfigSection({
|
|||||||
)}
|
)}
|
||||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
||||||
{((effectiveLevel === "camera" && isOverridden) ||
|
{((effectiveLevel === "camera" && isOverridden) ||
|
||||||
(effectiveLevel === "global" && globalDiffersFromDefaults)) &&
|
effectiveLevel === "global") &&
|
||||||
!sectionConfig.hideReset &&
|
|
||||||
!hasChanges &&
|
!hasChanges &&
|
||||||
!skipSave &&
|
!skipSave &&
|
||||||
!profileName && (
|
!profileName && (
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
|
|||||||
{ key: "review" },
|
{ key: "review" },
|
||||||
{ key: "audio" },
|
{ key: "audio" },
|
||||||
{ key: "notifications" },
|
{ key: "notifications" },
|
||||||
{ key: "live", compareFields: ["transcode", "height", "quality"] },
|
{ key: "live" },
|
||||||
{ key: "timestamp_style" },
|
{ key: "timestamp_style" },
|
||||||
{
|
{
|
||||||
key: "audio_transcription",
|
key: "audio_transcription",
|
||||||
|
|||||||
Reference in New Issue
Block a user