[Feat] New Provider - VertexAI Agent Engine (#18014)

* init A2AProviderConfigManager

* move file

* move file

* add pydnatic ai folder

* init providers

* test_pydantic_ai_non_streaming

* fix import

* INIT pydantic

* use_a2a_form_fields

* test_vertex_agent_engine_streaming

* add agent_engine

* init transform for agent engine

* init agent engine

* VertexAgentEngineSSEStreamIterator

* sample

* ui add new fields

* fix vertex_credentials

* working SSE iterator

* TestVertexAgentEngineTransformRequest

* fix code QA check

* Potential fix for code scanning alert no. 3923: Clear-text logging of sensitive information

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Ishaan Jaff 2025-12-15 19:14:49 -08:00 committed by GitHub
parent f58b76aee8
commit 32c07113cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1029 additions and 4 deletions

View File

@ -0,0 +1,13 @@
"""
Vertex AI Agent Engine (Reasoning Engines) Provider
Supports Vertex AI Reasoning Engines via the :query and :streamQuery endpoints.
"""
from litellm.llms.vertex_ai.agent_engine.transformation import (
VertexAgentEngineConfig,
VertexAgentEngineError,
)
__all__ = ["VertexAgentEngineConfig", "VertexAgentEngineError"]

View File

@ -0,0 +1,90 @@
"""
SSE Stream Iterator for Vertex AI Agent Engine.
Handles Server-Sent Events (SSE) streaming responses from Vertex AI Reasoning Engines.
"""
from typing import Any, Union
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.types.llms.openai import ChatCompletionUsageBlock
from litellm.types.utils import (
Delta,
GenericStreamingChunk,
ModelResponseStream,
StreamingChoices,
)
class VertexAgentEngineResponseIterator(BaseModelResponseIterator):
"""
Iterator for Vertex Agent Engine SSE streaming responses.
Uses BaseModelResponseIterator which handles sync/async iteration.
We just need to implement chunk_parser to parse Vertex Agent Engine response format.
"""
def __init__(self, streaming_response: Any, sync_stream: bool) -> None:
super().__init__(streaming_response=streaming_response, sync_stream=sync_stream)
def chunk_parser(
self, chunk: dict
) -> Union[GenericStreamingChunk, ModelResponseStream]:
"""
Parse a Vertex Agent Engine response chunk into ModelResponseStream.
Vertex Agent Engine response format:
{
"content": {
"parts": [{"text": "..."}],
"role": "model"
},
"finish_reason": "STOP",
"usage_metadata": {
"prompt_token_count": 100,
"candidates_token_count": 50,
"total_token_count": 150
}
}
"""
# Extract text from content.parts
text = None
content = chunk.get("content", {})
parts = content.get("parts", [])
for part in parts:
if isinstance(part, dict) and "text" in part:
text = part["text"]
break
# Extract finish_reason
finish_reason = None
raw_finish_reason = chunk.get("finish_reason")
if raw_finish_reason == "STOP":
finish_reason = "stop"
elif raw_finish_reason:
finish_reason = raw_finish_reason.lower()
# Extract usage from usage_metadata
usage = None
usage_metadata = chunk.get("usage_metadata", {})
if usage_metadata:
usage = ChatCompletionUsageBlock(
prompt_tokens=usage_metadata.get("prompt_token_count", 0),
completion_tokens=usage_metadata.get("candidates_token_count", 0),
total_tokens=usage_metadata.get("total_token_count", 0),
)
# Return ModelResponseStream (OpenAI-compatible chunk)
return ModelResponseStream(
choices=[
StreamingChoices(
finish_reason=finish_reason,
index=0,
delta=Delta(
content=text,
role="assistant" if text else None,
),
)
],
usage=usage,
)

View File

@ -0,0 +1,508 @@
"""
Transformation for Vertex AI Agent Engine (Reasoning Engines)
Handles the transformation between LiteLLM's OpenAI-compatible format and
Vertex AI Reasoning Engine's API format.
API Reference:
- :query endpoint - for session management (create, get, list, delete)
- :streamQuery endpoint - for actual queries (stream_query method)
"""
import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
import httpx
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
)
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.vertex_ai.agent_engine.sse_iterator import (
VertexAgentEngineResponseIterator,
)
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import Choices, Message, ModelResponse, Usage
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.utils import CustomStreamWrapper
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
HTTPHandler = Any
AsyncHTTPHandler = Any
CustomStreamWrapper = Any
class VertexAgentEngineError(BaseLLMException):
"""Exception for Vertex Agent Engine errors."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(message=message, status_code=status_code)
class VertexAgentEngineConfig(BaseConfig, VertexBase):
"""
Configuration for Vertex AI Agent Engine (Reasoning Engines).
Model format: vertex_ai/agent_engine/<resource_id>
Where resource_id is the numeric ID of the reasoning engine.
"""
def __init__(self, **kwargs):
BaseConfig.__init__(self, **kwargs)
VertexBase.__init__(self)
def get_supported_openai_params(self, model: str) -> List[str]:
"""Vertex Agent Engine has limited OpenAI compatible params."""
return ["user"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
"""Map OpenAI params to Agent Engine params."""
# Map 'user' to 'user_id' for session management
if "user" in non_default_params:
optional_params["user_id"] = non_default_params["user"]
return optional_params
def _parse_model_string(self, model: str) -> Tuple[str, str]:
"""
Parse model string to extract resource ID.
Model format: agent_engine/<project_number>/<location>/<engine_id>
Or: agent_engine/<engine_id> (uses default project/location)
Returns: (resource_path, engine_id)
"""
# Remove 'agent_engine/' prefix if present
if model.startswith("agent_engine/"):
model = model[len("agent_engine/") :]
# Check if it's a full resource path
if model.startswith("projects/"):
# Full path: projects/123/locations/us-central1/reasoningEngines/456
return model, model.split("/")[-1]
# Just the engine ID
return model, model
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
"""
Get the complete URL for the request.
For Vertex Agent Engine:
- Non-streaming: :query endpoint (for session management)
- Streaming: :streamQuery endpoint (for actual queries)
"""
resource_path, engine_id = self._parse_model_string(model)
# Get project and location from litellm_params or environment
vertex_project = self.safe_get_vertex_ai_project(litellm_params)
vertex_location = self.safe_get_vertex_ai_location(litellm_params) or "us-central1"
# Build the full resource path if only engine_id was provided
if not resource_path.startswith("projects/"):
if not vertex_project:
raise ValueError(
"vertex_project is required for Vertex Agent Engine. "
"Set via litellm_params['vertex_project'] or VERTEXAI_PROJECT env var."
)
resource_path = f"projects/{vertex_project}/locations/{vertex_location}/reasoningEngines/{engine_id}"
# Build the base URL
base_url = f"https://{vertex_location}-aiplatform.googleapis.com"
# Always use :streamQuery endpoint for actual queries
# The :query endpoint only supports session management methods
# (create_session, get_session, list_sessions, delete_session, etc.)
endpoint = f"{base_url}/v1beta1/{resource_path}:streamQuery"
verbose_logger.debug(f"Vertex Agent Engine URL: {endpoint}")
return endpoint
def _get_auth_headers(
self,
optional_params: dict,
litellm_params: dict,
) -> Dict[str, str]:
"""Get authentication headers using Google Cloud credentials."""
vertex_credentials = self.safe_get_vertex_ai_credentials(litellm_params)
vertex_project = self.safe_get_vertex_ai_project(litellm_params)
# Get access token using VertexBase
access_token, project_id = self.get_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
)
verbose_logger.debug(f"Vertex Agent Engine: Authenticated for project {project_id}")
return {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
def _get_user_id(self, optional_params: dict) -> str:
"""Get or generate user ID for session management."""
user_id = optional_params.get("user_id") or optional_params.get("user")
if user_id:
return user_id
# Generate a user ID
return f"litellm-user-{str(uuid.uuid4())[:8]}"
def _get_session_id(self, optional_params: dict) -> Optional[str]:
"""Get session ID if provided."""
return optional_params.get("session_id")
def transform_request(
self,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
"""
Transform the request to Vertex Agent Engine format.
The API expects:
{
"class_method": "stream_query",
"input": {
"message": "...",
"user_id": "...",
"session_id": "..." (optional)
}
}
"""
# Use the last message content as the prompt
prompt = convert_content_list_to_str(messages[-1])
# Get user_id and session_id
user_id = self._get_user_id(optional_params)
session_id = self._get_session_id(optional_params)
# Build the input
input_data: Dict[str, Any] = {
"message": prompt,
"user_id": user_id,
}
if session_id:
input_data["session_id"] = session_id
# Build the request payload
# Note: stream_query is used for both streaming and non-streaming
# The difference is the endpoint (:streamQuery vs :query)
payload = {
"class_method": "stream_query",
"input": input_data,
}
verbose_logger.debug(f"Vertex Agent Engine payload: {payload}")
return payload
def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
"""Validate environment and set up authentication headers."""
auth_headers = self._get_auth_headers(optional_params, litellm_params)
headers.update(auth_headers)
return headers
def _extract_text_from_response(self, response_data: dict) -> str:
"""Extract text content from the response."""
# Try to get from content.parts
content = response_data.get("content", {})
parts = content.get("parts", [])
for part in parts:
if "text" in part:
return part["text"]
# Try actions.state_delta
actions = response_data.get("actions", {})
state_delta = actions.get("state_delta", {})
for key, value in state_delta.items():
if isinstance(value, str) and value:
return value
return ""
def _calculate_usage(
self, model: str, messages: List[AllMessageValues], content: str
) -> Optional[Usage]:
"""Calculate token usage using LiteLLM's token counter."""
try:
from litellm.utils import token_counter
prompt_tokens = token_counter(model="gpt-3.5-turbo", messages=messages)
completion_tokens = token_counter(
model="gpt-3.5-turbo", text=content, count_response_tokens=True
)
total_tokens = prompt_tokens + completion_tokens
return Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
)
except Exception as e:
verbose_logger.warning(f"Failed to calculate token usage: {str(e)}")
return None
def transform_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ModelResponse,
logging_obj: LiteLLMLoggingObj,
request_data: dict,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
encoding: Any,
api_key: Optional[str] = None,
json_mode: Optional[bool] = None,
) -> ModelResponse:
"""
Transform Vertex Agent Engine response to LiteLLM ModelResponse format.
The response is a streaming SSE format even for non-streaming requests.
We need to collect all the chunks and extract the final response.
"""
try:
content_type = raw_response.headers.get("content-type", "").lower()
verbose_logger.debug(f"Vertex Agent Engine response Content-Type: {content_type}")
# Parse the SSE response
response_text = raw_response.text
verbose_logger.debug(f"Response (first 500 chars): {response_text[:500]}")
# Extract content from SSE stream
content = ""
for line in response_text.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
if isinstance(data, dict):
text = self._extract_text_from_response(data)
if text:
content = text # Use the last non-empty text
except json.JSONDecodeError:
continue
# Create the message
message = Message(content=content, role="assistant")
# Create choices
choice = Choices(finish_reason="stop", index=0, message=message)
# Update model response
model_response.choices = [choice]
model_response.model = model
# Calculate usage
calculated_usage = self._calculate_usage(model, messages, content)
if calculated_usage:
setattr(model_response, "usage", calculated_usage)
return model_response
except Exception as e:
verbose_logger.error(f"Error processing Vertex Agent Engine response: {str(e)}")
raise VertexAgentEngineError(
message=f"Error processing response: {str(e)}",
status_code=raw_response.status_code,
)
def get_streaming_response(
self,
model: str,
raw_response: httpx.Response,
) -> VertexAgentEngineResponseIterator:
"""Return a streaming iterator for SSE responses."""
return VertexAgentEngineResponseIterator(
streaming_response=raw_response.iter_lines(),
sync_stream=True,
)
def get_sync_custom_stream_wrapper(
self,
model: str,
custom_llm_provider: str,
logging_obj: LiteLLMLoggingObj,
api_base: str,
headers: dict,
data: dict,
messages: list,
client: Optional[Union[HTTPHandler, "AsyncHTTPHandler"]] = None,
json_mode: Optional[bool] = None,
signed_json_body: Optional[bytes] = None,
) -> "CustomStreamWrapper":
"""Get a CustomStreamWrapper for synchronous streaming."""
from litellm.llms.custom_httpx.http_handler import (
HTTPHandler,
_get_httpx_client,
)
from litellm.utils import CustomStreamWrapper
if client is None or not isinstance(client, HTTPHandler):
client = _get_httpx_client(params={})
# Avoid logging sensitive api_base directly
verbose_logger.debug("Making sync streaming request to Vertex AI endpoint.")
# Make streaming request
response = client.post(
api_base,
headers=headers,
data=json.dumps(data),
stream=True,
logging_obj=logging_obj,
)
if response.status_code != 200:
raise VertexAgentEngineError(
status_code=response.status_code, message=str(response.read())
)
# Create iterator for SSE stream
completion_stream = self.get_streaming_response(model=model, raw_response=response)
streaming_response = CustomStreamWrapper(
completion_stream=completion_stream,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
# LOGGING
logging_obj.post_call(
input=messages,
api_key="",
original_response="first stream response received",
additional_args={"complete_input_dict": data},
)
return streaming_response
async def get_async_custom_stream_wrapper(
self,
model: str,
custom_llm_provider: str,
logging_obj: LiteLLMLoggingObj,
api_base: str,
headers: dict,
data: dict,
messages: list,
client: Optional["AsyncHTTPHandler"] = None,
json_mode: Optional[bool] = None,
signed_json_body: Optional[bytes] = None,
) -> "CustomStreamWrapper":
"""Get a CustomStreamWrapper for asynchronous streaming."""
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
get_async_httpx_client,
)
from litellm.utils import CustomStreamWrapper
if client is None or not isinstance(client, AsyncHTTPHandler):
client = get_async_httpx_client(
llm_provider=cast(Any, "vertex_ai"), params={}
)
# Avoid logging sensitive api_base directly
verbose_logger.debug("Making async streaming request to Vertex AI endpoint.")
# Make async streaming request
response = await client.post(
api_base,
headers=headers,
data=json.dumps(data),
stream=True,
logging_obj=logging_obj,
)
if response.status_code != 200:
raise VertexAgentEngineError(
status_code=response.status_code, message=str(await response.aread())
)
# Create iterator for SSE stream (async)
completion_stream = VertexAgentEngineResponseIterator(
streaming_response=response.aiter_lines(),
sync_stream=False,
)
streaming_response = CustomStreamWrapper(
completion_stream=completion_stream,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
# LOGGING
logging_obj.post_call(
input=messages,
api_key="",
original_response="first stream response received",
additional_args={"complete_input_dict": data},
)
return streaming_response
@property
def has_custom_stream_wrapper(self) -> bool:
"""Indicates that this config has custom streaming support."""
return True
@property
def supports_stream_param_in_request_body(self) -> bool:
"""Agent Engine does not allow passing `stream` in the request body."""
return False
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
) -> BaseLLMException:
return VertexAgentEngineError(status_code=status_code, message=error_message)
def should_fake_stream(
self,
model: Optional[str],
stream: Optional[bool],
custom_llm_provider: Optional[str] = None,
) -> bool:
"""Agent Engine always returns SSE streams, so we use real streaming."""
return False

