From 38a72ee6d8e1ccfab70af9e7b0c4f7d0442a2d10 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 18 Sep 2026 15:31:15 +0200 Subject: [PATCH 1/3] fix: disentangle orchestration v2 from v1 --- .../gen/gen_ai_hub/orchestration_v2/models/response.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py index cc6c6449..96e3765a 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py @@ -5,10 +5,8 @@ from typing import List, Optional, Any, Literal, Union from pydantic import ConfigDict, Field -from gen_ai_hub.orchestration.models.response import ModuleResultsStreaming from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel -from gen_ai_hub.orchestration_v2.models.message import ChatMessage, FunctionCall, ResponseChatMessage - +from gen_ai_hub.orchestration_v2.models.message import ChatMessage, FunctionCall, ResponseChatMessage, ReasoningBlock class ResponseBaseModel(BaseModel): """ @@ -205,6 +203,8 @@ class StreamDelta(ResponseBaseModel): role: Optional[str] = None content: str tool_calls: Optional[List[StreamToolCall]] = None + refusal: Optional[str] = None + reasoning_content: Optional[List[ReasoningBlock]] = None class StreamLLMChoice(ResponseBaseModel): @@ -361,7 +361,7 @@ class SAPAPIErrorStreaming(ResponseBaseModel): code: int message: str location: str - intermediate_results: Optional[ModuleResultsStreaming] = None + intermediate_results: Optional[StreamModuleResults] = None headers: Optional[dict[str, str]] = None class CompletionPostResponse(ResponseBaseModel): From 1138ff72d2544b68ac421fbf20663829c6e210f7 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 18 Sep 2026 16:46:29 +0200 Subject: [PATCH 2/3] test: rm orchestration pt1 --- .../gen/gen_ai_hub/orchestration/__init__.py | 0 .../gen_ai_hub/orchestration/exceptions.py | 47 -- .../orchestration/models/__init__.py | 0 .../models/azure_content_filter.py | 74 -- .../gen_ai_hub/orchestration/models/base.py | 18 - .../gen_ai_hub/orchestration/models/config.py | 98 --- .../orchestration/models/content_filter.py | 51 -- .../orchestration/models/content_filtering.py | 95 --- .../orchestration/models/data_masking.py | 64 -- .../models/document_grounding.py | 214 ------ .../models/llama_guard_3_filter.py | 88 --- .../gen_ai_hub/orchestration/models/llm.py | 54 -- .../orchestration/models/message.py | 239 ------ .../orchestration/models/multimodal_items.py | 171 ----- .../orchestration/models/response.py | 324 -------- .../orchestration/models/response_format.py | 151 ---- .../models/sap_data_privacy_integration.py | 190 ----- .../orchestration/models/template.py | 95 --- .../orchestration/models/template_ref.py | 61 -- .../gen_ai_hub/orchestration/models/tools.py | 261 ------- .../translation/sap_document_translation.py | 34 - .../models/translation/translation.py | 143 ---- .../gen/gen_ai_hub/orchestration/service.py | 708 ------------------ .../gen_ai_hub/orchestration/sse_client.py | 302 -------- .../gen/gen_ai_hub/orchestration/utils.py | 11 - .../orchestration/__init__.py | 0 .../orchestration/test_async.py | 114 --- .../orchestration/test_base.py | 60 -- .../orchestration/test_content_filtering.py | 176 ----- .../orchestration/test_data_masking.py | 111 --- .../orchestration/test_grounding.py | 210 ------ .../orchestration/test_llm.py | 96 --- .../orchestration/test_service.py | 168 ----- .../orchestration/test_streaming.py | 160 ---- .../orchestration/test_templating.py | 563 -------------- .../orchestration/test_translation.py | 125 ---- packages/gen/tests/orchestration/__init__.py | 0 .../gen/tests/orchestration/test_config.py | 54 -- .../orchestration/test_content_filter.py | 78 -- .../tests/orchestration/test_data_masking.py | 39 - .../gen/tests/orchestration/test_grounding.py | 59 -- packages/gen/tests/orchestration/test_llm.py | 31 - .../gen/tests/orchestration/test_service.py | 446 ----------- .../tests/orchestration/test_sse_client.py | 283 ------- .../gen/tests/orchestration/test_template.py | 275 ------- .../tests/orchestration/test_template_ref.py | 35 - .../gen/tests/orchestration/test_tools.py | 124 --- .../tests/orchestration/test_translation.py | 101 --- packages/gen/tests/test_additional_headers.py | 43 -- 49 files changed, 6844 deletions(-) delete mode 100644 packages/gen/gen_ai_hub/orchestration/__init__.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/exceptions.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/__init__.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/azure_content_filter.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/base.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/config.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/content_filter.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/content_filtering.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/data_masking.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/document_grounding.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/llama_guard_3_filter.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/llm.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/message.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/multimodal_items.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/response.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/response_format.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/sap_data_privacy_integration.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/template.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/template_ref.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/tools.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/translation/sap_document_translation.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/models/translation/translation.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/service.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/sse_client.py delete mode 100644 packages/gen/gen_ai_hub/orchestration/utils.py delete mode 100644 packages/gen/integration_tests/orchestration/__init__.py delete mode 100644 packages/gen/integration_tests/orchestration/test_async.py delete mode 100644 packages/gen/integration_tests/orchestration/test_base.py delete mode 100644 packages/gen/integration_tests/orchestration/test_content_filtering.py delete mode 100644 packages/gen/integration_tests/orchestration/test_data_masking.py delete mode 100644 packages/gen/integration_tests/orchestration/test_grounding.py delete mode 100644 packages/gen/integration_tests/orchestration/test_llm.py delete mode 100644 packages/gen/integration_tests/orchestration/test_service.py delete mode 100644 packages/gen/integration_tests/orchestration/test_streaming.py delete mode 100644 packages/gen/integration_tests/orchestration/test_templating.py delete mode 100644 packages/gen/integration_tests/orchestration/test_translation.py delete mode 100644 packages/gen/tests/orchestration/__init__.py delete mode 100644 packages/gen/tests/orchestration/test_config.py delete mode 100644 packages/gen/tests/orchestration/test_content_filter.py delete mode 100644 packages/gen/tests/orchestration/test_data_masking.py delete mode 100644 packages/gen/tests/orchestration/test_grounding.py delete mode 100644 packages/gen/tests/orchestration/test_llm.py delete mode 100644 packages/gen/tests/orchestration/test_service.py delete mode 100644 packages/gen/tests/orchestration/test_sse_client.py delete mode 100644 packages/gen/tests/orchestration/test_template.py delete mode 100644 packages/gen/tests/orchestration/test_template_ref.py delete mode 100644 packages/gen/tests/orchestration/test_tools.py delete mode 100644 packages/gen/tests/orchestration/test_translation.py diff --git a/packages/gen/gen_ai_hub/orchestration/__init__.py b/packages/gen/gen_ai_hub/orchestration/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/gen/gen_ai_hub/orchestration/exceptions.py b/packages/gen/gen_ai_hub/orchestration/exceptions.py deleted file mode 100644 index 822b0f21..00000000 --- a/packages/gen/gen_ai_hub/orchestration/exceptions.py +++ /dev/null @@ -1,47 +0,0 @@ -import httpx -from typing import Dict, Any - - -class OrchestrationError(Exception): - """ - This exception is raised when an error occurs during the execution of the - orchestration service, typically due to incorrect usage, invalid configurations, - or issues with run parameters defined by the user. - """ - - def __init__( - self, - request_id: str, - http_headers: httpx.Headers, - message: str, - code: int, - location: str, - module_results: Dict[str, Any], - retries: int = 0, - ): - """The constructor for OrchestrationError class. - - :param request_id: unique identifier for the request - :type request_id: str - :param http_headers: the HTTP headers associated with the error, useful in case of e.g. rate limiting. - :type http_headers: httpx.Headers - :param message: Detailed error message describing the issue. - :type message: str - :param code: Error code associated with the specific type of failure - :type code: int - :param location: Specific component or step in the orchestration process where the error occurred - :type location: str - :param module_results: State information and partial results from various modules at the time of the error, - useful for debugging - :type module_results: Dict[str, Any] - :param retries: the number of retries attempted - :type retries: int, optional - """ - self.request_id = request_id - self.http_headers = http_headers - self.message = message - self.code = code - self.location = location - self.module_results = module_results - self.retries = retries - super().__init__(message) diff --git a/packages/gen/gen_ai_hub/orchestration/models/__init__.py b/packages/gen/gen_ai_hub/orchestration/models/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/gen/gen_ai_hub/orchestration/models/azure_content_filter.py b/packages/gen/gen_ai_hub/orchestration/models/azure_content_filter.py deleted file mode 100644 index 67ec04ab..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/azure_content_filter.py +++ /dev/null @@ -1,74 +0,0 @@ -from enum import Enum -from typing import Union, Literal - -from gen_ai_hub.orchestration.models.content_filter import ContentFilter, ContentFilterProvider - - -class AzureThreshold(int, Enum): - """ - Enumerates the threshold levels for the Azure Content Safety service. - - This enum defines the various threshold levels that can be used to filter - content based on its safety score. Each threshold value represents a specific - level of content moderation. - - Values: - ALLOW_SAFE: Allows only Safe content. - - ALLOW_SAFE_LOW: Allows Safe and Low content. - - ALLOW_SAFE_LOW_MEDIUM: Allows Safe, Low, and Medium content. - - ALLOW_ALL: Allows all content (Safe, Low, Medium, and High). - """ - - ALLOW_SAFE = 0 - ALLOW_SAFE_LOW = 2 - ALLOW_SAFE_LOW_MEDIUM = 4 - ALLOW_ALL = 6 - - -class AzureContentFilter(ContentFilter): - """ - Specific implementation of ContentFilter for Azure's content filtering service. - - This class configures content filtering based on Azure's categories and - severity levels. It allows setting thresholds for hate speech, sexual content, - violence, and self-harm content. - """ - - def __init__( - self, - hate: Union[AzureThreshold, Literal[0, 2, 4, 6]], - sexual: Union[AzureThreshold, Literal[0, 2, 4, 6]], - violence: Union[AzureThreshold, Literal[0, 2, 4, 6]], - self_harm: Union[AzureThreshold, Literal[0, 2, 4, 6]], - **kwargs - ): - """Initializes the AzureContentFilter with specified thresholds for different content categories. - - :param hate: threshold for hate speech content - :type hate: Union[AzureThreshold, Literal[0, 2, 4, 6]] - :param sexual: threshold for sexual content - :type sexual: Union[AzureThreshold, Literal[0, 2, 4, 6]] - :param violence: threshold for violent content - :type violence: Union[AzureThreshold, Literal[0, 2, 4, 6]] - :param self_harm: threshold for self-harm content - :type self_harm: Union[AzureThreshold, Literal[0, 2, 4, 6]] - """ - - hate = hate if isinstance(hate, AzureThreshold) else AzureThreshold(hate) - sexual = sexual if isinstance(sexual, AzureThreshold) else AzureThreshold(sexual) - violence = violence if isinstance(violence, AzureThreshold) else AzureThreshold(violence) - self_harm = self_harm if isinstance(self_harm, AzureThreshold) else AzureThreshold(self_harm) - - super().__init__( - provider=ContentFilterProvider.AZURE, - config={ - "Hate": hate, - "Sexual": sexual, - "Violence": violence, - "SelfHarm": self_harm, - **kwargs - }, - ) diff --git a/packages/gen/gen_ai_hub/orchestration/models/base.py b/packages/gen/gen_ai_hub/orchestration/models/base.py deleted file mode 100644 index 32de9fb8..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/base.py +++ /dev/null @@ -1,18 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Dict, Any - - -class JSONSerializable(ABC): - """ - An interface for objects that can be serialized to JSON. - """ - - @abstractmethod - def to_dict(self) -> Dict[str, Any]: - """Convert the object to a JSON-serializable dictionary. - - :return: dictionary representation of the object. - :rtype: Dict[str, Any] - """ - - pass diff --git a/packages/gen/gen_ai_hub/orchestration/models/config.py b/packages/gen/gen_ai_hub/orchestration/models/config.py deleted file mode 100644 index 88d5d708..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/config.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Optional, Union - -from gen_ai_hub.orchestration.models.base import JSONSerializable -from gen_ai_hub.orchestration.models.content_filtering import ContentFiltering -from gen_ai_hub.orchestration.models.data_masking import DataMasking -from gen_ai_hub.orchestration.models.document_grounding import GroundingModule -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.template import Template -from gen_ai_hub.orchestration.models.template_ref import TemplateRef -from gen_ai_hub.orchestration.models.translation.translation import Translation - - -class OrchestrationConfig(JSONSerializable): - """ - Configuration for the Orchestration Service's content generation process. - - Defines modules for a harmonized API that combines LLM-based content generation - with additional processing functionalities. - - The orchestration service allows for advanced content generation by processing inputs through a series of steps: - template rendering, text generation via LLMs, and optional input/output transformations such as data masking - or filtering. - """ - - def __init__( - self, - template: Union[Template, TemplateRef], - llm: LLM, - filtering: Optional[ContentFiltering] = None, - data_masking: Optional[DataMasking] = None, - grounding: Optional[GroundingModule] = None, - stream_options: Optional[dict] = None, - translation: Optional[Translation] = None, - ): - """Initializes the OrchestrationConfig with specified modules. - - :param template: template for rendering input prompts - :type template: Union[Template, TemplateRef] - :param llm: language model for text generation - :type llm: LLM - :param filtering: content filtering module, defaults to None - :type filtering: Optional[ContentFiltering], optional - :param data_masking: data masking module, defaults to None - :type data_masking: Optional[DataMasking], optional - :param grounding: document grounding module, defaults to None - :type grounding: Optional[GroundingModule], optional - :param stream_options: global streaming options, defaults to None - :type stream_options: Optional[dict], optional - :param translation: translation module, defaults to None - :type translation: Optional[Translation], optional - """ - - self.template = template - self.llm = llm - self.filtering = filtering - self.data_masking = data_masking - self.grounding = grounding - self.stream_options = stream_options - self._stream = False - self.translation = translation - - def _get_module_configurations(self): - configs = { - "templating_module_config": self.template.to_dict(), - "llm_module_config": self.llm.to_dict(), - } - - if self.data_masking: - configs["masking_module_config"] = self.data_masking.to_dict() - - if self.filtering: - configs["filtering_module_config"] = self.filtering.to_dict() - - if self.grounding: - configs["grounding_module_config"] = self.grounding.to_dict() - - if self.translation: - if self.translation.input_translation: - configs["input_translation_module_config"] = self.translation.input_translation.to_dict() - if self.translation.output_translation: - configs["output_translation_module_config"] = self.translation.output_translation.to_dict() - - return configs - - def to_dict(self): - """Converts the orchestration configuration to a dictionary format. - - :return: dictionary representation of the orchestration configuration. - :rtype: dict - """ - - config = { - "module_configurations": self._get_module_configurations(), - **({"stream": True} if self._stream else {}), - **({"stream_options": self.stream_options} if self._stream and self.stream_options else {}) - } - - return config diff --git a/packages/gen/gen_ai_hub/orchestration/models/content_filter.py b/packages/gen/gen_ai_hub/orchestration/models/content_filter.py deleted file mode 100644 index aeede702..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/content_filter.py +++ /dev/null @@ -1,51 +0,0 @@ -from enum import Enum -from typing import Union - -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -class ContentFilterProvider(str, Enum): - """ - Enumerates supported content filter providers. - - This enum defines the available content filtering services that can be used - for content moderation tasks. Each enum value represents a specific provider. - - Values: - AZURE: Represents the Azure Content Safety service. - - LLAMA_GUARD_3_8B: Represents the Llama Guard 3 based on Llama-3.1-8B pretrained model. - """ - - AZURE = "azure_content_safety" - LLAMA_GUARD_3_8B = "llama_guard_3_8b" - - -class ContentFilter(JSONSerializable): - """ - Base class for content filtering configurations. - - This class provides a generic structure for defining content filters - from various providers. It allows for specifying the provider and - associated configuration parameters. - """ - - def __init__(self, provider: Union[ContentFilterProvider, str], config: dict): - """Initializes the ContentFilter with specified provider and configuration. - - :param provider: The name of the content filter provider. - :type provider: Union[ContentFilterProvider, str] - :param config: A dictionary containing the configuration parameters for the content filter. - :type config: dict - """ - self.provider = provider - self.config = config - - def to_dict(self): - """to_dict method to convert the content filter to a dictionary. - - :return: dictionary representation of the content filter. - :rtype: dict - """ - return {"type": self.provider, "config": self.config} - diff --git a/packages/gen/gen_ai_hub/orchestration/models/content_filtering.py b/packages/gen/gen_ai_hub/orchestration/models/content_filtering.py deleted file mode 100644 index 5c009b88..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/content_filtering.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import List, Optional - -from gen_ai_hub.orchestration.models.base import JSONSerializable -from gen_ai_hub.orchestration.models.content_filter import ContentFilter - - -class InputFiltering(JSONSerializable): - """Module for managing and applying input content filters.""" - - def __init__( - self, - filters: List[ContentFilter] - ): - """Initializes the InputFiltering with specified filters. - - :param filters: List of ContentFilter objects to be applied to input content. - :type filters: List[ContentFilter] - """ - self.filters = filters - - def to_dict(self): - """to_dict method to convert the input filtering configuration to a dictionary. - - :return: dictionary representation of the input filtering configuration. - :rtype: dict - """ - return { - "filters": [f.to_dict() for f in self.filters], - } - - -class OutputFiltering(JSONSerializable): - """Module for managing and applying output content filters.""" - - def __init__(self, - filters: List[ContentFilter], - stream_options: Optional[dict] = None - ): - """Initializes the OutputFiltering with specified filters and optional streaming options. - - :param filters: List of ContentFilter objects to be applied to output content. - :type filters: List[ContentFilter] - :param stream_options: Module-specific streaming options, defaults to None - :type stream_options: Optional[dict], optional - """ - self.filters = filters - self.stream_options = stream_options - - def to_dict(self): - """to_dict method to convert the output filtering configuration to a dictionary. - - :return: dictionary representation of the output filtering configuration. - :rtype: dict - """ - - config = { - "filters": [f.to_dict() for f in self.filters], - } - - if self.stream_options: - config["stream_options"] = self.stream_options - - return config - -class ContentFiltering(JSONSerializable): - """Module for managing and applying content filters.""" - - def __init__( - self, - input_filtering: Optional[InputFiltering] = None, - output_filtering: Optional[OutputFiltering] = None - ): - """Initializes the ContentFiltering with optional input and output filtering configurations. - - :param input_filtering: the configuration for input filtering, defaults to None - :type input_filtering: Optional[InputFiltering], optional - :param output_filtering: the configuration for output filtering, defaults to None - :type output_filtering: Optional[OutputFiltering], optional - """ - self.input_filtering = input_filtering - self.output_filtering = output_filtering - - def to_dict(self): - """to_dict method to convert the content filtering configuration to a dictionary. - - :return: dictionary representation of the content filtering configuration. - :rtype: dict - """ - config = {} - if self.input_filtering: - config["input"] = self.input_filtering.to_dict() - if self.output_filtering: - config["output"] = self.output_filtering.to_dict() - - return config diff --git a/packages/gen/gen_ai_hub/orchestration/models/data_masking.py b/packages/gen/gen_ai_hub/orchestration/models/data_masking.py deleted file mode 100644 index 22b62fc0..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/data_masking.py +++ /dev/null @@ -1,64 +0,0 @@ -from abc import ABC -from enum import Enum -from typing import List - -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -class DataMaskingProviderName(str, Enum): - """ - Enumerates the available data masking providers. - - This enum defines the supported providers for masking sensitive data in the LLM module. - - Values: SAP_DATA_PRIVACY_INTEGRATION: Refers to the SAP Data Privacy Integration service, which offers - anonymization and pseudonymization capabilities for sensitive data. - """ - SAP_DATA_PRIVACY_INTEGRATION = "sap_data_privacy_integration" - - -class DataMaskingProvider(JSONSerializable, ABC): - """ - Abstract base class for data masking providers. - - This class serves as a blueprint for implementing different data masking providers. Each provider is responsible - for masking sensitive or personally identifiable information (PII) according to a specific method. - - Inherited by: - - SAPDataPrivacyIntegration - """ - pass - - -class DataMasking(JSONSerializable): - """ - Manages data masking operations using the specified providers. - - The DataMasking class is responsible for configuring and executing data masking processes - by delegating to one or more data masking providers. It supports either anonymization or pseudonymization - of sensitive information, depending on the provider and method used. - """ - - def __init__(self, providers: List[DataMaskingProvider]): - """Initializes the DataMasking instance with the specified providers. - - :param providers: A list of data masking providers. - :type providers: List[DataMaskingProvider] - :raises ValueError: If more than one provider is specified, as multiple providers - are not supported in the current version. - """ - - if len(providers) > 1: - raise ValueError("Multiple data masking providers are not supported in the current version.") - - self.providers = providers - - def to_dict(self): - """Converts the DataMasking instance to a dictionary representation. - - :return: A dictionary containing the data masking providers. - :rtype: dict - """ - return { - "masking_providers": [provider.to_dict() for provider in self.providers] - } diff --git a/packages/gen/gen_ai_hub/orchestration/models/document_grounding.py b/packages/gen/gen_ai_hub/orchestration/models/document_grounding.py deleted file mode 100644 index a5454760..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/document_grounding.py +++ /dev/null @@ -1,214 +0,0 @@ -from enum import Enum -from typing import List, Dict, Any - -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -class GroundingType(str, Enum): - """ - Enumerates supported grounding types. - """ - DOCUMENT_GROUNDING_SERVICE = "document_grounding_service" - - -class DataRepositoryType(str, Enum): - """ - Enumerates data repository types. - """ - VECTOR = "vector" # DataRepository with vector embeddings - URL = "help.sap.com" # website supporting elastic search - - -class DocumentMetadata(JSONSerializable): - """Restrict documents considered during search to those annotated with the given metadata.""" - - def __init__(self, key: str, value: List[str], select_mode: List[str] = None): - """Initializes the DocumentMetadata instance. - - :param key: The key for the metadata. - :type key: str - :param value: The list of values for the metadata. - :type value: List[str] - :param select_mode: Select mode for search filters. - :type select_mode: List[str], optional - """ - self.key = key - self.value = value - self.select_mode = select_mode - - def to_dict(self): - """Converts the DocumentMetadata instance to a dictionary representation. - - :return: Dictionary representation of the DocumentMetadata. - :rtype: dict - """ - config = {"key": self.key, "value": self.value} - if self.select_mode: - config["select_mode"] = self.select_mode - - return config - - -class GroundingFilterSearch(JSONSerializable): - """Search configuration for the data repository.""" - - def __init__(self, max_chunk_count: int = None, max_document_count: int = None): - """Initializes the GroundingFilterSearch instance. - - :param max_chunk_count: maximum number of chunks > 0 to return, defaults to None - :type max_chunk_count: int, optional - :param max_document_count: Maximum number of documents > 0 to return. - Only supports 'vector' dataRepositoryType. Cannot be used with 'maxChunkCount'. - If maxDocumentCount is given, then only one chunk per document is returned, defaults to None - :type max_document_count: int, optional - :raises ValueError: If both max_chunk_count and max_document_count are set. - """ - self.max_chunk_count = max_chunk_count - self.max_document_count = max_document_count - if self.max_chunk_count and self.max_document_count: - raise ValueError("Cannot set both max_chunk_count and max_document_count") - - def to_dict(self): - """Converts the GroundingFilterSearch instance to a dictionary representation. - - :return: Dictionary representation of the GroundingFilterSearch. - :rtype: dict - """ - config = {} - if self.max_chunk_count: - config["max_chunk_count"] = self.max_chunk_count - if self.max_document_count: - config["max_document_count"] = self.max_document_count - return config - - -class DocumentGroundingFilter(JSONSerializable): - """Module for configuring document grounding filters.""" - - def __init__(self, - id: str, - data_repository_type: str, - search_config: GroundingFilterSearch = None, - data_repositories: List[str] = None, - data_repository_metadata: List[Dict[str, Any]] = None, - document_metadata: List[DocumentMetadata] = None, - chunk_metadata: List[Dict[str, Any]] = None - ): - """Initializes the DocumentGroundingFilter instance. - - :param id: The unique identifier for the grounding filter. - :type id: str - :param data_repository_type: Only include DataRepositories with the given type: - 'vector' or 'url' of website supporting elastic search. - :type data_repository_type: str - :param search_config: GroundingFilterSearchConfiguration object, defaults to None - :type search_config: GroundingFilterSearch, optional - :param data_repositories: list of data repositories to search. - Specify ['*'] to search across all DataRepositories or - give a specific list of DataRepository ids, defaults to None - :type data_repositories: List[str], optional - :param data_repository_metadata: The metadata for the data repository, - Restrict DataRepositories considered during search to those annotated with the given - metadata. Useful when combined with dataRepositories=['*'], defaults to None - :type data_repository_metadata: List[Dict[str, Any]], optional - :param document_metadata: DocumentMetadata object, defaults to None - :type document_metadata: List[DocumentMetadata], optional - :param chunk_metadata: Restrict chunks considered during search to those with the given metadata, - defaults to None - :type chunk_metadata: List[Dict[str, Any]], optional - """ - - self.id = id - self.data_repository_type = data_repository_type - self.search_config = search_config - self.data_repositories = data_repositories - self.data_repository_metadata = data_repository_metadata - self.document_metadata = document_metadata - self.chunk_metadata = chunk_metadata - - def to_dict(self): - """Converts the DocumentGroundingFilter instance to a dictionary representation. - - :return: Dictionary representation of the DocumentGroundingFilter. - :rtype: dict - """ - config = { - "id": self.id, - "data_repository_type": self.data_repository_type, - } - if self.search_config: - config["search_config"] = self.search_config.to_dict() - if self.data_repositories: - config["data_repositories"] = self.data_repositories - if self.data_repository_metadata: - config["data_repository_metadata"] = self.data_repository_metadata - if self.document_metadata: - config["document_metadata"] = [metadata.to_dict() for metadata in self.document_metadata] - if self.chunk_metadata: - config["chunk_metadata"] = self.chunk_metadata - return config - - -class DocumentGrounding(JSONSerializable): - """defines the detailed configuration for the Grounding module.""" - - def __init__(self, input_params: List[str], output_param: str, filters: List[DocumentGroundingFilter] = None, - metadata_params: List[str] = None): - """Initializes the DocumentGrounding instance. - - :param input_params: The list of input parameters used for grounding input questions. - :type input_params: List[str] - :param output_param: Parameter name used for grounding output. - :type output_param: str - :param filters: List of DocumentGroundingFilter objects, defaults to None - :type filters: List[DocumentGroundingFilter], optional - :param metadata_params: Parameter name used for specifying metadata parameters, defaults to None - :type metadata_params: List[str], optional - """ - self.input_params = input_params - self.output_param = output_param - self.filters = filters - self.metadata_params = metadata_params - - def to_dict(self): - """Converts the DocumentGrounding instance to a dictionary representation. - - :return: Dictionary representation of the DocumentGrounding. - :rtype: dict - """ - config = { - "input_params": self.input_params, - "output_param": self.output_param, - } - if self.filters: - config["filters"] = [filter.to_dict() for filter in self.filters] - if self.metadata_params: - config["metadata_params"] = self.metadata_params - - return config - - -class GroundingModule(JSONSerializable): - """Module for managing and applying grounding aka RAG configurations.""" - - def __init__(self, type: str, config: DocumentGrounding): - """Initializes the GroundingModule instance. - - :param type: The type of the grounding module. - :type type: str - :param config: Configuration for the grounding module. - :type config: DocumentGrounding - """ - self.type = type - self.config = config - - def to_dict(self): - """Converts the GroundingModule instance to a dictionary representation. - - :return: Dictionary representation of the GroundingModule. - :rtype: dict - """ - return { - "type": self.type, - "config": self.config.to_dict(), - } diff --git a/packages/gen/gen_ai_hub/orchestration/models/llama_guard_3_filter.py b/packages/gen/gen_ai_hub/orchestration/models/llama_guard_3_filter.py deleted file mode 100644 index 754dccde..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/llama_guard_3_filter.py +++ /dev/null @@ -1,88 +0,0 @@ -from gen_ai_hub.orchestration.models.content_filter import ContentFilter, ContentFilterProvider - - -class LlamaGuard38bFilter(ContentFilter): - """ - Specific implementation of ContentFilter for Llama Guard 3. Llama Guard 3 is a - Llama-3.1-8B pretrained model, fine-tuned for content safety classification. - """ - - def __init__( - self, - violent_crimes: bool = False, - non_violent_crimes: bool = False, - sex_crimes: bool = False, - child_exploitation: bool = False, - defamation: bool = False, - specialized_advice: bool = False, - privacy: bool = False, - intellectual_property: bool = False, - indiscriminate_weapons: bool = False, - hate: bool = False, - self_harm: bool = False, - sexual_content: bool = False, - elections: bool = False, - code_interpreter_abuse: bool = False, - **kwargs): # pylint: disable=too-many-arguments - """Initializes the LlamaGuard38bFilter with specified content filtering options. - - :param violent_crimes: Responses that enable, encourage, or endorse the commission of violent crimes. - Defaults to False. - :type violent_crimes: bool, optional - :param non_violent_crimes: Responses that enable, encourage, or endorse the commission of non-violent crimes. - Defaults to False. - :type non_violent_crimes: bool, optional - :param sex_crimes: Responses that enable, encourage, or endorse the commission of sex-related crimes. - Defaults to False. - :type sex_crimes: bool, optional - :param child_exploitation: Responses that contain, describe, enable or endorse the sexual abuse of children. - Defaults to False. - :type child_exploitation: bool, optional - :param defamation: Responses that are both verifiably false and likely to injure a living person's reputation. - Defaults to False. - :type defamation: bool, optional - :param specialized_advice: Responses that contain specialized financial, medical or legal advice. - Defaults to False. - :type specialized_advice: bool, optional - :param privacy: Responses that contain sensitive or nonpublic personal information. Defaults to False. - :type privacy: bool, optional - :param intellectual_property: Responses that may violate the intellectual property rights of any third party. - Defaults to False. - :type intellectual_property: bool, optional - :param indiscriminate_weapons: Responses that enable, encourage, or endorse the creation of indiscriminate - weapons. Defaults to False. - :type indiscriminate_weapons: bool, optional - :param hate: Responses that demean or dehumanize people on the basis of their sensitive, - personal characteristics. Defaults to False. - :type hate: bool, optional - :param self_harm: Responses that enable, encourage, or endorse acts of intentional self-harm. Defaults to False. - :type self_harm: bool, optional - :param sexual_content: Responses that contain erotica. Defaults to False. - :type sexual_content: bool, optional - :param elections: Responses that contain factually incorrect information about electoral systems and processes. - Defaults to False. - :type elections: bool, optional - :param code_interpreter_abuse: Responses that seek to abuse code interpreters. Defaults to False. - :type code_interpreter_abuse: bool, optional - """ - - super().__init__( - provider=ContentFilterProvider.LLAMA_GUARD_3_8B, - config={ - "violent_crimes": violent_crimes, - "non_violent_crimes": non_violent_crimes, - "sex_crimes": sex_crimes, - "child_exploitation": child_exploitation, - "defamation": defamation, - "specialized_advice": specialized_advice, - "privacy": privacy, - "intellectual_property": intellectual_property, - "indiscriminate_weapons": indiscriminate_weapons, - "hate": hate, - "self_harm": self_harm, - "sexual_content": sexual_content, - "elections": elections, - "code_interpreter_abuse": code_interpreter_abuse, - **kwargs - } - ) diff --git a/packages/gen/gen_ai_hub/orchestration/models/llm.py b/packages/gen/gen_ai_hub/orchestration/models/llm.py deleted file mode 100644 index 98013b66..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/llm.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Optional, Dict - -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -class LLM(JSONSerializable): - """ - Represents a Large Language Model (LLM) configuration. - - This class encapsulates the details required to specify and configure a particular - LLM for use in natural language processing tasks. It includes the model's name, - version, and any additional parameters needed for its operation. - """ - - def __init__( - self, - name: str, - version: str = "latest", - parameters: Optional[Dict] = None, - ): - """Initializes the LLM with specified name, version, and parameters. - - :param name: Name of the LLM. - :type name: str - :param version: Version of the LLM, defaults to "latest" - :type version: str, optional - :param parameters: Additional parameters for the LLM, defaults to None - - Common parameters include: - - - 'temperature': Controls randomness in output. Lower values (e.g., 0.2) - make output more focused and deterministic, while higher values (e.g., 0.8) - make output more diverse and creative. - - - 'max_tokens': Sets the maximum number of tokens to generate in the response. - This can help control the length of the model's output. - - :type parameters: Optional[Dict], optional - """ - self.name = name - self.version = version - self.parameters = parameters or {} - - def to_dict(self): - """Converts the LLM instance to a dictionary representation. - - :return: Dictionary representation of the LLM. - :rtype: dict - """ - return { - "model_name": self.name, - "model_version": self.version, - "model_params": self.parameters, - } diff --git a/packages/gen/gen_ai_hub/orchestration/models/message.py b/packages/gen/gen_ai_hub/orchestration/models/message.py deleted file mode 100644 index e970e999..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/message.py +++ /dev/null @@ -1,239 +0,0 @@ -import json -import typing -from dataclasses import dataclass, field -from enum import Enum -from typing import Union, Optional, List - -from gen_ai_hub.orchestration.models.base import JSONSerializable -from gen_ai_hub.orchestration.models.multimodal_items import ContentPart, ImageItem, TextPart, ImageUrl, ImagePart - -@dataclass -class FunctionCall: - """ - The function that the model called. - """ - name: Optional[str] = field( - default=None, - metadata={"description": "The name of the function to call."} - ) - arguments: Optional[str] = field( - default=None, - metadata={ - "description": ( - "The arguments to call the function with, as generated by the " - "model in JSON format. Note that the model does not always " - "generate valid JSON, and may hallucinate parameters not " - "defined by your function schema. Validate the arguments in " - "your code before calling your function." - ) - } - ) - - def parse_arguments(self) -> dict: - """Parses the arguments string as JSON. - - :return: A dictionary representing the parsed arguments. - :rtype: dict - """ - if self.arguments is None: - return {} - - return json.loads(self.arguments) - - -@dataclass -class MessageToolCall: - """ - Represents a tool call within a message, specifically a function call. - """ - id: str = field(metadata={"description": "The ID of the tool call."}) - type: typing.Literal["function"] = field( - metadata={ - "description": ( - "The type of the tool. Currently, only `function` is supported." - ) - } - ) - function: FunctionCall = field( - metadata={"description": "The function that the model called."} - ) - - def to_dict(self): - """Converts the MessageToolCall instance to a dictionary. - - :return: A dictionary representation of the MessageToolCall instance. - :rtype: dict - """ - return { - "id": self.id, - "type": self.type, - "function": { - "name": self.function.name, - "arguments": self.function.arguments, - } - } - - -class Role(str, Enum): - """ - Enumerates supported roles in LLM-based conversations. - - This enum defines the standard roles used in interactions with Large Language Models (LLMs). - These roles are generally used to structure the input and distinguish between different parts of the conversation. - - Values: - - - USER: Represents the human user's input in the conversation. - - - SYSTEM: Represents system-level instructions or context setting for the LLM. - - - ASSISTANT: Represents the LLM's responses in the conversation. - - - TOOL: Represents a tool or function that the LLM can call. - - - DEVELOPER: Represents the developer's input or instructions in the conversation. - - """ - - USER = "user" - SYSTEM = "system" - ASSISTANT = "assistant" - TOOL = "tool" - DEVELOPER = "developer" - -@dataclass -class Message(JSONSerializable): - """ - Represents a single message in a prompt or conversation template. - - This base class defines the structure for all types of messages in a prompt, - including content and role. - - Args: - role: The role of the entity sending the message. - - content: The message content, which may be plain text or a sequence of text and images. - """ - - role: Union[Role, str] - content: Union[str, List[ContentPart]] - refusal: Optional[str] = None - tool_calls: Optional[List[MessageToolCall]] = None - - def to_dict(self): - """Converts the Message instance to a dictionary. - - :return: A dictionary representation of the Message instance. - :rtype: dict - """ - base = { - "role": self.role, - "content": self.content if isinstance(self.content, str) else [item.to_dict() for item in self.content], - } - - if self.refusal is not None: - base["refusal"] = self.refusal - - if self.tool_calls: - base["tool_calls"] = [tool_call.to_dict() for tool_call in self.tool_calls] - - return base - - -class SystemMessage(Message): - """ - Represents a system message in a prompt or conversation template. - - System messages typically provide context or instructions to the AI model. - """ - - def __init__(self, content: str): - """Initializes a SystemMessage instance. - - :param content: The text content of the system message. - :type content: str - """ - super().__init__(role=Role.SYSTEM, content=content) - - -class UserMessage(Message): - """ - Represents a user message in a prompt or conversation template. - - User messages typically contain queries or inputs from the user. - """ - - def __init__(self, content: Union[str, List[Union[str, ImageItem]]]): - """Initializes a UserMessage instance. - - :param content: The message content, which may be plain text or a sequence of text and images. - :type content: Union[str, List[Union[str, ImageItem]]] - :raises TypeError: If the content list contains unsupported types. - """ - mapped_content = [] - - if isinstance(content, str): - mapped_content = content - elif isinstance(content, list): - for item in content: - if isinstance(item, str): - mapped_content.append(TextPart(text=item)) - elif isinstance(item, ImageItem): - mapped_content.append(ImagePart(image_url=ImageUrl(url=item.url, detail=item.detail))) - else: - raise TypeError("User message content list must contain only str or ImageItem") - - super().__init__(role=Role.USER, content=mapped_content) - - -class AssistantMessage(Message): - """ - Represents an assistant message in a prompt or conversation template. - - Assistant messages typically contain responses or outputs from the AI model. - """ - - def __init__( - self, - content: str, - refusal: Optional[str] = None, - tool_calls: Optional[List[MessageToolCall]] = None, - ): - """Initializes an AssistantMessage instance. - - :param content: The text content of the assistant message. - :type content: str - :param refusal: A string indicating refusal reason, defaults to None - :type refusal: Optional[str], optional - :param tool_calls: A list of tool call objects, defaults to None - :type tool_calls: Optional[List[MessageToolCall]], optional - """ - super().__init__(role=Role.ASSISTANT, content=content, refusal=refusal, tool_calls=tool_calls) - - -class ToolMessage(Message): - """Represents a tool message in a prompt or conversation template. - - :param Message: The text content of the tool message. - :type Message: str - """ - def __init__(self, content: str, tool_call_id: str): - """Initializes a ToolMessage instance. - - :param content: The text content of the tool message. - :type content: str - :param tool_call_id: The ID of the tool call associated with this message. - :type tool_call_id: str - """ - super().__init__(role=Role.TOOL, content=content) - self.tool_call_id = tool_call_id - - def to_dict(self): - """Converts the ToolMessage instance to a dictionary. - - :return: A dictionary representation of the ToolMessage instance. - :rtype: dict - """ - base = super().to_dict() - base["tool_call_id"] = self.tool_call_id - return base diff --git a/packages/gen/gen_ai_hub/orchestration/models/multimodal_items.py b/packages/gen/gen_ai_hub/orchestration/models/multimodal_items.py deleted file mode 100644 index 98a2bd07..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/multimodal_items.py +++ /dev/null @@ -1,171 +0,0 @@ -import base64 -import mimetypes -from dataclasses import dataclass, field -from enum import Enum -from typing import Dict, Any, Optional, Union - -from gen_ai_hub.orchestration.models.base import JSONSerializable - -class ImageDetailLevel(Enum): - """ - Controls the resolution and detail level for image analysis. - - Attributes: - - - AUTO: The model determines the detail level automatically. - - - LOW: The model uses a low-fidelity, faster version of the image. - - - HIGH: The model uses a high-fidelity version of the image. - """ - AUTO = "auto" - LOW = "low" - HIGH = "high" - -@dataclass -class TextPart(JSONSerializable): - """ - Represents a text segment within a multimodal content block. - - Args: - - - text: The string content of the text part. - - - type: The type identifier, defaulting to "text". - """ - text: str - type: str = field(default="text") - - def to_dict(self): - """Converts the TextPart instance to a dictionary. - - :return: A dictionary representation of the TextPart. - :rtype: dict - """ - return { - "type": self.type, - "text": self.text, - } - - -@dataclass -class ImageUrl: - """ - A data structure holding the URL and detail level for an image. - - Args: - - - url: The location of the image, as a standard or data URL. - - - detail: The processing detail level for the image. - """ - url: str - detail: Optional[ImageDetailLevel] = None - - -@dataclass -class ImagePart(JSONSerializable): - """ - Represents an image segment within a multimodal content block. - - Args: - - - image_url: An `ImageUrl` object containing the image's location and detail level. - - type: The type identifier, defaulting to "image_url". - """ - image_url: ImageUrl - type: str = field(default="image_url") - - def to_dict(self): - """Converts the ImagePart instance to a dictionary. - - :return: A dictionary representation of the ImagePart. - :rtype: dict - """ - base = { - "type": self.type, - "image_url": { - "url": self.image_url.url, - }, - } - - if self.image_url.detail: - base["image_url"]["detail"] = self.image_url.detail - - return base - - -ContentPart = Union[TextPart, ImagePart] - - -class ImageItem(JSONSerializable): - """ - Represents an image for use in multimodal messages. - - Examples: - - Using a standard URL - img1 = ImageItem(url="https://example.com/image.png", detail=ImageDetailLevel.HIGH) - - Using a data URL - img2 = ImageItem(url="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...") - """ - - def __init__( - self, - url: Optional[str] = None, - detail: Optional[ImageDetailLevel] = None, - ): - """Initializes an ImageItem instance. - - :param url: The image location as a standard or data URL, defaults to None - - Standard URL example: 'https://example.com/image.png' - - Data URL example: 'data:image/png;base64,...' - :type url: Optional[str], optional - :param detail: The image detail level for model processing, defaults to None - :type detail: Optional[ImageDetailLevel], optional - """ - self.url = url - self.detail = detail - - @staticmethod - def from_file( - file_path: str, - mime_type: Optional[str] = None, - detail: Optional[ImageDetailLevel] = None, - ) -> "ImageItem": - """Creates an ImageItem from a local image file. - - :param file_path: Path to the image file. - :type file_path: str - :param mime_type: Explicit MIME type (e.g., 'image/png'). - If not provided, the MIME type will be guessed from the file extension. - :type mime_type: Optional[str], optional - :param detail: The image detail level for model processing. - :type detail: Optional[ImageDetailLevel], optional - :raises ValueError: If the MIME type cannot be determined and is not provided. - :raises FileNotFoundError: If the file does not exist. - :return: An ImageItem instance with the image data as a data URL. - :rtype: ImageItem - """ - mime = mime_type or mimetypes.guess_type(file_path)[0] - if not mime: - raise ValueError( - f"Could not determine MIME type for file: {file_path}. " - "Please provide mime_type explicitly." - ) - with open(file_path, "rb") as file: - encoded = base64.b64encode(file.read()).decode("utf-8") - data_url = f"data:{mime};base64,{encoded}" - - return ImageItem(url=data_url, detail=detail) - - def to_dict(self) -> Dict[str, Any]: - """Converts the ImageItem instance to a dictionary representation. - - :return: A dictionary representation of the ImageItem. - :rtype: Dict[str, Any] - """ - return ImagePart(image_url=ImageUrl(url=self.url, detail=self.detail)).to_dict() diff --git a/packages/gen/gen_ai_hub/orchestration/models/response.py b/packages/gen/gen_ai_hub/orchestration/models/response.py deleted file mode 100644 index 7c4489cb..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/response.py +++ /dev/null @@ -1,324 +0,0 @@ -from dataclasses import dataclass -from typing import List, Optional, Dict, Any, Union - -from gen_ai_hub.orchestration.models.message import Message, FunctionCall -from gen_ai_hub.orchestration.models.multimodal_items import ContentPart - - -@dataclass -class ToolCallChunk: - """Represents a chunk of a tool call in a streaming chat response.""" - index: int - """The index of this tool call chunk in the sequence of chunks.""" - id: Optional[str] = None - """The unique identifier for the tool call.""" - type: Optional[str] = None - """The type of tool call, e.g., 'function' .""" - function: Optional[FunctionCall] = None - """The function call details associated with this tool call chunk.""" - - -@dataclass -class ChatDelta: - """Represents a partial update in a streaming chat response.""" - content: Union[str, List[ContentPart]] - """The text content of the chat delta.""" - role: Optional[str] = None - """Optional role identifier (e.g., 'assistant', 'user') for the message delta.""" - refusal: Optional[str] = None - """Optional refusal reason if the model refused to generate content.""" - tool_calls: Optional[List[ToolCallChunk]] = None - """Optional list of tool call chunks associated with this chat delta.""" - - -@dataclass -class LLMUsage: - """Represents the token usage statistics for an LLM (Large Language Model) operation. - """ - completion_tokens: int - """The number of tokens generated by the model in the response.""" - prompt_tokens: int - """The number of tokens in the input prompt.""" - total_tokens: int - """The total number of tokens used, including both prompt and completion tokens.""" - - -@dataclass -class LLMChoice: - """ - Represents an individual choice or response generated by the LLM. - - Attributes: - index: The index of this particular choice in the list of possible choices. - - message: The message object containing the role and content of the response. - - finish_reason: The reason why the model stopped generating tokens. - - logprobs: Optional dictionary containing token log probabilities. - """ - index: int - """The index of this particular choice in the list of possible choices.""" - message: Message - """The message object containing the role and content of the response.""" - finish_reason: str - """The reason why the model stopped generating tokens.""" - logprobs: Optional[Dict[str, float]] = None - """Optional dictionary containing token log probabilities.""" - - -@dataclass -class LLMChoiceStreaming: - """ - Represents a streaming choice or partial response generated by the LLM. - - Attributes: - index: The index of this particular choice in the list of possible choices. - - delta: The partial update (ChatDelta) for this choice. - - finish_reason: Optional reason for why the generation stopped, may be None during streaming. - - logprobs: Optional dictionary containing token log probabilities. - """ - index: int - """The index of this particular choice in the list of possible choices.""" - delta: ChatDelta - """The partial update (ChatDelta) for this choice.""" - finish_reason: Optional[str] = None - """Optional reason for why the generation stopped, may be None during streaming.""" - logprobs: Optional[Dict[str, float]] = None - """Optional dictionary containing token log probabilities.""" - - -@dataclass -class BaseLLMResult: - """ - Base class for LLM results containing common attributes. - - Attributes: - id: Unique identifier for the LLM operation. - object: Type of object returned (e.g., "chat.completion"). - created: Timestamp when this result was created. - model: Name or identifier of the model used. - """ - id: str - object: str - created: int - model: str - - -@dataclass -class LLMResult(BaseLLMResult): - """ - Represents the complete result from an LLM operation. - - Attributes: - id: The unique identifier for this LLM operation. - - object: The type of object returned (typically "chat.completion"). - - created: The timestamp when this result was created. - - model: The name or identifier of the model used for generating the result. - - choices: A list of possible choices generated by the LLM. - - usage: The token usage statistics for this operation. - - system_fingerprint: An optional system fingerprint for tracking the model used. - """ - choices: List[LLMChoice] - """A list of possible choices generated by the LLM.""" - usage: LLMUsage - """The token usage statistics for this operation.""" - system_fingerprint: Optional[str] = None - """An optional system fingerprint for tracking the model used.""" - - -@dataclass -class LLMResultStreaming(BaseLLMResult): - """ - Represents a streaming result from an LLM operation. - - Attributes: - id: The unique identifier for this LLM operation. - - object: The type of object returned (typically "chat.completion.chunk"). - - created: The timestamp when this result was created. - - model: The name or identifier of the model used. - - choices: A list of streaming choices generated by the LLM. - - usage: optional token usage statistics for this operation. - - system_fingerprint: An optional system fingerprint for tracking the model used. - """ - choices: List[LLMChoiceStreaming] - """A list of streaming choices generated by the LLM.""" - usage: Optional[LLMUsage] = None - """optional token usage statistics for this operation.""" - system_fingerprint: Optional[str] = None - """An optional system fingerprint for tracking the model used.""" - - -@dataclass -class GenericModuleResult: - """Represents a generic module result in the orchestration process.""" - - message: str - """A message or description generated by the module.""" - data: Optional[Dict[str, Any]] = None - """Additional data relevant to the module result.""" - - -@dataclass -class BaseModuleResults: - """ - Base class for module results containing grounding, common filtering and masking attributes. - - Attributes: - input_filtering: Results from the input filtering module. - - output_filtering: Results from the output filtering module. - - input_masking: Results from the input masking module. - - grounding: A list of extracted text to be provided as grounding context. - - input_translation: Results from the input translation module. - - output_translation: Results from the output translation module. - """ - input_filtering: Optional[GenericModuleResult] = None - """Results from the input filtering module.""" - output_filtering: Optional[GenericModuleResult] = None - """Results from the output filtering module.""" - input_masking: Optional[GenericModuleResult] = None - """Results from the input masking module.""" - grounding: Optional[GenericModuleResult] = None - """A list of extracted text to be provided as grounding context.""" - input_translation: Optional[GenericModuleResult] = None - """Results from the input translation module.""" - output_translation: Optional[GenericModuleResult] = None - """Results from the output translation module.""" - - -@dataclass -class ModuleResults(BaseModuleResults): - """ - Represents the results of various modules used in processing an orchestration request. - - Attributes: - templating: A list of messages that define the conversation's context or template. - - llm: The result from the LLM operation. - - input_filtering: The result of any input filtering, if applicable. - - output_filtering: The result of any output filtering, if applicable. - - input_masking: The result of input masking, if applicable. - - output_unmasking: The result of output unmasking, if applicable. - """ - llm: Optional[LLMResult] = None - """The result from the LLM operation.""" - templating: Optional[List[Message]] = None - """A list of messages that define the conversation's context or template.""" - output_unmasking: Optional[List[LLMChoice]] = None - """The result of output unmasking, if applicable.""" - - -@dataclass -class ModuleResultsStreaming(BaseModuleResults): - """ - Represents the streaming results of various modules used in processing an orchestration request. - - Attributes: - llm: The streaming result from the LLM operation. - - templating: A list of chat deltas that define the conversation's context or template. - - input_filtering: The result of any input filtering, if applicable. - - output_filtering: The result of any output filtering, if applicable. - - input_masking: The result of input masking, if applicable. - - output_unmasking: The result of output unmasking for streaming responses. - """ - llm: Optional[LLMResultStreaming] = None - """The streaming result from the LLM operation.""" - templating: Optional[List[ChatDelta]] = None - """A list of chat deltas that define the conversation's context or template.""" - output_unmasking: Optional[List[LLMChoiceStreaming]] = None - """The result of output unmasking for streaming responses.""" - - -@dataclass -class OrchestrationResponse: - """ - Represents the complete response from an orchestration process. - - Attributes: - request_id: The unique identifier for the request being processed. - - module_results: The results from the various modules involved in processing the request. - - orchestration_result: The final result from the orchestration, typically mirroring the LLM result. - """ - request_id: str - """The unique identifier for the request being processed.""" - module_results: ModuleResults - """The results from the various modules involved in processing the request.""" - orchestration_result: LLMResult - """The final result from the orchestration, typically mirroring the LLM result.""" - - @property - def content(self) -> str: - """Gets the content of the first choice in the orchestration result. - - :raises ValueError: If there are no choices available in the orchestration result. - :return: The content of the first choice. - :rtype: str - """ - - if not self.orchestration_result.choices: - raise ValueError("No choices available in the orchestration result.") - - return self.orchestration_result.choices[0].message.content - -@dataclass -class OrchestrationResponseStreaming: - """ - Represents the streaming response from an orchestration process. - - Attributes: - request_id: The unique identifier for the request being processed. - - module_results: The streaming results from the various modules involved in processing the request. - - orchestration_result: The streaming result from the orchestration. - """ - request_id: str - """The unique identifier for the request being processed.""" - module_results: ModuleResultsStreaming - """The streaming results from the various modules involved in processing the request.""" - orchestration_result: LLMResultStreaming - """The streaming result from the orchestration.""" - -@dataclass -class OrchestrationResponseWithRetries(OrchestrationResponse): - """ - Extended OrchestrationResponse that includes retry count information. - - This is returned when using retry-enabled methods like run_with_retries(). - - Attributes: - retries: Number of retry attempts that were made to successfully complete this request. - """ - retries: int = 0 - """Number of retry attempts that were made to successfully complete this request.""" diff --git a/packages/gen/gen_ai_hub/orchestration/models/response_format.py b/packages/gen/gen_ai_hub/orchestration/models/response_format.py deleted file mode 100644 index c2259a18..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/response_format.py +++ /dev/null @@ -1,151 +0,0 @@ -import re -from enum import Enum -from typing import Optional - -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -class ResponseFormatType(str, Enum): - """ - Enumerates the supported response format. - - Response format that the model output should adhere to. This is the same as the OpenAI definition. - - Values: - TEXT: Response format as text - JSON_OBJECT: Response format as json object - JSON_SCHEMA: Response format as defined json schema - """ - TEXT = "text" - JSON_OBJECT = "json_object" - JSON_SCHEMA = "json_schema" - - -class ResponseFormatText(JSONSerializable): - """ - Response format that the model output should adhere to. - """ - - def to_dict(self): - """Converts the ResponseFormatText instance to a dictionary. - - :return: A dictionary representation of the ResponseFormatText. - :rtype: dict - """ - return {"type": ResponseFormatType.TEXT} - - -class ResponseFormatJsonObject(JSONSerializable): - """ - Response format JSON Object that the model output should adhere to. - """ - - def to_dict(self): - """Converts the ResponseFormatJsonObject instance to a dictionary. - - :return: A dictionary representation of the ResponseFormatJsonObject. - :rtype: dict - """ - return {"type": ResponseFormatType.JSON_OBJECT} - - -class ResponseFormatJsonSchema(JSONSerializable): - """ - Response format JSON Schema that the model output should adhere to. - """ - - def __init__( - self, - name, - schema: object = None, - description: Optional[str] = None, - strict: bool = False - ): - """Initializes a ResponseFormatJsonSchema instance. - - :param name: the name of the response format. - :type name: str - :param schema: the schema for the response format described as a JSON Schema object, defaults to None - :type schema: object, optional - :param description: A description of what the response format is for, defaults to None - :type description: Optional[str], optional - :param strict: Whether to enable strict schema adherence when generating the output, defaults to False - :type strict: bool, optional - """ - self.name = Validator.validate_name(name) - self.desciption = description - self.schema = schema - self.strict = strict - - def to_dict(self): - """Converts the ResponseFormatJsonSchema instance to a dictionary. - - :return: A dictionary representation of the ResponseFormatJsonSchema. - :rtype: dict - """ - json_schema = { - "name": self.name, - "strict": self.strict, - "schema": self.schema - } - if self.desciption: - json_schema['description'] = self.desciption - return { - "type": ResponseFormatType.JSON_SCHEMA, - "json_schema": json_schema - } - - -class ResponseFormatFactory(): - """ - Factory class that maps response format input to classes that can handle to_dict conversion. - """ - @staticmethod - def create_response_format_object(response_format): - """Creates a response format object based on the provided response format. - - :param response_format: The response format input. - :type response_format: Union[ResponseFormatType, ResponseFormatJsonSchema] - :return: An instance of the corresponding response format class. - :rtype: Optional[JSONSerializable] - """ - if response_format == ResponseFormatType.TEXT: - return ResponseFormatText() - - if response_format == ResponseFormatType.JSON_OBJECT: - return ResponseFormatJsonObject() - - if isinstance(response_format, ResponseFormatJsonSchema): - return response_format - - return None - - -class Validator(): - """ - A utility class for validating response format names. - - This class provides methods to validate the names of response formats to ensure - they adhere to specified patterns and length constraints. - """ - @staticmethod - def validate_name(name): - """Validates the name of the response format. - - :param name: The name to validate. - :type name: str - :raises ValueError: If the name does not match the required pattern or exceeds the maximum length. - :return: The validated name. - :rtype: str - """ - pattern = r'^[a-zA-Z0-9_-]+$' - if re.match(pattern, name): - if len(name) > 64: - raise ValueError("The name of the response format must be a-z, A-Z, 0-9, " - "or contain underscores and dashes, with a maximum length of 64.") - else: - raise ValueError("The name of the response format must be a-z, A-Z, 0-9, " - "or contain underscores and dashes, with a maximum length of 64.") - - return name - diff --git a/packages/gen/gen_ai_hub/orchestration/models/sap_data_privacy_integration.py b/packages/gen/gen_ai_hub/orchestration/models/sap_data_privacy_integration.py deleted file mode 100644 index bc80e568..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/sap_data_privacy_integration.py +++ /dev/null @@ -1,190 +0,0 @@ -from enum import Enum -from typing import List - -from gen_ai_hub.orchestration.models.data_masking import DataMaskingProvider, DataMaskingProviderName - - -class MaskingMethod(str, Enum): - """ - Enumerates the supported masking methods. - - This enum defines the two main methods for masking sensitive information: anonymization and pseudonymization. - Anonymization irreversibly removes sensitive data, while pseudonymization allows the original data to be recovered. - - Values: - ANONYMIZATION: Irreversibly replaces sensitive data with placeholders (e.g., MASKED_ENTITY). - - PSEUDONYMIZATION: Replaces sensitive data with reversible placeholders (e.g., MASKED_ENTITY_ID). - """ - ANONYMIZATION = "anonymization" - """Irreversibly replaces sensitive data with placeholders (e.g., MASKED_ENTITY). """ - PSEUDONYMIZATION = "pseudonymization" - """Replaces sensitive data with reversible placeholders (e.g., MASKED_ENTITY_ID). """ - - -class ProfileEntity(str, Enum): - """ - Enumerates the entity categories that can be masked by the SAP Data Privacy Integration service. - - This enum lists different types of personal or sensitive information (PII) that can be detected and masked - by the data masking module, such as personal details, organizational data, contact information, and identifiers. - - Values: - PERSON: Represents personal names. - - ORG: Represents organizational names. - - UNIVERSITY: Represents educational institutions. - - LOCATION: Represents geographical locations. - - EMAIL: Represents email addresses. - - PHONE: Represents phone numbers. - - ADDRESS: Represents physical addresses. - - SAP_IDS_INTERNAL: Represents internal SAP identifiers. - - SAP_IDS_PUBLIC: Represents public SAP identifiers. - - URL: Represents URLs. - - USERNAME_PASSWORD: Represents usernames and passwords. - - NATIONAL_ID: Represents national identification numbers. - - IBAN: Represents International Bank Account Numbers. - - SSN: Represents Social Security Numbers. - - CREDIT_CARD_NUMBER: Represents credit card numbers. - - PASSPORT: Represents passport numbers. - - DRIVING_LICENSE: Represents driving license numbers. - - NATIONALITY: Represents nationality information. - - RELIGIOUS_GROUP: Represents religious group affiliation. - - POLITICAL_GROUP: Represents political group affiliation. - - PRONOUNS_GENDER: Represents pronouns and gender identity. - - GENDER: Represents gender information. - - SEXUAL_ORIENTATION: Represents sexual orientation. - - TRADE_UNION: Represents trade union membership. - - SENSITIVE_DATA: Represents any other sensitive information. - """ - - PERSON = "profile-person" - """Represents personal names. """ - ORG = "profile-org" - """Represents organizational names. """ - UNIVERSITY = "profile-university" - """Represents educational institutions. """ - LOCATION = "profile-location" - """Represents geographical locations. """ - EMAIL = "profile-email" - """Represents email addresses. """ - PHONE = "profile-phone" - """Represents phone numbers. """ - ADDRESS = "profile-address" - """Represents physical addresses. """ - SAP_IDS_INTERNAL = "profile-sapids-internal" - """Represents internal SAP identifiers. """ - SAP_IDS_PUBLIC = "profile-sapids-public" - """Represents public SAP identifiers. """ - URL = "profile-url" - """Represents URLs. """ - USERNAME_PASSWORD = "profile-username-password" - """Represents usernames and passwords. """ - NATIONAL_ID = "profile-nationalid" - """Represents national identification numbers. """ - IBAN = "profile-iban" - """Represents International Bank Account Numbers. """ - SSN = "profile-ssn" - """Represents Social Security Numbers. """ - CREDIT_CARD_NUMBER = "profile-credit-card-number" - """Represents credit card numbers. """ - PASSPORT = "profile-passport" - """Represents passport numbers. """ - DRIVING_LICENSE = "profile-driverlicense" - """Represents driving license numbers. """ - NATIONALITY = "profile-nationality" - """Represents nationality information. """ - RELIGIOUS_GROUP = "profile-religious-group" - """Represents religious group affiliation. """ - POLITICAL_GROUP = "profile-political-group" - """Represents political group affiliation. """ - PRONOUNS_GENDER = "profile-pronouns-gender" - """Represents pronouns and gender identity.""" - GENDER = "profile-gender" - """Represents gender information.""" - SEXUAL_ORIENTATION = "profile-sexual-orientation" - """Represents sexual orientation. """ - TRADE_UNION = "profile-trade-union" - """Represents trade union membership. """ - SENSITIVE_DATA = "profile-sensitive-data" - """Represents any other sensitive information. """ - - -class SAPDataPrivacyIntegration(DataMaskingProvider): - """ - SAP Data Privacy Integration provider for data masking. - - This class implements the SAP Data Privacy Integration service, which can anonymize or pseudonymize - specified entity categories in the input data. It supports masking sensitive information like personal names, - contact details, and identifiers. - """ - - def __init__( - self, - method: MaskingMethod, - entities: List[ProfileEntity], - allowlist: List[str] = None, - mask_grounding_input: bool = False, - ): - """Initializes the SAPDataPrivacyIntegration data masking provider. - - :param method: The method of masking to apply - :type method: MaskingMethod - :param entities: A list of entity categories to be masked - :type entities: List[ProfileEntity] - :param allowlist: A list of strings that should not be masked, defaults to None - :type allowlist: List[str], optional - :param mask_grounding_input: A flag indicating whether to mask input to the grounding module, defaults to False - :type mask_grounding_input: bool, optional - """ - self.method = method - self.entities = entities - self.allowlist = allowlist or [] - self.mask_grounding_input = mask_grounding_input - - def to_dict(self): - """Converts the SAPDataPrivacyIntegration instance to a dictionary representation. - - :return: Dictionary representation of the SAPDataPrivacyIntegration instance. - :rtype: dict - """ - result = { - "type": DataMaskingProviderName.SAP_DATA_PRIVACY_INTEGRATION, - "method": self.method, - "entities": [ - { - "type": entity - } for entity in self.entities - ], - "mask_grounding_input": { - "enabled": self.mask_grounding_input - } - } - - if self.allowlist: - result["allowlist"] = self.allowlist - - return result diff --git a/packages/gen/gen_ai_hub/orchestration/models/template.py b/packages/gen/gen_ai_hub/orchestration/models/template.py deleted file mode 100644 index 5b9aa4de..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/template.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import List, Optional, Union, NamedTuple, Dict, Any - -from gen_ai_hub.orchestration.models.base import JSONSerializable -from gen_ai_hub.orchestration.models.message import Message -from gen_ai_hub.orchestration.models.response_format import ( - ResponseFormatType, - ResponseFormatJsonSchema, - ResponseFormatFactory -) -from gen_ai_hub.orchestration.models.tools import ChatCompletionTool - - -class TemplateValue(NamedTuple): - """ - Represents a named value for use in template substitution. - - This class pairs a name with a corresponding value, which can be a string, - integer, or float. It's designed to be used in template rendering processes - where named placeholders are replaced with specific values. - """ - - name: str - """The identifier for this template value.""" - value: Union[str, int, float] - """The actual value to be used in substitution.""" - -class Template(JSONSerializable): - """ - Represents a configurable template for generating prompts or conversations. - """ - - def __init__( - self, - messages: List[Message], - defaults: Optional[List[TemplateValue]] = None, - tools: Optional[List[Union[dict, ChatCompletionTool]]] = None, - response_format: Optional[Union[ - ResponseFormatType.TEXT, - ResponseFormatType.JSON_OBJECT, - ResponseFormatJsonSchema - ]] = None, - ): - """Initializes a Template instance. - - :param messages: list of prompt messages that form the template._ - :type messages: List[Message] - :param defaults: list of default values for template variables, defaults to None - :type defaults: Optional[List[TemplateValue]], optional - :param tools: list of tool definitions, defaults to None - :type tools: Optional[List[Union[dict, ChatCompletionTool]]], optional - :param response_format: response format that the model output should adhere to, defaults to None - :type response_format: Optional[Union[ ResponseFormatType.TEXT, - ResponseFormatType.JSON_OBJECT, ResponseFormatJsonSchema ]], optional - """ - self.messages = messages - self.defaults = defaults or [] - self.tools = tools or [] - self.response_format = response_format - - def to_dict(self) -> Dict[str, Any]: - """Converts the Template instance to a dictionary representation. - Serializes the template to a dictionary, converting tools as needed. - - :raises ValueError: If an invalid tool is encountered in the tools list. - :return: A dictionary representation of the Template instance. - :rtype: Dict[str, Any] - """ - - template_dict: Dict[str, Any] = { - "template": [message.to_dict() for message in self.messages], - "defaults": {default.name: default.value for default in self.defaults}, - } - - if self.tools: - tool_dicts = [] - for idx, tool in enumerate(self.tools): - if isinstance(tool, ChatCompletionTool): - tool_dicts.append(tool.to_dict()) - elif isinstance(tool, dict): - tool_dicts.append(tool) - else: - raise ValueError( - f"Invalid tool at index {idx}: {tool!r} (type: {type(tool).__name__}). " - "If you are passing a function, decorate it with @function_tool." - ) - template_dict["tools"] = tool_dicts - - if self.response_format: - template_dict["response_format"] = ( - ResponseFormatFactory.create_response_format_object( - self.response_format - ).to_dict() - ) - - return template_dict diff --git a/packages/gen/gen_ai_hub/orchestration/models/template_ref.py b/packages/gen/gen_ai_hub/orchestration/models/template_ref.py deleted file mode 100644 index 957fb8b9..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/template_ref.py +++ /dev/null @@ -1,61 +0,0 @@ -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -class TemplateRef(JSONSerializable): - """ - Represents a prompt template reference for generating prompts or conversations. - - This is a factory class for creating a reference to a prompt template. - It is used to reference a template by id, or the tuple: scenario, name, version - - """ - - def __init__(self, **kwargs): - """Initializes a TemplateRef instance with dynamic attributes.""" - for key, value in kwargs.items(): - setattr(self, key, value) - - @classmethod - def from_id( - cls, - prompt_template_id: str - ): - """Creates a TemplateRef instance from a prompt template ID. - - :param prompt_template_id: The ID of the prompt template. - :type prompt_template_id: str - :return: A TemplateRef instance with the specified ID. - :rtype: TemplateRef - """ - return cls(id=prompt_template_id) - - @classmethod - def from_tuple( - cls, - scenario: str, - name: str, - version: str - ): - """Creates a TemplateRef instance from a scenario, name, and version. - - :param scenario: The scenario of the prompt template. - :type scenario: str - :param name: The name of the prompt template. - :type name: str - :param version: The version of the prompt template. - :type version: str - :return: A TemplateRef instance with the specified scenario, name, and version. - :rtype: TemplateRef - """ - return cls(scenario=scenario, name=name, version=version) - - def to_dict(self): - """Converts the TemplateRef instance to a dictionary representation. - - :return: A dictionary representation of the TemplateRef instance. - :rtype: dict - """ - template_ref = {} - for key, value in self.__dict__.items(): - template_ref[key] = value - return { "template_ref": template_ref } diff --git a/packages/gen/gen_ai_hub/orchestration/models/tools.py b/packages/gen/gen_ai_hub/orchestration/models/tools.py deleted file mode 100644 index f6c7c3b9..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/tools.py +++ /dev/null @@ -1,261 +0,0 @@ -import inspect -import typing -from typing import Any, Callable, Dict, Optional - -from gen_ai_hub.orchestration.models.base import JSONSerializable - - -def python_type_to_json_type(py_type): - """Converts a Python type to a JSON Schema type. - - :param py_type: The Python type to convert. - :type py_type: type - :return: A dictionary representing the JSON Schema type. - :rtype: Dict[str, Any] - """ - origin = typing.get_origin(py_type) - args = typing.get_args(py_type) - - # Simple types - if py_type is str: - return {"type": "string"} - if py_type in (int, float): - return {"type": "number"} - if py_type is bool: - return {"type": "boolean"} - if py_type is type(None): - return {"type": "null"} - - # List/array - if origin in (list, typing.List): - item_type = args[0] if args else str - return { - "type": "array", - "items": python_type_to_json_type(item_type) - } - - # Dict/object - if origin in (dict, typing.Dict): - value_type = args[1] if len(args) > 1 else str - return { - "type": "object", - "additionalProperties": python_type_to_json_type(value_type) - } - - # Union/Optional - if origin is typing.Union: - json_types = [python_type_to_json_type(a) for a in args] - # Handle Optional[X] (Union[X, NoneType]) - non_null_types = [t for t in json_types if t.get("type") != "null"] - if len(json_types) == 2 and len(non_null_types) == 1: - result = non_null_types[0].copy() - result["nullable"] = True - return result - return {"anyOf": json_types} - - # Fallback - return {"type": "string"} - -class ChatCompletionTool(JSONSerializable): - """ - Base class for all chat completion tools. - """ - def __init__(self, type_: str): - """Initializes a ChatCompletionTool instance. - - :param type_: The type of the tool. - :type type_: str - """ - self.type = type_ - - def to_dict(self) -> Dict[str, Any]: - """Converts the ChatCompletionTool instance to a dictionary representation. - - :return: A dictionary representation of the ChatCompletionTool instance. - :rtype: Dict[str, Any] - """ - return { - "type": self.type, - } - - -class FunctionTool(ChatCompletionTool): - """ - Represents a function tool for OpenAI-like function calling. - """ - def __init__( - self, - name: str, - parameters: dict, - strict: bool = False, - description: Optional[str] = None, - function: Optional[Callable] = None, - ): - """Initializes a FunctionTool instance. - - :param name: The name of the function. - :type name: str - :param parameters: The parameters schema for the function. - :type parameters: dict - :param strict: Whether to enforce strict parameter checking, defaults to False - :type strict: bool, optional - :param description: The description of the function, defaults to None - :type description: Optional[str], optional - :param function: The actual callable function, defaults to None - :type function: Optional[Callable], optional - """ - super().__init__(type_="function") - self.name = name - self.description = description - self.parameters = parameters - self.strict = strict - self.function = function - - def to_dict(self) -> Dict[str, Any]: - """Converts the FunctionTool instance to a dictionary representation. - - :return: A dictionary representation of the FunctionTool instance. - :rtype: Dict[str, Any] - """ - base = { - "type": self.type, - "function": { - "name": self.name, - "parameters": self.parameters, - "strict": self.strict, - }, - } - - if self.description: - base["function"]["description"] = self.description - - return base - - def execute(self, **kwargs: Any) -> Any: - """Execute the function with the provided arguments. - - :raises ValueError: If the function is not set or if unexpected arguments are provided in strict mode. - :return: The result of the function execution. - :rtype: Any - """ - if self.function is None: - raise ValueError("Function is not set.") - - if self.strict: - for key in kwargs.keys(): - if key not in self.parameters["properties"]: - raise ValueError(f"Unexpected argument '{key}' for function '{self.name}'") - - return self.function(**kwargs) - - async def aexecute(self, **kwargs: Any) -> Any: - """Asynchronously execute the function with the provided arguments. - - :raises ValueError: If the function is not set or if unexpected arguments are provided in strict mode. - :return: The result of the function execution. - :rtype: Any - """ - if self.function is None: - raise ValueError("Function is not set.") - - if self.strict: - for key in kwargs.keys(): - if key not in self.parameters["properties"]: - raise ValueError(f"Unexpected argument '{key}' for function '{self.name}'") - - return await self.function(**kwargs) - - - @staticmethod - def from_function( - func: Callable, - *, - description: Optional[str] = None, - strict: bool = False - ) -> "FunctionTool": - """Create a FunctionTool from a Python function. - - :param func: The Python function to convert. - :type func: Callable - :param description: The description of the function, defaults to None - :type description: Optional[str], optional - :param strict: Whether to enforce strict parameter checking, defaults to False - :type strict: bool, optional - :raises TypeError: If any parameter is missing a type hint. - :return: A FunctionTool instance. - :rtype: FunctionTool - """ - - tool_description = description or inspect.getdoc(func) - sig = inspect.signature(func) - type_hints = typing.get_type_hints(func) - param_schema = {} - - for name, param in sig.parameters.items(): - if name not in type_hints: - raise TypeError( - f"Parameter '{name}' in '{func.__name__}' is missing a type hint." - ) - param_type = type_hints.get(name, str) - param_schema[name] = python_type_to_json_type(param_type) - - parameters = { - "type": "object", - "properties": param_schema, - "required": [ - name for name, param in sig.parameters.items() - if param.default is inspect.Parameter.empty - ], - "additionalProperties": False - } - - return FunctionTool( - name=func.__name__, - description=tool_description, - parameters=parameters, - strict=strict, - function=func, - ) - - -def function_tool( - func: Optional[Callable] = None, *, description: Optional[str] = None, strict: bool = False -) -> Callable[[Callable], FunctionTool] | FunctionTool: - """Create a decorator that converts a function into a FunctionTool. - - Usage: - - @function_tool - - def my_func(...): ... - - @function_tool() - - def my_func(...): ... - - :param func: The function to convert, defaults to None - :type func: Optional[Callable], optional - :param description: The description of the function, defaults to None - :type description: Optional[str], optional - :param strict: Whether to enforce strict parameter checking, defaults to False - :type strict: bool, optional - :return: A FunctionTool instance or a decorator function. - :rtype: Callable[[Callable], FunctionTool] | FunctionTool - """ - - def decorator(func_: Callable) -> FunctionTool: - """Create a FunctionTool from the decorated function. - - :param func_: The decorated function. - :type func_: Callable - :return: A FunctionTool instance. - :rtype: FunctionTool - """ - return FunctionTool.from_function(func=func_, description=description, strict=strict) - - if func is not None and callable(func): - # Used as @function_tool - return decorator(func) - else: - # Used as @function_tool() - return decorator diff --git a/packages/gen/gen_ai_hub/orchestration/models/translation/sap_document_translation.py b/packages/gen/gen_ai_hub/orchestration/models/translation/sap_document_translation.py deleted file mode 100644 index d63d44f2..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/translation/sap_document_translation.py +++ /dev/null @@ -1,34 +0,0 @@ -from gen_ai_hub.orchestration.models.translation.translation import Translation -from gen_ai_hub.orchestration.models.translation.translation import InputTranslationModule, \ - InputTranslationConfig, OutputTranslationModule, OutputTranslationConfig, TranslationType - - -class SAPDocumentTranslation(Translation): - """SAPTranslationHub represents the translation service provided by SAP.""" - - def __init__(self, input_translation_config: InputTranslationConfig = None, - output_translation_config: OutputTranslationConfig = None): - """Initializes the SAPDocumentTranslation with optional input and output translation configurations. - - :param input_translation_config: the configuration for input translation, defaults to None - :type input_translation_config: InputTranslationConfig, optional - :param output_translation_config: the configuration for output translation, defaults to None - :type output_translation_config: OutputTranslationConfig, optional - """ - - input_translation_module = None - output_translation_module = None - - if input_translation_config is not None: - input_translation_module = InputTranslationModule( - type=TranslationType.SAP_DOCUMENT_TRANSLATION, - config=input_translation_config - ) - - if output_translation_config is not None: - output_translation_module = OutputTranslationModule( - type=TranslationType.SAP_DOCUMENT_TRANSLATION, - config=output_translation_config - ) - super().__init__(input_translation=input_translation_module, - output_translation=output_translation_module) diff --git a/packages/gen/gen_ai_hub/orchestration/models/translation/translation.py b/packages/gen/gen_ai_hub/orchestration/models/translation/translation.py deleted file mode 100644 index 4a1f6dcc..00000000 --- a/packages/gen/gen_ai_hub/orchestration/models/translation/translation.py +++ /dev/null @@ -1,143 +0,0 @@ -from gen_ai_hub.orchestration.models.base import JSONSerializable -from enum import Enum - - -class TranslationType(str, Enum): - """Enumerates supported translation types.""" - SAP_DOCUMENT_TRANSLATION = "sap_document_translation" - - -class InputTranslationConfig(JSONSerializable): - """Configuration for input translation. These parameters are specific to SAP Translation Hub.""" - - def __init__(self, source_language: str, target_language: str): - """Initializes the InputTranslationConfig with source and target languages. - - :param source_language: the source language code (e.g., 'de-DE' for German). - :type source_language: str - :param target_language: the target language code (e.g., 'en-US' for US English). - :type target_language: str - """ - - self.source_language = source_language - self.target_language = target_language - - def to_dict(self): - """to_dict method to convert the configuration to a dictionary. - - :return: dictionary representation of the configuration. - :rtype: dict - """ - - return { - "source_language": self.source_language, - "target_language": self.target_language - } - - -class InputTranslationModule(JSONSerializable): - """Configuration for input translation module. - - :param JSONSerializable: _description_ - :type JSONSerializable: _type_ - :return: _description_ - :rtype: _type_ - """ - - def __init__(self, type: str, config: InputTranslationConfig): - """Initializes the InputTranslationModule with type and configuration. - - :param type: The type of translation module (e.g., 'sap_document_translation'). - :type type: str - :param config: Configuration object for the translation module. - :type config: InputTranslationConfig - """ - - self.type = type - self.config = config - - def to_dict(self): - """to_dict method to convert the module to a dictionary. - - :return: dictionary representation of the module. - :rtype: dict - """ - return { - "type": self.type, - "config": self.config.to_dict() - } - - -class OutputTranslationConfig(JSONSerializable): - """Configuration for output translation. - - :param JSONSerializable: _description_ - :type JSONSerializable: _type_ - :return: _description_ - :rtype: _type_ - """ - - def __init__(self, target_language: str, source_language: str = None): - """Initializes the OutputTranslationConfig with target and optional source languages. These parameters are specific to SAP Translation Hub. - - :param target_language: the target language code (e.g., 'en-US' for US English). - :type target_language: str - :param source_language: the source language code (e.g., 'de-DE' for German), defaults to None - :type source_language: str, optional - """ - self.target_language = target_language - self.source_language = source_language - - def to_dict(self): - """to_dict method to convert the configuration to a dictionary. - :return: dictionary representation of the configuration. - :rtype: dict - """ - - return { - "target_language": self.target_language, - "source_language": self.source_language - } - - -class OutputTranslationModule(JSONSerializable): - """Configuration for output translation module.""" - - def __init__(self, type: str, config: OutputTranslationConfig): - """Initializes the OutputTranslationModule with type and configuration. - - :param type: The type of translation module (e.g., 'sap_document_translation'). - :type type: str - :param config: Configuration object for the translation module. - :type config: OutputTranslationConfig - """ - - self.type = type - self.config = config - - def to_dict(self): - """to_dict method to convert the module to a dictionary. - :return: dictionary representation of the module. - :rtype: dict - """ - - return { - "type": self.type, - "config": self.config.to_dict() - } - - -class Translation: - """Translation module for managing input and output translations.""" - - def __init__(self, input_translation: InputTranslationModule = None, - output_translation: OutputTranslationModule = None): - """Initializes the Translation module with optional input and output translation configurations. - - :param input_translation: the configuration for input translation, defaults to None - :type input_translation: InputTranslationModule, optional - :param output_translation: the configuration for output translation, defaults to None - :type output_translation: OutputTranslationModule, optional - """ - self.input_translation = input_translation - self.output_translation = output_translation diff --git a/packages/gen/gen_ai_hub/orchestration/service.py b/packages/gen/gen_ai_hub/orchestration/service.py deleted file mode 100644 index 349abd23..00000000 --- a/packages/gen/gen_ai_hub/orchestration/service.py +++ /dev/null @@ -1,708 +0,0 @@ -""" -Module for orchestration service handling requests and responses. - -Provides synchronous and asynchronous methods to run orchestration pipelines. -""" - -from copy import deepcopy -from dataclasses import dataclass -from enum import Enum -from functools import wraps -import asyncio -import random -import time -import logging -from typing import List, Optional, Iterable, Union - -import dacite -from gen_ai_hub.orchestration.exceptions import OrchestrationError -import httpx -from ai_api_client_sdk.models.status import Status - -from gen_ai_hub import GenAIHubProxyClient -from gen_ai_hub.orchestration.models.base import JSONSerializable -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.message import Message -from gen_ai_hub.orchestration.models.response import OrchestrationResponse, OrchestrationResponseStreaming, \ - OrchestrationResponseWithRetries -from gen_ai_hub.orchestration.models.template import TemplateValue -from gen_ai_hub.orchestration.sse_client import SSEClient, AsyncSSEClient, _handle_http_error -from gen_ai_hub.proxy import get_proxy_client - -COMPLETION_SUFFIX = "/completion" - - -@dataclass -class OrchestrationRequest(JSONSerializable): - """ - Represents a request for the orchestration process, including configuration, - template values, and message history. - """ - config: OrchestrationConfig - """The orchestration configuration for the request. - - :return: OrchestrationConfig - :rtype: OrchestrationConfig - """ - template_values: List[TemplateValue] - """List of template values to be used in the orchestration.""" - history: List[Message] - """List of messages representing the conversation history.""" - - def to_dict(self): - """Converts the OrchestrationRequest instance to a dictionary. - - :return: Dictionary representation of the OrchestrationRequest - :rtype: dict - """ - return { - "orchestration_config": self.config.to_dict(), - "input_params": {value.name: str(value.value) for value in self.template_values}, - "messages_history": [message.to_dict() for message in self.history], - } - - -def cache_if_not_none(func): - """Custom cache decorator that only caches non-None results - - :param func: The function to be decorated. - :type func: callable - :return: The decorated function with caching behavior. - :rtype: callable - """ - cache = {} - - @wraps(func) - def wrapper(*args, **kwargs): - """Wrapper function that implements caching logic. - - :return: The result of the decorated function, either from cache or freshly computed. - :rtype: Any - """ - key = (args, frozenset(kwargs.items())) # Create hashable key for cache - if key not in cache: - result = func(*args, **kwargs) - if result is not None: # Only cache if result is not None - cache[key] = result - return result - return cache[key] - - def cache_clear(): - cache.clear() - - wrapper.cache_clear = cache_clear - return wrapper - - -# pylint: disable=too-many-arguments,too-many-positional-arguments -@cache_if_not_none -def discover_orchestration_api_url(base_url: str, - auth_url: str, - client_id: str, - client_secret: str, - resource_group: str, - config_id: Optional[str] = None, - config_name: Optional[str] = None, - orchestration_scenario: str = "orchestration", - executable_id: str = "orchestration") -> Optional[str]: - """Discovers the orchestration API URL based on provided configuration details. - - :param base_url: the base URL for the AI Core API. - :type base_url: str - :param auth_url: the URL for the AI Core authentication service. - :type auth_url: str - :param client_id: the client ID for the AI Core API. - :type client_id: str - :param client_secret: the client secret for the AI Core API. - :type client_secret: str - :param resource_group: the resource group for the AI Core API. - :type resource_group: str - :param config_id: the configuration ID, defaults to None - :type config_id: Optional[str], optional - :param config_name: the configuration name, defaults to None - :type config_name: Optional[str], optional - :param orchestration_scenario: the orchestration scenario ID, defaults to "orchestration" - :type orchestration_scenario: str, optional - :param executable_id: the orchestration executable ID, defaults to "orchestration" - :type executable_id: str, optional - :return: The orchestration API URL or None if no deployment is found. - :rtype: Optional[str] - """ - proxy_client = GenAIHubProxyClient( - base_url=base_url, - auth_url=auth_url, - client_id=client_id, - client_secret=client_secret, - resource_group=resource_group - ) - deployments = proxy_client.ai_core_client.deployment.query( - scenario_id=orchestration_scenario, - executable_ids=[executable_id], - status=Status.RUNNING - ) - if deployments.count > 0: - sorted_deployments = sorted(deployments.resources, key=lambda x: x.start_time)[::-1] - check_for = {} - if config_name: - check_for["configuration_name"] = config_name - if config_id: - check_for["configuration_id"] = config_id - if not check_for: - return sorted_deployments[0].deployment_url - for deployment in sorted_deployments: - if all(getattr(deployment, key) == value for key, value in check_for.items()): - return deployment.deployment_url - return None - - -def get_orchestration_api_url(proxy_client: GenAIHubProxyClient, - deployment_id: Optional[str] = None, - config_name: Optional[str] = None, - config_id: Optional[str] = None) -> str: - """Retrieves the orchestration API URL based on provided deployment or configuration details. - - :param proxy_client: The GenAIHubProxyClient instance. - :type proxy_client: GenAIHubProxyClient - :param deployment_id: the deployment ID, defaults to None - :type deployment_id: Optional[str], optional - :param config_name: the configuration name, defaults to None - :type config_name: Optional[str], optional - :param config_id: the configuration ID, defaults to None - :type config_id: Optional[str], optional - :raises ValueError: If no orchestration deployment is found. - :return: The orchestration API URL. - :rtype: str - """ - - if deployment_id: - return f"{proxy_client.ai_core_client.base_url.rstrip('/')}/inference/deployments/{deployment_id}" - url = discover_orchestration_api_url( - **proxy_client.model_dump(exclude='ai_core_client'), - config_name=config_name, - config_id=config_id - ) - if url is None: - raise ValueError('No Orchestration deployment found!') - return url - - -class OrchestrationService: - """A service for executing orchestration requests, allowing for the generation of - LLM-generated content through a pipeline of configured modules. This service supports both synchronous and - asynchronous request execution. For streaming responses, special care is taken to not close the underlying - HTTP stream prematurely. - - https://api.sap.com/api/ORCHESTRATION_API/overview - """ - - def __init__(self, - api_url: Optional[str] = None, - config: Optional[OrchestrationConfig] = None, - proxy_client: Optional[GenAIHubProxyClient] = None, - deployment_id: Optional[str] = None, - config_name: Optional[str] = None, - config_id: Optional[str] = None, - timeout: Union[int, float, httpx.Timeout, None] = None): - """Initializes the OrchestrationService with the provided parameters. - - :param api_url: The base URL for the orchestration API, defaults to None - :type api_url: Optional[str], optional - :param config: The default orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param proxy_client: The GenAIHubProxyClient instance, defaults to None - :type proxy_client: Optional[GenAIHubProxyClient], optional - :param deployment_id: the deployment ID, defaults to None - :type deployment_id: Optional[str], optional - :param config_name: the configuration name, defaults to None - :type config_name: Optional[str], optional - :param config_id: the configuration ID, defaults to None - :type config_id: Optional[str], optional - :param timeout: the timeout for HTTP requests, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - """ - self.proxy_client = proxy_client or get_proxy_client(proxy_version="gen-ai-hub") - if api_url: - self.api_url = api_url - else: - self.api_url = get_orchestration_api_url(self.proxy_client, deployment_id, config_name, config_id) - self.config = config - self.timeout = timeout - # create reusable httpx client to improve performance - self.client = httpx.Client(timeout=self.timeout) - self.async_client = httpx.AsyncClient(timeout=self.timeout) - - def _determine_timeout(self, timeout: httpx.Timeout) -> httpx.Timeout: - # Determine the timeout to use for this request - if timeout is not None: - # Overwrite default timeout for this request - request_timeout = timeout - elif self.timeout is not None: - # Use the default timeout is set - request_timeout = self.timeout - else: - # If timeout is not set, use httpx client's default behavior, rather than "None" (disables timeout) - request_timeout = httpx.USE_CLIENT_DEFAULT - return request_timeout - - def _should_retry(self, error: Exception) -> bool: - """Determines if a request should be retried based on the error type. - - :param error: The exception that occurred. - :type error: Exception - :return: True if the error is retryable (only 429 rate limit errors), False otherwise. - :rtype: bool - """ - if isinstance(error, httpx.HTTPStatusError): - return error.response.status_code == 429 - return False - - def _get_retry_after(self, error: Exception) -> Optional[float]: - """Extracts the Retry-After header value from a 429 response if available. - - :param error: The exception that occurred. - :type error: Exception - :return: Number of seconds to wait before retrying, or None if not specified. - :rtype: Optional[float] - """ - if isinstance(error, httpx.HTTPStatusError) and error.response.status_code == 429: - retry_after = error.response.headers.get('Retry-After') - if retry_after: - try: - # Retry-After can be in seconds (integer) or HTTP date format - return float(retry_after) - except ValueError: - # If it's a date format, we'll fall back to exponential backoff - return None - return None - - def _calculate_backoff(self, retry_count: int, base_delay: float = 1.0, max_delay: float = 60.0, - min_delay: float = 0.0) -> float: - """Calculates exponential backoff delay with jitter. - - :param retry_count: The current retry attempt number. - :type retry_count: int - :param base_delay: the initial delay in seconds, defaults to 1.0 - :type base_delay: float, optional - :param max_delay: the maximum delay in seconds, defaults to 60.0 - :type max_delay: float, optional - :param min_delay: the minimum delay in seconds, defaults to 0.0 - :type min_delay: float, optional - :return: Delay in seconds before the next retry. - :rtype: float - """ - # Calculate exponential delay: base_delay * 2^retry_count - exp_delay = base_delay * (2 ** retry_count) - - # Cap at max_delay - capped = min(exp_delay, max_delay) - - # Ensure the lower bound doesn't exceed the cap - lower = max(0.0, min_delay) - if lower >= capped: - return capped - - # Return random value in range [lower, capped] for jitter - return random.uniform(lower, capped) - - def _execute_request( - self, - config: OrchestrationConfig, - template_values: List[TemplateValue], - history: List[Message], - stream: bool, - stream_options: Optional[dict] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - ) -> Union[OrchestrationResponse, Iterable[OrchestrationResponseStreaming]]: - """Executes an orchestration request synchronously. - For streaming requests, this method creates a single HTTP stream. It manually enters the stream's - context to obtain the response, checks for HTTP errors, and then passes both the open response and - a custom close function to the SSE client. The SSEClient will then yield streaming events and - close the HTTP stream upon completion. - - :param config: the orchestration configuration. - :type config: OrchestrationConfig - :param template_values: the template values for the request. - :type template_values: List[TemplateValue] - :param history: the message history. - :type history: List[Message] - :param stream: whether to stream the response. - :type stream: bool - :param stream_options: additional streaming options, defaults to None - :type stream_options: Optional[dict], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :raises ValueError: If no configuration is provided. - :raises OrchestrationError: If the HTTP request fails. - :return: An OrchestrationResponse if not streaming, or an iterable of OrchestrationResponseStreaming - :rtype: Union[OrchestrationResponse, Iterable[OrchestrationResponseStreaming]] - :yield: OrchestrationResponseStreaming objects if streaming. - :rtype: Iterator[Union[OrchestrationResponse, Iterable[OrchestrationResponseStreaming]]] - """ - if config is None: - raise ValueError("A configuration is required to invoke the orchestration service.") - config_copy = deepcopy(config) - config_copy._stream = stream - if stream_options: - config_copy.stream_options = stream_options - request_obj = OrchestrationRequest( - config=config_copy, - template_values=template_values or [], - history=history or [], - ) - - if stream: - # Create the streaming response context manager. - response_cm = self.client.stream( - "POST", - self.api_url + COMPLETION_SUFFIX, - headers=self.proxy_client.request_header, - json=request_obj.to_dict(), - timeout=self._determine_timeout(timeout) - ) - return SSEClient(response_cm, prefix="data: ", final_message="[DONE]") - - response = self.client.post( - self.api_url + COMPLETION_SUFFIX, - headers=self.proxy_client.request_header, - json=request_obj.to_dict(), - timeout=self._determine_timeout(timeout) - ) - try: - response.raise_for_status() - except httpx.HTTPStatusError as error: - _handle_http_error(error, response) - - data = response.json() - return dacite.from_dict( - data_class=OrchestrationResponse, - data=data, - config=dacite.Config(cast=[Enum]), - ) - - async def _a_execute_request( - self, - config: OrchestrationConfig, - template_values: List[TemplateValue], - history: List[Message], - stream: bool, - stream_options: Optional[dict] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - ) -> Union[OrchestrationResponse, AsyncSSEClient]: - """Executes an orchestration request asynchronously. - - :param config: the orchestration configuration. - :type config: OrchestrationConfig - :param template_values: the template values for the request. - :type template_values: List[TemplateValue] - :param history: the message history. - :type history: List[Message] - :param stream: whether to stream the response. - :type stream: bool - :param stream_options: additional streaming options, defaults to None - :type stream_options: Optional[dict], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :raises ValueError: If no configuration is provided. - :raises OrchestrationError: If the HTTP request fails. - :return: An OrchestrationResponse if not streaming, or an AsyncSSEClient for iterating over - the streaming response. - :rtype: Union[OrchestrationResponse, AsyncSSEClient] - """ - if config is None: - raise ValueError("A configuration is required to invoke the orchestration service.") - config_copy = deepcopy(config) - config_copy._stream = stream - if stream_options: - config_copy.stream_options = stream_options - request_obj = OrchestrationRequest( - config=config_copy, - template_values=template_values or [], - history=history or [], - ) - - if stream: - response_cm = self.async_client.stream( - "POST", - self.api_url + COMPLETION_SUFFIX, - headers=self.proxy_client.request_header, - json=request_obj.to_dict(), - timeout=self._determine_timeout(timeout) - ) - return AsyncSSEClient(response_cm, prefix="data: ", final_message="[DONE]") - - response = await self.async_client.post( - self.api_url + COMPLETION_SUFFIX, - headers=self.proxy_client.request_header, - json=request_obj.to_dict(), - timeout=self._determine_timeout(timeout) - ) - try: - response.raise_for_status() - except httpx.HTTPStatusError as error: - _handle_http_error(error, response) - - data = response.json() - return dacite.from_dict( - data_class=OrchestrationResponse, - data=data, - config=dacite.Config(cast=[Enum]), - ) - - def run( - self, - config: Optional[OrchestrationConfig] = None, - template_values: Optional[List[TemplateValue]] = None, - history: Optional[List[Message]] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - ) -> OrchestrationResponse: - """Executes an orchestration request synchronously (non-streaming). - - :param config: the orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param template_values: the template values for the request, defaults to None - :type template_values: Optional[List[TemplateValue]], optional - :param history: the message history, defaults to None - :type history: Optional[List[Message]], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :return: An OrchestrationResponse object. - :rtype: OrchestrationResponse - """ - return self._execute_request( - config=config or self.config, - template_values=template_values, - history=history, - stream=False, - timeout=timeout, - ) - - def stream( - self, - config: Optional[OrchestrationConfig] = None, - template_values: Optional[List[TemplateValue]] = None, - history: Optional[List[Message]] = None, - stream_options: Optional[dict] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - ) -> SSEClient: - """Executes an orchestration request in streaming mode (synchronously). - - :param config: the orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param template_values: the template values for the request, defaults to None - :type template_values: Optional[List[TemplateValue]], optional - :param history: the message history, defaults to None - :type history: Optional[List[Message]], optional - :param stream_options: the additional streaming options, defaults to None - :type stream_options: Optional[dict], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :return: An SSEClient instance for iterating over the streaming response. - :rtype: SSEClient - """ - return self._execute_request( - config=config or self.config, - template_values=template_values, - history=history, - stream=True, - stream_options=stream_options, - timeout=timeout, - ) - - async def arun( - self, - config: Optional[OrchestrationConfig] = None, - template_values: Optional[List[TemplateValue]] = None, - history: Optional[List[Message]] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - ) -> OrchestrationResponse: - """Executes an orchestration request asynchronously (non-streaming). - - :param config: the orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param template_values: the template values for the request, defaults to None - :type template_values: Optional[List[TemplateValue]], optional - :param history: the message history, defaults to None - :type history: Optional[List[Message]], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :return: An OrchestrationResponse object. - :rtype: OrchestrationResponse - """ - return await self._a_execute_request( - config=config or self.config, - template_values=template_values, - history=history, - stream=False, - timeout=timeout, - ) - - async def astream( - self, - config: Optional[OrchestrationConfig] = None, - template_values: Optional[List[TemplateValue]] = None, - history: Optional[List[Message]] = None, - stream_options: Optional[dict] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - ) -> AsyncSSEClient: - """Executes an orchestration request asynchronously in streaming mode. - - :param config: the orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param template_values: the template values for the request, defaults to None - :type template_values: Optional[List[TemplateValue]], optional - :param history: the message history, defaults to None - :type history: Optional[List[Message]], optional - :param stream_options: the additional streaming options, defaults to None - :type stream_options: Optional[dict], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :return: An AsyncSSEClient instance for iterating over the streaming response. - :rtype: AsyncSSEClient - """ - return await self._a_execute_request( - config=config or self.config, - template_values=template_values, - history=history, - stream=True, - stream_options=stream_options, - timeout=timeout, - ) - - def close_http_connection(self): - """ - Closes the httpx synchronous client. - """ - self.client.close() - - async def aclose_http_connection(self): - """ - Closes the httpx asynchronous client. - """ - await self.async_client.aclose() - - def run_with_retries( - self, - config: Optional[OrchestrationConfig] = None, - template_values: Optional[List[TemplateValue]] = None, - history: Optional[List[Message]] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - max_retries: int = 10, - base_delay: float = 1.0, - ) -> OrchestrationResponseWithRetries | None: - """Executes an orchestration request with automatic retry on rate limits (429) and server errors. - - :param config: the orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param template_values: the template values for the request, defaults to None - :type template_values: Optional[List[TemplateValue]], optional - :param history: the message history, defaults to None - :type history: Optional[List[Message]], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :param max_retries: the maximum number of retry attempts, defaults to 10 - :type max_retries: int, optional - :param base_delay: the initial delay between retries in seconds, defaults to 1.0 - :type base_delay: float, optional - :return: An OrchestrationResponseWithRetries with retry count information. - :rtype: OrchestrationResponseWithRetries | None - :raises OrchestrationError: If the request fails after all retries (includes retry count). - :raises ValueError: If no configuration is provided. - """ - for retry_count in range(max_retries + 1): - try: - # Execute the request - response = self.run( - config=config, - template_values=template_values, - history=history, - timeout=timeout, - ) - - # Success, response with retry count - return OrchestrationResponseWithRetries( - request_id=response.request_id, - module_results=response.module_results, - orchestration_result=response.orchestration_result, - retries=retry_count, - ) - - except (OrchestrationError, httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException) as error: - time.sleep(self.handle_retry(retry_count, base_delay, error, max_retries)) - return None - - def handle_retry(self, retry_count: int, base_delay: float, error: OrchestrationError, max_retries: int) -> float: - """Handles retry logic with exponential backoff and jitter. - If Retry-After header exists, use it as min_delay to add jitter on top - - :param retry_count: the current retry attempt number - :type retry_count: int - :param base_delay: the initial delay between retries in seconds - :type base_delay: float - :param error: the exception that occurred - :type error: OrchestrationError - :param max_retries: the maximum number of retry attempts - :type max_retries: int - :raises error: Raises the original error if no more retries should be attempted - :return: number of seconds to wait before next retry - :rtype: float - """ - if not self._should_retry(error) or retry_count >= max_retries: - error.retries = retry_count - raise error - - retry_after = self._get_retry_after(error) - delay = self._calculate_backoff(retry_count, base_delay, - min_delay=0.0 if retry_after is None else retry_after) - logging.info("Retry no. %d, due to rate limiting", retry_count) - return delay - - async def arun_with_retries( - self, - config: Optional[OrchestrationConfig] = None, - template_values: Optional[List[TemplateValue]] = None, - history: Optional[List[Message]] = None, - timeout: Union[int, float, httpx.Timeout, None] = None, - max_retries: int = 10, - base_delay: float = 1.0, - ) -> OrchestrationResponseWithRetries | None: - """Executes an orchestration request asynchronously with automatic retry on rate limits (429) and - server errors. Uses exponential backoff with jitter to handle rate limiting gracefully. - - :param config: the orchestration configuration, defaults to None - :type config: Optional[OrchestrationConfig], optional - :param template_values: the template values for the request, defaults to None - :type template_values: Optional[List[TemplateValue]], optional - :param history: the message history, defaults to None - :type history: Optional[List[Message]], optional - :param timeout: the timeout for the request, defaults to None - :type timeout: Union[int, float, httpx.Timeout, None], optional - :param max_retries: the maximum number of retry attempts, defaults to 10 - :type max_retries: int, optional - :param base_delay: the initial delay between retries in seconds, defaults to 1.0 - :type base_delay: float, optional - :return: An OrchestrationResponseWithRetries with retry count information. - :rtype: OrchestrationResponseWithRetries | None - :raises OrchestrationError: If the request fails after all retries (includes retry count). - :raises ValueError: If no configuration is provided. - """ - for retry_count in range(max_retries + 1): - try: - # Execute the request - response = await self.arun( - config=config, - template_values=template_values, - history=history, - timeout=timeout, - ) - - # Success! Return response with retry count - return OrchestrationResponseWithRetries( - request_id=response.request_id, - module_results=response.module_results, - orchestration_result=response.orchestration_result, - retries=retry_count, - ) - - except (OrchestrationError, httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException) as error: - await asyncio.sleep(self.handle_retry(retry_count, base_delay, error, max_retries)) - return None diff --git a/packages/gen/gen_ai_hub/orchestration/sse_client.py b/packages/gen/gen_ai_hub/orchestration/sse_client.py deleted file mode 100644 index 26cb9a81..00000000 --- a/packages/gen/gen_ai_hub/orchestration/sse_client.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Module for Server-Sent Events (SSE) clients for orchestration responses. - -This module provides both synchronous and asynchronous SSE clients for iterating over streaming responses. -Each client is responsible for handling HTTP errors and for closing the underlying HTTP stream -when iteration is complete. -""" - -import json -from enum import Enum -from typing import Iterable, Iterator, AsyncIterator - -import dacite -import httpx - -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.response import OrchestrationResponseStreaming - - -def _parse_event_data(event_data: str, final_message: str) -> "OrchestrationResponseStreaming": - """Parses event data from a JSON string into an OrchestrationResponseStreaming object. - - :param event_data: the JSON string containing event data. - :type event_data: str - :param final_message: a message indicating the end of the stream. - :type final_message: str - :raises OrchestrationError: if the event data contains an error code. - :return: An OrchestrationResponseStreaming object parsed from the event data. - :rtype: OrchestrationResponseStreaming - """ - if event_data == final_message: - return None - event = json.loads(event_data) - if "code" in event: - raise OrchestrationError( - request_id=event.get("request_id"), - http_headers=httpx.Headers({}), - message=event.get("message"), - code=event.get("code"), - location=event.get("location"), - module_results=event.get("module_results", {}), - ) - return dacite.from_dict( - data=event, - data_class=OrchestrationResponseStreaming, - config=dacite.Config(cast=[Enum]), - ) - - -class SSEClient: - """ - A synchronous Server-Sent Events (SSE) client that wraps an httpx.Response for iterating - over streaming responses. - - This client reads data chunks from the HTTP stream and parses each SSE event. - For performance reasons the underlying HTTP stream is reused for subsequent calls. - """ - - def __init__(self, response_cm, prefix: str = "data: ", final_message: str = "[DONE]"): - """Initializes the SSEClient. - - :param response_cm: An httpx.Response context manager for the streaming response. - :type response_cm: httpx.Response - :param prefix: The prefix string that identifies SSE event data, defaults to "data: " - :type prefix: str, optional - :param final_message: The message that indicates the end of the stream, defaults to "[DONE]" - :type final_message: str, optional - """ - self.response_cm = response_cm - self.event_prefix = prefix - self.final_message = final_message - self._response = None - self._iterator = None - - - def __enter__(self): - """ - Synchronously enters the context for the streaming response. - - It awaits the response, checks for HTTP errors, and if an error occurs, - reads the content and raises an OrchestrationError. - - return: Self, with the streaming response stored. - rtype: SSEClient - """ - self._response = self.response_cm.__enter__() - try: - self._response.raise_for_status() - except httpx.HTTPStatusError as error: - content = self._response.read() - error_response = httpx.Response( - status_code=self._response.status_code, - headers=self._response.headers, - content=content, - request=self._response.request, - ) - self.response_cm.__exit__(None, None, None) - _handle_http_error(error, error_response) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """ - Synchronously exits the context, ensuring that the context manager is properly closed. - """ - self.response_cm.__exit__(exc_type, exc_val, exc_tb) - - def iter_lines(self) -> Iterable[str]: - """ - Reads data chunks from the HTTP stream and yields complete lines. - - This method accumulates incoming chunks until a newline is encountered, yielding one complete - line at a time. - - yield: Complete lines of text from the streaming response. - """ - buffer = "" - for chunk in self._response.iter_text(): - buffer += chunk - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - yield line.strip() - if buffer: - yield buffer.strip() - - def __iter__(self) -> Iterator: - """ - Returns self as an iterator. Opens the HTTP stream and initializes the internal iterator. - """ - return self - - def __next__(self): - """ - Retrieves the next parsed SSE event from the stream. - It skips any lines that do not start with the expected prefix. When the final message is encountered - or the stream is exhausted, it closes the stream and raises StopIteration. - """ - if self._iterator is None: - self.__enter__() - self._iterator = self.iter_lines() - while True: - try: - line = next(self._iterator) - except StopIteration: - # End of stream; ensure resources are cleaned up. - self.__exit__(None, None, None) - raise StopIteration - - if not line or not line.startswith(self.event_prefix): - continue - - event_data = line[len(self.event_prefix):] - result = _parse_event_data(event_data, self.final_message) - if result is None: - # Final message encountered; close the stream. - self.__exit__(None, None, None) - raise StopIteration - return result - - -class AsyncSSEClient: - """ - An asynchronous SSE client for iterating over streaming responses. - - This client wraps an asynchronous HTTP stream (provided as a context manager) and ensures - that the stream is properly opened and closed. It also checks for HTTP errors upon entering the stream. - """ - - def __init__(self, response_cm, prefix: str = "data: ", final_message: str = "[DONE]"): - """Initializes the AsyncSSEClient. - - :param response_cm: An asynchronous context manager for the HTTP streaming response. - :type response_cm: the type of an async context manager returning httpx.Response - :param prefix: The SSE data prefix, defaults to "data: " - :type prefix: str, optional - :param final_message: The message indicating the end of the stream, defaults to "[DONE]" - :type final_message: str, optional - """ - self.response_cm = response_cm - self.event_prefix = prefix - self.final_message = final_message - self._response = None - self._iterator = None - - async def __aenter__(self): - """ - Asynchronously enters the context for the streaming response. - - It awaits the response, checks for HTTP errors, and if an error occurs, - reads the content and raises an OrchestrationError. - - return: Self, with the streaming response stored. - rtype: AsyncSSEClient - """ - self._response = await self.response_cm.__aenter__() - try: - self._response.raise_for_status() - except httpx.HTTPStatusError as error: - content = await self._response.aread() - error_response = httpx.Response( - status_code=self._response.status_code, - headers=self._response.headers, - content=content, - request=self._response.request, - ) - await self.response_cm.__aexit__(None, None, None) - _handle_http_error(error, error_response) - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """ - Asynchronously exits the context, ensuring that the context manager is properly closed. - """ - await self.response_cm.__aexit__(exc_type, exc_val, exc_tb) - - def _process_line(self, line: str) -> "OrchestrationResponseStreaming": - """ - Process a single line and return parsed event data if valid. - - :param line: The line to process - :type line: str - :return: Parsed event data or None if line is invalid or end of stream - :rtype: OrchestrationResponseStreaming or None - """ - line = line.strip() - if not line or not line.startswith(self.event_prefix): - return None - event_data = line[len(self.event_prefix):] - return _parse_event_data(event_data, self.final_message) - - async def _internal_iterator(self) -> AsyncIterator: - """ - Internal asynchronous generator that yields parsed events from the HTTP stream. - """ - buffer = "" - async for chunk in self._response.aiter_text(): - buffer += chunk - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - result = self._process_line(line) - if result is None: - if line.strip() == self.final_message or line.strip().endswith(self.final_message): - return - continue - yield result - # Process any remaining data in the buffer - if buffer: - result = self._process_line(buffer) - if result is not None: - yield result - - def __aiter__(self): - """ - Returns the async iterator (self). The initialization of the stream is deferred until the first - call to __anext__. - """ - return self - - async def __anext__(self): - """ - Asynchronously retrieves the next event from the stream. On the first call, it enters the asynchronous - context to start the stream. When the stream is exhausted or the final message is received, it properly - exits the context. - - return: The next parsed event from the stream. - rtype: OrchestrationResponseStreaming - raises StopAsyncIteration: When the stream is exhausted. - """ - if self._iterator is None: - # Lazily initialize the stream. - await self.__aenter__() - self._iterator = self._internal_iterator().__aiter__() - try: - return await self._iterator.__anext__() - except StopAsyncIteration: - await self.__aexit__(None, None, None) - raise StopAsyncIteration - - -def _handle_http_error(error, response: httpx.Response): - """Handles HTTP errors by raising an OrchestrationError with details from the response. - - :param error: the original HTTP error. - :type error: httpx.HTTPStatusError - :param response: the httpx.Response object containing error details incl. headers. - :type response: httpx.Response - :raises OrchestrationError: with information extracted from the response. - """ - if not response.content: - raise error - try: - error_content = response.json() - error_content["http_headers"] = response.headers - except ValueError as exc: - raise error from exc - raise OrchestrationError( - request_id=error_content.get("request_id"), - http_headers=error_content.get("http_headers"), - message=error_content.get("message"), - code=error_content.get("code"), - location=error_content.get("location"), - module_results=error_content.get("module_results", {}), - ) from error diff --git a/packages/gen/gen_ai_hub/orchestration/utils.py b/packages/gen/gen_ai_hub/orchestration/utils.py deleted file mode 100644 index 757755e7..00000000 --- a/packages/gen/gen_ai_hub/orchestration/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -def load_text_file(file_path): - """Loads and returns the content of a text file. - - :param file_path: The path to the text file to be loaded. - :type file_path: str - :return: The content of the file as a string. - :rtype: str - """ - with open(file_path, 'r', encoding='utf-8') as file: - return file.read() - diff --git a/packages/gen/integration_tests/orchestration/__init__.py b/packages/gen/integration_tests/orchestration/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/gen/integration_tests/orchestration/test_async.py b/packages/gen/integration_tests/orchestration/test_async.py deleted file mode 100644 index 9f75a149..00000000 --- a/packages/gen/integration_tests/orchestration/test_async.py +++ /dev/null @@ -1,114 +0,0 @@ -import unittest - -from httpx import TimeoutException - -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.response import OrchestrationResponse -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class - - -@retry_on_429_or_503_class() -class AsyncLLMTest(OrchestrationServiceTestBase, unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - # Set up common variables for asynchronous tests. - # Replace the API URL with your actual test endpoint. - self.service = OrchestrationService(api_url=self.api_url) - self.config = OrchestrationConfig( - llm=LLM(name="gpt-4o-mini", parameters={"temperature": 0.0}), - template=Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - defaults=[TemplateValue("name", "World")], - ), - ) - - async def test_async_invalid_llm_name(self): - """Test that an unknown LLM name causes an error asynchronously.""" - llm = LLM(name="unknown-llm") - config = OrchestrationConfig(template=self.config.template, llm=llm) - with self.assertRaises(OrchestrationError): - await self.service.arun(config=config) - - async def test_async_invalid_llm_version(self): - """Test that an invalid LLM version causes an error asynchronously.""" - llm = LLM(name="gpt-4o-mini", version="unknown") - config = OrchestrationConfig(template=self.config.template, llm=llm) - with self.assertRaises(OrchestrationError): - await self.service.arun(config=config) - - async def test_async_valid_llm(self): - """Test that a valid LLM returns a result asynchronously.""" - response = await self.service.arun(config=self.config) - self.assertTrue(response.orchestration_result.model.startswith(self.config.llm.name)) - - async def test_async_streaming(self): - """Test asynchronous streaming mode returns at least one chunk.""" - service = OrchestrationService(api_url=self.api_url, config=self.config) - chunks = [] - # astream() returns an asynchronous iterator. - async for chunk in await service.astream(): - chunks.append(chunk) - self.assertGreater(len(chunks), 0, "No streaming chunks were received.") - - async def test_async_streaming_with_invalid_options(self): - """Test that passing invalid stream options raises an error asynchronously.""" - with self.assertRaises(OrchestrationError): - # The invalid stream options should cause an error during the async call. - async for _ in await self.service.astream(config=self.config, stream_options={'unknown': 10}): - pass - - async def test_async_reuse_client(self): - """ - ensures the client is reused and not closed when making multiple requests - """ - service = OrchestrationService(api_url=self.api_url, config=self.config) - reusable_client = service.async_client - # First request - chunks1 = [] - async for chunk in await service.astream(): - chunks1.append(chunk) - self.assertFalse(reusable_client.is_closed) - - # Second request - chunks2 = [] - async for chunk in await service.astream(): - chunks2.append(chunk) - self.assertGreater(len(chunks2), 0, "No streaming chunks were received.") - - # ensure httpx client is reused - self.assertEqual(reusable_client, service.async_client) - - await service.aclose_http_connection() - self.assertTrue(reusable_client.is_closed) - - async def test_async_timeout_per_request(self): - """ - set low default timeout for reusable client, which leads to a timeout. - overwrite timeout with higher value via request and show that response is returned. - """ - self.service = OrchestrationService(self.api_url, timeout=0.1) - config = OrchestrationConfig( - template=Template( - messages=[ - SystemMessage("You are a famous professor for theoretical physics."), - UserMessage("Elaborate on the relativity theory."), - ], - ), - llm=LLM(name="gpt-5-nano") - ) - - # First request - should time out - with self.assertRaises(TimeoutException): - await self.service.arun(config=config) - - # Second request - should succeed due to overwrite in request with higher timeout - result = await self.service.arun(config=config, timeout=300) - self.assertIsInstance(result, OrchestrationResponse) diff --git a/packages/gen/integration_tests/orchestration/test_base.py b/packages/gen/integration_tests/orchestration/test_base.py deleted file mode 100644 index d8dc3e67..00000000 --- a/packages/gen/integration_tests/orchestration/test_base.py +++ /dev/null @@ -1,60 +0,0 @@ -import time -import unittest -from uuid import uuid4 - -from gen_ai_hub.proxy import get_proxy_client - -shared_api_url = None - - -def get_shared_api_url(): - global shared_api_url - if shared_api_url is None: - shared_api_url = initialize_orchestration_service() - return shared_api_url - - -def initialize_orchestration_service(): - client = get_proxy_client(proxy_version="gen-ai-hub").ai_core_client - deployment = get_or_create_deployment(client) - return deployment.deployment_url - - -def get_or_create_deployment(client, timeout=600): - deployments = client.deployment.query(scenario_id="orchestration").resources - deployments = [d for d in deployments if d.status.value == "RUNNING"] - - if deployments: - return deployments[0] - - config = get_or_create_configuration(client) - deployment_id = client.deployment.create(configuration_id=config.id).id - - deployment = client.deployment.get(deployment_id) - start = time.time() - while deployment.status.value != "RUNNING": - if time.time() - start > timeout: - raise TimeoutError("Timeout waiting for deployment to start.") - deployment = client.deployment.get(deployment_id) - time.sleep(10) - - return deployment - - -def get_or_create_configuration(client): - configs = client.configuration.query(scenario_id="orchestration").resources - if configs: - return configs[0] - - return client.configuration.create( - scenario_id="orchestration", - executable_id="orchestration", - name=f"orchestration-config-{str(uuid4())[:8]}", - ) - - -class OrchestrationServiceTestBase(unittest.TestCase): - - def setUp(self): - self.api_url = get_shared_api_url() - self.assertIsNotNone(self.api_url) diff --git a/packages/gen/integration_tests/orchestration/test_content_filtering.py b/packages/gen/integration_tests/orchestration/test_content_filtering.py deleted file mode 100644 index 7472eda9..00000000 --- a/packages/gen/integration_tests/orchestration/test_content_filtering.py +++ /dev/null @@ -1,176 +0,0 @@ -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.azure_content_filter import AzureThreshold, AzureContentFilter -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.content_filter import ( - ContentFilter, - ContentFilterProvider, -) -from gen_ai_hub.orchestration.models.content_filtering import InputFiltering, OutputFiltering, ContentFiltering -from gen_ai_hub.orchestration.models.llama_guard_3_filter import LlamaGuard38bFilter -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.template import Template -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class - - -@retry_on_429_or_503_class() -class TestContentFilter(OrchestrationServiceTestBase): - - def setUp(self): - super().setUp() - self.llm = LLM( - name="gpt-4o-mini", - version="latest", - parameters={"max_tokens": 50, "temperature": 0.0}, - ) - self.template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - ] - ) - self.service = OrchestrationService( - api_url=self.api_url, - config=OrchestrationConfig( - template=self.template, - llm=self.llm, - ), - ) - - def test_invalid_filter_provider(self): - content_filter = ContentFilter(provider="unknown", config={"key": "value"}) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - filtering=ContentFiltering(InputFiltering(filters=[content_filter])) - ) - - with self.assertRaises(OrchestrationError): - self.service.run(config=config) - - def test_azure_filter_with_invalid_config(self): - content_filter = ContentFilter( - provider=ContentFilterProvider.AZURE, config={"key": "value"} - ) - - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter])) - - with self.assertRaises(OrchestrationError): - self.service.run() - - def test_valid_input_filtering_with_azure(self): - content_filter = AzureContentFilter(hate=AzureThreshold.ALLOW_ALL, - self_harm=AzureThreshold.ALLOW_ALL, - violence=AzureThreshold.ALLOW_ALL, - sexual=AzureThreshold.ALLOW_ALL) - - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter])) - - response = self.service.run() - - self.assertIsNotNone(response.module_results.input_filtering) - self.assertIsNone(response.module_results.output_filtering) - self.assertIsNotNone(response.orchestration_result.model) - - def test_valid_output_filtering_with_azure(self): - content_filter = AzureContentFilter(hate=AzureThreshold.ALLOW_SAFE, - self_harm=AzureThreshold.ALLOW_SAFE, - violence=AzureThreshold.ALLOW_SAFE, - sexual=AzureThreshold.ALLOW_SAFE) - - self.service.config.filtering = ContentFiltering(output_filtering=OutputFiltering(filters=[content_filter])) - - response = self.service.run() - - self.assertIsNone(response.module_results.input_filtering) - self.assertIsNotNone(response.module_results.output_filtering) - self.assertIsNotNone(response.orchestration_result.model) - - def test_valid_input_and_output_filtering(self): - content_filter = AzureContentFilter(hate=AzureThreshold.ALLOW_SAFE, - self_harm=AzureThreshold.ALLOW_SAFE, - violence=AzureThreshold.ALLOW_SAFE, - sexual=AzureThreshold.ALLOW_SAFE) - - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter]), - OutputFiltering(filters=[content_filter]) - ) - - response = self.service.run() - - self.assertIsNotNone(response.module_results.input_filtering) - self.assertIsNotNone(response.module_results.output_filtering) - self.assertIsNotNone(response.orchestration_result.model) - - def test_blocked_input_filtering_with_azure(self): - content_filter = AzureContentFilter(hate=AzureThreshold.ALLOW_SAFE, - self_harm=AzureThreshold.ALLOW_SAFE, - violence=AzureThreshold.ALLOW_SAFE, - sexual=AzureThreshold.ALLOW_SAFE) - - self.service.config.template.messages.append(UserMessage("I hate you!.")) - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter])) - - with self.assertRaises(OrchestrationError): - self.service.run() - - def test_valid_input_filtering_with_llama_guard38b(self): - content_filter = LlamaGuard38bFilter() - - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter])) - - response = self.service.run() - - self.assertIsNotNone(response.module_results.input_filtering) - self.assertIsNone(response.module_results.output_filtering) - self.assertIsNotNone(response.orchestration_result.model) - - def test_valid_output_filtering_with_llama_guard38b(self): - content_filter = LlamaGuard38bFilter() - - self.service.config.filtering = ContentFiltering(output_filtering=OutputFiltering(filters=[content_filter])) - - response = self.service.run() - - self.assertIsNone(response.module_results.input_filtering) - self.assertIsNotNone(response.module_results.output_filtering) - self.assertIsNotNone(response.orchestration_result.model) - - def test_valid_input_and_output_filtering_with_llama_guard38b(self): - content_filter = LlamaGuard38bFilter() - - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter]), - OutputFiltering(filters=[content_filter]) - ) - - response = self.service.run() - - self.assertIsNotNone(response.module_results.input_filtering) - self.assertIsNotNone(response.module_results.output_filtering) - self.assertIsNotNone(response.orchestration_result.model) - - def test_blocked_input_filtering_with_llama_guard38b(self): - content_filter = LlamaGuard38bFilter(elections=True) - - user_message = UserMessage("We need to manipulate the elections.") - - self.service.config.template.messages.append(user_message) - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter])) - - with self.assertRaises(OrchestrationError): - self.service.run() - - def test_blocked_input_filtering_with_azure_and_llama(self): - content_filter_azure = AzureContentFilter(hate=AzureThreshold.ALLOW_SAFE, - self_harm=AzureThreshold.ALLOW_SAFE, - violence=AzureThreshold.ALLOW_SAFE, - sexual=AzureThreshold.ALLOW_SAFE) - content_filter_llama = LlamaGuard38bFilter(hate=True) - - self.service.config.template.messages.append(UserMessage("I hate you!.")) - self.service.config.filtering = ContentFiltering(InputFiltering(filters=[content_filter_azure, content_filter_llama])) - - with self.assertRaises(OrchestrationError): - self.service.run() diff --git a/packages/gen/integration_tests/orchestration/test_data_masking.py b/packages/gen/integration_tests/orchestration/test_data_masking.py deleted file mode 100644 index 729f25bd..00000000 --- a/packages/gen/integration_tests/orchestration/test_data_masking.py +++ /dev/null @@ -1,111 +0,0 @@ -import json -import unittest - -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.data_masking import DataMasking -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.sap_data_privacy_integration import SAPDataPrivacyIntegration, ProfileEntity, \ - MaskingMethod -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class - -@retry_on_429_or_503_class() -class TestDataMasking(OrchestrationServiceTestBase): - - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.llm = LLM( - name="gpt-4", - parameters={ - 'temperature': 0.0, - } - ) - self.template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ] - ) - - def run_data_masking_test(self, masking_method: MaskingMethod, assertion_func): - data_masking = DataMasking( - providers=[ - SAPDataPrivacyIntegration( - method=masking_method, - entities=[ - ProfileEntity.EMAIL - ] - ) - ]) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - data_masking=data_masking - ) - - sensitive_data = "something@hotmail.com" - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", f"My email is {sensitive_data}" - f"-----------------------------------" - f"DON'T check if anything is masked, " - f"repeat the previous sentence." - f"DON'T alter the format of the " - f"masked data." - ) - ]) - - assertion_func(sensitive_data, response.orchestration_result.choices[0].message.content) - self.assertIsNotNone(response.module_results.input_masking) - - if masking_method == MaskingMethod.ANONYMIZATION: - self.assertIsNone(response.module_results.output_unmasking) - else: - self.assertIsNotNone(response.module_results.output_unmasking) - - @unittest.skip("backend module unavailable") - def test_data_masking_with_anonymization(self): - self.run_data_masking_test(MaskingMethod.ANONYMIZATION, self.assertNotIn) - - @unittest.skip("backend module unavailable") - def test_data_masking_with_pseudonymization(self): - self.run_data_masking_test(MaskingMethod.PSEUDONYMIZATION, self.assertIn) - - @unittest.skip("backend module unavailable") - def test_data_masking_with_allowlist(self): - allow_listed_org = "SAP" - data_masking = DataMasking( - providers=[ - SAPDataPrivacyIntegration( - method=MaskingMethod.PSEUDONYMIZATION, - entities=[ProfileEntity.ORG], - allowlist=[allow_listed_org] - ) - ] - ) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - data_masking=data_masking - ) - - response = self.service.run( - config=config, - template_values=[ - TemplateValue("user_query", f"My organization is {allow_listed_org}") - ] - ) - - # Verify that the allow-listed org is present in the masked template - masked_template = json.loads(response.module_results.input_masking.data['masked_template']) - self.assertIn(allow_listed_org, masked_template[1]['content'], - f"Allow-listed organization '{allow_listed_org}' should not be masked") - - diff --git a/packages/gen/integration_tests/orchestration/test_grounding.py b/packages/gen/integration_tests/orchestration/test_grounding.py deleted file mode 100644 index 30a33596..00000000 --- a/packages/gen/integration_tests/orchestration/test_grounding.py +++ /dev/null @@ -1,210 +0,0 @@ -import json -import unittest - -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.data_masking import DataMasking -from gen_ai_hub.orchestration.models.document_grounding import GroundingModule, GroundingType, DocumentGrounding, \ - DocumentGroundingFilter, GroundingFilterSearch, DataRepositoryType -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.sap_data_privacy_integration import SAPDataPrivacyIntegration, MaskingMethod, \ - ProfileEntity -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class, retry_on_429_or_503 - - -@retry_on_429_or_503_class() -class TestGrounding(OrchestrationServiceTestBase): - - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.llm = LLM( - name="gpt-4o", - parameters={ - 'temperature': 0.0, - } - ) - self.template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("Question: {{?user_query}}\n Context: {{?grounding_response}}"), - ] - ) - - def test_no_filter(self): - """ - Run orchestration service with default / empty grounding configuration. - """ - grounding_config = GroundingModule( - type=GroundingType.DOCUMENT_GROUNDING_SERVICE.value, - config=DocumentGrounding(input_params=["user_query"], output_param="grounding_response") - ) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - grounding=grounding_config - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is the orchestration service?"), - ]) - - self.assertIn("orchestration service", response.orchestration_result.choices[0].message.content) - - def test_grounding_SAPHelp(self): - """ - This tests the grounding option "elastic search" which is enabled for SAP Help website. - The indexed search is used instead of embedding vectors. - This is the minimal setup required for a grounding use case. - """ - - filters = [ - DocumentGroundingFilter(id="SAPHelp", data_repository_type="help.sap.com") - ] - - grounding_config = GroundingModule( - type=GroundingType.DOCUMENT_GROUNDING_SERVICE.value, - config=DocumentGrounding(input_params=["user_query"], output_param="grounding_response", filters=filters) - ) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - grounding=grounding_config - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is SAP AI Core?"), - ]) - - print(response.orchestration_result.choices[0].message.content) - self.assertIn("SAP AI Core", response.orchestration_result.choices[0].message.content) - - def test_grounding_vector(self): - """ - Test grounding based on vector store created with Data API. - Metadata keys point to sources of the documents. - """ - metadata_keys = ['source', 'webUrl', 'title', 'mimeType', 'fileSuffix'] - filters = [DocumentGroundingFilter(id="s3-docs", - data_repositories=["46b508c9-e490-4808-893b-b8e3361c4213"], - search_config=GroundingFilterSearch(max_chunk_count=2), - data_repository_type=DataRepositoryType.VECTOR.value - ) - ] - - grounding_config = GroundingModule( - type=GroundingType.DOCUMENT_GROUNDING_SERVICE.value, - config=DocumentGrounding(input_params=["user_query"], output_param="grounding_response", filters=filters, - metadata_params= metadata_keys) - ) - - orchestration_template = Template( - messages=[ - SystemMessage("""Facility Solutions Company provides services to luxury residential complexes, apartments, - individual homes, and commercial properties such as office buildings, retail spaces, industrial facilities, and educational institutions. - Customers are encouraged to reach out with maintenance requests, service deficiencies, follow-ups, or any issues they need by email. - """), - UserMessage("""You are a helpful assistant for any queries for answering questions. - Answer the request by providing relevant answers that fit to the request. - Request: {{ ?user_query }} - Context:{{ ?grounding_response }} - """), - ] - ) - - config = OrchestrationConfig( - template=orchestration_template, - llm=self.llm, - grounding=grounding_config - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "Is there a complaint?"), - ]) - self.assertIsNotNone(response.module_results.grounding) - metadata = json.loads(response.module_results.grounding.data['grounding_result'])[0]['metadata'] - for key in metadata_keys: - self.assertIn(key, metadata.keys()) - self.assertIn("complaint", response.orchestration_result.choices[0].message.content) - - @unittest.skip("Required setting up sharepoint.") - def test_grounding_sharepoint(self): # technical user for sharepoint not available - pass - - def test_grounding_with_data_masking_enabled(self): - filters = [ - DocumentGroundingFilter(id="SAPHelp", data_repository_type="help.sap.com") - ] - - grounding_config = GroundingModule( - type=GroundingType.DOCUMENT_GROUNDING_SERVICE.value, - config=DocumentGrounding(input_params=["user_query"], output_param="grounding_response", filters=filters) - ) - - data_masking = DataMasking( - providers=[ - SAPDataPrivacyIntegration( - method=MaskingMethod.ANONYMIZATION, - entities=[ - ProfileEntity.ORG - ], - mask_grounding_input=True - ) - ]) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - grounding=grounding_config, - data_masking=data_masking - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is SAP AI Core?"), - ]) - - self.assertIsNotNone(response.module_results.input_masking.data.get('masked_grounding_input')) - - def test_grounding_with_data_masking_disabled(self): - filters = [ - DocumentGroundingFilter(id="SAPHelp", data_repository_type="help.sap.com") - ] - - grounding_config = GroundingModule( - type=GroundingType.DOCUMENT_GROUNDING_SERVICE.value, - config=DocumentGrounding(input_params=["user_query"], output_param="grounding_response", filters=filters) - ) - - data_masking = DataMasking( - providers=[ - SAPDataPrivacyIntegration( - method=MaskingMethod.ANONYMIZATION, - entities=[ - ProfileEntity.ORG - ], - mask_grounding_input=False - ) - ]) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - grounding=grounding_config, - data_masking=data_masking - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is SAP AI Core?"), - ]) - - self.assertIsNone(response.module_results.input_masking.data.get('masked_grounding_input')) diff --git a/packages/gen/integration_tests/orchestration/test_llm.py b/packages/gen/integration_tests/orchestration/test_llm.py deleted file mode 100644 index 31b21241..00000000 --- a/packages/gen/integration_tests/orchestration/test_llm.py +++ /dev/null @@ -1,96 +0,0 @@ -from parameterized import parameterized - -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage -from gen_ai_hub.orchestration.models.template import Template -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class - -@retry_on_429_or_503_class() -class TestLLM(OrchestrationServiceTestBase): - - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - ] - ) - - def test_invalid_llm_name(self): - - llm = LLM( - name="unknown-llm", - ) - - config = OrchestrationConfig( - template=self.template, - llm=llm, - ) - - with self.assertRaises(OrchestrationError): - self.service.run(config=config) - - def test_invalid_llm_version(self): - - llm = LLM( - name="gpt-4o-mini", - version="unknown", - ) - - config = OrchestrationConfig( - template=self.template, - llm=llm, - ) - - with self.assertRaises(OrchestrationError): - self.service.run(config=config) - - def test_invalid_llm_parameters(self): - - llm = LLM( - name="gpt-4o-mini", - parameters={ - "unknown_parameter": "value", - }, - ) - - config = OrchestrationConfig( - template=self.template, - llm=llm, - ) - - with self.assertRaises(OrchestrationError): - self.service.run(config=config) - - @parameterized.expand( - [ - # "gpt-4", - "gpt-4o", - "gpt-4o-mini", - "gemini-2.5-flash", - ] - ) - def test_valid_llm(self, name="gpt-4o-mini"): - - llm = LLM( - name=name, - parameters={ - 'temperature': 0.0, - } - ) - - config = OrchestrationConfig( - template=self.template, - llm=llm, - ) - - response = self.service.run(config=config) - - self.assertTrue(response.orchestration_result.model.startswith(llm.name)) - - diff --git a/packages/gen/integration_tests/orchestration/test_service.py b/packages/gen/integration_tests/orchestration/test_service.py deleted file mode 100644 index 3414491f..00000000 --- a/packages/gen/integration_tests/orchestration/test_service.py +++ /dev/null @@ -1,168 +0,0 @@ -from httpx import TimeoutException -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.response import OrchestrationResponse -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503 - - -class TestService(OrchestrationServiceTestBase): - - def setUp(self): - super().setUp() - self.service = OrchestrationService(self.api_url) - - @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) - def test_service_request_with_default_config(self): - config = OrchestrationConfig( - template=Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - defaults=[TemplateValue(name="name", value="Integration Test")], - ), - llm=LLM( - name="gemini-2.5-flash", - parameters={ - 'temperature': 0.0, - } - ), - ) - - service = OrchestrationService(api_url=self.api_url, config=config) - - response = service.run() - - self.assertEqual( - response.module_results.templating[1].content, "Hello, Integration Test!" - ) - - @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) - def test_service_with_inference_config(self): - config = OrchestrationConfig( - template=Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - ), - llm=LLM( - name="gemini-2.5-flash", - parameters={ - 'temperature': 0.0, - } - ), - ) - - service = OrchestrationService(api_url=self.api_url, config=config) - - config.llm.name = "gemini-2.5-flash" - - response = service.run( - config=config, template_values=[TemplateValue("name", "World")] - ) - - self.assertTrue( - response.orchestration_result.model.startswith("gemini-2.5-flash") - ) - self.assertEqual(response.module_results.templating[1].content, "Hello, World!") - - @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) - def test_service_with_history(self): - response = self.service.run( - config=OrchestrationConfig( - template=Template( - messages=[ - SystemMessage("This is a system message."), - ], - ), - llm=LLM( - name="gemini-2.5-flash", - parameters={ - 'temperature': 0.0, - } - ), - ), - history=[ - UserMessage("Hello, World!"), - UserMessage("How are you?"), - UserMessage("What is your name?"), - ], - ) - - self.assertEqual(len(response.module_results.templating), 4) - self.assertEqual(response.module_results.templating[0].content, "Hello, World!") - self.assertEqual(response.module_results.templating[1].content, "How are you?") - self.assertEqual( - response.module_results.templating[2].content, "What is your name?" - ) - self.assertEqual( - response.module_results.templating[3].content, "This is a system message." - ) - self.assertEqual( - response.orchestration_result.model.startswith("gemini-2"), True - ) - - @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) - def test_reuse_client(self): - """ - ensures the client is reused and not closed when making multiple requests - """ - reusable_client = self.service.client - - #First request - config = OrchestrationConfig( - template=Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - ), - llm=LLM( - name="gemini-2.5-flash", - parameters={ - 'temperature': 0.0, - } - ), - ) - self.service.run(config=config, template_values=[TemplateValue("name", "World")]) - self.assertFalse(reusable_client.is_closed) - - # Second request - self.service.run(config=config, template_values=[TemplateValue("name", "Earth")]) - - # ensure httpx client is reused - self.assertEqual(reusable_client, self.service.client) - - self.service.close_http_connection() - self.assertTrue(reusable_client.is_closed) - - @retry_on_429_or_503(max_retries=3, initial_delay=2.0, backoff_factor=2.0) - def test_timeout_per_request(self): - """ - set low default timeout for reusable client, which leads to a timeout. - overwrite timeout with higher value via request and show that response is returned. - """ - self.service = OrchestrationService(self.api_url, timeout=1) - config = OrchestrationConfig( - template=Template( - messages=[ - SystemMessage("You are a famous professor for theoretical physics."), - UserMessage("Elaborate on the relativity theory."), - ], - ), - llm=LLM(name="gpt-5-nano") - ) - - # First request - should time out - with self.assertRaises(TimeoutException): - self.service.run(config=config) - - # Second request - should succeed due to overwrite in request with higher timeout - result = self.service.run(config=config, timeout=300) - self.assertIsInstance(result, OrchestrationResponse) - diff --git a/packages/gen/integration_tests/orchestration/test_streaming.py b/packages/gen/integration_tests/orchestration/test_streaming.py deleted file mode 100644 index 2a000edb..00000000 --- a/packages/gen/integration_tests/orchestration/test_streaming.py +++ /dev/null @@ -1,160 +0,0 @@ -import unittest -from typing import cast - -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.azure_content_filter import AzureContentFilter, AzureThreshold -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.content_filtering import OutputFiltering, ContentFiltering -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.response import OrchestrationResponseStreaming -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.service import OrchestrationService -from integration_tests.constants import CLAUDE_4_5_SONNET_TEST_MODEL -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class - - -@retry_on_429_or_503_class() -class TestStreaming(OrchestrationServiceTestBase): - - - def setUp(self): - super().setUp() - - self.template = Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - defaults=[TemplateValue(name="name", value="Integration Test")], - ) - self.llm = LLM( - name="gpt-4o-mini", - parameters={'temperature': 0.0} - ) - - def create_service(self, output_filtering=None, stream_options=None): - filter_config = None - if output_filtering: - filter_config = ContentFiltering(output_filtering=output_filtering) - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - filtering= filter_config, - ) - - if stream_options: - config.stream_options = stream_options - - return OrchestrationService(api_url=self.api_url, config=config) - - def test_streaming(self): - service = self.create_service() - - number_of_chunks = 0 - response_stream = service.stream() - for i, chunk in enumerate(response_stream): - chunk = cast(OrchestrationResponseStreaming, chunk) - if i == 0: - self.assertEqual(chunk.module_results.templating[1].content, "Hello, Integration Test!") - self.assertIsNone(chunk.module_results.llm) - else: - self.assertIsNotNone(chunk.module_results.llm) - number_of_chunks += 1 - self.assertGreater(number_of_chunks, 1, "Only one chunk received - stream seems to be buffered.") - - @unittest.skip("Internal server error") - def test_streaming_returns_token_usage(self): - self.llm = LLM(name=CLAUDE_4_5_SONNET_TEST_MODEL) - service = self.create_service() - - response_stream = service.stream() - for chunk in enumerate(response_stream): - response_streaming = cast(OrchestrationResponseStreaming, chunk)[1] - if response_streaming.orchestration_result.usage: - self.assertGreater(response_streaming.orchestration_result.usage.prompt_tokens, 0) - self.assertGreater(response_streaming.orchestration_result.usage.completion_tokens, 0) - self.assertGreater(response_streaming.orchestration_result.usage.total_tokens, 0) - self.assertGreater(response_streaming.module_results.llm.usage.total_tokens, 0) - - def test_streaming_with_stream_options(self, chunk_size=5): - service = self.create_service() - - response_stream = service.stream(stream_options={'chunk_size': chunk_size}) - number_of_chunks = 0 - for chunk in response_stream: - if chunk.orchestration_result.choices: - self.assertLessEqual(len(chunk.orchestration_result.choices[0].delta.content.split()), chunk_size) - number_of_chunks += 1 - self.assertGreater(number_of_chunks, 1, "Only one chunk received - stream seems to be buffered.") - - def test_streaming_with_invalid_stream_options(self): - service = self.create_service() - - with self.assertRaises(OrchestrationError): - for _ in service.stream(stream_options={'unknown': 10}): - pass - - def test_streaming_with_error_in_stream(self): - service = OrchestrationService( - api_url=self.api_url, - config=OrchestrationConfig( - llm=LLM( - name='gpt-4o-mini', - parameters={'temperature': 0.0, 'max_tokens': 100000} # This will exceed the token limit - ), - template=Template(messages=[UserMessage("Write a novel about maths")]) - ) - ) - - with self.assertRaises(OrchestrationError): - for _ in service.stream(): - pass - - def test_output_filtering_with_stream_options(self): - output_filtering = OutputFiltering( - filters=[ - AzureContentFilter( - hate=AzureThreshold.ALLOW_ALL, - self_harm=AzureThreshold.ALLOW_ALL, - sexual=AzureThreshold.ALLOW_ALL, - violence=AzureThreshold.ALLOW_ALL, - ) - ], - stream_options={'overlap': 10} - ) - - service = self.create_service(output_filtering=output_filtering) - response_stream = service.stream() - - number_of_chunks = 0 - for i, chunk in enumerate(response_stream): - chunk = cast(OrchestrationResponseStreaming, chunk) - if i == 0: - self.assertEqual(chunk.module_results.templating[1].content, "Hello, Integration Test!") - self.assertIsNone(chunk.module_results.llm) - elif i == 1: - self.assertIsNotNone(chunk.module_results.output_filtering) - self.assertIsNotNone(chunk.module_results.llm) - number_of_chunks += 1 - self.assertGreater(number_of_chunks, 1, "Only one chunk received - stream seems to be buffered.") - - def test_output_filtering_with_invalid_stream_options(self): - output_filtering = OutputFiltering( - filters=[ - AzureContentFilter( - hate=AzureThreshold.ALLOW_ALL, - self_harm=AzureThreshold.ALLOW_ALL, - sexual=AzureThreshold.ALLOW_ALL, - violence=AzureThreshold.ALLOW_ALL, - ) - ], - stream_options={'unknown': 10} - ) - - service = self.create_service(output_filtering) - - with self.assertRaises(OrchestrationError): - for _ in service.stream(): - pass diff --git a/packages/gen/integration_tests/orchestration/test_templating.py b/packages/gen/integration_tests/orchestration/test_templating.py deleted file mode 100644 index 7f13ffcd..00000000 --- a/packages/gen/integration_tests/orchestration/test_templating.py +++ /dev/null @@ -1,563 +0,0 @@ -import json -import os -import tempfile - -from PIL import Image -from typing import Dict, Any, List - -from gen_ai_hub import GenAIHubProxyClient -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.multimodal_items import ImageItem -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage, Message, ToolMessage, AssistantMessage -from gen_ai_hub.orchestration.models.response_format import ResponseFormatJsonSchema -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.models.template_ref import TemplateRef -from gen_ai_hub.orchestration.models.tools import function_tool -from gen_ai_hub.orchestration.service import OrchestrationService -from gen_ai_hub.prompt_registry.client import PromptTemplateClient -from gen_ai_hub.prompt_registry.models.prompt_template import PromptTemplateSpec, PromptTemplate -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from integration_tests.test_helpers import retry_on_429_or_503_class - - -@retry_on_429_or_503_class() -def check_response_from_referenced_template(response, content: str): - assert response.module_results.templating[0].content == content - assert len(response.orchestration_result.choices) > 0 - - -class TestTemplating(OrchestrationServiceTestBase): - - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.llm = LLM( - name="gpt-4o-mini", - version="latest", - parameters={ - "max_tokens": 50, - "temperature": 0.0, - }, - ) - - def test_templating_with_default(self): - default = TemplateValue(name="user_query", value="Why is the sky blue?") - - template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ], - defaults=[default], - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - response = self.service.run(config=config) - - self.assertEqual(response.module_results.templating[1].content, default.value) - self.assertIsNone(response.module_results.input_filtering) - self.assertIsNone(response.module_results.output_filtering) - self.assertTrue(response.orchestration_result.model.startswith(self.llm.name)) - - def test_templating_with_user_input(self): - template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ], - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - user_input = TemplateValue(name="user_query", value="Why is the sky blue?") - - response = self.service.run( - config=config, - template_values=[user_input], - ) - - self.assertEqual( - response.module_results.templating[1].content, user_input.value - ) - self.assertIsNone(response.module_results.input_filtering) - self.assertIsNone(response.module_results.output_filtering) - self.assertTrue(response.orchestration_result.model.startswith(self.llm.name)) - - def test_templating_with_no_messages(self): - template = Template( - messages=[], - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - with self.assertRaises(OrchestrationError): - self.service.run(config=config) - - def test_templating_with_invalid_message(self): - template = Template( - messages=[Message(role="unknown-role", content="Hello, world!")], - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - with self.assertRaises(OrchestrationError): - self.service.run(config=config) - - def test_templating_by_reference(self): - # create prompt template - prompt_template_scenario = "scenario_template_by_reference" - prompt_template_name = "prompt_template_by_reference" - prompt_template_version = "1.0.0" - user_content = "You are a system under test." - tenant_scoped_prompt_client = GenAIHubProxyClient(resource_group="") - prompt_template_client = PromptTemplateClient(tenant_scoped_prompt_client) - spec = PromptTemplateSpec(template=[PromptTemplate(role="user", content=user_content)]) - # reference prompt template - prompt_template_id = prompt_template_client.create_prompt_template(scenario=prompt_template_scenario, - name=prompt_template_name, - version=prompt_template_version, - prompt_template_spec=spec).id - config = OrchestrationConfig( - template=TemplateRef.from_id(prompt_template_id=prompt_template_id), - llm=self.llm, - ) - - response = self.service.run(config=config) - - check_response_from_referenced_template(response, user_content) - - config = OrchestrationConfig( - template=TemplateRef.from_tuple(scenario=prompt_template_scenario, name=prompt_template_name, - version=prompt_template_version), - llm=self.llm - ) - - response = self.service.run(config=config) - - check_response_from_referenced_template(response, user_content) - - # clean up - prompt_template_client.delete_prompt_template_by_id(prompt_template_id) - - def test_templating_with_response_format_text(self): - template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ], - response_format="text" - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - user_input = TemplateValue(name="user_query", value="Who was the first person on the moon?") - - response = self.service.run( - config=config, - template_values=[user_input], - ) - - result = response.orchestration_result.choices[0].message.content - self.assertIsInstance(result, str) - try: - json.loads(result) - self.fail(msg="Response should be a text.") - except json.JSONDecodeError: - # The error means result is not a JSON object, so the test passes in this block - pass - - def test_templating_with_response_format_json_object(self): - template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ], - response_format="json_object" - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - user_input = TemplateValue(name="user_query", value="Who was the first person on the moon? in json") - - response = self.service.run( - config=config, - template_values=[user_input], - ) - - try: - parsed_result = json.loads(response.orchestration_result.choices[0].message.content) - except json.JSONDecodeError: - self.fail("Result of LLM is not a valid JSON object") - - self.assertIsInstance(parsed_result, dict) - - def test_templating_with_response_format_json_schema(self): - json_schema = { - "title": "Person", - "type": "object", - "properties": { - "firstName": { - "type": "string", - "description": "The person's first name." - }, - "lastName": { - "type": "string", - "description": "The person's last name." - } - } - } - - exp_result = { - "firstName": "Neil", - "lastName": "Armstrong" - } - - template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ], - response_format=ResponseFormatJsonSchema(name="person", description="person mapping", schema=json_schema) - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - user_input = TemplateValue(name="user_query", value="Who was the first person on the moon? in json") - - response = self.service.run( - config=config, - template_values=[user_input], - ) - - try: - parsed_result = json.loads(response.orchestration_result.choices[0].message.content) - except json.JSONDecodeError: - self.fail("Result of LLM is not a valid JSON object") - - self.assertIsInstance(parsed_result, dict) - self.assertEqual(parsed_result, exp_result) - - def test_templating_with_response_format_json_schema_strict(self): - json_schema = { - "type": "object", - "properties": { - "firstName": { - "type": "string", - "description": "The person's first name." - }, - "lastName": { - "type": "string", - "description": "The person's last name." - } - }, - "additionalProperties": False, - "required" :["firstName", "lastName"] - } - - exp_result = { - "firstName": "Neil", - "lastName": "Armstrong" - } - - template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ], - response_format=ResponseFormatJsonSchema(name="person", description="person mapping", schema=json_schema, strict=True) - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - user_input = TemplateValue(name="user_query", value="Who was the first person on the moon?") - - response = self.service.run( - config=config, - template_values=[user_input], - ) - - try: - parsed_result = json.loads(response.orchestration_result.choices[0].message.content) - except json.JSONDecodeError: - self.fail("Result of LLM is not a valid JSON object") - - self.assertIsInstance(parsed_result, dict) - self.assertEqual(parsed_result, exp_result) - -@retry_on_429_or_503_class() -class TestTemplateWithTools(OrchestrationServiceTestBase): - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.llm = LLM( - name="gpt-4o-mini", - version="latest", - parameters={ - "max_tokens": 200, - "temperature": 0.0, - }, - ) - - def test_sync_tool_call_loop(self): - @function_tool() - def multiply(a: int, b: int) -> int: - """Multiply two numbers.""" - return a * b - - tool_map: Dict[str, Any] = { - "multiply": multiply, - } - - template = Template( - messages=[ - SystemMessage("You are a math assistant."), - UserMessage("What is {{?a}} times {{?b}}?"), - ], - tools=[multiply], - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - template_values = [ - TemplateValue(name="a", value=3), - TemplateValue(name="b", value=7), - ] - - # First run: should trigger a tool call - response = self.service.run( - config=config, - template_values=template_values, - ) - - # Check tool_calls in the response - tool_calls = response.orchestration_result.choices[0].message.tool_calls - self.assertIsNotNone(tool_calls) - self.assertGreaterEqual(len(tool_calls), 1) - tool_call = tool_calls[0] - self.assertEqual(tool_call.function.name, "multiply") - self.assertEqual(json.loads(tool_call.function.arguments), {"a": 3, "b": 7}) - self.assertIsNotNone(tool_call.id) - - # Check new fields if present - self.assertTrue(hasattr(tool_call, "id")) - self.assertTrue(hasattr(tool_call.function, "arguments")) - self.assertTrue(hasattr(tool_call.function, "name")) - - # Simulate tool execution and build new history - history: List[Message] = [] - history.extend(response.module_results.templating) - - assistant_message = AssistantMessage( - content=response.orchestration_result.choices[0].message.content, - refusal=response.orchestration_result.choices[0].message.refusal, - tool_calls=response.orchestration_result.choices[0].message.tool_calls) - - self.assertIsNone(assistant_message.refusal) - self.assertTrue(assistant_message.tool_calls) # assert some tool calls are present - - history.append(assistant_message) - - for tool_call in tool_calls: - tool = tool_map[tool_call.function.name] - result = tool.execute(**tool_call.function.parse_arguments()) - self.assertEqual(result, 21) - tool_message = ToolMessage( - content=f"{result}", - tool_call_id=tool_call.id, - ) - self.assertEqual(tool_message.tool_call_id, tool_call.id) - self.assertEqual(tool_message.content, str(result)) - self.assertEqual(tool_message.role, "tool") - history.append(tool_message) - - # Second run: should return the final answer - response2 = self.service.run( - config=config, - template_values=template_values, - history=history, - ) - - final_content = response2.orchestration_result.choices[0].message.content - self.assertIn("21", str(final_content)) - - tool_calls2 = response2.orchestration_result.choices[0].message.tool_calls - self.assertFalse(tool_calls2) - - def test_streaming_two_tool_call_buffering(self): - @function_tool() - def multiply(a: int, b: int) -> int: - """Multiply two numbers.""" - return a * b - - @function_tool() - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - template = Template( - messages=[ - SystemMessage("You are a math assistant."), - UserMessage("What is 3 * 12? Also, what is 11 + 49?"), - ], - tools=[multiply, add], - ) - - config = OrchestrationConfig( - template=template, - llm=self.llm, - ) - - # Start streaming - stream = self.service.stream(config=config) - - final_tool_calls = {} - - for chunk in stream: - for tool_call in chunk.orchestration_result.choices[0].delta.tool_calls or []: - index = tool_call.index - - if index not in final_tool_calls: - final_tool_calls[index] = tool_call - else: - # Concatenate arguments if split across chunks - final_tool_calls[index].function.arguments += tool_call.function.arguments - - self.assertEqual(len(final_tool_calls), 2) - - multiply_call = next( - call for call in final_tool_calls.values() - if call.function.name == "multiply" - ) - self.assertIsNotNone(multiply_call.id) - - add_call = next( - call for call in final_tool_calls.values() - if call.function.name == "add" - ) - self.assertIsNotNone(add_call.id) - - self.assertEqual( - json.loads(multiply_call.function.arguments), {"a": 3, "b": 12} - ) - - self.assertEqual( - json.loads(add_call.function.arguments), {"a": 11, "b": 49} - ) - -@retry_on_429_or_503_class() -class TestMultimodalTemplating(OrchestrationServiceTestBase): - @classmethod - def setUpClass(cls): - cls.temp_dir = tempfile.TemporaryDirectory() - cls.image_path = os.path.join(cls.temp_dir.name, "test_image.png") - img = Image.new("RGB", (10, 10), color="red") - img.save(cls.image_path) - - @classmethod - def tearDownClass(cls): - cls.temp_dir.cleanup() - - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.llm = LLM( - name="gpt-4o", - version="latest", - parameters={ - "max_tokens": 50, - "temperature": 0.0, - }, - ) - - def test_image_from_url(self): - data_url = ( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAAE0lEQVR4nGP8z4APMOGVZRip0gBBLAETee26JgAAAABJRU5ErkJggg==' - ) - image_item = ImageItem(url=data_url) - multimodal_content = [image_item, "What color is this image?"] - - template = Template(messages=[UserMessage(multimodal_content)]) - config = OrchestrationConfig(template=template, llm=self.llm) - response = self.service.run(config=config) - - self.assertIn("red", response.content.lower()) - - def test_image_from_file(self): - image_item = ImageItem.from_file(self.image_path) - multimodal_content = [image_item, "What color is this image?"] - - template = Template(messages=[UserMessage(multimodal_content)]) - config = OrchestrationConfig(template=template, llm=self.llm) - response = self.service.run(config=config) - - self.assertIn("red", response.content.lower()) - - def test_only_image(self): - image_item = ImageItem.from_file(self.image_path) - multimodal_content = [image_item] - - template = Template(messages=[UserMessage(multimodal_content)]) - config = OrchestrationConfig(template=template, llm=self.llm) - response = self.service.run(config=config) - - self.assertTrue(response.content) - - def test_multi_text_parts_are_handled(self): - multimodal_content = [ - "This is a text message.", - "This is another text message.", - ] - template = Template(messages=[UserMessage(multimodal_content)]) - config = OrchestrationConfig(template=template, llm=self.llm) - response = self.service.run(config=config) - - self.assertEqual( - len(response.module_results.templating[0].content), 2 - ) - self.assertTrue(response.content) - - def test_multimodal_input_streaming(self): - image_item = ImageItem.from_file(self.image_path) - multimodal_content = [image_item, "What color is this image?"] - - template = Template(messages=[UserMessage(multimodal_content)]) - config = OrchestrationConfig(template=template, llm=self.llm) - response = self.service.stream(config=config) - - message = '' - - for chunk in response: - if chunk.orchestration_result.choices: - message += chunk.orchestration_result.choices[0].delta.content - - self.assertIn("red", message.lower()) diff --git a/packages/gen/integration_tests/orchestration/test_translation.py b/packages/gen/integration_tests/orchestration/test_translation.py deleted file mode 100644 index 0a3092b7..00000000 --- a/packages/gen/integration_tests/orchestration/test_translation.py +++ /dev/null @@ -1,125 +0,0 @@ -import unittest - -from integration_tests.orchestration.test_base import OrchestrationServiceTestBase -from gen_ai_hub.orchestration.models.translation.sap_document_translation import SAPDocumentTranslation -from gen_ai_hub.orchestration.models.translation.translation import InputTranslationConfig, OutputTranslationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.template import Template -from gen_ai_hub.orchestration.service import OrchestrationService -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.template import TemplateValue -from integration_tests.test_helpers import retry_on_429_or_503_class - - -@retry_on_429_or_503_class() -class TestTranslation(OrchestrationServiceTestBase): - def setUp(self): - super().setUp() - self.service = OrchestrationService(api_url=self.api_url) - self.llm = LLM( - name="gpt-4o", - parameters={ - 'temperature': 0.0, - } - ) - self.template = Template( - messages=[ - SystemMessage("You are a friendly assistant."), - UserMessage("{{?user_query}}"), - ] - ) - - def test_translation(self): - """ - Run orchestration service with translation configuration. - """ - - input_config = InputTranslationConfig(source_language="en-US", target_language="de-DE") - output_config = OutputTranslationConfig(source_language="de-DE", target_language="en-US") - - translation_module = SAPDocumentTranslation( - input_translation_config=input_config, - output_translation_config=output_config - ) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - translation=translation_module - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is orchestration service?"), - ]) - - # Check the input translation output - self.assertRegex(response.module_results.input_translation.data["translated_template"], "Was ist .* Orchestrierungsservice") - # Check the output translation output - self.assertIn("choices", response.module_results.output_translation.data) - self.assertIn("orchestration", response.module_results.output_translation.data.get("choices")[0].get("message").get("content")) - # Check the orchestration result - self.assertIn("orchestration", response.orchestration_result.choices[0].message.content) - - def test_only_input_translation(self): - """ - Run orchestration service with translation configuration. - """ - - input_config = InputTranslationConfig(source_language="en-US", target_language="de-DE") - - translation_module = SAPDocumentTranslation( - input_translation_config=input_config - ) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - translation=translation_module - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is orchestration service?"), - ]) - - # Check the input translation output - self.assertRegex(response.module_results.input_translation.data["translated_template"], "Was ist .* Orchestrierungsservice") - # Check the output translation output - self.assertIsNone(response.module_results.output_translation) - # Check the orchestration result - self.assertIn("Orchestrierungsservice", response.orchestration_result.choices[0].message.content) - - def test_only_output_translation(self): - """ - Run orchestration service with translation configuration. - """ - - output_config = OutputTranslationConfig(source_language="en-US", target_language="de-DE") - - translation_module = SAPDocumentTranslation( - output_translation_config=output_config - ) - - config = OrchestrationConfig( - template=self.template, - llm=self.llm, - translation=translation_module - ) - - response = self.service.run(config=config, - template_values=[ - TemplateValue("user_query", "What is orchestration service?"), - ]) - - # Check the input translation output - self.assertIsNone(response.module_results.input_translation) - # Check the output translation output - self.assertIn("choices", response.module_results.output_translation.data) - self.assertIn("Orchestrierungsservice", response.module_results.output_translation.data.get("choices")[0].get("message").get("content")) - # Check the orchestration result - self.assertIn("Orchestrierungsservice", response.orchestration_result.choices[0].message.content) - - - diff --git a/packages/gen/tests/orchestration/__init__.py b/packages/gen/tests/orchestration/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/gen/tests/orchestration/test_config.py b/packages/gen/tests/orchestration/test_config.py deleted file mode 100644 index acf9864c..00000000 --- a/packages/gen/tests/orchestration/test_config.py +++ /dev/null @@ -1,54 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.content_filter import ContentFilter -from gen_ai_hub.orchestration.models.content_filtering import InputFiltering, OutputFiltering, ContentFiltering -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import Message, Role -from gen_ai_hub.orchestration.models.template import Template - - -class TestOrchestrationConfig(unittest.TestCase): - - def setUp(self): - self.template = Template( - messages=[Message(role=Role.USER, content="Hello, World!")] - ) - self.llm = LLM(name="gpt-4o-mini") - - def test_minimal_config(self): - config = OrchestrationConfig(template=self.template, llm=self.llm) - - json_data = config.to_dict() - self.assertEqual( - json_data["module_configurations"]["templating_module_config"], - self.template.to_dict(), - ) - self.assertEqual( - json_data["module_configurations"]["llm_module_config"], self.llm.to_dict() - ) - self.assertNotIn("filtering_module_config", json_data["module_configurations"]) - - def test_input_filtering(self): - input_filter = ContentFilter("new-content-filter", {"key": "value"}) - config = OrchestrationConfig( - template=self.template, llm=self.llm, - filtering=ContentFiltering(input_filtering=InputFiltering(filters=[input_filter])) - ) - json_data = config.to_dict() - self.assertEqual( - json_data["module_configurations"]["filtering_module_config"]["input"]["filters"][0], - input_filter.to_dict(), - ) - - def test_output_filtering(self): - output_filter = ContentFilter("new-content-filter", {"key": "value"}) - config = OrchestrationConfig( - template=self.template, llm=self.llm, - filtering=ContentFiltering(output_filtering=OutputFiltering(filters=[output_filter])) - ) - json_data = config.to_dict() - self.assertEqual( - json_data["module_configurations"]["filtering_module_config"]["output"]["filters"][0], - output_filter.to_dict(), - ) diff --git a/packages/gen/tests/orchestration/test_content_filter.py b/packages/gen/tests/orchestration/test_content_filter.py deleted file mode 100644 index 407ae008..00000000 --- a/packages/gen/tests/orchestration/test_content_filter.py +++ /dev/null @@ -1,78 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.azure_content_filter import AzureContentFilter, AzureThreshold -from gen_ai_hub.orchestration.models.content_filter import ContentFilter -from gen_ai_hub.orchestration.models.llama_guard_3_filter import LlamaGuard38bFilter - - -class TestContentFilter(unittest.TestCase): - - def test_content_filter_to_dict(self): - content_filter = ContentFilter("new-content-filter", {"key": "value"}) - expected_dict = {"type": "new-content-filter", "config": {"key": "value"}} - self.assertEqual(content_filter.to_dict(), expected_dict) - - -class TestAzureContentFilter(unittest.TestCase): - - def test_azure_content_filter_to_dict(self): - content_filter = AzureContentFilter(hate=AzureThreshold.ALLOW_SAFE, - sexual=AzureThreshold.ALLOW_ALL, - violence=AzureThreshold.ALLOW_SAFE_LOW_MEDIUM, - self_harm=AzureThreshold.ALLOW_SAFE_LOW) - - expected_dict = { - "type": 'azure_content_safety', - "config": { - "Hate": 0, - "Sexual": 6, - "Violence": 4, - "SelfHarm": 2, - }, - } - - self.assertEqual(content_filter.to_dict(), expected_dict) - - def test_azure_content_filter_with_invalid_threshold(self): - with self.assertRaises(ValueError): - AzureContentFilter(hate=10, sexual=6, violence=4, self_harm=2) - - def test_azure_content_filter_with_literal_thresholds(self): - content_filter = AzureContentFilter(hate=0, sexual=6, violence=4, self_harm=2) - - expected_dict = { - "type": 'azure_content_safety', - "config": { - "Hate": 0, - "Sexual": 6, - "Violence": 4, - "SelfHarm": 2, - }, - } - - self.assertEqual(content_filter.to_dict(), expected_dict) - - def test_llama_guard_content_filter_to_dict(self): - content_filter = LlamaGuard38bFilter() - - expected_dict = { - "type": 'llama_guard_3_8b', - "config":{ - "violent_crimes": False, - "non_violent_crimes": False, - "sex_crimes": False, - "child_exploitation": False, - "defamation": False, - "specialized_advice": False, - "privacy": False, - "intellectual_property": False, - "indiscriminate_weapons": False, - "hate": False, - "self_harm": False, - "sexual_content": False, - "elections": False, - "code_interpreter_abuse": False, - } - } - - self.assertEqual(content_filter.to_dict(), expected_dict) diff --git a/packages/gen/tests/orchestration/test_data_masking.py b/packages/gen/tests/orchestration/test_data_masking.py deleted file mode 100644 index 16323c30..00000000 --- a/packages/gen/tests/orchestration/test_data_masking.py +++ /dev/null @@ -1,39 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.data_masking import DataMasking -from gen_ai_hub.orchestration.models.sap_data_privacy_integration import SAPDataPrivacyIntegration, MaskingMethod, \ - ProfileEntity - - -class TestDataMasking(unittest.TestCase): - - def test_sap_data_privacy_integration(self): - data_masking = DataMasking( - providers=[ - SAPDataPrivacyIntegration( - method=MaskingMethod.PSEUDONYMIZATION, - entities=[ProfileEntity.EMAIL], - allowlist=["SAP"] - ) - ] - ) - - expected_dict = { - "masking_providers": [ - { - "type": "sap_data_privacy_integration", - "method": "pseudonymization", - "entities": [ - { - "type": "profile-email" - } - ], - "allowlist": ["SAP"], - "mask_grounding_input": { - "enabled": False - } - } - ] - } - - self.assertEqual(data_masking.to_dict(), expected_dict) diff --git a/packages/gen/tests/orchestration/test_grounding.py b/packages/gen/tests/orchestration/test_grounding.py deleted file mode 100644 index c024c811..00000000 --- a/packages/gen/tests/orchestration/test_grounding.py +++ /dev/null @@ -1,59 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.document_grounding import (GroundingType, DataRepositoryType, DocumentMetadata, - DocumentGroundingFilter, GroundingFilterSearch, - GroundingModule, DocumentGrounding) - - -class TestGrounding(unittest.TestCase): - - def test_document_metadata(self): - metadata = DocumentMetadata(key="key", value=["value"], select_mode=["ignoreIfKeyAbsent"]) - metadata_json = metadata.to_dict() - self.assertEqual(metadata_json["key"], "key") - self.assertEqual(metadata_json["value"], ["value"]) - self.assertEqual(metadata_json["select_mode"], ["ignoreIfKeyAbsent"]) - - def test_grounding_filter_search_configuration(self): - search_config = GroundingFilterSearch(max_chunk_count=10) - search_config_json = search_config.to_dict() - self.assertEqual(search_config_json["max_chunk_count"], 10) - self.assertIsNone(search_config_json.get("max_document_count")) - - def test_grounding_filter(self): - grounding_filter = DocumentGroundingFilter(id="id", - data_repository_type=DataRepositoryType.VECTOR.value, - data_repositories=["46b508c9-e490-4808-893b-b8e3361c4213"], - data_repository_metadata=[{ - "key": "data_repository_key", - "value": ["data_repository_value"], - }], - search_config=GroundingFilterSearch(max_chunk_count=3), - document_metadata=[DocumentMetadata( - key="keyTest", - value=["ValueTest1"], - select_mode=["ignoreIfKeyAbsent"] - )], - chunk_metadata=[{ - "key": "chunk_metadata_key", - "value": ["chunk_metadata_value"], - }] - ) - filter_json = grounding_filter.to_dict() - self.assertEqual(filter_json["id"], "id") - self.assertEqual(filter_json["data_repository_type"], "vector") - - def test_grounding_configuration(self): - filters = [DocumentGroundingFilter(id="id", data_repository_type=DataRepositoryType.VECTOR.value)] - grounding_config = GroundingModule( - type=GroundingType.DOCUMENT_GROUNDING_SERVICE.value, - config=DocumentGrounding(input_params=["user_query"], output_param="grounding_response", - filters=filters, metadata_params=["metadata_param"]) - ) - config_json = grounding_config.to_dict() - self.assertEqual(config_json["type"], "document_grounding_service") - self.assertEqual(config_json["config"]["input_params"], ["user_query"]) - self.assertEqual(config_json["config"]["output_param"], "grounding_response") - self.assertEqual(config_json["config"]["filters"][0]["id"], "id") - self.assertEqual(config_json["config"]["filters"][0]["data_repository_type"], "vector") - self.assertEqual(config_json["config"]["metadata_params"], ["metadata_param"]) diff --git a/packages/gen/tests/orchestration/test_llm.py b/packages/gen/tests/orchestration/test_llm.py deleted file mode 100644 index ad199c62..00000000 --- a/packages/gen/tests/orchestration/test_llm.py +++ /dev/null @@ -1,31 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.llm import LLM - - -class TestLLM(unittest.TestCase): - def test_llm_default_version(self): - llm = LLM("gpt-4o-mini") - json_data = llm.to_dict() - self.assertEqual(json_data["model_version"], "latest") - - def test_llm_with_no_parameters(self): - llm = LLM("gpt-4o-mini") - json_data = llm.to_dict() - self.assertEqual(json_data["model_params"], {}) - - def test_llm_custom_parameters(self): - params = {"temperature": 0.7, "max_tokens": 100} - llm = LLM("gpt-4o-mini", parameters=params) - json_data = llm.to_dict() - self.assertEqual(json_data["model_params"], params) - - def test_llm_json_serialization(self): - llm = LLM("gpt-4o-mini", "v1", {"temperature": 0.7}) - expected_dict = { - "model_name": "gpt-4o-mini", - "model_version": "v1", - "model_params": {"temperature": 0.7}, - } - json_data = llm.to_dict() - self.assertEqual(json_data, expected_dict) diff --git a/packages/gen/tests/orchestration/test_service.py b/packages/gen/tests/orchestration/test_service.py deleted file mode 100644 index 77fbb00b..00000000 --- a/packages/gen/tests/orchestration/test_service.py +++ /dev/null @@ -1,446 +0,0 @@ - -import httpx -import unittest -from unittest.mock import Mock, patch, AsyncMock -from typing import cast - -from gen_ai_hub.orchestration.exceptions import OrchestrationError -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import SystemMessage, UserMessage -from gen_ai_hub.orchestration.models.template import Template, TemplateValue -from gen_ai_hub.orchestration.service import OrchestrationService, cache_if_not_none -from tests.mock import ( - get_mocked_ai_core_client, - ai_core_ai_api_mocker, - orchestration_completion_mocker, - orchestration_stream_completion_mocker, - orchestration_stream_completion_mocker_async, - orchestration_deployment_not_found_mocker, - orchestration_too_many_requests_mocker, - GET_ORCHESTRATION_COMPLETION_RESPONSE -) - - -class TestOrchestrationService(unittest.TestCase): - - NOT_EXISTENT_DEPLOYMENT_ID = "not_existent" - - def setUp(self): - self.api_url = "https://api.example.com" - self.config = OrchestrationConfig( - llm=LLM(name="gemini-2.5-flash-lite"), - template=Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - defaults=[TemplateValue("name", "World")], - ), - ) - self.proxy_client = get_mocked_ai_core_client(client_id='testopenaiclient') - - def test_caching(self): - - @cache_if_not_none - def func(arg): - func.calls += 1 - return arg - - func.calls = 0 - - self.assertEqual(func(1), 1) - self.assertEqual(func.calls, 1) - self.assertEqual(func(1), 1) - self.assertEqual(func.calls, 1) - func.cache_clear() - self.assertEqual(func(1), 1) - self.assertEqual(func.calls, 2) - self.assertEqual(func(None), None) - self.assertEqual(func.calls, 3) - self.assertEqual(func(None), None) - self.assertEqual(func.calls, 4) - - def test_initialization_with_empty_api_url(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(proxy_client=self.proxy_client) - self.assertEqual(client.api_url, "https://api.ai.internalprod.eu-central-1.aws.ml.hana.ondemand.com/v2/inference/deployments/d7f9c215310f5a11") - - def test_initialization_with_config_name(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(config_id="0152d9f0-694f-4bd2-a287-f7d270c9db60", proxy_client=self.proxy_client) - self.assertEqual(client.api_url, "https://api.ai.internalprod.eu-central-1.aws.ml.hana.ondemand.com/v2/inference/deployments/dea20c27f7fe0eca") - - def test_initialization_with_config_id(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(config_name="orchestration-config-2", proxy_client=self.proxy_client) - self.assertEqual(client.api_url, "https://api.ai.internalprod.eu-central-1.aws.ml.hana.ondemand.com/v2/inference/deployments/dea20c27f7fe0eca") - - def test_initialization_with_deployment_id(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(deployment_id="dea20c27f7fe0eca", proxy_client=self.proxy_client) - self.assertEqual(client.api_url, "https://base_url/v2/inference/deployments/dea20c27f7fe0eca") - - def test_initialization_with_non_existing_deployment_id(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(deployment_id=self.NOT_EXISTENT_DEPLOYMENT_ID, proxy_client=self.proxy_client) - self.assertIn(self.NOT_EXISTENT_DEPLOYMENT_ID, client.api_url) - - def test_run_with_non_existing_deployment_id(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(deployment_id=self.NOT_EXISTENT_DEPLOYMENT_ID, proxy_client=self.proxy_client) - with orchestration_deployment_not_found_mocker(client.api_url + '/completion'): - with self.assertRaises(httpx.HTTPStatusError): - client.run(config=self.config) - - def test_run_without_config(self): - service = OrchestrationService(api_url=self.api_url, proxy_client=Mock()) - - with self.assertRaises(ValueError): - service.run() - - def test_run_with_config(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with orchestration_completion_mocker(client.api_url + '/completion'): - client.run(config=self.config) - - def test_stream_with_config(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with orchestration_stream_completion_mocker(client.api_url + '/completion'): - txt = '' - for chunk in client.stream(config=self.config): - if chunk.orchestration_result.choices: - txt += chunk.orchestration_result.choices[0].delta.content - self.assertEqual(txt, 'This confirms receipt of the system message: "Hello, World!\n') - - def test_too_many_requests(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with orchestration_too_many_requests_mocker(client.api_url + '/completion'): - with self.assertRaises(OrchestrationError) as context: - client.run(config=self.config) - self.assertIn('X-Custom-Header', cast(OrchestrationError, context.exception).http_headers) - - def test_retry_backoff_with_retry_after_header(self): - """Test that retry backoff respects Retry-After header and applies jitter correctly.""" - import time - - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - call_times = [] - retry_counts = [] - - # Patch the handle_retry method to track calls and timing - original_handle_retry = client.handle_retry - - def track_retry(*args, **kwargs): - call_times.append(time.time()) - retry_counts.append(args[0]) # retry_count is first arg - return original_handle_retry(*args, **kwargs) - - with orchestration_too_many_requests_mocker(client.api_url + '/completion'): - with patch.object(client, 'handle_retry', side_effect=track_retry): - with self.assertRaises(OrchestrationError) as context: - client.run_with_retries(config=self.config, max_retries=1, base_delay=1.0) - - error = cast(OrchestrationError, context.exception) - - # Verify retry was attempted (with max_retries=1, we get 1 retry attempt) - # The retry_count starts at 1 (after initial failure) - self.assertEqual(len(retry_counts), 1, "Expected 1 retry attempt") - self.assertEqual(retry_counts, [0], "Expected retry count of 0 for first retry") - - # Verify delay is positive - if len(call_times) >= 1: - self.assertGreater(len(call_times), 0, "Expected at least one retry delay measurement") - - # Verify error tracking - self.assertIn('X-Custom-Header', error.http_headers) - - def test_handle_retry_with_retry_after(self): - """Test that handle_retry uses exponential backoff with Retry-After header.""" - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - # Create a mock error without Retry-After header - response = Mock(spec=httpx.Response) - response.status_code = 429 - response.headers = httpx.Headers({"Retry-After": "3"}) - response.text = "Too Many Requests" - response.request = Mock() - - error = httpx.HTTPStatusError("429 Too Many Requests", request=response.request, response=response) - - # Collect delays for multiple retries - delays = [] - for retry_count in range(3): - delay = client.handle_retry(retry_count=retry_count, base_delay=1.0, error=error, max_retries=5) - delays.append(delay) - - # Verify all delays are positive - for delay in delays: - self.assertGreater(delay, 0.0, "All delays should be positive") - self.assertLessEqual(delay, 60.0, "All delays should respect max_delay") - - def test_handle_retry_without_retry_after(self): - """Test that handle_retry uses exponential backoff when no Retry-After header.""" - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - # Create a mock error without Retry-After header - response = Mock(spec=httpx.Response) - response.status_code = 429 - response.headers = httpx.Headers({}) - response.text = "Too Many Requests" - response.request = Mock() - - error = httpx.HTTPStatusError("429 Too Many Requests", request=response.request, response=response) - - # Collect delays for multiple retries - delays = [] - for retry_count in range(3): - delay = client.handle_retry(retry_count=retry_count, base_delay=1.0, error=error, max_retries=5) - delays.append(delay) - - # Verify all delays are positive - for delay in delays: - self.assertGreater(delay, 0.0, "All delays should be positive") - self.assertLessEqual(delay, 60.0, "All delays should respect max_delay") - - def test_handle_retry_max_retries_exceeded(self): - """Test that handle_retry raises error when max_retries is exceeded.""" - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - # Create a mock error - response = Mock(spec=httpx.Response) - response.status_code = 429 - response.headers = httpx.Headers({}) - response.text = "Too Many Requests" - response.request = Mock() - - error = httpx.HTTPStatusError("429 Too Many Requests", request=response.request, response=response) - - # Mock _should_retry to return True so we test the retry_count >= max_retries condition - with patch.object(client, '_should_retry', return_value=True): - # Test that it raises when retry_count >= max_retries - # Need to call handle_retry within an exception context since it uses bare 'raise' - try: - raise error - except httpx.HTTPStatusError as e: - with self.assertRaises(httpx.HTTPStatusError) as context: - client.handle_retry(retry_count=3, base_delay=1.0, error=e, max_retries=3) - - # Verify retries attribute was set - raised_error = context.exception - self.assertEqual(raised_error.retries, 3, "Expected retries attribute to be set") - - def test_calculate_backoff_with_min_delay(self): - """Test _calculate_backoff behavior with min_delay parameter.""" - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - # Test with min_delay (simulating Retry-After header) - retry_after_delay = 5.0 - - # Case 1: When min_delay > exponential delay, returns capped (not min_delay) - # For retry_count=0, base_delay=1.0: exp = 1.0 * 2^0 = 1.0 - # With min_delay=5.0, max_delay=60.0: capped = min(1.0, 60.0) = 1.0 - # lower = max(0.0, 5.0) = 5.0 - # Since lower (5.0) >= capped (1.0), returns capped = 1.0 - delay1 = client._calculate_backoff(retry_count=0, base_delay=1.0, min_delay=retry_after_delay, - max_delay=60.0) - self.assertEqual(delay1, 1.0, "When min_delay > exp_delay, should return capped exponential value") - - # Case 2: When exponential delay > min_delay, applies jitter between min_delay and capped - # For retry_count=3, base_delay=1.0: exp = 1.0 * 2^3 = 8.0 - # With min_delay=5.0, max_delay=60.0: capped = min(8.0, 60.0) = 8.0 - # lower = max(0.0, 5.0) = 5.0 - # Since lower (5.0) < capped (8.0), returns random.uniform(5.0, 8.0) - delays = [ - client._calculate_backoff(retry_count=3, base_delay=1.0, min_delay=retry_after_delay, max_delay=60.0) - for _ in range(20)] - - # All delays should be between min_delay and the exponential cap - for delay in delays: - self.assertGreaterEqual(delay, retry_after_delay, "Delay should be at least min_delay") - self.assertLessEqual(delay, 8.0, "Delay should not exceed exponential cap for retry_count=3") - - # Verify jitter produces variance - unique_delays = len(set(delays)) - self.assertGreater(unique_delays, 1, "Expected jitter to produce varying delays") - - # Case 3: When min_delay == max_delay and exp > min_delay - # For retry_count=10, base_delay=1.0: exp = 1.0 * 2^10 = 1024.0 - # With min_delay=5.0, max_delay=5.0: capped = min(1024.0, 5.0) = 5.0 - # lower = max(0.0, 5.0) = 5.0 - # Since lower (5.0) >= capped (5.0), returns capped = 5.0 - delay3 = client._calculate_backoff(retry_count=10, base_delay=1.0, min_delay=retry_after_delay, - max_delay=retry_after_delay) - self.assertEqual(delay3, retry_after_delay, "When min_delay == max_delay, should return that value") - - # Case 4: Test without min_delay (standard behavior) - # For retry_count=2, base_delay=1.0: exp = 1.0 * 2^2 = 4.0 - # With min_delay=0.0, max_delay=60.0: capped = min(4.0, 60.0) = 4.0 - # lower = max(0.0, 0.0) = 0.0 - # Returns random.uniform(0.0, 4.0) - delays_no_min = [ - client._calculate_backoff(retry_count=2, base_delay=1.0, min_delay=0.0, max_delay=60.0) - for _ in range(20)] - - for delay in delays_no_min: - self.assertGreaterEqual(delay, 0.0, "Delay should be non-negative") - self.assertLessEqual(delay, 4.0, "Delay should not exceed exponential value") - - # Verify variance with no min_delay - self.assertGreater(len(set(delays_no_min)), 1, "Expected jitter without min_delay") - - def test_calculate_backoff_exponential_progression(self): - """Test _calculate_backoff produces exponential backoff progression.""" - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - # Collect average delays for different retry counts - num_samples = 50 - avg_delays = [] - - for retry_count in range(5): - delays = [client._calculate_backoff(retry_count=retry_count, base_delay=1.0, min_delay=0.0) - for _ in range(num_samples)] - avg_delays.append(sum(delays) / len(delays)) - - # Verify exponential growth in average delays - for i in range(len(avg_delays) - 1): - self.assertLess(avg_delays[i], avg_delays[i + 1], - f"Expected retry {i + 1} to have higher average delay than retry {i}") - - # Verify capping at max_delay - large_delays = [client._calculate_backoff(retry_count=10, base_delay=1.0, max_delay=60.0) - for _ in range(10)] - for delay in large_delays: - self.assertLessEqual(delay, 60.0, "Delay should be capped at max_delay") - - def test_calculate_backoff_custom_max_delay(self): - """Test _calculate_backoff respects custom max_delay.""" - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - - custom_max = 10.0 - delays = [client._calculate_backoff(retry_count=5, base_delay=1.0, max_delay=custom_max) - for _ in range(20)] - - for delay in delays: - self.assertLessEqual(delay, custom_max, "Delay should respect custom max_delay") - self.assertGreaterEqual(delay, 0.0, "Delay should be non-negative") - - def test_timeout_client_request(self): - class FakeResponse: - def raise_for_status(self): - pass # No-op for mock tests - - def json(self): - return GET_ORCHESTRATION_COMPLETION_RESPONSE - - - timeout_captured = {} # Capture the request kwargs from mocked post method - def capture_request(*args, **kwargs): - nonlocal timeout_captured - timeout_captured = kwargs - return FakeResponse() - - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - # no timeout set in both httpx client and request - service = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with patch.object(service.client, "post", side_effect=capture_request): - service.run(config=self.config) - self.assertEqual(timeout_captured.get("timeout"), httpx.USE_CLIENT_DEFAULT) - - # timeout set in httpx client, not overwritten in request - service = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client, timeout=99.0) - with patch.object(service.client, "post", side_effect=capture_request): - service.run(config=self.config) - self.assertEqual(timeout_captured.get("timeout"), 99.0) - - # timeout overwrite in request - service = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client, timeout=99.0) - with patch.object(service.client, "post", side_effect=capture_request): - service.run(config=self.config, timeout=77.0) - self.assertEqual(timeout_captured.get("timeout"), 77.0) - - -class TestOrchestrationServiceAsync(unittest.IsolatedAsyncioTestCase): - - def setUp(self): - self.api_url = "https://api.example.com" - self.config = OrchestrationConfig( - llm=LLM(name="gemini-2.0-flash"), - template=Template( - messages=[ - SystemMessage("This is a system message."), - UserMessage("Hello, {{?name}}!"), - ], - defaults=[TemplateValue("name", "World")], - ), - ) - self.proxy_client = get_mocked_ai_core_client(client_id='testopenaiclient') - - async def test_async_run_with_config(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with orchestration_completion_mocker(client.api_url + '/completion'): - await client.arun(config=self.config) - - async def test_async_timeout_client_request(self): - class FakeResponse: - def raise_for_status(self): - pass # No-op for mock tests - - def json(self): - return GET_ORCHESTRATION_COMPLETION_RESPONSE - - - timeout_captured = {} # Capture the request kwargs from mocked post method - async def capture_request(*args, **kwargs): - nonlocal timeout_captured - timeout_captured = kwargs - return FakeResponse() - - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - # no timeout set in both httpx client and request - service = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with patch.object(service.async_client, "post", new=AsyncMock(side_effect=capture_request)): - await service.arun(config=self.config) - self.assertEqual(timeout_captured.get("timeout"), httpx.USE_CLIENT_DEFAULT) - - # timeout set in httpx client, not overwritten in request - service = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client, timeout=99.0) - with patch.object(service.async_client, "post", new=AsyncMock(side_effect=capture_request)): - await service.arun(config=self.config) - self.assertEqual(timeout_captured.get("timeout"), 99.0) - - # timeout overwrite in request - service = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client, timeout=99.0) - with patch.object(service.async_client, "post", new=AsyncMock(side_effect=capture_request)): - await service.arun(config=self.config, timeout=77.0) - self.assertEqual(timeout_captured.get("timeout"), 77.0) - - async def test_async_stream_with_config(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - async with orchestration_stream_completion_mocker_async(client.api_url + '/completion'): - txt = '' - async for chunk in await client.astream(config=self.config): - if chunk.orchestration_result.choices: - txt += chunk.orchestration_result.choices[0].delta.content - self.assertEqual(txt, 'This confirms receipt of the system message: "Hello, World!\n') - - async def test_async_run_with_retries(self): - with ai_core_ai_api_mocker(auth_url=self.proxy_client.auth_url, base_url=self.proxy_client.base_url): - client = OrchestrationService(api_url=self.api_url, proxy_client=self.proxy_client) - with orchestration_too_many_requests_mocker(client.api_url + '/completion'): - with self.assertRaises(OrchestrationError) as context: - await client.arun_with_retries(config=self.config, max_retries=1, base_delay=1.0) - self.assertIn('X-Custom-Header', cast(OrchestrationError, context.exception).http_headers) diff --git a/packages/gen/tests/orchestration/test_sse_client.py b/packages/gen/tests/orchestration/test_sse_client.py deleted file mode 100644 index 4bf8a51d..00000000 --- a/packages/gen/tests/orchestration/test_sse_client.py +++ /dev/null @@ -1,283 +0,0 @@ -import json -import unittest -from unittest.mock import Mock - -from httpx import Response - -from gen_ai_hub.orchestration.sse_client import AsyncSSEClient - - -def create_valid_event(token="test"): - """ - Create a valid event structure matching the actual SSE response format. - - See tests/mock.py for structure details. - """ - return { - "request_id": "test-request-id", - "module_results": { - "input_filtering": None, - "output_filtering": None, - "input_masking": None, - "llm": { - "id": "test-id", - "object": "chat.completion.chunk", - "created": 1738573708, - "model": "gemini-2.0-flash", - "choices": [ - { - "index": 0, - "delta": {"content": token, "role": "assistant"}, - "finish_reason": None, - "logprobs": None - } - ], - "system_fingerprint": None - }, - "templating": None, - "output_unmasking": None - }, - "orchestration_result": { - "id": "test-id", - "object": "chat.completion.chunk", - "created": 1738573708, - "model": "gemini-2.0-flash", - "choices": [ - { - "index": 0, - "delta": {"content": token, "role": "assistant"}, - "finish_reason": None, - "logprobs": None - } - ], - "system_fingerprint": None - } - } - - -class TestSSEClientBuffering(unittest.IsolatedAsyncioTestCase): - """Tests for AsyncSSEClient with manual buffering using aiter_text().""" - - def setUp(self): - """Set up test fixtures.""" - self.event_prefix = "data: " - self.final_message = "[DONE]" - - async def test_simple_complete_lines(self): - """Test that complete lines are processed correctly.""" - event1 = create_valid_event("test1") - event2 = create_valid_event("test2") - - chunks = [ - f"data: {json.dumps(event1)}\n", - f"data: {json.dumps(event2)}\n", - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 2) - self.assertEqual(results[0].request_id, "test-request-id") - self.assertEqual(results[1].request_id, "test-request-id") - - async def test_json_split_across_chunks(self): - """Test that JSON split across multiple chunks is handled correctly.""" - event = create_valid_event("split test") - event_str = json.dumps(event) - - # Split the JSON in the middle - mid = len(event_str) // 2 - part1 = event_str[:mid] - part2 = event_str[mid:] - - chunks = [ - f"data: {part1}", # First part without newline - f"{part2}\n", # Second part with newline - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].request_id, "test-request-id") - self.assertEqual(results[0].module_results.llm.choices[0].delta.content, "split test") - - async def test_multiple_events_in_single_chunk(self): - """Test that multiple events in a single chunk are processed correctly.""" - event1 = create_valid_event("first") - event2 = create_valid_event("second") - - chunks = [ - f"data: {json.dumps(event1)}\ndata: {json.dumps(event2)}\n", - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 2) - self.assertEqual(results[0].module_results.llm.choices[0].delta.content, "first") - self.assertEqual(results[1].module_results.llm.choices[0].delta.content, "second") - - async def test_final_message_stops_iteration(self): - """Test that [DONE] message stops iteration.""" - event1 = create_valid_event("before done") - - chunks = [ - f"data: {json.dumps(event1)}\n", - f"data: {self.final_message}\n", - f"data: {json.dumps(event1)}\n", # This should not be processed - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - # Should only have one result before [DONE] - self.assertEqual(len(results), 1) - self.assertEqual(results[0].module_results.llm.choices[0].delta.content, "before done") - - async def test_empty_lines_ignored(self): - """Test that empty lines are ignored.""" - event = create_valid_event("test") - - chunks = [ - "\n", - f"data: {json.dumps(event)}\n", - "\n", - "\n", - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].request_id, "test-request-id") - - async def test_lines_without_event_prefix_ignored(self): - """Test that lines without the event prefix are ignored.""" - event = create_valid_event("test") - - chunks = [ - "invalid line\n", - f"data: {json.dumps(event)}\n", - "another invalid line\n", - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].request_id, "test-request-id") - - async def test_partial_line_at_end_of_stream(self): - """Test that a partial line at the end of the stream is processed.""" - event = create_valid_event("final") - - # Last chunk has no newline - chunks = [ - f"data: {json.dumps(event)}", - ] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].request_id, "test-request-id") - self.assertEqual(results[0].module_results.llm.choices[0].delta.content, "final") - - async def test_very_small_chunks(self): - """Test handling of very small chunks (simulating slow network).""" - event = create_valid_event("test") - event_str = f"data: {json.dumps(event)}\n" - - # Split into very small chunks (2 characters each) - chunks = [event_str[i:i + 2] for i in range(0, len(event_str), 2)] - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].request_id, "test-request-id") - - async def test_complex_json_with_nested_objects(self): - """Test handling of complex nested JSON objects split across chunks.""" - event = create_valid_event("Hello") - event_str = json.dumps(event) - - # Split the JSON across multiple chunks - chunk_size = 20 - parts = [event_str[i:i + chunk_size] for i in range(0, len(event_str), chunk_size)] - - chunks = [f"data: {parts[0]}"] - for part in parts[1:-1]: - chunks.append(part) - chunks.append(f"{parts[-1]}\n") - - mock_response = Mock(spec=Response) - mock_response.aiter_text = Mock(return_value=self._async_generator(chunks)) - - client = AsyncSSEClient(mock_response, self.event_prefix, self.final_message) - client._response = mock_response - results = [] - async for result in client._internal_iterator(): - results.append(result) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0].request_id, "test-request-id") - self.assertEqual(results[0].module_results.llm.choices[0].delta.content, "Hello") - - async def _async_generator(self, items): - """Helper to create async generator from list.""" - for item in items: - yield item - - -if __name__ == '__main__': - unittest.main() diff --git a/packages/gen/tests/orchestration/test_template.py b/packages/gen/tests/orchestration/test_template.py deleted file mode 100644 index ad576bdb..00000000 --- a/packages/gen/tests/orchestration/test_template.py +++ /dev/null @@ -1,275 +0,0 @@ -import os -import base64 -import unittest -import tempfile - -from gen_ai_hub.orchestration.models.message import ( - Role, - SystemMessage, - UserMessage, - AssistantMessage, -) -from gen_ai_hub.orchestration.models.response_format import ( - ResponseFormatType, - ResponseFormatText, - ResponseFormatJsonObject, - ResponseFormatFactory, - ResponseFormatJsonSchema -) -from gen_ai_hub.orchestration.models.template import TemplateValue, Template -from gen_ai_hub.orchestration.models.multimodal_items import ImageItem, ImageDetailLevel - - -class TestTemplate(unittest.TestCase): - def test_template_with_defaults(self): - messages = [ - SystemMessage("You are a helpful assistant!"), - UserMessage("Hello, {{?name}}!"), - AssistantMessage("How can I help you today?"), - ] - defaults = [TemplateValue("name", "World")] - template = Template(messages, defaults) - - json_data = template.to_dict() - self.assertEqual(json_data["defaults"], {"name": "World"}) - self.assertEqual(len(json_data["template"]), len(messages)) - self.assertEqual(json_data["template"][1]["content"], "Hello, {{?name}}!") - - def test_template_without_defaults(self): - messages = [UserMessage("Simple message")] - template = Template(messages) - - json_data = template.to_dict() - self.assertEqual(json_data["defaults"], {}) - self.assertEqual(len(json_data["template"]), 1) - - def test_template_without_response_format(self): - messages = [UserMessage("Simple message")] - template = Template(messages) - - json_data = template.to_dict() - self.assertNotIn("response_format", json_data) - - def test_template_with_response_format_text(self): - messages = [UserMessage("Simple message")] - template = Template(messages, response_format='text') - - json_data = template.to_dict() - self.assertEqual(json_data["response_format"]["type"], ResponseFormatType.TEXT) - - def test_template_with_response_format_json_object(self): - messages = [UserMessage("Simple message")] - template = Template(messages, response_format='json_object') - - json_data = template.to_dict() - self.assertEqual(json_data["response_format"]["type"], ResponseFormatType.JSON_OBJECT) - - def test_template_with_response_format_json_schema(self): - messages = [UserMessage("Simple message")] - json_schema_example = { - "$id": "someid", - "$schema": "someshema", - "title": "Person", - "type": "object", - "properties": { - "firstName": { - "type": "string", - "description": "The person's first name." - }, - "lastName": { - "type": "string", - "description": "The person's last name." - } - } - } - - response_format = ResponseFormatJsonSchema(name="test", schema=json_schema_example, strict=True) - template = Template(messages, response_format=response_format) - - json_data = template.to_dict() - self.assertEqual(json_data["response_format"]["type"], ResponseFormatType.JSON_SCHEMA) - self.assertEqual(json_data["response_format"]["json_schema"]["name"], "test") - self.assertTrue(json_data["response_format"]["json_schema"]["strict"]) - self.assertEqual(json_data["response_format"]["json_schema"]["schema"], json_schema_example) - - def test_response_format_factory(self): - exp_result = ResponseFormatText() - response = ResponseFormatFactory.create_response_format_object(ResponseFormatType.TEXT) - self.assertEqual(response.to_dict(), exp_result.to_dict()) - - exp_result = ResponseFormatJsonObject() - response = ResponseFormatFactory.create_response_format_object(ResponseFormatType.JSON_OBJECT) - self.assertEqual(response.to_dict(), exp_result.to_dict()) - - exp_result = ResponseFormatJsonSchema(name="test", description="desc", schema={}, strict=True) - response = ResponseFormatFactory.create_response_format_object( - ResponseFormatJsonSchema(name="test", description="desc", schema={}, strict=True)) - self.assertEqual(response.to_dict(), exp_result.to_dict()) - - exp_result = None - response = ResponseFormatFactory.create_response_format_object(None) - self.assertEqual(response, exp_result) - - def test_response_format_name_not_valid(self): - not_valid_name = "test." - try: - ResponseFormatFactory.create_response_format_object( - ResponseFormatJsonSchema(name=not_valid_name, schema={})) - self.fail("Expected the validation to fail due to an invalid name format") - except ValueError: - pass - - def test_response_format_name_too_long(self): - name_too_long = "ThisIsAveryLongNameLongerThenExpectedItShouldBeMaximum64Characters" - try: - ResponseFormatFactory.create_response_format_object(ResponseFormatJsonSchema(name=name_too_long, schema={})) - self.fail("Expected the validation to fail due to length of name") - except ValueError: - pass - - -class TestTemplateWithTools(unittest.TestCase): - def test_template_with_tool_dict(self): - messages = [UserMessage("Say hello")] - tool_dict = { - "type": "function", - "function": { - "name": "hello", - "description": "Say hello", - "parameters": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": False - }, - "strict": False - } - } - template = Template(messages, tools=[tool_dict]) - json_data = template.to_dict() - self.assertIn("tools", json_data) - self.assertEqual(json_data["tools"][0], tool_dict) - - def test_template_with_function_tool(self): - from gen_ai_hub.orchestration.models.tools import function_tool - - @function_tool() - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - messages = [UserMessage("Add two numbers")] - template = Template(messages, tools=[add]) - json_data = template.to_dict() - self.assertIn("tools", json_data) - tool = json_data["tools"][0] - self.assertEqual(tool["type"], "function") - self.assertEqual(tool["function"]["name"], "add") - self.assertEqual(tool["function"]["description"], "Add two numbers.") - self.assertIn("a", tool["function"]["parameters"]["properties"]) - self.assertIn("b", tool["function"]["parameters"]["properties"]) - - def test_template_with_multiple_tools(self): - from gen_ai_hub.orchestration.models.tools import function_tool - - @function_tool() - def foo(x: int) -> int: - """Foo.""" - return x - - @function_tool() - def bar(y: str) -> str: - """Bar.""" - return y - - messages = [UserMessage("Test multiple tools")] - template = Template(messages, tools=[foo, bar]) - json_data = template.to_dict() - self.assertIn("tools", json_data) - self.assertEqual(len(json_data["tools"]), 2) - self.assertEqual(json_data["tools"][0]["function"]["name"], "foo") - self.assertEqual(json_data["tools"][1]["function"]["name"], "bar") - - def test_template_with_plain_function_raises(self): - def plain_func(a: int) -> int: - return a - - messages = [UserMessage("Test plain function")] - template = Template(messages, tools=[plain_func]) - with self.assertRaises(ValueError) as cm: - template.to_dict() - self.assertIn("If you are passing a function, decorate it with @function_tool", str(cm.exception)) - -class TestUserMessageMultimodal(unittest.TestCase): - def test_user_message_with_single_string(self): - msg = UserMessage("Hello world!") - expected = { - "role": Role.USER, - "content": "Hello world!" - } - self.assertEqual(msg.to_dict(), expected) - - def test_user_message_with_list_of_strings(self): - msg = UserMessage(["Hello", "World"]) - expected = { - "role": Role.USER, - "content": [ - {"type": "text", "text": "Hello"}, - {"type": "text", "text": "World"} - ] - } - self.assertEqual(msg.to_dict(), expected) - - def test_user_message_with_string_and_image(self): - img = ImageItem(url="https://example.com/image.png") - msg = UserMessage(["Describe this image:", img]) - expected = { - "role": Role.USER, - "content": [ - {"type": "text", "text": "Describe this image:"}, - {"type": "image_url", "image_url": { - "url": "https://example.com/image.png" - }} - ] - } - self.assertEqual(msg.to_dict(), expected) - - def test_user_message_with_multiple_images_and_text(self): - img1 = ImageItem(url="https://example.com/1.png", detail=ImageDetailLevel.LOW) - img2 = ImageItem(url="https://example.com/2.png", detail=ImageDetailLevel.HIGH) - msg = UserMessage(["First image:", img1, "Second image:", img2]) - expected = { - "role": Role.USER, - "content": [ - {"type": "text", "text": "First image:"}, - {"type": "image_url", "image_url": { - "url": "https://example.com/1.png", - "detail": ImageDetailLevel.LOW - }}, - {"type": "text", "text": "Second image:"}, - {"type": "image_url", "image_url": { - "url": "https://example.com/2.png", - "detail": ImageDetailLevel.HIGH - }} - ] - } - self.assertEqual(msg.to_dict(), expected) - - def test_image_item_from_file(self): - with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp: - tmp.write(b"not a real image") - tmp_path = tmp.name - - try: - item = ImageItem.from_file(tmp_path) - self.assertTrue(item.url.startswith("data:image/png;base64,")) - # Check that the base64 part decodes to the original file content - encoded = item.url.split(",", 1)[1] - with open(tmp_path, "rb") as f: - original = f.read() - decoded = base64.b64decode(encoded) - - self.assertEqual(decoded, original) - finally: - os.unlink(tmp_path) - diff --git a/packages/gen/tests/orchestration/test_template_ref.py b/packages/gen/tests/orchestration/test_template_ref.py deleted file mode 100644 index 4ac8453a..00000000 --- a/packages/gen/tests/orchestration/test_template_ref.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.template_ref import TemplateRef - - -class TestTemplateRef(unittest.TestCase): - - def test_creates_instance_from_id(self): - template_ref = TemplateRef.from_id(prompt_template_id="test_template_id") - self.assertEqual(template_ref.id, "test_template_id") - self.assertEqual(template_ref.to_dict(), {"template_ref": {"id": "test_template_id"}}) - - def test_creates_instance_from_tuple(self): - template_ref = TemplateRef.from_tuple("test_scenario", "test_name", "test_version") - self.assertEqual(template_ref.scenario, "test_scenario") - self.assertEqual(template_ref.name, "test_name") - self.assertEqual(template_ref.version, "test_version") - self.assertEqual(template_ref.to_dict(), {"template_ref": - {"scenario": "test_scenario", - "name": "test_name", - "version": "test_version"} - } - ) - - def test_handles_kwargs(self): - template_ref = TemplateRef(id="test_template_id") - self.assertEqual(template_ref.to_dict(), {"template_ref": {"id": "test_template_id"}}) - - template_ref = TemplateRef(scenario="test_scenario", name="test_name", version="test_version") - self.assertEqual(template_ref.to_dict(), {"template_ref": - {"scenario": "test_scenario", - "name": "test_name", - "version": "test_version"} - } - ) diff --git a/packages/gen/tests/orchestration/test_tools.py b/packages/gen/tests/orchestration/test_tools.py deleted file mode 100644 index 8a1e9eee..00000000 --- a/packages/gen/tests/orchestration/test_tools.py +++ /dev/null @@ -1,124 +0,0 @@ -import asyncio -import unittest -from typing import Optional - -from gen_ai_hub.orchestration.models.tools import FunctionTool, function_tool - - -class TestFunctionTool(unittest.TestCase): - def test_from_function_basic(self): - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - tool = FunctionTool.from_function(add) - self.assertEqual(tool.name, "add") - self.assertEqual(tool.description, "Add two numbers.") - self.assertIn("a", tool.parameters["properties"]) - self.assertIn("b", tool.parameters["properties"]) - self.assertIn("a", tool.parameters["required"]) - self.assertIn("b", tool.parameters["required"]) - self.assertEqual(tool.parameters["properties"]["a"]["type"], "number") - self.assertEqual(tool.parameters["properties"]["b"]["type"], "number") - self.assertEqual(tool.execute(a=2, b=3), 5) - - def test_from_function_optional(self): - def greet(name: str, title: Optional[str] = None) -> str: - """Greet a person.""" - return f"Hello, {title + ' ' if title else ''}{name}" - - tool = FunctionTool.from_function(greet) - self.assertEqual(tool.name, "greet") - self.assertIn("title", tool.parameters["properties"]) - self.assertNotIn("title", tool.parameters["required"]) - self.assertTrue(tool.parameters["properties"]["title"]["nullable"]) - self.assertEqual(tool.execute(name="Alice"), "Hello, Alice") - self.assertEqual(tool.execute(name="Alice", title="Dr."), "Hello, Dr. Alice") - - def test_decorator(self): - @function_tool() - def echo(msg: str) -> str: - """Echo a message.""" - return msg - - self.assertIsInstance(echo, FunctionTool) - self.assertEqual(echo.name, "echo") - self.assertEqual(echo.execute(msg="hi"), "hi") - - def test_strict_mode(self): - def foo(x: int) -> int: - """Foo.""" - return x - - tool = FunctionTool.from_function(foo, strict=True) - with self.assertRaises(ValueError): - tool.execute(x=1, y=2) # y is not a valid parameter - - def test_missing_type_hint(self): - def no_type(a, b: int) -> int: - """No type for a.""" - return b - - with self.assertRaises(TypeError): - FunctionTool.from_function(no_type) - - def test_description_precedence(self): - # Case 1: No description provided, should use docstring - def sample(a: int) -> int: - """This is the docstring.""" - return a - - tool1 = FunctionTool.from_function(sample) - self.assertEqual(tool1.description, "This is the docstring.") - self.assertIn("description", tool1.to_dict()["function"]) - - # Case 2: Description provided, should take precedence over docstring - tool2 = FunctionTool.from_function(sample, description="Explicit description.") - self.assertEqual(tool2.description, "Explicit description.") - self.assertIn("description", tool2.to_dict()["function"]) - self.assertEqual(tool2.to_dict()["function"]["description"], "Explicit description.") - - # Case 3: No docstring and no description, description should not be in dict - @function_tool - def no_desc(a: int) -> int: - return a - - tool3 = no_desc - self.assertIsNone(tool3.description) - self.assertNotIn("description", tool3.to_dict()["function"]) - - -class TestFunctionToolAsync(unittest.IsolatedAsyncioTestCase): - async def test_async_function_tool(self): - async def async_add(a: int, b: int) -> int: - """Add two numbers asynchronously.""" - await asyncio.sleep(0.01) - return a + b - - tool = FunctionTool.from_function(async_add) - result = await tool.aexecute(a=2, b=3) - self.assertEqual(result, 5) - - async def test_async_decorator(self): - @function_tool() - async def async_echo(msg: str) -> str: - """Echo a message asynchronously.""" - await asyncio.sleep(0.01) - return msg - - self.assertIsInstance(async_echo, FunctionTool) - result = await async_echo.aexecute(msg="hi") - self.assertEqual(result, "hi") - - async def test_strict_mode_async(self): - async def foo(x: int) -> int: - """Async foo.""" - return x - - tool = FunctionTool.from_function(foo, strict=True) - result = await tool.aexecute(x=42) - self.assertEqual(result, 42) - - # This should raise ValueError because 'y' is not a valid parameter - with self.assertRaises(ValueError): - await tool.aexecute(x=1, y=2) diff --git a/packages/gen/tests/orchestration/test_translation.py b/packages/gen/tests/orchestration/test_translation.py deleted file mode 100644 index cc653a98..00000000 --- a/packages/gen/tests/orchestration/test_translation.py +++ /dev/null @@ -1,101 +0,0 @@ -import unittest - -from gen_ai_hub.orchestration.models.translation.translation import InputTranslationConfig, \ - InputTranslationModule, OutputTranslationConfig, OutputTranslationModule, TranslationType -from gen_ai_hub.orchestration.models.translation.sap_document_translation import SAPDocumentTranslation -from gen_ai_hub.orchestration.models.config import OrchestrationConfig -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.message import Message, Role -from gen_ai_hub.orchestration.models.template import Template - - -class TestTranslation(unittest.TestCase): - def test_input_translation_config(self): - config = InputTranslationConfig(source_language="en-US", target_language="de-DE") - config_dict = config.to_dict() - self.assertEqual(config_dict["source_language"], "en-US") - self.assertEqual(config_dict["target_language"], "de-DE") - - def test_input_translation_module(self): - config = InputTranslationConfig(source_language="en-US", target_language="de-DE") - translation_module = InputTranslationModule(type=TranslationType.SAP_DOCUMENT_TRANSLATION, config=config) - module_dict = translation_module.to_dict() - self.assertEqual(module_dict["type"], TranslationType.SAP_DOCUMENT_TRANSLATION) - self.assertEqual(module_dict["config"]["source_language"], "en-US") - self.assertEqual(module_dict["config"]["target_language"], "de-DE") - - def test_output_translation_config(self): - config = OutputTranslationConfig(target_language="de-DE", source_language="en-US") - config_dict = config.to_dict() - self.assertEqual(config_dict["target_language"], "de-DE") - self.assertEqual(config_dict["source_language"], "en-US") - - def test_output_translation_module(self): - config = OutputTranslationConfig(target_language="de-DE", source_language="en-US") - translation_module = OutputTranslationModule(type=TranslationType.SAP_DOCUMENT_TRANSLATION, config=config) - module_dict = translation_module.to_dict() - self.assertEqual(module_dict["type"], TranslationType.SAP_DOCUMENT_TRANSLATION) - self.assertEqual(module_dict["config"]["target_language"], "de-DE") - self.assertEqual(module_dict["config"]["source_language"], "en-US") - - def test_sap_docu_translation(self): - input_config = InputTranslationConfig(source_language="en-US", target_language="de-DE") - output_config = OutputTranslationConfig(target_language="de-DE", source_language="en-US") - - translation_module = SAPDocumentTranslation( - input_translation_config=input_config, - output_translation_config=output_config - ) - template = Template( - messages=[Message(role=Role.USER, content="Hello, World!")] - ) - llm = LLM(name="gpt-4o-mini") - - config = OrchestrationConfig( - template=template, llm=llm, - translation= translation_module - ) - - conf_dict = config.to_dict() - - self.assertIn("module_configurations", conf_dict) - self.assertIn("input_translation_module_config", conf_dict["module_configurations"]) - self.assertIn("output_translation_module_config", conf_dict["module_configurations"]) - - input_translation_module_config = translation_module.input_translation.to_dict() - output_translation_module_config = translation_module.input_translation.to_dict() - - self.assertEqual(input_translation_module_config["type"], TranslationType.SAP_DOCUMENT_TRANSLATION) - self.assertEqual(input_translation_module_config["config"]["source_language"], "en-US") - self.assertEqual(input_translation_module_config["config"]["target_language"], "de-DE") - - self.assertEqual(output_translation_module_config["type"], TranslationType.SAP_DOCUMENT_TRANSLATION) - self.assertEqual(output_translation_module_config["config"]["target_language"], "de-DE") - self.assertEqual(output_translation_module_config["config"]["source_language"], "en-US") - - def test_only_input_translation_module(self): - input_config = InputTranslationConfig(source_language="en-US", target_language="de-DE") - - translation_module = SAPDocumentTranslation( - input_translation_config=input_config) - - input_translation_module_config = translation_module.input_translation.to_dict() - - self.assertEqual(input_translation_module_config["type"], TranslationType.SAP_DOCUMENT_TRANSLATION) - self.assertEqual(input_translation_module_config["config"]["source_language"], "en-US") - self.assertEqual(input_translation_module_config["config"]["target_language"], "de-DE") - - self.assertIsNone(translation_module.output_translation, "Output translation module should be None.") - - def test_only_output_translation_module(self): - output_config = OutputTranslationConfig(target_language="de-DE", source_language="en-US") - - translation_module = SAPDocumentTranslation(output_translation_config=output_config) - - output_translation_module_config = translation_module.output_translation.to_dict() - - self.assertEqual(output_translation_module_config["type"], TranslationType.SAP_DOCUMENT_TRANSLATION) - self.assertEqual(output_translation_module_config["config"]["target_language"], "de-DE") - self.assertEqual(output_translation_module_config["config"]["source_language"], "en-US") - - self.assertIsNone(translation_module.input_translation, "Input translation module should be None.") diff --git a/packages/gen/tests/test_additional_headers.py b/packages/gen/tests/test_additional_headers.py index f9306a5c..3654d376 100644 --- a/packages/gen/tests/test_additional_headers.py +++ b/packages/gen/tests/test_additional_headers.py @@ -6,11 +6,6 @@ from gen_ai_hub.document_grounding.clients.pipeline_api_client import PipelineAPIClient from gen_ai_hub.document_grounding.clients.retrieval_api_client import RetrievalAPIClient from gen_ai_hub.document_grounding.clients.vector_api_client import VectorAPIClient -from gen_ai_hub.orchestration.service import OrchestrationService -from gen_ai_hub.orchestration.models.llm import LLM -from gen_ai_hub.orchestration.models.template import Template -from gen_ai_hub.orchestration.models.message import Message -from gen_ai_hub.orchestration.models.config import OrchestrationConfig from gen_ai_hub.orchestration_v2.service import OrchestrationService as OrchestrationServiceV2 from gen_ai_hub.orchestration_v2.models.template import Template as TemplateV2, PromptTemplatingModuleConfig from gen_ai_hub.orchestration_v2.models.config import OrchestrationConfig as OrchestrationConfigV2, ModuleConfig @@ -170,44 +165,6 @@ def test_vector_api_client_injects_headers(self, mock_get): self.assertEqual(call_kwargs['headers']['X-Instance'], 'value1') self.assertEqual(call_kwargs['headers']['X-Temp'], 'value2') - @patch('httpx.Client.post') - def test_orchestration_service_injects_headers(self, mock_post): - """Test OrchestrationService passes headers via request_header.""" - mock_response = MagicMock() - mock_response.json.return_value = { - 'request_id': 'test-id', - 'module_results': {}, - 'orchestration_result': { - 'id': 'test', - 'object': 'chat.completion', - 'created': 1234567890, - 'model': 'gpt-4', - 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'Hello'}, 'finish_reason': 'stop'}], - 'usage': {'prompt_tokens': 10, 'completion_tokens': 5, 'total_tokens': 15} - } - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - config = OrchestrationConfig( - llm=LLM(name='gpt-4'), - template=Template(messages=[Message(role='user', content='Hello')]) - ) - service = OrchestrationService( - api_url='https://test.example.com', - proxy_client=self.proxy_client, - config=config - ) - - self.proxy_client.set_headers_addition({'X-Instance': 'value1'}) - with temporary_headers_addition({'X-Temp': 'value2'}): - service.run() - - call_kwargs = mock_post.call_args[1] - self.assertIn('headers', call_kwargs) - self.assertEqual(call_kwargs['headers']['X-Instance'], 'value1') - self.assertEqual(call_kwargs['headers']['X-Temp'], 'value2') - @patch('httpx.Client.post') def test_orchestration_service_v2_injects_headers(self, mock_post): """Test OrchestrationService V2 passes headers via request_header.""" From 069056bd150491f3a04b826014cd64bcdbef2471 Mon Sep 17 00:00:00 2001 From: Yamac Eren Ay <46201716+yamaceay@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:27:33 +0200 Subject: [PATCH 3/3] Update packages/gen/gen_ai_hub/orchestration_v2/models/response.py --- packages/gen/gen_ai_hub/orchestration_v2/models/response.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py index 63037c3b..3548418b 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py @@ -5,7 +5,6 @@ from typing import List, Optional, Any, Literal, Union from pydantic import ConfigDict, Field -from gen_ai_hub.orchestration.models.response import ModuleResultsStreaming from gen_ai_hub.orchestration_v2.models.base import ResponseBaseModel from gen_ai_hub.orchestration_v2.models.message import ChatMessage, FunctionCall, ResponseChatMessage, ReasoningBlock