Files
frigate/frigate/genai/plugins/openai.py
T

483 lines
18 KiB
Python
Raw Normal View History

2024-06-21 17:30:19 -04:00
"""OpenAI Provider for Frigate AI."""
import base64
import json
2024-09-24 15:04:35 -05:00
import logging
2026-07-06 09:28:02 -08:00
from collections.abc import AsyncGenerator
from typing import Any
2024-06-21 17:30:19 -04:00
from httpx import TimeoutException
from openai import OpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
2024-09-24 15:04:35 -05:00
logger = logging.getLogger(__name__)
2024-06-21 17:30:19 -04:00
2026-07-06 09:28:02 -08:00
def _stats_from_openai_usage(usage: Any) -> dict[str, Any] | None:
2026-05-14 11:05:38 -06:00
"""Build a stats dict from an OpenAI-compatible usage object."""
if usage is None:
return None
prompt_tokens = getattr(usage, "prompt_tokens", None)
completion_tokens = getattr(usage, "completion_tokens", None)
if prompt_tokens is None and completion_tokens is None:
return None
stats: dict[str, Any] = {}
if isinstance(prompt_tokens, int):
stats["prompt_tokens"] = prompt_tokens
if isinstance(completion_tokens, int):
stats["completion_tokens"] = completion_tokens
return stats or None
2024-06-21 17:30:19 -04:00
@register_genai_provider(GenAIProviderEnum.openai)
class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using OpenAI."""
provider: OpenAI
2026-07-06 09:28:02 -08:00
context_size: int | None = None
2024-06-21 17:30:19 -04:00
2026-03-25 09:28:48 -06:00
def _init_provider(self) -> OpenAI:
2026-05-19 12:03:57 -06:00
"""Initialize the client.
Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider.
"""
2026-01-12 20:36:38 -07:00
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
# It will be used in get_context_size() instead
provider_opts = {
k: v
for k, v in self.genai_config.provider_options.items()
if k != "context_size"
}
2026-02-28 10:40:26 -07:00
if self.genai_config.base_url:
provider_opts["base_url"] = self.genai_config.base_url
2026-01-12 20:36:38 -07:00
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
2024-06-21 17:30:19 -04:00
2026-03-09 18:47:37 -06:00
def _send(
self,
prompt: str,
images: list[bytes],
2026-07-06 09:28:02 -08:00
response_format: dict | None = None,
2026-05-21 11:54:23 -06:00
enable_thinking: bool = False,
2026-07-06 09:28:02 -08:00
) -> str | None:
2024-06-21 17:30:19 -04:00
"""Submit a request to OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
2026-03-27 07:48:50 -06:00
messages_content: list[dict] = [
{
"type": "text",
"text": prompt,
}
]
2025-03-03 11:53:24 -08:00
for image in encoded_images:
messages_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
)
2024-06-21 17:30:19 -04:00
try:
2026-03-09 18:47:37 -06:00
request_params = {
"model": self.genai_config.model,
"messages": [
{
"role": "user",
"content": messages_content,
},
],
2026-03-09 18:47:37 -06:00
"timeout": self.timeout,
2026-01-12 20:36:38 -07:00
**self.genai_config.runtime_options,
2026-03-09 18:47:37 -06:00
}
if response_format:
2026-04-30 11:53:34 -06:00
# OpenAI strict mode requires additionalProperties: false on the schema
if response_format.get("type") == "json_schema" and response_format.get(
"json_schema", {}
).get("strict"):
schema = response_format.get("json_schema", {}).get("schema")
if isinstance(schema, dict):
schema["additionalProperties"] = False
2026-03-09 18:47:37 -06:00
request_params["response_format"] = response_format
2026-04-30 11:53:34 -06:00
2026-03-09 18:47:37 -06:00
result = self.provider.chat.completions.create(**request_params)
2026-04-30 11:53:34 -06:00
if (
result is not None
and hasattr(result, "choices")
and len(result.choices) > 0
):
2026-04-22 09:55:54 -06:00
message = result.choices[0].message
content = message.content
if not content:
# When reasoning is enabled for some OpenAI backends the actual response
# is incorrectly placed in reasoning_content instead of content.
# This is buggy/incorrect behavior — reasoning should not be
# enabled for these models.
reasoning_content = getattr(message, "reasoning_content", None)
if reasoning_content:
logger.warning(
"Response content was empty but reasoning_content was provided; "
"reasoning appears to be enabled and should be disabled for this model."
)
content = reasoning_content
return str(content.strip()) if content else None
return None
except (TimeoutException, Exception) as e:
2024-09-24 15:04:35 -05:00
logger.warning("OpenAI returned an error: %s", str(e))
2024-06-21 17:30:19 -04:00
return None
2025-10-02 09:17:25 -06:00
2026-04-03 17:13:52 -06:00
def list_models(self) -> list[str]:
"""Return available model IDs from the OpenAI-compatible API."""
try:
return sorted(m.id for m in self.provider.models.list().data)
except Exception as e:
logger.warning("Failed to list OpenAI models: %s", e)
return []
2025-10-02 09:17:25 -06:00
def get_context_size(self) -> int:
"""Get the context window size for OpenAI."""
2025-10-31 12:40:31 -06:00
if self.context_size is not None:
return self.context_size
2026-01-12 20:36:38 -07:00
# First check provider_options for manually specified context size
# This is necessary for llama.cpp and other OpenAI-compatible servers
# that don't expose the configured runtime context size in the API response
if "context_size" in self.genai_config.provider_options:
self.context_size = self.genai_config.provider_options["context_size"]
logger.debug(
f"Using context size {self.context_size} from provider_options for model {self.genai_config.model}"
)
return self.context_size
2025-10-31 12:40:31 -06:00
try:
models = self.provider.models.list()
for model in models.data:
if model.id == self.genai_config.model:
if hasattr(model, "max_model_len") and model.max_model_len:
self.context_size = model.max_model_len
logger.debug(
f"Retrieved context size {self.context_size} for model {self.genai_config.model}"
)
return self.context_size
except Exception as e:
logger.debug(
f"Failed to fetch model context size from API: {e}, using default"
)
# Default to 128K for ChatGPT models, 8K for others
model_name = self.genai_config.model.lower()
if "gpt" in model_name:
2025-10-31 12:40:31 -06:00
self.context_size = 128000
else:
self.context_size = 8192
logger.debug(
f"Using default context size {self.context_size} for model {self.genai_config.model}"
)
return self.context_size
def chat_with_tools(
self,
messages: list[dict[str, Any]],
2026-07-06 09:28:02 -08:00
tools: list[dict[str, Any]] | None = None,
tool_choice: str | None = "auto",
enable_thinking: bool | None = None,
) -> dict[str, Any]:
"""
Send chat messages to OpenAI with optional tool definitions.
2026-05-21 11:54:23 -06:00
Implements function calling/tool usage for OpenAI models. The OpenAI
chat completions API does not expose a per-request thinking toggle,
so ``enable_thinking`` is accepted for interface parity and ignored.
"""
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
if isinstance(self.genai_config.provider_options, dict):
excluded_options = {"context_size"}
provider_opts = {
k: v
for k, v in self.genai_config.provider_options.items()
if k not in excluded_options
}
request_params.update(provider_opts)
result = self.provider.chat.completions.create(**request_params)
if (
result is None
or not hasattr(result, "choices")
or len(result.choices) == 0
):
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
2026-05-19 12:03:57 -06:00
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
message, "reasoning", None
)
reasoning = raw_reasoning.strip() if raw_reasoning else None
tool_calls = None
if message.tool_calls:
tool_calls = []
for tool_call in message.tool_calls:
try:
arguments = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(
f"Failed to parse tool call arguments: {e}, "
f"tool: {tool_call.function.name if hasattr(tool_call.function, 'name') else 'unknown'}"
)
arguments = {}
tool_calls.append(
{
"id": tool_call.id if hasattr(tool_call, "id") else "",
"name": tool_call.function.name
if hasattr(tool_call.function, "name")
else "",
"arguments": arguments,
}
)
finish_reason = "error"
if hasattr(choice, "finish_reason") and choice.finish_reason:
finish_reason = choice.finish_reason
elif tool_calls:
finish_reason = "tool_calls"
elif content:
finish_reason = "stop"
return {
"content": content,
2026-05-19 12:03:57 -06:00
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
except TimeoutException as e:
logger.warning("OpenAI request timed out: %s", str(e))
return {
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
except Exception as e:
logger.warning("OpenAI returned an error: %s", str(e))
return {
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
2026-02-28 10:40:26 -07:00
async def chat_with_tools_stream(
self,
messages: list[dict[str, Any]],
2026-07-06 09:28:02 -08:00
tools: list[dict[str, Any]] | None = None,
tool_choice: str | None = "auto",
enable_thinking: bool | None = None,
2026-03-25 09:28:48 -06:00
) -> AsyncGenerator[tuple[str, Any], None]:
2026-02-28 10:40:26 -07:00
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for OpenAI models.
2026-05-21 14:38:38 -06:00
The OpenAI chat completions API does not expose a per-request thinking
toggle, so ``enable_thinking`` is accepted for interface parity and
ignored.
2026-02-28 10:40:26 -07:00
"""
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
"stream": True,
2026-05-14 11:05:38 -06:00
"stream_options": {"include_usage": True},
**self.genai_config.runtime_options,
2026-02-28 10:40:26 -07:00
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
if isinstance(self.genai_config.provider_options, dict):
excluded_options = {"context_size"}
provider_opts = {
k: v
for k, v in self.genai_config.provider_options.items()
if k not in excluded_options
}
request_params.update(provider_opts)
# Use streaming API
content_parts: list[str] = []
2026-05-19 12:03:57 -06:00
reasoning_parts: list[str] = []
2026-02-28 10:40:26 -07:00
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
2026-07-06 09:28:02 -08:00
usage_stats: dict[str, Any] | None = None
2026-02-28 10:40:26 -07:00
stream = self.provider.chat.completions.create(**request_params)
2026-02-28 10:40:26 -07:00
for chunk in stream:
2026-05-14 11:05:38 -06:00
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
2026-02-28 10:40:26 -07:00
if not chunk or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
2026-05-19 12:03:57 -06:00
# Extract reasoning deltas (reasoning_content or reasoning,
# depending on the server)
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
delta, "reasoning", None
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
2026-02-28 10:40:26 -07:00
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
if idx not in tool_calls_by_index:
tool_calls_by_index[idx] = {
"id": tc.id or "",
"name": fn.name if fn and fn.name else "",
"arguments": "",
}
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
# Build final message
full_content = "".join(content_parts).strip() or None
2026-05-19 12:03:57 -06:00
full_reasoning = "".join(reasoning_parts).strip() or None
2026-02-28 10:40:26 -07:00
# Convert tool calls to list format
tool_calls_list = None
if tool_calls_by_index:
tool_calls_list = []
for tc in tool_calls_by_index.values():
try:
# Parse accumulated arguments as JSON
2026-07-13 05:33:15 -08:00
parsed_args = json.loads(tc["arguments"] or "{}")
except (json.JSONDecodeError, ValueError):
logger.warning(
"Failed to parse streamed tool call arguments for %s",
tc["name"],
)
parsed_args = {}
# Downstream (ToolCall model) requires a dict; never leak a
# partial/invalid arguments string.
if not isinstance(parsed_args, dict):
parsed_args = {}
2026-02-28 10:40:26 -07:00
tool_calls_list.append(
{
"id": tc["id"],
"name": tc["name"],
"arguments": parsed_args,
}
)
finish_reason = "tool_calls"
2026-05-14 11:05:38 -06:00
if usage_stats is not None:
yield ("stats", usage_stats)
2026-02-28 10:40:26 -07:00
yield (
"message",
{
"content": full_content,
2026-05-19 12:03:57 -06:00
"reasoning": full_reasoning,
2026-02-28 10:40:26 -07:00
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
)
except TimeoutException as e:
logger.warning("OpenAI streaming request timed out: %s", str(e))
yield (
"message",
{
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
2026-02-28 10:40:26 -07:00
"tool_calls": None,
"finish_reason": "error",
},
)
except Exception as e:
logger.warning("OpenAI streaming returned an error: %s", str(e))
yield (
"message",
{
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
2026-02-28 10:40:26 -07:00
"tool_calls": None,
"finish_reason": "error",
},
)