diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d197af --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.DS_Store +.idea +.mypy_cache diff --git a/libs/ibm/.mypy_cache/missing_stubs b/libs/ibm/.mypy_cache/missing_stubs deleted file mode 100644 index 23dfa73..0000000 --- a/libs/ibm/.mypy_cache/missing_stubs +++ /dev/null @@ -1,5 +0,0 @@ -types-PyYAML -types-Pygments -types-colorama -types-psutil -types-setuptools diff --git a/libs/ibm/langchain_ibm/chat_models.py b/libs/ibm/langchain_ibm/chat_models.py index 00b7ab2..cc54721 100644 --- a/libs/ibm/langchain_ibm/chat_models.py +++ b/libs/ibm/langchain_ibm/chat_models.py @@ -1,7 +1,7 @@ import json import logging import os -import re +from datetime import datetime from operator import itemgetter from typing import ( Any, @@ -26,6 +26,7 @@ from langchain_core.language_models import LanguageModelInput from langchain_core.language_models.chat_models import ( BaseChatModel, + LangSmithParams, generate_from_stream, ) from langchain_core.messages import ( @@ -39,8 +40,10 @@ FunctionMessageChunk, HumanMessage, HumanMessageChunk, + InvalidToolCall, SystemMessage, SystemMessageChunk, + ToolCall, ToolMessage, ToolMessageChunk, convert_to_messages, @@ -50,8 +53,6 @@ from langchain_core.output_parsers.openai_tools import ( JsonOutputKeyToolsParser, PydanticToolsParser, - make_invalid_tool_call, - parse_tool_call, ) from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.prompt_values import ChatPromptValue @@ -67,11 +68,12 @@ logger = logging.getLogger(__name__) -def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: +def _convert_dict_to_message(_dict: Mapping[str, Any], call_id: str) -> BaseMessage: """Convert a dictionary to a LangChain message. Args: _dict: The dictionary. + call_id: call id Returns: The LangChain message. @@ -82,34 +84,35 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: else: additional_kwargs: Dict = {} tool_calls = [] - invalid_tool_calls = [] - try: - content = "" - - raw_tool_calls = _dict.get("generated_text") - if raw_tool_calls: - json_parts = re.split(r"\n\n(?:\n\n)?", raw_tool_calls) - parsed_raw_tool_calls = [ - json.loads(part) for part in json_parts if part.strip() - ] - additional_kwargs["tool_calls"] = parsed_raw_tool_calls - additional_kwargs["function_call"] = dict(parsed_raw_tool_calls) - - for obj in parsed_raw_tool_calls: - b = json.dumps(obj["function"]["arguments"]) - obj["function"]["arguments"] = b - - for raw_tool_call in parsed_raw_tool_calls: - try: - raw_tool_call["id"] = "None" - tool_calls.append( - parse_tool_call(raw_tool_call, return_id=True) - ) - except Exception as e: - invalid_tool_calls.append( - dict(make_invalid_tool_call(raw_tool_call, str(e))) - ) - except: # noqa: E722 + invalid_tool_calls: List[InvalidToolCall] = [] + content = "" + + raw_tool_calls = _dict.get("generated_text", "") + + if "json" in raw_tool_calls: + try: + split_raw_tool_calls = raw_tool_calls.split("\n\n") + for raw_tool_call in split_raw_tool_calls: + if "json" in raw_tool_call: + json_parts = JsonOutputParser().parse(raw_tool_call) + + if json_parts["function"]["name"] == "Final Answer": + content = json_parts["function"]["arguments"]["output"] + break + + additional_kwargs["tool_calls"] = json_parts + + parsed = { + "name": json_parts["function"]["name"] or "", + "args": json_parts["function"]["arguments"] or {}, + "id": call_id, + } + tool_calls.append(parsed) + + except: # noqa: E722 + content = _dict.get("generated_text", "") or "" + + else: content = _dict.get("generated_text", "") or "" return AIMessage( @@ -120,6 +123,50 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: ) +def _format_message_content(content: Any) -> Any: + """Format message content.""" + if content and isinstance(content, list): + # Remove unexpected block types + formatted_content = [] + for block in content: + if ( + isinstance(block, dict) + and "type" in block + and block["type"] == "tool_use" + ): + continue + else: + formatted_content.append(block) + else: + formatted_content = content + + return formatted_content + + +def _lc_tool_call_to_openai_tool_call(tool_call: ToolCall) -> dict: + return { + "type": "function", + "id": tool_call["id"], + "function": { + "name": tool_call["name"], + "arguments": json.dumps(tool_call["args"]), + }, + } + + +def _lc_invalid_tool_call_to_openai_tool_call( + invalid_tool_call: InvalidToolCall, +) -> dict: + return { + "type": "function", + "id": invalid_tool_call["id"], + "function": { + "name": invalid_tool_call["name"], + "arguments": invalid_tool_call["args"], + }, + } + + def _convert_message_to_dict(message: BaseMessage) -> dict: """Convert a LangChain message to a dictionary. @@ -129,55 +176,73 @@ def _convert_message_to_dict(message: BaseMessage) -> dict: Returns: The dictionary. """ - message_dict: Dict[str, Any] + message_dict: Dict[str, Any] = {"content": _format_message_content(message.content)} + if (name := message.name or message.additional_kwargs.get("name")) is not None: + message_dict["name"] = name + + # populate role and additional message data if isinstance(message, ChatMessage): - message_dict = {"role": message.role, "content": message.content} + message_dict["role"] = message.role elif isinstance(message, HumanMessage): - message_dict = {"role": "user", "content": message.content} + message_dict["role"] = "user" elif isinstance(message, AIMessage): - message_dict = {"role": "assistant", "content": message.content} + message_dict["role"] = "assistant" if "function_call" in message.additional_kwargs: message_dict["function_call"] = message.additional_kwargs["function_call"] - # If function call only, content is None not empty string - if message_dict["content"] == "": - message_dict["content"] = None - if "tool_calls" in message.additional_kwargs: + if message.tool_calls or message.invalid_tool_calls: + message_dict["tool_calls"] = [ + _lc_tool_call_to_openai_tool_call(tc) for tc in message.tool_calls + ] + [ + _lc_invalid_tool_call_to_openai_tool_call(tc) + for tc in message.invalid_tool_calls + ] + elif "tool_calls" in message.additional_kwargs: message_dict["tool_calls"] = message.additional_kwargs["tool_calls"] - # If tool calls only, content is None not empty string - if message_dict["content"] == "": - message_dict["content"] = None + tool_call_supported_props = {"id", "type", "function"} + message_dict["tool_calls"] = [ + {k: v for k, v in tool_call.items() if k in tool_call_supported_props} + for tool_call in message_dict["tool_calls"] + ] + else: + pass + # If tool calls present, content null value should be None not empty string. + if "function_call" in message_dict or "tool_calls" in message_dict: + message_dict["content"] = message_dict["content"] or "" + message_dict["tool_calls"][0]["name"] = message_dict["tool_calls"][0][ + "function" + ]["name"] + message_dict["tool_calls"][0]["args"] = json.loads( + message_dict["tool_calls"][0]["function"]["arguments"] + ) + elif isinstance(message, SystemMessage): - message_dict = {"role": "system", "content": message.content} + message_dict["role"] = "system" elif isinstance(message, FunctionMessage): - message_dict = { - "role": "function", - "content": message.content, - "name": message.name, - } + message_dict["role"] = "function" elif isinstance(message, ToolMessage): - message_dict = { - "role": "tool", - "content": message.content, - "tool_call_id": "None", - } + message_dict["role"] = "tool" + message_dict["tool_call_id"] = message.tool_call_id + + supported_props = {"content", "role", "tool_call_id"} + message_dict = {k: v for k, v in message_dict.items() if k in supported_props} else: raise TypeError(f"Got unknown type {message}") - if "name" in message.additional_kwargs: - message_dict["name"] = message.additional_kwargs["name"] return message_dict def _convert_delta_to_message_chunk( _dict: Mapping[str, Any], default_class: Type[BaseMessageChunk] ) -> BaseMessageChunk: + id_ = "sample_id" role = cast(str, _dict.get("role")) - content = cast(str, _dict.get("content") or "") + content = cast(str, _dict.get("generated_text") or "") additional_kwargs: Dict = {} if _dict.get("function_call"): function_call = dict(_dict["function_call"]) if "name" in function_call and function_call["name"] is None: function_call["name"] = "" additional_kwargs["function_call"] = function_call + tool_call_chunks = [] if raw_tool_calls := _dict.get("tool_calls"): additional_kwargs["tool_calls"] = raw_tool_calls try: @@ -192,27 +257,28 @@ def _convert_delta_to_message_chunk( ] except KeyError: pass - else: - tool_call_chunks = [] if role == "user" or default_class == HumanMessageChunk: - return HumanMessageChunk(content=content) + return HumanMessageChunk(content=content, id=id_) elif role == "assistant" or default_class == AIMessageChunk: return AIMessageChunk( content=content, additional_kwargs=additional_kwargs, - tool_call_chunks=tool_call_chunks, + id=id_, + tool_call_chunks=tool_call_chunks, # type: ignore[arg-type] ) elif role == "system" or default_class == SystemMessageChunk: - return SystemMessageChunk(content=content) + return SystemMessageChunk(content=content, id=id_) elif role == "function" or default_class == FunctionMessageChunk: - return FunctionMessageChunk(content=content, name=_dict["name"]) + return FunctionMessageChunk(content=content, name=_dict["name"], id=id_) elif role == "tool" or default_class == ToolMessageChunk: - return ToolMessageChunk(content=content, tool_call_id=_dict["tool_call_id"]) + return ToolMessageChunk( + content=content, tool_call_id=_dict["tool_call_id"], id=id_ + ) elif role or default_class == ChatMessageChunk: - return ChatMessageChunk(content=content, role=role) + return ChatMessageChunk(content=content, role=role, id=id_) else: - return default_class(content=content) # type: ignore + return default_class(content=content, id=id_) # type: ignore class _FunctionCall(TypedDict): @@ -310,8 +376,18 @@ def is_lc_serializable(cls) -> bool: @property def _llm_type(self) -> str: + """Return type of chat model.""" return "watsonx-chat" + def _get_ls_params( + self, stop: Optional[List[str]] = None, **kwargs: Any + ) -> LangSmithParams: + """Get standard params for tracing.""" + params = super()._get_ls_params(stop=stop, **kwargs) + params["ls_provider"] = "together" + params["ls_model_name"] = self.model_id + return params + @property def lc_secrets(self) -> Dict[str, str]: """A map of constructor argument names to secret ids. @@ -429,39 +505,77 @@ def _generate( return generate_from_stream(stream_iter) message_dicts, params = self._create_message_dicts(messages, stop, **kwargs) - chat_prompt = self._create_chat_prompt(message_dicts) + if message_dicts[-1].get("role") == "tool": + chat_prompt = ( + "User: Please summarize given sentences into " + "JSON containing Final Answer: '" + ) + for message in message_dicts: + if message["content"]: + chat_prompt += message["content"] + "\n" + chat_prompt += "'" + else: + chat_prompt = self._create_chat_prompt(message_dicts) tools = kwargs.get("tools") if tools: - chat_prompt = f"""[AVAILABLE_TOOLS] + chat_prompt = f""" +You are Mixtral Chat function calling, an AI language model developed by Mistral AI. +You are a cautious assistant. You carefully follow instructions. You are helpful and +harmless and you follow ethical guidelines and promote positive behavior. Here are a +few of the tools available to you: +[AVAILABLE_TOOLS] {json.dumps(tools[0], indent=2)} [/AVAILABLE_TOOLS] -[INST]<>You are Mixtral Chat function calling, an AI language model developed by -Mistral AI. You are a cautious assistant. You carefully follow instructions. You are -helpful and harmless and you follow ethical guidelines and promote positive behavior. -<> - To use these tools you must always respond in JSON format containing `"type"` and `"function"` key-value pairs. Also `"function"` key-value pair always containing -`"name"` and `"arguments"` key-value pairs. - -Between subsequent JSONs should be one blank line. - -Remember, even when answering to the user, you must still use this only JSON format! - -{chat_prompt}[/INST]""" - - if "tools" in kwargs: - del kwargs["tools"] - if "tool_choice" in kwargs: - del kwargs["tool_choice"] - - if "params" in kwargs: - del kwargs["params"] +`"name"` and `"arguments"` key-value pairs. For example, to answer the question, +"What is a length of word think?" you must use the get_word_length tool like so: + +```json +{{ + "type": "function", + "function": {{ + "name": "get_word_length", + "arguments": {{ + "word": "think" + }} + }} +}} +``` + + +Remember, even when answering to the user, you must still use this JSON format! +If you'd like to ask how the user is doing you must write: + +```json +{{ + "type": "function", + "function": {{ + "name": "Final Answer", + "arguments": {{ + "output": "How are you today?" + }} + }} +}} +``` + + +Remember to end your response with '' + +{chat_prompt} +(reminder to respond in a JSON blob no matter what and use tools only if necessary)""" + + params = params | {"stop_sequences": [""]} + + if "tools" in kwargs: + del kwargs["tools"] + if "tool_choice" in kwargs: + del kwargs["tool_choice"] response = self.watsonx_model.generate( - prompt=chat_prompt, params=params, **kwargs + prompt=chat_prompt, **(kwargs | {"params": params}) ) return self._create_chat_result(response) @@ -472,11 +586,80 @@ def _stream( run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any, ) -> Iterator[ChatGenerationChunk]: - message_dicts, params = self._create_message_dicts(messages, stop) - chat_prompt = self._create_chat_prompt(message_dicts) + message_dicts, params = self._create_message_dicts(messages, stop, **kwargs) + if message_dicts[-1].get("role") == "tool": + chat_prompt = ( + "User: Please summarize given sentences into JSON " + "containing Final Answer: '" + ) + for message in message_dicts: + if message["content"]: + chat_prompt += message["content"] + "\n" + chat_prompt += "'" + else: + chat_prompt = self._create_chat_prompt(message_dicts) + + tools = kwargs.get("tools") + + if tools: + chat_prompt = f""" +You are Mixtral Chat function calling, an AI language model developed by Mistral AI. +You are a cautious assistant. You carefully follow instructions. You are helpful and +harmless and you follow ethical guidelines and promote positive behavior. Here are a +few of the tools available to you: +[AVAILABLE_TOOLS] +{json.dumps(tools[0], indent=2)} +[/AVAILABLE_TOOLS] +To use these tools you must always respond in JSON format containing `"type"` and +`"function"` key-value pairs. Also `"function"` key-value pair always containing +`"name"` and `"arguments"` key-value pairs. For example, to answer the question, +"What is a length of word think?" you must use the get_word_length tool like so: + +```json +{{ + "type": "function", + "function": {{ + "name": "get_word_length", + "arguments": {{ + "word": "think" + }} + }} +}} +``` + + +Remember, even when answering to the user, you must still use this JSON format! +If you'd like to ask how the user is doing you must write: + +```json +{{ + "type": "function", + "function": {{ + "name": "Final Answer", + "arguments": {{ + "output": "How are you today?" + }} + }} +}} +``` + + +Remember to end your response with '' + +{chat_prompt[:-5]} +(reminder to respond in a JSON blob no matter what and use tools only if necessary)""" + + params = params | {"stop_sequences": [""]} + + if "tools" in kwargs: + del kwargs["tools"] + if "tool_choice" in kwargs: + del kwargs["tool_choice"] + + default_chunk_class: Type[BaseMessageChunk] = AIMessageChunk for chunk in self.watsonx_model.generate_text_stream( - prompt=chat_prompt, raw_response=True, params=params, **kwargs + prompt=chat_prompt, raw_response=True, **(kwargs | {"params": params}) ): if not isinstance(chunk, dict): chunk = chunk.dict() @@ -484,9 +667,7 @@ def _stream( continue choice = chunk["results"][0] - chunk = AIMessageChunk( - content=choice["generated_text"], - ) + message_chunk = _convert_delta_to_message_chunk(choice, default_chunk_class) generation_info = {} if finish_reason := choice.get("stop_reason"): generation_info["finish_reason"] = finish_reason @@ -494,10 +675,12 @@ def _stream( if logprobs: generation_info["logprobs"] = logprobs chunk = ChatGenerationChunk( - message=chunk, generation_info=generation_info or None + message=message_chunk, generation_info=generation_info or None ) if run_manager: - run_manager.on_llm_new_token(chunk.text, chunk=chunk, logprobs=logprobs) + run_manager.on_llm_new_token( + chunk.content, chunk=chunk, logprobs=logprobs + ) yield chunk @@ -532,7 +715,9 @@ def _create_chat_prompt(self, messages: List[Dict[str, Any]]) -> str: prompt += message["content"] + "\n[/INST]\n" else: - prompt = ChatPromptValue(messages=convert_to_messages(messages)).to_string() + prompt = ChatPromptValue( + messages=convert_to_messages(messages) + [AIMessage(content="")] + ).to_string() return prompt @@ -554,12 +739,17 @@ def _create_chat_result(self, response: Union[dict]) -> ChatResult: generations = [] sum_of_total_generated_tokens = 0 sum_of_total_input_tokens = 0 + call_id = "" + date_string = response.get("created_at") + if date_string: + date_object = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%fZ") + call_id = str(date_object.timestamp()) if response.get("error"): raise ValueError(response.get("error")) for res in response["results"]: - message = _convert_dict_to_message(res) + message = _convert_dict_to_message(res, call_id) generation_info = dict(finish_reason=res.get("stop_reason")) if "logprobs" in res: generation_info["logprobs"] = res["logprobs"] @@ -567,6 +757,13 @@ def _create_chat_result(self, response: Union[dict]) -> ChatResult: sum_of_total_generated_tokens += res["generated_token_count"] if "input_token_count" in res: sum_of_total_input_tokens += res["input_token_count"] + total_token = sum_of_total_generated_tokens + sum_of_total_input_tokens + if total_token and isinstance(message, AIMessage): + message.usage_metadata = { + "input_tokens": sum_of_total_input_tokens, + "output_tokens": sum_of_total_generated_tokens, + "total_tokens": total_token, + } gen = ChatGeneration( message=message, generation_info=generation_info, @@ -832,7 +1029,8 @@ class AnswerWithJustification(BaseModel): llm = self.bind_tools([schema], tool_choice=True) if is_pydantic_schema: output_parser: OutputParserLike = PydanticToolsParser( - tools=[schema], first_tool_only=True + tools=[schema], # type: ignore[list-item] + first_tool_only=True, # type: ignore[list-item] ) else: key_name = convert_to_openai_tool(schema)["function"]["name"] @@ -842,7 +1040,7 @@ class AnswerWithJustification(BaseModel): elif method == "json_mode": llm = self.bind(response_format={"type": "json_object"}) output_parser = ( - PydanticOutputParser(pydantic_object=schema) + PydanticOutputParser(pydantic_object=schema) # type: ignore[type-var, arg-type] if is_pydantic_schema else JsonOutputParser() ) diff --git a/libs/ibm/langchain_ibm/llms.py b/libs/ibm/langchain_ibm/llms.py index 039aa02..5ed9874 100644 --- a/libs/ibm/langchain_ibm/llms.py +++ b/libs/ibm/langchain_ibm/llms.py @@ -266,10 +266,15 @@ def get_count_value(key: str, result: Dict[str, Any]) -> int: } def _get_chat_params( - self, stop: Optional[List[str]] = None + self, stop: Optional[List[str]] = None, **kwargs: Any ) -> Optional[Dict[str, Any]]: - params: Optional[Dict[str, Any]] = {**self.params} if self.params else None + params = {**self.params} if self.params else {} + params = params | {**kwargs.get("params", {})} if stop is not None: + if params and "stop_sequences" in params: + raise ValueError( + "`stop_sequences` found in both the input and default params." + ) params = (params or {}) | {"stop_sequences": stop} return params @@ -355,7 +360,7 @@ def _generate( response = watsonx_llm.generate(["What is a molecule"]) """ - params = self._get_chat_params(stop=stop) + params = self._get_chat_params(stop=stop, **kwargs) should_stream = stream if stream is not None else self.streaming if should_stream: if len(prompts) > 1: @@ -378,7 +383,7 @@ def _generate( return LLMResult(generations=[[generation]]) else: response = self.watsonx_model.generate( - prompt=prompts, params=params, **kwargs + prompt=prompts, **(kwargs | {"params": params}) ) return self._create_llm_result(response) @@ -403,9 +408,9 @@ def _stream( for chunk in response: print(chunk, end='') """ - params = self._get_chat_params(stop=stop) + params = self._get_chat_params(stop=stop, **kwargs) for stream_resp in self.watsonx_model.generate_text_stream( - prompt=prompt, raw_response=True, params=params, **kwargs + prompt=prompt, raw_response=True, **(kwargs | {"params": params}) ): if not isinstance(stream_resp, dict): stream_resp = stream_resp.dict() diff --git a/libs/ibm/poetry.lock b/libs/ibm/poetry.lock index 25fea0e..7e7e09b 100644 --- a/libs/ibm/poetry.lock +++ b/libs/ibm/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -328,7 +328,7 @@ files = [ [[package]] name = "langchain-core" -version = "0.2.11" +version = "0.2.16" description = "Building applications with LLMs through composability" optional = false python-versions = ">=3.8.1,<4.0" @@ -350,18 +350,18 @@ tenacity = "^8.1.0,!=8.4.0" type = "git" url = "https://github.com/langchain-ai/langchain.git" reference = "HEAD" -resolved_reference = "4d6f28cdde42b6bd0542d8d123f5737146cbc171" +resolved_reference = "7014d07cab704bde291ad8057c0e619762ab2bbd" subdirectory = "libs/core" [[package]] name = "langsmith" -version = "0.1.84" +version = "0.1.85" description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." optional = false python-versions = "<4.0,>=3.8.1" files = [ - {file = "langsmith-0.1.84-py3-none-any.whl", hash = "sha256:01f3c6390dba26c583bac8dd0e551ce3d0509c7f55cad714db0b5c8d36e4c7ff"}, - {file = "langsmith-0.1.84.tar.gz", hash = "sha256:5220c0439838b9a5bd320fd3686be505c5083dcee22d2452006c23891153bea1"}, + {file = "langsmith-0.1.85-py3-none-any.whl", hash = "sha256:c1f94384f10cea96f7b4d33fd3db7ec180c03c7468877d50846f881d2017ff94"}, + {file = "langsmith-0.1.85.tar.gz", hash = "sha256:acff31f9e53efa48586cf8e32f65625a335c74d7c4fa306d1655ac18452296f6"}, ] [package.dependencies] @@ -891,7 +891,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1040,13 +1039,13 @@ files = [ [[package]] name = "types-requests" -version = "2.32.0.20240622" +version = "2.32.0.20240712" description = "Typing stubs for requests" optional = false python-versions = ">=3.8" files = [ - {file = "types-requests-2.32.0.20240622.tar.gz", hash = "sha256:ed5e8a412fcc39159d6319385c009d642845f250c63902718f605cd90faade31"}, - {file = "types_requests-2.32.0.20240622-py3-none-any.whl", hash = "sha256:97bac6b54b5bd4cf91d407e62f0932a74821bc2211f22116d9ee1dd643826caf"}, + {file = "types-requests-2.32.0.20240712.tar.gz", hash = "sha256:90c079ff05e549f6bf50e02e910210b98b8ff1ebdd18e19c873cd237737c1358"}, + {file = "types_requests-2.32.0.20240712-py3-none-any.whl", hash = "sha256:f754283e152c752e46e70942fa2a146b5bc70393522257bb85bd1ef7e019dcc3"}, ] [package.dependencies] diff --git a/libs/ibm/pyproject.toml b/libs/ibm/pyproject.toml index 195ebc2..ed1578e 100644 --- a/libs/ibm/pyproject.toml +++ b/libs/ibm/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langchain-ibm" -version = "0.1.9rc0" +version = "0.1.10" description = "An integration package connecting IBM watsonx.ai and LangChain" authors = ["IBM"] readme = "README.md" diff --git a/libs/ibm/tests/integration_tests/test_chat_models.py b/libs/ibm/tests/integration_tests/test_chat_models.py index 887ce84..0e45138 100644 --- a/libs/ibm/tests/integration_tests/test_chat_models.py +++ b/libs/ibm/tests/integration_tests/test_chat_models.py @@ -1,9 +1,12 @@ import json import os +from typing import Any +import pytest from ibm_watsonx_ai.metanames import GenTextParamsMetaNames # type: ignore from langchain_core.messages import ( AIMessage, + AIMessageChunk, BaseMessage, HumanMessage, SystemMessage, @@ -31,6 +34,7 @@ def test_01_generate_chat() -> None: ] response = chat.invoke(messages) assert response + assert response.content def test_01a_generate_chat_with_invoke_params() -> None: @@ -50,6 +54,32 @@ def test_01a_generate_chat_with_invoke_params() -> None: ] response = chat.invoke(messages, params=params) assert response + assert response.content + + +def test_01b_generate_chat_with_invoke_params() -> None: + from ibm_watsonx_ai.metanames import GenTextParamsMetaNames + + parameters_1 = { + GenTextParamsMetaNames.DECODING_METHOD: "sample", + GenTextParamsMetaNames.MAX_NEW_TOKENS: 10, + } + parameters_2 = { + GenTextParamsMetaNames.MIN_NEW_TOKENS: 5, + } + chat = ChatWatsonx( + model_id=MODEL_ID, url=URL, project_id=WX_PROJECT_ID, params=parameters_1 + ) + messages = [ + ("system", "You are a helpful assistant that translates English to French."), + ( + "human", + "Translate this sentence from English to French. I love programming.", + ), + ] + response = chat.invoke(messages, params=parameters_2) + assert response + assert response.content def test_02_generate_chat_with_few_inputs() -> None: @@ -57,6 +87,8 @@ def test_02_generate_chat_with_few_inputs() -> None: message = HumanMessage(content="Hello") response = chat.generate([[message], [message]]) assert response + for generation in response.generations: + assert generation[0].text def test_03_generate_chat_with_few_various_inputs() -> None: @@ -64,7 +96,9 @@ def test_03_generate_chat_with_few_various_inputs() -> None: system_message = SystemMessage(content="You are to chat with the user.") human_message = HumanMessage(content="Hello") response = chat.invoke([system_message, human_message]) + assert response assert isinstance(response, BaseMessage) + assert response.content assert isinstance(response.content, str) @@ -75,6 +109,52 @@ def test_05_generate_chat_with_stream() -> None: assert isinstance(chunk.content, str) +def test_05_generate_chat_with_stream_with_param() -> None: + from ibm_watsonx_ai.metanames import GenTextParamsMetaNames + + params = { + GenTextParamsMetaNames.MIN_NEW_TOKENS: 1, + GenTextParamsMetaNames.MAX_NEW_TOKENS: 10, + } + chat = ChatWatsonx( + model_id=MODEL_ID, url=URL, project_id=WX_PROJECT_ID, params=params + ) + response = chat.stream("What's the weather in san francisco") + for chunk in response: + assert isinstance(chunk.content, str) + + +def test_05_generate_chat_with_stream_with_param_v2() -> None: + from ibm_watsonx_ai.metanames import GenTextParamsMetaNames + + params = { + GenTextParamsMetaNames.MIN_NEW_TOKENS: 1, + GenTextParamsMetaNames.MAX_NEW_TOKENS: 10, + } + chat = ChatWatsonx(model_id=MODEL_ID, url=URL, project_id=WX_PROJECT_ID) + response = chat.stream("What's the weather in san francisco", params=params) + for chunk in response: + assert isinstance(chunk.content, str) + + +def test_06_chain_invoke() -> None: + chat = ChatWatsonx( + model_id=MODEL_ID, + url=URL, # type: ignore[arg-type] + project_id=WX_PROJECT_ID, + ) + + system = "You are a helpful assistant." + human = "{text}" + prompt = ChatPromptTemplate.from_messages([("system", system), ("human", human)]) + + chain = prompt | chat + response = chain.invoke({"text": "Explain the importance of low latency for LLMs."}) + + assert response + assert response.content + + def test_10_chaining() -> None: chat = ChatWatsonx(model_id=MODEL_ID, url=URL, project_id=WX_PROJECT_ID) prompt = ChatPromptTemplate.from_messages( @@ -97,6 +177,7 @@ def test_10_chaining() -> None: } ) assert response + assert response.content def test_11_chaining_with_params() -> None: @@ -128,6 +209,7 @@ def test_11_chaining_with_params() -> None: } ) assert response + assert response.content def test_20_tool_choice() -> None: @@ -139,29 +221,22 @@ def test_20_tool_choice() -> None: model_id=MODEL_ID, url=URL, project_id=WX_PROJECT_ID, params=params ) - class MyTool(BaseModel): + class Person(BaseModel): name: str age: int - with_tool = chat.bind_tools([MyTool], tool_choice="MyTool") + with_tool = chat.bind_tools([Person]) - resp = with_tool.invoke("Who was the 27 year old named Erick?") - assert isinstance(resp, AIMessage) - assert resp.content == "" # should just be tool call - tool_calls = resp.additional_kwargs["tool_calls"] - assert len(tool_calls) == 1 - tool_call = tool_calls[0] - assert tool_call["function"]["name"] == "MyTool" - assert json.loads(tool_call["function"]["arguments"]) == { + result = with_tool.invoke("Erick, 27 years old") + assert isinstance(result, AIMessage) + assert result.content == "" # should just be tool call + assert len(result.tool_calls) == 1 + tool_call = result.tool_calls[0] + assert tool_call["name"] == "Person" + assert tool_call["args"] == { "age": 27, "name": "Erick", } - assert tool_call["type"] == "function" - assert isinstance(resp.tool_calls, list) - assert len(resp.tool_calls) == 1 - tool_call = resp.tool_calls[0] - assert tool_call["name"] == "MyTool" - assert tool_call["args"] == {"age": 27, "name": "Erick"} def test_21_tool_choice_bool() -> None: @@ -173,21 +248,113 @@ def test_21_tool_choice_bool() -> None: model_id=MODEL_ID, url=URL, project_id=WX_PROJECT_ID, params=params ) - class MyTool(BaseModel): + class Person(BaseModel): name: str age: int - with_tool = chat.bind_tools([MyTool], tool_choice=True) + with_tool = chat.bind_tools([Person], tool_choice=True) - resp = with_tool.invoke("Who was the 27 year old named Erick?") - assert isinstance(resp, AIMessage) - assert resp.content == "" # should just be tool call - tool_calls = resp.additional_kwargs["tool_calls"] - assert len(tool_calls) == 1 - tool_call = tool_calls[0] - assert tool_call["function"]["name"] == "MyTool" - assert json.loads(tool_call["function"]["arguments"]) == { + result = with_tool.invoke("Erick, 27 years old") + assert isinstance(result, AIMessage) + assert result.content == "" # should just be tool call + tool_call = result.tool_calls[0] + assert tool_call["name"] == "Person" + assert tool_call["args"] == { "age": 27, "name": "Erick", } - assert tool_call["type"] == "function" + + +def test_22_tool_invoke() -> None: + """Test that tool choice is respected just passing in True.""" + from ibm_watsonx_ai.metanames import GenTextParamsMetaNames + + params = {GenTextParamsMetaNames.MAX_NEW_TOKENS: 500} + chat = ChatWatsonx( + model_id=MODEL_ID, + url=URL, # type: ignore[arg-type] + project_id=WX_PROJECT_ID, + params=params, # type: ignore[arg-type] + ) + from langchain_core.tools import tool + + @tool + def add(a: int, b: int) -> int: + """Adds a and b.""" + return a + b + + @tool + def multiply(a: int, b: int) -> int: + """Multiplies a and b.""" + return a * b + + @tool + def get_word_length(word: str) -> int: + """Get word length.""" + return len(word) + + tools = [add, multiply, get_word_length] + + chat_with_tools = chat.bind_tools(tools) + + query = "What is 3 + 12? What is 3 + 10?" + resp = chat_with_tools.invoke(query) + + assert resp.content == "" + + query = "Who was the famous painter from Italy?" + resp = chat_with_tools.invoke(query) + + assert resp.content + + +@pytest.mark.skip(reason="Not implemented") +def test_streaming_tool_call() -> None: + from ibm_watsonx_ai.metanames import GenTextParamsMetaNames + + params = {GenTextParamsMetaNames.MAX_NEW_TOKENS: 500} + chat = ChatWatsonx( + model_id=MODEL_ID, + url=URL, # type: ignore[arg-type] + project_id=WX_PROJECT_ID, + params=params, # type: ignore[arg-type] + ) + + class Person(BaseModel): + name: str + age: int + + tool_llm = chat.bind_tools([Person]) + + # where it calls the tool + strm = tool_llm.stream("Erick, 27 years old") + + additional_kwargs = None + for chunk in strm: + assert isinstance(chunk, AIMessageChunk) + assert chunk.content == "" + additional_kwargs = chunk.additional_kwargs + + assert additional_kwargs is not None + assert "tool_calls" in additional_kwargs + assert len(additional_kwargs["tool_calls"]) == 1 + assert additional_kwargs["tool_calls"][0]["function"]["name"] == "Person" + assert json.loads(additional_kwargs["tool_calls"][0]["function"]["arguments"]) == { + "name": "Erick", + "age": 27, + } + + assert isinstance(chunk, AIMessageChunk) + assert len(chunk.tool_call_chunks) == 1 + tool_call_chunk = chunk.tool_call_chunks[0] + assert tool_call_chunk["name"] == "Person" + assert tool_call_chunk["args"] == '{"name": "Erick", "age": 27}' + + # where it doesn't call the tool + strm = tool_llm.stream("What is 2+2?") + acc: Any = None + for chunk in strm: + assert isinstance(chunk, AIMessageChunk) + acc = chunk if acc is None else acc + chunk + assert acc.content != "" + assert "tool_calls" not in acc.additional_kwargs diff --git a/libs/ibm/tests/integration_tests/test_llms.py b/libs/ibm/tests/integration_tests/test_llms.py index 96e742d..cf0a39e 100644 --- a/libs/ibm/tests/integration_tests/test_llms.py +++ b/libs/ibm/tests/integration_tests/test_llms.py @@ -24,7 +24,7 @@ def test_watsonxllm_invoke() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = watsonxllm.invoke("What color sunflower is?") @@ -42,7 +42,7 @@ def test_watsonxllm_invoke_with_params() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, params=parameters, ) @@ -52,10 +52,49 @@ def test_watsonxllm_invoke_with_params() -> None: assert len(response) > 0 +def test_watsonxllm_invoke_with_params_2() -> None: + parameters = { + GenTextParamsMetaNames.DECODING_METHOD: "sample", + GenTextParamsMetaNames.MAX_NEW_TOKENS: 10, + GenTextParamsMetaNames.MIN_NEW_TOKENS: 5, + } + + watsonxllm = WatsonxLLM( + model_id=MODEL_ID, + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] + project_id=WX_PROJECT_ID, + ) + response = watsonxllm.invoke("What color sunflower is?", params=parameters) + print(f"\nResponse: {response}") + assert isinstance(response, str) + assert len(response) > 0 + + +def test_watsonxllm_invoke_with_params_3() -> None: + parameters_1 = { + GenTextParamsMetaNames.DECODING_METHOD: "sample", + GenTextParamsMetaNames.MAX_NEW_TOKENS: 10, + } + parameters_2 = { + GenTextParamsMetaNames.MIN_NEW_TOKENS: 5, + } + + watsonxllm = WatsonxLLM( + model_id=MODEL_ID, + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] + project_id=WX_PROJECT_ID, + params=parameters_1, + ) + response = watsonxllm.invoke("What color sunflower is?", params=parameters_2) + print(f"\nResponse: {response}") + assert isinstance(response, str) + assert len(response) > 0 + + def test_watsonxllm_generate() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = watsonxllm.generate(["What color sunflower is?"]) @@ -66,10 +105,29 @@ def test_watsonxllm_generate() -> None: assert len(response_text) > 0 +def test_watsonxllm_generate_with_param() -> None: + parameters = { + GenTextParamsMetaNames.DECODING_METHOD: "sample", + GenTextParamsMetaNames.MAX_NEW_TOKENS: 10, + GenTextParamsMetaNames.MIN_NEW_TOKENS: 5, + } + watsonxllm = WatsonxLLM( + model_id=MODEL_ID, + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] + project_id=WX_PROJECT_ID, + ) + response = watsonxllm.generate(["What color sunflower is?"], params=parameters) + print(f"\nResponse: {response}") + response_text = response.generations[0][0].text + print(f"Response text: {response_text}") + assert isinstance(response, LLMResult) + assert len(response_text) > 0 + + def test_watsonxllm_generate_with_multiple_prompts() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = watsonxllm.generate( @@ -85,7 +143,7 @@ def test_watsonxllm_generate_with_multiple_prompts() -> None: def test_watsonxllm_generate_stream() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = watsonxllm.generate(["What color sunflower is?"], stream=True) @@ -99,7 +157,7 @@ def test_watsonxllm_generate_stream() -> None: def test_watsonxllm_stream() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = watsonxllm.invoke("What color sunflower is?") @@ -204,7 +262,7 @@ def test_watsonxllm_invoke_from_wx_model_inference_with_params_as_enum() -> None async def test_watsonx_ainvoke() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = await watsonxllm.ainvoke("What color sunflower is?") @@ -214,7 +272,7 @@ async def test_watsonx_ainvoke() -> None: async def test_watsonx_agenerate() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) response = await watsonxllm.agenerate( @@ -227,7 +285,7 @@ async def test_watsonx_agenerate() -> None: def test_get_num_tokens() -> None: watsonxllm = WatsonxLLM( model_id=MODEL_ID, - url="https://us-south.ml.cloud.ibm.com", + url="https://us-south.ml.cloud.ibm.com", # type: ignore[arg-type] project_id=WX_PROJECT_ID, ) num_tokens = watsonxllm.get_num_tokens("What color sunflower is?")