@@ -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