项目文件夹

文件
Ali Khokhar fe40548a30 Give Google reasoning controls one owner (#1211)
## Problem

Gemini requests with FCC reasoning could send both `reasoning_effort`
and `extra_body.google.thinking_config`, which [Google documents as
mutually
exclusive](https://ai.google.dev/gemini-api/docs/openai#thinking).
Google reasoning and thought-signature postprocessing had overlapping
request ownership. Fixes #1206.

## Changes

| Before | After |
| --- | --- |
| Shared Google quirks injected thought output independently of the
profile encoder. | One provider-selected Google encoder owns every
reasoning wire field. |
| Gemini could send named effort beside a custom thinking config. |
Gemini selects one channel, with exact budgets taking precedence over
named effort. |
| Vertex reasoning and thought-signature behavior shared one quirks
module. | Vertex retains its budget mapping while thought signatures
have a separate owner. |
| Caller-native Google controls could collide with FCC controls. |
Native controls are preserved only under provider-default reasoning;
controlled collisions fail preflight. |
| Regression coverage inspected isolated body fragments. | Policy
matrices, SDK-merge assertions, and an ownership contract enforce the
final wire shape. |
| The reported Gemini 3.5 Flash path failed upstream with HTTP 400. |
The same live path streams to a normal terminal stop with one reasoning
channel. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR gives each Google provider one owner for reasoning request
fields. The main changes are:

- Adds dedicated Gemini and Vertex reasoning encoders.
- Separates thought-signature replay from reasoning serialization.
- Validates caller-provided Google configuration before encoding.
- Adds request-policy and final wire-shape tests.
- Bumps the package version to 4.11.2.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

No blocking issues found in the changed code. The request pipeline keeps
thought signatures and reasoning fields separate. Tests cover the
supported reasoning policies and caller configuration conflicts.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- The focused validation suite was executed and completed with 78 passed
in 4.57s.
- Before the change, adaptive/high emitted two channels:
reasoning\_effort: high and thinking\_config.include\_thoughts: true.
- After the change, the same request emits exactly one channel:
reasoning\_effort: high, with thinking\_config: null.

<a
href="https://app.greptile.com/trex/runs/15056242/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/providers/google_openai/reasoning.py | Adds
exclusive Gemini and Vertex reasoning encoders and validates
caller-native thinking configuration. |
| src/free_claude_code/providers/google_openai/provider.py | Separates
message signature replay from profile-owned reasoning encoding. |
| src/free_claude_code/providers/google_openai/thought_signatures.py |
Narrows the former quirks module to tool-call thought-signature replay.
|
| src/free_claude_code/providers/gemini/client.py | Selects the Gemini
encoder and enables validated extra-body forwarding. |
| src/free_claude_code/providers/vertex/client.py | Selects the Vertex
encoder while retaining budget-based reasoning controls. |
| tests/providers/test_gemini.py | Covers channel exclusivity, budget
precedence, native configuration, conflicts, and SDK merging. |
| tests/providers/test_vertex.py | Covers Vertex policy mapping, native
configuration, conflicts, and final wire shape. |
| tests/contracts/test_import_boundaries.py | Enforces one source owner
for Google reasoning wire fields. |

</details>

<details open><summary><h3>Flowchart</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Messages request] --> B[Resolve reasoning policy]
A --> C[Validate and copy extra_body]
B --> D{Google provider profile}
C --> E[Build OpenAI request body]
E --> F[Replay thought signatures]
F --> D
D -->|Gemini| G[Gemini reasoning encoder]
D -->|Vertex| H[Vertex reasoning encoder]
G --> I[One Gemini reasoning channel]
H --> J[Google thinking configuration]
I --> K[Final SDK request]
J --> K
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Messages request] --> B[Resolve reasoning policy]
A --> C[Validate and copy extra_body]
B --> D{Google provider profile}
C --> E[Build OpenAI request body]
E --> F[Replay thought signatures]
F --> D
D -->|Gemini| G[Gemini reasoning encoder]
D -->|Vertex| H[Vertex reasoning encoder]
G --> I[One Gemini reasoning channel]
H --> J[Google thinking configuration]
I --> K[Final SDK request]
J --> K
```

</a>
</details>

<sub>Reviews (1): Last reviewed commit: ["Give Google reasoning controls
one
owner"](https://github.com/alishahryar1/free-claude-code/commit/cdfba8273878d5075ac8d6afb702e0f76224cba4)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45569811)</sub>

<!-- /greptile_comment -->
2026-07-20 00:26:00 -07:00

547 行
17 KiB
Python

"""Tests for Google AI Studio Gemini (OpenAI-compatible) provider."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from free_claude_code.application.errors import InvalidRequestError
from free_claude_code.config.provider_catalog import GEMINI_DEFAULT_BASE
from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy
from free_claude_code.providers.base import ProviderConfig
from free_claude_code.providers.gemini import GeminiProvider
from free_claude_code.providers.google_openai import (
GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR,
)
from tests.providers.request_factory import make_messages_request
from tests.providers.support import immediate_admission, reasoning_for
def make_request(**overrides):
return make_messages_request("models/gemini-3.1-flash-lite", **overrides)
def _simulate_openai_sdk_wire_json(body: dict) -> dict:
wire = {key: value for key, value in body.items() if key != "extra_body"}
sdk_extra = body.get("extra_body")
if isinstance(sdk_extra, dict):
wire.update(sdk_extra)
return wire
def _google_thinking_config(wire: dict) -> dict | None:
literal_extra_body = wire.get("extra_body")
if not isinstance(literal_extra_body, dict):
return None
google = literal_extra_body.get("google")
if not isinstance(google, dict):
return None
thinking_config = google.get("thinking_config")
return thinking_config if isinstance(thinking_config, dict) else None
@pytest.fixture
def gemini_config():
return ProviderConfig(
api_key="test_gemini_key",
base_url=GEMINI_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
)
@pytest.fixture
def gemini_provider(gemini_config):
return GeminiProvider(gemini_config, admission=immediate_admission())
def test_init(gemini_config):
"""Test provider initialization."""
with patch(
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
) as mock_openai:
provider = GeminiProvider(gemini_config, admission=immediate_admission())
assert provider._api_key == "test_gemini_key"
assert (
provider._base_url
== "https://generativelanguage.googleapis.com/v1beta/openai"
)
mock_openai.assert_called_once()
def test_default_base_url_constant():
assert GEMINI_DEFAULT_BASE == (
"https://generativelanguage.googleapis.com/v1beta/openai/"
)
def test_build_request_body_basic(gemini_provider):
"""Basic body conversion attaches Gemini thinking fields when thinking is on."""
req = make_request()
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
assert body["model"] == "models/gemini-3.1-flash-lite"
assert body["messages"][0]["role"] == "system"
assert "reasoning_effort" not in body
eb = body.get("extra_body")
assert isinstance(eb, dict)
literal_extra_body = eb.get("extra_body")
assert isinstance(literal_extra_body, dict)
gc = literal_extra_body.get("google")
assert isinstance(gc, dict)
tc = gc.get("thinking_config")
assert isinstance(tc, dict)
assert tc.get("include_thoughts") is True
assert "google" not in eb
def test_build_request_body_sdk_wire_json_has_literal_extra_body(gemini_provider):
"""Regression for issue #542: SDK merge must not send top-level google."""
req = make_request()
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
wire_json = _simulate_openai_sdk_wire_json(body)
assert "reasoning_effort" not in wire_json
assert "google" not in wire_json
literal_extra_body = wire_json.get("extra_body")
assert isinstance(literal_extra_body, dict)
google = literal_extra_body.get("google")
assert isinstance(google, dict)
thinking_config = google.get("thinking_config")
assert isinstance(thinking_config, dict)
assert thinking_config.get("include_thoughts") is True
def test_build_request_body_reasoning_off_sets_reasoning_none():
"""When thinking is off, Gemini uses reasoning_effort none (Gemini 2.5 convention)."""
provider = GeminiProvider(
ProviderConfig(
api_key="test_gemini_key",
base_url=GEMINI_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
),
admission=immediate_admission(),
)
req = make_request(thinking={"type": "disabled"})
body = provider._build_request_body(req, reasoning=reasoning_for(req))
assert body["reasoning_effort"] == "none"
roles = [m.get("role") for m in body.get("messages", [])]
assert "assistant_reasoning_content" not in roles
@pytest.mark.parametrize(
("reasoning", "expected_effort", "expected_thinking_config"),
[
(ReasoningPolicy.provider_default(), None, None),
(ReasoningPolicy.off(), "none", None),
(ReasoningPolicy.on(), None, {"include_thoughts": True}),
(
ReasoningPolicy.on(effort=ReasoningEffort.HIGH),
"high",
None,
),
(
ReasoningPolicy.on(budget_tokens=777),
None,
{"thinking_budget": 777, "include_thoughts": True},
),
(
ReasoningPolicy.on(
effort=ReasoningEffort.HIGH,
budget_tokens=777,
),
None,
{"thinking_budget": 777, "include_thoughts": True},
),
],
)
def test_gemini_reasoning_uses_exactly_one_wire_channel(
gemini_provider: GeminiProvider,
reasoning: ReasoningPolicy,
expected_effort: str | None,
expected_thinking_config: dict | None,
) -> None:
body = gemini_provider._build_request_body(
make_request(thinking=None),
reasoning=reasoning,
)
wire = _simulate_openai_sdk_wire_json(body)
assert wire.get("reasoning_effort") == expected_effort
assert _google_thinking_config(wire) == expected_thinking_config
assert not (
"reasoning_effort" in wire and _google_thinking_config(wire) is not None
)
def test_gemini_adaptive_thinking_with_effort_does_not_emit_custom_config(
gemini_provider: GeminiProvider,
) -> None:
request = make_messages_request(
"models/gemini-3.5-flash",
thinking={"type": "adaptive"},
output_config={"effort": "high"},
)
body = gemini_provider._build_request_body(
request,
reasoning=reasoning_for(request),
)
wire = _simulate_openai_sdk_wire_json(body)
assert wire["reasoning_effort"] == "high"
assert _google_thinking_config(wire) is None
def test_build_request_body_preserves_caller_extra_body(gemini_provider):
req = make_request(extra_body={"metadata": {"user": "u1"}})
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
assert "reasoning_effort" not in body
eb = body.get("extra_body")
assert isinstance(eb, dict)
assert eb.get("metadata") == {"user": "u1"}
literal_extra_body = eb.get("extra_body")
assert isinstance(literal_extra_body, dict)
google = literal_extra_body.get("google")
assert isinstance(google, dict)
def test_build_request_body_merges_caller_nested_google(gemini_provider):
req = make_request(
thinking=None,
extra_body={
"metadata": {"user": "u1"},
"extra_body": {
"google": {
"thinking_config": {
"thinking_level": "low",
"include_thoughts": False,
},
"cached_content": "cachedContents/example",
}
},
},
)
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
assert "reasoning_effort" not in body
eb = body.get("extra_body")
assert isinstance(eb, dict)
assert eb.get("metadata") == {"user": "u1"}
literal_extra_body = eb.get("extra_body")
assert isinstance(literal_extra_body, dict)
google = literal_extra_body.get("google")
assert isinstance(google, dict)
assert google.get("cached_content") == "cachedContents/example"
thinking_config = google.get("thinking_config")
assert isinstance(thinking_config, dict)
assert thinking_config == {
"thinking_level": "low",
"include_thoughts": False,
}
def test_gemini_rejects_caller_thinking_config_with_fcc_reasoning_control(
gemini_provider: GeminiProvider,
) -> None:
request = make_request(
thinking=None,
extra_body={
"extra_body": {"google": {"thinking_config": {"thinking_level": "low"}}}
},
)
with pytest.raises(InvalidRequestError, match="thinking_config"):
gemini_provider._build_request_body(
request,
reasoning=ReasoningPolicy.on(effort=ReasoningEffort.HIGH),
)
def test_gemini_rejects_malformed_google_extension_container(
gemini_provider: GeminiProvider,
) -> None:
request = make_request(
thinking=None,
extra_body={"extra_body": {"google": {"thinking_config": "low"}}},
)
with pytest.raises(InvalidRequestError, match="thinking_config must be an object"):
gemini_provider._build_request_body(
request,
reasoning=ReasoningPolicy.provider_default(),
)
def test_build_request_body_preserves_tool_call_extra_content(gemini_provider):
req = make_request(
system=None,
messages=[
{"role": "user", "content": "Find files"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "function-call-1",
"name": "Glob",
"input": {"pattern": "*.py"},
"extra_content": {
"google": {"thought_signature": "sig-from-client"}
},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "function-call-1",
"content": "[]",
},
],
},
],
)
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
tool_call = body["messages"][1]["tool_calls"][0]
assert tool_call["extra_content"] == {
"google": {"thought_signature": "sig-from-client"}
}
def test_build_request_body_uses_cached_tool_call_signature(gemini_provider):
gemini_provider._record_tool_call_extra_content(
"function-call-1", {"google": {"thought_signature": "sig-from-cache"}}
)
req = make_request(
system=None,
messages=[
{"role": "user", "content": "Find files"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "function-call-1",
"name": "Glob",
"input": {"pattern": "*.py"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "function-call-1",
"content": "[]",
},
],
},
],
)
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
tool_call = body["messages"][1]["tool_calls"][0]
assert tool_call["extra_content"] == {
"google": {"thought_signature": "sig-from-cache"}
}
def test_build_request_body_adds_current_turn_fallback_signature(
gemini_provider,
):
req = make_request(
system=None,
messages=[
{"role": "user", "content": "Find files"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "function-call-1",
"name": "Glob",
"input": {"pattern": "*.py"},
},
{
"type": "tool_use",
"id": "function-call-2",
"name": "Read",
"input": {"file_path": "a.py"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "function-call-1",
"content": "[]",
},
{
"type": "tool_result",
"tool_use_id": "function-call-2",
"content": "contents",
},
],
},
],
)
body = gemini_provider._build_request_body(req, reasoning=reasoning_for(req))
tool_calls = body["messages"][1]["tool_calls"]
assert tool_calls[0]["extra_content"] == {
"google": {"thought_signature": GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR}
}
assert "extra_content" not in tool_calls[1]
@pytest.mark.asyncio
async def test_stream_response_text(gemini_provider):
req = make_request(thinking={"type": "enabled"})
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content="Hello back!",
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
gemini_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [
event
async for event in gemini_provider.stream_response(
req, reasoning=reasoning_for(req)
)
]
assert any(
'"text_delta"' in event and "Hello back!" in event for event in events
)
kwargs = mock_create.call_args.kwargs
assert "reasoning_effort" not in kwargs
extra_body = kwargs.get("extra_body")
assert isinstance(extra_body, dict)
literal_extra_body = extra_body.get("extra_body")
assert isinstance(literal_extra_body, dict)
google = literal_extra_body.get("google")
assert isinstance(google, dict)
thinking_config = google.get("thinking_config")
assert isinstance(thinking_config, dict)
assert thinking_config.get("include_thoughts") is True
@pytest.mark.asyncio
async def test_stream_response_preserves_tool_call_extra_content(gemini_provider):
req = make_request()
mock_tc = MagicMock()
mock_tc.index = 0
mock_tc.id = "function-call-1"
mock_tc.extra_content = {"google": {"thought_signature": "sig-stream"}}
mock_tc.function = MagicMock()
mock_tc.function.name = "Glob"
mock_tc.function.arguments = '{"pattern":"*.py"}'
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=None,
reasoning_content=None,
tool_calls=[mock_tc],
),
finish_reason="tool_calls",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
gemini_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in gemini_provider.stream_response(req)]
tool_starts = [
event
for event in events
if '"content_block_start"' in event and '"tool_use"' in event
]
assert any(
'"extra_content"' in event and "sig-stream" in event for event in tool_starts
)
assert gemini_provider._tool_call_extra_content_by_id["function-call-1"] == {
"google": {"thought_signature": "sig-stream"}
}
@pytest.mark.asyncio
async def test_stream_response_reasoning_content(gemini_provider):
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=None,
reasoning_content="Thinking...",
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
gemini_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in gemini_provider.stream_response(req)]
assert any(
'"thinking_delta"' in event and "Thinking..." in event for event in events
)
@pytest.mark.asyncio
async def test_cleanup(gemini_provider):
gemini_provider._client = AsyncMock()
await gemini_provider.cleanup()
gemini_provider._client.close.assert_called_once()