diff --git a/packages/gen/gen_ai_hub/orchestration_v2/__init__.py b/packages/gen/gen_ai_hub/orchestration_v2/__init__.py index d378b8f7..48c412c4 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/__init__.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/__init__.py @@ -11,6 +11,7 @@ # config "ModuleConfig", "OrchestrationConfig", "OrchestrationConfigReference", + "PartialPromptTemplatingModuleConfig", "PartialModuleConfigs", "PartialOrchestrationConfig", "CompletionRequestConfigurationReferenceByIdConfigRef", "CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef", @@ -22,8 +23,9 @@ "InputFiltering", "OutputFiltering", "FilteringModuleConfig", # data_masking - "DataMaskingProviderName", "MaskingMethod", "ProfileEntity", "DPIMethodConstant", "DPIMethodFabricatedData", - "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", "MaskingModuleConfig", + "DataMaskingProviderName", "MaskingMethod", "MaskFileInputMethod", "ProfileEntity", "DPIMethodConstant", + "DPIMethodFabricatedData", "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", + "MaskingModuleConfig", # document_grounding "GroundingType", "DataRepositoryType", "DocumentGroundingFilter", "DocumentGroundingPlaceholders", @@ -33,7 +35,8 @@ # embeddings "EmbeddingsEncodingFormat", "EmbeddingsInputType", "EmbeddingsModelParams", "EmbeddingsModelDetails", "EmbeddingsModelConfig", "EmbeddingsModuleConfigs", "EmbeddingsOrchestrationConfig", "EmbeddingsInput", - "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", "EmbeddingsPostResponse", "EmbeddingsRequest", + "EmbeddingMultiFormat", "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", + "EmbeddingsPostResponse", "EmbeddingsRequest", # llama_guard_3_filter "LlamaGuard38bFilter", @@ -47,6 +50,7 @@ # multimodal_items "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem", # response "CacheCreationTokenDetails", "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", @@ -55,6 +59,9 @@ "StreamModuleResults", "SAPAPIError", "SAPAPIErrorStreaming", "CompletionPostResponse", "StreamCompletionPostResponse", "ErrorResponse", "ErrorResponseStreaming", "OrchestrationResponseWithRetries", + # request_headers + "OrchestrationRequestHeaders", + # response_format "ResponseFormatText", "ResponseFormatJsonObject", "ResponseFormatJsonSchema", "JSONResponseSchema", diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py b/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py index 1d3b3961..2cd0bea0 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py @@ -1,31 +1,35 @@ from .azure_content_filter import AzureContentSafetyInput, AzureContentSafetyOutput, AzureContentFilter, AzureThreshold from .cache_control import CacheControl from .config import (ModuleConfig, OrchestrationConfig, OrchestrationConfigReference, + PartialPromptTemplatingModuleConfig, PartialModuleConfigs, PartialOrchestrationConfig, CompletionRequestConfigurationReferenceByIdConfigRef, CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef) from .content_filter import (ContentFilterProvider, ContentFilter, LlamaGuard38bFilterConfig, AzureContentSafetyInputFilterConfig, AzureContentSafetyOutputFilterConfig, FilteringStreamOptions) from .content_filtering import InputFiltering, OutputFiltering, FilteringModuleConfig -from .data_masking import (DataMaskingProviderName, MaskingMethod, ProfileEntity, DPIMethodConstant, - DPIMethodFabricatedData, DPICustomEntity, DPIStandardEntity, MaskGroundingInput, - MaskingProviderConfig, MaskingModuleConfig) +from .data_masking import (DataMaskingProviderName, MaskingMethod, MaskFileInputMethod, ProfileEntity, + DPIMethodConstant, DPIMethodFabricatedData, DPICustomEntity, DPIStandardEntity, + MaskGroundingInput, MaskingProviderConfig, MaskingModuleConfig) from .document_grounding import (GroundingType, DataRepositoryType, DocumentGroundingFilter, DocumentGroundingPlaceholders, DocumentGroundingConfig, GroundingModuleConfig, KeyValueListPair, DocumentMetadataKeyValueListPairs, GroundingSearchConfig) from .embeddings import (EmbeddingsEncodingFormat, EmbeddingsInputType, EmbeddingsModelParams, EmbeddingsModelDetails, EmbeddingsModelConfig, EmbeddingsModuleConfigs, EmbeddingsOrchestrationConfig, EmbeddingsInput, - EmbeddingsUsage, EmbeddingResult, EmbeddingsResponse, EmbeddingsPostResponse, EmbeddingsRequest) + EmbeddingMultiFormat, EmbeddingsUsage, EmbeddingResult, EmbeddingsResponse, + EmbeddingsPostResponse, EmbeddingsRequest) from .llama_guard_3_filter import LlamaGuard38bFilter from .llm_model_details import LLMModelDetails from .message import (SystemMessage, UserMessage, AssistantMessage, ToolChatMessage, DeveloperChatMessage, ChatMessage, ResponseChatMessage, FunctionCall, MessageToolCall, ReasoningBlock) -from .multimodal_items import ImageDetailLevel, TextPart, ImageUrl, ImagePart, ContentPart, ImageItem +from .multimodal_items import (ImageDetailLevel, TextPart, ImageUrl, ImagePart, ContentPart, ImageItem, + FileContent, FilePart, FileItem) from .response import (CacheCreationTokenDetails, PromptTokensDetails, CompletionTokensDetails, TokenUsage, GenericModuleResult, TopLogprob, ChatCompletionTokenLogprob, ChoiceLogprobs, LLMChoice, StreamFunctionObject, StreamToolCall, StreamDelta, StreamLLMChoice, Citation, LLMModuleResult, StreamLLMModuleResult, ModuleResults, StreamModuleResults, SAPAPIError, SAPAPIErrorStreaming, CompletionPostResponse, StreamCompletionPostResponse, ErrorResponse, ErrorResponseStreaming, OrchestrationResponseWithRetries) +from .request_headers import OrchestrationRequestHeaders from .response_format import ResponseFormatText, ResponseFormatJsonObject, ResponseFormatJsonSchema, JSONResponseSchema from .streaming import GlobalStreamOptions from .template import Template, PromptTemplatingModuleConfig @@ -45,6 +49,7 @@ # config "ModuleConfig", "OrchestrationConfig", "OrchestrationConfigReference", + "PartialPromptTemplatingModuleConfig", "PartialModuleConfigs", "PartialOrchestrationConfig", "CompletionRequestConfigurationReferenceByIdConfigRef", "CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef", @@ -56,8 +61,9 @@ "InputFiltering", "OutputFiltering", "FilteringModuleConfig", # data_masking - "DataMaskingProviderName", "MaskingMethod", "ProfileEntity", "DPIMethodConstant", "DPIMethodFabricatedData", - "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", "MaskingModuleConfig", + "DataMaskingProviderName", "MaskingMethod", "MaskFileInputMethod", "ProfileEntity", "DPIMethodConstant", + "DPIMethodFabricatedData", "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", + "MaskingModuleConfig", # document_grounding "GroundingType", "DataRepositoryType", "DocumentGroundingFilter", "DocumentGroundingPlaceholders", @@ -67,7 +73,8 @@ # embeddings "EmbeddingsEncodingFormat", "EmbeddingsInputType", "EmbeddingsModelParams", "EmbeddingsModelDetails", "EmbeddingsModelConfig", "EmbeddingsModuleConfigs", "EmbeddingsOrchestrationConfig", "EmbeddingsInput", - "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", "EmbeddingsPostResponse", "EmbeddingsRequest", + "EmbeddingMultiFormat", "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", + "EmbeddingsPostResponse", "EmbeddingsRequest", # llama_guard_3_filter "LlamaGuard38bFilter", @@ -81,6 +88,7 @@ # multimodal_items "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem", # response "CacheCreationTokenDetails", "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", @@ -89,6 +97,9 @@ "StreamModuleResults", "SAPAPIError", "SAPAPIErrorStreaming", "CompletionPostResponse", "StreamCompletionPostResponse", "ErrorResponse", "ErrorResponseStreaming", "OrchestrationResponseWithRetries", + # request_headers + "OrchestrationRequestHeaders", + # response_format "ResponseFormatText", "ResponseFormatJsonObject", "ResponseFormatJsonSchema", "JSONResponseSchema", diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/config.py b/packages/gen/gen_ai_hub/orchestration_v2/models/config.py index 0708a717..0a5ae600 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/config.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/config.py @@ -10,8 +10,10 @@ from gen_ai_hub.orchestration_v2.models.content_filtering import FilteringModuleConfig from gen_ai_hub.orchestration_v2.models.data_masking import MaskingModuleConfig from gen_ai_hub.orchestration_v2.models.document_grounding import GroundingModuleConfig +from gen_ai_hub.orchestration_v2.models.llm_model_details import LLMModelDetails from gen_ai_hub.orchestration_v2.models.streaming import GlobalStreamOptions -from gen_ai_hub.orchestration_v2.models.template import PromptTemplatingModuleConfig +from gen_ai_hub.orchestration_v2.models.template import PromptTemplatingModuleConfig, Template +from gen_ai_hub.orchestration_v2.models.template_ref import TemplateRef from gen_ai_hub.orchestration_v2.models.translation import TranslationModuleConfig @@ -59,6 +61,62 @@ class OrchestrationConfig(BaseModel): stream: Optional[GlobalStreamOptions] = None +class PartialPromptTemplatingModuleConfig(BaseModel): + """ + Partial prompt templating configuration for use with config_ref overrides. + + Every field is optional so that only the parts to override need to be provided; + the referenced configuration supplies the rest. + + Args: + prompt: Optional prompt template override (inline Template or TemplateRef). + + model: Optional model override. Unlike the non-partial config, model is optional + here so that only the prompt can be overridden without repeating the model config. + """ + prompt: Optional[Template | TemplateRef] = None + model: Optional[LLMModelDetails] = None + + +class PartialModuleConfigs(BaseModel): + """ + Partial module configuration for use with config_ref overrides. + + Every module is optional; only the modules provided here override the referenced config. + + Args: + prompt_templating: Optional partial prompt templating override. + + filtering: Optional filtering module override. + + masking: Optional masking module override. + + grounding: Optional grounding module override. + + translation: Optional translation module override. + """ + prompt_templating: Optional[PartialPromptTemplatingModuleConfig] = None + filtering: Optional[FilteringModuleConfig] = None + masking: Optional[MaskingModuleConfig] = None + grounding: Optional[GroundingModuleConfig] = None + translation: Optional[TranslationModuleConfig] = None + + +class PartialOrchestrationConfig(BaseModel): + """ + Partial orchestration configuration used to override a referenced config (config_ref). + + The referenced configuration is the base; the fields provided here are merged on top. + + Args: + modules: Optional partial module configurations to override. + + stream: Optional streaming configuration to override. + """ + modules: Optional[PartialModuleConfigs] = None + stream: Optional[GlobalStreamOptions] = None + + class CompletionRequestConfigurationReferenceByIdConfigRef(BaseModel): """Represents a reference to an orchestration config identified by a unique ID. @@ -86,5 +144,6 @@ class CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef(Base CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef) __all__ = ["ModuleConfig", "OrchestrationConfig", "OrchestrationConfigReference", + "PartialPromptTemplatingModuleConfig", "PartialModuleConfigs", "PartialOrchestrationConfig", "CompletionRequestConfigurationReferenceByIdConfigRef", "CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef"] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/data_masking.py b/packages/gen/gen_ai_hub/orchestration_v2/models/data_masking.py index 30b489cf..17f773c7 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/data_masking.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/data_masking.py @@ -166,6 +166,21 @@ class MaskGroundingInput(BaseModel): """ enabled: bool = False +class MaskFileInputMethod(str, Enum): + """ + Enumerates the supported masking methods for file inputs. + + This enum defines how file inputs are handled by the data masking module. It is required if file inputs + are provided. + + Values: + ANONYMIZATION: Irreversibly replaces sensitive data in file inputs with placeholders. + + SKIP: Skips masking of file inputs, leaving them unchanged. + """ + ANONYMIZATION = "anonymization" + SKIP = "skip" + class MaskingProviderConfig(BaseModel): """ @@ -183,12 +198,15 @@ class MaskingProviderConfig(BaseModel): allowlist: A list of strings that should not be masked. mask_grounding_input: A flag indicating whether to mask input to the grounding module. + + mask_file_input_method: The method of masking to apply to file inputs. Required if file inputs are provided. """ type_: DataMaskingProviderName = Field(default=DataMaskingProviderName.SAP_DATA_PRIVACY_INTEGRATION, alias="type") method: MaskingMethod entities: List[Union[DPIStandardEntity, DPICustomEntity]] allowlist: Optional[List[str]] = None mask_grounding_input: Optional[MaskGroundingInput] = None + mask_file_input_method: Optional[MaskFileInputMethod] = None class MaskingModuleConfig(BaseModel): """ @@ -217,5 +235,6 @@ def enforce_exactly_one_provider_list(self): return self -__all__ = ["DataMaskingProviderName", "MaskingMethod", "ProfileEntity", "DPIMethodConstant", "DPIMethodFabricatedData", - "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", "MaskingModuleConfig"] +__all__ = ["DataMaskingProviderName", "MaskingMethod", "MaskFileInputMethod", "ProfileEntity", "DPIMethodConstant", + "DPIMethodFabricatedData", "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", + "MaskingProviderConfig", "MaskingModuleConfig"] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/embeddings.py b/packages/gen/gen_ai_hub/orchestration_v2/models/embeddings.py index 2ef95b7c..0b29b9d9 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/embeddings.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/embeddings.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Optional, Dict, List, Union -from pydantic import Field +from pydantic import ConfigDict, Field from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel, ResponseBaseModel from gen_ai_hub.orchestration_v2.models.data_masking import MaskingModuleConfig @@ -16,13 +16,19 @@ class EmbeddingsEncodingFormat(str, Enum): Encoding format for the embeddings output. Values: - FLOAT: Returns embeddings as an array of floats. + FLOAT: Returns embeddings as an array of floating point numbers. BASE64: Returns embeddings as a base64 encoded string. BINARY: Returns embeddings in binary format. + INT8: Returns embeddings as an array of 8-bit integers. + UINT8: Returns embeddings as an array of unsigned 8-bit integers. + UBINARY: Returns embeddings in unsigned binary format. """ FLOAT = "float" BASE64 = "base64" BINARY = "binary" + INT8 = "int8" + UINT8 = "uint8" + UBINARY = "ubinary" class EmbeddingsInputType(str, Enum): @@ -35,10 +41,20 @@ class EmbeddingsInputType(str, Enum): TEXT: General purpose text (default). DOCUMENT: Content to be searched/retrieved. QUERY: Short search queries. + SEARCH_DOCUMENT: Content stored in a search index to be retrieved. + SEARCH_QUERY: Query run against a search index. + CLASSIFICATION: Text used in a classification task. + CLUSTERING: Text used in a clustering task. + IMAGE: Image input. """ TEXT = "text" DOCUMENT = "document" QUERY = "query" + SEARCH_DOCUMENT = "search_document" + SEARCH_QUERY = "search_query" + CLASSIFICATION = "classification" + CLUSTERING = "clustering" + IMAGE = "image" class EmbeddingsModelParams(BaseModel): @@ -47,11 +63,13 @@ class EmbeddingsModelParams(BaseModel): Args: dimensions: The number of dimensions for the output embeddings. - encoding_format: The format for the embeddings output (float, base64, or binary). + encoding_format: The format to return the embeddings in. Can be a single + format or a list of formats (e.g. for Cohere models that support + returning multiple encodings at once). normalize: Whether to normalize the embeddings. """ dimensions: Optional[int] = None - encoding_format: Optional[EmbeddingsEncodingFormat] = None + encoding_format: Optional[Union[EmbeddingsEncodingFormat, List[EmbeddingsEncodingFormat]]] = None normalize: Optional[bool] = None @@ -69,7 +87,7 @@ class EmbeddingsModelDetails(BaseModel): name: str version: Optional[str] = "latest" params: Optional[EmbeddingsModelParams] = None - timeout: Optional[int] = Field(default=600, ge=1, le=600) + timeout: Optional[int] = Field(default=600, ge=1, le=1200) max_retries: Optional[int] = Field(default=2, ge=0, le=5) @@ -117,6 +135,42 @@ class EmbeddingsInput(BaseModel): type_: Optional[EmbeddingsInputType] = Field(default=None, alias="type") +class EmbeddingMultiFormat(ResponseBaseModel): + """ + A dictionary mapping encoding format names to their embeddings. + + Used when multiple encoding formats are requested at once (Cohere-specific). + Each field holds the embedding in the corresponding encoding format and is + only populated when that format was requested. + + Args: + float_: Float encoding format - array of floating point numbers. + int8: Int8 encoding format - array of 8-bit integers. + uint8: Uint8 encoding format - array of unsigned 8-bit integers. + base64_: Base64 encoding format - base64 encoded string. + binary: Binary encoding format - array of integers. + ubinary: Ubinary encoding format - array of unsigned integers. + """ + float_: Optional[List[float]] = Field(default=None, alias="float") + int8: Optional[List[int]] = None + uint8: Optional[List[int]] = None + base64_: Optional[str] = Field(default=None, alias="base64") + binary: Optional[List[int]] = None + ubinary: Optional[List[int]] = None + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + frozen=False, + ) + + def model_dump(self, **kwargs): + """Dumps the model using aliases and omitting unset formats.""" + kwargs.setdefault("by_alias", True) + kwargs.setdefault("exclude_none", True) + return super().model_dump(**kwargs) + + class EmbeddingsUsage(ResponseBaseModel): """ Token usage information for the embeddings request. @@ -135,11 +189,13 @@ class EmbeddingResult(ResponseBaseModel): Args: object: The object type, always "embedding". - embedding: The embedding vector (array of floats) or base64 string. + embedding: The embedding vector (array of floats), a base64 string, or a + mapping of encoding format names to embeddings when multiple formats + were requested (Cohere-specific). index: The index of this embedding in the list. """ object: str = "embedding" - embedding: Union[List[float], str] + embedding: Union[List[float], str, EmbeddingMultiFormat] index: int @@ -186,4 +242,5 @@ class EmbeddingsRequest(BaseModel): __all__ = ["EmbeddingsEncodingFormat", "EmbeddingsInputType", "EmbeddingsModelParams", "EmbeddingsModelDetails", "EmbeddingsModelConfig", "EmbeddingsModuleConfigs", "EmbeddingsOrchestrationConfig", "EmbeddingsInput", - "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", "EmbeddingsPostResponse", "EmbeddingsRequest"] + "EmbeddingMultiFormat", "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", + "EmbeddingsPostResponse", "EmbeddingsRequest"] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/llm_model_details.py b/packages/gen/gen_ai_hub/orchestration_v2/models/llm_model_details.py index cd6b1c5e..ff7bd378 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/llm_model_details.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/llm_model_details.py @@ -4,7 +4,7 @@ from typing import Optional, Dict -from pydantic import Field +from pydantic import Field, StrictBool from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel @@ -24,11 +24,19 @@ class LLMModelDetails(BaseModel): timeout: Timeout for the LLM request in seconds. This parameter is currently ignored for Vertex AI models. max_retries: Maximum number of retries for the LLM request. This parameter is currently ignored for Vertex AI models. + + drop_unsupported_params: Controls behaviour when Chat Completions parameters not supported by the + Responses API are present in the request. Only applies to models that use the Responses API. + When True: unsupported params are silently dropped and reported in the + X-Orchestration-Dropped-Model-Params response header. When False: any unsupported parameter + causes a 400 error with an actionable message. Defaults to True on the server when omitted. """ name: str version: Optional[str] = "latest" params: Optional[Dict] = None - timeout: Optional[int] = Field(default=600, ge=1, le=600) + timeout: Optional[int] = Field(default=600, ge=1, le=1200) max_retries: Optional[int] = Field(default=2, ge=0, le=5) + # Server defaults this to True when omitted. + drop_unsupported_params: Optional[StrictBool] = None __all__ = ["LLMModelDetails"] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/message.py b/packages/gen/gen_ai_hub/orchestration_v2/models/message.py index d3b40d09..aed67579 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/message.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/message.py @@ -11,7 +11,9 @@ from pydantic import field_validator, ValidationError from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel, ResponseBaseModel -from gen_ai_hub.orchestration_v2.models.multimodal_items import ContentPart, ImageItem, TextPart, ImageUrl, ImagePart +from gen_ai_hub.orchestration_v2.models.multimodal_items import ( + ContentPart, ImageItem, TextPart, ImageUrl, ImagePart, FileItem, FilePart, FileContent +) class FunctionCall(ResponseBaseModel): @@ -122,10 +124,10 @@ class UserMessage(BaseModel): 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. + content: The message content, which may be plain text or a sequence of text, images, or files. """ role: Role = Role.USER - content: Union[str, ContentPart, List[Union[str, ContentPart, ImageItem, ImagePart]]] + content: Union[str, ContentPart, List[Union[str, ContentPart, ImageItem, ImagePart, FileItem, FilePart]]] @field_validator("content", mode="before") def content_validation(cls, content): # pylint: disable=no-self-argument @@ -145,8 +147,10 @@ def content_validation(cls, content): # pylint: disable=no-self-argument mapped_content.append(TextPart(text=item)) elif isinstance(item, ImageItem): mapped_content.append(ImagePart(image_url=ImageUrl(url=item.url, detail=item.detail))) + elif isinstance(item, FileItem): + mapped_content.append(FilePart(file=FileContent(file_data=item.file_data, filename=item.filename))) else: - raise ValidationError("User message content list must contain only str or ImageItem") + raise ValidationError("User message content list must contain only str, ImageItem or FileItem") return mapped_content diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py b/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py index 2f46ed71..ef9d6978 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/multimodal_items.py @@ -76,7 +76,36 @@ class ImagePart(BaseModel): cache_control: Optional[CacheControl] = None -ContentPart = Union[TextPart, ImagePart] +class FileContent(BaseModel): + """ + A data structure holding the data and name for a file. + + Args: + file_data: The file content, as a base64 encoded string or a file URL. + + filename: The name of the file. + """ + file_data: str + filename: Optional[str] = None + + +class FilePart(BaseModel): + """ + Represents a file segment within a multimodal content block. + + Args: + file: A `FileContent` object containing the file data and optional filename. + + type: The type identifier, defaulting to "file". + + cache_control: Optional cache control settings for prompt caching. + """ + file: FileContent + type_: Literal["file"] = Field(default="file", alias="type") + cache_control: Optional[CacheControl] = None + + +ContentPart = Union[TextPart, ImagePart, FilePart] class ImageItem(BaseModel): @@ -149,7 +178,7 @@ def model_dump( # pylint: disable=arguments-differ warnings: bool | Literal['none', 'warn', 'error'] = True, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, - ) -> dict[str, Any]: + ) -> dict[str, Any]: return ImagePart(image_url=ImageUrl(url=self.url, detail=self.detail)).model_dump( mode=mode, include=include, @@ -165,4 +194,83 @@ def model_dump( # pylint: disable=arguments-differ serialize_as_any=serialize_as_any, ) -__all__ = ["ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem"] + +class FileItem(BaseModel): + """ + Represents a file for use in multimodal messages. + + Args: + file_data: The file content, specified as either a base64 encoded string, + a data URL, or a file URL. + + filename: The name of the file. + """ + + file_data: Optional[str] = None + filename: Optional[str] = None + + @staticmethod + def from_file( + file_path: str, + mime_type: Optional[str] = None, + filename: Optional[str] = None, + ) -> "FileItem": + """Create a FileItem from a local file. + + :param file_path: Path to the file. + :type file_path: str + :param mime_type: Explicit MIME type (e.g., 'application/pdf'). + If not provided, the MIME type will be guessed from the file extension. + :type mime_type: Optional[str], optional + :param filename: The name of the file. + :type filename: Optional[str], optional + :raises ValueError: If the MIME type cannot be determined and is not provided. + :return: A FileItem instance with the file data as a data URL. + :rtype: FileItem + """ + + 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 FileItem(file_data=data_url, filename=filename) + + def model_dump( # pylint: disable=arguments-differ + self, + *, + mode: Literal['json', 'python'] | str = 'python', + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal['none', 'warn', 'error'] = True, + fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, + ) -> dict[str, Any]: + return FilePart(file=FileContent(file_data=self.file_data, filename=self.filename)).model_dump( + mode=mode, + include=include, + exclude=exclude, + context=context, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + round_trip=round_trip, + warnings=warnings, + fallback=fallback, + serialize_as_any=serialize_as_any, + ) + +__all__ = ["ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem"] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/orchestration_request.py b/packages/gen/gen_ai_hub/orchestration_v2/models/orchestration_request.py index 1358125c..02016265 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/orchestration_request.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/orchestration_request.py @@ -2,30 +2,51 @@ from pydantic import model_validator from gen_ai_hub.orchestration_v2.models.base import ABCBaseModel as BaseModel -from gen_ai_hub.orchestration_v2.models.config import OrchestrationConfig, OrchestrationConfigReference +from gen_ai_hub.orchestration_v2.models.config import (OrchestrationConfig, OrchestrationConfigReference, + PartialOrchestrationConfig) from gen_ai_hub.orchestration_v2.models.message import ChatMessage class CompletionPostRequest(BaseModel): """ Represents a request for the orchestration process, including configuration, template values, and message history. + + When ``config_ref`` is used, ``config`` may carry a ``PartialOrchestrationConfig`` + that overrides parts of the referenced configuration. """ - config: Optional[OrchestrationConfig] = None + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None config_ref: Optional[OrchestrationConfigReference] = None placeholder_values: Optional[dict[str, str]] = None messages_history: Optional[List[ChatMessage]] = None @model_validator(mode="after") def validate_config_or_ref(self): - """validates that exactly one of 'config' or 'config_ref' is provided. + """Validates the config / config_ref combination. + + Mirrors the three request shapes defined by the spec: + - inline: a full ``OrchestrationConfig`` in ``config`` and no ``config_ref``; + - by reference: a ``config_ref`` optionally accompanied by a + ``PartialOrchestrationConfig`` in ``config`` that overrides the referenced config. - :raises ValueError: If neither or both 'config' and 'config_ref' are provided. - :return: The validated OrchestrationRequest instance. + :raises ValueError: If neither is provided, if an inline ``config`` is not a full + ``OrchestrationConfig``, or if a ``config`` override alongside ``config_ref`` is + not a ``PartialOrchestrationConfig``. + :return: The validated CompletionPostRequest instance. :rtype: CompletionPostRequest """ - if (self.config is None) == (self.config_ref is None): + if self.config is None and self.config_ref is None: + raise ValueError( + "At least one of 'config' or 'config_ref' must be provided." + ) + if self.config_ref is None: + if not isinstance(self.config, OrchestrationConfig): + raise ValueError( + "Inline usage requires a full 'config' (OrchestrationConfig). " + "A PartialOrchestrationConfig is only valid as an override alongside 'config_ref'." + ) + elif self.config is not None and not isinstance(self.config, PartialOrchestrationConfig): raise ValueError( - "Exactly one of 'config' or 'config_ref' must be provided." + "When 'config_ref' is provided, 'config' must be a PartialOrchestrationConfig override." ) return self diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/request_headers.py b/packages/gen/gen_ai_hub/orchestration_v2/models/request_headers.py new file mode 100644 index 00000000..f897442e --- /dev/null +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/request_headers.py @@ -0,0 +1,43 @@ +""" +Service-specific request headers for the Orchestration Service. +""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class OrchestrationRequestHeaders(BaseModel): + """Service-specific headers for orchestration requests. + + - ``AI-Object-Store-Secret-Name`` is documented explicitly; it is used by the + feedback service to persist an inference. + - Arbitrary additional headers are allowed (``extra="allow"``), mirroring the + JS index signature ``[key: string]: any``. + - ``AI-Resource-Group`` must not be set here; it is managed by the proxy client + / deployment configuration. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + ai_object_store_secret_name: Optional[str] = Field( + default=None, + alias="AI-Object-Store-Secret-Name", + description="Name of the object store secret used by the feedback service.", + ) + + @model_validator(mode="before") + @classmethod + def _reject_ai_resource_group(cls, data): + if isinstance(data, dict): + for key in data: + if isinstance(key, str) and key.lower() == "ai-resource-group": + raise ValueError( + "AI-Resource-Group must not be set here; " + "it is managed by the proxy client / deployment configuration." + ) + return data + + def to_headers(self) -> dict: + """Serialize to a plain header dict using alias names, dropping unset values.""" + return self.model_dump(by_alias=True, exclude_none=True) 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 59ebcc43..a8fad3ec 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/response.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/response.py @@ -144,12 +144,17 @@ class LLMChoice(ResponseBaseModel): - 'content_filter' if content was omitted due to a filter enforced by the LLM model provider or the content filtering module + + provider_specific_fields: Provider-specific fields not representable in the OpenAI schema. + When not identical, includes 'finish_reason' with the raw, unmapped + finish reason string from the provider. """ index: int message: ResponseChatMessage logprobs: Optional[ChoiceLogprobs] = None finish_reason: str + provider_specific_fields: Optional[dict[str, Any]] = None class StreamFunctionObject(FunctionCall): @@ -197,6 +202,7 @@ class StreamLLMChoice(ResponseBaseModel): delta: StreamDelta logprobs: Optional[ChoiceLogprobs] = None finish_reason: Optional[str] = None + provider_specific_fields: Optional[dict[str, Any]] = None class Citation(ResponseBaseModel): diff --git a/packages/gen/gen_ai_hub/orchestration_v2/models/template.py b/packages/gen/gen_ai_hub/orchestration_v2/models/template.py index 1fe55b1f..3559ae7b 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/template.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/template.py @@ -36,7 +36,7 @@ class Template(BaseModel): class PromptTemplatingModuleConfig(BaseModel): - prompt: Template | TemplateRef + prompt: Optional[Template | TemplateRef] = None model: LLMModelDetails __all__ = ["Template", "PromptTemplatingModuleConfig"] diff --git a/packages/gen/gen_ai_hub/orchestration_v2/service.py b/packages/gen/gen_ai_hub/orchestration_v2/service.py index e9732ade..35bf2706 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/service.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/service.py @@ -5,6 +5,7 @@ Provides synchronous and asynchronous methods to run orchestration pipelines. """ +import contextlib from copy import deepcopy import random import time @@ -18,8 +19,10 @@ from gen_ai_hub import GenAIHubProxyClient from gen_ai_hub.orchestration_v2.models.orchestration_request import CompletionPostRequest -from gen_ai_hub.orchestration_v2.models.config import OrchestrationConfig, OrchestrationConfigReference +from gen_ai_hub.orchestration_v2.models.config import (OrchestrationConfig, OrchestrationConfigReference, + PartialOrchestrationConfig) from gen_ai_hub.orchestration_v2.models.message import ChatMessage +from gen_ai_hub.orchestration_v2.models.request_headers import OrchestrationRequestHeaders from gen_ai_hub.orchestration_v2.models.response import (CompletionPostResponse, StreamCompletionPostResponse, OrchestrationResponseWithRetries) from gen_ai_hub.orchestration_v2.models.embeddings import ( @@ -31,10 +34,14 @@ from gen_ai_hub.orchestration_v2.sse_client import SSEClient, AsyncSSEClient, _handle_http_error from gen_ai_hub.orchestration_v2.exceptions import OrchestrationError from gen_ai_hub.proxy import get_proxy_client +from gen_ai_hub.proxy.gen_ai_hub_proxy import temporary_headers_addition V2_COMPLETION_SUFFIX = "/v2/completion" V2_EMBEDDINGS_SUFFIX = "/v2/embeddings" -CONFIG_AND_CONFIG_REF_ERROR_TEXT = "Cannot provide both a configuration and a configuration reference." +CONFIG_AND_CONFIG_REF_ERROR_TEXT = ( + "A full 'config' (OrchestrationConfig) cannot be combined with 'config_ref'. " + "Use a PartialOrchestrationConfig to override a referenced config." +) def cache_if_not_none(func): """Custom cache decorator that only caches non-None results""" @@ -160,6 +167,12 @@ class OrchestrationService: See https://api.sap.com/api/ORCHESTRATION_API_v2/overview + Feedback service headers: + To persist an inference via the feedback service, pass service-specific headers (e.g. + ``AI-Object-Store-Secret-Name``) through the ``request_headers`` parameter of ``run``/``stream``/ + ``arun``/``astream`` using :class:`OrchestrationRequestHeaders`. The ``AI-Resource-Group`` header, + also used by the feedback service, is already set by the proxy client. + Args: api_url: The base URL for the orchestration API. @@ -183,7 +196,7 @@ class OrchestrationService: def __init__(self, api_url: Optional[str] = None, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, proxy_client: Optional[GenAIHubProxyClient] = None, deployment_id: Optional[str] = None, @@ -211,14 +224,14 @@ def __init__(self, :raises ValueError: if both config and config_ref are provided. """ self.proxy_client = proxy_client or get_proxy_client(proxy_version="gen-ai-hub") + if isinstance(config, OrchestrationConfig) and config_ref is not None: + raise ValueError(CONFIG_AND_CONFIG_REF_ERROR_TEXT) 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.config_ref = config_ref - if self.config_ref and self.config: - raise ValueError(CONFIG_AND_CONFIG_REF_ERROR_TEXT) self.timeout = timeout # create reusable httpx client to improve performance self.client = httpx.Client(timeout=self.timeout) @@ -237,6 +250,17 @@ def _determine_timeout(self, timeout: httpx.Timeout) -> httpx.Timeout: request_timeout = httpx.USE_CLIENT_DEFAULT return request_timeout + def _header_context(self, request_headers: Optional[OrchestrationRequestHeaders]): + """Return a context manager that injects per-request headers via the proxy client. + + Reuses the proxy client's existing ``temporary_headers_addition`` machinery so the + request sites keep reading ``self.proxy_client.request_header`` unchanged. When no + headers are supplied, a no-op context is returned. + """ + if request_headers is None: + return contextlib.nullcontext() + return temporary_headers_addition(request_headers.to_headers()) + def _should_retry(self, error: Exception) -> bool: """ Determines if a request should be retried based on the error type. @@ -305,7 +329,7 @@ def _calculate_backoff(self, retry_count: int, base_delay: float = 1.0, max_dela def _execute_request( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, @@ -339,7 +363,7 @@ def _execute_request( """ if config is None and config_ref is None: raise ValueError("A configuration is required to invoke the orchestration service.") - if config and config_ref: + if isinstance(config, OrchestrationConfig) and config_ref is not None: raise ValueError(CONFIG_AND_CONFIG_REF_ERROR_TEXT) if config and config.stream: if config.stream.enabled != stream: @@ -384,7 +408,7 @@ def _execute_request( async def _a_execute_request( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, @@ -415,7 +439,7 @@ async def _a_execute_request( """ if config is None and config_ref is None: raise ValueError("A configuration is required to invoke the orchestration service.") - if config and config_ref: + if isinstance(config, OrchestrationConfig) and config_ref is not None: raise ValueError(CONFIG_AND_CONFIG_REF_ERROR_TEXT) if config and config.stream: if config.stream.enabled != stream: @@ -459,11 +483,12 @@ async def _a_execute_request( def run( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, timeout: Union[int, float, httpx.Timeout, None] = None, + request_headers: Optional[OrchestrationRequestHeaders] = None, ) -> CompletionPostResponse: """Executes an orchestration request synchronously (non-streaming). @@ -478,25 +503,30 @@ def run( :type history: Optional[List[ChatMessage]], optional :param timeout: the timeout overwrite per request, defaults to None :type timeout: Union[int, float, httpx.Timeout, None], optional + :param request_headers: service-specific headers to add for this request only + (e.g. the feedback service object store secret), defaults to None + :type request_headers: Optional[OrchestrationRequestHeaders], optional :return: the CompletionPostResponse object :rtype: CompletionPostResponse - """ + """ - return self._execute_request( - config=config or self.config, - config_ref=config_ref or self.config_ref, - placeholder_values=placeholder_values, - history=history, - timeout=timeout, - ) + with self._header_context(request_headers): + return self._execute_request( + config=config or self.config, + config_ref=config_ref or self.config_ref, + placeholder_values=placeholder_values, + history=history, + timeout=timeout, + ) def stream( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, timeout: Union[int, float, httpx.Timeout, None] = None, + request_headers: Optional[OrchestrationRequestHeaders] = None, ) -> Iterable[StreamCompletionPostResponse]: """Executes an orchestration streaming request synchronously. @@ -511,26 +541,31 @@ def stream( :type history: Optional[List[ChatMessage]], optional :param timeout: the timeout overwrite per request, defaults to None :type timeout: Union[int, float, httpx.Timeout, None], optional + :param request_headers: service-specific headers to add for this request only + (e.g. the feedback service object store secret), defaults to None + :type request_headers: Optional[OrchestrationRequestHeaders], optional :return: An Iterable[StreamCompletionPostResponse] object :rtype: Iterable[StreamCompletionPostResponse] """ - return self._execute_request( - config=config or self.config, - config_ref=config_ref or self.config_ref, - placeholder_values=placeholder_values, - history=history, - timeout=timeout, - stream=True - ) + with self._header_context(request_headers): + return self._execute_request( + config=config or self.config, + config_ref=config_ref or self.config_ref, + placeholder_values=placeholder_values, + history=history, + timeout=timeout, + stream=True + ) async def arun( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, timeout: Union[int, float, httpx.Timeout, None] = None, + request_headers: Optional[OrchestrationRequestHeaders] = None, ) -> CompletionPostResponse: """Executes an orchestration request asynchronously (non-streaming). @@ -544,25 +579,30 @@ async def arun( :type history: Optional[List[ChatMessage]], optional :param timeout: the timeout overwrite per request, defaults to None :type timeout: Union[int, float, httpx.Timeout, None], optional + :param request_headers: service-specific headers to add for this request only + (e.g. the feedback service object store secret), defaults to None + :type request_headers: Optional[OrchestrationRequestHeaders], optional :return: the CompletionPostResponse object :rtype: CompletionPostResponse """ - return await self._a_execute_request( - config=config or self.config, - config_ref=config_ref or self.config_ref, - placeholder_values=placeholder_values, - history=history, - timeout=timeout, - ) + with self._header_context(request_headers): + return await self._a_execute_request( + config=config or self.config, + config_ref=config_ref or self.config_ref, + placeholder_values=placeholder_values, + history=history, + timeout=timeout, + ) async def astream( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, timeout: Union[int, float, httpx.Timeout, None] = None, + request_headers: Optional[OrchestrationRequestHeaders] = None, ) -> AsyncSSEClient: """Executes an orchestration streaming request asynchronously. @@ -576,28 +616,33 @@ async def astream( :type history: Optional[List[ChatMessage]], optional :param timeout: the timeout overwrite per request, defaults to None :type timeout: Union[int, float, httpx.Timeout, None], optional + :param request_headers: service-specific headers to add for this request only + (e.g. the feedback service object store secret), defaults to None + :type request_headers: Optional[OrchestrationRequestHeaders], optional :return: the AsyncSSEClient object :rtype: AsyncSSEClient - """ + """ - return await self._a_execute_request( - config=config or self.config, - config_ref=config_ref or self.config_ref, - placeholder_values=placeholder_values, - history=history, - timeout=timeout, - stream=True - ) + with self._header_context(request_headers): + return await self._a_execute_request( + config=config or self.config, + config_ref=config_ref or self.config_ref, + placeholder_values=placeholder_values, + history=history, + timeout=timeout, + stream=True + ) def run_with_retries( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, timeout: Union[int, float, httpx.Timeout, None] = None, max_retries: int = 10, base_delay: float = 1.0, + request_headers: Optional[OrchestrationRequestHeaders] = None, ) -> OrchestrationResponseWithRetries | None: """Executes an orchestration request with automatic retry on rate limits (429) and server errors. @@ -615,6 +660,9 @@ def run_with_retries( :type max_retries: int, optional :param base_delay: the initial delay between retries in seconds, defaults to 1.0 :type base_delay: float, optional + :param request_headers: service-specific headers to add for each attempt + (e.g. the feedback service object store secret), defaults to None + :type request_headers: Optional[OrchestrationRequestHeaders], optional :return: the OrchestrationResponseWithRetries with retry count information :rtype: OrchestrationResponseWithRetries | None :raises ValueError: if no configuration is provided. @@ -630,6 +678,7 @@ def run_with_retries( placeholder_values=placeholder_values, history=history, timeout=timeout, + request_headers=request_headers, ) # Success, response with retry count @@ -674,13 +723,14 @@ def handle_retry(self, retry_count: int, base_delay: float, error: Orchestration async def arun_with_retries( self, - config: Optional[OrchestrationConfig] = None, + config: Optional[OrchestrationConfig | PartialOrchestrationConfig] = None, config_ref: Optional[OrchestrationConfigReference] = None, placeholder_values: Optional[dict] = None, history: Optional[List[ChatMessage]] = None, timeout: Union[int, float, httpx.Timeout, None] = None, max_retries: int = 10, base_delay: float = 1.0, + request_headers: Optional[OrchestrationRequestHeaders] = None, ) -> 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. @@ -699,11 +749,14 @@ async def arun_with_retries( :type max_retries: int, optional :param base_delay: the initial delay between retries in seconds, defaults to 1.0 :type base_delay: float, optional + :param request_headers: service-specific headers to add for each attempt + (e.g. the feedback service object store secret), defaults to None + :type request_headers: Optional[OrchestrationRequestHeaders], optional :return: the OrchestrationResponseWithRetries with retry count information :rtype: OrchestrationResponseWithRetries | None :raises ValueError: if no configuration is provided. :raises OrchestrationError: if request fails after all retries (includes retry count). - """ + """ for retry_count in range(max_retries + 1): try: @@ -714,6 +767,7 @@ async def arun_with_retries( placeholder_values=placeholder_values, history=history, timeout=timeout, + request_headers=request_headers, ) return OrchestrationResponseWithRetries( diff --git a/packages/gen/tests/orchestration_v2/test_config_v2.py b/packages/gen/tests/orchestration_v2/test_config_v2.py index c9308399..6cd6bc01 100644 --- a/packages/gen/tests/orchestration_v2/test_config_v2.py +++ b/packages/gen/tests/orchestration_v2/test_config_v2.py @@ -1,6 +1,11 @@ import unittest -from gen_ai_hub.orchestration_v2.models.config import OrchestrationConfig, ModuleConfig +from gen_ai_hub.orchestration_v2.models.config import ( + OrchestrationConfig, ModuleConfig, + PartialOrchestrationConfig, PartialModuleConfigs, PartialPromptTemplatingModuleConfig, + CompletionRequestConfigurationReferenceByIdConfigRef, +) +from gen_ai_hub.orchestration_v2.models.orchestration_request import CompletionPostRequest from gen_ai_hub.orchestration_v2.models.azure_content_filter import AzureContentSafetyInput, AzureContentSafetyOutput from gen_ai_hub.orchestration_v2.models.content_filtering import FilteringModuleConfig, InputFiltering, OutputFiltering from gen_ai_hub.orchestration_v2.models.content_filter import AzureContentSafetyInputFilterConfig, AzureContentSafetyOutputFilterConfig @@ -33,6 +38,44 @@ def test_minimal_config(self): ) self.assertNotIn("filtering_module_config", json_data["modules"]) + def test_prompt_optional(self): + # prompt is no longer required; it is omitted from the payload when not provided + # (messages_history must then be supplied in the request). + prompt_template = PromptTemplatingModuleConfig(model=self.llm) + self.assertIsNone(prompt_template.prompt) + dumped = prompt_template.model_dump() + self.assertNotIn("prompt", dumped) + + def test_config_ref_with_overrides(self): + request = CompletionPostRequest( + config_ref=CompletionRequestConfigurationReferenceByIdConfigRef(id="stored-config-id"), + config=PartialOrchestrationConfig( + modules=PartialModuleConfigs( + prompt_templating=PartialPromptTemplatingModuleConfig(model=self.llm) + ) + ), + ) + dumped = request.model_dump() + self.assertEqual(dumped["config_ref"], {"id": "stored-config-id"}) + self.assertEqual( + dumped["config"]["modules"]["prompt_templating"]["model"], + self.llm.model_dump(), + ) + self.assertNotIn("prompt", dumped["config"]["modules"]["prompt_templating"]) + self.assertNotIn("filtering", dumped["config"]["modules"]) + + def test_config_ref_without_overrides(self): + request = CompletionPostRequest( + config_ref=CompletionRequestConfigurationReferenceByIdConfigRef(id="stored-config-id"), + ) + dumped = request.model_dump() + self.assertEqual(dumped["config_ref"], {"id": "stored-config-id"}) + self.assertNotIn("config", dumped) + + def test_request_requires_config_or_ref(self): + with self.assertRaises(ValueError): + CompletionPostRequest() + def test_input_filtering(self): input_filtering = InputFiltering(filters=[ AzureContentSafetyInputFilterConfig(config=AzureContentSafetyInput(hate=0)) diff --git a/packages/gen/tests/orchestration_v2/test_data_masking_v2.py b/packages/gen/tests/orchestration_v2/test_data_masking_v2.py index 83b3124d..8bed57a4 100644 --- a/packages/gen/tests/orchestration_v2/test_data_masking_v2.py +++ b/packages/gen/tests/orchestration_v2/test_data_masking_v2.py @@ -1,7 +1,7 @@ import unittest from gen_ai_hub.orchestration_v2.models.data_masking import (MaskingModuleConfig, MaskingProviderConfig, MaskingMethod, - DPIStandardEntity, ProfileEntity) + MaskFileInputMethod, DPIStandardEntity, ProfileEntity) @@ -65,6 +65,50 @@ def test_sap_data_privacy_integration_masking_providers(self): self.assertEqual(data_masking.model_dump(), expected_dict) + def test_mask_file_input_method(self): + data_masking = MaskingModuleConfig( + providers=[MaskingProviderConfig( + method=MaskingMethod.ANONYMIZATION, + entities=[DPIStandardEntity(type=ProfileEntity.PERSON)], + mask_file_input_method=MaskFileInputMethod.ANONYMIZATION, + )] + ) + + expected_dict = { + 'providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-person'} + ], + 'mask_file_input_method': 'anonymization' + } + ] + } + + self.assertEqual(data_masking.model_dump(), expected_dict) + + def test_mask_file_input_method_skip(self): + provider = MaskingProviderConfig( + method=MaskingMethod.PSEUDONYMIZATION, + entities=[DPIStandardEntity(type=ProfileEntity.EMAIL)], + mask_file_input_method=MaskFileInputMethod.SKIP, + ) + + self.assertEqual(provider.mask_file_input_method, MaskFileInputMethod.SKIP) + self.assertEqual(provider.model_dump()['mask_file_input_method'], 'skip') + + def test_mask_file_input_method_omitted_when_not_set(self): + provider = MaskingProviderConfig( + method=MaskingMethod.ANONYMIZATION, + entities=[DPIStandardEntity(type=ProfileEntity.PERSON)], + ) + + self.assertIsNone(provider.mask_file_input_method) + self.assertNotIn('mask_file_input_method', provider.model_dump()) + def test_maskin_module_config_error(self): with self.assertRaises(ValueError): MaskingModuleConfig() diff --git a/packages/gen/tests/orchestration_v2/test_embeddings.py b/packages/gen/tests/orchestration_v2/test_embeddings.py index cae0357f..9777dcd5 100644 --- a/packages/gen/tests/orchestration_v2/test_embeddings.py +++ b/packages/gen/tests/orchestration_v2/test_embeddings.py @@ -9,6 +9,7 @@ EmbeddingsModuleConfigs, EmbeddingsOrchestrationConfig, EmbeddingsInput, + EmbeddingMultiFormat, EmbeddingsUsage, EmbeddingResult, EmbeddingsResponse, @@ -31,11 +32,19 @@ def test_encoding_format_values(self): self.assertEqual(EmbeddingsEncodingFormat.FLOAT.value, "float") self.assertEqual(EmbeddingsEncodingFormat.BASE64.value, "base64") self.assertEqual(EmbeddingsEncodingFormat.BINARY.value, "binary") + self.assertEqual(EmbeddingsEncodingFormat.INT8.value, "int8") + self.assertEqual(EmbeddingsEncodingFormat.UINT8.value, "uint8") + self.assertEqual(EmbeddingsEncodingFormat.UBINARY.value, "ubinary") def test_input_type_values(self): self.assertEqual(EmbeddingsInputType.TEXT.value, "text") self.assertEqual(EmbeddingsInputType.DOCUMENT.value, "document") self.assertEqual(EmbeddingsInputType.QUERY.value, "query") + self.assertEqual(EmbeddingsInputType.SEARCH_DOCUMENT.value, "search_document") + self.assertEqual(EmbeddingsInputType.SEARCH_QUERY.value, "search_query") + self.assertEqual(EmbeddingsInputType.CLASSIFICATION.value, "classification") + self.assertEqual(EmbeddingsInputType.CLUSTERING.value, "clustering") + self.assertEqual(EmbeddingsInputType.IMAGE.value, "image") class TestEmbeddingsModelParams(unittest.TestCase): @@ -58,6 +67,16 @@ def test_params_with_encoding_format(self): self.assertEqual(params.encoding_format, EmbeddingsEncodingFormat.BASE64) self.assertEqual(params.model_dump(), {"encoding_format": "base64"}) + def test_params_with_encoding_format_list(self): + params = EmbeddingsModelParams( + encoding_format=[EmbeddingsEncodingFormat.FLOAT, EmbeddingsEncodingFormat.INT8] + ) + self.assertEqual( + params.encoding_format, + [EmbeddingsEncodingFormat.FLOAT, EmbeddingsEncodingFormat.INT8], + ) + self.assertEqual(params.model_dump(), {"encoding_format": ["float", "int8"]}) + def test_params_with_normalize(self): params = EmbeddingsModelParams(normalize=True) self.assertTrue(params.normalize) @@ -296,6 +315,16 @@ def test_embedding_result_dump(self): self.assertEqual(dumped["embedding"], [0.1, 0.2, 0.3]) self.assertEqual(dumped["index"], 0) + def test_embedding_result_with_multi_format(self): + result = EmbeddingResult.model_validate({ + "object": "embedding", + "embedding": {"float": [0.1, 0.2], "int8": [1, 2], "base64": "AAEC"}, + "index": 0, + }) + self.assertIsInstance(result.embedding, EmbeddingMultiFormat) + self.assertEqual(result.embedding.float_, [0.1, 0.2]) + self.assertEqual(result.embedding.int8, [1, 2]) + self.assertEqual(result.embedding.base64_, "AAEC") class TestEmbeddingsResponse(unittest.TestCase): """Tests for EmbeddingsResponse model.""" diff --git a/packages/gen/tests/orchestration_v2/test_flat_import.py b/packages/gen/tests/orchestration_v2/test_flat_import.py index ca877b41..8769877b 100644 --- a/packages/gen/tests/orchestration_v2/test_flat_import.py +++ b/packages/gen/tests/orchestration_v2/test_flat_import.py @@ -7,6 +7,7 @@ # config "ModuleConfig", "OrchestrationConfig", "OrchestrationConfigReference", + "PartialPromptTemplatingModuleConfig", "PartialModuleConfigs", "PartialOrchestrationConfig", "CompletionRequestConfigurationReferenceByIdConfigRef", "CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef", @@ -18,8 +19,9 @@ "InputFiltering", "OutputFiltering", "FilteringModuleConfig", # data_masking - "DataMaskingProviderName", "MaskingMethod", "ProfileEntity", "DPIMethodConstant", "DPIMethodFabricatedData", - "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", "MaskingModuleConfig", + "DataMaskingProviderName", "MaskingMethod", "MaskFileInputMethod", "ProfileEntity", "DPIMethodConstant", + "DPIMethodFabricatedData", "DPICustomEntity", "DPIStandardEntity", "MaskGroundingInput", "MaskingProviderConfig", + "MaskingModuleConfig", # document_grounding "GroundingType", "DataRepositoryType", "DocumentGroundingFilter", "DocumentGroundingPlaceholders", @@ -29,7 +31,8 @@ # embeddings "EmbeddingsEncodingFormat", "EmbeddingsInputType", "EmbeddingsModelParams", "EmbeddingsModelDetails", "EmbeddingsModelConfig", "EmbeddingsModuleConfigs", "EmbeddingsOrchestrationConfig", "EmbeddingsInput", - "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", "EmbeddingsPostResponse", "EmbeddingsRequest", + "EmbeddingMultiFormat", "EmbeddingsUsage", "EmbeddingResult", "EmbeddingsResponse", + "EmbeddingsPostResponse", "EmbeddingsRequest", # llama_guard_3_filter "LlamaGuard38bFilter", @@ -43,6 +46,7 @@ # multimodal_items "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem", # response "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", @@ -60,6 +64,9 @@ # template "Template", "PromptTemplatingModuleConfig", + # request_headers + "OrchestrationRequestHeaders", + # template_ref "TemplateRef", "TemplateRefByID", "TemplateRefByScenarioNameVersion", diff --git a/packages/gen/tests/orchestration_v2/test_llm_model_details_v2.py b/packages/gen/tests/orchestration_v2/test_llm_model_details_v2.py index c8cf5ca3..d5e7cac3 100644 --- a/packages/gen/tests/orchestration_v2/test_llm_model_details_v2.py +++ b/packages/gen/tests/orchestration_v2/test_llm_model_details_v2.py @@ -28,6 +28,23 @@ def test_llm_json_serialization(self): json_data = llm.model_dump() self.assertEqual(json_data, expected_dict) + def test_llm_timeout_upper_bound(self): + llm = LLMModelDetails(name="gpt-4o-mini", timeout=1200) + self.assertEqual(llm.timeout, 1200) + + with self.assertRaises(ValueError): + LLMModelDetails(name="gpt-4o-mini", timeout=1201) + + def test_llm_drop_unsupported_params(self): + llm = LLMModelDetails(name="gpt-4o-mini") + # No client-side default; the server applies the default when omitted. + self.assertIsNone(llm.drop_unsupported_params) + self.assertNotIn("drop_unsupported_params", llm.model_dump()) + + llm = LLMModelDetails(name="gpt-4o-mini", drop_unsupported_params=False) + self.assertEqual(llm.drop_unsupported_params, False) + self.assertEqual(llm.model_dump()["drop_unsupported_params"], False) + def test_llm_model_details_with_unsupported_properties(self): with self.assertRaises(ValueError): LLMModelDetails(name="gpt-4o-mini", max_retries=10) diff --git a/packages/gen/tests/orchestration_v2/test_message_v2.py b/packages/gen/tests/orchestration_v2/test_message_v2.py index a19ea15a..54805433 100644 --- a/packages/gen/tests/orchestration_v2/test_message_v2.py +++ b/packages/gen/tests/orchestration_v2/test_message_v2.py @@ -102,6 +102,23 @@ def test_llm_choice_stores_extra_field(self): }) self.assertEqual(choice.extra_field, "extra") + def test_llm_choice_provider_specific_fields(self): + choice = LLMChoice.model_validate({ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + "provider_specific_fields": {"finish_reason": "end_turn"}, + }) + self.assertEqual(choice.provider_specific_fields, {"finish_reason": "end_turn"}) + + def test_llm_choice_provider_specific_fields_optional(self): + choice = LLMChoice.model_validate({ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }) + self.assertIsNone(choice.provider_specific_fields) + def test_llm_module_result_stores_extra_field(self): result = LLMModuleResult.model_validate({ "id": "chatcmpl-abc", @@ -189,6 +206,22 @@ def test_stream_llm_choice_stores_extra_field(self): }) self.assertEqual(choice.extra_field, "extra") + def test_stream_llm_choice_provider_specific_fields(self): + choice = StreamLLMChoice.model_validate({ + "index": 0, + "delta": {"content": "hi"}, + "finish_reason": "stop", + "provider_specific_fields": {"finish_reason": "end_turn"}, + }) + self.assertEqual(choice.provider_specific_fields, {"finish_reason": "end_turn"}) + + def test_stream_llm_choice_provider_specific_fields_optional(self): + choice = StreamLLMChoice.model_validate({ + "index": 0, + "delta": {"content": "hi"}, + }) + self.assertIsNone(choice.provider_specific_fields) + def test_citation_stores_extra_field(self): citation = Citation.model_validate({ "title": "Source A", "url": "https://example.com", "extra_field": "extra", diff --git a/packages/gen/tests/orchestration_v2/test_request_obj_v2.py b/packages/gen/tests/orchestration_v2/test_request_obj_v2.py index a26a472d..70ae68b4 100644 --- a/packages/gen/tests/orchestration_v2/test_request_obj_v2.py +++ b/packages/gen/tests/orchestration_v2/test_request_obj_v2.py @@ -1,6 +1,7 @@ import unittest from gen_ai_hub.orchestration_v2.models.config import (OrchestrationConfig, ModuleConfig, +PartialOrchestrationConfig, PartialModuleConfigs, PartialPromptTemplatingModuleConfig, CompletionRequestConfigurationReferenceByIdConfigRef, CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef) from gen_ai_hub.orchestration_v2.models.llm_model_details import LLMModelDetails @@ -48,10 +49,36 @@ def test_with_config_ref_by_scenario(self): self.assertEqual(json_request["config_ref"]["scenario"], self.config_ref_name.scenario) self.assertIsNone(json_request.get("config")) - def test_with_config_and_config_ref(self): + def test_with_config_ref_and_partial_override(self): + # config_ref may be accompanied by a PartialOrchestrationConfig override; both allowed. + partial = PartialOrchestrationConfig( + modules=PartialModuleConfigs( + prompt_templating=PartialPromptTemplatingModuleConfig(model=self.llm) + ) + ) + request = CompletionPostRequest(config=partial, config_ref=self.config_ref_id) + json_request = request.model_dump() + self.assertEqual(json_request["config_ref"]["id"], self.config_ref_id.id) + self.assertEqual( + json_request["config"]["modules"]["prompt_templating"]["model"], + self.llm.model_dump(), + ) + + def test_config_ref_with_full_config_rejected(self): + # a full OrchestrationConfig is not a valid override alongside config_ref with self.assertRaises(ValueError): CompletionPostRequest(config=self.config, config_ref=self.config_ref_id) + def test_inline_partial_config_rejected(self): + # a partial config is only valid as an override; inline requires a full config + partial = PartialOrchestrationConfig( + modules=PartialModuleConfigs( + prompt_templating=PartialPromptTemplatingModuleConfig(model=self.llm) + ) + ) + with self.assertRaises(ValueError): + CompletionPostRequest(config=partial) + def test_with_no_config_or_config_ref(self): with self.assertRaises(ValueError): CompletionPostRequest() diff --git a/packages/gen/tests/orchestration_v2/test_template_v2.py b/packages/gen/tests/orchestration_v2/test_template_v2.py index 3139566f..9c1773ae 100644 --- a/packages/gen/tests/orchestration_v2/test_template_v2.py +++ b/packages/gen/tests/orchestration_v2/test_template_v2.py @@ -11,7 +11,7 @@ UserMessage, AssistantMessage, ) -from gen_ai_hub.orchestration_v2.models.multimodal_items import ImageItem, ImageDetailLevel +from gen_ai_hub.orchestration_v2.models.multimodal_items import ImageItem, ImageDetailLevel, FileItem from gen_ai_hub.orchestration_v2.models.response_format import ( ResponseFormatType, ResponseFormatText, @@ -249,3 +249,58 @@ def test_image_item_from_file(self): self.assertEqual(decoded, original) finally: os.unlink(tmp_path) + + def test_user_message_with_string_and_file(self): + file = FileItem(file_data="https://example.com/1.pdf", filename="doc.pdf") + msg = UserMessage(content=["Summarize this file:", file]) + expected = { + "role": Role.USER, + "content": [ + {"type": "text", "text": "Summarize this file:"}, + {"type": "file", "file": { + "file_data": "https://example.com/1.pdf", + "filename": "doc.pdf" + }} + ] + } + self.assertEqual(msg.model_dump(), expected) + + def test_user_message_with_file_dict(self): + msg = UserMessage(content=[{"type": "file", "file": {"file_data": "data:x", "filename": "a.pdf"}}]) + expected = { + "role": Role.USER, + "content": [ + {"type": "file", "file": {"file_data": "data:x", "filename": "a.pdf"}} + ] + } + self.assertEqual(msg.model_dump(), expected) + + def test_user_message_with_file_without_filename(self): + file = FileItem(file_data="https://example.com/1.pdf") + msg = UserMessage(content=[file]) + expected = { + "role": Role.USER, + "content": [ + {"type": "file", "file": {"file_data": "https://example.com/1.pdf"}} + ] + } + self.assertEqual(msg.model_dump(), expected) + + def test_file_item_from_file(self): + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: + tmp.write(b"not a real pdf") + tmp_path = tmp.name + + try: + item = FileItem.from_file(tmp_path) + self.assertTrue(item.file_data.startswith("data:application/pdf;base64,")) + self.assertIsNone(item.filename) + # Check that the base64 part decodes to the original file content + encoded = item.file_data.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/prompt_registry/test_pr_client.py b/packages/gen/tests/prompt_registry/test_pr_client.py index 0a29a1eb..dd3513fc 100644 --- a/packages/gen/tests/prompt_registry/test_pr_client.py +++ b/packages/gen/tests/prompt_registry/test_pr_client.py @@ -252,9 +252,10 @@ def test_create_orchestration_config(self, mock_post): self.assertIsInstance(response, OrchestrationConfigPostResponse) self.assertEqual(response.model_dump(), ORCHESTRATION_CONFIG_POST_RESPONSE) + expected_spec = spec.model_dump() mock_post.assert_called_once_with(path=PATH_REGISTRY_CONFIG, body={ 'name': ORCHESTRATION_CONFIG_NAME, 'version': VERSION, 'scenario': SCENARIO, - 'spec': spec.model_dump()}, + 'spec': expected_spec}, convert_body_to_camel_case=False )