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

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109
  1. import asyncio
  2. import json
  3. import os
  4. from typing import Annotated, Any, AsyncGenerator, Dict, Generic, List, Literal, Tuple, TypeVar
  5. from unittest.mock import MagicMock
  6. import httpx
  7. import pytest
  8. from autogen_core import CancellationToken, FunctionCall, Image
  9. from autogen_core.models import (
  10. AssistantMessage,
  11. CreateResult,
  12. FunctionExecutionResult,
  13. FunctionExecutionResultMessage,
  14. LLMMessage,
  15. ModelInfo,
  16. RequestUsage,
  17. SystemMessage,
  18. UserMessage,
  19. )
  20. from autogen_core.models._model_client import ModelFamily
  21. from autogen_core.tools import BaseTool, FunctionTool
  22. from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient
  23. from autogen_ext.models.openai._model_info import resolve_model
  24. from autogen_ext.models.openai._openai_client import calculate_vision_tokens, convert_tools, to_oai_type
  25. from openai.resources.beta.chat.completions import AsyncCompletions as BetaAsyncCompletions
  26. from openai.resources.chat.completions import AsyncCompletions
  27. from openai.types.chat.chat_completion import ChatCompletion, Choice
  28. from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, ChoiceDelta
  29. from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
  30. from openai.types.chat.chat_completion_message import ChatCompletionMessage
  31. from openai.types.chat.chat_completion_message_tool_call import (
  32. ChatCompletionMessageToolCall,
  33. Function,
  34. )
  35. from openai.types.chat.parsed_chat_completion import ParsedChatCompletion, ParsedChatCompletionMessage, ParsedChoice
  36. from openai.types.completion_usage import CompletionUsage
  37. from pydantic import BaseModel, Field
  38. class _MockChatCompletion:
  39. def __init__(self, chat_completions: List[ChatCompletion]) -> None:
  40. self._saved_chat_completions = chat_completions
  41. self.curr_index = 0
  42. self.calls: List[Dict[str, Any]] = []
  43. async def mock_create(
  44. self, *args: Any, **kwargs: Any
  45. ) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  46. self.calls.append(kwargs) # Save the call
  47. await asyncio.sleep(0.1)
  48. completion = self._saved_chat_completions[self.curr_index]
  49. self.curr_index += 1
  50. return completion
  51. ResponseFormatT = TypeVar("ResponseFormatT", bound=BaseModel)
  52. class _MockBetaChatCompletion(Generic[ResponseFormatT]):
  53. def __init__(self, chat_completions: List[ParsedChatCompletion[ResponseFormatT]]) -> None:
  54. self._saved_chat_completions = chat_completions
  55. self.curr_index = 0
  56. self.calls: List[Dict[str, Any]] = []
  57. async def mock_parse(
  58. self,
  59. *args: Any,
  60. **kwargs: Any,
  61. ) -> ParsedChatCompletion[ResponseFormatT]:
  62. self.calls.append(kwargs) # Save the call
  63. await asyncio.sleep(0.1)
  64. completion = self._saved_chat_completions[self.curr_index]
  65. self.curr_index += 1
  66. return completion
  67. def _pass_function(input: str) -> str:
  68. return "pass"
  69. async def _fail_function(input: str) -> str:
  70. return "fail"
  71. async def _echo_function(input: str) -> str:
  72. return input
  73. class MyResult(BaseModel):
  74. result: str = Field(description="The other description.")
  75. class MyArgs(BaseModel):
  76. query: str = Field(description="The description.")
  77. class MockChunkDefinition(BaseModel):
  78. # defining elements for diffentiating mocking chunks
  79. chunk_choice: ChunkChoice
  80. usage: CompletionUsage | None
  81. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  82. model = resolve_model(kwargs.get("model", "gpt-4o"))
  83. mock_chunks_content = ["Hello", " Another Hello", " Yet Another Hello"]
  84. # The openai api implementations (OpenAI and Litellm) stream chunks of tokens
  85. # with content as string, and then at the end a token with stop set and finally if
  86. # usage requested with `"stream_options": {"include_usage": True}` a chunk with the usage data
  87. mock_chunks = [
  88. # generate the list of mock chunk content
  89. MockChunkDefinition(
  90. chunk_choice=ChunkChoice(
  91. finish_reason=None,
  92. index=0,
  93. delta=ChoiceDelta(
  94. content=mock_chunk_content,
  95. role="assistant",
  96. ),
  97. ),
  98. usage=None,
  99. )
  100. for mock_chunk_content in mock_chunks_content
  101. ] + [
  102. # generate the stop chunk
  103. MockChunkDefinition(
  104. chunk_choice=ChunkChoice(
  105. finish_reason="stop",
  106. index=0,
  107. delta=ChoiceDelta(
  108. content=None,
  109. role="assistant",
  110. ),
  111. ),
  112. usage=None,
  113. )
  114. ]
  115. # generate the usage chunk if configured
  116. if kwargs.get("stream_options", {}).get("include_usage") is True:
  117. mock_chunks = mock_chunks + [
  118. # ---- API differences
  119. # OPENAI API does NOT create a choice
  120. # LITELLM (proxy) DOES create a choice
  121. # Not simulating all the API options, just implementing the LITELLM variant
  122. MockChunkDefinition(
  123. chunk_choice=ChunkChoice(
  124. finish_reason=None,
  125. index=0,
  126. delta=ChoiceDelta(
  127. content=None,
  128. role="assistant",
  129. ),
  130. ),
  131. usage=CompletionUsage(prompt_tokens=3, completion_tokens=3, total_tokens=6),
  132. )
  133. ]
  134. elif kwargs.get("stream_options", {}).get("include_usage") is False:
  135. pass
  136. else:
  137. pass
  138. for mock_chunk in mock_chunks:
  139. await asyncio.sleep(0.1)
  140. yield ChatCompletionChunk(
  141. id="id",
  142. choices=[mock_chunk.chunk_choice],
  143. created=0,
  144. model=model,
  145. object="chat.completion.chunk",
  146. usage=mock_chunk.usage,
  147. )
  148. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  149. stream = kwargs.get("stream", False)
  150. model = resolve_model(kwargs.get("model", "gpt-4o"))
  151. if not stream:
  152. await asyncio.sleep(0.1)
  153. return ChatCompletion(
  154. id="id",
  155. choices=[
  156. Choice(finish_reason="stop", index=0, message=ChatCompletionMessage(content="Hello", role="assistant"))
  157. ],
  158. created=0,
  159. model=model,
  160. object="chat.completion",
  161. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  162. )
  163. else:
  164. return _mock_create_stream(*args, **kwargs)
  165. @pytest.mark.asyncio
  166. async def test_openai_chat_completion_client() -> None:
  167. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  168. assert client
  169. @pytest.mark.asyncio
  170. async def test_openai_chat_completion_client_with_gemini_model() -> None:
  171. client = OpenAIChatCompletionClient(model="gemini-1.5-flash", api_key="api_key")
  172. assert client
  173. @pytest.mark.asyncio
  174. async def test_openai_chat_completion_client_raise_on_unknown_model() -> None:
  175. with pytest.raises(ValueError, match="model_info is required"):
  176. _ = OpenAIChatCompletionClient(model="unknown", api_key="api_key")
  177. @pytest.mark.asyncio
  178. async def test_custom_model_with_capabilities() -> None:
  179. with pytest.raises(ValueError, match="model_info is required"):
  180. client = OpenAIChatCompletionClient(model="dummy_model", base_url="https://api.dummy.com/v0", api_key="api_key")
  181. client = OpenAIChatCompletionClient(
  182. model="dummy_model",
  183. base_url="https://api.dummy.com/v0",
  184. api_key="api_key",
  185. model_info={"vision": False, "function_calling": False, "json_output": False, "family": ModelFamily.UNKNOWN},
  186. )
  187. assert client
  188. @pytest.mark.asyncio
  189. async def test_azure_openai_chat_completion_client() -> None:
  190. client = AzureOpenAIChatCompletionClient(
  191. azure_deployment="gpt-4o-1",
  192. model="gpt-4o",
  193. api_key="api_key",
  194. api_version="2020-08-04",
  195. azure_endpoint="https://dummy.com",
  196. model_info={"vision": True, "function_calling": True, "json_output": True, "family": ModelFamily.GPT_4O},
  197. )
  198. assert client
  199. @pytest.mark.asyncio
  200. async def test_openai_chat_completion_client_create(monkeypatch: pytest.MonkeyPatch) -> None:
  201. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  202. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  203. result = await client.create(messages=[UserMessage(content="Hello", source="user")])
  204. assert result.content == "Hello"
  205. @pytest.mark.asyncio
  206. async def test_openai_chat_completion_client_create_stream_with_usage(monkeypatch: pytest.MonkeyPatch) -> None:
  207. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  208. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  209. chunks: List[str | CreateResult] = []
  210. async for chunk in client.create_stream(
  211. messages=[UserMessage(content="Hello", source="user")],
  212. # include_usage not the default of the OPENAI API and must be explicitly set
  213. extra_create_args={"stream_options": {"include_usage": True}},
  214. ):
  215. chunks.append(chunk)
  216. assert chunks[0] == "Hello"
  217. assert chunks[1] == " Another Hello"
  218. assert chunks[2] == " Yet Another Hello"
  219. assert isinstance(chunks[-1], CreateResult)
  220. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  221. assert chunks[-1].usage == RequestUsage(prompt_tokens=3, completion_tokens=3)
  222. @pytest.mark.asyncio
  223. async def test_openai_chat_completion_client_create_stream_no_usage_default(monkeypatch: pytest.MonkeyPatch) -> None:
  224. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  225. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  226. chunks: List[str | CreateResult] = []
  227. async for chunk in client.create_stream(
  228. messages=[UserMessage(content="Hello", source="user")],
  229. # include_usage not the default of the OPENAI APIis ,
  230. # it can be explicitly set
  231. # or just not declared which is the default
  232. # extra_create_args={"stream_options": {"include_usage": False}},
  233. ):
  234. chunks.append(chunk)
  235. assert chunks[0] == "Hello"
  236. assert chunks[1] == " Another Hello"
  237. assert chunks[2] == " Yet Another Hello"
  238. assert isinstance(chunks[-1], CreateResult)
  239. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  240. assert chunks[-1].usage == RequestUsage(prompt_tokens=0, completion_tokens=0)
  241. @pytest.mark.asyncio
  242. async def test_openai_chat_completion_client_create_stream_no_usage_explicit(monkeypatch: pytest.MonkeyPatch) -> None:
  243. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  244. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  245. chunks: List[str | CreateResult] = []
  246. async for chunk in client.create_stream(
  247. messages=[UserMessage(content="Hello", source="user")],
  248. # include_usage is not the default of the OPENAI API ,
  249. # it can be explicitly set
  250. # or just not declared which is the default
  251. extra_create_args={"stream_options": {"include_usage": False}},
  252. ):
  253. chunks.append(chunk)
  254. assert chunks[0] == "Hello"
  255. assert chunks[1] == " Another Hello"
  256. assert chunks[2] == " Yet Another Hello"
  257. assert isinstance(chunks[-1], CreateResult)
  258. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  259. assert chunks[-1].usage == RequestUsage(prompt_tokens=0, completion_tokens=0)
  260. @pytest.mark.asyncio
  261. async def test_openai_chat_completion_client_create_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
  262. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  263. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  264. cancellation_token = CancellationToken()
  265. task = asyncio.create_task(
  266. client.create(messages=[UserMessage(content="Hello", source="user")], cancellation_token=cancellation_token)
  267. )
  268. cancellation_token.cancel()
  269. with pytest.raises(asyncio.CancelledError):
  270. await task
  271. @pytest.mark.asyncio
  272. async def test_openai_chat_completion_client_create_stream_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
  273. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  274. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  275. cancellation_token = CancellationToken()
  276. stream = client.create_stream(
  277. messages=[UserMessage(content="Hello", source="user")], cancellation_token=cancellation_token
  278. )
  279. assert await anext(stream)
  280. cancellation_token.cancel()
  281. with pytest.raises(asyncio.CancelledError):
  282. async for _ in stream:
  283. pass
  284. @pytest.mark.asyncio
  285. async def test_openai_chat_completion_client_count_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
  286. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  287. messages: List[LLMMessage] = [
  288. SystemMessage(content="Hello"),
  289. UserMessage(content="Hello", source="user"),
  290. AssistantMessage(content="Hello", source="assistant"),
  291. UserMessage(
  292. content=[
  293. "str1",
  294. Image.from_base64(
  295. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
  296. ),
  297. ],
  298. source="user",
  299. ),
  300. FunctionExecutionResultMessage(content=[FunctionExecutionResult(content="Hello", call_id="1", is_error=False)]),
  301. ]
  302. def tool1(test: str, test2: str) -> str:
  303. return test + test2
  304. def tool2(test1: int, test2: List[int]) -> str:
  305. return str(test1) + str(test2)
  306. tools = [FunctionTool(tool1, description="example tool 1"), FunctionTool(tool2, description="example tool 2")]
  307. mockcalculate_vision_tokens = MagicMock()
  308. monkeypatch.setattr("autogen_ext.models.openai._openai_client.calculate_vision_tokens", mockcalculate_vision_tokens)
  309. num_tokens = client.count_tokens(messages, tools=tools)
  310. assert num_tokens
  311. # Check that calculate_vision_tokens was called
  312. mockcalculate_vision_tokens.assert_called_once()
  313. remaining_tokens = client.remaining_tokens(messages, tools=tools)
  314. assert remaining_tokens
  315. @pytest.mark.parametrize(
  316. "mock_size, expected_num_tokens",
  317. [
  318. ((1, 1), 255),
  319. ((512, 512), 255),
  320. ((2048, 512), 765),
  321. ((2048, 2048), 765),
  322. ((512, 1024), 425),
  323. ],
  324. )
  325. def test_openai_count_image_tokens(mock_size: Tuple[int, int], expected_num_tokens: int) -> None:
  326. # Step 1: Mock the Image class with only the 'image' attribute
  327. mock_image_attr = MagicMock()
  328. mock_image_attr.size = mock_size
  329. mock_image = MagicMock()
  330. mock_image.image = mock_image_attr
  331. # Directly call calculate_vision_tokens and check the result
  332. calculated_tokens = calculate_vision_tokens(mock_image, detail="auto")
  333. assert calculated_tokens == expected_num_tokens
  334. def test_convert_tools_accepts_both_func_tool_and_schema() -> None:
  335. def my_function(arg: str, other: Annotated[int, "int arg"], nonrequired: int = 5) -> MyResult:
  336. return MyResult(result="test")
  337. tool = FunctionTool(my_function, description="Function tool.")
  338. schema = tool.schema
  339. converted_tool_schema = convert_tools([tool, schema])
  340. assert len(converted_tool_schema) == 2
  341. assert converted_tool_schema[0] == converted_tool_schema[1]
  342. def test_convert_tools_accepts_both_tool_and_schema() -> None:
  343. class MyTool(BaseTool[MyArgs, MyResult]):
  344. def __init__(self) -> None:
  345. super().__init__(
  346. args_type=MyArgs,
  347. return_type=MyResult,
  348. name="TestTool",
  349. description="Description of test tool.",
  350. )
  351. async def run(self, args: MyArgs, cancellation_token: CancellationToken) -> MyResult:
  352. return MyResult(result="value")
  353. tool = MyTool()
  354. schema = tool.schema
  355. converted_tool_schema = convert_tools([tool, schema])
  356. assert len(converted_tool_schema) == 2
  357. assert converted_tool_schema[0] == converted_tool_schema[1]
  358. @pytest.mark.asyncio
  359. async def test_structured_output(monkeypatch: pytest.MonkeyPatch) -> None:
  360. class AgentResponse(BaseModel):
  361. thoughts: str
  362. response: Literal["happy", "sad", "neutral"]
  363. model = "gpt-4o-2024-11-20"
  364. chat_completions: List[ParsedChatCompletion[AgentResponse]] = [
  365. ParsedChatCompletion(
  366. id="id1",
  367. choices=[
  368. ParsedChoice(
  369. finish_reason="stop",
  370. index=0,
  371. message=ParsedChatCompletionMessage(
  372. content=json.dumps(
  373. {
  374. "thoughts": "The user explicitly states that they are happy without any indication of sadness or neutrality.",
  375. "response": "happy",
  376. }
  377. ),
  378. role="assistant",
  379. ),
  380. )
  381. ],
  382. created=0,
  383. model=model,
  384. object="chat.completion",
  385. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  386. ),
  387. ]
  388. mock = _MockBetaChatCompletion(chat_completions)
  389. monkeypatch.setattr(BetaAsyncCompletions, "parse", mock.mock_parse)
  390. model_client = OpenAIChatCompletionClient(
  391. model=model,
  392. api_key="",
  393. response_format=AgentResponse, # type: ignore
  394. )
  395. # Test that the openai client was called with the correct response format.
  396. create_result = await model_client.create(messages=[UserMessage(content="I am happy.", source="user")])
  397. assert isinstance(create_result.content, str)
  398. response = AgentResponse.model_validate(json.loads(create_result.content))
  399. assert (
  400. response.thoughts
  401. == "The user explicitly states that they are happy without any indication of sadness or neutrality."
  402. )
  403. assert response.response == "happy"
  404. @pytest.mark.asyncio
  405. async def test_r1_think_field(monkeypatch: pytest.MonkeyPatch) -> None:
  406. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  407. chunks = ["<think> Hello</think>", " Another Hello", " Yet Another Hello"]
  408. for i, chunk in enumerate(chunks):
  409. await asyncio.sleep(0.1)
  410. yield ChatCompletionChunk(
  411. id="id",
  412. choices=[
  413. ChunkChoice(
  414. finish_reason="stop" if i == len(chunks) - 1 else None,
  415. index=0,
  416. delta=ChoiceDelta(
  417. content=chunk,
  418. role="assistant",
  419. ),
  420. ),
  421. ],
  422. created=0,
  423. model="r1",
  424. object="chat.completion.chunk",
  425. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  426. )
  427. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  428. stream = kwargs.get("stream", False)
  429. if not stream:
  430. await asyncio.sleep(0.1)
  431. return ChatCompletion(
  432. id="id",
  433. choices=[
  434. Choice(
  435. finish_reason="stop",
  436. index=0,
  437. message=ChatCompletionMessage(
  438. content="<think> Hello</think> Another Hello Yet Another Hello", role="assistant"
  439. ),
  440. )
  441. ],
  442. created=0,
  443. model="r1",
  444. object="chat.completion",
  445. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  446. )
  447. else:
  448. return _mock_create_stream(*args, **kwargs)
  449. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  450. model_client = OpenAIChatCompletionClient(
  451. model="r1",
  452. api_key="",
  453. model_info={"family": ModelFamily.R1, "vision": False, "function_calling": False, "json_output": False},
  454. )
  455. # Successful completion with think field.
  456. create_result = await model_client.create(messages=[UserMessage(content="I am happy.", source="user")])
  457. assert create_result.content == "Another Hello Yet Another Hello"
  458. assert create_result.finish_reason == "stop"
  459. assert not create_result.cached
  460. assert create_result.thought == "Hello"
  461. # Stream completion with think field.
  462. chunks: List[str | CreateResult] = []
  463. async for chunk in model_client.create_stream(messages=[UserMessage(content="Hello", source="user")]):
  464. chunks.append(chunk)
  465. assert len(chunks) > 0
  466. assert isinstance(chunks[-1], CreateResult)
  467. assert chunks[-1].content == "Another Hello Yet Another Hello"
  468. assert chunks[-1].thought == "Hello"
  469. assert not chunks[-1].cached
  470. @pytest.mark.asyncio
  471. async def test_r1_think_field_not_present(monkeypatch: pytest.MonkeyPatch) -> None:
  472. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  473. chunks = ["Hello", " Another Hello", " Yet Another Hello"]
  474. for i, chunk in enumerate(chunks):
  475. await asyncio.sleep(0.1)
  476. yield ChatCompletionChunk(
  477. id="id",
  478. choices=[
  479. ChunkChoice(
  480. finish_reason="stop" if i == len(chunks) - 1 else None,
  481. index=0,
  482. delta=ChoiceDelta(
  483. content=chunk,
  484. role="assistant",
  485. ),
  486. ),
  487. ],
  488. created=0,
  489. model="r1",
  490. object="chat.completion.chunk",
  491. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  492. )
  493. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  494. stream = kwargs.get("stream", False)
  495. if not stream:
  496. await asyncio.sleep(0.1)
  497. return ChatCompletion(
  498. id="id",
  499. choices=[
  500. Choice(
  501. finish_reason="stop",
  502. index=0,
  503. message=ChatCompletionMessage(
  504. content="Hello Another Hello Yet Another Hello", role="assistant"
  505. ),
  506. )
  507. ],
  508. created=0,
  509. model="r1",
  510. object="chat.completion",
  511. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  512. )
  513. else:
  514. return _mock_create_stream(*args, **kwargs)
  515. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  516. model_client = OpenAIChatCompletionClient(
  517. model="r1",
  518. api_key="",
  519. model_info={"family": ModelFamily.R1, "vision": False, "function_calling": False, "json_output": False},
  520. )
  521. # Warning completion when think field is not present.
  522. with pytest.warns(UserWarning, match="Could not find <think>..</think> field in model response content."):
  523. create_result = await model_client.create(messages=[UserMessage(content="I am happy.", source="user")])
  524. assert create_result.content == "Hello Another Hello Yet Another Hello"
  525. assert create_result.finish_reason == "stop"
  526. assert not create_result.cached
  527. assert create_result.thought is None
  528. # Stream completion with think field.
  529. with pytest.warns(UserWarning, match="Could not find <think>..</think> field in model response content."):
  530. chunks: List[str | CreateResult] = []
  531. async for chunk in model_client.create_stream(messages=[UserMessage(content="Hello", source="user")]):
  532. chunks.append(chunk)
  533. assert len(chunks) > 0
  534. assert isinstance(chunks[-1], CreateResult)
  535. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  536. assert chunks[-1].thought is None
  537. assert not chunks[-1].cached
  538. @pytest.mark.asyncio
  539. async def test_tool_calling(monkeypatch: pytest.MonkeyPatch) -> None:
  540. model = "gpt-4o-2024-05-13"
  541. chat_completions = [
  542. # Successful completion, single tool call
  543. ChatCompletion(
  544. id="id1",
  545. choices=[
  546. Choice(
  547. finish_reason="tool_calls",
  548. index=0,
  549. message=ChatCompletionMessage(
  550. content=None,
  551. tool_calls=[
  552. ChatCompletionMessageToolCall(
  553. id="1",
  554. type="function",
  555. function=Function(
  556. name="_pass_function",
  557. arguments=json.dumps({"input": "task"}),
  558. ),
  559. )
  560. ],
  561. role="assistant",
  562. ),
  563. )
  564. ],
  565. created=0,
  566. model=model,
  567. object="chat.completion",
  568. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  569. ),
  570. # Successful completion, parallel tool calls
  571. ChatCompletion(
  572. id="id2",
  573. choices=[
  574. Choice(
  575. finish_reason="tool_calls",
  576. index=0,
  577. message=ChatCompletionMessage(
  578. content=None,
  579. tool_calls=[
  580. ChatCompletionMessageToolCall(
  581. id="1",
  582. type="function",
  583. function=Function(
  584. name="_pass_function",
  585. arguments=json.dumps({"input": "task"}),
  586. ),
  587. ),
  588. ChatCompletionMessageToolCall(
  589. id="2",
  590. type="function",
  591. function=Function(
  592. name="_fail_function",
  593. arguments=json.dumps({"input": "task"}),
  594. ),
  595. ),
  596. ChatCompletionMessageToolCall(
  597. id="3",
  598. type="function",
  599. function=Function(
  600. name="_echo_function",
  601. arguments=json.dumps({"input": "task"}),
  602. ),
  603. ),
  604. ],
  605. role="assistant",
  606. ),
  607. )
  608. ],
  609. created=0,
  610. model=model,
  611. object="chat.completion",
  612. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  613. ),
  614. # Warning completion when finish reason is not tool_calls.
  615. ChatCompletion(
  616. id="id3",
  617. choices=[
  618. Choice(
  619. finish_reason="stop",
  620. index=0,
  621. message=ChatCompletionMessage(
  622. content=None,
  623. tool_calls=[
  624. ChatCompletionMessageToolCall(
  625. id="1",
  626. type="function",
  627. function=Function(
  628. name="_pass_function",
  629. arguments=json.dumps({"input": "task"}),
  630. ),
  631. )
  632. ],
  633. role="assistant",
  634. ),
  635. )
  636. ],
  637. created=0,
  638. model=model,
  639. object="chat.completion",
  640. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  641. ),
  642. # Warning completion when content is not None.
  643. ChatCompletion(
  644. id="id4",
  645. choices=[
  646. Choice(
  647. finish_reason="tool_calls",
  648. index=0,
  649. message=ChatCompletionMessage(
  650. content="I should make a tool call.",
  651. tool_calls=[
  652. ChatCompletionMessageToolCall(
  653. id="1",
  654. type="function",
  655. function=Function(
  656. name="_pass_function",
  657. arguments=json.dumps({"input": "task"}),
  658. ),
  659. )
  660. ],
  661. role="assistant",
  662. ),
  663. )
  664. ],
  665. created=0,
  666. model=model,
  667. object="chat.completion",
  668. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  669. ),
  670. # Should not be returning tool calls when the tool_calls are empty
  671. ChatCompletion(
  672. id="id5",
  673. choices=[
  674. Choice(
  675. finish_reason="stop",
  676. index=0,
  677. message=ChatCompletionMessage(
  678. content="I should make a tool call.",
  679. tool_calls=[],
  680. role="assistant",
  681. ),
  682. )
  683. ],
  684. created=0,
  685. model=model,
  686. object="chat.completion",
  687. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  688. ),
  689. # Should raise warning when function arguments is not a string.
  690. ChatCompletion(
  691. id="id6",
  692. choices=[
  693. Choice(
  694. finish_reason="tool_calls",
  695. index=0,
  696. message=ChatCompletionMessage(
  697. content=None,
  698. tool_calls=[
  699. ChatCompletionMessageToolCall(
  700. id="1",
  701. type="function",
  702. function=Function.construct(name="_pass_function", arguments={"input": "task"}), # type: ignore
  703. )
  704. ],
  705. role="assistant",
  706. ),
  707. )
  708. ],
  709. created=0,
  710. model=model,
  711. object="chat.completion",
  712. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  713. ),
  714. ]
  715. mock = _MockChatCompletion(chat_completions)
  716. monkeypatch.setattr(AsyncCompletions, "create", mock.mock_create)
  717. pass_tool = FunctionTool(_pass_function, description="pass tool.")
  718. fail_tool = FunctionTool(_fail_function, description="fail tool.")
  719. echo_tool = FunctionTool(_echo_function, description="echo tool.")
  720. model_client = OpenAIChatCompletionClient(model=model, api_key="")
  721. # Single tool call
  722. create_result = await model_client.create(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool])
  723. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  724. # Verify that the tool schema was passed to the model client.
  725. kwargs = mock.calls[0]
  726. assert kwargs["tools"] == [{"function": pass_tool.schema, "type": "function"}]
  727. # Verify finish reason
  728. assert create_result.finish_reason == "function_calls"
  729. # Parallel tool calls
  730. create_result = await model_client.create(
  731. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool, fail_tool, echo_tool]
  732. )
  733. assert create_result.content == [
  734. FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function"),
  735. FunctionCall(id="2", arguments=r'{"input": "task"}', name="_fail_function"),
  736. FunctionCall(id="3", arguments=r'{"input": "task"}', name="_echo_function"),
  737. ]
  738. # Verify that the tool schema was passed to the model client.
  739. kwargs = mock.calls[1]
  740. assert kwargs["tools"] == [
  741. {"function": pass_tool.schema, "type": "function"},
  742. {"function": fail_tool.schema, "type": "function"},
  743. {"function": echo_tool.schema, "type": "function"},
  744. ]
  745. # Verify finish reason
  746. assert create_result.finish_reason == "function_calls"
  747. # Warning completion when finish reason is not tool_calls.
  748. with pytest.warns(UserWarning, match="Finish reason mismatch"):
  749. create_result = await model_client.create(
  750. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool]
  751. )
  752. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  753. assert create_result.finish_reason == "function_calls"
  754. # Warning completion when content is not None.
  755. with pytest.warns(UserWarning, match="Both tool_calls and content are present in the message"):
  756. create_result = await model_client.create(
  757. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool]
  758. )
  759. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  760. assert create_result.finish_reason == "function_calls"
  761. # Should not be returning tool calls when the tool_calls are empty
  762. create_result = await model_client.create(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool])
  763. assert create_result.content == "I should make a tool call."
  764. assert create_result.finish_reason == "stop"
  765. # Should raise warning when function arguments is not a string.
  766. with pytest.warns(UserWarning, match="Tool call function arguments field is not a string"):
  767. create_result = await model_client.create(
  768. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool]
  769. )
  770. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  771. assert create_result.finish_reason == "function_calls"
  772. async def _test_model_client_basic_completion(model_client: OpenAIChatCompletionClient) -> None:
  773. # Test basic completion
  774. create_result = await model_client.create(
  775. messages=[
  776. SystemMessage(content="You are a helpful assistant."),
  777. UserMessage(content="Explain to me how AI works.", source="user"),
  778. ]
  779. )
  780. assert isinstance(create_result.content, str)
  781. assert len(create_result.content) > 0
  782. async def _test_model_client_with_function_calling(model_client: OpenAIChatCompletionClient) -> None:
  783. # Test tool calling
  784. pass_tool = FunctionTool(_pass_function, name="pass_tool", description="pass session.")
  785. fail_tool = FunctionTool(_fail_function, name="fail_tool", description="fail session.")
  786. messages: List[LLMMessage] = [UserMessage(content="Call the pass tool with input 'task'", source="user")]
  787. create_result = await model_client.create(messages=messages, tools=[pass_tool, fail_tool])
  788. assert isinstance(create_result.content, list)
  789. assert len(create_result.content) == 1
  790. assert isinstance(create_result.content[0], FunctionCall)
  791. assert create_result.content[0].name == "pass_tool"
  792. assert json.loads(create_result.content[0].arguments) == {"input": "task"}
  793. assert create_result.finish_reason == "function_calls"
  794. assert create_result.usage is not None
  795. # Test reflection on tool call response.
  796. messages.append(AssistantMessage(content=create_result.content, source="assistant"))
  797. messages.append(
  798. FunctionExecutionResultMessage(
  799. content=[FunctionExecutionResult(content="passed", call_id=create_result.content[0].id, is_error=False)]
  800. )
  801. )
  802. create_result = await model_client.create(messages=messages)
  803. assert isinstance(create_result.content, str)
  804. assert len(create_result.content) > 0
  805. # Test parallel tool calling
  806. messages = [
  807. UserMessage(
  808. content="Call both the pass tool with input 'task' and the fail tool also with input 'task'", source="user"
  809. )
  810. ]
  811. create_result = await model_client.create(messages=messages, tools=[pass_tool, fail_tool])
  812. assert isinstance(create_result.content, list)
  813. assert len(create_result.content) == 2
  814. assert isinstance(create_result.content[0], FunctionCall)
  815. assert create_result.content[0].name == "pass_tool"
  816. assert json.loads(create_result.content[0].arguments) == {"input": "task"}
  817. assert isinstance(create_result.content[1], FunctionCall)
  818. assert create_result.content[1].name == "fail_tool"
  819. assert json.loads(create_result.content[1].arguments) == {"input": "task"}
  820. assert create_result.finish_reason == "function_calls"
  821. assert create_result.usage is not None
  822. # Test reflection on parallel tool call response.
  823. messages.append(AssistantMessage(content=create_result.content, source="assistant"))
  824. messages.append(
  825. FunctionExecutionResultMessage(
  826. content=[
  827. FunctionExecutionResult(content="passed", call_id=create_result.content[0].id, is_error=False),
  828. FunctionExecutionResult(content="failed", call_id=create_result.content[1].id, is_error=True),
  829. ]
  830. )
  831. )
  832. create_result = await model_client.create(messages=messages)
  833. assert isinstance(create_result.content, str)
  834. assert len(create_result.content) > 0
  835. @pytest.mark.asyncio
  836. async def test_openai() -> None:
  837. api_key = os.getenv("OPENAI_API_KEY")
  838. if not api_key:
  839. pytest.skip("OPENAI_API_KEY not found in environment variables")
  840. model_client = OpenAIChatCompletionClient(
  841. model="gpt-4o-mini",
  842. api_key=api_key,
  843. )
  844. await _test_model_client_basic_completion(model_client)
  845. await _test_model_client_with_function_calling(model_client)
  846. @pytest.mark.asyncio
  847. async def test_gemini() -> None:
  848. api_key = os.getenv("GEMINI_API_KEY")
  849. if not api_key:
  850. pytest.skip("GEMINI_API_KEY not found in environment variables")
  851. model_client = OpenAIChatCompletionClient(
  852. model="gemini-1.5-flash",
  853. )
  854. await _test_model_client_basic_completion(model_client)
  855. await _test_model_client_with_function_calling(model_client)
  856. @pytest.mark.asyncio
  857. async def test_hugging_face() -> None:
  858. api_key = os.getenv("HF_TOKEN")
  859. if not api_key:
  860. pytest.skip("HF_TOKEN not found in environment variables")
  861. model_client = OpenAIChatCompletionClient(
  862. model="microsoft/Phi-3.5-mini-instruct",
  863. api_key=api_key,
  864. base_url="https://api-inference.huggingface.co/v1/",
  865. model_info={
  866. "function_calling": False,
  867. "json_output": False,
  868. "vision": False,
  869. "family": ModelFamily.UNKNOWN,
  870. },
  871. )
  872. await _test_model_client_basic_completion(model_client)
  873. @pytest.mark.asyncio
  874. async def test_ollama() -> None:
  875. model = "deepseek-r1:1.5b"
  876. model_info: ModelInfo = {
  877. "function_calling": False,
  878. "json_output": False,
  879. "vision": False,
  880. "family": ModelFamily.R1,
  881. }
  882. # Check if the model is running locally.
  883. try:
  884. async with httpx.AsyncClient() as client:
  885. response = await client.get(f"http://localhost:11434/v1/models/{model}")
  886. response.raise_for_status()
  887. except httpx.HTTPStatusError as e:
  888. pytest.skip(f"{model} model is not running locally: {e}")
  889. except httpx.ConnectError as e:
  890. pytest.skip(f"Ollama is not running locally: {e}")
  891. model_client = OpenAIChatCompletionClient(
  892. model=model,
  893. api_key="placeholder",
  894. base_url="http://localhost:11434/v1",
  895. model_info=model_info,
  896. )
  897. # Test basic completion with the Ollama deepseek-r1:1.5b model.
  898. create_result = await model_client.create(
  899. messages=[
  900. UserMessage(
  901. content="Taking two balls from a bag of 10 green balls and 20 red balls, "
  902. "what is the probability of getting a green and a red balls?",
  903. source="user",
  904. ),
  905. ]
  906. )
  907. assert isinstance(create_result.content, str)
  908. assert len(create_result.content) > 0
  909. assert create_result.finish_reason == "stop"
  910. assert create_result.usage is not None
  911. if model_info["family"] == ModelFamily.R1:
  912. assert create_result.thought is not None
  913. # Test streaming completion with the Ollama deepseek-r1:1.5b model.
  914. chunks: List[str | CreateResult] = []
  915. async for chunk in model_client.create_stream(
  916. messages=[
  917. UserMessage(
  918. content="Taking two balls from a bag of 10 green balls and 20 red balls, "
  919. "what is the probability of getting a green and a red balls?",
  920. source="user",
  921. ),
  922. ]
  923. ):
  924. chunks.append(chunk)
  925. assert len(chunks) > 0
  926. assert isinstance(chunks[-1], CreateResult)
  927. assert chunks[-1].finish_reason == "stop"
  928. assert len(chunks[-1].content) > 0
  929. assert chunks[-1].usage is not None
  930. if model_info["family"] == ModelFamily.R1:
  931. assert chunks[-1].thought is not None
  932. @pytest.mark.asyncio
  933. async def test_add_name_prefixes(monkeypatch: pytest.MonkeyPatch) -> None:
  934. sys_message = SystemMessage(content="You are a helpful AI agent, and you answer questions in a friendly way.")
  935. assistant_message = AssistantMessage(content="Hello, how can I help you?", source="Assistant")
  936. user_text_message = UserMessage(content="Hello, I am from Seattle.", source="Adam")
  937. user_mm_message = UserMessage(
  938. content=[
  939. "Here is a postcard from Seattle:",
  940. Image.from_base64(
  941. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
  942. ),
  943. ],
  944. source="Adam",
  945. )
  946. # Default conversion
  947. oai_sys = to_oai_type(sys_message)[0]
  948. oai_asst = to_oai_type(assistant_message)[0]
  949. oai_text = to_oai_type(user_text_message)[0]
  950. oai_mm = to_oai_type(user_mm_message)[0]
  951. converted_sys = to_oai_type(sys_message, prepend_name=True)[0]
  952. converted_asst = to_oai_type(assistant_message, prepend_name=True)[0]
  953. converted_text = to_oai_type(user_text_message, prepend_name=True)[0]
  954. converted_mm = to_oai_type(user_mm_message, prepend_name=True)[0]
  955. # Invariants
  956. assert "content" in oai_sys
  957. assert "content" in oai_asst
  958. assert "content" in oai_text
  959. assert "content" in oai_mm
  960. assert "content" in converted_sys
  961. assert "content" in converted_asst
  962. assert "content" in converted_text
  963. assert "content" in converted_mm
  964. assert oai_sys["role"] == converted_sys["role"]
  965. assert oai_sys["content"] == converted_sys["content"]
  966. assert oai_asst["role"] == converted_asst["role"]
  967. assert oai_asst["content"] == converted_asst["content"]
  968. assert oai_text["role"] == converted_text["role"]
  969. assert oai_mm["role"] == converted_mm["role"]
  970. assert isinstance(oai_mm["content"], list)
  971. assert isinstance(converted_mm["content"], list)
  972. assert len(oai_mm["content"]) == len(converted_mm["content"])
  973. assert "text" in converted_mm["content"][0]
  974. assert "text" in oai_mm["content"][0]
  975. # Name prepended
  976. assert str(converted_text["content"]) == "Adam said:\n" + str(oai_text["content"])
  977. assert str(converted_mm["content"][0]["text"]) == "Adam said:\n" + str(oai_mm["content"][0]["text"])
  978. # TODO: add integration tests for Azure OpenAI using AAD token.