Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
161 changes: 161 additions & 0 deletions libs/community/langchain_community/chat_models/ovhcloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""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, pre_init
from langchain_core.utils.function_calling import convert_to_openai_tool
from pydantic import Field, SecretStr

from langchain_community.chat_models.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

@pre_init
def validate_environment(cls, values: Dict) -> Dict:
"""Validate that api key and python package exists in environment."""
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)

52 changes: 27 additions & 25 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,9 +20,12 @@ class OVHCloudEmbeddings(BaseModel, Embeddings):
""" OVHcloud AI Endpoints model name for embeddings generation"""
model_name: str = ""

""" OVHcloud AI Endpoints region"""
""" OVHcloud AI Endpoints region (deprecated, kept for backward compatibility)"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we specify a removal version? i.e. v1.0?

See other classes for example

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, it's done! 😄

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mdrxy, did you have a chance to review my modification? Thanks! 😄

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=())

def __init__(self, **kwargs: Any):
Expand All @@ -32,8 +34,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 +42,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 +53,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 +64,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 +110,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
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"QianfanChatEndpoint",
"VolcEngineMaasChat",
"ChatOctoAI",
"ChatOVHcloud",
"ChatSnowflakeCortex",
"ChatYi",
]
Expand Down
52 changes: 52 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,52 @@
import pytest
from pydantic import SecretStr, ValidationError

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")
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")
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")
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")
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")
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")
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"