Files
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

392 lines
15 KiB
Python

import json
from unittest.mock import Mock, patch
from types import SimpleNamespace
import uuid
from application.llm.handlers.google import (
GoogleLLMHandler,
_decode_thought_signature,
_encode_thought_signature,
)
from application.llm.handlers.base import ToolCall, LLMResponse
class TestGoogleLLMHandler:
"""Test GoogleLLMHandler class."""
def test_handler_initialization(self):
"""Test handler initialization."""
handler = GoogleLLMHandler()
assert handler.llm_calls == []
assert handler.tool_calls == []
def test_parse_response_string_input(self):
"""Test parsing string response."""
handler = GoogleLLMHandler()
response = "Hello from Google!"
result = handler.parse_response(response)
assert isinstance(result, LLMResponse)
assert result.content == "Hello from Google!"
assert result.tool_calls == []
assert result.finish_reason == "stop"
assert result.raw_response == "Hello from Google!"
def test_parse_response_with_candidates_text_only(self):
"""Test parsing response with candidates containing only text."""
handler = GoogleLLMHandler()
mock_part = SimpleNamespace(text="Google response text")
mock_content = SimpleNamespace(parts=[mock_part])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == "Google response text"
assert result.tool_calls == []
assert result.finish_reason == "stop"
assert result.raw_response == mock_response
def test_parse_response_with_multiple_text_parts(self):
"""Test parsing response with multiple text parts."""
handler = GoogleLLMHandler()
mock_part1 = SimpleNamespace(text="First part")
mock_part2 = SimpleNamespace(text="Second part")
mock_content = SimpleNamespace(parts=[mock_part1, mock_part2])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == "First part Second part"
assert result.tool_calls == []
assert result.finish_reason == "stop"
@patch('uuid.uuid4')
def test_parse_response_with_function_call(self, mock_uuid):
"""Test parsing response with function call."""
mock_uuid.return_value = Mock(spec=uuid.UUID)
mock_uuid.return_value.__str__ = Mock(return_value="test-uuid-123")
handler = GoogleLLMHandler()
mock_function_call = SimpleNamespace(
name="get_weather",
args={"location": "San Francisco"}
)
mock_part = SimpleNamespace(function_call=mock_function_call)
mock_content = SimpleNamespace(parts=[mock_part])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == ""
assert len(result.tool_calls) == 1
assert result.tool_calls[0].id == "test-uuid-123"
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[0].arguments == {"location": "San Francisco"}
assert result.finish_reason == "tool_calls"
@patch('uuid.uuid4')
def test_parse_response_with_mixed_parts(self, mock_uuid):
"""Test parsing response with both text and function call parts."""
mock_uuid.return_value = Mock(spec=uuid.UUID)
mock_uuid.return_value.__str__ = Mock(return_value="test-uuid-456")
handler = GoogleLLMHandler()
mock_text_part = SimpleNamespace(text="I'll check the weather for you.")
mock_function_call = SimpleNamespace(
name="get_weather",
args={"location": "NYC"}
)
mock_function_part = SimpleNamespace(function_call=mock_function_call)
mock_content = SimpleNamespace(parts=[mock_text_part, mock_function_part])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == "I'll check the weather for you."
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_weather"
assert result.finish_reason == "tool_calls"
def test_parse_response_empty_candidates(self):
"""Test parsing response with empty candidates."""
handler = GoogleLLMHandler()
mock_response = SimpleNamespace(candidates=[])
result = handler.parse_response(mock_response)
assert result.content == ""
assert result.tool_calls == []
assert result.finish_reason == "stop"
def test_parse_response_parts_with_none_text(self):
"""Test parsing response with parts that have None text."""
handler = GoogleLLMHandler()
mock_part1 = SimpleNamespace(text=None)
mock_part2 = SimpleNamespace(text="Valid text")
mock_content = SimpleNamespace(parts=[mock_part1, mock_part2])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == "Valid text"
def test_parse_response_parts_without_text_attribute(self):
"""Test parsing response with parts missing text attribute."""
handler = GoogleLLMHandler()
mock_part1 = SimpleNamespace()
mock_part2 = SimpleNamespace(text="Valid text")
mock_content = SimpleNamespace(parts=[mock_part1, mock_part2])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == "Valid text"
@patch('uuid.uuid4')
def test_parse_response_direct_function_call(self, mock_uuid):
"""Test parsing response with direct function call (not in candidates)."""
mock_uuid.return_value = Mock(spec=uuid.UUID)
mock_uuid.return_value.__str__ = Mock(return_value="direct-uuid-789")
handler = GoogleLLMHandler()
mock_function_call = SimpleNamespace(
name="calculate",
args={"expression": "2+2"}
)
mock_response = SimpleNamespace(
function_call=mock_function_call,
text="The calculation result is:"
)
result = handler.parse_response(mock_response)
assert result.content == "The calculation result is:"
assert len(result.tool_calls) == 1
assert result.tool_calls[0].id == "direct-uuid-789"
assert result.tool_calls[0].name == "calculate"
assert result.tool_calls[0].arguments == {"expression": "2+2"}
assert result.finish_reason == "tool_calls"
def test_parse_response_direct_function_call_no_text(self):
"""Test parsing response with direct function call and no text."""
handler = GoogleLLMHandler()
mock_function_call = SimpleNamespace(
name="get_data",
args={"id": 123}
)
mock_response = SimpleNamespace(function_call=mock_function_call)
result = handler.parse_response(mock_response)
assert result.content == ""
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_data"
assert result.finish_reason == "tool_calls"
def test_create_tool_message(self):
"""Test creating tool message in standard format."""
handler = GoogleLLMHandler()
tool_call = ToolCall(
id="call_123",
name="get_weather",
arguments={"location": "Tokyo"},
index=0
)
result = {"temperature": "25C", "condition": "cloudy"}
message = handler.create_tool_message(tool_call, result)
assert message["role"] == "tool"
assert message["tool_call_id"] == "call_123"
import json
assert json.loads(message["content"]) == result
def test_create_tool_message_string_result(self):
"""Test creating tool message with string result."""
handler = GoogleLLMHandler()
tool_call = ToolCall(id="call_456", name="get_time", arguments={})
result = "2023-12-01 15:30:00 JST"
message = handler.create_tool_message(tool_call, result)
assert message["role"] == "tool"
assert message["tool_call_id"] == "call_456"
assert message["content"] == result
def test_create_tool_message_with_pg_native_types(self):
# PostgresTool returns dicts containing datetime / UUID / Decimal
# / bytes when the user runs a SELECT against timestamptz / uuid /
# numeric / bytea columns. The shared PGNativeJSONEncoder handles
# all five types lossless — bytes round-trip through base64.
import base64
from datetime import datetime, date, timezone
from decimal import Decimal
from uuid import UUID
handler = GoogleLLMHandler()
tool_call = ToolCall(id="call_pg", name="run_sql", arguments={})
result = {
"data": [
{
"id": UUID("12345678-1234-5678-1234-567812345678"),
"created_at": datetime(2026, 5, 2, 12, 14, 32, tzinfo=timezone.utc),
"scheduled_for": date(2026, 6, 1),
"amount": Decimal("123.45"),
"blob": b"\x00\x01\xff",
}
]
}
message = handler.create_tool_message(tool_call, result)
assert message["role"] == "tool"
decoded = json.loads(message["content"])
row = decoded["data"][0]
assert row["id"] == "12345678-1234-5678-1234-567812345678"
assert row["created_at"] == "2026-05-02T12:14:32+00:00"
assert row["scheduled_for"] == "2026-06-01"
assert row["amount"] == "123.45"
assert base64.b64decode(row["blob"]) == b"\x00\x01\xff"
def test_iterate_stream(self):
"""Test stream iteration."""
handler = GoogleLLMHandler()
mock_chunks = ["chunk1", "chunk2", "chunk3"]
result = list(handler._iterate_stream(mock_chunks))
assert result == mock_chunks
def test_iterate_stream_empty(self):
"""Test stream iteration with empty response."""
handler = GoogleLLMHandler()
result = list(handler._iterate_stream([]))
assert result == []
def test_iterate_stream_preserves_thought_events(self):
"""Test stream iteration preserves provider-emitted thought events."""
handler = GoogleLLMHandler()
mock_chunks = [
{"type": "thought", "thought": "first thought"},
"answer token",
]
result = list(handler._iterate_stream(mock_chunks))
assert result == [
{"type": "thought", "thought": "first thought"},
"answer token",
]
def test_parse_response_parts_without_function_call_attribute(self):
"""Test parsing response with parts missing function_call attribute."""
handler = GoogleLLMHandler()
mock_part = SimpleNamespace(text="Normal text")
mock_content = SimpleNamespace(parts=[mock_part])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
assert result.content == "Normal text"
assert result.tool_calls == []
assert result.finish_reason == "stop"
def test_parse_response_function_call_with_bytes_thought_signature_is_json_serializable(self):
"""Gemini 3 returns thought_signature as bytes; the parsed ToolCall
must remain json-serialisable so the SSE writer doesn't crash."""
handler = GoogleLLMHandler()
signature_bytes = b"\x00\x01gemini-binary-sig\xff"
mock_function_call = SimpleNamespace(
name="search_docs", args={"query": "workflows"}
)
mock_part = SimpleNamespace(
function_call=mock_function_call,
thought_signature=signature_bytes,
)
mock_content = SimpleNamespace(parts=[mock_part])
mock_candidate = SimpleNamespace(content=mock_content)
mock_response = SimpleNamespace(candidates=[mock_candidate])
result = handler.parse_response(mock_response)
sig = result.tool_calls[0].thought_signature
assert isinstance(sig, str)
json.dumps({"thought_signature": sig}) # would raise on bytes
# Round-trip must be lossless so we can replay the call to Gemini.
assert _decode_thought_signature(sig) == signature_bytes
def test_parse_response_direct_call_with_bytes_thought_signature(self):
"""Streaming path (direct Part, no candidates) — same invariant."""
handler = GoogleLLMHandler()
signature_bytes = b"\xde\xad\xbe\xef"
mock_response = SimpleNamespace(
function_call=SimpleNamespace(name="t", args={}),
thought_signature=signature_bytes,
)
result = handler.parse_response(mock_response)
sig = result.tool_calls[0].thought_signature
assert isinstance(sig, str)
assert _decode_thought_signature(sig) == signature_bytes
class TestThoughtSignatureRoundtrip:
"""Encoder / decoder helpers for Gemini's thought_signature field."""
def test_encode_bytes_to_str(self):
assert _encode_thought_signature(b"abc") == "YWJj"
def test_encode_str_passthrough(self):
assert _encode_thought_signature("YWJj") == "YWJj"
def test_encode_none(self):
assert _encode_thought_signature(None) is None
def test_decode_str_to_bytes(self):
assert _decode_thought_signature("YWJj") == b"abc"
def test_decode_bytes_passthrough(self):
assert _decode_thought_signature(b"abc") == b"abc"
def test_decode_none(self):
assert _decode_thought_signature(None) is None
def test_decode_invalid_base64_falls_back_to_input(self):
# If the value somehow isn't base64, we don't want to lose it —
# return the original string so the SDK gets *something* (and
# Gemini surfaces a clearer error than "decode failed in our code").
assert _decode_thought_signature("not!valid!b64!") == "not!valid!b64!"
def test_idempotent_encode_decode_roundtrip(self):
original = b"\x00\xff\x10\x20gemini\x00"
assert _decode_thought_signature(_encode_thought_signature(original)) == original