test_openai_length_finish_reason.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. from types import SimpleNamespace
  2. from unittest.mock import AsyncMock, patch
  3. import pytest
  4. from lightrag.llm.openai import openai_complete_if_cache
  5. def _make_completion(content: str, finish_reason: str = "stop"):
  6. return SimpleNamespace(
  7. choices=[
  8. SimpleNamespace(
  9. finish_reason=finish_reason,
  10. message=SimpleNamespace(
  11. content=content,
  12. parsed=None,
  13. reasoning_content="",
  14. ),
  15. )
  16. ],
  17. usage=SimpleNamespace(
  18. prompt_tokens=10,
  19. completion_tokens=20,
  20. total_tokens=30,
  21. ),
  22. )
  23. def _make_fake_client(completion):
  24. return SimpleNamespace(
  25. chat=SimpleNamespace(
  26. completions=SimpleNamespace(
  27. create=AsyncMock(return_value=completion),
  28. )
  29. ),
  30. close=AsyncMock(),
  31. )
  32. class _FakeAsyncStream:
  33. def __init__(self, chunks):
  34. self._chunks = iter(chunks)
  35. def __aiter__(self):
  36. return self
  37. async def __anext__(self):
  38. try:
  39. return next(self._chunks)
  40. except StopIteration:
  41. raise StopAsyncIteration
  42. async def aclose(self):
  43. return None
  44. def _make_stream_chunk(content=None, reasoning_content=None):
  45. return SimpleNamespace(
  46. choices=[
  47. SimpleNamespace(
  48. delta=SimpleNamespace(
  49. content=content,
  50. reasoning_content=reasoning_content,
  51. )
  52. )
  53. ]
  54. )
  55. @pytest.mark.offline
  56. @pytest.mark.asyncio
  57. async def test_length_finish_reason_returns_raw_content():
  58. """Truncated responses (finish_reason='length') still yield raw content.
  59. After the dispatch simplification, we no longer rely on the typed
  60. ``LengthFinishReasonError`` path — ``create()`` returns the partial
  61. content unchanged and upstream tolerant JSON parsing handles it.
  62. """
  63. raw_json = (
  64. '{"entities":[{"name":"Alice","type":"Person",'
  65. '"description":"Founder"}],"relationships":[]}'
  66. )
  67. completion = _make_completion(raw_json, finish_reason="length")
  68. fake_client = _make_fake_client(completion)
  69. with patch(
  70. "lightrag.llm.openai.create_openai_async_client",
  71. return_value=fake_client,
  72. ):
  73. result = await openai_complete_if_cache(
  74. model="test-model",
  75. prompt="Extract entities",
  76. response_format={"type": "json_object"},
  77. max_completion_tokens=128,
  78. )
  79. assert result == raw_json
  80. fake_client.chat.completions.create.assert_awaited_once()
  81. fake_client.close.assert_awaited_once()
  82. @pytest.mark.offline
  83. @pytest.mark.asyncio
  84. async def test_json_object_response_format_forwarded_to_create():
  85. completion = _make_completion(
  86. '{"high_level_keywords":["AI"],"low_level_keywords":["RAG"]}'
  87. )
  88. fake_client = _make_fake_client(completion)
  89. with patch(
  90. "lightrag.llm.openai.create_openai_async_client",
  91. return_value=fake_client,
  92. ):
  93. result = await openai_complete_if_cache(
  94. model="test-model",
  95. prompt="Extract keywords",
  96. response_format={"type": "json_object"},
  97. )
  98. assert result == '{"high_level_keywords":["AI"],"low_level_keywords":["RAG"]}'
  99. fake_client.chat.completions.create.assert_awaited_once()
  100. assert fake_client.chat.completions.create.await_args.kwargs["response_format"] == {
  101. "type": "json_object"
  102. }
  103. fake_client.close.assert_awaited_once()
  104. @pytest.mark.offline
  105. @pytest.mark.asyncio
  106. async def test_legacy_entity_extraction_emits_deprecation_warning():
  107. completion = _make_completion('{"entities":[],"relationships":[]}')
  108. fake_client = _make_fake_client(completion)
  109. with patch(
  110. "lightrag.llm.openai.create_openai_async_client",
  111. return_value=fake_client,
  112. ):
  113. with pytest.warns(DeprecationWarning):
  114. await openai_complete_if_cache(
  115. model="test-model",
  116. prompt="Extract entities",
  117. entity_extraction=True,
  118. )
  119. fake_client.chat.completions.create.assert_awaited_once()
  120. assert fake_client.chat.completions.create.await_args.kwargs["response_format"] == {
  121. "type": "json_object"
  122. }
  123. @pytest.mark.offline
  124. @pytest.mark.asyncio
  125. async def test_legacy_keyword_extraction_emits_deprecation_warning():
  126. completion = _make_completion('{"high_level_keywords":[],"low_level_keywords":[]}')
  127. fake_client = _make_fake_client(completion)
  128. with patch(
  129. "lightrag.llm.openai.create_openai_async_client",
  130. return_value=fake_client,
  131. ):
  132. with pytest.warns(DeprecationWarning):
  133. await openai_complete_if_cache(
  134. model="test-model",
  135. prompt="Extract keywords",
  136. keyword_extraction=True,
  137. )
  138. fake_client.chat.completions.create.assert_awaited_once()
  139. assert fake_client.chat.completions.create.await_args.kwargs["response_format"] == {
  140. "type": "json_object"
  141. }
  142. @pytest.mark.offline
  143. @pytest.mark.asyncio
  144. async def test_typed_response_format_is_rejected():
  145. completion = _make_completion("{}")
  146. fake_client = _make_fake_client(completion)
  147. class FakeSchemaModel:
  148. pass
  149. with patch(
  150. "lightrag.llm.openai.create_openai_async_client",
  151. return_value=fake_client,
  152. ):
  153. with pytest.raises(TypeError, match="typed/Pydantic"):
  154. await openai_complete_if_cache(
  155. model="test-model",
  156. prompt="Extract entities",
  157. response_format=FakeSchemaModel,
  158. )
  159. fake_client.chat.completions.create.assert_not_awaited()
  160. fake_client.close.assert_not_awaited()
  161. @pytest.mark.offline
  162. @pytest.mark.asyncio
  163. async def test_streaming_structured_output_disables_cot():
  164. fake_stream = _FakeAsyncStream(
  165. [
  166. _make_stream_chunk(reasoning_content="this should not be included"),
  167. _make_stream_chunk(content='{"answer":"ok"}'),
  168. ]
  169. )
  170. fake_client = _make_fake_client(fake_stream)
  171. with patch(
  172. "lightrag.llm.openai.create_openai_async_client",
  173. return_value=fake_client,
  174. ):
  175. stream = await openai_complete_if_cache(
  176. model="test-model",
  177. prompt="Extract entities",
  178. stream=True,
  179. enable_cot=True,
  180. response_format={"type": "json_object"},
  181. )
  182. chunks = []
  183. async for chunk in stream:
  184. chunks.append(chunk)
  185. assert "".join(chunks) == '{"answer":"ok"}'
  186. fake_client.close.assert_awaited_once()