Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions libs/community/langchain_community/chat_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -204,6 +207,7 @@
"ChatCohere",
"ChatCoze",
"ChatOctoAI",
"ChatOVHcloud",
"ChatDatabricks",
"ChatDeepInfra",
"ChatEdenAI",
Expand Down Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions libs/community/langchain_community/chat_models/ovhcloud.py
Original file line number Diff line number Diff line change
@@ -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)

51 changes: 25 additions & 26 deletions libs/community/langchain_community/embeddings/ovhcloud.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
import logging
import time
from typing import Any, List
Expand All @@ -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=())

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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
1 change: 1 addition & 0 deletions libs/community/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"QianfanChatEndpoint",
"VolcEngineMaasChat",
"ChatOctoAI",
"ChatOVHcloud",
"ChatSnowflakeCortex",
"ChatYi",
]
Expand Down
54 changes: 54 additions & 0 deletions libs/community/tests/unit_tests/chat_models/test_ovhcloud.py
Original file line number Diff line number Diff line change
@@ -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"

13 changes: 3 additions & 10 deletions libs/community/tests/unit_tests/embeddings/test_ovhcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
)
Loading