Measure Gemini image-token cost instead of using the pixel heuristic (#24469)

* Measure Gemini image-token cost instead of using the pixel heuristic

Review descriptions size their frame budget from the provider's
estimate_image_tokens(). Gemini did not override it, so the base
~1-token-per-1250-pixels heuristic was used: about 46 tokens for a 320x180
preview frame. Gemini 3 models actually bill about 1100 tokens per image
regardless of size, so the 24000-token image budget let Frigate send one
frame per second of the review item (e.g. 90 frames for a 90 s alert)
instead of ~21, and review descriptions cost roughly 4x more than intended.

Probe the model with the free count_tokens endpoint, the same way the
llama.cpp provider measures its mmproj, and cache the result per image
size. Fall back to the base heuristic if the request fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bound Gemini image-token probes and back off after failures

estimate_image_tokens() runs on the enrichment thread, and count_tokens was
using the client's defaults: a 120 s timeout and 3 attempts on 429/5xx. A
hung endpoint could stall review processing for minutes per review, and a
rate-limited key retried on every review of an unmeasured size.

Give the probe a 5 s timeout with no retries, and after a failure use the
heuristic without probing again for 5 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update docs to note that Gemini charges roughly-flat token cost regardless of video resolution

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Knowles
2026-11-06 07:47:44 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 52c6175255
commit 463942e01d
3 changed files with 136 additions and 2 deletions
+50
View File
@@ -431,6 +431,56 @@ class TestGeminiProvider(unittest.TestCase):
self.assertEqual(final["content"], "ok")
class TestGeminiImageTokens(unittest.TestCase):
def _client(self, count_tokens):
client = _make_client("gemini", model="gemini-3-flash", api_key="k")
client.provider = SimpleNamespace(
models=SimpleNamespace(count_tokens=count_tokens)
)
return client
def test_measures_image_cost_and_caches_per_size(self):
count_tokens = MagicMock(
side_effect=lambda model, contents, config: SimpleNamespace(
total_tokens=2 if len(contents) == 1 else 1102
)
)
client = self._client(count_tokens)
self.assertEqual(client.estimate_image_tokens(320, 180), 1100)
self.assertEqual(client.estimate_image_tokens(320, 180), 1100)
self.assertEqual(count_tokens.call_count, 2)
image = count_tokens.call_args.kwargs["contents"][1]
self.assertEqual(image.inline_data.mime_type, "image/jpeg")
self.assertEqual(count_tokens.call_args.kwargs["model"], "gemini-3-flash")
http_options = count_tokens.call_args.kwargs["config"].http_options
self.assertEqual(http_options.timeout, 5000)
self.assertEqual(http_options.retry_options.attempts, 1)
client.estimate_image_tokens(640, 360)
self.assertEqual(count_tokens.call_count, 4)
def test_failure_falls_back_to_heuristic_and_waits_before_retrying(self):
count_tokens = MagicMock(side_effect=RuntimeError("offline"))
client = self._client(count_tokens)
with patch("frigate.genai.plugins.gemini.time.monotonic", return_value=1000):
self.assertEqual(client.estimate_image_tokens(320, 180), 320 * 180 / 1250)
self.assertEqual(client.estimate_image_tokens(640, 360), 640 * 360 / 1250)
self.assertEqual(count_tokens.call_count, 1)
with patch("frigate.genai.plugins.gemini.time.monotonic", return_value=1300):
client.estimate_image_tokens(320, 180)
self.assertEqual(count_tokens.call_count, 2)
def test_missing_total_falls_back_to_heuristic(self):
client = self._client(
MagicMock(return_value=SimpleNamespace(total_tokens=None))
)
self.assertEqual(client.estimate_image_tokens(320, 180), 320 * 180 / 1250)
# ---------------------------------------------------------------------------
# Ollama
# ---------------------------------------------------------------------------