Files
2026-07-13 13:39:38 +08:00

554 lines
20 KiB
Python

from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from typing import Any, Literal, cast
import httpx
import openai
from openai.types.chat import (
ChatCompletionChunk,
ChatCompletionMessageParam,
ChatCompletionPredictionContentParam,
ChatCompletionToolChoiceOptionParam,
ChatCompletionToolParam,
completion_create_params,
)
from openai.types.chat.chat_completion_chunk import Choice
from openai.types.shared.reasoning_effort import ReasoningEffort
from openai.types.shared_params import Metadata
from typing_extensions import TypedDict
from .. import llm
from .._exceptions import APIConnectionError, APIStatusError, APITimeoutError
from ..llm import ToolChoice, utils as llm_utils
from ..llm.chat_context import ChatContext
from ..llm.tool_context import Tool
from ..log import logger
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from ..utils import is_given
from ._utils import (
HEADER_INFERENCE_PRIORITY,
HEADER_INFERENCE_PROVIDER,
create_access_token,
get_default_inference_url,
get_inference_headers,
)
lk_oai_debug = int(os.getenv("LK_OPENAI_DEBUG", 0))
# Reasoning models don't support sampling parameters.
# See: https://platform.openai.com/docs/guides/reasoning
_REASONING_UNSUPPORTED_PARAMS: set[str] = {
"temperature",
"top_p",
"presence_penalty",
"frequency_penalty",
"logit_bias",
"logprobs",
"top_logprobs",
"n",
}
# xAI reasoning models only restrict presence_penalty, frequency_penalty, stop.
# They still support temperature and top_p.
_XAI_REASONING_UNSUPPORTED_PARAMS: set[str] = {
"presence_penalty",
"frequency_penalty",
"stop",
}
# Model prefix -> set of param names that should be dropped
_UNSUPPORTED_PARAMS: dict[str, set[str]] = {
"o1": _REASONING_UNSUPPORTED_PARAMS,
"o3": _REASONING_UNSUPPORTED_PARAMS,
"o4": _REASONING_UNSUPPORTED_PARAMS,
"gpt-5": _REASONING_UNSUPPORTED_PARAMS,
"grok-4-1-fast-reasoning": _XAI_REASONING_UNSUPPORTED_PARAMS,
"grok-4.20-0309-reasoning": _XAI_REASONING_UNSUPPORTED_PARAMS,
"grok-4.20-multi-agent": _XAI_REASONING_UNSUPPORTED_PARAMS,
}
# models that don't support reasoning_effort when function tools are present
_REASONING_EFFORT_TOOL_INCOMPATIBLE_PREFIXES: set[str] = {"gpt-5.2", "gpt-5.4"}
def drop_unsupported_params(
model: str, params: dict[str, Any], tools: list[Any] | None = None
) -> dict[str, Any]:
"""Remove parameters that are not supported by the given model.
Strips any provider prefix (e.g. ``openai/o3-pro`` -> ``o3-pro``) before
matching against known model prefixes.
"""
model_name = model.split("/")[-1] if "/" in model else model
for prefix, unsupported in _UNSUPPORTED_PARAMS.items():
if model_name.startswith(prefix):
params = {k: v for k, v in params.items() if k not in unsupported}
break
if tools and any(
model_name.startswith(p) for p in _REASONING_EFFORT_TOOL_INCOMPATIBLE_PREFIXES
):
params = {k: v for k, v in params.items() if k != "reasoning_effort"}
return params
OpenAIModels = Literal[
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-4.1",
"openai/gpt-4.1-mini",
"openai/gpt-4.1-nano",
"openai/gpt-5",
"openai/gpt-5-mini",
"openai/gpt-5-nano",
"openai/gpt-5.1",
"openai/gpt-5.1-chat-latest",
"openai/gpt-5.2",
"openai/gpt-5.2-chat-latest",
"openai/gpt-5.3-chat-latest",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
"openai/gpt-5.5",
"openai/chat-latest",
"openai/gpt-oss-120b",
]
GoogleModels = Literal[
"google/gemini-3.1-pro",
"google/gemini-3-flash",
"google/gemini-3.1-flash-lite",
"google/gemini-3.5-flash",
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
"google/gemini-2.5-flash-lite",
]
KimiModels = Literal[
"moonshotai/kimi-k2.5",
"moonshotai/kimi-k2.6",
]
DeepSeekModels = Literal[
"deepseek-ai/deepseek-v3",
"deepseek-ai/deepseek-v3.2",
]
ZAIModels = Literal["zai/glm-5.1"]
XAIModels = Literal[
"xai/grok-4-1-fast-non-reasoning",
"xai/grok-4-1-fast-reasoning",
"xai/grok-4.20-0309-non-reasoning",
"xai/grok-4.20-0309-reasoning",
"xai/grok-4.20-multi-agent-0309",
]
LLMModels = OpenAIModels | GoogleModels | KimiModels | DeepSeekModels | ZAIModels | XAIModels
InferenceClass = Literal["priority", "standard"]
class ChatCompletionOptions(TypedDict, total=False):
frequency_penalty: float | None
logit_bias: dict[str, int] | None
logprobs: bool | None
max_completion_tokens: int | None
max_tokens: int | None
metadata: Metadata | None
modalities: list[Literal["text", "audio"]] | None
n: int | None
parallel_tool_calls: bool
prediction: ChatCompletionPredictionContentParam | None
presence_penalty: float | None
prompt_cache_key: str
prompt_cache_retention: Literal["in_memory", "24h"] | None
reasoning_effort: ReasoningEffort | None
safety_identifier: str
seed: int | None
service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None
stop: str | None | list[str] | None
store: bool | None
temperature: float | None
top_logprobs: int | None
top_p: float | None
user: str
verbosity: Literal["low", "medium", "high"] | None
web_search_options: completion_create_params.WebSearchOptions
# livekit-typed arguments
tool_choice: ToolChoice
# TODO(theomonnomn): support repsonse format
# response_format: completion_create_params.ResponseFormat
@dataclass
class _LLMOptions:
model: LLMModels | str
provider: str | None
base_url: str
api_key: str
api_secret: str
inference_class: InferenceClass | None
extra_kwargs: ChatCompletionOptions | dict[str, Any]
class LLM(llm.LLM):
def __init__(
self,
model: LLMModels | str,
*,
provider: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
api_secret: str | None = None,
inference_class: InferenceClass | None = None,
extra_kwargs: ChatCompletionOptions | dict[str, Any] | None = None,
) -> None:
super().__init__()
lk_base_url = base_url if base_url else get_default_inference_url()
lk_api_key = (
api_key
if api_key
else os.getenv("LIVEKIT_INFERENCE_API_KEY", os.getenv("LIVEKIT_API_KEY", ""))
)
if not lk_api_key:
raise ValueError(
"api_key is required, either as argument or set LIVEKIT_API_KEY environmental variable"
)
lk_api_secret = (
api_secret
if api_secret
else os.getenv("LIVEKIT_INFERENCE_API_SECRET", os.getenv("LIVEKIT_API_SECRET", ""))
)
if not lk_api_secret:
raise ValueError(
"api_secret is required, either as argument or set LIVEKIT_API_SECRET environmental variable"
)
self._opts = _LLMOptions(
model=model,
provider=provider,
base_url=lk_base_url,
api_key=lk_api_key,
api_secret=lk_api_secret,
inference_class=inference_class,
extra_kwargs=extra_kwargs or {},
)
self._client = openai.AsyncClient(
api_key=create_access_token(self._opts.api_key, self._opts.api_secret),
base_url=self._opts.base_url,
max_retries=0,
http_client=httpx.AsyncClient(
timeout=httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
follow_redirects=True,
limits=httpx.Limits(
max_connections=50, max_keepalive_connections=50, keepalive_expiry=120
),
),
)
async def aclose(self) -> None:
await self._client.close()
@classmethod
def from_model_string(cls, model: str) -> LLM:
"""Create a LLM instance from a model string"""
return cls(model)
def update_options(
self,
*,
model: NotGivenOr[LLMModels | str] = NOT_GIVEN,
extra_kwargs: NotGivenOr[ChatCompletionOptions | dict[str, Any]] = NOT_GIVEN,
) -> None:
"""Update LLM configuration options.
Each option is read on the next ``chat()`` call, so a swap
takes effect on the agent's next turn without recreating the
LLM. ``extra_kwargs`` *replaces* the persistent kwargs dict
rather than merging — pass ``{}`` to clear it.
"""
if is_given(model):
self._opts.model = model
if is_given(extra_kwargs):
self._opts.extra_kwargs = dict(extra_kwargs)
@property
def model(self) -> str:
"""Get the model name for this LLM instance."""
return self._opts.model
@property
def provider(self) -> str:
return "livekit"
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[Tool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
response_format: NotGivenOr[
completion_create_params.ResponseFormat | type[llm_utils.ResponseFormatT]
] = NOT_GIVEN,
inference_class: NotGivenOr[InferenceClass] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
extra = {}
if is_given(extra_kwargs):
extra.update(extra_kwargs)
parallel_tool_calls = (
parallel_tool_calls
if is_given(parallel_tool_calls)
else self._opts.extra_kwargs.get("parallel_tool_calls", NOT_GIVEN)
)
if is_given(parallel_tool_calls):
extra["parallel_tool_calls"] = parallel_tool_calls
extra_tool_choice = self._opts.extra_kwargs.get("tool_choice", NOT_GIVEN)
tool_choice = tool_choice if is_given(tool_choice) else extra_tool_choice
if is_given(tool_choice):
oai_tool_choice: ChatCompletionToolChoiceOptionParam
if isinstance(tool_choice, dict):
oai_tool_choice = {
"type": "function",
"function": {"name": tool_choice["function"]["name"]},
}
extra["tool_choice"] = oai_tool_choice
elif tool_choice in ("auto", "required", "none"):
oai_tool_choice = tool_choice
extra["tool_choice"] = oai_tool_choice
if is_given(response_format):
extra["response_format"] = llm_utils.to_openai_response_format(response_format) # type: ignore
extra.update(self._opts.extra_kwargs)
effective_inference_class = (
inference_class if is_given(inference_class) else self._opts.inference_class
)
self._client.api_key = create_access_token(self._opts.api_key, self._opts.api_secret)
return LLMStream(
self,
model=self._opts.model,
provider=self._opts.provider,
inference_class=effective_inference_class,
strict_tool_schema=True,
client=self._client,
chat_ctx=chat_ctx,
tools=tools or [],
conn_options=conn_options,
extra_kwargs=extra,
)
class LLMStream(llm.LLMStream):
def __init__(
self,
llm_v: LLM | llm.LLM,
*,
model: LLMModels | str,
provider: str | None = None,
inference_class: InferenceClass | None = None,
strict_tool_schema: bool,
client: openai.AsyncClient,
chat_ctx: llm.ChatContext,
tools: list[Tool],
conn_options: APIConnectOptions,
extra_kwargs: dict[str, Any],
provider_fmt: str = "openai", # used internally for chat_ctx format
) -> None:
super().__init__(llm_v, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._model = model
self._provider = provider
self._inference_class = inference_class
self._provider_fmt = provider_fmt
self._strict_tool_schema = strict_tool_schema
self._client = client
self._llm = llm_v
self._extra_kwargs = drop_unsupported_params(model, extra_kwargs, tools=tools)
self._tool_ctx = llm.ToolContext(tools)
async def _run(self) -> None:
# current function call that we're waiting for full completion (args are streamed)
# (defined inside the _run method to make sure the state is reset for each run/attempt)
self._oai_stream: openai.AsyncStream[ChatCompletionChunk] | None = None
self._tool_call_id: str | None = None
self._fnc_name: str | None = None
self._fnc_raw_arguments: str | None = None
self._tool_extra: dict[str, Any] | None = None
self._tool_index: int | None = None
retryable = True
try:
chat_ctx, _ = self._chat_ctx.to_provider_format(format=self._provider_fmt)
tool_schemas = cast(
list[ChatCompletionToolParam],
self._tool_ctx.parse_function_tools("openai", strict=self._strict_tool_schema),
)
if lk_oai_debug:
tool_choice = self._extra_kwargs.get("tool_choice", NOT_GIVEN)
logger.debug(
"chat.completions.create",
extra={
"fnc_ctx": tool_schemas,
"tool_choice": tool_choice,
"chat_ctx": chat_ctx,
},
)
if not self._tools:
# remove tool_choice from extra_kwargs if no tools are provided
self._extra_kwargs.pop("tool_choice", None)
extra_headers = self._extra_kwargs.setdefault("extra_headers", {})
extra_headers.update(get_inference_headers())
if self._provider:
extra_headers[HEADER_INFERENCE_PROVIDER] = self._provider
if self._inference_class:
extra_headers[HEADER_INFERENCE_PRIORITY] = self._inference_class
self._oai_stream = stream = await self._client.chat.completions.create(
messages=cast(list[ChatCompletionMessageParam], chat_ctx),
tools=tool_schemas or openai.omit,
model=self._model,
stream_options={"include_usage": True},
stream=True,
timeout=httpx.Timeout(self._conn_options.timeout),
**self._extra_kwargs,
)
thinking = asyncio.Event()
async with stream:
async for chunk in stream:
for choice in chunk.choices:
chat_chunk = self._parse_choice(chunk.id, choice, thinking)
if chat_chunk is not None:
retryable = False
self._event_ch.send_nowait(chat_chunk)
if chunk.usage is not None:
retryable = False
tokens_details = chunk.usage.prompt_tokens_details
cached_tokens = tokens_details.cached_tokens if tokens_details else 0
usage_chunk = llm.ChatChunk(
id=chunk.id,
usage=llm.CompletionUsage(
completion_tokens=chunk.usage.completion_tokens,
prompt_tokens=chunk.usage.prompt_tokens,
prompt_cached_tokens=cached_tokens or 0,
total_tokens=chunk.usage.total_tokens,
service_tier=getattr(chunk, "service_tier", None),
),
)
self._event_ch.send_nowait(usage_chunk)
except openai.APITimeoutError:
raise APITimeoutError(retryable=retryable) from None
except openai.APIStatusError as e:
raise APIStatusError(
e.message,
status_code=e.status_code,
request_id=e.request_id,
body=e.body,
retryable=retryable,
) from None
except Exception as e:
raise APIConnectionError(retryable=retryable) from e
def _parse_choice(
self, id: str, choice: Choice, thinking: asyncio.Event
) -> llm.ChatChunk | None:
delta = choice.delta
# https://github.com/livekit/agents/issues/688
# the delta can be None when using Azure OpenAI (content filtering)
if delta is None:
return None
if delta.tool_calls:
for tool in delta.tool_calls:
if not tool.function:
continue
call_chunk = None
if self._tool_call_id and tool.id and tool.index != self._tool_index:
call_chunk = llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(
role="assistant",
content=delta.content,
tool_calls=[
llm.FunctionToolCall(
arguments=self._fnc_raw_arguments or "",
name=self._fnc_name or "",
call_id=self._tool_call_id or "",
extra=self._tool_extra,
)
],
),
)
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
self._tool_extra = None
if tool.function.name:
self._tool_index = tool.index
self._tool_call_id = tool.id
self._fnc_name = tool.function.name
self._fnc_raw_arguments = tool.function.arguments or ""
# Extract extra from tool call (e.g., Google thought signatures)
self._tool_extra = getattr(tool, "extra_content", None)
elif tool.function.arguments:
self._fnc_raw_arguments += tool.function.arguments # type: ignore
if call_chunk is not None:
return call_chunk
if choice.finish_reason in ("tool_calls", "stop") and self._tool_call_id:
finish_extra = getattr(delta, "extra_content", None)
call_chunk = llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(
role="assistant",
content=delta.content,
extra=finish_extra,
tool_calls=[
llm.FunctionToolCall(
arguments=self._fnc_raw_arguments or "",
name=self._fnc_name or "",
call_id=self._tool_call_id or "",
extra=self._tool_extra,
)
],
),
)
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
self._tool_extra = None
return call_chunk
delta.content = llm_utils.strip_thinking_tokens(delta.content, thinking)
# Extract extra from delta (e.g., Google thought signatures on text parts)
delta_extra = getattr(delta, "extra_content", None)
if not delta.content and not delta_extra:
return None
return llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(
content=delta.content,
role="assistant",
extra=delta_extra,
),
)