This repository was archived by the owner on Jun 19, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 469
feat(agents): add OVHcloud chat completions and update to OpenAI-compatible endpoint #411
Closed
Elias (eliasto)
wants to merge
5
commits into
langchain-ai:main
from
eliasto:update-ovhcloud-ai-endpoints-integration
Closed
Changes from 2 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
aecc82a
feat(ovhcloud): Update OVHcloud AI Endpoints
eliasto b684335
Add deprecated for OVHcloud region
eliasto 1189926
Fix ChatOpenAI dependency
eliasto be76750
Edit tests to remove region since it's not used anymore
eliasto 0fff536
Merge branch 'main' into update-ovhcloud-ai-endpoints-integration
eliasto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
161 changes: 161 additions & 0 deletions
161
libs/community/langchain_community/chat_models/ovhcloud.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| import json | ||
| import logging | ||
| import time | ||
| from typing import Any, List | ||
|
|
||
| import requests | ||
| from langchain_core._api.deprecation import deprecated | ||
| from langchain_core.embeddings import Embeddings | ||
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
|
|
@@ -21,9 +21,16 @@ class OVHCloudEmbeddings(BaseModel, Embeddings): | |
| """ OVHcloud AI Endpoints model name for embeddings generation""" | ||
| model_name: str = "" | ||
|
|
||
| """ OVHcloud AI Endpoints region""" | ||
| @deprecated( | ||
| since="0.4.2", | ||
| removal="1.0", | ||
| ) | ||
| """ OVHcloud AI Endpoints region (deprecated, kept for backward compatibility)""" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi, it's done! 😄
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi Mason Daugherty (@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): | ||
|
|
@@ -32,8 +39,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 +47,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 +58,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 +69,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 +115,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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
52 changes: 52 additions & 0 deletions
52
libs/community/tests/unit_tests/chat_models/test_ovhcloud.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
|
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.