View File

@ -5,7 +5,6 @@ from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, get_ty
import httpx
import litellm
from litellm.utils import supports_response_schema, supports_system_messages
from litellm._logging import verbose_logger
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs
@ -14,6 +13,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.vertex_ai import PartType, Schema
from litellm.types.utils import TokenCountResponse
from litellm.utils import supports_response_schema, supports_system_messages
class VertexAIError(BaseLLMException):
@ -36,6 +36,7 @@ class VertexAIModelRoute(str, Enum):
MODEL_GARDEN = "model_garden"
NON_GEMINI = "non_gemini"
OPENAI_COMPATIBLE = "openai"
AGENT_ENGINE = "agent_engine"
VERTEX_AI_MODEL_ROUTES = [f"{route.value}/" for route in VertexAIModelRoute]
@ -76,6 +77,10 @@ def get_vertex_ai_model_route(
if litellm_params and litellm_params.get("base_model") is not None:
if "gemini" in litellm_params["base_model"]:
return VertexAIModelRoute.GEMINI
# Check for agent_engine models (Reasoning Engines)
if "agent_engine/" in model:
return VertexAIModelRoute.AGENT_ENGINE
# Check if numeric endpoint ID with custom api_base (PSC endpoint)
# Route to GEMINI (HTTP path) to support PSC endpoints properly

View File

@ -3242,6 +3242,37 @@ def completion( # type: ignore # noqa: PLR0915
timeout=timeout,
client=client,
)
elif model_route == VertexAIModelRoute.AGENT_ENGINE:
# Vertex AI Agent Engine (Reasoning Engines)
from litellm.llms.vertex_ai.agent_engine.transformation import (
VertexAgentEngineConfig,
)
vertex_agent_engine_config = VertexAgentEngineConfig()
# Update litellm_params with vertex credentials
litellm_params["vertex_project"] = vertex_ai_project
litellm_params["vertex_location"] = vertex_ai_location
litellm_params["vertex_credentials"] = vertex_credentials
model_response = base_llm_http_handler.completion(
model=model,
stream=stream,
messages=messages,
model_response=model_response,
optional_params=new_params,
litellm_params=litellm_params, # type: ignore
encoding=encoding,
api_key=None,
api_base=api_base,
logging_obj=logging,
acompletion=acompletion,
timeout=timeout,
client=client,
custom_llm_provider="vertex_ai",
provider_config=vertex_agent_engine_config,
headers=headers or {},
)
else: # VertexAIModelRoute.NON_GEMINI
model_response = vertex_ai_non_gemini.completion(
model=model,

View File

@ -30628,7 +30628,7 @@
"litellm_provider": "fireworks_ai",
"mode": "embedding"
},
"fireworks_ai/accounts/fireworks/models/qwen3-embedding-8b": {
"fireworks_ai/accounts/fireworks/models/": {
"max_tokens": 40960,
"max_input_tokens": 40960,
"max_output_tokens": 40960,

View File

@ -166,6 +166,29 @@
"litellm_params_template": {
"custom_llm_provider": "pydantic_ai_agents"
}
},
{
"agent_type": "vertex_agent_engine",
"agent_type_display_name": "Vertex AI Agent Engine",
"description": "Connect to Google Cloud Vertex AI Reasoning Engines",
"logo_url": "/ui/assets/logos/google.svg",
"inherit_credentials_from_provider": "Vertex_AI",
"model_template": "vertex_ai/agent_engine/{reasoning_engine_id}",
"credential_fields": [
{
"key": "reasoning_engine_id",
"label": "Reasoning Engine Resource ID",
"placeholder": "projects/123456789/locations/us-central1/reasoningEngines/987654321",
"tooltip": "The full resource ID of your Vertex AI Reasoning Engine. Find this in Google Cloud Console under Vertex AI > Agent Builder > Your Agent.",
"required": true,
"field_type": "text",
"default_value": null,
"include_in_litellm_params": false
}
],
"litellm_params_template": {
"custom_llm_provider": "vertex_ai"
}
}
]

View File

@ -2689,8 +2689,8 @@
"key": "vertex_credentials",
"label": "Vertex Credentials",
"placeholder": null,
"tooltip": null,
"required": true,
"tooltip": "Optional - Upload your GCP service account JSON file. If not provided, uses default GCP credentials (ADC).",
"required": false,
"field_type": "upload",
"options": null,
"default_value": null

View File

@ -0,0 +1,151 @@
"""
Test script for Vertex AI Reasoning Engine.
This script demonstrates how to:
1. Authenticate with Google Cloud
2. Send queries to a Vertex AI Reasoning Engine using the :query endpoint
Usage:
python local_vertex_agent.py
Requirements:
pip install httpx google-auth
"""
import asyncio
import json
from uuid import uuid4
from google.auth import default
from google.auth.transport.requests import Request
import httpx
# Configuration - update these for your agent
PROJECT_ID = "gen-lang-client-0682925754" # Your GCP project ID
LOCATION = "us-central1" # Your agent's location
# For Reasoning Engines, use just the numeric ID at the end
REASONING_ENGINE_ID = "8263861224643493888"
# The project number from the resource name
PROJECT_NUMBER = "1060139831167"
async def main():
"""Main function to test Vertex AI Reasoning Engine."""
# Step 1: Authenticate with Google Cloud
print("Step 1: Authenticating with Google Cloud...")
credentials, project = default(scopes=['https://www.googleapis.com/auth/cloud-platform'])
credentials.refresh(Request())
print(f"Authenticated! Project: {project}")
print(f"Token (first 20 chars): {credentials.token[:20]}...")
# Step 2: Build the endpoint URL
base_url = f"https://{LOCATION}-aiplatform.googleapis.com"
resource_path = f"projects/{PROJECT_NUMBER}/locations/{LOCATION}/reasoningEngines/{REASONING_ENGINE_ID}"
# The Reasoning Engine uses :query endpoint with specific format
query_url = f"{base_url}/v1beta1/{resource_path}:query"
stream_url = f"{base_url}/v1beta1/{resource_path}:streamQuery"
print(f"\nQuery URL: {query_url}")
print(f"Stream URL: {stream_url}")
# Step 3: Create authenticated httpx client
print("\nStep 2: Creating authenticated HTTP client...")
client = httpx.AsyncClient(
headers={
"Authorization": f"Bearer {credentials.token}",
"Content-Type": "application/json",
},
timeout=120.0,
)
# Step 4: Build the query request (non-streaming)
# Note: For non-streaming, we need to:
# 1. Create a session
# 2. Use the streaming endpoint with stream_query method
# The :query endpoint only supports session management methods
user_id = f"test-user-{uuid4().hex[:8]}"
# First create a session
create_session_request = {
"class_method": "async_create_session",
"input": {
"user_id": user_id,
}
}
print(f"\nStep 3: Creating session...")
print(f"User ID: {user_id}")
async with client:
# Create session
print(f"\nSending to: {query_url}")
response = await client.post(query_url, json=create_session_request)
print(f"Create session status: {response.status_code}")
if response.status_code == 200:
session_data = response.json()
print(f"Session created:\n{json.dumps(session_data, indent=2)}")
# Extract session_id from response
session_id = session_data.get("output", {}).get("id") or session_data.get("output", {}).get("session_id")
print(f"\nSession ID: {session_id}")
# Now send the actual query via streamQuery
query_request = {
"class_method": "stream_query",
"input": {
"message": "Hello! What can you do?",
"user_id": user_id,
"session_id": session_id,
}
}
print(f"\nStep 4: Sending query via streamQuery...")
print(f"Request:\n{json.dumps(query_request, indent=2)}")
# Use streaming endpoint but collect full response
async with client.stream("POST", stream_url, json=query_request) as stream_response:
print(f"Query status: {stream_response.status_code}")
if stream_response.status_code == 200:
print("\nResponse:")
full_response = ""
async for line in stream_response.aiter_lines():
if line:
full_response = line # Keep last line (full response)
# Parse and display
try:
data = json.loads(full_response)
# Extract the text from the response
content = data.get("content", {})
parts = content.get("parts", [])
for part in parts:
if "text" in part:
print(f"\nAgent response:\n{part['text']}")
except:
print(full_response)
else:
content = await stream_response.aread()
print(f"Error: {content.decode()}")
else:
print(f"Error creating session: {response.text}")
if __name__ == "__main__":
print("=" * 60)
print("Vertex AI Reasoning Engine Test Script")
print("=" * 60)
print(f"\nConfiguration:")
print(f" PROJECT_ID: {PROJECT_ID}")
print(f" PROJECT_NUMBER: {PROJECT_NUMBER}")
print(f" LOCATION: {LOCATION}")
print(f" REASONING_ENGINE_ID: {REASONING_ENGINE_ID}")
print()
asyncio.run(main())

View File

@ -201,3 +201,79 @@ async def test_a2a_completion_bridge_bedrock_agentcore():
print(f"Received {len(chunks)} chunks from Bedrock AgentCore")
# ============================================================
# Vertex AI Agent Engine Tests
# ============================================================
# Configuration - update these for your Vertex AI Reasoning Engine
VERTEX_AGENT_RESOURCE_NAME = "projects/1060139831167/locations/us-central1/reasoningEngines/8263861224643493888"
@pytest.mark.asyncio
async def test_vertex_agent_engine_non_streaming():
"""
Test non-streaming request to Vertex AI Agent Engine via litellm.acompletion.
Uses the Reasoning Engine resource ID to call a hosted agent.
"""
litellm._turn_on_debug()
# Call via litellm.acompletion with vertex_ai/agent_engine/ prefix
response = await litellm.acompletion(
model=f"vertex_ai/agent_engine/{VERTEX_AGENT_RESOURCE_NAME}",
messages=[{"role": "user", "content": "Hello! What can you do?"}],
stream=False,
)
print(f"\n=== Vertex Agent Engine Non-Streaming Response ===")
print(f"Response: {response}")
# Basic assertions
assert response is not None
assert hasattr(response, "choices")
assert len(response.choices) > 0
assert response.choices[0].message is not None
assert response.choices[0].message.content is not None
assert len(response.choices[0].message.content) > 0
print(f"Agent response: {response.choices[0].message.content[:200]}...")
@pytest.mark.asyncio
async def test_vertex_agent_engine_streaming():
"""
Test streaming request to Vertex AI Agent Engine via litellm.acompletion.
Uses the Reasoning Engine resource ID to call a hosted agent with streaming.
"""
#litellm._turn_on_debug()
# Call via litellm.acompletion with streaming
response = await litellm.acompletion(
model=f"vertex_ai/agent_engine/{VERTEX_AGENT_RESOURCE_NAME}",
messages=[{"role": "user", "content": "Hello! What can you do?"}],
stream=True,
)
print(f"\n=== Vertex Agent Engine Streaming Response ===")
chunks = []
full_content = ""
async for chunk in response:
print(f"Chunk: {chunk}")
# chunks.append(chunk)
# if hasattr(chunk, "choices") and len(chunk.choices) > 0:
# delta = chunk.choices[0].delta
# if hasattr(delta, "content") and delta.content:
# full_content += delta.content
# print(f"Chunk: {delta.content}", end="", flush=True)
# # print(f"\n\nReceived {len(chunks)} chunks")
# print(f"Full content: {full_content[:200]}...")
# # Basic assertions
# assert len(chunks) > 0
# assert len(full_content) > 0

View File

@ -0,0 +1,128 @@
"""
Tests for Vertex AI Agent Engine transformation.
Tests the request transformation and streaming chunk parsing without making real API calls.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.llms.vertex_ai.agent_engine.sse_iterator import (
VertexAgentEngineResponseIterator,
)
from litellm.llms.vertex_ai.agent_engine.transformation import VertexAgentEngineConfig
class TestVertexAgentEngineTransformRequest:
"""Tests for transform_request method."""
def test_transform_request_basic(self):
"""
Test that transform_request correctly formats messages into Vertex Agent Engine payload.
"""
config = VertexAgentEngineConfig()
messages = [{"role": "user", "content": "Hello, what can you do?"}]
optional_params = {"user_id": "test-user-123"}
litellm_params = {}
result = config.transform_request(
model="agent_engine/123456789",
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers={},
)
assert result["class_method"] == "stream_query"
assert result["input"]["message"] == "Hello, what can you do?"
assert result["input"]["user_id"] == "test-user-123"
assert "session_id" not in result["input"]
def test_transform_request_with_session_id(self):
"""
Test that transform_request includes session_id when provided.
"""
config = VertexAgentEngineConfig()
messages = [{"role": "user", "content": "Follow up question"}]
optional_params = {
"user_id": "test-user-123",
"session_id": "session-abc-456",
}
litellm_params = {}
result = config.transform_request(
model="agent_engine/123456789",
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers={},
)
assert result["class_method"] == "stream_query"
assert result["input"]["message"] == "Follow up question"
assert result["input"]["user_id"] == "test-user-123"
assert result["input"]["session_id"] == "session-abc-456"
class TestVertexAgentEngineChunkParser:
"""Tests for the streaming chunk parser."""
def test_chunk_parser_with_text_content(self):
"""
Test that chunk_parser correctly extracts text from Vertex Agent Engine response format.
"""
iterator = VertexAgentEngineResponseIterator(
streaming_response=iter([]),
sync_stream=True,
)
chunk = {
"content": {
"parts": [{"text": "Hello! I can help you with financial analysis."}],
"role": "model",
},
"finish_reason": "STOP",
"usage_metadata": {
"prompt_token_count": 100,
"candidates_token_count": 50,
"total_token_count": 150,
},
}
result = iterator.chunk_parser(chunk)
assert result.choices[0].delta.content == "Hello! I can help you with financial analysis."
assert result.choices[0].delta.role == "assistant"
assert result.choices[0].finish_reason == "stop"
assert result.usage["prompt_tokens"] == 100
assert result.usage["completion_tokens"] == 50
assert result.usage["total_tokens"] == 150
def test_chunk_parser_without_finish_reason(self):
"""
Test that chunk_parser handles chunks without finish_reason (intermediate chunks).
"""
iterator = VertexAgentEngineResponseIterator(
streaming_response=iter([]),
sync_stream=True,
)
chunk = {
"content": {
"parts": [{"text": "Partial response..."}],
"role": "model",
},
}
result = iterator.chunk_parser(chunk)
assert result.choices[0].delta.content == "Partial response..."
assert result.choices[0].finish_reason is None
assert result.usage is None