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_openai_model_client.py 13 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import asyncio
  2. from typing import Annotated, Any, AsyncGenerator, List, Tuple
  3. from unittest.mock import MagicMock
  4. import pytest
  5. from autogen_core import CancellationToken, Image
  6. from autogen_core.models import (
  7. AssistantMessage,
  8. CreateResult,
  9. FunctionExecutionResult,
  10. FunctionExecutionResultMessage,
  11. LLMMessage,
  12. RequestUsage,
  13. SystemMessage,
  14. UserMessage,
  15. )
  16. from autogen_core.tools import BaseTool, FunctionTool
  17. from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient
  18. from autogen_ext.models.openai._model_info import resolve_model
  19. from autogen_ext.models.openai._openai_client import calculate_vision_tokens, convert_tools
  20. from openai.resources.chat.completions import AsyncCompletions
  21. from openai.types.chat.chat_completion import ChatCompletion, Choice
  22. from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, ChoiceDelta
  23. from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
  24. from openai.types.chat.chat_completion_message import ChatCompletionMessage
  25. from openai.types.completion_usage import CompletionUsage
  26. from pydantic import BaseModel, Field
  27. class MyResult(BaseModel):
  28. result: str = Field(description="The other description.")
  29. class MyArgs(BaseModel):
  30. query: str = Field(description="The description.")
  31. class MockChunkDefinition(BaseModel):
  32. # defining elements for diffentiating mocking chunks
  33. chunk_choice: ChunkChoice
  34. usage: CompletionUsage | None
  35. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  36. model = resolve_model(kwargs.get("model", "gpt-4o"))
  37. mock_chunks_content = ["Hello", " Another Hello", " Yet Another Hello"]
  38. # The openai api implementations (OpenAI and Litellm) stream chunks of tokens
  39. # with content as string, and then at the end a token with stop set and finally if
  40. # usage requested with `"stream_options": {"include_usage": True}` a chunk with the usage data
  41. mock_chunks = [
  42. # generate the list of mock chunk content
  43. MockChunkDefinition(
  44. chunk_choice=ChunkChoice(
  45. finish_reason=None,
  46. index=0,
  47. delta=ChoiceDelta(
  48. content=mock_chunk_content,
  49. role="assistant",
  50. ),
  51. ),
  52. usage=None,
  53. )
  54. for mock_chunk_content in mock_chunks_content
  55. ] + [
  56. # generate the stop chunk
  57. MockChunkDefinition(
  58. chunk_choice=ChunkChoice(
  59. finish_reason="stop",
  60. index=0,
  61. delta=ChoiceDelta(
  62. content=None,
  63. role="assistant",
  64. ),
  65. ),
  66. usage=None,
  67. )
  68. ]
  69. # generate the usage chunk if configured
  70. if kwargs.get("stream_options", {}).get("include_usage") is True:
  71. mock_chunks = mock_chunks + [
  72. # ---- API differences
  73. # OPENAI API does NOT create a choice
  74. # LITELLM (proxy) DOES create a choice
  75. # Not simulating all the API options, just implementing the LITELLM variant
  76. MockChunkDefinition(
  77. chunk_choice=ChunkChoice(
  78. finish_reason=None,
  79. index=0,
  80. delta=ChoiceDelta(
  81. content=None,
  82. role="assistant",
  83. ),
  84. ),
  85. usage=CompletionUsage(prompt_tokens=3, completion_tokens=3, total_tokens=6),
  86. )
  87. ]
  88. elif kwargs.get("stream_options", {}).get("include_usage") is False:
  89. pass
  90. else:
  91. pass
  92. for mock_chunk in mock_chunks:
  93. await asyncio.sleep(0.1)
  94. yield ChatCompletionChunk(
  95. id="id",
  96. choices=[mock_chunk.chunk_choice],
  97. created=0,
  98. model=model,
  99. object="chat.completion.chunk",
  100. usage=mock_chunk.usage,
  101. )
  102. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  103. stream = kwargs.get("stream", False)
  104. model = resolve_model(kwargs.get("model", "gpt-4o"))
  105. if not stream:
  106. await asyncio.sleep(0.1)
  107. return ChatCompletion(
  108. id="id",
  109. choices=[
  110. Choice(finish_reason="stop", index=0, message=ChatCompletionMessage(content="Hello", role="assistant"))
  111. ],
  112. created=0,
  113. model=model,
  114. object="chat.completion",
  115. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  116. )
  117. else:
  118. return _mock_create_stream(*args, **kwargs)
  119. @pytest.mark.asyncio
  120. async def test_openai_chat_completion_client() -> None:
  121. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  122. assert client
  123. @pytest.mark.asyncio
  124. async def test_azure_openai_chat_completion_client() -> None:
  125. client = AzureOpenAIChatCompletionClient(
  126. azure_deployment="gpt-4o-1",
  127. model="gpt-4o",
  128. api_key="api_key",
  129. api_version="2020-08-04",
  130. azure_endpoint="https://dummy.com",
  131. model_capabilities={"vision": True, "function_calling": True, "json_output": True},
  132. )
  133. assert client
  134. @pytest.mark.asyncio
  135. async def test_openai_chat_completion_client_create(monkeypatch: pytest.MonkeyPatch) -> None:
  136. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  137. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  138. result = await client.create(messages=[UserMessage(content="Hello", source="user")])
  139. assert result.content == "Hello"
  140. @pytest.mark.asyncio
  141. async def test_openai_chat_completion_client_create_stream_with_usage(monkeypatch: pytest.MonkeyPatch) -> None:
  142. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  143. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  144. chunks: List[str | CreateResult] = []
  145. async for chunk in client.create_stream(
  146. messages=[UserMessage(content="Hello", source="user")],
  147. # include_usage not the default of the OPENAI API and must be explicitly set
  148. extra_create_args={"stream_options": {"include_usage": True}},
  149. ):
  150. chunks.append(chunk)
  151. assert chunks[0] == "Hello"
  152. assert chunks[1] == " Another Hello"
  153. assert chunks[2] == " Yet Another Hello"
  154. assert isinstance(chunks[-1], CreateResult)
  155. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  156. assert chunks[-1].usage == RequestUsage(prompt_tokens=3, completion_tokens=3)
  157. @pytest.mark.asyncio
  158. async def test_openai_chat_completion_client_create_stream_no_usage_default(monkeypatch: pytest.MonkeyPatch) -> None:
  159. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  160. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  161. chunks: List[str | CreateResult] = []
  162. async for chunk in client.create_stream(
  163. messages=[UserMessage(content="Hello", source="user")],
  164. # include_usage not the default of the OPENAI APIis ,
  165. # it can be explicitly set
  166. # or just not declared which is the default
  167. # extra_create_args={"stream_options": {"include_usage": False}},
  168. ):
  169. chunks.append(chunk)
  170. assert chunks[0] == "Hello"
  171. assert chunks[1] == " Another Hello"
  172. assert chunks[2] == " Yet Another Hello"
  173. assert isinstance(chunks[-1], CreateResult)
  174. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  175. assert chunks[-1].usage == RequestUsage(prompt_tokens=0, completion_tokens=0)
  176. @pytest.mark.asyncio
  177. async def test_openai_chat_completion_client_create_stream_no_usage_explicit(monkeypatch: pytest.MonkeyPatch) -> None:
  178. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  179. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  180. chunks: List[str | CreateResult] = []
  181. async for chunk in client.create_stream(
  182. messages=[UserMessage(content="Hello", source="user")],
  183. # include_usage is not the default of the OPENAI API ,
  184. # it can be explicitly set
  185. # or just not declared which is the default
  186. extra_create_args={"stream_options": {"include_usage": False}},
  187. ):
  188. chunks.append(chunk)
  189. assert chunks[0] == "Hello"
  190. assert chunks[1] == " Another Hello"
  191. assert chunks[2] == " Yet Another Hello"
  192. assert isinstance(chunks[-1], CreateResult)
  193. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  194. assert chunks[-1].usage == RequestUsage(prompt_tokens=0, completion_tokens=0)
  195. @pytest.mark.asyncio
  196. async def test_openai_chat_completion_client_create_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
  197. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  198. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  199. cancellation_token = CancellationToken()
  200. task = asyncio.create_task(
  201. client.create(messages=[UserMessage(content="Hello", source="user")], cancellation_token=cancellation_token)
  202. )
  203. cancellation_token.cancel()
  204. with pytest.raises(asyncio.CancelledError):
  205. await task
  206. @pytest.mark.asyncio
  207. async def test_openai_chat_completion_client_create_stream_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
  208. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  209. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  210. cancellation_token = CancellationToken()
  211. stream = client.create_stream(
  212. messages=[UserMessage(content="Hello", source="user")], cancellation_token=cancellation_token
  213. )
  214. assert await anext(stream)
  215. cancellation_token.cancel()
  216. with pytest.raises(asyncio.CancelledError):
  217. async for _ in stream:
  218. pass
  219. @pytest.mark.asyncio
  220. async def test_openai_chat_completion_client_count_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
  221. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  222. messages: List[LLMMessage] = [
  223. SystemMessage(content="Hello"),
  224. UserMessage(content="Hello", source="user"),
  225. AssistantMessage(content="Hello", source="assistant"),
  226. UserMessage(
  227. content=[
  228. "str1",
  229. Image.from_base64(
  230. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
  231. ),
  232. ],
  233. source="user",
  234. ),
  235. FunctionExecutionResultMessage(content=[FunctionExecutionResult(content="Hello", call_id="1")]),
  236. ]
  237. def tool1(test: str, test2: str) -> str:
  238. return test + test2
  239. def tool2(test1: int, test2: List[int]) -> str:
  240. return str(test1) + str(test2)
  241. tools = [FunctionTool(tool1, description="example tool 1"), FunctionTool(tool2, description="example tool 2")]
  242. mockcalculate_vision_tokens = MagicMock()
  243. monkeypatch.setattr("autogen_ext.models.openai._openai_client.calculate_vision_tokens", mockcalculate_vision_tokens)
  244. num_tokens = client.count_tokens(messages, tools=tools)
  245. assert num_tokens
  246. # Check that calculate_vision_tokens was called
  247. mockcalculate_vision_tokens.assert_called_once()
  248. remaining_tokens = client.remaining_tokens(messages, tools=tools)
  249. assert remaining_tokens
  250. @pytest.mark.parametrize(
  251. "mock_size, expected_num_tokens",
  252. [
  253. ((1, 1), 255),
  254. ((512, 512), 255),
  255. ((2048, 512), 765),
  256. ((2048, 2048), 765),
  257. ((512, 1024), 425),
  258. ],
  259. )
  260. def test_openai_count_image_tokens(mock_size: Tuple[int, int], expected_num_tokens: int) -> None:
  261. # Step 1: Mock the Image class with only the 'image' attribute
  262. mock_image_attr = MagicMock()
  263. mock_image_attr.size = mock_size
  264. mock_image = MagicMock()
  265. mock_image.image = mock_image_attr
  266. # Directly call calculate_vision_tokens and check the result
  267. calculated_tokens = calculate_vision_tokens(mock_image, detail="auto")
  268. assert calculated_tokens == expected_num_tokens
  269. def test_convert_tools_accepts_both_func_tool_and_schema() -> None:
  270. def my_function(arg: str, other: Annotated[int, "int arg"], nonrequired: int = 5) -> MyResult:
  271. return MyResult(result="test")
  272. tool = FunctionTool(my_function, description="Function tool.")
  273. schema = tool.schema
  274. converted_tool_schema = convert_tools([tool, schema])
  275. assert len(converted_tool_schema) == 2
  276. assert converted_tool_schema[0] == converted_tool_schema[1]
  277. def test_convert_tools_accepts_both_tool_and_schema() -> None:
  278. class MyTool(BaseTool[MyArgs, MyResult]):
  279. def __init__(self) -> None:
  280. super().__init__(
  281. args_type=MyArgs,
  282. return_type=MyResult,
  283. name="TestTool",
  284. description="Description of test tool.",
  285. )
  286. async def run(self, args: MyArgs, cancellation_token: CancellationToken) -> MyResult:
  287. return MyResult(result="value")
  288. tool = MyTool()
  289. schema = tool.schema
  290. converted_tool_schema = convert_tools([tool, schema])
  291. assert len(converted_tool_schema) == 2
  292. assert converted_tool_schema[0] == converted_tool_schema[1]