diff --git a/libs/community/langchain_community/chat_models/__init__.py b/libs/community/langchain_community/chat_models/__init__.py index 9c83bdec..e9c8fbe0 100644 --- a/libs/community/langchain_community/chat_models/__init__.py +++ b/libs/community/langchain_community/chat_models/__init__.py @@ -140,6 +140,9 @@ from langchain_community.chat_models.ollama import ( ChatOllama, ) + from langchain_community.chat_models.ovhcloud import ( + ChatOVHcloud, + ) from langchain_community.chat_models.openai import ( ChatOpenAI, ) @@ -204,6 +207,7 @@ "ChatCohere", "ChatCoze", "ChatOctoAI", + "ChatOVHcloud", "ChatDatabricks", "ChatDeepInfra", "ChatEdenAI", @@ -290,6 +294,7 @@ "ChatMlflow": "langchain_community.chat_models.mlflow", "ChatNebula": "langchain_community.chat_models.symblai_nebula", "ChatOctoAI": "langchain_community.chat_models.octoai", + "ChatOVHcloud": "langchain_community.chat_models.ovhcloud", "ChatOCIGenAI": "langchain_community.chat_models.oci_generative_ai", "ChatOCIModelDeployment": "langchain_community.chat_models.oci_data_science", "ChatOCIModelDeploymentVLLM": "langchain_community.chat_models.oci_data_science", diff --git a/libs/community/langchain_community/chat_models/ovhcloud.py b/libs/community/langchain_community/chat_models/ovhcloud.py new file mode 100644 index 00000000..880848d8 --- /dev/null +++ b/libs/community/langchain_community/chat_models/ovhcloud.py @@ -0,0 +1,164 @@ +"""OVHcloud AI Endpoints chat wrapper. Relies heavily on ChatOpenAI.""" + +from typing import ( + Any, + Callable, + Dict, + Literal, + Optional, + Sequence, + Type, + Union, +) + +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool +from langchain_core.utils import convert_to_secret_str, get_from_dict_or_env +from langchain_core.utils.function_calling import convert_to_openai_tool +from pydantic import Field, SecretStr, model_validator + +from langchain_openai import ChatOpenAI +from langchain_community.utils.openai import is_openai_v1 + +DEFAULT_API_BASE = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1" +DEFAULT_MODEL = "gpt-oss-120b" + + +class ChatOVHcloud(ChatOpenAI): + """OVHcloud AI Endpoints Chat large language models. + + See https://www.ovhcloud.com/en/public-cloud/ai-endpoints/catalog/ for information about OVHcloud AI Endpoints. + + To use, you should have the ``openai`` python package installed and the + environment variable ``OVHCLOUD_API_TOKEN`` set with your API token. + Alternatively, you can use the ovhcloud_api_token keyword argument. + + Any parameters that are valid to be passed to the `openai.create` call can be passed + in, even if not explicitly saved on this class. + + Example: + .. code-block:: python + + from langchain_community.chat_models import ChatOVHcloud + chat = ChatOVHcloud(model_name="gpt-oss-120b") + """ + + ovhcloud_api_base: str = Field(default=DEFAULT_API_BASE) + ovhcloud_api_token: SecretStr = Field(default=SecretStr(""), alias="api_key") + model_name: str = Field(default=DEFAULT_MODEL, alias="model") + + @property + def _llm_type(self) -> str: + """Return type of chat model.""" + return "ovhcloud-chat" + + @property + def lc_secrets(self) -> Dict[str, str]: + return {"ovhcloud_api_token": "OVHCLOUD_API_TOKEN"} + + @classmethod + def is_lc_serializable(cls) -> bool: + return False + + @model_validator(mode="before") + @classmethod + def validate_environment(cls, values: Any) -> Any: + """Validate that api key and python package exists in environment.""" + if not isinstance(values, dict): + return values + values["ovhcloud_api_base"] = get_from_dict_or_env( + values, + "ovhcloud_api_base", + "OVHCLOUD_API_BASE", + default=DEFAULT_API_BASE, + ) + values["ovhcloud_api_token"] = convert_to_secret_str( + get_from_dict_or_env( + values, "ovhcloud_api_token", "OVHCLOUD_API_TOKEN" + ) + ) + values["model_name"] = get_from_dict_or_env( + values, + "model_name", + "OVHCLOUD_MODEL_NAME", + default=DEFAULT_MODEL, + ) + + try: + import openai + + if is_openai_v1(): + client_params = { + "api_key": values["ovhcloud_api_token"].get_secret_value(), + "base_url": values["ovhcloud_api_base"], + } + if not values.get("client"): + values["client"] = openai.OpenAI(**client_params).chat.completions + if not values.get("async_client"): + values["async_client"] = openai.AsyncOpenAI( + **client_params + ).chat.completions + else: + values["openai_api_base"] = values["ovhcloud_api_base"] + values["openai_api_key"] = values["ovhcloud_api_token"].get_secret_value() + values["client"] = openai.ChatCompletion + except ImportError: + raise ImportError( + "Could not import openai python package. " + "Please install it with `pip install openai`." + ) + + return values + + def bind_tools( + self, + tools: Sequence[Union[Dict[str, Any], Type, Callable, BaseTool]], + *, + tool_choice: Optional[ + Union[dict, str, Literal["auto", "none", "required", "any"], bool] + ] = None, + strict: Optional[bool] = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, AIMessage]: + """Imitating bind_tool method from langchain_openai.ChatOpenAI""" + + formatted_tools = [ + convert_to_openai_tool(tool, strict=strict) for tool in tools + ] + if tool_choice: + if isinstance(tool_choice, str): + # tool_choice is a tool/function name + if tool_choice not in ("auto", "none", "any", "required"): + tool_choice = { + "type": "function", + "function": {"name": tool_choice}, + } + # 'any' is not natively supported by OpenAI API. + # We support 'any' since other models use this instead of 'required'. + if tool_choice == "any": + tool_choice = "required" + elif isinstance(tool_choice, bool): + tool_choice = "required" + elif isinstance(tool_choice, dict): + tool_names = [ + formatted_tool["function"]["name"] + for formatted_tool in formatted_tools + ] + if not any( + tool_name == tool_choice["function"]["name"] + for tool_name in tool_names + ): + raise ValueError( + f"Tool choice {tool_choice} was specified, but the only " + f"provided tools were {tool_names}." + ) + else: + raise ValueError( + f"Unrecognized tool_choice type. Expected str, bool or dict. " + f"Received: {tool_choice}" + ) + kwargs["tool_choice"] = tool_choice + return super().bind(tools=formatted_tools, **kwargs) + diff --git a/libs/community/langchain_community/embeddings/ovhcloud.py b/libs/community/langchain_community/embeddings/ovhcloud.py index 49b9cbfa..ea21db2c 100644 --- a/libs/community/langchain_community/embeddings/ovhcloud.py +++ b/libs/community/langchain_community/embeddings/ovhcloud.py @@ -1,4 +1,3 @@ -import json import logging import time from typing import Any, List @@ -21,8 +20,8 @@ class OVHCloudEmbeddings(BaseModel, Embeddings): """ OVHcloud AI Endpoints model name for embeddings generation""" model_name: str = "" - """ OVHcloud AI Endpoints region""" - region: str = "kepler" + """ OVHcloud AI Endpoints base URL""" + base_url: str = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1" model_config = ConfigDict(extra="forbid", protected_namespaces=()) @@ -32,8 +31,6 @@ def __init__(self, **kwargs: Any): raise ValueError("Access token is required for OVHCloud embeddings.") if self.model_name == "": raise ValueError("Model name is required for OVHCloud embeddings.") - if self.region == "": - raise ValueError("Region is required for OVHCloud embeddings.") def _generate_embedding(self, text: str) -> List[float]: """Generate embeddings from OVHCLOUD AIE. @@ -42,8 +39,7 @@ def _generate_embedding(self, text: str) -> List[float]: Returns: List[float]: Embeddings for the text. """ - - return self._send_request_to_ai_endpoints("text/plain", text, "text2vec") + return self._send_request_to_ai_endpoints([text])[0] def embed_documents(self, texts: List[str]) -> List[List[float]]: """Embed a list of documents. @@ -54,10 +50,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]: List[List[float]]: List of embeddings, one for each input text. """ - - return self._send_request_to_ai_endpoints( - "application/json", json.dumps(texts), "batch_text2vec" - ) + return self._send_request_to_ai_endpoints(texts) def embed_query(self, text: str) -> List[float]: """Embed a single query text. @@ -68,29 +61,31 @@ def embed_query(self, text: str) -> List[float]: """ return self._generate_embedding(text) - def _send_request_to_ai_endpoints( - self, contentType: str, payload: str, route: str - ) -> Any: - """Send a HTTPS request to OVHcloud AI Endpoints + def _send_request_to_ai_endpoints(self, texts: List[str]) -> List[List[float]]: + """Send a HTTPS request to OVHcloud AI Endpoints using OpenAI-compatible API Args: - contentType (str): The content type of the request, application/json or text/plain. - payload (str): The payload of the request. - route (str): The route of the request, batch_text2vec or text2vec. - """ # noqa: E501 + texts (List[str]): The list of texts to embed. + Returns: + List[List[float]]: List of embeddings, one for each input text. + """ headers = { - "content-type": contentType, + "Content-Type": "application/json", "Authorization": f"Bearer {self.access_token}", } + # Prepare request body in OpenAI format + # OpenAI API accepts both string and array, but we always use array for consistency + payload = { + "model": self.model_name, + "input": texts, + } + session = requests.session() while True: response = session.post( - ( - f"https://{self.model_name}.endpoints.{self.region}" - f".ai.cloud.ovh.net/api/{route}" - ), + f"{self.base_url}/embeddings", headers=headers, - data=payload, + json=payload, ) if response.status_code != 200: if response.status_code == 429: @@ -112,4 +107,8 @@ def _send_request_to_ai_endpoints( status_code=response.status_code, text=response.text ) ) - return response.json() + # Parse OpenAI-compatible response format + response_data = response.json() + # OpenAI format: {"data": [{"embedding": [...]}, ...]} + embeddings = [item["embedding"] for item in response_data["data"]] + return embeddings diff --git a/libs/community/pyproject.toml b/libs/community/pyproject.toml index a8f47cc8..aa5b72d9 100644 --- a/libs/community/pyproject.toml +++ b/libs/community/pyproject.toml @@ -54,6 +54,7 @@ test = [ "blockbuster>=1.5.18,<1.6.0", "cffi<1.17.1; python_version < \"3.10\"", "cffi; python_version >= \"3.10\"", + "langchain-openai>=0.2.1", "langchain-tests>=1.0.0,<2.0.0", "toml>=0.10.2,<1.0.0", "mypy-extensions>=1.0.0", diff --git a/libs/community/tests/unit_tests/chat_models/test_imports.py b/libs/community/tests/unit_tests/chat_models/test_imports.py index 1c7dff19..2df2c9bb 100644 --- a/libs/community/tests/unit_tests/chat_models/test_imports.py +++ b/libs/community/tests/unit_tests/chat_models/test_imports.py @@ -63,6 +63,7 @@ "QianfanChatEndpoint", "VolcEngineMaasChat", "ChatOctoAI", + "ChatOVHcloud", "ChatSnowflakeCortex", "ChatYi", ] diff --git a/libs/community/tests/unit_tests/chat_models/test_ovhcloud.py b/libs/community/tests/unit_tests/chat_models/test_ovhcloud.py new file mode 100644 index 00000000..bd137062 --- /dev/null +++ b/libs/community/tests/unit_tests/chat_models/test_ovhcloud.py @@ -0,0 +1,54 @@ +import pytest +from pydantic import SecretStr, ValidationError + +pytest.importorskip("langchain_openai", reason="langchain-openai not installed") + +from langchain_community.chat_models.ovhcloud import ChatOVHcloud + +DEFAULT_API_BASE = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1" +DEFAULT_MODEL = "gpt-oss-120b" + + +@pytest.mark.requires("openai", "langchain_openai") +def test__default_ovhcloud_api_base() -> None: + chat = ChatOVHcloud(ovhcloud_api_token=SecretStr("test_token")) # type: ignore[call-arg] + assert chat.ovhcloud_api_base == DEFAULT_API_BASE + + +@pytest.mark.requires("openai", "langchain_openai") +def test__default_ovhcloud_api_token() -> None: + chat = ChatOVHcloud(ovhcloud_api_token=SecretStr("test_token")) # type: ignore[call-arg] + assert chat.ovhcloud_api_token.get_secret_value() == "test_token" + + +@pytest.mark.requires("openai", "langchain_openai") +def test__default_model_name() -> None: + chat = ChatOVHcloud(ovhcloud_api_token=SecretStr("test_token")) # type: ignore[call-arg] + assert chat.model_name == DEFAULT_MODEL + + +@pytest.mark.requires("openai", "langchain_openai") +def test__field_aliases() -> None: + chat = ChatOVHcloud(ovhcloud_api_token=SecretStr("test_token"), model="custom-model") # type: ignore[call-arg] + assert chat.model_name == "custom-model" + assert chat.ovhcloud_api_token.get_secret_value() == "test_token" + + +@pytest.mark.requires("openai", "langchain_openai") +def test__missing_ovhcloud_api_token() -> None: + with pytest.raises(ValidationError) as e: + ChatOVHcloud() + assert "Did not find ovhcloud_api_token" in str(e) + + +@pytest.mark.requires("openai", "langchain_openai") +def test__all_fields_provided() -> None: + chat = ChatOVHcloud( # type: ignore[call-arg] + ovhcloud_api_token=SecretStr("test_token"), + model="custom-model", + ovhcloud_api_base="https://custom.api/base/", + ) + assert chat.ovhcloud_api_base == "https://custom.api/base/" + assert chat.ovhcloud_api_token.get_secret_value() == "test_token" + assert chat.model_name == "custom-model" + diff --git a/libs/community/tests/unit_tests/embeddings/test_ovhcloud.py b/libs/community/tests/unit_tests/embeddings/test_ovhcloud.py index c06c1550..bd7b9d6a 100644 --- a/libs/community/tests/unit_tests/embeddings/test_ovhcloud.py +++ b/libs/community/tests/unit_tests/embeddings/test_ovhcloud.py @@ -7,25 +7,18 @@ def test_ovhcloud_correct_instantiation() -> None: llm = OVHCloudEmbeddings(model_name="multilingual-e5-base", access_token="token") assert isinstance(llm, OVHCloudEmbeddings) llm = OVHCloudEmbeddings( - model_name="multilingual-e5-base", region="kepler", access_token="token" + model_name="multilingual-e5-base", access_token="token" ) assert isinstance(llm, OVHCloudEmbeddings) def test_ovhcloud_empty_model_name_should_raise_error() -> None: with pytest.raises(ValueError): - OVHCloudEmbeddings(model_name="", region="kepler", access_token="token") - - -def test_ovhcloud_empty_region_should_raise_error() -> None: - with pytest.raises(ValueError): - OVHCloudEmbeddings( - model_name="multilingual-e5-base", region="", access_token="token" - ) + OVHCloudEmbeddings(model_name="", access_token="token") def test_ovhcloud_empty_access_token_should_raise_error() -> None: with pytest.raises(ValueError): OVHCloudEmbeddings( - model_name="multilingual-e5-base", region="kepler", access_token="" + model_name="multilingual-e5-base", access_token="" ) diff --git a/libs/community/tests/unit_tests/test_dependencies.py b/libs/community/tests/unit_tests/test_dependencies.py index 1ce576f8..3092c504 100644 --- a/libs/community/tests/unit_tests/test_dependencies.py +++ b/libs/community/tests/unit_tests/test_dependencies.py @@ -62,6 +62,7 @@ def test_test_group_dependencies(uv_conf: Mapping[str, Any]) -> None: [ "duckdb-engine", "freezegun", + "langchain-openai", "langchain-tests", "lark", "mypy-extensions",