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 14 kB

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