Implement Custom LiteLlm for Google ADK with Tool Call Support
Original question: Implementing a custom LiteLlm for Google agent development kit that supports tool calls

To implement a custom LiteLlm wrapper for the Google Agent Development Kit (ADK) that supports tool calls, you must propagate tool call information from your LLM endpoint's response into LiteLlm's ModelResponse object. The common mistake is only mapping message.content while ignoring the tool_calls array. The accepted solution involves subclassing BaseLlm directly from google.adk.models.base_llm and manually constructing LlmResponse objects that include types.Part instances for both text and function calls.
The Full Answer

Understanding the Problem
The original code from Source 1 creates a custom LiteLlm class that inherits from litellm.CustomLLM. It works for simple text generation but fails when the agent needs to call a tool. The root cause is in the process_request method: it only extracts content from the API response and maps it into a litellm.Message. The tool_calls field, which the API may return when the model decides to invoke a tool, is completely ignored. Without this field in the ModelResponse, LiteLlm and consequently the ADK agent never see that a tool should be called. The agent receives only text and proceeds as if no tool is needed, leading to the behavior described: "my tool never gets called."
Solution: Subclass BaseLlm Directly
The accepted solution from Source 2 takes a different approach. Instead of wrapping LiteLlm's CustomLLM, it subclasses BaseLlm from the ADK itself (google.adk.models.base_llm). This gives direct control over how the LLM response is parsed and how tool calls are represented. The key classes involved are:
LlmRequest: Contains the incoming request, includingcontents(messages) andtools(tool definitions).LlmResponse: The response object that the ADK agent expects. It contains aContentobject with a list ofPartobjects.types.Part: A part can be text (viaPart.from_text()) or a function call (viatypes.Part(function_call=...)).
Step-by-Step Implementation
1. Create a Custom HTTP Client
First, define a minimal async HTTP client to communicate with your LLM endpoint. This client handles authentication and sends requests to a standard OpenAI-compatible chat completions endpoint.
import aiohttp
from typing import Any, Dict, Optional
class MyCustomClient:
"""Minimal HTTP client for a custom LLM endpoint."""
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession()
return self.session
async def close(self):
if self.session and not self.session.closed:
await self.session.close()
async def generate(self, payload: Dict[str, Any]) -> Dict[str, Any]:
session = await self._get_session()
headers = {"Authorization": f"Bearer {self.api_key}"}
url = f"{self.base_url}/v1/chat/completions"
async with session.post(url, json=payload, headers=headers) as response:
response.raise_for_status()
return await response.json()
What this does: It creates a reusable session, sends a POST request to your endpoint with the payload (messages and tools), and returns the JSON response. The close() method ensures proper cleanup.
2. Implement the Custom LLM Class
Now, create the main class that inherits from BaseLlm. This class must implement the generate_content_async method, which is an async generator that yields LlmResponse objects.
import logging
from typing import AsyncGenerator
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types
logger = logging.getLogger(__name__)
class MyLiteLlm(BaseLlm):
"""Custom LLM implementation that supports tool calls."""
def __init__(self, model: str, base_url: str, api_key: str, **kwargs):
super().__init__(model=model, **kwargs)
self._client = MyCustomClient(base_url, api_key)
self._model = model
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
if not llm_request.contents:
raise ValueError("LlmRequest must contain contents")
payload = {
"model": self._model,
"messages": [c.model_dump(exclude_none=True) for c in llm_request.contents],
}
# optional: include tool declarations if present
if llm_request.tools:
payload["tools"] = [t.model_dump(exclude_none=True) for t in llm_request.tools]
api_response = await self._client.generate(payload)
# Convert response into ADK's LlmResponse, preserving tool calls
parts = []
for choice in api_response.get("choices", []):
msg = choice.get("message", {})
if "content" in msg and msg["content"]:
parts.append(types.Part.from_text(text=msg["content"]))
if "tool_calls" in msg:
for tc in msg["tool_calls"]:
parts.append(
types.Part(
function_call=types.FunctionCall(
name=tc.get("function", {}).get("name", ""),
args=tc.get("function", {}).get("arguments", {}),
)
)
)
llm_response = LlmResponse(
content=types.Content(
role=api_response.get("role", "model"),
parts=parts,
),
partial=False,
)
yield llm_response
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._client.close()
Key details:
- The
payloaddictionary is constructed fromllm_request.contents. Themodel_dump(exclude_none=True)method serializes each content object into a dictionary, excluding any fields that areNone. This ensures compatibility with standard OpenAI API formats. - If
llm_request.toolsis present (a list of tool definitions), they are also serialized and included in the payload under the"tools"key. This tells your LLM endpoint that tools are available. - After receiving the API response, the code iterates over
choices. For each choice, it checks for bothcontentandtool_callsin the message. Text content becomes aPart.from_text(). Each tool call becomes atypes.Partwith afunction_callattribute containing the function name and arguments. - The
LlmResponseis constructed with aContentobject containing all parts. Thepartial=Falseflag indicates this is a complete response (not streaming). - The
__aenter__and__aexit__methods enable use as an async context manager, ensuring the HTTP client is properly closed.
3. Use the Custom LLM with an ADK Agent
Once the class is defined, you can instantiate it and pass it to an ADK agent. The agent will call generate_content_async when it needs to generate a response, and tool calls will be handled automatically.
my_llm = MyLiteLlm(
model="your-model-name",
base_url="https://your-endpoint.com",
api_key="your-api-key"
)
# Then pass my_llm to your ADK agent
# agent = YourAgent(llm=my_llm, ...)
When to Use Which Approach
- Use the
BaseLlmsubclass approach (Source 2) when you need full control over how tool calls are represented and your LLM endpoint returns tool calls in a standard format (e.g., OpenAI-compatible). This is the recommended solution for supporting tools. - Use the
litellm.CustomLLMapproach (Source 1) only if you do not need tool call support and are only doing simple text generation. It is simpler but incomplete for agent workflows.
Common Pitfalls
-
Missing
tool_callsin the response mapping: The most common mistake, as reported in Source 1, is only mappingcontentand ignoringtool_calls. Even if your endpoint returns tool calls, if you don't propagate them into theModelResponseorLlmResponse, the agent will never see them. -
Incorrect serialization of tool definitions: When building the payload, ensure you use
model_dump(exclude_none=True)on both contents and tools. If you omitexclude_none=True, fields withNonevalues may cause serialization errors or unexpected behavior in your LLM endpoint. -
Role field in the response: The code in Source 2 uses
api_response.get("role", "model")for theContentrole. Some endpoints may not return arolefield at the top level of the response. If your endpoint returns it inside each choice, you need to adjust accordingly. Always check your API's actual response format. -
Streaming not implemented: The
generate_content_asyncmethod in Source 2 accepts astreamparameter but does not use it. If you need streaming support, you must implement it separately. The current implementation always returns a single, non-streaming response. -
Error handling: The code uses
response.raise_for_status()which will raise an exception for HTTP errors. In production, you may want to add more robust error handling and logging, especially for network timeouts or malformed responses. -
Context manager usage: If you do not use the custom LLM as an async context manager (i.e.,
async with MyLiteLlm(...) as llm:), you must manually callawait llm._client.close()to release the HTTP session. Failing to do so may lead to resource leaks.
Related Questions
How do I debug why my custom LLM is not receiving tool definitions?
Check the llm_request.tools attribute inside generate_content_async. If it is None or empty, the agent is not passing tool definitions. Ensure that your agent is configured with tools. Also verify that the payload["tools"] line is executed and that the serialization produces valid JSON. Add logging to print the payload before sending it to the endpoint.
Can I use this approach with a non-OpenAI-compatible API?
Yes, but you will need to adapt the MyCustomClient.generate method and the payload construction to match your API's format. The key is that your endpoint must return a response that includes both text content and tool call information. You then map that into types.Part objects as shown. The BaseLlm subclass pattern remains the same.
What if my LLM endpoint returns tool calls in a different format?
You must adjust the parsing logic in the generate_content_async method. For example, if your endpoint returns tool calls under a different key (e.g., "function_call" instead of "tool_calls"), change the condition accordingly. The essential part is to create types.Part objects with function_call set to a types.FunctionCall with the correct name and args.
How do I handle multiple tool calls in a single response?
The code in Source 2 already handles this. It iterates over msg["tool_calls"] and creates a separate types.Part for each. The ADK agent will receive all tool calls and execute them in parallel or sequentially depending on its implementation. No additional changes are needed.
The #1 AI Newsletter
The most important ai updates, guides, and fixes โ one weekly email.
No spam, unsubscribe anytime. Privacy policy
Sources & References
This page was researched from 2 independent sources, combined and verified for completeness.
- 1.
- 2.Answer by Ahmed OTHMEN (score 2, accepted)Stack Overflow ยท primary source
Related Answers
Keep exploring
Latest error solutions
Skip the manual work
Ready-made AI workflows and automation templates โ import and run instead of building from scratch.