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
2 changes: 2 additions & 0 deletions src/google/adk/models/apigee_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from typing_extensions import override

from ..utils.env_utils import is_enterprise_mode_enabled
from .base_llm import _validate_candidate_count
from .google_llm import Gemini
from .llm_response import LlmResponse

Expand Down Expand Up @@ -633,6 +634,7 @@ def _map_config_parameters(
self, config: types.GenerateContentConfig, payload: dict[str, Any]
) -> None:
"""Maps configuration parameters to the payload."""
_validate_candidate_count(config)
if config.temperature is not None:
payload['temperature'] = config.temperature
if config.top_p is not None:
Expand Down
15 changes: 15 additions & 0 deletions src/google/adk/models/base_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@
from .llm_response import LlmResponse


def _validate_candidate_count(
config: types.GenerateContentConfig | None,
) -> None:
"""Validates that the request fits ADK's single-candidate response model."""
if (
config
and config.candidate_count is not None
and config.candidate_count > 1
):
raise ValueError(
'ADK supports only one response candidate; candidate_count must be 1'
' or unset.'
)


class BaseLlm(BaseModel):
"""The BaseLLM class."""

Expand Down
2 changes: 2 additions & 0 deletions src/google/adk/models/google_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from ..utils.context_utils import Aclosing
from ..utils.streaming_utils import StreamingResponseAggregator
from ..utils.variant_utils import GoogleLLMVariant
from .base_llm import _validate_candidate_count
from .base_llm import BaseLlm
from .base_llm_connection import BaseLlmConnection
from .gemini_llm_connection import GeminiLlmConnection
Expand Down Expand Up @@ -194,6 +195,7 @@ async def generate_content_async(
Yields:
LlmResponse: The model response.
"""
_validate_candidate_count(llm_request.config)
await self._preprocess_request(llm_request)
self._maybe_append_user_content(llm_request)

Expand Down
2 changes: 2 additions & 0 deletions src/google/adk/models/lite_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from typing_extensions import override

from ..utils._google_client_headers import merge_tracking_headers
from .base_llm import _validate_candidate_count
from .base_llm import BaseLlm
from .llm_request import LlmRequest
from .llm_response import LlmResponse
Expand Down Expand Up @@ -2348,6 +2349,7 @@ async def _get_completion_inputs(
The litellm inputs (message list, tool dictionary, response format,
generation params, and tool_choice).
"""
_validate_candidate_count(llm_request.config)
_ensure_litellm_imported()

# Determine provider for file handling
Expand Down
24 changes: 22 additions & 2 deletions tests/unittests/models/test_completions_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async def test_construct_payload_with_config(client, llm_request):
frequency_penalty=0.5,
presence_penalty=0.5,
seed=42,
candidate_count=2,
candidate_count=1,
response_mime_type='application/json',
)

Expand Down Expand Up @@ -107,10 +107,30 @@ async def test_construct_payload_with_config(client, llm_request):
assert payload['frequency_penalty'] == 0.5
assert payload['presence_penalty'] == 0.5
assert payload['seed'] == 42
assert payload['n'] == 2
assert payload['n'] == 1
assert payload['response_format'] == {'type': 'json_object'}


@pytest.mark.asyncio
async def test_construct_payload_rejects_multiple_candidates(
client, llm_request
):
llm_request.config = types.GenerateContentConfig(candidate_count=2)

with mock.patch.object(httpx.AsyncClient, 'post') as mock_post:
with pytest.raises(
ValueError, match='supports only one response candidate'
):
_ = [
response
async for response in client.generate_content_async(
llm_request, stream=False
)
]

mock_post.assert_not_called()


@pytest.mark.asyncio
async def test_construct_payload_with_tools(client, llm_request):
tool = types.Tool(
Expand Down
20 changes: 20 additions & 0 deletions tests/unittests/models/test_google_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,26 @@ async def mock_coro():
mock_client.aio.models.generate_content.assert_called_once()


@pytest.mark.asyncio
async def test_generate_content_async_rejects_multiple_candidates(
gemini_llm, llm_request
):
llm_request.config.candidate_count = 2

with mock.patch.object(gemini_llm, "api_client") as mock_client:
with pytest.raises(
ValueError, match="supports only one response candidate"
):
_ = [
response
async for response in gemini_llm.generate_content_async(
llm_request, stream=False
)
]

mock_client.aio.models.generate_content.assert_not_called()


@pytest.mark.asyncio
async def test_generate_content_async_stream(gemini_llm, llm_request):
with mock.patch.object(gemini_llm, "api_client") as mock_client:
Expand Down
37 changes: 37 additions & 0 deletions tests/unittests/models/test_litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2612,6 +2612,30 @@ def test_model_response_to_generate_content_response_reasoning_content():
assert response.content.parts[1].text == "Answer"


def test_model_response_to_generate_content_response_uses_first_choice():
"""Test LiteLLM conversion follows the single-candidate contract."""
model_response = ModelResponse(
model="test-model",
choices=[
{
"message": {"role": "assistant", "content": "First"},
"finish_reason": "stop",
},
{
"message": {"role": "assistant", "content": "Second"},
"finish_reason": "stop",
},
],
)

response = _model_response_to_generate_content_response(model_response)

assert len(model_response.choices) == 2
assert [
part.text for part in response.content.parts if part.text is not None
] == ["First"]


def test_message_to_generate_content_response_reasoning_field():
"""Test that the 'reasoning' field is supported (LM Studio, vLLM)."""
message = {
Expand Down Expand Up @@ -4779,6 +4803,19 @@ async def test_get_completion_inputs_generation_params():
assert "stop_sequences" not in generation_params


@pytest.mark.asyncio
async def test_get_completion_inputs_rejects_multiple_candidates():
req = LlmRequest(
contents=[
types.Content(role="user", parts=[types.Part.from_text(text="hi")]),
],
config=types.GenerateContentConfig(candidate_count=2),
)

with pytest.raises(ValueError, match="supports only one response candidate"):
await _get_completion_inputs(req, model="gpt-4o-mini")


@pytest.mark.asyncio
async def test_get_completion_inputs_empty_generation_params():
# Test that generation_params is None when no generation parameters are set
Expand Down
23 changes: 23 additions & 0 deletions tests/unittests/models/test_llm_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ def test_llm_response_create_without_logprobs():
assert response.content.parts[0].text == 'Response text'


def test_llm_response_create_uses_first_candidate():
"""Test LlmResponse.create() follows the single-candidate contract."""
generate_content_response = types.GenerateContentResponse(
candidates=[
types.Candidate(
content=types.Content(parts=[types.Part(text='First')]),
finish_reason=types.FinishReason.STOP,
),
types.Candidate(
content=types.Content(parts=[types.Part(text='Second')]),
finish_reason=types.FinishReason.STOP,
),
]
)

response = LlmResponse.create(generate_content_response)

assert len(generate_content_response.candidates) == 2
assert [
part.text for part in response.content.parts if part.text is not None
] == ['First']


def test_llm_response_create_error_case_with_logprobs():
"""Test LlmResponse.create() includes logprobs in error cases."""
avg_logprobs = -2.1
Expand Down