Refactor to use async openai

This commit is contained in:
Nicolas Mowen
2026-11-04 10:42:02 -07:00
parent 169c5e1303
commit ef0de74fa5
3 changed files with 158 additions and 122 deletions
+18 -10
View File
@@ -7,7 +7,8 @@ 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`
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is (or :class:`openai.AsyncAzureOpenAI` for streaming chat) with
``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
@@ -29,9 +30,10 @@ 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 AzureOpenAI from openai import AsyncAzureOpenAI, 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
@@ -44,8 +46,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 _init_provider(self) -> AzureOpenAI: def _client_options(self) -> dict[str, Any]:
"""Initialize the AzureOpenAI client from the configured base_url.""" """Build the Azure client options 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]
@@ -53,13 +55,19 @@ 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.")
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/" return {
"api_key": self.genai_config.api_key,
"api_version": api_version,
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
}
return AzureOpenAI( def _init_provider(self) -> AzureOpenAI:
api_key=self.genai_config.api_key, """Initialize the AzureOpenAI client from the configured base_url."""
api_version=api_version, return AzureOpenAI(**self._client_options())
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."""
+24 -25
View File
@@ -1,15 +1,13 @@
"""OpenAI Provider for Frigate AI.""" """OpenAI Provider for Frigate AI."""
import asyncio
import base64 import base64
import json import json
import logging import logging
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator
from typing import Any from typing import Any
from httpx import TimeoutException from httpx import TimeoutException
from openai import OpenAI from openai import AsyncOpenAI, OpenAI
from starlette.concurrency import iterate_in_threadpool
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
@@ -18,18 +16,6 @@ from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _open_stream(create: Callable[..., Any], **kwargs: Any) -> Any:
"""Open a stream in a worker thread, closing it if the caller goes away."""
task = asyncio.ensure_future(asyncio.to_thread(create, **kwargs))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
task.add_done_callback(
lambda t: t.cancelled() or t.exception() or t.result().close()
)
raise
# 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
@@ -60,8 +46,8 @@ class OpenAIClient(GenAIClient):
provider: OpenAI provider: OpenAI
context_size: int | None = None context_size: int | None = None
def _init_provider(self) -> OpenAI: def _client_options(self) -> dict[str, Any]:
"""Initialize the client. """Build the constructor options shared by the sync and async clients.
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.
@@ -77,7 +63,19 @@ 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 OpenAI(api_key=self.genai_config.api_key, **provider_opts) return {"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,
@@ -427,12 +425,13 @@ class OpenAIClient(GenAIClient):
finish_reason = "stop" finish_reason = "stop"
usage_stats: dict[str, Any] | None = None usage_stats: dict[str, Any] | None = None
stream = await _open_stream( # Exiting these blocks, including when the caller disconnects and
self.provider.chat.completions.create, **request_params # the generator is cancelled, closes the response and its connection
) async with (
self._init_async_provider() as client,
with stream: await client.chat.completions.create(**request_params) as stream,
async for chunk in iterate_in_threadpool(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)
+116 -87
View File
@@ -15,7 +15,6 @@ crashing message conversion.
import asyncio import asyncio
import base64 import base64
import json import json
import time
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -146,21 +145,45 @@ 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 _FakeOpenAIStream: class _FakeAsyncOpenAIStream:
"""Mimics openai.Stream: iterable, closable, and a context manager.""" """Mimics openai.AsyncStream: async iterable and async context manager.
def __init__(self, chunks): With ``stall_after`` set, the stream stops producing after that many
self._chunks = iter(chunks) chunks, like a model that stops sending tokens, and sets ``stalled``.
self.close = MagicMock() """
def __iter__(self): def __init__(self, chunks, stall_after=None):
return self._chunks self._chunks = chunks
self._stall_after = stall_after
self.stalled = asyncio.Event()
self.close = AsyncMock()
def __enter__(self): 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 return self
def __exit__(self, *exc): async def __aexit__(self, *exc):
self.close() 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):
@@ -169,6 +192,25 @@ class TestOpenAIProvider(unittest.TestCase):
"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 = [
@@ -180,9 +222,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._client() client, _ = self._streaming_client(
client.provider.chat.completions.create = MagicMock( AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
return_value=_FakeOpenAIStream(chunks)
) )
final = _final_message(_collect(client, SIMPLE_MESSAGES)) final = _final_message(_collect(client, SIMPLE_MESSAGES))
@@ -197,9 +238,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._client() client, _ = self._streaming_client(
client.provider.chat.completions.create = MagicMock( AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
return_value=_FakeOpenAIStream(chunks)
) )
events = _collect(client, SIMPLE_MESSAGES) events = _collect(client, SIMPLE_MESSAGES)
@@ -208,105 +248,83 @@ 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._client() client, _ = self._streaming_client(
client.provider.chat.completions.create = MagicMock( AsyncMock(
return_value=_FakeOpenAIStream( return_value=_FakeAsyncOpenAIStream(
[_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_does_not_block_event_loop(self): def test_stream_uses_async_client_and_closes_it(self):
# Other coroutines must keep running while the stream blocks. # The blocking sync client must never be used on the event loop.
def slow_chunks(): stream = _FakeAsyncOpenAIStream(
for content in ("a", "b", "c"): [_openai_chunk(content="ok", finish_reason="stop")]
time.sleep(0.1)
yield _openai_chunk(content=content)
yield _openai_chunk(finish_reason="stop")
client = self._client()
client.provider.chat.completions.create = MagicMock(
return_value=_FakeOpenAIStream(slow_chunks())
) )
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(): async def _run():
ticks = 0 opening = asyncio.Event()
done = asyncio.Event()
async def ticker(): async def hang(**kwargs):
nonlocal ticks opening.set()
while not done.is_set(): await asyncio.Event().wait()
ticks += 1
await asyncio.sleep(0.01)
ticker_task = asyncio.create_task(ticker()) client, async_client = self._streaming_client(hang)
events = [
event
async for event in client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
)
]
done.set()
await ticker_task
return events, ticks
events, ticks = asyncio.run(_run())
self.assertEqual(_final_message(events)["content"], "abc")
self.assertGreater(ticks, 10)
def test_stream_closed_when_caller_leaves_while_opening(self):
# A stream opened after the caller left must still be closed.
stream = MagicMock()
def slow_create(**kwargs):
time.sleep(0.2)
return stream
client = self._client()
client.provider.chat.completions.create = slow_create
async def _run():
gen = client.chat_with_tools_stream( gen = client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto" messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
) )
task = asyncio.create_task(gen.__anext__()) task = asyncio.create_task(gen.__anext__())
await asyncio.sleep(0.05) await opening.wait()
task.cancel() task.cancel()
with self.assertRaises(asyncio.CancelledError): with self.assertRaises(asyncio.CancelledError):
await task await asyncio.wait_for(task, timeout=1)
await asyncio.sleep(0.3) return async_client
asyncio.run(_run()) async_client = asyncio.run(_run())
stream.close.assert_called_once() async_client.close.assert_awaited_once()
def test_stream_closed_when_caller_leaves_mid_stream(self): def test_stream_closed_when_caller_leaves_mid_stream(self):
# A stream abandoned after the first chunk must be closed once. # Cancelling while waiting on a stalled model must close the stream
def chunks_then_block(): # right away instead of waiting for the next chunk.
yield _openai_chunk(content="a")
time.sleep(0.5)
yield _openai_chunk(finish_reason="stop")
stream = _FakeOpenAIStream(chunks_then_block())
client = self._client()
client.provider.chat.completions.create = MagicMock(return_value=stream)
async def _run(): 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( gen = client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto" messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
) )
first = await gen.__anext__() first = await gen.__anext__()
task = asyncio.create_task(gen.__anext__()) task = asyncio.create_task(gen.__anext__())
await asyncio.sleep(0.05) await stream.stalled.wait()
task.cancel() task.cancel()
with self.assertRaises(asyncio.CancelledError): with self.assertRaises(asyncio.CancelledError):
await task await asyncio.wait_for(task, timeout=1)
await gen.aclose() return first, stream, async_client
return first
self.assertEqual(asyncio.run(_run()), ("content_delta", "a")) first, stream, async_client = asyncio.run(_run())
stream.close.assert_called_once() self.assertEqual(first, ("content_delta", "a"))
stream.close.assert_awaited_once()
async_client.close.assert_awaited_once()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1054,6 +1072,17 @@ 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")