Skip to content

fix(google): route Gemma 4 thinking through thinking_level - #7095

Merged
longcw merged 2 commits into
mainfrom
longc/gemma-4-thinking-level
Sep 2, 2026
Merged

fix(google): route Gemma 4 thinking through thinking_level#7095
longcw merged 2 commits into
mainfrom
longc/gemma-4-thinking-level

Conversation

@longcw

@longcw longcw commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Problem: _is_gemini_3_model() matches the substring gemini-3, so gemma-4-* models land in the thinking_budget branch and chat() raises ValueError on the first user turn when the caller sets thinking_level. The Gemini API rejects thinking_budget for Gemma 4, so no caller can control thinking on those models through the plugin.

Fix: _supports_thinking_level() sends Gemma 4 down the same level branch as Gemini 3, which now also keeps include_thoughts, and every model predicate matches on a substring so a qualified name such as models/gemma-4-31b-it cannot fall out of it. Gemma 4 accepts minimal and high only, so __init__ rejects the other two levels instead of letting the API answer the first turn with 400.

Replaces #7080, whose predicate split this keeps. Close #7071.

Context for reviewing and coding agents

How to see the failure

On main, both of these raise before a request goes out. The second one still raises on the first commit of this branch.

from google.genai import types
from livekit.plugins.google import LLM

cfg = types.ThinkingConfig(thinking_level=types.ThinkingLevel.MINIMAL)
LLM(model="gemma-4-31b-it", api_key="x", thinking_config=cfg).chat(chat_ctx=...)
LLM(model="models/gemma-4-31b-it", api_key="x", thinking_config=cfg).chat(chat_ctx=...)
# ValueError: ... does not support thinking_level

TestThinkingConfigRequestConstruction in tests/test_plugin_google_llm.py drives chat() against a stubbed generate_content_stream and reads the ThinkingConfig that reaches the request. Run it with uv run pytest tests/test_plugin_google_llm.py --plugin google. The module carries pytest.mark.plugin("google"), so --unit deselects all of it.

Which levels Gemma 4 accepts, and how that was measured

Google documents two: "Gemma 4 strictly supports toggling this feature on or off, you can control it using the API by setting the thinking level to "high" for enabled or "minimal" for disabled" — https://ai.google.dev/gemma/docs/core/gemma_on_gemini_api.

Real requests agree. gemma-4-31b-it and gemma-4-26b-a4b-it both answer on minimal and high, and both return 400 INVALID_ARGUMENT "Thinking level is not supported for this model." on low and medium. Each case ran twice against the Gemini API and gave the same result. gemini-3-flash-preview and gemini-3.5-flash answer on all four levels, so the old warning text that named only low and high was wrong and is gone.

GET /v1beta/models/gemma-4-31b-it reports only "thinking": true, so a client cannot discover the accepted levels at runtime.

Why the level check sits in __init__ rather than in chat()

A wrong level is a configuration error, and __init__ already unpacks the union and checks the budget type. A check there fails the session at startup. The earlier version of this branch passed the level through instead, on the argument that only the API knows what a model accepts; that argument does not survive the two measurements above, and it costs a voice session its first turn.

Coercing low to minimal and medium to high was rejected. It keeps the session alive but silently sends a different level than the caller asked for.

Blast radius: the other callers of the changed predicates

_is_gemini_3_model() now reads "gemini-3" in model.lower(), which drops a startswith clause the substring already covered. Its behaviour is unchanged, and its two other callers still exclude Gemma 4: the Gemini 3 API gate at llm.py:331, and thought signatures at llm.py:92, where _requires_thought_signatures("gemma-4-31b-it") is False.

_is_gemini_3_flash_model() changes behaviour. It matched on a prefix, so models/gemini-3-flash-preview was read as a non-flash model and defaulted to low instead of minimal at llm.py:415. It has no other caller.

Why the SDK fallbacks are gone

getattr(thinking_config, "thinking_level", None) and the raw dict passed as thinking_config both existed because the field was new. The plugin declares google-genai >= 2.13, and 2.13.0 already carries ThinkingConfig.thinking_level and types.ThinkingLevel, so the level branch now builds a types.ThinkingConfig like the budget branch does.

The guard compares _thinking_level.upper() against the enum members, not against string literals. ThinkingLevel values are upper case and the enum subclasses str, so ThinkingLevel.HIGH == "high" is False, and the dict form of thinking_config is never validated and keeps a raw lower-case string. types.ThinkingLevel("bogus") is not an alternative: it warns and returns a pseudo-member instead of raising.

What the include_thoughts change costs existing Gemini 3 users

Both branches on main replace the whole ThinkingConfig, so include_thoughts never reached the API. Callers who set include_thoughts=True on a Gemini 3 model got no thought parts and now will get them. The thinking_budget branch for Gemini 2.5 and earlier still drops the field.

The default-level rule

A default level is chosen only for Gemini 3: minimal for flash, low otherwise, unchanged from main. Gemma 4 sends no level when the caller gives none, and the API applies its own default.

What the measurements do not cover

Every real request went to the Gemini API. No request went to Vertex AI, so the publishers/google/models/... form is covered by a predicate test only. gemini-3.1-pro-preview returned 429 on all four levels, so no Gemini 3 pro model was measured.

_is_gemini_3_model() matches the substring "gemini-3", so gemma-4-*
models fell into the thinking_budget branch and chat() raised a
ValueError on the first turn for any caller that set thinking_level.
The Gemini API rejects thinking_budget for Gemma 4, so thinking could
not be controlled at all.

A new _supports_thinking_level() predicate routes Gemma 4 down the same
level branch as Gemini 3. _is_gemini_3_model() itself is unchanged, so
provider/function tool mixing and thought signatures still stay off for
Gemma. The level is sent as given, because only the API knows which
levels a model accepts, and a default level is only chosen for Gemini 3,
whose accepted levels are known.

The shared level branch now also keeps include_thoughts, which it
discarded before for Gemini 3 as well.
@longcw
longcw requested a review from a team as a code owner September 2, 2026 04:01
devin-ai-integration[bot]

This comment was marked as resolved.

_is_gemma_4_model and _is_gemini_3_flash_model matched on a prefix while
_is_gemini_3_model matched on a substring, so a qualified name split them.
models/gemma-4-31b-it fell past the level branch and raised ValueError
before any request, and models/gemini-3-flash-preview lost its minimal
default. Both now match on a substring.

Gemma 4 answers thinking_level low and medium with 400 INVALID_ARGUMENT;
it toggles thinking with minimal and high only. The level is checked in
__init__ against those two, so a wrong one fails at construction instead
of on the first turn.

google-genai 2.13, the declared floor, already carries
ThinkingConfig.thinking_level, so drop the getattr fallbacks and build the
config with types.ThinkingConfig like the budget branch does.

refs #7071
@longcw
longcw merged commit 9edba6d into main Sep 2, 2026
24 checks passed
@longcw
longcw deleted the longc/gemma-4-thinking-level branch September 2, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

google plugin: thinking_config gate misclassifies Gemma 4, making thinking uncontrollable

2 participants