alishahryar1--free-claude-code
fe40548a30
## 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 -->
547 行
17 KiB
Python
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()
|