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, ), )