diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 25d283469a..4e5860420c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -101,6 +101,14 @@ from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_ logger = logging.getLogger(__name__) +def _safe_isinstance(obj: Any, maybe_type: Any) -> bool: + """Return False instead of raising when a patched symbol is not a type.""" + try: + return isinstance(obj, maybe_type) + except TypeError: + return False + + def _extract_url_query_params(url: str): """Extract query params from URL, return (clean_url, default_query dict or None).""" parsed = urlparse(url) @@ -861,20 +869,20 @@ def _maybe_wrap_anthropic( - The ``anthropic`` SDK is not installed (falls back to OpenAI wire). """ # Already wrapped — don't double-wrap. - if isinstance(client_obj, AnthropicAuxiliaryClient): + if _safe_isinstance(client_obj, AnthropicAuxiliaryClient): return client_obj # Other specialized adapters we should never re-dispatch. - if isinstance(client_obj, CodexAuxiliaryClient): + if _safe_isinstance(client_obj, CodexAuxiliaryClient): return client_obj try: from agent.gemini_native_adapter import GeminiNativeClient - if isinstance(client_obj, GeminiNativeClient): + if _safe_isinstance(client_obj, GeminiNativeClient): return client_obj except ImportError: pass try: from agent.copilot_acp_client import CopilotACPClient - if isinstance(client_obj, CopilotACPClient): + if _safe_isinstance(client_obj, CopilotACPClient): return client_obj except ImportError: pass diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 6206e325cf..c43611f85f 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -20,12 +20,7 @@ from agent.transports.types import NormalizedResponse, ToolCall, Usage def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None: - """Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig. - - Gemini native/cloud-code adapters do not read ``extra_body.reasoning``. - They only inspect ``extra_body.thinking_config`` / ``thinkingConfig`` and - then request thought parts with ``includeThoughts`` enabled. - """ + """Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig.""" if reasoning_config is None or not isinstance(reasoning_config, dict): return None @@ -71,6 +66,30 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> return thinking_config +def _snake_case_gemini_thinking_config(config: dict | None) -> dict | None: + """Convert Gemini thinking config keys to the OpenAI-compat field names.""" + if not isinstance(config, dict) or not config: + return None + + translated: Dict[str, Any] = {} + if isinstance(config.get("includeThoughts"), bool): + translated["include_thoughts"] = config["includeThoughts"] + if isinstance(config.get("thinkingLevel"), str) and config["thinkingLevel"].strip(): + translated["thinking_level"] = config["thinkingLevel"].strip().lower() + if isinstance(config.get("thinkingBudget"), (int, float)): + translated["thinking_budget"] = int(config["thinkingBudget"]) + return translated or None + + +def _is_gemini_openai_compat_base_url(base_url: Any) -> bool: + normalized = str(base_url or "").strip().rstrip("/").lower() + if not normalized: + return False + if "generativelanguage.googleapis.com" not in normalized: + return False + return normalized.endswith("/openai") + + class ChatCompletionsTransport(ProviderTransport): """Transport for api_mode='chat_completions'. @@ -309,6 +328,7 @@ class ChatCompletionsTransport(ProviderTransport): is_nous = params.get("is_nous", False) is_github_models = params.get("is_github_models", False) provider_name = str(params.get("provider_name") or "").strip().lower() + base_url = params.get("base_url") provider_prefs = params.get("provider_preferences") if provider_prefs and is_openrouter: @@ -362,7 +382,19 @@ class ChatCompletionsTransport(ProviderTransport): if is_qwen: extra_body["vl_high_resolution_images"] = True - if provider_name in {"gemini", "google-gemini-cli"}: + if provider_name == "gemini": + raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config) + if _is_gemini_openai_compat_base_url(base_url): + thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config) + if thinking_config: + openai_compat_extra = extra_body.get("extra_body", {}) + google_extra = openai_compat_extra.get("google", {}) + google_extra["thinking_config"] = thinking_config + openai_compat_extra["google"] = google_extra + extra_body["extra_body"] = openai_compat_extra + elif raw_thinking_config: + extra_body["thinking_config"] = raw_thinking_config + elif provider_name == "google-gemini-cli": thinking_config = _build_gemini_thinking_config(model, reasoning_config) if thinking_config: extra_body["thinking_config"] = thinking_config diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index e558fa3de7..bec7dc58a0 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -122,21 +122,25 @@ class TestChatCompletionsBuildKwargs: ) assert kw["extra_body"]["think"] is False - def test_gemini_without_explicit_reasoning_config_keeps_existing_behavior(self, transport): + def test_gemini_native_without_explicit_reasoning_config_keeps_existing_behavior(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( model="gemini-3-flash-preview", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta", ) assert "thinking_config" not in kw.get("extra_body", {}) + assert "google" not in kw.get("extra_body", {}) + assert "extra_body" not in kw.get("extra_body", {}) - def test_gemini_flash_reasoning_maps_to_thinking_config(self, transport): + def test_gemini_native_flash_reasoning_maps_to_top_level_thinking_config(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( model="gemini-3-flash-preview", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta", reasoning_config={"enabled": True, "effort": "high"}, ) assert kw["extra_body"]["thinking_config"] == { @@ -144,52 +148,85 @@ class TestChatCompletionsBuildKwargs: "thinkingLevel": "high", } - def test_gemini_25_reasoning_only_enables_visible_thoughts(self, transport): + def test_gemini_openai_compat_flash_reasoning_maps_to_nested_google_thinking_config(self, transport): + msgs = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="gemini-3-flash-preview", + messages=msgs, + provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta/openai", + reasoning_config={"enabled": True, "effort": "high"}, + ) + assert "thinking_config" not in kw["extra_body"] + assert kw["extra_body"]["extra_body"]["google"]["thinking_config"] == { + "include_thoughts": True, + "thinking_level": "high", + } + + def test_gemini_native_25_reasoning_only_enables_visible_thoughts(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( model="gemini-2.5-flash", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta", reasoning_config={"enabled": True, "effort": "high"}, ) assert kw["extra_body"]["thinking_config"] == { "includeThoughts": True, } - def test_gemini_pro_reasoning_clamps_to_supported_levels(self, transport): + def test_gemini_openai_compat_pro_reasoning_clamps_to_supported_levels(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( model="google/gemini-3.1-pro-preview", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta/openai", reasoning_config={"enabled": True, "effort": "medium"}, ) - assert kw["extra_body"]["thinking_config"] == { - "includeThoughts": True, - "thinkingLevel": "low", + assert kw["extra_body"]["extra_body"]["google"]["thinking_config"] == { + "include_thoughts": True, + "thinking_level": "low", } - def test_gemini_disabled_reasoning_hides_thoughts(self, transport): + def test_gemini_native_disabled_reasoning_hides_thoughts(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( model="gemini-3-flash-preview", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta", reasoning_config={"enabled": False}, ) assert kw["extra_body"]["thinking_config"] == { "includeThoughts": False, } - def test_gemini_xhigh_clamps_to_high(self, transport): + def test_gemini_openai_compat_xhigh_clamps_to_high(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( model="gemini-3-flash-preview", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta/openai", reasoning_config={"enabled": True, "effort": "xhigh"}, ) - assert kw["extra_body"]["thinking_config"]["thinkingLevel"] == "high" + assert kw["extra_body"]["extra_body"]["google"]["thinking_config"]["thinking_level"] == "high" + + def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport): + msgs = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="gemini-3-flash-preview", + messages=msgs, + provider_name="google-gemini-cli", + reasoning_config={"enabled": True, "effort": "high"}, + ) + assert kw["extra_body"]["thinking_config"] == { + "includeThoughts": True, + "thinkingLevel": "high", + } + assert "google" not in kw["extra_body"] def test_gemini_flash_minimal_clamps_to_low(self, transport): # Gemini 3 Flash documents low/medium/high; "minimal" isn't accepted, @@ -199,11 +236,12 @@ class TestChatCompletionsBuildKwargs: model="gemini-3-flash-preview", messages=msgs, provider_name="gemini", + base_url="https://generativelanguage.googleapis.com/v1beta/openai", reasoning_config={"enabled": True, "effort": "minimal"}, ) - assert kw["extra_body"]["thinking_config"] == { - "includeThoughts": True, - "thinkingLevel": "low", + assert kw["extra_body"]["extra_body"]["google"]["thinking_config"] == { + "include_thoughts": True, + "thinking_level": "low", } def test_max_tokens_with_fn(self, transport):