From ee9e9c1fe31b10bfc7ea5a9df41d3ab3e490a067 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sat, 4 Jul 2026 01:22:43 -0700 Subject: [PATCH] fix(providers): thread the session effort knob to Gemini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _GOOGLE_DEFAULT declared no reasoning_effort_values, so resolve_reasoning_effort returned None and the session effort knob was silently dropped for every Gemini model — the same bug class this branch fixed on the local lanes. Gemini's OpenAI-compat surface documents a flat reasoning_effort (2.5: thinking_budget mapping; 3.x: thinking_level), so declaring values lights up the inherited chat-completions path. Values are the safe cross-model set (minimal/low/medium/high): "none" is excluded because 2.5 Pro and the 3.x family reject disabling thinking — and the resolver never forwards the knob's none anyway (the param is omitted, server default applies). Off-list xhigh/max snap to the declared default high. Encoded from the official compatibility docs per the static-caps pattern; not live-verified. --- tests/test_providers.py | 33 +++++++++++++++++++++++++++++ turnstone/core/providers/_google.py | 13 ++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/test_providers.py b/tests/test_providers.py index 0b35eb68..b37cc1c3 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1802,6 +1802,39 @@ class TestProviderFactory: # =========================================================================== +class TestGoogleEffortKnob: + """The session effort knob reaches Gemini as a flat reasoning_effort.""" + + def _create_kwargs(self, reasoning_effort: str) -> dict[str, Any]: + from turnstone.core.providers._google import GoogleProvider + + prov = GoogleProvider() + client = MagicMock() + client.chat.completions.create.return_value = iter([]) + list( + prov.create_streaming( + client=client, + model="gemini-3-flash", + messages=[{"role": "user", "content": "hi"}], + reasoning_effort=reasoning_effort, + ) + ) + return client.chat.completions.create.call_args[1] + + def test_knob_values_forward_verbatim(self) -> None: + for knob in ("minimal", "low", "medium", "high"): + assert self._create_kwargs(knob)["reasoning_effort"] == knob + + def test_off_list_knob_snaps_to_high(self) -> None: + """xhigh/max are not in Gemini's vocabulary — snap down to high.""" + for knob in ("xhigh", "max"): + assert self._create_kwargs(knob)["reasoning_effort"] == "high" + + def test_none_omits_the_param(self) -> None: + """Knob none never sends "none" — 2.5 Pro / 3.x reject disabling.""" + assert "reasoning_effort" not in self._create_kwargs("none") + + class TestGoogleProviderFidelity: """Tests for thought_signature round-trip via provider_blocks.""" diff --git a/turnstone/core/providers/_google.py b/turnstone/core/providers/_google.py index 1ea819c5..2b30d739 100644 --- a/turnstone/core/providers/_google.py +++ b/turnstone/core/providers/_google.py @@ -43,6 +43,19 @@ _GOOGLE_DEFAULT = ModelCapabilities( # Gemini's OpenAI-compat endpoint accepts max_tokens (not # max_completion_tokens which is OpenAI Responses-specific). token_param="max_tokens", + # Gemini's OpenAI-compat endpoint accepts a flat ``reasoning_effort`` + # (2.5 family: thinking_budget 1024/1024/8192/24576 for minimal/low/ + # medium/high; 3.x family: thinking_level of the same name). The + # declared values are the safe set across ALL current Gemini models: + # "none" is excluded because 2.5 Pro and the 3.x family reject + # disabling thinking — and ``resolve_reasoning_effort`` never + # forwards the knob's "none" anyway (the param is omitted and the + # server default applies). Off-list knob values (xhigh, max) snap + # to the default "high". Encoded from + # ai.google.dev/gemini-api/docs/openai (2026-07); not live-verified — + # the static-caps pattern for commercial providers. + reasoning_effort_values=("minimal", "low", "medium", "high"), + default_reasoning_effort="high", )