diff --git a/pyproject.toml b/pyproject.toml index 985350fb5..ee23052fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,12 +39,12 @@ mistral = [ anthropic = [] gemini = [ - "google-genai>=1.51.0", + "google-genai>=1.70.0", "google-cloud-storage", ] vertexai = [ - "google-genai>=1.51.0", + "google-genai>=1.70.0", "google-cloud-storage", ] diff --git a/src/any_llm/providers/gemini/base.py b/src/any_llm/providers/gemini/base.py index 7d49f0892..8ec3c0be3 100644 --- a/src/any_llm/providers/gemini/base.py +++ b/src/any_llm/providers/gemini/base.py @@ -20,6 +20,7 @@ CreateEmbeddingResponse, Function, Reasoning, + ReasoningEffort, ) from any_llm.utils.structured_output import get_json_schema, is_structured_output_type @@ -52,7 +53,7 @@ from any_llm.types.model import Model REASONING_EFFORT_TO_THINKING_BUDGETS = { - "minimal": 256, + "minimal": 1024, "low": 1024, "medium": 8192, "high": 24576, @@ -68,17 +69,118 @@ "max": types.ThinkingLevel.HIGH, } _SUPPORTED_BATCH_ENDPOINTS = frozenset({"/v1/chat/completions"}) -_THINKING_LEVEL_MIN_GEMINI_VERSION = (3, 5) +_ALL_THINKING_LEVELS = frozenset(REASONING_EFFORT_TO_THINKING_LEVELS.values()) +# Known model capabilities differ within the Gemini 3 family, so these exceptions refine rather than replace +# the permissive version routing used for custom, dated, and newly released model IDs. +# Source: https://ai.google.dev/gemini-api/docs/generate-content/thinking#thinking-levels +_THINKING_LEVELS_BY_MODEL = { + "gemini-3.8-flash": frozenset({types.ThinkingLevel.LOW, types.ThinkingLevel.MEDIUM, types.ThinkingLevel.HIGH}), + "gemini-3.7-flash": frozenset({types.ThinkingLevel.LOW, types.ThinkingLevel.MEDIUM, types.ThinkingLevel.HIGH}), + "gemini-3.6-flash": _ALL_THINKING_LEVELS, + "gemini-3.5-flash": _ALL_THINKING_LEVELS, + "gemini-3.5-flash-lite": _ALL_THINKING_LEVELS, + "gemini-3.1-flash-lite": _ALL_THINKING_LEVELS, + "gemini-3.1-pro-preview": frozenset( + {types.ThinkingLevel.LOW, types.ThinkingLevel.MEDIUM, types.ThinkingLevel.HIGH} + ), + "gemini-3.1-flash-image": frozenset({types.ThinkingLevel.MINIMAL, types.ThinkingLevel.HIGH}), + "gemini-3.1-flash-lite-image": frozenset({types.ThinkingLevel.MINIMAL, types.ThinkingLevel.HIGH}), + "gemini-3-flash-preview": _ALL_THINKING_LEVELS, +} +_MAX_THINKING_BUDGET_BY_MODEL = { + "gemini-2.5-pro": 32768, + "gemini-2.5-flash": 24576, + "gemini-2.5-flash-lite": 24576, +} _GEMINI_VERSION_PATTERN = re.compile(r"(?:^|/)gemini-(\d+)(?:\.(\d+))?") def _uses_thinking_level(model_id: str) -> bool: - """Gemini 3.5 and newer reject `thinking_budget` and expect `thinking_level` instead.""" + """Route Gemini 3 and newer to `thinking_level`, including unlisted model IDs.""" match = _GEMINI_VERSION_PATTERN.search(model_id.lower()) if match is None: return False - major, minor = int(match.group(1)), int(match.group(2) or 0) - return (major, minor) >= _THINKING_LEVEL_MIN_GEMINI_VERSION + return int(match.group(1)) >= 3 + + +def _matches_known_model(model_name: str, known_model: str) -> bool: + version_suffix = model_name.removeprefix(known_model) + return model_name == known_model or ( + model_name.startswith(known_model) and re.fullmatch(r"(?:-\d+)+", version_suffix) is not None + ) + + +def _known_thinking_levels(model_name: str) -> frozenset[types.ThinkingLevel] | None: + for known_model, supported_levels in _THINKING_LEVELS_BY_MODEL.items(): + if _matches_known_model(model_name, known_model): + return supported_levels + return None + + +def _known_max_thinking_budget(model_name: str) -> int | None: + for known_model, max_budget in _MAX_THINKING_BUDGET_BY_MODEL.items(): + if _matches_known_model(model_name, known_model): + return max_budget + return None + + +def _convert_reasoning_effort( + model_id: str, + reasoning_effort: ReasoningEffort | None, + provider_name: str, +) -> types.ThinkingConfig | None: + if reasoning_effort is None or reasoning_effort == "auto": + return None + + parameter_name = "reasoning_effort" + model_name = model_id.rsplit("/", maxsplit=1)[-1].lower() + supported_levels = _known_thinking_levels(model_name) + additional_message = f"'{reasoning_effort}' is not available for model '{model_id}'." + if reasoning_effort == "none": + if _uses_thinking_level(model_id) or _matches_known_model(model_name, "gemini-2.5-pro"): + raise UnsupportedParameterError(parameter_name, provider_name, additional_message) + return types.ThinkingConfig(thinking_budget=0) + + if supported_levels is not None or _uses_thinking_level(model_id): + thinking_level = REASONING_EFFORT_TO_THINKING_LEVELS.get(reasoning_effort) + # Google's OpenAI compatibility contract maps `minimal` to `low` for Gemini 3.1 Pro. + # Source: https://ai.google.dev/gemini-api/docs/openai#thinking + if _matches_known_model(model_name, "gemini-3.1-pro-preview") and reasoning_effort == "minimal": + thinking_level = types.ThinkingLevel.LOW + if thinking_level is None or (supported_levels is not None and thinking_level not in supported_levels): + raise UnsupportedParameterError(parameter_name, provider_name, additional_message) + return types.ThinkingConfig(include_thoughts=True, thinking_level=thinking_level) + + thinking_budget = REASONING_EFFORT_TO_THINKING_BUDGETS.get(reasoning_effort) + if thinking_budget is None: + raise UnsupportedParameterError(parameter_name, provider_name, additional_message) + max_budget = _known_max_thinking_budget(model_name) + if max_budget is not None: + thinking_budget = min(thinking_budget, max_budget) + return types.ThinkingConfig(include_thoughts=True, thinking_budget=thinking_budget) + + +def _convert_response_format(response_format: dict[str, Any] | type | None) -> dict[str, Any]: + if is_structured_output_type(response_format): + schema = get_json_schema(response_format) + schema_key = "response_json_schema" if _has_additional_properties(schema) else "response_schema" + return {"response_mime_type": "application/json", schema_key: schema} + if not isinstance(response_format, dict): + return {} + + response_type = response_format.get("type") + if response_type == "json_schema": + return { + "response_mime_type": "application/json", + "response_json_schema": response_format["json_schema"]["schema"], + } + if response_type == "json_object": + return {"response_mime_type": "application/json"} + if response_type in (None, "text"): + return {} + + msg = f"Unsupported response_format type: {response_type}" + raise ValueError(msg) class GoogleProvider(AnyLLM): @@ -140,61 +242,36 @@ def _convert_completion_params(params: CompletionParams, **kwargs: Any) -> dict[ error_message = "parallel_tool_calls" raise UnsupportedParameterError(error_message, provider_name) - if params.frequency_penalty is not None: - kwargs["frequency_penalty"] = params.frequency_penalty - if params.max_tokens is not None: - kwargs["max_output_tokens"] = params.max_tokens - if params.presence_penalty is not None: - kwargs["presence_penalty"] = params.presence_penalty - if params.reasoning_effort != "auto": - if params.reasoning_effort is None or params.reasoning_effort == "none": - kwargs["thinking_config"] = types.ThinkingConfig(include_thoughts=False) - elif _uses_thinking_level(params.model_id): - kwargs["thinking_config"] = types.ThinkingConfig( - include_thoughts=True, thinking_level=REASONING_EFFORT_TO_THINKING_LEVELS[params.reasoning_effort] - ) - else: - kwargs["thinking_config"] = types.ThinkingConfig( - include_thoughts=True, thinking_budget=REASONING_EFFORT_TO_THINKING_BUDGETS[params.reasoning_effort] + kwargs.update( + { + option_name: option_value + for option_name, option_value in ( + ("frequency_penalty", params.frequency_penalty), + ("max_output_tokens", params.max_tokens), + ("presence_penalty", params.presence_penalty), + ("seed", params.seed), + ("service_tier", params.service_tier), + ("temperature", params.temperature), + ("top_p", params.top_p), ) - if params.seed is not None: - kwargs["seed"] = params.seed - if params.service_tier is not None: - kwargs["service_tier"] = params.service_tier - if params.temperature is not None: - kwargs["temperature"] = params.temperature + if option_value is not None + } + ) + + thinking_config = _convert_reasoning_effort(params.model_id, params.reasoning_effort, provider_name) + if thinking_config is not None: + kwargs["thinking_config"] = thinking_config if params.tools is not None: kwargs["tools"] = _convert_tool_spec(params.tools, provider_name) if params.tool_choice is not None: kwargs["tool_config"] = _convert_tool_choice(params.tool_choice, provider_name) - if params.top_p is not None: - kwargs["top_p"] = params.top_p if params.stop is not None: if isinstance(params.stop, str): kwargs["stop_sequences"] = [params.stop] else: kwargs["stop_sequences"] = params.stop - response_format = params.response_format - if is_structured_output_type(response_format): - kwargs["response_mime_type"] = "application/json" - schema = get_json_schema(response_format) - if _has_additional_properties(schema): - kwargs["response_json_schema"] = schema - else: - kwargs["response_schema"] = schema - elif isinstance(response_format, dict): - response_type = response_format.get("type") - if response_type == "json_schema": - kwargs["response_mime_type"] = "application/json" - kwargs["response_json_schema"] = response_format["json_schema"]["schema"] - elif response_type == "json_object": - kwargs["response_mime_type"] = "application/json" - elif response_type == "text": - pass - else: - msg = f"Unsupported response_format type: {response_type}" - raise ValueError(msg) + kwargs.update(_convert_response_format(params.response_format)) formatted_messages, system_instruction = _convert_messages(params.messages, provider_name=provider_name) if system_instruction: diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 58be7d63a..6a62daa80 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -2,9 +2,10 @@ import json from collections.abc import AsyncIterator from contextlib import contextmanager -from typing import Any, get_args +from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch +import httpx import pytest from google.genai import types from pydantic import BaseModel, ConfigDict @@ -16,7 +17,7 @@ UnsupportedParameterError, ) from any_llm.providers.gemini import GeminiProvider -from any_llm.providers.gemini.base import REASONING_EFFORT_TO_THINKING_BUDGETS, GoogleProvider +from any_llm.providers.gemini.base import GoogleProvider, _convert_reasoning_effort from any_llm.providers.gemini.utils import ( _convert_messages, _convert_response_to_response_dict, @@ -816,45 +817,47 @@ async def test_completion_inside_agent_loop(agent_loop_messages: list[dict[str, assert contents[2].role == "function" -@pytest.mark.parametrize("reasoning_effort", [None, *get_args(ReasoningEffort)]) -@pytest.mark.asyncio -async def test_completion_with_custom_reasoning_effort(reasoning_effort: ReasoningEffort | None) -> None: - api_key = "test-api-key" - model = "model-id" - messages = [{"role": "user", "content": "Hello"}] - - with mock_gemini_provider() as mock_genai: - provider = GeminiProvider(api_key=api_key) - await provider._acompletion( - CompletionParams(model_id=model, messages=messages, reasoning_effort=reasoning_effort) - ) - - _, call_kwargs = mock_genai.return_value.aio.models.generate_content.call_args - thinking_config = call_kwargs["config"].thinking_config - - if reasoning_effort == "auto": - assert thinking_config is None - elif reasoning_effort is None or reasoning_effort == "none": - assert thinking_config == types.ThinkingConfig(include_thoughts=False) - else: - assert thinking_config == types.ThinkingConfig( - include_thoughts=True, thinking_budget=REASONING_EFFORT_TO_THINKING_BUDGETS[reasoning_effort] - ) - - @pytest.mark.parametrize( - ("model_id", "reasoning_effort", "expected_level"), + ("model_id", "reasoning_effort", "expected"), [ - ("gemini-3.5-flash", "xhigh", types.ThinkingLevel.HIGH), - ("gemini-3.5-flash", "max", types.ThinkingLevel.HIGH), - ("gemini-3.5-pro", "low", types.ThinkingLevel.LOW), - ("models/gemini-3.5-flash", "medium", types.ThinkingLevel.MEDIUM), - ("gemini-3.10-flash", "minimal", types.ThinkingLevel.MINIMAL), - ("gemini-4-pro", "high", types.ThinkingLevel.HIGH), + ("gemini-3.8-flash", "low", {"includeThoughts": True, "thinkingLevel": "LOW"}), + ("gemini-3.7-flash", "medium", {"includeThoughts": True, "thinkingLevel": "MEDIUM"}), + ("gemini-3.6-flash", "minimal", {"includeThoughts": True, "thinkingLevel": "MINIMAL"}), + ("gemini-3.5-flash", "high", {"includeThoughts": True, "thinkingLevel": "HIGH"}), + ("gemini-3.5-flash-lite", "minimal", {"includeThoughts": True, "thinkingLevel": "MINIMAL"}), + ("gemini-3.1-flash-lite", "medium", {"includeThoughts": True, "thinkingLevel": "MEDIUM"}), + ("models/gemini-3.1-pro-preview", "minimal", {"includeThoughts": True, "thinkingLevel": "LOW"}), + ("gemini-3.1-flash-image", "minimal", {"includeThoughts": True, "thinkingLevel": "MINIMAL"}), + ("gemini-3.1-flash-lite-image", "high", {"includeThoughts": True, "thinkingLevel": "HIGH"}), + ("gemini-3-flash-preview", "minimal", {"includeThoughts": True, "thinkingLevel": "MINIMAL"}), + ("gemini-2.5-flash", "none", {"thinkingBudget": 0}), + ("gemini-2.5-flash", "minimal", {"includeThoughts": True, "thinkingBudget": 1024}), + ("gemini-2.5-flash", "low", {"includeThoughts": True, "thinkingBudget": 1024}), + ("gemini-2.5-flash-lite", "none", {"thinkingBudget": 0}), + ("gemini-2.5-flash-lite", "minimal", {"includeThoughts": True, "thinkingBudget": 1024}), + ("gemini-2.5-flash-lite", "medium", {"includeThoughts": True, "thinkingBudget": 8192}), + ("gemini-2.5-pro", "minimal", {"includeThoughts": True, "thinkingBudget": 1024}), + ("gemini-2.5-pro", "high", {"includeThoughts": True, "thinkingBudget": 24576}), + ("gemini-2.5-pro", "xhigh", {"includeThoughts": True, "thinkingBudget": 32768}), + ("gemini-2.5-flash", "max", {"includeThoughts": True, "thinkingBudget": 24576}), + ("gemini-2.5-flash-lite", "xhigh", {"includeThoughts": True, "thinkingBudget": 24576}), + ("gemini-3.8-flash", "xhigh", {"includeThoughts": True, "thinkingLevel": "HIGH"}), + ("-001", "minimal", {"includeThoughts": True, "thinkingBudget": 1024}), + ("custom-gemini-model", "minimal", {"includeThoughts": True, "thinkingBudget": 1024}), + ("gemini-3.1-custom", "high", {"includeThoughts": True, "thinkingLevel": "HIGH"}), + ("models/gemini-3.10-flash-preview-202609", "max", {"includeThoughts": True, "thinkingLevel": "HIGH"}), + ("gemini-3.8-flash-custom", "minimal", {"includeThoughts": True, "thinkingLevel": "MINIMAL"}), + ( + "projects/p/locations/l/publishers/google/models/gemini-3.8-flash-001", + "low", + {"includeThoughts": True, "thinkingLevel": "LOW"}, + ), ], ) -def test_new_gemini_models_use_thinking_level( - model_id: str, reasoning_effort: ReasoningEffort, expected_level: types.ThinkingLevel +def test_gemini_reasoning_effort_matches_documented_thinking_config( + model_id: str, + reasoning_effort: ReasoningEffort, + expected: dict[str, object], ) -> None: result = GoogleProvider._convert_completion_params( CompletionParams( @@ -863,29 +866,119 @@ def test_new_gemini_models_use_thinking_level( provider_name="gemini", ) - assert result["config"].thinking_config == types.ThinkingConfig( - include_thoughts=True, thinking_level=expected_level - ) + config = result["config"].model_dump(by_alias=True, exclude_none=True) + assert config["thinkingConfig"] == expected @pytest.mark.parametrize( - "model_id", + ("model_id", "reasoning_effort"), [ - "gemini-3.0-flash", - "gemini-3.4-flash", - "gemini-3-pro-preview", - "gemini-2.5-flash", - "gemini-pro", - "projects/p/locations/l/publishers/google/models/gemini-3-pro", + ("gemini-3.8-flash", "minimal"), + ("gemini-3.1-flash-image", "low"), + ("gemini-3.1-flash-lite-image", "low"), + ("gemini-3.8-flash-001", "minimal"), + ("gemini-3.8-flash", "none"), + ("gemini-3.1-flash-image", "none"), + ("gemini-2.5-pro", "none"), ], ) -def test_older_gemini_models_keep_thinking_budget(model_id: str) -> None: +def test_gemini_rejects_undocumented_reasoning_effort( + model_id: str, + reasoning_effort: ReasoningEffort, +) -> None: + with pytest.raises(UnsupportedParameterError) as exc_info: + GoogleProvider._convert_completion_params( + CompletionParams( + model_id=model_id, + messages=[{"role": "user", "content": "Hello"}], + reasoning_effort=reasoning_effort, + ), + provider_name="gemini", + ) + + assert str(exc_info.value) == ( + "[gemini] 'reasoning_effort' is not supported for gemini.\n" + f"'{reasoning_effort}' is not available for model '{model_id}'." + ) + + +def test_gemini_invalid_reasoning_effort_error_identifies_model_and_effort() -> None: + reasoning_effort = cast("ReasoningEffort", "invalid") + + with pytest.raises(UnsupportedParameterError) as exc_info: + _convert_reasoning_effort("custom-gemini-model", reasoning_effort, "gemini") + + assert str(exc_info.value) == ( + "[gemini] 'reasoning_effort' is not supported for gemini.\n" + "'invalid' is not available for model 'custom-gemini-model'." + ) + + +@pytest.mark.parametrize( + ("reasoning_effort", "expected"), + [(None, None), ("auto", None)], +) +def test_gemini_preserves_default_thinking_config_wire_behavior( + reasoning_effort: ReasoningEffort | None, expected: dict[str, object] | None +) -> None: result = GoogleProvider._convert_completion_params( - CompletionParams(model_id=model_id, messages=[{"role": "user", "content": "Hello"}], reasoning_effort="high"), + CompletionParams( + model_id="gemini-3.8-flash", + messages=[{"role": "user", "content": "Hello"}], + reasoning_effort=reasoning_effort, + ), provider_name="gemini", ) - assert result["config"].thinking_config == types.ThinkingConfig(include_thoughts=True, thinking_budget=24576) + config = result["config"].model_dump(by_alias=True, exclude_none=True) + assert config.get("thinkingConfig") == expected + + +@pytest.mark.parametrize( + ("model_id", "reasoning_effort", "expected"), + [ + ("gemini-3.8-flash", "high", {"include_thoughts": True, "thinking_level": "HIGH"}), + ("gemini-2.5-flash", "none", {"thinking_budget": 0}), + ], +) +@pytest.mark.asyncio +async def test_gemini_reasoning_effort_reaches_official_sdk_wire( + model_id: str, + reasoning_effort: ReasoningEffort, + expected: dict[str, object], +) -> None: + requests: list[dict[str, object]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "candidates": [ + { + "content": {"parts": [{"text": "ok"}], "role": "model"}, + "finishReason": "STOP", + } + ] + }, + ) + + provider = GeminiProvider( + api_key="test-api-key", + http_options=types.HttpOptions( + async_client_args={"transport": httpx.MockTransport(handler)}, + ), + ) + await provider._acompletion( + CompletionParams( + model_id=model_id, + messages=[{"role": "user", "content": "Hello"}], + reasoning_effort=reasoning_effort, + ) + ) + await provider.client.aio.aclose() + + assert requests[0]["generationConfig"] == {"thinkingConfig": expected} @pytest.mark.asyncio