Files
wehub-resource-sync 555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

301 lines
13 KiB
Python

from unittest.mock import Mock, patch
import pytest
from google.genai import types
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.gemini import GeminiConfig
from mem0.llms.gemini import GeminiLLM
@pytest.fixture
def mock_gemini_client():
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
mock_client = Mock()
mock_client_class.return_value = mock_client
yield mock_client
def test_generate_response_without_tools(mock_gemini_client: Mock):
config = BaseLlmConfig(model="gemini-2.0-flash-latest", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"},
]
mock_part = Mock(text="I'm doing well, thank you for asking!")
mock_content = Mock(parts=[mock_part])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
mock_gemini_client.models.generate_content.return_value = mock_response
response = llm.generate_response(messages)
# Check the actual call - system instruction is now in config
mock_gemini_client.models.generate_content.assert_called_once()
call_args = mock_gemini_client.models.generate_content.call_args
# Verify model and contents
assert call_args.kwargs["model"] == "gemini-2.0-flash-latest"
assert len(call_args.kwargs["contents"]) == 1 # Only user message
# Verify config has system instruction
config_arg = call_args.kwargs["config"]
assert config_arg.system_instruction == "You are a helpful assistant."
assert config_arg.temperature == 0.7
assert config_arg.max_output_tokens == 100
assert config_arg.top_p == 1.0
assert response == "I'm doing well, thank you for asking!"
def test_generate_response_with_tools(mock_gemini_client: Mock):
config = BaseLlmConfig(model="gemini-1.5-flash-latest", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Add a new memory: Today is a sunny day."},
]
tools = [
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Add a memory",
"parameters": {
"type": "object",
"properties": {"data": {"type": "string", "description": "Data to add to memory"}},
"required": ["data"],
},
},
}
]
mock_tool_call = Mock()
mock_tool_call.name = "add_memory"
mock_tool_call.args = {"data": "Today is a sunny day."}
# Create mock parts with both text and function_call
mock_text_part = Mock()
mock_text_part.text = "I've added the memory for you."
mock_text_part.function_call = None
mock_func_part = Mock()
mock_func_part.text = None
mock_func_part.function_call = mock_tool_call
mock_content = Mock()
mock_content.parts = [mock_text_part, mock_func_part]
mock_candidate = Mock()
mock_candidate.content = mock_content
mock_response = Mock(candidates=[mock_candidate])
mock_gemini_client.models.generate_content.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
# Check the actual call
mock_gemini_client.models.generate_content.assert_called_once()
call_args = mock_gemini_client.models.generate_content.call_args
# Verify model and contents
assert call_args.kwargs["model"] == "gemini-1.5-flash-latest"
assert len(call_args.kwargs["contents"]) == 1 # Only user message
# Verify config has system instruction and tools
config_arg = call_args.kwargs["config"]
assert config_arg.system_instruction == "You are a helpful assistant."
assert config_arg.temperature == 0.7
assert config_arg.max_output_tokens == 100
assert config_arg.top_p == 1.0
assert len(config_arg.tools) == 1
assert config_arg.tool_config.function_calling_config.mode == types.FunctionCallingConfigMode.AUTO
assert response["content"] == "I've added the memory for you."
assert len(response["tool_calls"]) == 1
assert response["tool_calls"][0]["name"] == "add_memory"
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_parse_response_none_content_no_tools(mock_gemini_client: Mock):
"""Gemini can return content=None when response is blocked by safety filters."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_candidate = Mock(content=None)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_none_content_with_tools(mock_gemini_client: Mock):
"""Gemini can return content=None when response is blocked by safety filters (tools path)."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_candidate = Mock(content=None)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=[{"function": {"name": "test"}}])
assert result == {"content": None, "tool_calls": []}
def test_parse_response_empty_candidates(mock_gemini_client: Mock):
"""Gemini can return an empty candidates list."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_response = Mock(candidates=[])
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_none_candidates(mock_gemini_client: Mock):
"""Gemini can return candidates=None."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_response = Mock(candidates=None)
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_empty_parts_no_tools(mock_gemini_client: Mock):
"""Gemini can return content with an empty parts list."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_content = Mock(parts=[])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=None)
assert result == ""
def test_parse_response_empty_parts_with_tools(mock_gemini_client: Mock):
"""Gemini can return content with an empty parts list (tools path)."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GeminiLLM(config)
mock_content = Mock(parts=[])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
result = llm._parse_response(mock_response, tools=[{"function": {"name": "test"}}])
assert result == {"content": None, "tool_calls": []}
def test_none_config_values_omitted_from_generation_config(mock_gemini_client: Mock):
"""When temperature/max_tokens/top_p are None, they must not be passed
to GenerateContentConfig (verified via model_fields_set)."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=None, max_tokens=None, top_p=None)
llm = GeminiLLM(config)
mock_part = Mock(text="ok")
mock_content = Mock(parts=[mock_part])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
mock_gemini_client.models.generate_content.return_value = mock_response
llm.generate_response([{"role": "user", "content": "hi"}])
config_arg = mock_gemini_client.models.generate_content.call_args.kwargs["config"]
assert "temperature" not in config_arg.model_fields_set
assert "max_output_tokens" not in config_arg.model_fields_set
assert "top_p" not in config_arg.model_fields_set
def test_explicit_config_values_passed_to_generation_config(mock_gemini_client: Mock):
"""When temperature/max_tokens/top_p are explicitly set, they must appear."""
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.5, max_tokens=200, top_p=0.9)
llm = GeminiLLM(config)
mock_part = Mock(text="ok")
mock_content = Mock(parts=[mock_part])
mock_candidate = Mock(content=mock_content)
mock_response = Mock(candidates=[mock_candidate])
mock_gemini_client.models.generate_content.return_value = mock_response
llm.generate_response([{"role": "user", "content": "hi"}])
config_arg = mock_gemini_client.models.generate_content.call_args.kwargs["config"]
assert "temperature" in config_arg.model_fields_set
assert config_arg.temperature == 0.5
assert "max_output_tokens" in config_arg.model_fields_set
assert config_arg.max_output_tokens == 200
assert "top_p" in config_arg.model_fields_set
assert config_arg.top_p == 0.9
# --- Vertex AI backend initialization (issue #3990, PR #4030) ---
def test_init_default_path_uses_api_key(monkeypatch):
"""Default (non-Vertex) path stays backward compatible: client built with api_key, never vertexai."""
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM(GeminiConfig(model="gemini-2.0-flash"))
mock_client_class.assert_called_once_with(api_key="test-key")
def test_init_backward_compat_with_base_config(monkeypatch):
"""A legacy BaseLlmConfig still works and uses the API-key path (no missing-attr error)."""
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "legacy-key")
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM(BaseLlmConfig(model="gemini-2.0-flash"))
mock_client_class.assert_called_once_with(api_key="legacy-key")
def test_init_vertexai_via_explicit_config(monkeypatch):
"""vertexai=True in GeminiConfig routes to the Vertex AI client with project/location, no api_key."""
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM(GeminiConfig(vertexai=True, project="my-project", location="europe-west1"))
mock_client_class.assert_called_once_with(vertexai=True, project="my-project", location="europe-west1")
def test_init_vertexai_via_dict_config(monkeypatch):
"""The factory hands GeminiLLM a dict; the vertexai key still routes to the Vertex client."""
monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM({"vertexai": True, "project": "p", "location": "us-central1"})
mock_client_class.assert_called_once_with(vertexai=True, project="p", location="us-central1")
def test_init_vertexai_via_env_vars(monkeypatch):
"""GOOGLE_GENAI_USE_VERTEXAI + project/location env vars enable Vertex (the exact ask in issue #3990)."""
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "env-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "asia-south1")
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM(GeminiConfig())
mock_client_class.assert_called_once_with(vertexai=True, project="env-project", location="asia-south1")
def test_init_vertexai_location_defaults_to_us_central1(monkeypatch):
"""When Vertex is on but no location is supplied, it defaults to us-central1."""
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM(GeminiConfig(project="p"))
mock_client_class.assert_called_once_with(vertexai=True, project="p", location="us-central1")
def test_init_base_config_respects_vertexai_env(monkeypatch):
"""GOOGLE_GENAI_USE_VERTEXAI is the authoritative global switch (Google's own
convention): a legacy BaseLlmConfig is routed to Vertex when the env var is set,
even if it carries an api_key. This pins the precedence as intentional."""
monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "env-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-west1")
with patch("mem0.llms.gemini.genai.Client") as mock_client_class:
GeminiLLM(BaseLlmConfig(model="gemini-2.0-flash", api_key="ignored-key"))
mock_client_class.assert_called_once_with(vertexai=True, project="env-project", location="us-west1")