-
Notifications
You must be signed in to change notification settings - Fork 306
feat(agents): add OVHcloud chat completions and update to OpenAI-compatible endpoint #411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
aecc82a
b684335
1189926
be76750
0fff536
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
| 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 | ||
|
|
@@ -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)""" | ||
|
||
| 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 +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. | ||
|
|
@@ -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. | ||
|
|
@@ -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. | ||
|
|
@@ -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: | ||
|
|
@@ -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 |
|---|---|---|
| @@ -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" | ||
|
|
Uh oh!
There was an error while loading. Please reload this page.