Skip to content

Commit 231779e

Browse files
ainergizclaudealexzhang13
authored
add gemini client (#12)
* feat: add Gemini client using native google-genai SDK Adds support for Google Gemini models as a new backend option. - Create GeminiClient in rlm/clients/gemini.py using official google-genai SDK - Add "gemini" to ClientBackend type - Register GeminiClient in get_client() factory - Add google-genai>=1.56.0 dependency - Default model: gemini-2.5-flash Usage: ```python rlm = RLM( backend="gemini", backend_kwargs={"api_key": "...", "model_name": "gemini-2.5-flash"}, environment="local", ) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add load_dotenv() to gemini client for consistency Matches the pattern used in openai.py client. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add comprehensive unit tests for Gemini client - 12 unit tests with mocked API calls - 3 integration tests (skipped without GEMINI_API_KEY) - Tests cover initialization, usage tracking, message preparation, role mapping 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: az <alex.lx.zhang@gmail.com>
1 parent a9b0507 commit 231779e

6 files changed

Lines changed: 1643 additions & 1168 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ readme = "README.md"
99
requires-python = ">=3.11"
1010
dependencies = [
1111
"anthropic>=0.75.0",
12+
"google-genai>=1.56.0",
1213
"openai>=2.14.0",
1314
"portkey-ai>=2.1.0",
1415
"pytest>=9.0.2",

‎rlm/clients/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,15 @@ def get_client(
4444
from rlm.clients.anthropic import AnthropicClient
4545

4646
return AnthropicClient(**backend_kwargs)
47+
elif backend == "gemini":
48+
from rlm.clients.gemini import GeminiClient
49+
50+
return GeminiClient(**backend_kwargs)
4751
elif backend == "azure_openai":
4852
from rlm.clients.azure_openai import AzureOpenAIClient
4953

5054
return AzureOpenAIClient(**backend_kwargs)
5155
else:
5256
raise ValueError(
53-
f"Unknown backend: {backend}. Supported backends: ['openai', 'vllm', 'portkey', 'openrouter', 'litellm', 'anthropic', 'azure_openai']"
57+
f"Unknown backend: {backend}. Supported backends: ['openai', 'vllm', 'portkey', 'openrouter', 'litellm', 'anthropic', 'azure_openai', 'gemini']"
5458
)

‎rlm/clients/gemini.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import os
2+
from collections import defaultdict
3+
from typing import Any
4+
5+
from dotenv import load_dotenv
6+
from google import genai
7+
from google.genai import types
8+
9+
from rlm.clients.base_lm import BaseLM
10+
from rlm.core.types import ModelUsageSummary, UsageSummary
11+
12+
load_dotenv()
13+
14+
DEFAULT_GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
15+
16+
17+
class GeminiClient(BaseLM):
18+
"""
19+
LM Client for running models with the Google Gemini API.
20+
Uses the official google-genai SDK.
21+
"""
22+
23+
def __init__(
24+
self,
25+
api_key: str | None = None,
26+
model_name: str | None = "gemini-2.5-flash",
27+
**kwargs,
28+
):
29+
super().__init__(model_name=model_name, **kwargs)
30+
31+
if api_key is None:
32+
api_key = DEFAULT_GEMINI_API_KEY
33+
34+
if api_key is None:
35+
raise ValueError(
36+
"Gemini API key is required. Set GEMINI_API_KEY env var or pass api_key."
37+
)
38+
39+
self.client = genai.Client(api_key=api_key)
40+
self.model_name = model_name
41+
42+
# Per-model usage tracking
43+
self.model_call_counts: dict[str, int] = defaultdict(int)
44+
self.model_input_tokens: dict[str, int] = defaultdict(int)
45+
self.model_output_tokens: dict[str, int] = defaultdict(int)
46+
self.model_total_tokens: dict[str, int] = defaultdict(int)
47+
48+
# Last call tracking
49+
self.last_prompt_tokens = 0
50+
self.last_completion_tokens = 0
51+
52+
def completion(self, prompt: str | list[dict[str, Any]], model: str | None = None) -> str:
53+
contents, system_instruction = self._prepare_contents(prompt)
54+
55+
model = model or self.model_name
56+
if not model:
57+
raise ValueError("Model name is required for Gemini client.")
58+
59+
config = None
60+
if system_instruction:
61+
config = types.GenerateContentConfig(system_instruction=system_instruction)
62+
63+
response = self.client.models.generate_content(
64+
model=model,
65+
contents=contents,
66+
config=config,
67+
)
68+
69+
self._track_cost(response, model)
70+
return response.text
71+
72+
async def acompletion(
73+
self, prompt: str | list[dict[str, Any]], model: str | None = None
74+
) -> str:
75+
contents, system_instruction = self._prepare_contents(prompt)
76+
77+
model = model or self.model_name
78+
if not model:
79+
raise ValueError("Model name is required for Gemini client.")
80+
81+
config = None
82+
if system_instruction:
83+
config = types.GenerateContentConfig(system_instruction=system_instruction)
84+
85+
# google-genai SDK supports async via aio interface
86+
response = await self.client.aio.models.generate_content(
87+
model=model,
88+
contents=contents,
89+
config=config,
90+
)
91+
92+
self._track_cost(response, model)
93+
return response.text
94+
95+
def _prepare_contents(
96+
self, prompt: str | list[dict[str, Any]]
97+
) -> tuple[list[types.Content] | str, str | None]:
98+
"""Prepare contents and extract system instruction for Gemini API."""
99+
system_instruction = None
100+
101+
if isinstance(prompt, str):
102+
return prompt, None
103+
104+
if isinstance(prompt, list) and all(isinstance(item, dict) for item in prompt):
105+
# Convert OpenAI-style messages to Gemini format
106+
contents = []
107+
for msg in prompt:
108+
role = msg.get("role")
109+
content = msg.get("content", "")
110+
111+
if role == "system":
112+
# Gemini handles system instruction separately
113+
system_instruction = content
114+
elif role == "user":
115+
contents.append(types.Content(role="user", parts=[types.Part(text=content)]))
116+
elif role == "assistant":
117+
# Gemini uses "model" instead of "assistant"
118+
contents.append(types.Content(role="model", parts=[types.Part(text=content)]))
119+
else:
120+
# Default to user role for unknown roles
121+
contents.append(types.Content(role="user", parts=[types.Part(text=content)]))
122+
123+
return contents, system_instruction
124+
125+
raise ValueError(f"Invalid prompt type: {type(prompt)}")
126+
127+
def _track_cost(self, response: types.GenerateContentResponse, model: str):
128+
self.model_call_counts[model] += 1
129+
130+
# Extract token usage from response
131+
usage = response.usage_metadata
132+
if usage:
133+
input_tokens = usage.prompt_token_count or 0
134+
output_tokens = usage.candidates_token_count or 0
135+
136+
self.model_input_tokens[model] += input_tokens
137+
self.model_output_tokens[model] += output_tokens
138+
self.model_total_tokens[model] += input_tokens + output_tokens
139+
140+
# Track last call for handler to read
141+
self.last_prompt_tokens = input_tokens
142+
self.last_completion_tokens = output_tokens
143+
else:
144+
self.last_prompt_tokens = 0
145+
self.last_completion_tokens = 0
146+
147+
def get_usage_summary(self) -> UsageSummary:
148+
model_summaries = {}
149+
for model in self.model_call_counts:
150+
model_summaries[model] = ModelUsageSummary(
151+
total_calls=self.model_call_counts[model],
152+
total_input_tokens=self.model_input_tokens[model],
153+
total_output_tokens=self.model_output_tokens[model],
154+
)
155+
return UsageSummary(model_usage_summaries=model_summaries)
156+
157+
def get_last_usage(self) -> ModelUsageSummary:
158+
return ModelUsageSummary(
159+
total_calls=1,
160+
total_input_tokens=self.last_prompt_tokens,
161+
total_output_tokens=self.last_completion_tokens,
162+
)

‎rlm/core/types.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any, Literal
44

55
ClientBackend = Literal[
6-
"openai", "portkey", "openrouter", "vllm", "litellm", "anthropic", "azure_openai"
6+
"openai", "portkey", "openrouter", "vllm", "litellm", "anthropic", "azure_openai", "gemini"
77
]
88
EnvironmentType = Literal["local", "prime", "modal"]
99

‎tests/clients/test_gemini.py‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Tests for the Gemini client."""
2+
3+
import os
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
from dotenv import load_dotenv
8+
9+
from rlm.clients.gemini import GeminiClient
10+
from rlm.core.types import ModelUsageSummary, UsageSummary
11+
12+
load_dotenv()
13+
14+
15+
class TestGeminiClientUnit:
16+
"""Unit tests that don't require API calls."""
17+
18+
def test_init_with_api_key(self):
19+
"""Test client initialization with explicit API key."""
20+
with patch("rlm.clients.gemini.genai.Client"):
21+
client = GeminiClient(api_key="test-key", model_name="gemini-2.5-flash")
22+
assert client.model_name == "gemini-2.5-flash"
23+
24+
def test_init_default_model(self):
25+
"""Test client uses default model name."""
26+
with patch("rlm.clients.gemini.genai.Client"):
27+
client = GeminiClient(api_key="test-key")
28+
assert client.model_name == "gemini-2.5-flash"
29+
30+
def test_init_requires_api_key(self):
31+
"""Test client raises error when no API key provided."""
32+
with patch.dict(os.environ, {}, clear=True):
33+
with patch("rlm.clients.gemini.DEFAULT_GEMINI_API_KEY", None):
34+
with pytest.raises(ValueError, match="Gemini API key is required"):
35+
GeminiClient(api_key=None)
36+
37+
def test_usage_tracking_initialization(self):
38+
"""Test that usage tracking is properly initialized."""
39+
with patch("rlm.clients.gemini.genai.Client"):
40+
client = GeminiClient(api_key="test-key")
41+
assert client.model_call_counts == {}
42+
assert client.model_input_tokens == {}
43+
assert client.model_output_tokens == {}
44+
assert client.last_prompt_tokens == 0
45+
assert client.last_completion_tokens == 0
46+
47+
def test_get_usage_summary_empty(self):
48+
"""Test usage summary when no calls have been made."""
49+
with patch("rlm.clients.gemini.genai.Client"):
50+
client = GeminiClient(api_key="test-key")
51+
summary = client.get_usage_summary()
52+
assert isinstance(summary, UsageSummary)
53+
assert summary.model_usage_summaries == {}
54+
55+
def test_get_last_usage(self):
56+
"""Test last usage returns correct format."""
57+
with patch("rlm.clients.gemini.genai.Client"):
58+
client = GeminiClient(api_key="test-key")
59+
client.last_prompt_tokens = 100
60+
client.last_completion_tokens = 50
61+
usage = client.get_last_usage()
62+
assert isinstance(usage, ModelUsageSummary)
63+
assert usage.total_calls == 1
64+
assert usage.total_input_tokens == 100
65+
assert usage.total_output_tokens == 50
66+
67+
def test_prepare_contents_string(self):
68+
"""Test _prepare_contents with string input."""
69+
with patch("rlm.clients.gemini.genai.Client"):
70+
client = GeminiClient(api_key="test-key")
71+
contents, system = client._prepare_contents("Hello world")
72+
assert contents == "Hello world"
73+
assert system is None
74+
75+
def test_prepare_contents_messages_with_system(self):
76+
"""Test _prepare_contents extracts system message."""
77+
with patch("rlm.clients.gemini.genai.Client"):
78+
client = GeminiClient(api_key="test-key")
79+
messages = [
80+
{"role": "system", "content": "You are helpful"},
81+
{"role": "user", "content": "Hello"},
82+
]
83+
contents, system = client._prepare_contents(messages)
84+
assert system == "You are helpful"
85+
assert len(contents) == 1
86+
assert contents[0].role == "user"
87+
88+
def test_prepare_contents_role_mapping(self):
89+
"""Test _prepare_contents maps assistant to model."""
90+
with patch("rlm.clients.gemini.genai.Client"):
91+
client = GeminiClient(api_key="test-key")
92+
messages = [
93+
{"role": "user", "content": "Hello"},
94+
{"role": "assistant", "content": "Hi there"},
95+
{"role": "user", "content": "How are you?"},
96+
]
97+
contents, system = client._prepare_contents(messages)
98+
assert system is None
99+
assert len(contents) == 3
100+
assert contents[0].role == "user"
101+
assert contents[1].role == "model" # assistant -> model
102+
assert contents[2].role == "user"
103+
104+
def test_prepare_contents_invalid_type(self):
105+
"""Test _prepare_contents raises on invalid input."""
106+
with patch("rlm.clients.gemini.genai.Client"):
107+
client = GeminiClient(api_key="test-key")
108+
with pytest.raises(ValueError, match="Invalid prompt type"):
109+
client._prepare_contents(12345)
110+
111+
def test_completion_requires_model(self):
112+
"""Test completion raises when no model specified."""
113+
with patch("rlm.clients.gemini.genai.Client"):
114+
client = GeminiClient(api_key="test-key", model_name=None)
115+
with pytest.raises(ValueError, match="Model name is required"):
116+
client.completion("Hello")
117+
118+
def test_completion_with_mocked_response(self):
119+
"""Test completion with mocked API response."""
120+
mock_response = MagicMock()
121+
mock_response.text = "Hello from Gemini!"
122+
mock_response.usage_metadata.prompt_token_count = 10
123+
mock_response.usage_metadata.candidates_token_count = 5
124+
125+
with patch("rlm.clients.gemini.genai.Client") as mock_client_class:
126+
mock_client = MagicMock()
127+
mock_client.models.generate_content.return_value = mock_response
128+
mock_client_class.return_value = mock_client
129+
130+
client = GeminiClient(api_key="test-key", model_name="gemini-2.5-flash")
131+
result = client.completion("Hello")
132+
133+
assert result == "Hello from Gemini!"
134+
assert client.model_call_counts["gemini-2.5-flash"] == 1
135+
assert client.model_input_tokens["gemini-2.5-flash"] == 10
136+
assert client.model_output_tokens["gemini-2.5-flash"] == 5
137+
138+
139+
class TestGeminiClientIntegration:
140+
"""Integration tests that require a real API key."""
141+
142+
@pytest.mark.skipif(
143+
not os.environ.get("GEMINI_API_KEY"),
144+
reason="GEMINI_API_KEY not set",
145+
)
146+
def test_simple_completion(self):
147+
"""Test a simple completion with real API."""
148+
client = GeminiClient(model_name="gemini-2.5-flash")
149+
result = client.completion("What is 2+2? Reply with just the number.")
150+
assert "4" in result
151+
152+
# Verify usage was tracked
153+
usage = client.get_usage_summary()
154+
assert "gemini-2.5-flash" in usage.model_usage_summaries
155+
assert usage.model_usage_summaries["gemini-2.5-flash"].total_calls == 1
156+
157+
@pytest.mark.skipif(
158+
not os.environ.get("GEMINI_API_KEY"),
159+
reason="GEMINI_API_KEY not set",
160+
)
161+
def test_message_list_completion(self):
162+
"""Test completion with message list format."""
163+
client = GeminiClient(model_name="gemini-2.5-flash")
164+
messages = [
165+
{"role": "system", "content": "You are a helpful math tutor."},
166+
{"role": "user", "content": "What is 5 * 5? Reply with just the number."},
167+
]
168+
result = client.completion(messages)
169+
assert "25" in result
170+
171+
@pytest.mark.skipif(
172+
not os.environ.get("GEMINI_API_KEY"),
173+
reason="GEMINI_API_KEY not set",
174+
)
175+
async def test_async_completion(self):
176+
"""Test async completion."""
177+
client = GeminiClient(model_name="gemini-2.5-flash")
178+
result = await client.acompletion("What is 3+3? Reply with just the number.")
179+
assert "6" in result
180+
181+
182+
if __name__ == "__main__":
183+
# Run integration tests directly
184+
test = TestGeminiClientIntegration()
185+
print("Testing simple completion...")
186+
test.test_simple_completion()
187+
print("Testing message list completion...")
188+
test.test_message_list_completion()
189+
print("All integration tests passed!")

0 commit comments

Comments
 (0)