Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 131 additions & 13 deletions src/google/adk/models/lite_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,94 @@ def _strip_proxy_prefix(model: str) -> str:
Returns:
The model string without the ``litellm_proxy/`` prefix if nested.
"""
if not model:
return model
# Strip based on the literal prefix only. `_is_proxied_model` is also true
# for unprefixed models under `USE_LITELLM_PROXY`, and slicing those would
# cut into the model name itself.
prefix = _PROXY_PROVIDER + "/"
if model.lower().startswith(prefix):
if model and model.lower().startswith(prefix):
remaining = model[len(prefix) :]
if "/" in remaining:
return remaining
return model


_UPLOAD_ENDPOINT_KEYS = ("api_base", "api_key", "api_version")

# LiteLLM reads these when the completion call carries no explicit endpoint,
# so a proxy configured only through the environment still has to be honored
# by the upload.
_PROXY_ENV_FALLBACKS = {
"api_base": "LITELLM_PROXY_API_BASE",
"api_key": "LITELLM_PROXY_API_KEY",
}


def _get_upload_params(
model: str, completion_args: Dict[str, Any]
) -> Dict[str, Any]:
"""Endpoint overrides to forward to `litellm.acreate_file`.

File uploads resolve their endpoint independently of the completion call, so
without these they fall back to the underlying provider's own credentials.
For a proxied model that splits the request in two: the completion goes to
the proxy while the upload goes straight to the provider. That fails when the
caller holds only proxy credentials, and when the provider resolves to
``openai`` it is worse than a failure -- `get_openai_credentials` defaults to
``https://api.openai.com/v1``, so a stray ``OPENAI_API_KEY`` in the
environment sends proxied file content to the public OpenAI API instead.

Explicit completion arguments win, falling back to the proxy's own
environment variables so that proxies configured purely through the
environment are still routed correctly.

Only proxied models forward anything. A direct `azure/...` model keeps the
existing environment-variable behavior, since its `api_base` already points
at the provider.

Args:
model: The LiteLLM model string.
completion_args: The arguments the caller passes to the completion call.

Returns:
The subset of endpoint overrides to forward, empty when not proxied.
"""
if not _is_proxied_model(model):
return {}
upload_params = {
key: completion_args[key]
for key in _UPLOAD_ENDPOINT_KEYS
if completion_args.get(key)
}
# LiteLLM resolves the proxy endpoint from these when the completion call
# does not carry one, so the upload has to follow the same fallback.
for key, env_var in _PROXY_ENV_FALLBACKS.items():
if not upload_params.get(key):
# Strip before testing: a whitespace-only value is not an endpoint, and
# forwarding it would override the resolution it was meant to supply.
value = os.environ.get(env_var, "").strip()
if value:
upload_params[key] = value
return upload_params


def _is_proxied_model(model: str) -> bool:
"""Returns True if the model is served through a LiteLLM Proxy.

Covers both the explicit ``litellm_proxy/`` prefix and LiteLLM's
``USE_LITELLM_PROXY`` flag, which routes unprefixed models through the proxy
as well.
"""
if not model:
return False
if model.lower().startswith(_PROXY_PROVIDER + "/"):
return True
# Match LiteLLM's own reading of the flag rather than inventing one. It only
# treats "true"/"false" as booleans, so a hand-rolled check that also
# accepted "1" would route the upload through the proxy while LiteLLM sent
# the completion straight to the provider.
return os.environ.get("USE_LITELLM_PROXY", "").strip().lower() == "true"


def _get_provider_from_model(model: str) -> str:
"""Extracts the provider name from a LiteLLM model string.

