diff --git a/docs/docs/integrations/chat/hunyuan.ipynb b/docs/docs/integrations/chat/hunyuan.ipynb new file mode 100644 index 00000000000..07cbe1d7d87 --- /dev/null +++ b/docs/docs/integrations/chat/hunyuan.ipynb @@ -0,0 +1,160 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tencent Hunyuan\n", + "\n", + "Hunyuan chat model API by Tencent. For more information, see [https://cloud.tencent.com/document/product/1729](https://cloud.tencent.com/document/product/1729)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "ExecuteTime": { + "end_time": "2023-10-19T10:20:38.718834Z", + "start_time": "2023-10-19T10:20:38.264050Z" + } + }, + "outputs": [], + "source": [ + "from langchain.chat_models import ChatHunyuan\n", + "from langchain.schema import HumanMessage" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "ExecuteTime": { + "end_time": "2023-10-19T10:19:53.529876Z", + "start_time": "2023-10-19T10:19:53.526210Z" + } + }, + "outputs": [], + "source": [ + "chat = ChatHunyuan(\n", + " hunyuan_app_id='YOUR_APP_ID',\n", + " hunyuan_secret_id='YOUR_SECRET_ID',\n", + " hunyuan_secret_key='YOUR_SECRET_KEY',\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "ExecuteTime": { + "end_time": "2023-10-19T10:19:56.054289Z", + "start_time": "2023-10-19T10:19:53.531078Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": "AIMessage(content=\"J'aime programmer.\")" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "chat([\n", + " HumanMessage(content='You are a helpful assistant that translates English to French.Translate this sentence from English to French. I love programming.')\n", + "])" + ] + }, + { + "cell_type": "markdown", + "source": [ + "## For ChatHunyuan with Streaming" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 2, + "outputs": [], + "source": [ + "chat = ChatHunyuan(\n", + " hunyuan_app_id='YOUR_APP_ID',\n", + " hunyuan_secret_id='YOUR_SECRET_ID',\n", + " hunyuan_secret_key='YOUR_SECRET_KEY',\n", + " streaming=True,\n", + ")" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-19T10:20:41.507720Z", + "start_time": "2023-10-19T10:20:41.496456Z" + } + } + }, + { + "cell_type": "code", + "execution_count": 3, + "outputs": [ + { + "data": { + "text/plain": "AIMessageChunk(content=\"J'aime programmer.\")" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "chat([\n", + " HumanMessage(content='You are a helpful assistant that translates English to French.Translate this sentence from English to French. I love programming.')\n", + "])" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2023-10-19T10:20:46.275673Z", + "start_time": "2023-10-19T10:20:44.241097Z" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "start_time": "2023-10-19T10:19:56.233477Z" + } + } + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/libs/langchain/langchain/chat_models/__init__.py b/libs/langchain/langchain/chat_models/__init__.py index 0596faf4f37..9dc30fe9217 100644 --- a/libs/langchain/langchain/chat_models/__init__.py +++ b/libs/langchain/langchain/chat_models/__init__.py @@ -30,6 +30,7 @@ from langchain.chat_models.fake import FakeListChatModel from langchain.chat_models.fireworks import ChatFireworks from langchain.chat_models.google_palm import ChatGooglePalm from langchain.chat_models.human import HumanInputChatModel +from langchain.chat_models.hunyuan import ChatHunyuan from langchain.chat_models.javelin_ai_gateway import ChatJavelinAIGateway from langchain.chat_models.jinachat import JinaChat from langchain.chat_models.konko import ChatKonko @@ -69,4 +70,5 @@ __all__ = [ "ChatFireworks", "ChatYandexGPT", "ChatBaichuan", + "ChatHunyuan", ] diff --git a/libs/langchain/langchain/chat_models/hunyuan.py b/libs/langchain/langchain/chat_models/hunyuan.py new file mode 100644 index 00000000000..3f0b7261b82 --- /dev/null +++ b/libs/langchain/langchain/chat_models/hunyuan.py @@ -0,0 +1,325 @@ +import base64 +import hashlib +import hmac +import json +import logging +import time +from typing import Any, Dict, Iterator, List, Mapping, Optional, Type, Union +from urllib.parse import urlparse + +import requests + +from langchain.callbacks.manager import CallbackManagerForLLMRun +from langchain.chat_models.base import BaseChatModel, _generate_from_stream +from langchain.pydantic_v1 import Field, SecretStr, root_validator +from langchain.schema import ( + AIMessage, + BaseMessage, + ChatGeneration, + ChatMessage, + ChatResult, + HumanMessage, +) +from langchain.schema.messages import ( + AIMessageChunk, + BaseMessageChunk, + ChatMessageChunk, + HumanMessageChunk, +) +from langchain.schema.output import ChatGenerationChunk +from langchain.utils import get_from_dict_or_env, get_pydantic_field_names + +logger = logging.getLogger(__name__) + +DEFAULT_HUNYUAN_API_BASE = "https://hunyuan.cloud.tencent.com" +DEFAULT_HUNYUAN_PATH = "/hyllm/v1/chat/completions" + + +def _convert_message_to_dict(message: BaseMessage) -> dict: + message_dict: Dict[str, Any] + if isinstance(message, ChatMessage): + message_dict = {"role": message.role, "content": message.content} + elif isinstance(message, HumanMessage): + message_dict = {"role": "user", "content": message.content} + elif isinstance(message, AIMessage): + message_dict = {"role": "assistant", "content": message.content} + else: + raise TypeError(f"Got unknown type {message}") + + return message_dict + + +def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: + role = _dict["role"] + if role == "user": + return HumanMessage(content=_dict["content"]) + elif role == "assistant": + return AIMessage(content=_dict.get("content", "") or "") + else: + return ChatMessage(content=_dict["content"], role=role) + + +def _convert_delta_to_message_chunk( + _dict: Mapping[str, Any], default_class: Type[BaseMessageChunk] +) -> BaseMessageChunk: + role = _dict.get("role") + content = _dict.get("content") or "" + + if role == "user" or default_class == HumanMessageChunk: + return HumanMessageChunk(content=content) + elif role == "assistant" or default_class == AIMessageChunk: + return AIMessageChunk(content=content) + elif role or default_class == ChatMessageChunk: + return ChatMessageChunk(content=content, role=role) + else: + return default_class(content=content) + + +# signature generation +# https://cloud.tencent.com/document/product/1729/97732#532252ce-e960-48a7-8821-940a9ce2ccf3 +def _signature(secret_key: SecretStr, url: str, payload: Dict[str, Any]) -> str: + sorted_keys = sorted(payload.keys()) + + url_info = urlparse(url) + + sign_str = url_info.netloc + url_info.path + "?" + + for key in sorted_keys: + value = payload[key] + + if isinstance(value, list) or isinstance(value, dict): + value = json.dumps(value, separators=(",", ":")) + elif isinstance(value, float): + value = "%g" % value + + sign_str = sign_str + key + "=" + str(value) + "&" + + sign_str = sign_str[:-1] + + hmacstr = hmac.new( + key=secret_key.get_secret_value().encode("utf-8"), + msg=sign_str.encode("utf-8"), + digestmod=hashlib.sha1, + ).digest() + + return base64.b64encode(hmacstr).decode("utf-8") + + +def _create_chat_result(response: Mapping[str, Any]) -> ChatResult: + generations = [] + for choice in response["choices"]: + message = _convert_dict_to_message(choice["messages"]) + generations.append(ChatGeneration(message=message)) + + token_usage = response["usage"] + llm_output = {"token_usage": token_usage} + return ChatResult(generations=generations, llm_output=llm_output) + + +def _to_secret(value: Union[SecretStr, str]) -> SecretStr: + """Convert a string to a SecretStr if needed.""" + if isinstance(value, SecretStr): + return value + return SecretStr(value) + + +class ChatHunyuan(BaseChatModel): + """Tencent Hunyuan chat models API by Tencent. + + For more information, see https://cloud.tencent.com/document/product/1729 + """ + + @property + def lc_secrets(self) -> Dict[str, str]: + return { + "hunyuan_app_id": "HUNYUAN_APP_ID", + "hunyuan_secret_id": "HUNYUAN_SECRET_ID", + "hunyuan_secret_key": "HUNYUAN_SECRET_KEY", + } + + @property + def lc_serializable(self) -> bool: + return True + + hunyuan_api_base: str = "https://hunyuan.cloud.tencent.com" + """Hunyuan custom endpoints""" + hunyuan_app_id: Optional[str] = None + """Hunyuan App ID""" + hunyuan_secret_id: Optional[str] = None + """Hunyuan Secret ID""" + hunyuan_secret_key: Optional[SecretStr] = None + """Hunyuan Secret Key""" + streaming: bool = False + """Whether to stream the results or not.""" + request_timeout: int = 60 + """Timeout for requests to Hunyuan API. Default is 60 seconds.""" + + query_id: Optional[str] = None + """Query id for troubleshooting""" + temperature: float = 1.0 + """What sampling temperature to use.""" + top_p: float = 1.0 + """What probability mass to use.""" + + model_kwargs: Dict[str, Any] = Field(default_factory=dict) + """Holds any model parameters valid for API call not explicitly specified.""" + + class Config: + """Configuration for this pydantic object.""" + + allow_population_by_field_name = True + + @root_validator(pre=True) + def build_extra(cls, values: Dict[str, Any]) -> Dict[str, Any]: + """Build extra kwargs from additional params that were passed in.""" + all_required_field_names = get_pydantic_field_names(cls) + extra = values.get("model_kwargs", {}) + for field_name in list(values): + if field_name in extra: + raise ValueError(f"Found {field_name} supplied twice.") + if field_name not in all_required_field_names: + logger.warning( + f"""WARNING! {field_name} is not default parameter. + {field_name} was transferred to model_kwargs. + Please confirm that {field_name} is what you intended.""" + ) + extra[field_name] = values.pop(field_name) + + invalid_model_kwargs = all_required_field_names.intersection(extra.keys()) + if invalid_model_kwargs: + raise ValueError( + f"Parameters {invalid_model_kwargs} should be specified explicitly. " + f"Instead they were passed in as part of `model_kwargs` parameter." + ) + + values["model_kwargs"] = extra + return values + + @root_validator() + def validate_environment(cls, values: Dict) -> Dict: + values["hunyuan_api_base"] = get_from_dict_or_env( + values, + "hunyuan_api_base", + "HUNYUAN_API_BASE", + ) + values["hunyuan_app_id"] = get_from_dict_or_env( + values, + "hunyuan_app_id", + "HUNYUAN_APP_ID", + ) + values["hunyuan_secret_id"] = get_from_dict_or_env( + values, + "hunyuan_secret_id", + "HUNYUAN_SECRET_ID", + ) + values["hunyuan_secret_key"] = _to_secret( + get_from_dict_or_env( + values, + "hunyuan_secret_key", + "HUNYUAN_SECRET_KEY", + ) + ) + + return values + + @property + def _default_params(self) -> Dict[str, Any]: + """Get the default parameters for calling Hunyuan API.""" + normal_params = { + "app_id": self.hunyuan_app_id, + "secret_id": self.hunyuan_secret_id, + "temperature": self.temperature, + "top_p": self.top_p, + } + + if self.query_id is not None: + normal_params["query_id"] = self.query_id + + return {**normal_params, **self.model_kwargs} + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[CallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> ChatResult: + if self.streaming: + stream_iter = self._stream( + messages=messages, stop=stop, run_manager=run_manager, **kwargs + ) + return _generate_from_stream(stream_iter) + + res = self._chat(messages, **kwargs) + + response = res.json() + + if "error" in response: + raise ValueError(f"Error from Hunyuan api response: {response}") + + return _create_chat_result(response) + + def _stream( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[CallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + res = self._chat(messages, **kwargs) + + default_chunk_class = AIMessageChunk + for chunk in res.iter_lines(): + response = json.loads(chunk) + if "error" in response: + raise ValueError(f"Error from Hunyuan api response: {response}") + + for choice in response["choices"]: + chunk = _convert_delta_to_message_chunk( + choice["delta"], default_chunk_class + ) + default_chunk_class = chunk.__class__ + yield ChatGenerationChunk(message=chunk) + if run_manager: + run_manager.on_llm_new_token(chunk.content) + + def _chat(self, messages: List[BaseMessage], **kwargs: Any) -> requests.Response: + if self.hunyuan_secret_key is None: + raise ValueError("Hunyuan secret key is not set.") + + parameters = {**self._default_params, **kwargs} + + headers = parameters.pop("headers", {}) + timestamp = parameters.pop("timestamp", int(time.time())) + expired = parameters.pop("expired", timestamp + 24 * 60 * 60) + + payload = { + "timestamp": timestamp, + "expired": expired, + "messages": [_convert_message_to_dict(m) for m in messages], + **parameters, + } + + if self.streaming: + payload["stream"] = 1 + + url = self.hunyuan_api_base + DEFAULT_HUNYUAN_PATH + + res = requests.post( + url=url, + timeout=self.request_timeout, + headers={ + "Content-Type": "application/json", + "Authorization": _signature( + secret_key=self.hunyuan_secret_key, url=url, payload=payload + ), + **headers, + }, + json=payload, + stream=self.streaming, + ) + return res + + @property + def _llm_type(self) -> str: + return "hunyuan-chat" diff --git a/libs/langchain/tests/integration_tests/chat_models/test_hunyuan.py b/libs/langchain/tests/integration_tests/chat_models/test_hunyuan.py new file mode 100644 index 00000000000..4024994584c --- /dev/null +++ b/libs/langchain/tests/integration_tests/chat_models/test_hunyuan.py @@ -0,0 +1,24 @@ +from langchain.chat_models.hunyuan import ChatHunyuan +from langchain.schema.messages import AIMessage, HumanMessage + + +def test_chat_hunyuan() -> None: + chat = ChatHunyuan() + message = HumanMessage(content="Hello") + response = chat([message]) + assert isinstance(response, AIMessage) + assert isinstance(response.content, str) + + +def test_chat_hunyuan_with_temperature() -> None: + chat = ChatHunyuan(temperature=0.6) + message = HumanMessage(content="Hello") + response = chat([message]) + assert isinstance(response, AIMessage) + assert isinstance(response.content, str) + + +def test_extra_kwargs() -> None: + chat = ChatHunyuan(temperature=0.88, top_p=0.7) + assert chat.temperature == 0.88 + assert chat.top_p == 0.7 diff --git a/libs/langchain/tests/unit_tests/chat_models/test_hunyuan.py b/libs/langchain/tests/unit_tests/chat_models/test_hunyuan.py new file mode 100644 index 00000000000..5bec5eb95d6 --- /dev/null +++ b/libs/langchain/tests/unit_tests/chat_models/test_hunyuan.py @@ -0,0 +1,114 @@ +import pytest + +from langchain.chat_models.hunyuan import ( + _convert_delta_to_message_chunk, + _convert_dict_to_message, + _convert_message_to_dict, + _signature, +) +from langchain.pydantic_v1 import SecretStr +from langchain.schema.messages import ( + AIMessage, + AIMessageChunk, + ChatMessage, + FunctionMessage, + HumanMessage, + HumanMessageChunk, + SystemMessage, +) + + +def test__convert_message_to_dict_human() -> None: + message = HumanMessage(content="foo") + result = _convert_message_to_dict(message) + expected_output = {"role": "user", "content": "foo"} + assert result == expected_output + + +def test__convert_message_to_dict_ai() -> None: + message = AIMessage(content="foo") + result = _convert_message_to_dict(message) + expected_output = {"role": "assistant", "content": "foo"} + assert result == expected_output + + +def test__convert_message_to_dict_system() -> None: + message = SystemMessage(content="foo") + with pytest.raises(TypeError) as e: + _convert_message_to_dict(message) + assert "Got unknown type" in str(e) + + +def test__convert_message_to_dict_function() -> None: + message = FunctionMessage(name="foo", content="bar") + with pytest.raises(TypeError) as e: + _convert_message_to_dict(message) + assert "Got unknown type" in str(e) + + +def test__convert_dict_to_message_human() -> None: + message_dict = {"role": "user", "content": "foo"} + result = _convert_dict_to_message(message_dict) + expected_output = HumanMessage(content="foo") + assert result == expected_output + + +def test__convert_dict_to_message_ai() -> None: + message_dict = {"role": "assistant", "content": "foo"} + result = _convert_dict_to_message(message_dict) + expected_output = AIMessage(content="foo") + assert result == expected_output + + +def test__convert_dict_to_message_other_role() -> None: + message_dict = {"role": "system", "content": "foo"} + result = _convert_dict_to_message(message_dict) + expected_output = ChatMessage(role="system", content="foo") + assert result == expected_output + + +def test__convert_delta_to_message_assistant() -> None: + delta = {"role": "assistant", "content": "foo"} + result = _convert_delta_to_message_chunk(delta, AIMessageChunk) + expected_output = AIMessageChunk(content="foo") + assert result == expected_output + + +def test__convert_delta_to_message_human() -> None: + delta = {"role": "user", "content": "foo"} + result = _convert_delta_to_message_chunk(delta, HumanMessageChunk) + expected_output = HumanMessageChunk(content="foo") + assert result == expected_output + + +def test__signature() -> None: + secret_key = SecretStr("YOUR_SECRET_KEY") + url = "https://hunyuan.cloud.tencent.com/hyllm/v1/chat/completions" + + result = _signature( + secret_key=secret_key, + url=url, + payload={ + "app_id": "YOUR_APP_ID", + "secret_id": "YOUR_SECRET_ID", + "query_id": "test_query_id_cb5d8156-0ce2-45af-86b4-d02f5c26a142", + "messages": [ + { + "role": "user", + "content": "You are a helpful assistant that translates English" + " to French.Translate this sentence from English to" + " French. I love programming.", + } + ], + "temperature": 0.0, + "top_p": 0.8, + "stream": 1, + "timestamp": 1697738378, + "expired": 1697824778, + }, + ) + + # The signature was generated by the demo provided by Huanyuan. + # https://hunyuan-sdk-1256237915.cos.ap-guangzhou.myqcloud.com/python.zip + expected_output = "MXBvqNCXyxJWfEyBwk1pYBVnxzo=" + assert result == expected_output