mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
chore: Add model tracking to LiteLlm and introduce a LiteLLM with fallbacks demo
Related: #2292 Co-authored-by: Shan Cao <caoshan@google.com> PiperOrigin-RevId: 828024955
This commit is contained in:
committed by
Copybara-Service
parent
e25beb4bce
commit
d4c63fc562
@@ -0,0 +1,10 @@
|
|||||||
|
# LiteLLM with Fallback Models
|
||||||
|
|
||||||
|
This agent is built for resilience using LiteLLM's built-in fallback mechanism. It automatically switches models to guard against common disruptions like token limit errors and connection failures, while ensuring full conversational context is preserved across all model changes.
|
||||||
|
|
||||||
|
To run this example, ensure your .env file includes the following variables:
|
||||||
|
```
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
```
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Copyright 2025 Google LLC
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
from . import agent
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Copyright 2025 Google LLC
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from google.adk import Agent
|
||||||
|
from google.adk.models.lite_llm import LiteLlm
|
||||||
|
from google.adk.tools.tool_context import ToolContext
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
|
||||||
|
def roll_die(sides: int, tool_context: ToolContext) -> int:
|
||||||
|
"""Roll a die and return the rolled result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sides: The integer number of sides the die has.
|
||||||
|
tool_context: The tool context to use for the die roll.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An integer of the result of rolling the die.
|
||||||
|
The result is also stored in the tool context for future use.
|
||||||
|
"""
|
||||||
|
result = random.randint(1, sides)
|
||||||
|
if 'rolls' not in tool_context.state:
|
||||||
|
tool_context.state['rolls'] = []
|
||||||
|
|
||||||
|
tool_context.state['rolls'] = tool_context.state['rolls'] + [result]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def before_model_callback(callback_context, llm_request):
|
||||||
|
print('@before_model_callback')
|
||||||
|
print(f'Beginning model choice: {llm_request.model}')
|
||||||
|
callback_context.state['beginning_model_choice'] = llm_request.model
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def after_model_callback(callback_context, llm_response):
|
||||||
|
print('@after_model_callback')
|
||||||
|
print(f'Final model choice: {llm_response.model_version}')
|
||||||
|
callback_context.state['final_model_choice'] = llm_response.model_version
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
root_agent = Agent(
|
||||||
|
model=LiteLlm(
|
||||||
|
model='gemini/gemini-2.5-pro',
|
||||||
|
fallbacks=[
|
||||||
|
'anthropic/claude-sonnet-4-5-20250929',
|
||||||
|
'openai/gpt-4o',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
name='resilient_agent',
|
||||||
|
description=(
|
||||||
|
'hello world agent that can roll a dice of given number of sides.'
|
||||||
|
),
|
||||||
|
instruction="""
|
||||||
|
You roll dice and answer questions about the outcome of the dice rolls.
|
||||||
|
You can roll dice of different sizes.
|
||||||
|
It is ok to discuss previous dice roles, and comment on the dice rolls.
|
||||||
|
When you are asked to roll a die, you must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string.
|
||||||
|
You should never roll a die on your own.
|
||||||
|
""",
|
||||||
|
tools=[
|
||||||
|
roll_die,
|
||||||
|
],
|
||||||
|
generate_content_config=types.GenerateContentConfig(
|
||||||
|
safety_settings=[
|
||||||
|
types.SafetySetting( # avoid false alarm about rolling dice.
|
||||||
|
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||||
|
threshold=types.HarmBlockThreshold.OFF,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
before_model_callback=before_model_callback,
|
||||||
|
after_model_callback=after_model_callback,
|
||||||
|
)
|
||||||
@@ -558,7 +558,9 @@ def _model_response_to_generate_content_response(
|
|||||||
if not message:
|
if not message:
|
||||||
raise ValueError("No message in response")
|
raise ValueError("No message in response")
|
||||||
|
|
||||||
llm_response = _message_to_generate_content_response(message)
|
llm_response = _message_to_generate_content_response(
|
||||||
|
message, model_version=response.model
|
||||||
|
)
|
||||||
if finish_reason:
|
if finish_reason:
|
||||||
# If LiteLLM already provides a FinishReason enum (e.g., for Gemini), use
|
# If LiteLLM already provides a FinishReason enum (e.g., for Gemini), use
|
||||||
# it directly. Otherwise, map the finish_reason string to the enum.
|
# it directly. Otherwise, map the finish_reason string to the enum.
|
||||||
@@ -579,13 +581,14 @@ def _model_response_to_generate_content_response(
|
|||||||
|
|
||||||
|
|
||||||
def _message_to_generate_content_response(
|
def _message_to_generate_content_response(
|
||||||
message: Message, is_partial: bool = False
|
message: Message, *, is_partial: bool = False, model_version: str = None
|
||||||
) -> LlmResponse:
|
) -> LlmResponse:
|
||||||
"""Converts a litellm message to LlmResponse.
|
"""Converts a litellm message to LlmResponse.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: The message to convert.
|
message: The message to convert.
|
||||||
is_partial: Whether the message is partial.
|
is_partial: Whether the message is partial.
|
||||||
|
model_version: The model version used to generate the response.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The LlmResponse.
|
The LlmResponse.
|
||||||
@@ -606,7 +609,9 @@ def _message_to_generate_content_response(
|
|||||||
parts.append(part)
|
parts.append(part)
|
||||||
|
|
||||||
return LlmResponse(
|
return LlmResponse(
|
||||||
content=types.Content(role="model", parts=parts), partial=is_partial
|
content=types.Content(role="model", parts=parts),
|
||||||
|
partial=is_partial,
|
||||||
|
model_version=model_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -950,6 +955,7 @@ class LiteLlm(BaseLlm):
|
|||||||
content=chunk.text,
|
content=chunk.text,
|
||||||
),
|
),
|
||||||
is_partial=True,
|
is_partial=True,
|
||||||
|
model_version=part.model,
|
||||||
)
|
)
|
||||||
elif isinstance(chunk, UsageMetadataChunk):
|
elif isinstance(chunk, UsageMetadataChunk):
|
||||||
usage_metadata = types.GenerateContentResponseUsageMetadata(
|
usage_metadata = types.GenerateContentResponseUsageMetadata(
|
||||||
@@ -981,14 +987,16 @@ class LiteLlm(BaseLlm):
|
|||||||
role="assistant",
|
role="assistant",
|
||||||
content=text,
|
content=text,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
)
|
),
|
||||||
|
model_version=part.model,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text = ""
|
text = ""
|
||||||
function_calls.clear()
|
function_calls.clear()
|
||||||
elif finish_reason == "stop" and text:
|
elif finish_reason == "stop" and text:
|
||||||
aggregated_llm_response = _message_to_generate_content_response(
|
aggregated_llm_response = _message_to_generate_content_response(
|
||||||
ChatCompletionAssistantMessage(role="assistant", content=text)
|
ChatCompletionAssistantMessage(role="assistant", content=text),
|
||||||
|
model_version=part.model,
|
||||||
)
|
)
|
||||||
text = ""
|
text = ""
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class LlmResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
"""The pydantic model config."""
|
"""The pydantic model config."""
|
||||||
|
|
||||||
|
model_version: Optional[str] = None
|
||||||
|
"""Output only. The model version used to generate the response."""
|
||||||
|
|
||||||
content: Optional[types.Content] = None
|
content: Optional[types.Content] = None
|
||||||
"""The generative content of the response.
|
"""The generative content of the response.
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class LlmResponse(BaseModel):
|
|||||||
citation_metadata=candidate.citation_metadata,
|
citation_metadata=candidate.citation_metadata,
|
||||||
avg_logprobs=candidate.avg_logprobs,
|
avg_logprobs=candidate.avg_logprobs,
|
||||||
logprobs_result=candidate.logprobs_result,
|
logprobs_result=candidate.logprobs_result,
|
||||||
|
model_version=generate_content_response.model_version,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return LlmResponse(
|
return LlmResponse(
|
||||||
@@ -169,6 +173,7 @@ class LlmResponse(BaseModel):
|
|||||||
finish_reason=candidate.finish_reason,
|
finish_reason=candidate.finish_reason,
|
||||||
avg_logprobs=candidate.avg_logprobs,
|
avg_logprobs=candidate.avg_logprobs,
|
||||||
logprobs_result=candidate.logprobs_result,
|
logprobs_result=candidate.logprobs_result,
|
||||||
|
model_version=generate_content_response.model_version,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if generate_content_response.prompt_feedback:
|
if generate_content_response.prompt_feedback:
|
||||||
@@ -177,10 +182,12 @@ class LlmResponse(BaseModel):
|
|||||||
error_code=prompt_feedback.block_reason,
|
error_code=prompt_feedback.block_reason,
|
||||||
error_message=prompt_feedback.block_reason_message,
|
error_message=prompt_feedback.block_reason_message,
|
||||||
usage_metadata=usage_metadata,
|
usage_metadata=usage_metadata,
|
||||||
|
model_version=generate_content_response.model_version,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return LlmResponse(
|
return LlmResponse(
|
||||||
error_code='UNKNOWN_ERROR',
|
error_code='UNKNOWN_ERROR',
|
||||||
error_message='Unknown error.',
|
error_message='Unknown error.',
|
||||||
usage_metadata=usage_metadata,
|
usage_metadata=usage_metadata,
|
||||||
|
model_version=generate_content_response.model_version,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ LLM_REQUEST_WITH_FUNCTION_DECLARATION = LlmRequest(
|
|||||||
|
|
||||||
STREAMING_MODEL_RESPONSE = [
|
STREAMING_MODEL_RESPONSE = [
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
StreamingChoices(
|
StreamingChoices(
|
||||||
finish_reason=None,
|
finish_reason=None,
|
||||||
@@ -98,9 +99,10 @@ STREAMING_MODEL_RESPONSE = [
|
|||||||
content="zero, ",
|
content="zero, ",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
StreamingChoices(
|
StreamingChoices(
|
||||||
finish_reason=None,
|
finish_reason=None,
|
||||||
@@ -109,9 +111,10 @@ STREAMING_MODEL_RESPONSE = [
|
|||||||
content="one, ",
|
content="one, ",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
StreamingChoices(
|
StreamingChoices(
|
||||||
finish_reason=None,
|
finish_reason=None,
|
||||||
@@ -120,9 +123,10 @@ STREAMING_MODEL_RESPONSE = [
|
|||||||
content="two:",
|
content="two:",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
StreamingChoices(
|
StreamingChoices(
|
||||||
finish_reason=None,
|
finish_reason=None,
|
||||||
@@ -141,9 +145,10 @@ STREAMING_MODEL_RESPONSE = [
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
StreamingChoices(
|
StreamingChoices(
|
||||||
finish_reason=None,
|
finish_reason=None,
|
||||||
@@ -162,14 +167,15 @@ STREAMING_MODEL_RESPONSE = [
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
StreamingChoices(
|
StreamingChoices(
|
||||||
finish_reason="tool_use",
|
finish_reason="tool_use",
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -342,6 +348,7 @@ STREAM_WITH_EMPTY_CHUNK = [
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_response():
|
def mock_response():
|
||||||
return ModelResponse(
|
return ModelResponse(
|
||||||
|
model="test_model",
|
||||||
choices=[
|
choices=[
|
||||||
Choices(
|
Choices(
|
||||||
message=ChatCompletionAssistantMessage(
|
message=ChatCompletionAssistantMessage(
|
||||||
@@ -359,7 +366,7 @@ def mock_response():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -529,6 +536,7 @@ async def test_generate_content_async(mock_acompletion, lite_llm_instance):
|
|||||||
"test_arg": "test_value"
|
"test_arg": "test_value"
|
||||||
}
|
}
|
||||||
assert response.content.parts[1].function_call.id == "test_tool_call_id"
|
assert response.content.parts[1].function_call.id == "test_tool_call_id"
|
||||||
|
assert response.model_version == "test_model"
|
||||||
|
|
||||||
mock_acompletion.assert_called_once()
|
mock_acompletion.assert_called_once()
|
||||||
|
|
||||||
@@ -1262,6 +1270,19 @@ def test_message_to_generate_content_response_tool_call():
|
|||||||
assert response.content.parts[0].function_call.id == "test_tool_call_id"
|
assert response.content.parts[0].function_call.id == "test_tool_call_id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_to_generate_content_response_with_model():
|
||||||
|
message = ChatCompletionAssistantMessage(
|
||||||
|
role="assistant",
|
||||||
|
content="Test response",
|
||||||
|
)
|
||||||
|
response = _message_to_generate_content_response(
|
||||||
|
message, model_version="gemini-2.5-pro"
|
||||||
|
)
|
||||||
|
assert response.content.role == "model"
|
||||||
|
assert response.content.parts[0].text == "Test response"
|
||||||
|
assert response.model_version == "gemini-2.5-pro"
|
||||||
|
|
||||||
|
|
||||||
def test_get_content_text():
|
def test_get_content_text():
|
||||||
parts = [types.Part.from_text(text="Test text")]
|
parts = [types.Part.from_text(text="Test text")]
|
||||||
content = _get_content(parts)
|
content = _get_content(parts)
|
||||||
@@ -1556,16 +1577,20 @@ async def test_generate_content_async_stream(
|
|||||||
assert len(responses) == 4
|
assert len(responses) == 4
|
||||||
assert responses[0].content.role == "model"
|
assert responses[0].content.role == "model"
|
||||||
assert responses[0].content.parts[0].text == "zero, "
|
assert responses[0].content.parts[0].text == "zero, "
|
||||||
|
assert responses[0].model_version == "test_model"
|
||||||
assert responses[1].content.role == "model"
|
assert responses[1].content.role == "model"
|
||||||
assert responses[1].content.parts[0].text == "one, "
|
assert responses[1].content.parts[0].text == "one, "
|
||||||
|
assert responses[1].model_version == "test_model"
|
||||||
assert responses[2].content.role == "model"
|
assert responses[2].content.role == "model"
|
||||||
assert responses[2].content.parts[0].text == "two:"
|
assert responses[2].content.parts[0].text == "two:"
|
||||||
|
assert responses[2].model_version == "test_model"
|
||||||
assert responses[3].content.role == "model"
|
assert responses[3].content.role == "model"
|
||||||
assert responses[3].content.parts[-1].function_call.name == "test_function"
|
assert responses[3].content.parts[-1].function_call.name == "test_function"
|
||||||
assert responses[3].content.parts[-1].function_call.args == {
|
assert responses[3].content.parts[-1].function_call.args == {
|
||||||
"test_arg": "test_value"
|
"test_arg": "test_value"
|
||||||
}
|
}
|
||||||
assert responses[3].content.parts[-1].function_call.id == "test_tool_call_id"
|
assert responses[3].content.parts[-1].function_call.id == "test_tool_call_id"
|
||||||
|
assert responses[3].model_version == "test_model"
|
||||||
mock_completion.assert_called_once()
|
mock_completion.assert_called_once()
|
||||||
|
|
||||||
_, kwargs = mock_completion.call_args
|
_, kwargs = mock_completion.call_args
|
||||||
|
|||||||
@@ -334,3 +334,18 @@ def test_llm_response_create_empty_content_with_stop_reason():
|
|||||||
|
|
||||||
assert response.error_code is None
|
assert response.error_code is None
|
||||||
assert response.content is not None
|
assert response.content is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_response_create_includes_model_version():
|
||||||
|
"""Test LlmResponse.create() includes model version."""
|
||||||
|
generate_content_response = types.GenerateContentResponse(
|
||||||
|
model_version='gemini-2.0-flash',
|
||||||
|
candidates=[
|
||||||
|
types.Candidate(
|
||||||
|
content=types.Content(parts=[types.Part(text='Response text')]),
|
||||||
|
finish_reason=types.FinishReason.STOP,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
response = LlmResponse.create(generate_content_response)
|
||||||
|
assert response.model_version == 'gemini-2.0-flash'
|
||||||
|
|||||||
Reference in New Issue
Block a user