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

323 lines
12 KiB
Python

# Copyright 2026 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Vertex AI Model Garden (AI Platform) LLM integration.
Targets self-deployed Model Garden endpoints that expose an OpenAI-compatible
chat completions API at:
{ENDPOINT_DNS}/{API_VERSION}/projects/{PROJECT}/locations/{LOCATION}/endpoints/{ENDPOINT_ID}/chat/completions
The dedicated endpoint DNS comes from ``Endpoint.dedicated_endpoint_dns`` on a
deployed Model Garden endpoint (for example
``mg-endpoint-<id>.us-central1-<project_number>.prediction.vertexai.goog``).
"""
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable, Generator
from dataclasses import dataclass
from typing import Any, Literal
import httpx
import openai
from openai.types.chat import ChatCompletionToolChoiceOptionParam, completion_create_params
import google.auth
import google.auth.credentials
import google.auth.transport.requests
from livekit.agents import llm
from livekit.agents.inference.llm import LLMStream
from livekit.agents.llm import ToolChoice, utils as llm_utils
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from livekit.agents.utils import is_given
ApiVersion = Literal["v1", "v1beta1"]
class _GoogleBearerAuth(httpx.Auth):
"""httpx auth handler that injects a Google OAuth bearer token.
Accepts either a static token (useful for short-lived testing) or
``google.auth.credentials.Credentials``, which are refreshed lazily
when their access token is missing or expired. A custom callable can
also be supplied for cases where the caller manages tokens itself.
"""
requires_request_body = False
requires_response_body = False
def __init__(
self,
*,
credentials: google.auth.credentials.Credentials | None = None,
static_token: str | None = None,
token_provider: Callable[[], str] | None = None,
) -> None:
if not credentials and not static_token and not token_provider:
raise ValueError("one of credentials, static_token, or token_provider must be provided")
self._credentials = credentials
self._static_token = static_token
self._token_provider = token_provider
self._refresh_request = (
google.auth.transport.requests.Request() if credentials is not None else None
)
def _current_token(self) -> str:
if self._token_provider is not None:
return self._token_provider()
if self._credentials is not None:
# Credentials.valid is False when token is missing or expired.
if not self._credentials.valid:
assert self._refresh_request is not None
self._credentials.refresh(self._refresh_request) # type: ignore[no-untyped-call]
return self._credentials.token or ""
return self._static_token or ""
def sync_auth_flow(
self, request: httpx.Request
) -> Generator[httpx.Request, httpx.Response, None]:
request.headers["Authorization"] = f"Bearer {self._current_token()}"
yield request
async def async_auth_flow(
self, request: httpx.Request
) -> AsyncGenerator[httpx.Request, httpx.Response]:
request.headers["Authorization"] = f"Bearer {self._current_token()}"
yield request
@dataclass
class _AIPlatformOptions:
model: str
temperature: NotGivenOr[float]
top_p: NotGivenOr[float]
max_completion_tokens: NotGivenOr[int]
parallel_tool_calls: NotGivenOr[bool]
tool_choice: NotGivenOr[ToolChoice]
extra_body: NotGivenOr[dict[str, Any]]
extra_headers: NotGivenOr[dict[str, str]]
extra_query: NotGivenOr[dict[str, str]]
class AIPlatformLLM(llm.LLM):
"""LLM that talks to a self-deployed Vertex AI Model Garden chat-completions endpoint.
Example:
```python
llm = AIPlatformLLM(
endpoint_url="https://mg-endpoint-<id>.us-central1-<projnum>.prediction.vertexai.goog",
project="my-project",
endpoint_id="12345678-abcd-1234-abcd-1234567890ab",
location="us-central1",
model="google/gemma-4-31b-it",
)
```
"""
def __init__(
self,
*,
endpoint_url: str,
project: str,
endpoint_id: str,
location: str = "us-central1",
model: str = "gemma",
access_token: NotGivenOr[str] = NOT_GIVEN,
credentials: google.auth.credentials.Credentials | None = None,
token_provider: Callable[[], str] | None = None,
api_version: ApiVersion = "v1beta1",
temperature: NotGivenOr[float] = NOT_GIVEN,
top_p: NotGivenOr[float] = NOT_GIVEN,
max_completion_tokens: NotGivenOr[int] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_body: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
extra_headers: NotGivenOr[dict[str, str]] = NOT_GIVEN,
extra_query: NotGivenOr[dict[str, str]] = NOT_GIVEN,
strict_tool_schema: bool = True,
client: openai.AsyncClient | None = None,
timeout: httpx.Timeout | None = None,
) -> None:
"""Create a new AIPlatformLLM.
Args:
endpoint_url: Base DNS for the dedicated Model Garden endpoint
(no path component), e.g.
``https://mg-endpoint-<id>.us-central1-<projnum>.prediction.vertexai.goog``.
project: Google Cloud project (id or number) that owns the endpoint.
endpoint_id: The numeric or UUID endpoint id.
location: GCP region (defaults to ``us-central1``).
model: Model name passed in the chat completions request body.
access_token: Optional static OAuth access token. If omitted and
``credentials``/``token_provider`` are not given, falls back to
``google.auth.default(scopes=["…/cloud-platform"])``.
credentials: ``google.auth.credentials.Credentials`` instance. The
auth handler will refresh it on demand.
token_provider: Callable returning a fresh bearer token. Takes
precedence over ``credentials`` and ``access_token``.
api_version: ``v1`` or ``v1beta1``. Defaults to ``v1beta1``, which
is currently the public-documented version for
``projects.locations.endpoints.chat.completions``.
strict_tool_schema: When ``True`` (default), emits OpenAI-style
strict JSON-schema function descriptions. Set to ``False`` for
self-deployed OSS models that don't accept strict schemas.
client: Pre-built ``openai.AsyncClient``. When provided, all
auth/base-url construction is bypassed and the caller is
responsible for those concerns.
"""
super().__init__()
self._opts = _AIPlatformOptions(
model=model,
temperature=temperature,
top_p=top_p,
max_completion_tokens=max_completion_tokens,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
extra_body=extra_body,
extra_headers=extra_headers,
extra_query=extra_query,
)
self._strict_tool_schema = strict_tool_schema
if client is not None:
self._owns_client = False
self._client = client
else:
resolved_credentials = credentials
resolved_token = access_token if is_given(access_token) else None
if token_provider is None and resolved_credentials is None and resolved_token is None:
# Fall back to application default credentials.
resolved_credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
auth = _GoogleBearerAuth(
credentials=resolved_credentials,
static_token=resolved_token,
token_provider=token_provider,
)
base_url = (
f"{endpoint_url.rstrip('/')}"
f"/{api_version}/projects/{project}"
f"/locations/{location}/endpoints/{endpoint_id}"
)
self._owns_client = True
self._client = openai.AsyncClient(
api_key="ignored-auth-comes-from-httpx-auth",
base_url=base_url,
max_retries=0,
http_client=httpx.AsyncClient(
auth=auth,
timeout=timeout
if timeout is not None
else httpx.Timeout(connect=10.0, read=10.0, write=10.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:
if self._owns_client:
await self._client.close()
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "Vertex AI Model Garden"
def chat(
self,
*,
chat_ctx: llm.ChatContext,
tools: list[llm.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,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
extra: dict[str, Any] = {}
if is_given(extra_kwargs):
extra.update(extra_kwargs)
if is_given(self._opts.extra_body):
extra["extra_body"] = self._opts.extra_body
if is_given(self._opts.extra_headers):
extra["extra_headers"] = self._opts.extra_headers
if is_given(self._opts.extra_query):
extra["extra_query"] = self._opts.extra_query
if is_given(self._opts.temperature):
extra["temperature"] = self._opts.temperature
if is_given(self._opts.top_p):
extra["top_p"] = self._opts.top_p
if is_given(self._opts.max_completion_tokens):
extra["max_completion_tokens"] = self._opts.max_completion_tokens
parallel_tool_calls = (
parallel_tool_calls if is_given(parallel_tool_calls) else self._opts.parallel_tool_calls
)
if is_given(parallel_tool_calls):
extra["parallel_tool_calls"] = parallel_tool_calls
tool_choice = tool_choice if is_given(tool_choice) else self._opts.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"):
extra["tool_choice"] = tool_choice
if is_given(response_format):
extra["response_format"] = llm_utils.to_openai_response_format(response_format) # type: ignore[arg-type]
return LLMStream(
self,
model=self._opts.model,
provider=None,
inference_class=None,
strict_tool_schema=self._strict_tool_schema,
client=self._client,
chat_ctx=chat_ctx,
tools=tools or [],
conn_options=conn_options,
extra_kwargs=extra,
provider_fmt="openai",
)
__all__ = ["AIPlatformLLM"]