Skip to content

Commit d15cdb3

Browse files
bleddenclaude
andcommitted
fix(gemini): convert OpenAI messages to Gemini contents in count_tokens
When count_tokens() is called with messages (OpenAI format) but no contents (Gemini format) — e.g. via the /v1/messages/count_tokens endpoint — the contents parameter was passed as None to the Gemini countTokens API, resulting in a 400 error. Added _convert_messages_to_gemini_contents() to handle the role mapping (assistant -> model, system -> user) and wrap content in the {"parts": [{"text": ...}]} structure the API expects. Fixes #21748 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6134984 commit d15cdb3

2 files changed

Lines changed: 211 additions & 4 deletions

File tree

litellm/llms/gemini/common_utils.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,43 @@ def get_api_key_from_env() -> Optional[str]:
153153
class GoogleAIStudioTokenCounter(BaseTokenCounter):
154154
"""Token counter implementation for Google AI Studio provider."""
155155
def should_use_token_counting_api(
156-
self,
156+
self,
157157
custom_llm_provider: Optional[str] = None,
158158
) -> bool:
159159
from litellm.types.utils import LlmProviders
160160
return custom_llm_provider == LlmProviders.GEMINI.value
161-
161+
162+
@staticmethod
163+
def _convert_messages_to_gemini_contents(
164+
messages: List[Dict[str, Any]],
165+
) -> List[Dict[str, Any]]:
166+
"""Convert OpenAI-format messages to Gemini-format contents.
167+
168+
Handles the role mapping (assistant -> model, system -> user) and
169+
wraps string content in the ``{"parts": [{"text": ...}]}`` structure
170+
that the Gemini countTokens API expects.
171+
"""
172+
role_map = {"assistant": "model", "system": "user"}
173+
contents: List[Dict[str, Any]] = []
174+
for msg in messages:
175+
role = role_map.get(msg.get("role", "user"), msg.get("role", "user"))
176+
content = msg.get("content", "")
177+
if isinstance(content, str):
178+
parts = [{"text": content}]
179+
elif isinstance(content, list):
180+
parts = []
181+
for part in content:
182+
if isinstance(part, str):
183+
parts.append({"text": part})
184+
elif isinstance(part, dict) and part.get("type") == "text":
185+
parts.append({"text": part.get("text", "")})
186+
else:
187+
parts.append(part)
188+
else:
189+
parts = [{"text": str(content)}]
190+
contents.append({"role": role, "parts": parts})
191+
return contents
192+
162193
async def count_tokens(
163194
self,
164195
model_to_use: str,
@@ -170,6 +201,13 @@ async def count_tokens(
170201
import copy
171202

172203
from litellm.llms.gemini.count_tokens.handler import GoogleAIStudioTokenCounter
204+
205+
# When called from the Anthropic /v1/messages/count_tokens endpoint,
206+
# contents is None and messages holds the OpenAI-format payload.
207+
# Convert messages to Gemini contents so the API gets what it needs.
208+
if contents is None and messages:
209+
contents = self._convert_messages_to_gemini_contents(messages)
210+
173211
deployment = deployment or {}
174212
count_tokens_params_request = copy.deepcopy(deployment.get("litellm_params", {}))
175213
count_tokens_params = {
@@ -180,7 +218,7 @@ async def count_tokens(
180218
result = await GoogleAIStudioTokenCounter().acount_tokens(
181219
**count_tokens_params_request,
182220
)
183-
221+
184222
if result is not None:
185223
return TokenCountResponse(
186224
total_tokens=result.get("totalTokens", 0),
@@ -189,5 +227,5 @@ async def count_tokens(
189227
tokenizer_type=result.get("tokenizer_used", ""),
190228
original_response=result,
191229
)
192-
230+
193231
return None

tests/proxy_unit_tests/test_proxy_token_counter.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,3 +1277,172 @@ async def mock_token_counter_error(request, call_endpoint=False):
12771277
proxy_server.token_counter = original_token_counter
12781278

12791279

1280+
def test_gemini_convert_messages_to_gemini_contents_basic():
1281+
"""
1282+
Test that _convert_messages_to_gemini_contents converts OpenAI-format
1283+
messages to the Gemini contents structure expected by the countTokens API.
1284+
"""
1285+
from litellm.llms.gemini.common_utils import GoogleAIStudioTokenCounter
1286+
1287+
messages = [
1288+
{"role": "user", "content": "Hello"},
1289+
{"role": "assistant", "content": "Hi there!"},
1290+
{"role": "user", "content": "How are you?"},
1291+
]
1292+
1293+
result = GoogleAIStudioTokenCounter._convert_messages_to_gemini_contents(messages)
1294+
1295+
assert len(result) == 3
1296+
# user stays as user
1297+
assert result[0] == {"role": "user", "parts": [{"text": "Hello"}]}
1298+
# assistant maps to model
1299+
assert result[1] == {"role": "model", "parts": [{"text": "Hi there!"}]}
1300+
assert result[2] == {"role": "user", "parts": [{"text": "How are you?"}]}
1301+
1302+
1303+
def test_gemini_convert_messages_system_role_maps_to_user():
1304+
"""
1305+
Test that system messages are mapped to the 'user' role since Gemini
1306+
does not have a dedicated system role in the countTokens API.
1307+
"""
1308+
from litellm.llms.gemini.common_utils import GoogleAIStudioTokenCounter
1309+
1310+
messages = [
1311+
{"role": "system", "content": "You are a helpful assistant."},
1312+
{"role": "user", "content": "Hi"},
1313+
]
1314+
1315+
result = GoogleAIStudioTokenCounter._convert_messages_to_gemini_contents(messages)
1316+
1317+
assert result[0]["role"] == "user"
1318+
assert result[0]["parts"] == [{"text": "You are a helpful assistant."}]
1319+
assert result[1]["role"] == "user"
1320+
1321+
1322+
def test_gemini_convert_messages_list_content():
1323+
"""
1324+
Test conversion when message content is a list (multimodal format),
1325+
including text-type parts and plain string parts.
1326+
"""
1327+
from litellm.llms.gemini.common_utils import GoogleAIStudioTokenCounter
1328+
1329+
messages = [
1330+
{
1331+
"role": "user",
1332+
"content": [
1333+
{"type": "text", "text": "What is in this image?"},
1334+
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
1335+
],
1336+
},
1337+
]
1338+
1339+
result = GoogleAIStudioTokenCounter._convert_messages_to_gemini_contents(messages)
1340+
1341+
assert len(result) == 1
1342+
assert result[0]["role"] == "user"
1343+
parts = result[0]["parts"]
1344+
assert len(parts) == 2
1345+
# text part gets extracted
1346+
assert parts[0] == {"text": "What is in this image?"}
1347+
# non-text part is passed through as-is
1348+
assert parts[1] == {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}
1349+
1350+
1351+
def test_gemini_convert_messages_empty_and_missing_content():
1352+
"""
1353+
Test edge cases: missing content field defaults to empty string,
1354+
non-string/non-list content is stringified.
1355+
"""
1356+
from litellm.llms.gemini.common_utils import GoogleAIStudioTokenCounter
1357+
1358+
messages = [
1359+
{"role": "user"}, # no content key
1360+
{"role": "user", "content": 42}, # numeric content
1361+
]
1362+
1363+
result = GoogleAIStudioTokenCounter._convert_messages_to_gemini_contents(messages)
1364+
1365+
assert result[0]["parts"] == [{"text": ""}]
1366+
assert result[1]["parts"] == [{"text": "42"}]
1367+
1368+
1369+
@pytest.mark.asyncio
1370+
async def test_gemini_count_tokens_converts_messages_when_contents_is_none():
1371+
"""
1372+
Test that GoogleAIStudioTokenCounter.count_tokens() converts OpenAI-format
1373+
messages to Gemini contents when contents is None. This is the core fix for
1374+
https://github.com/BerriAI/litellm/issues/21748
1375+
"""
1376+
from litellm.llms.gemini.common_utils import GoogleAIStudioTokenCounter
1377+
1378+
counter = GoogleAIStudioTokenCounter()
1379+
1380+
messages = [
1381+
{"role": "user", "content": "Hello, count my tokens!"},
1382+
]
1383+
1384+
captured_contents = {}
1385+
1386+
# Mock the handler's acount_tokens to capture what gets passed
1387+
async def mock_acount_tokens(contents, model, **kwargs):
1388+
captured_contents["contents"] = contents
1389+
return {"totalTokens": 7}
1390+
1391+
with patch(
1392+
"litellm.llms.gemini.count_tokens.handler.GoogleAIStudioTokenCounter.acount_tokens",
1393+
side_effect=mock_acount_tokens,
1394+
):
1395+
result = await counter.count_tokens(
1396+
model_to_use="gemini-2.5-flash",
1397+
messages=messages,
1398+
contents=None, # <-- the bug scenario: contents is None
1399+
)
1400+
1401+
# The handler should have received properly converted contents
1402+
assert captured_contents["contents"] is not None
1403+
assert len(captured_contents["contents"]) == 1
1404+
assert captured_contents["contents"][0]["role"] == "user"
1405+
assert captured_contents["contents"][0]["parts"] == [{"text": "Hello, count my tokens!"}]
1406+
1407+
# The result should be a proper TokenCountResponse
1408+
assert result is not None
1409+
assert result.total_tokens == 7
1410+
1411+
1412+
@pytest.mark.asyncio
1413+
async def test_gemini_count_tokens_passes_contents_through_when_provided():
1414+
"""
1415+
Test that when contents is already provided (non-None), count_tokens
1416+
passes it through without modification instead of converting messages.
1417+
"""
1418+
from litellm.llms.gemini.common_utils import GoogleAIStudioTokenCounter
1419+
1420+
counter = GoogleAIStudioTokenCounter()
1421+
1422+
# Pre-formatted Gemini contents
1423+
gemini_contents = [
1424+
{"role": "user", "parts": [{"text": "Already in Gemini format"}]}
1425+
]
1426+
1427+
captured_contents = {}
1428+
1429+
async def mock_acount_tokens(contents, model, **kwargs):
1430+
captured_contents["contents"] = contents
1431+
return {"totalTokens": 5}
1432+
1433+
with patch(
1434+
"litellm.llms.gemini.count_tokens.handler.GoogleAIStudioTokenCounter.acount_tokens",
1435+
side_effect=mock_acount_tokens,
1436+
):
1437+
result = await counter.count_tokens(
1438+
model_to_use="gemini-2.5-flash",
1439+
messages=[{"role": "user", "content": "This should be ignored"}],
1440+
contents=gemini_contents,
1441+
)
1442+
1443+
# Should pass through the original contents, not convert messages
1444+
assert captured_contents["contents"] == gemini_contents
1445+
assert result is not None
1446+
assert result.total_tokens == 5
1447+
1448+

0 commit comments

Comments
 (0)