feat(genai): add TwelveLabs Marengo embeddings provider

Adds an opt-in 'twelvelabs' GenAI provider implementing the embeddings
role using TwelveLabs' Marengo multimodal model. Text and image inputs
are embedded into a shared vector space, powering semantic search over
event thumbnails.

The provider only implements the embeddings role; descriptions/chat are
left to other providers since Marengo is an embedding model. Marengo
returns 512-dim vectors, which the existing GenAIEmbedding adapter pads
to Frigate's 768-dim search schema.

Includes no-network unit tests and a live test gated on
TWELVELABS_API_KEY.
This commit is contained in:
Mohit Varikuti
2026-06-25 00:43:04 -07:00
parent ec3fb00494
commit 85e16eb31b
5 changed files with 301 additions and 1 deletions
+1
View File
@@ -50,6 +50,7 @@ transformers == 4.45.*
google-genai == 1.58.*
ollama == 0.6.*
openai == 1.65.*
twelvelabs == 1.2.*
# push notifications
py-vapid == 1.9.*
pywebpush == 2.0.*
+35 -1
View File
@@ -9,7 +9,7 @@ import NavPath from "@site/src/components/NavPath";
## Configuration
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 4 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently several native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
@@ -386,3 +386,37 @@ genai:
</TabItem>
</ConfigTabs>
### TwelveLabs (Marengo embeddings)
[TwelveLabs](https://twelvelabs.io) provides the Marengo multimodal model, which embeds text and images into a shared vector space. This makes it usable as the `embeddings` provider for Frigate's [Semantic Search](/configuration/semantic_search), letting a natural-language query match stored event thumbnails directly.
This provider implements only the `embeddings` role. Use a separate provider (such as Ollama, Gemini, or OpenAI) for the `descriptions` and `chat` roles if you want generated descriptions as well.
#### Get API Key
Create an API key from the [TwelveLabs dashboard](https://playground.twelvelabs.io/dashboard/api-key). There is a generous free tier.
#### Configuration
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
- Set **Provider** to `twelvelabs`
- Set **API key** to your TwelveLabs API key (or use an environment variable such as `{FRIGATE_TWELVELABS_API_KEY}`)
- Optionally set **Model** to override the default Marengo model (`marengo3.0`)
</TabItem>
<TabItem value="yaml">
```yaml
genai:
provider: twelvelabs
api_key: "{FRIGATE_TWELVELABS_API_KEY}"
roles:
- embeddings
```
</TabItem>
</ConfigTabs>
+1
View File
@@ -15,6 +15,7 @@ class GenAIProviderEnum(str, Enum):
gemini = "gemini"
ollama = "ollama"
llamacpp = "llamacpp"
twelvelabs = "twelvelabs"
class GenAIRoleEnum(str, Enum):
+120
View File
@@ -0,0 +1,120 @@
"""TwelveLabs provider for Frigate AI.
Provides multimodal embeddings for Frigate's semantic search via TwelveLabs'
Marengo model. Marengo produces text and image embeddings in a shared vector
space, so a natural-language query and a stored event thumbnail can be matched
directly — which is exactly what the ``embeddings`` GenAI role drives.
This provider is opt-in: it is only used when a ``genai`` config entry sets
``provider: twelvelabs`` and includes the ``embeddings`` role. It does not
implement the ``descriptions``/``chat`` roles — Marengo is an embedding model,
and TwelveLabs' Pegasus description model operates on whole video clips rather
than the per-frame thumbnails Frigate hands to ``_send``.
Marengo embeddings are 512-dimensional; Frigate's semantic search schema
expects 768 dimensions. The shared :class:`~frigate.embeddings.genai_embedding.GenAIEmbedding`
adapter zero-pads shorter vectors, so the dimension difference is handled
upstream and is consistent for both text and image inputs.
"""
import logging
import numpy as np
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
logger = logging.getLogger(__name__)
# Default Marengo model. Overridable via the `model` config field.
DEFAULT_MODEL = "marengo3.0"
@register_genai_provider(GenAIProviderEnum.twelvelabs)
class TwelveLabsClient(GenAIClient):
"""GenAI client for Frigate using TwelveLabs Marengo embeddings."""
def _init_provider(self):
"""Initialize the TwelveLabs SDK client."""
try:
from twelvelabs import TwelveLabs
except ImportError:
logger.error(
"The twelvelabs package is required for the TwelveLabs provider."
)
return None
if not self.genai_config.api_key:
logger.error("TwelveLabs provider requires an api_key.")
return None
return TwelveLabs(api_key=self.genai_config.api_key)
@property
def _model(self) -> str:
return self.genai_config.model or DEFAULT_MODEL
def list_models(self) -> list[str]:
"""Marengo is the embedding model exposed by this provider."""
return [DEFAULT_MODEL]
def embed(
self,
texts: list[str] | None = None,
images: list[bytes] | None = None,
) -> list[np.ndarray]:
"""Generate Marengo embeddings for text and/or images.
The TwelveLabs embed API embeds a single input per call, so inputs are
sent one at a time. Returns one 512-dim float32 vector per input, in
order (texts first, then images). The shared GenAIEmbedding adapter
pads these to Frigate's 768-dim search schema.
"""
if self.provider is None:
logger.warning(
"TwelveLabs provider has not been initialized. Check your configuration."
)
return []
results: list[np.ndarray] = []
for text in texts or []:
vector = self._embed_one(text=text)
if vector is not None:
results.append(vector)
for image in images or []:
vector = self._embed_one(image=image)
if vector is not None:
results.append(vector)
return results
def _embed_one(
self, text: str | None = None, image: bytes | None = None
) -> np.ndarray | None:
"""Embed a single text or image input, returning a float32 vector."""
try:
if text is not None:
response = self.provider.embed.create(
model_name=self._model,
text=text,
request_options={"timeout_in_seconds": self.timeout},
)
result = response.text_embedding
else:
response = self.provider.embed.create(
model_name=self._model,
image_file=image,
request_options={"timeout_in_seconds": self.timeout},
)
result = response.image_embedding
if result is None or not result.segments:
logger.warning("TwelveLabs returned no embedding for input.")
return None
return np.array(result.segments[0].float_, dtype=np.float32)
except Exception as e:
logger.warning("TwelveLabs returned an error: %s", e)
return None
+144
View File
@@ -0,0 +1,144 @@
"""Tests for the TwelveLabs GenAI provider (Marengo embeddings)."""
import io
import os
import unittest
from unittest.mock import MagicMock
import numpy as np
from frigate.config.camera.genai import (
GenAIConfig,
GenAIProviderEnum,
GenAIRoleEnum,
)
from frigate.genai.plugins.twelvelabs import DEFAULT_MODEL, TwelveLabsClient
def _make_config(model: str = "") -> GenAIConfig:
return GenAIConfig(
provider=GenAIProviderEnum.twelvelabs,
api_key="test-key",
model=model,
roles=[GenAIRoleEnum.embeddings],
)
def _segment(values):
"""Mimic the SDK BaseSegment shape (a `float_` list per segment)."""
seg = MagicMock()
seg.float_ = values
return seg
class TestTwelveLabsEmbedNoNetwork(unittest.TestCase):
"""Unit tests with the SDK client mocked — no network access."""
def _client_with_provider(self, provider) -> TwelveLabsClient:
client = TwelveLabsClient.__new__(TwelveLabsClient)
client.genai_config = _make_config()
client.timeout = 120
client.provider = provider
return client
def test_text_embedding_returns_vector(self):
provider = MagicMock()
response = MagicMock()
response.text_embedding.segments = [_segment([0.1, 0.2, 0.3])]
provider.embed.create.return_value = response
client = self._client_with_provider(provider)
out = client.embed(texts=["a person walking a dog"])
self.assertEqual(len(out), 1)
self.assertIsInstance(out[0], np.ndarray)
self.assertEqual(out[0].dtype, np.float32)
np.testing.assert_allclose(out[0], [0.1, 0.2, 0.3], rtol=1e-6)
_, kwargs = provider.embed.create.call_args
self.assertEqual(kwargs["model_name"], DEFAULT_MODEL)
self.assertEqual(kwargs["text"], "a person walking a dog")
def test_image_embedding_uses_image_file(self):
provider = MagicMock()
response = MagicMock()
response.image_embedding.segments = [_segment([1.0, 2.0])]
provider.embed.create.return_value = response
client = self._client_with_provider(provider)
out = client.embed(images=[b"\xff\xd8\xff jpeg bytes"])
self.assertEqual(len(out), 1)
_, kwargs = provider.embed.create.call_args
self.assertEqual(kwargs["image_file"], b"\xff\xd8\xff jpeg bytes")
self.assertNotIn("text", kwargs)
def test_custom_model_name_is_used(self):
provider = MagicMock()
response = MagicMock()
response.text_embedding.segments = [_segment([0.0])]
provider.embed.create.return_value = response
client = self._client_with_provider(provider)
client.genai_config = _make_config(model="marengo-custom")
client.embed(texts=["x"])
_, kwargs = provider.embed.create.call_args
self.assertEqual(kwargs["model_name"], "marengo-custom")
def test_empty_segments_are_skipped(self):
provider = MagicMock()
response = MagicMock()
response.text_embedding = None
provider.embed.create.return_value = response
client = self._client_with_provider(provider)
self.assertEqual(client.embed(texts=["x"]), [])
def test_api_error_is_swallowed(self):
provider = MagicMock()
provider.embed.create.side_effect = RuntimeError("boom")
client = self._client_with_provider(provider)
self.assertEqual(client.embed(texts=["x"]), [])
def test_no_provider_returns_empty(self):
client = self._client_with_provider(None)
self.assertEqual(client.embed(texts=["x"]), [])
def test_no_inputs_returns_empty(self):
client = self._client_with_provider(MagicMock())
self.assertEqual(client.embed(), [])
@unittest.skipUnless(
os.environ.get("TWELVELABS_API_KEY"),
"TWELVELABS_API_KEY not set; skipping live TwelveLabs API test",
)
class TestTwelveLabsEmbedLive(unittest.TestCase):
"""Live smoke test against the real TwelveLabs API (Marengo)."""
def _client(self) -> TwelveLabsClient:
config = _make_config()
config.api_key = os.environ["TWELVELABS_API_KEY"]
return TwelveLabsClient(config)
def test_text_embedding_dim(self):
out = self._client().embed(texts=["a delivery person at the front door"])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].shape, (512,))
def test_image_embedding_dim(self):
from PIL import Image
arr = (np.random.rand(224, 224, 3) * 255).astype("uint8")
buf = io.BytesIO()
Image.fromarray(arr, "RGB").save(buf, format="JPEG")
out = self._client().embed(images=[buf.getvalue()])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].shape, (512,))
if __name__ == "__main__":
unittest.main()