Expand Down Expand Up @@ -1182,6 +1260,7 @@ async def _content_to_message_param(
*,
provider: str = "",
model: str = "",
upload_params: Optional[Dict[str, Any]] = None,
) -> Union[Message, list[Message]]:
"""Converts a types.Content to a litellm Message or list of Messages.

Expand Down Expand Up @@ -1251,7 +1330,13 @@ async def _content_to_message_param(
if role == "user":
user_parts = [part for part in content_parts_or_empty if not part.thought]
message_content = (
await _get_content(user_parts, provider=provider, model=model) or None
await _get_content(
user_parts,
provider=provider,
model=model,
upload_params=upload_params,
)
or None
)
return ChatCompletionUserMessage(
role="user",
Expand Down Expand Up @@ -1297,7 +1382,12 @@ async def _content_to_message_param(
content_parts.append(part)

final_content = (
await _get_content(content_parts, provider=provider, model=model)
await _get_content(
content_parts,
provider=provider,
model=model,
upload_params=upload_params,
)
if content_parts
else None
)
Expand Down Expand Up @@ -1447,6 +1537,7 @@ async def _get_content(
*,
provider: str = "",
model: str = "",
upload_params: Optional[Dict[str, Any]] = None,
) -> _MessageContent:
"""Converts a list of parts to litellm content.

Expand All @@ -1458,6 +1549,10 @@ async def _get_content(
provider: The LLM provider name (e.g., "openai", "azure").
model: The LiteLLM model string (e.g., "openai/gpt-4o",
"vertex_ai/gemini-2.5-flash").
upload_params: Endpoint overrides (``api_base``, ``api_key``,
``api_version``) forwarded to the file upload. Needed when the model is
served through a LiteLLM Proxy, since the upload would otherwise fall
back to provider environment variables and bypass the proxy.

Returns:
The litellm content.
Expand Down Expand Up @@ -1521,15 +1616,28 @@ async def _get_content(
elif mime_type in _SUPPORTED_FILE_CONTENT_MIME_TYPES:
# OpenAI/Azure require file_id from uploaded file, not inline data
if provider in _FILE_ID_REQUIRED_PROVIDERS:
upload_provider = (
"openai"
if model.lower().startswith(_PROXY_PROVIDER + "/")
else provider
)
# Keep the provider that actually serves the request so the payload
# keeps its provider-specific shape, and point the upload at the
# proxy via `upload_params` instead of swapping the provider out.
if _is_proxied_model(model) and not (upload_params or {}).get(
"api_base"
):
# Without an endpoint the upload would fall back to the provider's
# own credentials. For `openai` that default is the public
# https://api.openai.com/v1, so proxied file content would leave
# the proxy entirely. Fail instead of silently sending it there.
raise ValueError(
f"Cannot upload file for proxied model {model!r}: the LiteLLM"
" Proxy endpoint is unknown. Pass `api_base` (and `api_key`) to"
" LiteLlm(...), or set LITELLM_PROXY_API_BASE and"
" LITELLM_PROXY_API_KEY, so the upload reaches the proxy"
" instead of the provider's default endpoint."
)
file_response = await litellm.acreate_file(
file=part.inline_data.data,
purpose="assistants",
custom_llm_provider=upload_provider,
custom_llm_provider=provider,
**(upload_params or {}),
)
content_objects.append(
_FileContentObject(
Expand Down Expand Up @@ -2544,6 +2652,7 @@ def _to_litellm_response_format(
async def _get_completion_inputs(
llm_request: LlmRequest,
model: str,
upload_params: Optional[Dict[str, Any]] = None,
) -> Tuple[
List[Message],
Optional[List[Dict[str, Any]]],
Expand All @@ -2570,7 +2679,10 @@ async def _get_completion_inputs(
messages: List[Message] = []
for content in llm_request.contents or []:
message_param_or_list = await _content_to_message_param(
content, provider=provider, model=model
content,
provider=provider,
model=model,
upload_params=upload_params,
)
if isinstance(message_param_or_list, list):
messages.extend(message_param_or_list)
Expand Down Expand Up @@ -2991,7 +3103,13 @@ async def generate_content_async(

effective_model = llm_request.model or self.model
messages, tools, response_format, generation_params, tool_choice = (
await _get_completion_inputs(llm_request, effective_model)
await _get_completion_inputs(
llm_request,
effective_model,
upload_params=_get_upload_params(
effective_model, self._additional_args
),
)
)
normalized_messages = _normalize_ollama_chat_messages(
messages,
Expand Down
Loading