You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

test_chat_completion_cache.py 36 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  1. import copy
  2. from typing import Any, Dict, List, Optional, Tuple, Union, cast
  3. import pytest
  4. from autogen_core import CacheStore, FunctionCall
  5. from autogen_core.models import (
  6. ChatCompletionClient,
  7. CreateResult,
  8. LLMMessage,
  9. RequestUsage,
  10. SystemMessage,
  11. UserMessage,
  12. )
  13. from autogen_ext.models.cache import CHAT_CACHE_VALUE_TYPE, ChatCompletionCache
  14. from autogen_ext.models.replay import ReplayChatCompletionClient
  15. from pydantic import BaseModel
  16. def get_test_data(
  17. num_messages: int = 3,
  18. ) -> Tuple[list[str], list[str], SystemMessage, ChatCompletionClient, ChatCompletionCache]:
  19. responses = [f"This is dummy message number {i}" for i in range(num_messages)]
  20. prompts = [f"This is dummy prompt number {i}" for i in range(num_messages)]
  21. system_prompt = SystemMessage(content="This is a system prompt")
  22. replay_client = ReplayChatCompletionClient(responses)
  23. replay_client.set_cached_bool_value(False)
  24. cached_client = ChatCompletionCache(replay_client)
  25. return responses, prompts, system_prompt, replay_client, cached_client
  26. @pytest.mark.asyncio
  27. async def test_cache_basic_with_args() -> None:
  28. responses, prompts, system_prompt, _, cached_client = get_test_data()
  29. response0 = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  30. assert isinstance(response0, CreateResult)
  31. assert not response0.cached
  32. assert response0.content == responses[0]
  33. response1 = await cached_client.create([system_prompt, UserMessage(content=prompts[1], source="user")])
  34. assert not response1.cached
  35. assert response1.content == responses[1]
  36. # Cached output.
  37. response0_cached = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  38. assert isinstance(response0, CreateResult)
  39. assert response0_cached.cached
  40. assert response0_cached.content == responses[0]
  41. # Cache miss if args change.
  42. response2 = await cached_client.create(
  43. [system_prompt, UserMessage(content=prompts[0], source="user")], json_output=True
  44. )
  45. assert isinstance(response2, CreateResult)
  46. assert not response2.cached
  47. assert response2.content == responses[2]
  48. @pytest.mark.asyncio
  49. async def test_cache_structured_output_with_args() -> None:
  50. responses, prompts, system_prompt, _, cached_client = get_test_data(num_messages=4)
  51. class Answer(BaseModel):
  52. thought: str
  53. answer: str
  54. class Answer2(BaseModel):
  55. calculation: str
  56. answer: str
  57. response0 = await cached_client.create(
  58. [system_prompt, UserMessage(content=prompts[0], source="user")], json_output=Answer
  59. )
  60. assert isinstance(response0, CreateResult)
  61. assert not response0.cached
  62. assert response0.content == responses[0]
  63. response1 = await cached_client.create(
  64. [system_prompt, UserMessage(content=prompts[1], source="user")], json_output=Answer
  65. )
  66. assert not response1.cached
  67. assert response1.content == responses[1]
  68. # Cached output.
  69. response0_cached = await cached_client.create(
  70. [system_prompt, UserMessage(content=prompts[0], source="user")], json_output=Answer
  71. )
  72. assert isinstance(response0, CreateResult)
  73. assert response0_cached.cached
  74. assert response0_cached.content == responses[0]
  75. # Without the json_output argument, the cache should not be hit.
  76. response0 = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  77. assert isinstance(response0, CreateResult)
  78. assert not response0.cached
  79. assert response0.content == responses[2]
  80. # With a different output type, the cache should not be hit.
  81. response0 = await cached_client.create(
  82. [system_prompt, UserMessage(content=prompts[1], source="user")], json_output=Answer2
  83. )
  84. assert isinstance(response0, CreateResult)
  85. assert not response0.cached
  86. assert response0.content == responses[3]
  87. @pytest.mark.asyncio
  88. async def test_cache_model_and_count_api() -> None:
  89. _, prompts, system_prompt, replay_client, cached_client = get_test_data()
  90. assert replay_client.model_info == cached_client.model_info
  91. assert replay_client.capabilities == cached_client.capabilities
  92. messages: List[LLMMessage] = [system_prompt, UserMessage(content=prompts[0], source="user")]
  93. assert replay_client.count_tokens(messages) == cached_client.count_tokens(messages)
  94. assert replay_client.remaining_tokens(messages) == cached_client.remaining_tokens(messages)
  95. @pytest.mark.asyncio
  96. async def test_cache_token_usage() -> None:
  97. responses, prompts, system_prompt, replay_client, cached_client = get_test_data()
  98. response0 = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  99. assert isinstance(response0, CreateResult)
  100. assert not response0.cached
  101. assert response0.content == responses[0]
  102. actual_usage0 = copy.copy(cached_client.actual_usage())
  103. total_usage0 = copy.copy(cached_client.total_usage())
  104. response1 = await cached_client.create([system_prompt, UserMessage(content=prompts[1], source="user")])
  105. assert not response1.cached
  106. assert response1.content == responses[1]
  107. actual_usage1 = copy.copy(cached_client.actual_usage())
  108. total_usage1 = copy.copy(cached_client.total_usage())
  109. assert total_usage1.prompt_tokens > total_usage0.prompt_tokens
  110. assert total_usage1.completion_tokens > total_usage0.completion_tokens
  111. assert actual_usage1.prompt_tokens == actual_usage0.prompt_tokens
  112. assert actual_usage1.completion_tokens == actual_usage0.completion_tokens
  113. # Cached output.
  114. response0_cached = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  115. assert isinstance(response0, CreateResult)
  116. assert response0_cached.cached
  117. assert response0_cached.content == responses[0]
  118. total_usage2 = copy.copy(cached_client.total_usage())
  119. assert total_usage2.prompt_tokens == total_usage1.prompt_tokens
  120. assert total_usage2.completion_tokens == total_usage1.completion_tokens
  121. assert cached_client.actual_usage() == replay_client.actual_usage()
  122. assert cached_client.total_usage() == replay_client.total_usage()
  123. @pytest.mark.asyncio
  124. async def test_cache_create_stream() -> None:
  125. _, prompts, system_prompt, _, cached_client = get_test_data()
  126. original_streamed_results: List[Union[str, CreateResult]] = []
  127. async for completion in cached_client.create_stream(
  128. [system_prompt, UserMessage(content=prompts[0], source="user")]
  129. ):
  130. original_streamed_results.append(copy.copy(completion))
  131. total_usage0 = copy.copy(cached_client.total_usage())
  132. cached_completion_results: List[Union[str, CreateResult]] = []
  133. async for completion in cached_client.create_stream(
  134. [system_prompt, UserMessage(content=prompts[0], source="user")]
  135. ):
  136. cached_completion_results.append(copy.copy(completion))
  137. total_usage1 = copy.copy(cached_client.total_usage())
  138. assert total_usage1.prompt_tokens == total_usage0.prompt_tokens
  139. assert total_usage1.completion_tokens == total_usage0.completion_tokens
  140. for original, cached in zip(original_streamed_results, cached_completion_results, strict=False):
  141. if isinstance(original, str):
  142. assert original == cached
  143. elif isinstance(original, CreateResult) and isinstance(cached, CreateResult):
  144. assert original.content == cached.content
  145. assert cached.cached
  146. assert not original.cached
  147. else:
  148. raise ValueError(f"Unexpected types : {type(original)} and {type(cached)}")
  149. # test serialization
  150. # cached_client_config = cached_client.dump_component()
  151. # loaded_client = ChatCompletionCache.load_component(cached_client_config)
  152. # assert loaded_client.client == cached_client.client
  153. class MockCacheStore(CacheStore[CHAT_CACHE_VALUE_TYPE]):
  154. """Mock cache store for testing deserialization scenarios."""
  155. def __init__(self, return_value: Optional[CHAT_CACHE_VALUE_TYPE] = None) -> None:
  156. self._return_value = return_value
  157. self._storage: Dict[str, CHAT_CACHE_VALUE_TYPE] = {}
  158. def get(self, key: str, default: Optional[CHAT_CACHE_VALUE_TYPE] = None) -> Optional[CHAT_CACHE_VALUE_TYPE]:
  159. return self._return_value # type: ignore
  160. def set(self, key: str, value: CHAT_CACHE_VALUE_TYPE) -> None:
  161. self._storage[key] = value
  162. def _to_config(self) -> BaseModel:
  163. """Dummy implementation for testing."""
  164. return BaseModel()
  165. @classmethod
  166. def _from_config(cls, _config: BaseModel) -> "MockCacheStore":
  167. """Dummy implementation for testing."""
  168. return cls()
  169. def test_check_cache_redis_dict_deserialization_success() -> None:
  170. """Test _check_cache when Redis cache returns a dict that can be deserialized to CreateResult.
  171. This tests the core Redis deserialization fix where Redis returns serialized Pydantic
  172. models as dictionaries instead of the original objects.
  173. """
  174. _, prompts, system_prompt, replay_client, _ = get_test_data()
  175. # Create a CreateResult instance (simulating deserialized Redis data)
  176. create_result = CreateResult(
  177. content="test response from redis",
  178. usage=RequestUsage(prompt_tokens=15, completion_tokens=8),
  179. cached=False,
  180. finish_reason="stop",
  181. )
  182. # Mock cache store that returns a CreateResult (simulating Redis behavior)
  183. mock_store = MockCacheStore(return_value=create_result)
  184. cached_client = ChatCompletionCache(replay_client, mock_store)
  185. # Test _check_cache method directly using proper test data
  186. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  187. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  188. assert cached_result is not None
  189. assert isinstance(cached_result, CreateResult)
  190. assert cached_result.content == "test response from redis"
  191. assert cache_key is not None
  192. def test_check_cache_redis_dict_deserialization_failure() -> None:
  193. """Test _check_cache gracefully handles corrupted Redis data.
  194. This ensures the system degrades gracefully when Redis returns corrupted
  195. or invalid data that cannot be deserialized back to CreateResult.
  196. """
  197. _, prompts, system_prompt, replay_client, _ = get_test_data()
  198. # Mock cache store that returns None (simulating deserialization failure)
  199. mock_store = MockCacheStore(return_value=None)
  200. cached_client = ChatCompletionCache(replay_client, mock_store)
  201. # Test _check_cache method directly using proper test data
  202. messages = [system_prompt, UserMessage(content=prompts[1], source="user")]
  203. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  204. # Should return None (cache miss) when deserialization fails
  205. assert cached_result is None
  206. assert cache_key is not None
  207. def test_check_cache_redis_streaming_dict_deserialization() -> None:
  208. """Test _check_cache with Redis streaming data containing dicts that need deserialization.
  209. This tests the streaming scenario where Redis returns a list containing
  210. serialized CreateResult objects as dictionaries mixed with string chunks.
  211. """
  212. _, prompts, system_prompt, replay_client, _ = get_test_data()
  213. # Create a list with CreateResult objects mixed with strings (streaming scenario)
  214. create_result = CreateResult(
  215. content="final streaming response from redis",
  216. usage=RequestUsage(prompt_tokens=12, completion_tokens=6),
  217. cached=False,
  218. finish_reason="stop",
  219. )
  220. cached_list: List[Union[str, CreateResult]] = [
  221. "streaming chunk 1",
  222. create_result, # Proper CreateResult object
  223. "streaming chunk 2",
  224. ]
  225. # Mock cache store that returns the list with CreateResults (simulating Redis streaming)
  226. mock_store = MockCacheStore(return_value=cached_list)
  227. cached_client = ChatCompletionCache(replay_client, mock_store)
  228. # Test _check_cache method directly using proper test data
  229. messages = [system_prompt, UserMessage(content=prompts[2], source="user")]
  230. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  231. assert cached_result is not None
  232. assert isinstance(cached_result, list)
  233. assert len(cached_result) == 3
  234. assert cached_result[0] == "streaming chunk 1"
  235. assert isinstance(cached_result[1], CreateResult)
  236. assert cached_result[1].content == "final streaming response from redis"
  237. assert cached_result[2] == "streaming chunk 2"
  238. assert cache_key is not None
  239. def test_check_cache_redis_streaming_deserialization_failure() -> None:
  240. """Test _check_cache gracefully handles corrupted Redis streaming data.
  241. This ensures the system degrades gracefully when Redis returns streaming
  242. data with corrupted CreateResult dictionaries that cannot be deserialized.
  243. """
  244. _, prompts, system_prompt, replay_client, _ = get_test_data(num_messages=4)
  245. # Mock cache store that returns None (simulating deserialization failure)
  246. mock_store = MockCacheStore(return_value=None)
  247. cached_client = ChatCompletionCache(replay_client, mock_store)
  248. # Test _check_cache method directly using proper test data
  249. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  250. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  251. # Should return None (cache miss) when deserialization fails
  252. assert cached_result is None
  253. assert cache_key is not None
  254. def test_check_cache_dict_reconstruction_success() -> None:
  255. """Test _check_cache successfully reconstructs CreateResult from a dict.
  256. This tests the line: cached_result = CreateResult.model_validate(cached_result)
  257. """
  258. _, prompts, system_prompt, replay_client, _ = get_test_data()
  259. # Create a dict that can be successfully validated as CreateResult
  260. valid_dict = {
  261. "content": "reconstructed response",
  262. "usage": {"prompt_tokens": 10, "completion_tokens": 5},
  263. "cached": False,
  264. "finish_reason": "stop",
  265. }
  266. # Create a MockCacheStore that returns the dict directly (simulating Redis)
  267. mock_store = MockCacheStore(return_value=cast(Any, valid_dict))
  268. cached_client = ChatCompletionCache(replay_client, mock_store)
  269. # Test _check_cache method
  270. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  271. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  272. # Should successfully reconstruct the CreateResult from dict
  273. assert cached_result is not None
  274. assert isinstance(cached_result, CreateResult)
  275. assert cached_result.content == "reconstructed response"
  276. assert cache_key is not None
  277. def test_check_cache_dict_reconstruction_failure() -> None:
  278. """Test _check_cache handles ValidationError when dict cannot be reconstructed.
  279. This tests the except ValidationError block for single dicts.
  280. """
  281. _, prompts, system_prompt, replay_client, _ = get_test_data()
  282. # Create an invalid dict that will fail CreateResult validation
  283. invalid_dict = {
  284. "invalid_field": "value",
  285. "missing_required_fields": True,
  286. }
  287. # Create a MockCacheStore that returns the invalid dict
  288. mock_store = MockCacheStore(return_value=cast(Any, invalid_dict))
  289. cached_client = ChatCompletionCache(replay_client, mock_store)
  290. # Test _check_cache method
  291. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  292. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  293. # Should return None (cache miss) when reconstruction fails
  294. assert cached_result is None
  295. assert cache_key is not None
  296. def test_check_cache_list_reconstruction_success() -> None:
  297. """Test _check_cache successfully reconstructs CreateResult objects from dicts in a list.
  298. This tests the line: reconstructed_list.append(CreateResult.model_validate(item))
  299. """
  300. _, prompts, system_prompt, replay_client, _ = get_test_data()
  301. # Create a list with valid dicts that can be reconstructed
  302. valid_dict1 = {
  303. "content": "first result",
  304. "usage": {"prompt_tokens": 8, "completion_tokens": 3},
  305. "cached": False,
  306. "finish_reason": "stop",
  307. }
  308. valid_dict2 = {
  309. "content": "second result",
  310. "usage": {"prompt_tokens": 12, "completion_tokens": 7},
  311. "cached": False,
  312. "finish_reason": "stop",
  313. }
  314. cached_list = [
  315. "streaming chunk 1",
  316. valid_dict1,
  317. "streaming chunk 2",
  318. valid_dict2,
  319. ]
  320. # Create a MockCacheStore that returns the list with dicts
  321. mock_store = MockCacheStore(return_value=cast(Any, cached_list))
  322. cached_client = ChatCompletionCache(replay_client, mock_store)
  323. # Test _check_cache method
  324. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  325. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  326. # Should successfully reconstruct the list with CreateResult objects
  327. assert cached_result is not None
  328. assert isinstance(cached_result, list)
  329. assert len(cached_result) == 4
  330. assert cached_result[0] == "streaming chunk 1"
  331. assert isinstance(cached_result[1], CreateResult)
  332. assert cached_result[1].content == "first result"
  333. assert cached_result[2] == "streaming chunk 2"
  334. assert isinstance(cached_result[3], CreateResult)
  335. assert cached_result[3].content == "second result"
  336. assert cache_key is not None
  337. def test_check_cache_list_reconstruction_failure() -> None:
  338. """Test _check_cache handles ValidationError when list contains invalid dicts.
  339. This tests the except ValidationError block for lists.
  340. """
  341. _, prompts, system_prompt, replay_client, _ = get_test_data()
  342. # Create a list with an invalid dict that will fail validation
  343. invalid_dict = {
  344. "invalid_field": "value",
  345. "missing_required": True,
  346. }
  347. cached_list = [
  348. "streaming chunk 1",
  349. invalid_dict, # This will cause ValidationError
  350. "streaming chunk 2",
  351. ]
  352. # Create a MockCacheStore that returns the list with invalid dict
  353. mock_store = MockCacheStore(return_value=cast(Any, cached_list))
  354. cached_client = ChatCompletionCache(replay_client, mock_store)
  355. # Test _check_cache method
  356. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  357. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  358. # Should return None (cache miss) when list reconstruction fails
  359. assert cached_result is None
  360. assert cache_key is not None
  361. def test_check_cache_already_correct_type() -> None:
  362. """Test _check_cache returns data as-is when it's already the correct type.
  363. This tests the final return path when no reconstruction is needed.
  364. """
  365. _, prompts, system_prompt, replay_client, _ = get_test_data()
  366. # Create a proper CreateResult object (already correct type)
  367. create_result = CreateResult(
  368. content="already correct type",
  369. usage=RequestUsage(prompt_tokens=15, completion_tokens=8),
  370. cached=False,
  371. finish_reason="stop",
  372. )
  373. # Create a MockCacheStore that returns the proper type
  374. mock_store = MockCacheStore(return_value=create_result)
  375. cached_client = ChatCompletionCache(replay_client, mock_store)
  376. # Test _check_cache method
  377. messages = [system_prompt, UserMessage(content=prompts[0], source="user")]
  378. cached_result, cache_key = cached_client._check_cache(messages, [], None, {}) # type: ignore
  379. # Should return the same object without reconstruction
  380. assert cached_result is not None
  381. assert cached_result is create_result # Same object reference
  382. assert isinstance(cached_result, CreateResult)
  383. assert cached_result.content == "already correct type"
  384. assert cache_key is not None
  385. @pytest.mark.asyncio
  386. async def test_redis_streaming_cache_integration() -> None:
  387. """Integration test for Redis streaming cache scenario.
  388. This test covers the original streaming cache issues:
  389. 1. Cache is stored after streaming completes (not before)
  390. 2. Redis cache properly handles lists containing CreateResult objects
  391. 3. ChatCompletionCache properly reconstructs CreateResult from Redis dicts
  392. """
  393. from unittest.mock import MagicMock
  394. # Skip this test if redis is not available
  395. pytest.importorskip("redis")
  396. from autogen_ext.cache_store.redis import RedisStore
  397. # Use standardized test data
  398. _, prompts, system_prompt, replay_client, _ = get_test_data()
  399. # Mock Redis instance to control what gets stored/retrieved
  400. redis_instance = MagicMock()
  401. redis_store = RedisStore[CHAT_CACHE_VALUE_TYPE](redis_instance)
  402. # Create the cached client with Redis store
  403. cached_client = ChatCompletionCache(replay_client, redis_store)
  404. # Simulate first streaming call (should cache after completion)
  405. first_stream_results: List[Union[str, CreateResult]] = []
  406. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  407. first_stream_results.append(copy.copy(chunk))
  408. # Verify Redis set was called with the complete streaming results
  409. redis_instance.set.assert_called_once()
  410. call_args = redis_instance.set.call_args
  411. serialized_data = call_args[0][1]
  412. # Verify the serialized data represents the complete stream
  413. assert isinstance(serialized_data, bytes)
  414. import json
  415. deserialized = json.loads(serialized_data.decode("utf-8"))
  416. assert isinstance(deserialized, list)
  417. # Type narrowing: after isinstance check, deserialized is known to be a list
  418. deserialized_list: List[Union[str, Dict[str, Union[str, int]]]] = deserialized # Now properly typed as list
  419. # Should contain both string chunks and final CreateResult (as dict)
  420. assert len(deserialized_list) > 0
  421. # Reset the mock for the second call
  422. redis_instance.reset_mock()
  423. # Configure Redis to return the serialized data (simulating cache hit)
  424. redis_instance.get.return_value = serialized_data
  425. # Second streaming call should hit the cache
  426. second_stream_results: List[Union[str, CreateResult]] = []
  427. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  428. second_stream_results.append(copy.copy(chunk))
  429. # Verify Redis get was called but set was not (cache hit)
  430. redis_instance.get.assert_called_once()
  431. redis_instance.set.assert_not_called()
  432. # Verify both streams have the same content
  433. assert len(first_stream_results) == len(second_stream_results)
  434. # Verify cached results are marked as cached
  435. for first, second in zip(first_stream_results, second_stream_results, strict=True):
  436. if isinstance(first, CreateResult) and isinstance(second, CreateResult):
  437. assert not first.cached # First call should not be cached
  438. assert second.cached # Second call should be cached
  439. assert first.content == second.content
  440. elif isinstance(first, str) and isinstance(second, str):
  441. assert first == second
  442. else:
  443. pytest.fail(f"Unexpected chunk types: {type(first)}, {type(second)}")
  444. @pytest.mark.asyncio
  445. async def test_cache_cross_compatibility_create_to_stream() -> None:
  446. """Test that create() cache can be used by create_stream() call.
  447. This tests the scenario where:
  448. 1. User calls create() - stores CreateResult
  449. 2. User calls create_stream() with same inputs - should get cache hit and yield the CreateResult
  450. """
  451. responses, prompts, system_prompt, _, cached_client = get_test_data()
  452. # First call: create() - should cache a CreateResult
  453. create_result = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  454. assert isinstance(create_result, CreateResult)
  455. assert not create_result.cached
  456. assert create_result.content == responses[0]
  457. # Second call: create_stream() with same inputs - should hit the cache
  458. stream_results: List[Union[str, CreateResult]] = []
  459. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  460. stream_results.append(copy.copy(chunk))
  461. # Should yield exactly two items: the string content + the cached CreateResult
  462. assert len(stream_results) == 2
  463. # First item should be the string content
  464. assert isinstance(stream_results[0], str)
  465. assert stream_results[0] == responses[0]
  466. # Second item should be the cached CreateResult
  467. assert isinstance(stream_results[1], CreateResult)
  468. assert stream_results[1].cached # Should be marked as cached
  469. assert stream_results[1].content == responses[0]
  470. # Verify no additional API calls were made (cache hit)
  471. initial_usage = cached_client.total_usage()
  472. # Third call: create_stream() again - should still hit cache
  473. stream_results_2: List[Union[str, CreateResult]] = []
  474. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  475. stream_results_2.append(copy.copy(chunk))
  476. # Usage should be the same (no new API calls)
  477. assert cached_client.total_usage().prompt_tokens == initial_usage.prompt_tokens
  478. assert cached_client.total_usage().completion_tokens == initial_usage.completion_tokens
  479. @pytest.mark.asyncio
  480. async def test_cache_cross_compatibility_stream_to_create() -> None:
  481. """Test that create_stream() cache can be used by create() call.
  482. This tests the scenario where:
  483. 1. User calls create_stream() - stores List[Union[str, CreateResult]]
  484. 2. User calls create() with same inputs - should get cache hit and return the final CreateResult
  485. """
  486. _, prompts, system_prompt, _, cached_client = get_test_data()
  487. # First call: create_stream() - should cache a List[Union[str, CreateResult]]
  488. first_stream_results: List[Union[str, CreateResult]] = []
  489. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  490. first_stream_results.append(copy.copy(chunk))
  491. # Verify we got streaming results
  492. assert len(first_stream_results) > 0
  493. final_create_result = None
  494. for item in first_stream_results:
  495. if isinstance(item, CreateResult):
  496. final_create_result = item
  497. break
  498. assert final_create_result is not None
  499. assert not final_create_result.cached # First call should not be cached
  500. # Second call: create() with same inputs - should hit the streaming cache
  501. create_result = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  502. assert isinstance(create_result, CreateResult)
  503. assert create_result.cached # Should be marked as cached
  504. assert create_result.content == final_create_result.content
  505. # Verify no additional API calls were made (cache hit)
  506. initial_usage = cached_client.total_usage()
  507. # Third call: create() again - should still hit cache
  508. create_result_2 = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  509. # Usage should be the same (no new API calls)
  510. assert cached_client.total_usage().prompt_tokens == initial_usage.prompt_tokens
  511. assert cached_client.total_usage().completion_tokens == initial_usage.completion_tokens
  512. assert create_result_2.cached
  513. @pytest.mark.asyncio
  514. async def test_cache_cross_compatibility_mixed_sequence() -> None:
  515. """Test mixed sequence of create() and create_stream() calls with caching.
  516. This tests a realistic scenario with multiple interleaved calls:
  517. create() → create_stream() → create() → create_stream()
  518. """
  519. responses, prompts, system_prompt, _, cached_client = get_test_data(num_messages=4)
  520. # Call 1: create() with prompt[0] - should make API call
  521. result1 = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  522. assert not result1.cached
  523. assert result1.content == responses[0]
  524. usage_after_1 = copy.copy(cached_client.total_usage())
  525. # Call 2: create_stream() with prompt[0] - should hit cache from call 1
  526. stream1_results: List[Union[str, CreateResult]] = []
  527. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  528. stream1_results.append(chunk)
  529. assert len(stream1_results) == 2 # Should yield string content + cached CreateResult
  530. assert isinstance(stream1_results[0], str) # First item: string content
  531. assert stream1_results[0] == responses[0]
  532. assert isinstance(stream1_results[1], CreateResult) # Second item: cached CreateResult
  533. assert stream1_results[1].cached
  534. usage_after_2 = copy.copy(cached_client.total_usage())
  535. # No new API call should have been made
  536. assert usage_after_2.prompt_tokens == usage_after_1.prompt_tokens
  537. # Call 3: create_stream() with prompt[1] - should make new API call
  538. stream2_results: List[Union[str, CreateResult]] = []
  539. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[1], source="user")]):
  540. stream2_results.append(copy.copy(chunk))
  541. # Should have made a new API call
  542. usage_after_3 = copy.copy(cached_client.total_usage())
  543. assert usage_after_3.prompt_tokens > usage_after_2.prompt_tokens
  544. # Find the final CreateResult
  545. final_result = None
  546. for item in stream2_results:
  547. if isinstance(item, CreateResult):
  548. final_result = item
  549. break
  550. assert final_result is not None
  551. assert not final_result.cached
  552. # Call 4: create() with prompt[1] - should hit cache from call 3
  553. result4 = await cached_client.create([system_prompt, UserMessage(content=prompts[1], source="user")])
  554. assert result4.cached
  555. assert result4.content == final_result.content
  556. usage_after_4 = copy.copy(cached_client.total_usage())
  557. # No new API call should have been made
  558. assert usage_after_4.prompt_tokens == usage_after_3.prompt_tokens
  559. @pytest.mark.asyncio
  560. async def test_cache_streaming_list_without_create_result() -> None:
  561. """Test edge case where streaming cache contains only strings (no CreateResult).
  562. This could happen if streaming was interrupted or in unusual scenarios.
  563. The create() method should handle this gracefully by falling through to make a real API call.
  564. """
  565. responses, prompts, system_prompt, replay_client, _ = get_test_data()
  566. # Create a mock cache store that returns a list with only strings (no CreateResult)
  567. string_only_list: List[Union[str, CreateResult]] = ["Hello", " world", "!"]
  568. mock_store = MockCacheStore(return_value=string_only_list)
  569. cached_client = ChatCompletionCache(replay_client, mock_store)
  570. # Call create() - should fall through and make API call since no CreateResult in cached list
  571. result = await cached_client.create([system_prompt, UserMessage(content=prompts[0], source="user")])
  572. assert isinstance(result, CreateResult)
  573. assert not result.cached # Should be from real API call, not cache
  574. assert result.content == responses[0]
  575. @pytest.mark.asyncio
  576. async def test_create_stream_with_cached_non_streaming_result_string_content() -> None:
  577. """
  578. Test that when create_stream() finds a cached non-streaming result with string content,
  579. it yields both the content string as a streaming chunk and then the CreateResult.
  580. """
  581. responses, prompts, system_prompt, replay_client, _ = get_test_data()
  582. # Create a CreateResult with string content (simulating a cached non-streaming result)
  583. cached_create_result = CreateResult(
  584. content=responses[0], # This is a string
  585. finish_reason="stop",
  586. usage=RequestUsage(prompt_tokens=10, completion_tokens=20),
  587. cached=False, # Will be set to True when retrieved from cache
  588. )
  589. # Mock cache store that returns the non-streaming CreateResult
  590. mock_store = MockCacheStore(return_value=cached_create_result)
  591. cached_client = ChatCompletionCache(replay_client, mock_store)
  592. # Call create_stream() - should yield string content first, then CreateResult
  593. stream_results: List[Union[str, CreateResult]] = []
  594. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  595. stream_results.append(copy.copy(chunk))
  596. # Should have exactly 2 items: the string content, then the CreateResult
  597. assert len(stream_results) == 2
  598. # First item should be the string content
  599. assert isinstance(stream_results[0], str)
  600. assert stream_results[0] == responses[0]
  601. # Second item should be the CreateResult
  602. assert isinstance(stream_results[1], CreateResult)
  603. assert stream_results[1].content == responses[0]
  604. assert stream_results[1].finish_reason == "stop"
  605. assert stream_results[1].cached is True # Should be marked as cached
  606. @pytest.mark.asyncio
  607. async def test_create_stream_with_cached_non_streaming_result_empty_content() -> None:
  608. """
  609. Test that when create_stream() finds a cached non-streaming result with empty string content,
  610. it only yields the CreateResult (no separate string chunk).
  611. """
  612. _, prompts, system_prompt, replay_client, _ = get_test_data()
  613. # Create a CreateResult with empty string content
  614. cached_create_result = CreateResult(
  615. content="", # Empty string
  616. finish_reason="stop",
  617. usage=RequestUsage(prompt_tokens=10, completion_tokens=0),
  618. cached=False,
  619. )
  620. # Mock cache store that returns the non-streaming CreateResult
  621. mock_store = MockCacheStore(return_value=cached_create_result)
  622. cached_client = ChatCompletionCache(replay_client, mock_store)
  623. # Call create_stream() - should yield only the CreateResult (no string chunk)
  624. stream_results: List[Union[str, CreateResult]] = []
  625. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  626. stream_results.append(copy.copy(chunk))
  627. # Should have exactly 1 item: just the CreateResult
  628. assert len(stream_results) == 1
  629. # Only item should be the CreateResult
  630. assert isinstance(stream_results[0], CreateResult)
  631. assert stream_results[0].content == ""
  632. assert stream_results[0].finish_reason == "stop"
  633. assert stream_results[0].cached is True
  634. @pytest.mark.asyncio
  635. async def test_create_stream_with_cached_non_streaming_result_non_string_content() -> None:
  636. """
  637. Test that when create_stream() finds a cached non-streaming result with non-string content,
  638. it only yields the CreateResult (no separate string chunk).
  639. """
  640. _, prompts, system_prompt, replay_client, _ = get_test_data()
  641. # Create a CreateResult with non-string content (e.g., list of function calls)
  642. cached_create_result = CreateResult(
  643. content=[
  644. FunctionCall(id="call_123", name="test_func", arguments='{"param": "value"}')
  645. ], # List of FunctionCall objects
  646. finish_reason="function_calls", # Valid finish reason for function calls
  647. usage=RequestUsage(prompt_tokens=10, completion_tokens=15),
  648. cached=False,
  649. )
  650. # Mock cache store that returns the non-streaming CreateResult
  651. mock_store = MockCacheStore(return_value=cached_create_result)
  652. cached_client = ChatCompletionCache(replay_client, mock_store)
  653. # Call create_stream() - should yield only the CreateResult (no string chunk)
  654. stream_results: List[Union[str, CreateResult]] = []
  655. async for chunk in cached_client.create_stream([system_prompt, UserMessage(content=prompts[0], source="user")]):
  656. stream_results.append(copy.copy(chunk))
  657. # Should have exactly 1 item: just the CreateResult
  658. assert len(stream_results) == 1
  659. # Only item should be the CreateResult
  660. assert isinstance(stream_results[0], CreateResult)
  661. expected_function_call = FunctionCall(id="call_123", name="test_func", arguments='{"param": "value"}')
  662. assert stream_results[0].content == [expected_function_call]
  663. assert stream_results[0].finish_reason == "function_calls"
  664. assert stream_results[0].cached is True